fix: synchronize in-memory store access

This commit is contained in:
lda committed 2026-09-24 05:25:24 +07:00 Verified
1 parent d1fba584aa
commit 250d606a9a
2 files changed
+172 -95

No files matched your search

+115 -95
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import threading
import uuid import uuid
from collections import defaultdict from collections import defaultdict
@@ -14,138 +15,157 @@ class InMemoryStore:
self._threads: dict[uuid.UUID, Thread] = {} self._threads: dict[uuid.UUID, Thread] = {}
self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list) self._msgs: dict[uuid.UUID, list[Message]] = defaultdict(list)
self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {} self._cursors: dict[tuple[uuid.UUID, uuid.UUID], int] = {}
self._lock = threading.RLock()
def get_or_create_agent(self, name: str) -> Agent: def get_or_create_agent(self, name: str) -> Agent:
if name in self._name_idx: with self._lock:
return self._agents[self._name_idx[name]] if name in self._name_idx:
return self.create_agent(name) return self._agents[self._name_idx[name]]
return self.create_agent(name)
def create_agent(self, name: str) -> Agent: def create_agent(self, name: str) -> Agent:
if name in self._name_idx: with self._lock:
raise ValueError(f"agent name taken: {name}") if name in self._name_idx:
a = Agent(id=uuid.uuid4(), name=name) raise ValueError(f"agent name taken: {name}")
self._agents[a.id] = a a = Agent(id=uuid.uuid4(), name=name)
self._name_idx[name] = a.id self._agents[a.id] = a
return a self._name_idx[name] = a.id
return a
def get_agent_by_name(self, name: str) -> Agent | None: def get_agent_by_name(self, name: str) -> Agent | None:
uid = self._name_idx.get(name) with self._lock:
if uid is None: uid = self._name_idx.get(name)
return None if uid is None:
return self._agents.get(uid) return None
return self._agents.get(uid)
def get_agent_by_id(self, id: uuid.UUID) -> Agent | None: def get_agent_by_id(self, id: uuid.UUID) -> Agent | None:
return self._agents.get(id) with self._lock:
return self._agents.get(id)
def delete_agent(self, id: uuid.UUID) -> None: def delete_agent(self, id: uuid.UUID) -> None:
a = self._agents.pop(id, None) with self._lock:
if a is not None: a = self._agents.pop(id, None)
self._name_idx.pop(a.name, None) if a is not None:
for tid, t in list(self._threads.items()): self._name_idx.pop(a.name, None)
if a in t.participants: for tid, t in list(self._threads.items()):
self._threads[tid] = Thread( if a in t.participants:
id=t.id, self._threads[tid] = Thread(
participants=t.participants - {a}, id=t.id,
created_at=t.created_at, participants=t.participants - {a},
) created_at=t.created_at,
)
def list_agents(self) -> list[Agent]: def list_agents(self) -> list[Agent]:
return list(self._agents.values()) with self._lock:
return list(self._agents.values())
def create_thread(self, participants: set[Agent]) -> Thread: def create_thread(self, participants: set[Agent]) -> Thread:
if len(participants) < 2: with self._lock:
raise ValueError("need >=2 participants") if len(participants) < 2:
t = Thread( raise ValueError("need >=2 participants")
id=uuid.uuid4(), t = Thread(
participants=frozenset(participants), id=uuid.uuid4(),
created_at=datetime.datetime.now(datetime.UTC), participants=frozenset(participants),
) created_at=datetime.datetime.now(datetime.UTC),
self._threads[t.id] = t )
return t self._threads[t.id] = t
return t
def get_thread(self, id: uuid.UUID) -> Thread | None: def get_thread(self, id: uuid.UUID) -> Thread | None:
return self._threads.get(id) with self._lock:
return self._threads.get(id)
def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None: def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None:
thread = self.get_thread(id) with self._lock:
if thread is None: thread = self.get_thread(id)
return None if thread is None:
messages = self._msgs.get(id, []) return None
return ThreadInfo( messages = self._msgs.get(id, [])
thread=thread, return ThreadInfo(
message_count=len(messages), thread=thread,
latest_message=messages[-1] if messages else None, message_count=len(messages),
) latest_message=messages[-1] if messages else None,
)
def get_thread_read_state( def get_thread_read_state(
self, id: uuid.UUID, agent: Agent self, id: uuid.UUID, agent: Agent
) -> ThreadReadState | None: ) -> ThreadReadState | None:
if self.get_thread(id) is None: with self._lock:
return None if self.get_thread(id) is None:
messages = self._msgs.get(id, []) return None
cursor_seq = self.get_cursor(id, agent) messages = self._msgs.get(id, [])
unread = [message for message in messages if message.seq > cursor_seq] cursor_seq = self.get_cursor(id, agent)
return ThreadReadState( unread = [message for message in messages if message.seq > cursor_seq]
agent=agent, return ThreadReadState(
cursor_seq=cursor_seq, agent=agent,
latest_seq=messages[-1].seq if messages else 0, cursor_seq=cursor_seq,
unread_count=len(unread), latest_seq=messages[-1].seq if messages else 0,
unread_from_others_count=sum( unread_count=len(unread),
message.sender.id != agent.id for message in unread unread_from_others_count=sum(
), message.sender.id != agent.id for message in unread
) ),
)
def find_threads(self, containing: set[Agent]) -> list[Thread]: def find_threads(self, containing: set[Agent]) -> list[Thread]:
c = set(containing) with self._lock:
return [t for t in self._threads.values() if c <= t.participants] c = set(containing)
return [t for t in self._threads.values() if c <= t.participants]
def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
t = self._threads[thread_id] with self._lock:
nt = Thread( t = self._threads[thread_id]
id=t.id, participants=t.participants | {agent}, created_at=t.created_at nt = Thread(
) id=t.id, participants=t.participants | {agent}, created_at=t.created_at
self._threads[thread_id] = nt )
return nt self._threads[thread_id] = nt
return nt
def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread:
t = self._threads[thread_id] with self._lock:
nt = Thread( t = self._threads[thread_id]
id=t.id, participants=t.participants - {agent}, created_at=t.created_at nt = Thread(
) id=t.id, participants=t.participants - {agent}, created_at=t.created_at
self._threads[thread_id] = nt )
return nt self._threads[thread_id] = nt
return nt
def delete_thread(self, id: uuid.UUID) -> None: def delete_thread(self, id: uuid.UUID) -> None:
self._threads.pop(id, None) with self._lock:
self._msgs.pop(id, None) self._threads.pop(id, None)
# also delete cursors for that thread self._msgs.pop(id, None)
for k in list(self._cursors.keys()): # also delete cursors for that thread
if k[0] == id: for k in list(self._cursors.keys()):
self._cursors.pop(k, None) if k[0] == id:
self._cursors.pop(k, None)
def append_message( def append_message(
self, thread_id: uuid.UUID, sender: Agent, content: str self, thread_id: uuid.UUID, sender: Agent, content: str
) -> Message: ) -> Message:
if thread_id not in self._threads: with self._lock:
raise KeyError(thread_id) if thread_id not in self._threads:
if sender not in self._threads[thread_id].participants: raise KeyError(thread_id)
raise ValueError("sender not in thread") if sender not in self._threads[thread_id].participants:
seq = len(self._msgs[thread_id]) + 1 raise ValueError("sender not in thread")
m = Message( seq = len(self._msgs[thread_id]) + 1
id=uuid.uuid4(), m = Message(
sender=sender, id=uuid.uuid4(),
content=content, sender=sender,
seq=seq, content=content,
ts=datetime.datetime.now(datetime.UTC), seq=seq,
) ts=datetime.datetime.now(datetime.UTC),
self._msgs[thread_id].append(m) )
return m self._msgs[thread_id].append(m)
return m
def list_messages(self, thread_id: uuid.UUID, after_seq: int = 0) -> list[Message]: def list_messages(self, thread_id: uuid.UUID, after_seq: int = 0) -> list[Message]:
return [m for m in self._msgs.get(thread_id, []) if m.seq > after_seq] with self._lock:
return [m for m in self._msgs.get(thread_id, []) if m.seq > after_seq]
def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int:
return self._cursors.get((thread_id, agent.id), 0) with self._lock:
return self._cursors.get((thread_id, agent.id), 0)
def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None:
self._cursors[(thread_id, agent.id)] = seq with self._lock:
self._cursors[(thread_id, agent.id)] = seq
+57
View File
@@ -1,4 +1,7 @@
import threading
import time
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor
from agentmsgs.stores.memory import InMemoryStore from agentmsgs.stores.memory import InMemoryStore
@@ -86,3 +89,57 @@ def test_memory_thread_info_for_empty_thread():
assert state.unread_from_others_count == 0 assert state.unread_from_others_count == 0
assert s.get_thread_info(uuid.uuid4()) is None assert s.get_thread_info(uuid.uuid4()) is None
assert s.get_thread_read_state(uuid.uuid4(), a) is None assert s.get_thread_read_state(uuid.uuid4(), a) is None
def test_memory_concurrent_get_or_create_agent_is_idempotent():
store = InMemoryStore()
start = threading.Barrier(2)
class SlowNameIndex(dict):
def __contains__(self, key):
present = super().__contains__(key)
if not present:
time.sleep(0.01)
return present
store._name_idx = SlowNameIndex()
def create_agent():
start.wait()
return store.get_or_create_agent("Alice")
with ThreadPoolExecutor(max_workers=2) as executor:
agents = list(executor.map(lambda _: create_agent(), range(2)))
assert len({agent.id for agent in agents}) == 1
assert len(store.list_agents()) == 1
def test_memory_concurrent_append_assigns_unique_sequences():
store = InMemoryStore()
alice = store.get_or_create_agent("Alice")
bob = store.get_or_create_agent("Bob")
thread = store.create_thread({alice, bob})
gate = threading.Barrier(2, timeout=0.2)
local = threading.local()
class SlowMessageList(list):
def __len__(self):
length = super().__len__()
if not hasattr(local, "waited"):
try:
gate.wait()
except threading.BrokenBarrierError:
pass
local.waited = True
return length
store._msgs[thread.id] = SlowMessageList()
def append_message(index):
return store.append_message(thread.id, alice, str(index))
with ThreadPoolExecutor(max_workers=2) as executor:
messages = list(executor.map(append_message, range(2)))
assert sorted(message.seq for message in messages) == [1, 2]