[F106] compound (timestamp, id) keyset cursor for message pagination

get_messages used strict timestamp inequalities with a non-deterministic
order_by(timestamp.desc()), so equal-timestamp messages were cut by limit
on one page and excluded (strict < T / > T) from the next — they vanished
across pages. Bundled the (timestamp, id) pair into a MessageCursor dataclass
so the next page resumes exactly past the cursor's id at the shared
timestamp (or_: strictly-older OR same-timestamp-smaller-id for before; the
mirror for after), with a deterministic order_by(timestamp.desc(), id.desc())
so the last-item cursor is unambiguous. id is None for a legacy timestamp-
only cursor (strict inequality, prior behavior). The route builds cursors
from the flat before/before_id + after/after_id HTTP params; the schema now
carries the tie-breaker ids. Also clears PLR0913 (cursors replace the
before_id/after_id params).
This commit is contained in:
Renn F
2026-06-28 21:56:10 +02:00
parent c8bb16e634
commit b1d5dbb240
4 changed files with 201 additions and 19 deletions
+13 -2
View File
@@ -25,6 +25,7 @@ from roboco.services.messaging import (
MessageCreateRequest as ServiceMessageRequest,
)
from roboco.services.messaging import (
MessageCursor,
get_messaging_service,
)
@@ -45,10 +46,20 @@ async def list_messages(
"""List messages in a session (session existence is checked in the service)."""
messaging = get_messaging_service(db)
try:
before_cursor = (
MessageCursor(params.before, params.before_id)
if params.before is not None
else None
)
after_cursor = (
MessageCursor(params.after, params.after_id)
if params.after is not None
else None
)
messages, has_more = await messaging.list_messages_for_session(
session_id=params.session_id,
before=params.before,
after=params.after,
before=before_cursor,
after=after_cursor,
message_type=params.type_filter,
limit=params.limit,
)
+6
View File
@@ -23,6 +23,12 @@ class ListMessagesParams(BaseModel):
session_id: UUID
before: datetime | None = None
after: datetime | None = None
# Keyset-pagination tie-breakers: pass the last (before) / first (after)
# message's id alongside its timestamp so equal-timestamp messages are not
# skipped across pages. Optional — a timestamp-only cursor keeps the
# legacy strict-inequality behavior.
before_id: UUID | None = None
after_id: UUID | None = None
type_filter: MessageType | None = None
limit: int = Field(50, ge=1, le=100)
+71 -14
View File
@@ -16,7 +16,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any, ClassVar, cast
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import and_, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
@@ -76,6 +76,23 @@ def _minutes_to_timedelta(value: int | None) -> timedelta | None:
# =============================================================================
@dataclass(frozen=True, slots=True)
class MessageCursor:
"""A keyset-pagination cursor over messages: a ``(timestamp, id)`` pair.
The ``id`` tie-breaks equal timestamps so paging across same-timestamp
messages skips nothing: the next page resumes past the cursor's id at
the shared timestamp rather than being excluded by a strict
``timestamp < cursor`` / ``timestamp > cursor`` inequality (which drops
every row sharing the cursor's timestamp — they are cut by ``limit`` on
the prior page and never returned). ``id`` is ``None`` for a legacy
timestamp-only cursor (strict inequality, the prior behavior).
"""
timestamp: datetime
id: UUID | None = None
class MessagingService(BaseService):
"""
Service for managing all messaging operations.
@@ -1571,8 +1588,8 @@ class MessagingService(BaseService):
self,
*,
session_id: UUID,
before: datetime | None,
after: datetime | None,
before: MessageCursor | None,
after: MessageCursor | None,
message_type: MessageType | None,
limit: int,
) -> tuple[list[MessageTable], bool]:
@@ -1580,7 +1597,10 @@ class MessagingService(BaseService):
Existing `get_messages` skips the session-existence check — this
variant raises NotFoundError when the session is missing so routes
can return a clean 404 without issuing their own query.
can return a clean 404 without issuing their own query. The ``before``
/ ``after`` cursors carry the keyset ``(timestamp, id)`` pair so
callers can paginate without skipping equal-timestamp messages across
pages.
"""
await self.get_session_or_raise(session_id)
return await self.get_messages(
@@ -1668,8 +1688,8 @@ class MessagingService(BaseService):
async def get_messages(
self,
session_id: UUID,
before: datetime | None = None,
after: datetime | None = None,
before: MessageCursor | None = None,
after: MessageCursor | None = None,
message_type: MessageType | None = None,
limit: int = 50,
) -> tuple[list[MessageTable], bool]:
@@ -1678,8 +1698,12 @@ class MessagingService(BaseService):
Args:
session_id: Session to get messages from
before: Get messages before this timestamp
after: Get messages after this timestamp
before: Keyset cursor — return messages older than (or, when the
cursor carries an id, equal-timestamp-but-smaller-id than) this
position. ``None`` for no upper bound.
after: Keyset cursor — return messages newer than (or
equal-timestamp-but-larger-id than) this position. ``None`` for
no lower bound.
message_type: Filter by message type
limit: Maximum messages to return
@@ -1688,15 +1712,48 @@ class MessagingService(BaseService):
"""
query = select(MessageTable).where(MessageTable.session_id == session_id)
if before:
query = query.where(MessageTable.timestamp < before)
if after:
query = query.where(MessageTable.timestamp > after)
if before is not None:
if before.id is not None:
# Compound keyset cursor: resume past (before.timestamp,
# before.id) in desc order. Rows where (timestamp, id) <
# (before.timestamp, before.id): strictly older, OR same
# timestamp with a smaller id. Without this tie-break,
# equal-timestamp rows cut by ``limit`` on the prior page are
# excluded by the strict ``< before.timestamp`` and vanish.
query = query.where(
or_(
MessageTable.timestamp < before.timestamp,
and_(
MessageTable.timestamp == before.timestamp,
MessageTable.id < before.id,
),
)
)
else:
query = query.where(MessageTable.timestamp < before.timestamp)
if after is not None:
if after.id is not None:
query = query.where(
or_(
MessageTable.timestamp > after.timestamp,
and_(
MessageTable.timestamp == after.timestamp,
MessageTable.id > after.id,
),
)
)
else:
query = query.where(MessageTable.timestamp > after.timestamp)
if message_type:
query = query.where(MessageTable.type == message_type)
# Get one extra to check if there are more
query = query.order_by(MessageTable.timestamp.desc()).limit(limit + 1)
# Deterministic total order: timestamp desc, then id desc so the
# "last item" cursor is unambiguous for keyset pagination (without the
# id tie-break, same-timestamp rows have a non-deterministic order and
# the cursor is ambiguous).
query = query.order_by(
MessageTable.timestamp.desc(), MessageTable.id.desc()
).limit(limit + 1)
result = await self.session.execute(query)
messages = list(result.scalars().all())