|
@@ -295,8 +295,15 @@ class _RoomRoster:
|
|
|
_UNSEEDED: Final = object()
|
|
_UNSEEDED: Final = object()
|
|
|
"""Sentinel: this process has delivered nothing for that thread yet.
|
|
"""Sentinel: this process has delivered nothing for that thread yet.
|
|
|
|
|
|
|
|
-Distinct from a recorded ``None``, which means an *earlier* process seeded the
|
|
|
|
|
-thread and its cursor is unknowable here.
|
|
|
|
|
|
|
+Distinct from a recorded ``None``, which means the cursor is unknowable — an
|
|
|
|
|
+earlier process seeded the thread before this bookkeeping existed.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+_CURSOR_META_KEY = "chatto.thread_seed_cursor"
|
|
|
|
|
+"""Session-metadata key holding the last message ID seeded into that session.
|
|
|
|
|
+
|
|
|
|
|
+One opaque ID per thread, on the session the platform already owns: nothing of
|
|
|
|
|
+the thread's content is mirrored anywhere, and the value dies with the session.
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1277,15 +1284,22 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
if not thread_root_event_id:
|
|
if not thread_root_event_id:
|
|
|
return ""
|
|
return ""
|
|
|
|
|
|
|
|
|
|
+ session_key = self._session_key_for(source)
|
|
|
delivered_to = self._seeded_threads.get(thread_root_event_id, _UNSEEDED)
|
|
delivered_to = self._seeded_threads.get(thread_root_event_id, _UNSEEDED)
|
|
|
- if delivered_to is _UNSEEDED and self._thread_session_exists(source):
|
|
|
|
|
- # An earlier process seeded this thread and its session is still
|
|
|
|
|
- # there, so that session already holds the history. Recording it
|
|
|
|
|
- # keeps a later turn from re-asking — but the cursor died with that
|
|
|
|
|
- # process, so later turns deliver no tail rather than guess at one
|
|
|
|
|
- # and duplicate a block the session already has.
|
|
|
|
|
- self._record_thread_seeded(thread_root_event_id, None)
|
|
|
|
|
- return ""
|
|
|
|
|
|
|
+ if delivered_to is _UNSEEDED:
|
|
|
|
|
+ # A cursor written by an earlier process outlives it: it lives on the
|
|
|
|
|
+ # session, so a restart keeps it and the thread can go on delivering
|
|
|
|
|
+ # its tail instead of going silent for good.
|
|
|
|
|
+ delivered_to = self._persisted_cursor(session_key, _UNSEEDED)
|
|
|
|
|
+ if delivered_to is not _UNSEEDED:
|
|
|
|
|
+ self._seeded_threads[thread_root_event_id] = delivered_to
|
|
|
|
|
+ elif self._thread_session_exists(source):
|
|
|
|
|
+ # This session predates the cursor bookkeeping entirely, so its
|
|
|
|
|
+ # history is in the session but the cursor is unknowable. Later
|
|
|
|
|
+ # turns deliver nothing rather than guess at one and duplicate a
|
|
|
|
|
+ # block the session already has.
|
|
|
|
|
+ self._record_thread_seeded(thread_root_event_id, None, session_key)
|
|
|
|
|
+ return ""
|
|
|
|
|
|
|
|
history = await self._thread_history(
|
|
history = await self._thread_history(
|
|
|
client, room_id=room_id, thread_root_event_id=thread_root_event_id
|
|
client, room_id=room_id, thread_root_event_id=thread_root_event_id
|
|
@@ -1299,12 +1313,18 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
|
|
|
|
|
if isinstance(delivered_to, str) or delivered_to is None:
|
|
if isinstance(delivered_to, str) or delivered_to is None:
|
|
|
return self._seed_thread_tail(
|
|
return self._seed_thread_tail(
|
|
|
- thread_root_event_id, history, delivered_to, current_message_id
|
|
|
|
|
|
|
+ thread_root_event_id,
|
|
|
|
|
+ history,
|
|
|
|
|
+ delivered_to,
|
|
|
|
|
+ current_message_id,
|
|
|
|
|
+ session_key,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
earlier = [m for m in history if m.id != current_message_id]
|
|
earlier = [m for m in history if m.id != current_message_id]
|
|
|
self._record_thread_seeded(
|
|
self._record_thread_seeded(
|
|
|
- thread_root_event_id, earlier[-1].id if earlier else current_message_id
|
|
|
|
|
|
|
+ thread_root_event_id,
|
|
|
|
|
+ earlier[-1].id if earlier else current_message_id,
|
|
|
|
|
+ session_key,
|
|
|
)
|
|
)
|
|
|
return self._thread_context_block(earlier)
|
|
return self._thread_context_block(earlier)
|
|
|
|
|
|
|
@@ -1314,6 +1334,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
history: list[Message],
|
|
history: list[Message],
|
|
|
delivered_to: str | None | object,
|
|
delivered_to: str | None | object,
|
|
|
current_message_id: str,
|
|
current_message_id: str,
|
|
|
|
|
+ session_key: str,
|
|
|
) -> str:
|
|
) -> str:
|
|
|
"""Deliver only what landed in an already-seeded thread since the cursor.
|
|
"""Deliver only what landed in an already-seeded thread since the cursor.
|
|
|
|
|
|
|
@@ -1339,7 +1360,7 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
unseen = [m for m in tail if m.id != current_message_id]
|
|
unseen = [m for m in tail if m.id != current_message_id]
|
|
|
if not unseen:
|
|
if not unseen:
|
|
|
return ""
|
|
return ""
|
|
|
- self._record_thread_seeded(thread_root_event_id, unseen[-1].id)
|
|
|
|
|
|
|
+ self._record_thread_seeded(thread_root_event_id, unseen[-1].id, session_key)
|
|
|
return self._thread_context_block(unseen)
|
|
return self._thread_context_block(unseen)
|
|
|
|
|
|
|
|
async def _thread_history(
|
|
async def _thread_history(
|
|
@@ -1404,19 +1425,68 @@ class ChattoAdapter(BasePlatformAdapter):
|
|
|
return [root] if root is not None and not root.deleted_at else []
|
|
return [root] if root is not None and not root.deleted_at else []
|
|
|
|
|
|
|
|
def _record_thread_seeded(
|
|
def _record_thread_seeded(
|
|
|
- self, thread_root_event_id: str, last_delivered_id: str | None
|
|
|
|
|
|
|
+ self, thread_root_event_id: str, last_delivered_id: str | None, session_key: str
|
|
|
) -> None:
|
|
) -> None:
|
|
|
"""Note that this thread's session holds its history up to that message.
|
|
"""Note that this thread's session holds its history up to that message.
|
|
|
|
|
|
|
|
- ``None`` when an earlier process seeded the thread: the cursor died with
|
|
|
|
|
- it, so later turns must deliver nothing rather than re-seed blindly.
|
|
|
|
|
- Re-inserting keeps the map insertion-ordered, which is what the cap below
|
|
|
|
|
- evicts from — the least recently touched thread.
|
|
|
|
|
|
|
+ ``None`` when the cursor is unknowable — an earlier process seeded the
|
|
|
|
|
+ thread before this bookkeeping existed. Re-inserting keeps the map
|
|
|
|
|
+ insertion-ordered, which is what the cap below evicts from: the least
|
|
|
|
|
+ recently touched thread.
|
|
|
"""
|
|
"""
|
|
|
self._seeded_threads.pop(thread_root_event_id, None)
|
|
self._seeded_threads.pop(thread_root_event_id, None)
|
|
|
self._seeded_threads[thread_root_event_id] = last_delivered_id
|
|
self._seeded_threads[thread_root_event_id] = last_delivered_id
|
|
|
while len(self._seeded_threads) > ChattoConstants.SEEN_CAP:
|
|
while len(self._seeded_threads) > ChattoConstants.SEEN_CAP:
|
|
|
self._seeded_threads.pop(next(iter(self._seeded_threads)))
|
|
self._seeded_threads.pop(next(iter(self._seeded_threads)))
|
|
|
|
|
+ self._persist_cursor(session_key, last_delivered_id)
|
|
|
|
|
+
|
|
|
|
|
+ def _persisted_cursor(self, session_key: str, default: Any) -> Any:
|
|
|
|
|
+ """The cursor a previous process left on this session, or ``default``.
|
|
|
|
|
+
|
|
|
|
|
+ Best-effort bookkeeping, so every failure answers ``default``: a missing
|
|
|
|
|
+ store, a store without metadata support, a rejected read. That degrades
|
|
|
|
|
+ to seeding as if the thread were new, never to a failed turn.
|
|
|
|
|
+ """
|
|
|
|
|
+ store = getattr(self, "_session_store", None)
|
|
|
|
|
+ reader = getattr(store, "get_session_metadata", None)
|
|
|
|
|
+ if not callable(reader):
|
|
|
|
|
+ return default
|
|
|
|
|
+ try:
|
|
|
|
|
+ value = reader(session_key, _CURSOR_META_KEY, None)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "Chatto: reading the seed cursor for %s failed",
|
|
|
|
|
+ session_key,
|
|
|
|
|
+ exc_info=True,
|
|
|
|
|
+ )
|
|
|
|
|
+ return default
|
|
|
|
|
+ return default if value is None else value
|
|
|
|
|
+
|
|
|
|
|
+ def _persist_cursor(self, session_key: str, cursor: str | None) -> None:
|
|
|
|
|
+ """Mirror the cursor onto the session so it outlives this process.
|
|
|
|
|
+
|
|
|
|
|
+ Only when it moves: the store saves the whole session index on every
|
|
|
|
|
+ call, and an unchanged cursor has nothing to say. A refusal costs
|
|
|
|
|
+ nothing — the in-memory cursor carries the thread until it moves, and
|
|
|
|
|
+ that is the state this call is only a backup of. There is no session
|
|
|
|
|
+ yet on a thread's first turn, which is why the first cursor never lands.
|
|
|
|
|
+ """
|
|
|
|
|
+ store = getattr(self, "_session_store", None)
|
|
|
|
|
+ writer = getattr(store, "set_session_metadata", None)
|
|
|
|
|
+ if not callable(writer):
|
|
|
|
|
+ return
|
|
|
|
|
+ try:
|
|
|
|
|
+ if not writer(session_key, _CURSOR_META_KEY, cursor):
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "Chatto: session %s declined the seed cursor (no session yet?)",
|
|
|
|
|
+ session_key,
|
|
|
|
|
+ )
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.debug(
|
|
|
|
|
+ "Chatto: persisting the seed cursor for %s failed",
|
|
|
|
|
+ session_key,
|
|
|
|
|
+ exc_info=True,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
def _thread_session_exists(self, source: Any) -> bool:
|
|
def _thread_session_exists(self, source: Any) -> bool:
|
|
|
"""Whether the gateway already has a session for this source.
|
|
"""Whether the gateway already has a session for this source.
|