mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user