ADR-0021: Activity streams use cursor pagination

ODD Platform paginates its append-only activity streams by a (lastEventId, lastEventDateTime) cursor rather than offset/limit — list endpoints keep offset paging; the shape follows the data.

Status

Accepted. Reconstructed from the codebase on 2026-05-30; the decision is live in the source today.

Context

The activity feed is an append-only audit trail that can grow very large. Two pagination shapes are available: offset/limit (page + size), which the platform uses for aggregate listings like alerts, and cursor pagination, which carries a pointer to the last item seen. Offset paging degrades as the offset grows — the database must scan and discard every skipped row — which is exactly the wrong cost profile for deep scrolling through a long audit log.

Decision

Activity-stream endpoints paginate by a (lastEventId, lastEventDateTime) cursor, not offset/limit. The caller passes the id and timestamp of the last event it saw, plus a size; the next page is the rows immediately after that cursor. The same cursor shape is reused across the platform-wide activity feed (getActivity) and the per-data-entity activity feed (getDataEntityActivityList), establishing cursor pagination as the convention for activity streams specifically.

This is not a global pagination convention: aggregate/mutable listings (alerts, the data-entity list) continue to use offset/limit (page + size). The pagination shape follows the data semantics — append-only audit streams get cursors; bounded mutable listings get offsets.

Consequences

  • Deep scrolling through the activity feed stays cheap: each page is an indexed seek past the cursor rather than an offset scan, so cost does not grow with how far back the reader has paged.

  • The cursor is a compound key (lastEventId, lastEventDateTime) because event timestamps are not unique — the id breaks ties so the page boundary is deterministic.

  • A reader cannot jump to "page N" of the activity feed (there are no page numbers) — only forward/continued paging from a cursor. That is the accepted trade-off of the shape, and it matches how an audit feed is actually read.

  • There is a known naming wart: the controller-level cursor parameter is spelled lasEventId (missing a "t"), so the generated client exposes that misspelling on the public contract even though the service-layer parameter is the correct lastEventId. The decision is the cursor shape; the misspelled parameter name is a contract blemish that renaming would be a breaking change to fix.

Evidence

  • odd-platform-api/.../controller/ActivityController.java:34-35getActivity takes final Long lasEventId, final OffsetDateTime lastEventDateTime (the cursor pair; note the lasEventId misspelling at the controller/contract layer) and passes them to the service.

  • odd-platform-api/.../service/activity/ActivityServiceImpl.java:96-97getActivityList(... final Long lastEventId, final OffsetDateTime lastEventDateTime); :179-180fetchAllActivities forwards them to activityRepository.findAllActivities(... lastEventId, lastEventDateTime).

  • odd-platform-api/.../service/activity/ActivityServiceImpl.java:119-127getDataEntityActivityList takes the same (lastEventId, lastEventDateTime) cursor pair, confirming the shape is reused across activity-stream endpoints.

See also

Last updated