Skip to content

feat: add easy-paging-keyset-demo (cursor pagination example)#4

Merged
jlc488 merged 1 commit into
mainfrom
feat/easy-paging-keyset-demo
May 23, 2026
Merged

feat: add easy-paging-keyset-demo (cursor pagination example)#4
jlc488 merged 1 commit into
mainfrom
feat/easy-paging-keyset-demo

Conversation

@jlc488

@jlc488 jlc488 commented May 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Second demo in this repo. Showcases `@KeysetPaginate` — the cursor pagination path of `easy-paging-spring-boot-starter` (0.4.0) — which is the strategy you want for unbounded time-series streams (logs, audit events, location pings) where `OFFSET` slows with depth and `COUNT(*)` is wasted work.

Companion to `easy-paging-demo` (offset pagination); same H2 in-memory DB and same independent-Gradle-build layout, but different annotation, return type, and `WHERE`-clause pattern.

Domain & seed

  • `Location` entity: `(id, workerId UUID, time, lat, lng)` — GPS pings for field workers, the canonical use case for keyset pagination
  • 300 rows for a fixed worker UUID (`00000000-0000-0000-0000-000000000001`), 1 minute apart, applied via `data.sql` on startup
  • Schema includes the covering index `(worker_id, time DESC, id DESC)` that a real PG/MySQL deployment wants for cursor pagination to stay O(log N) per page

The interesting bits

Detail Why it matters
Composite key on `(time, id)` The id tiebreaker is what makes the walk stable when two pings share a timestamp
Service does the size + 1 fetch Lets `KeysetPage.build` set `hasNext` correctly without a separate count
`keyExtractor` lambda returns the last visible row's `(time, id)` That tuple becomes the encoded `nextCursor`
`easy-paging.keyset.cursor-secret` blank in demo Cursors are inspectable for learning. README explicitly flags the production vulnerability and shows the env-var override

Verification

  • `./gradlew build` locally: BUILD SUCCESSFUL, 3/3 tests passing.
  • The headline test `walkingNextCursorCoversEveryRowExactlyOnce` walks the entire cursor chain across 4 pages (size=80 × 4 = 300 rows + 1 to detect last) and asserts the no-overlap / no-gaps invariant — the property keyset pagination actually has to guarantee, and the one that breaks first when the `WHERE`-clause tiebreaker is wrong.

CI expectation

The `detect` job from #1 (with #3's fix applied) should:

  1. Identify `easy-paging-keyset-demo/` as the only changed demo
  2. Spawn one `build` matrix job
  3. Run `./gradlew build` from that directory, including the cursor-walk test

`easy-paging-demo` should not rebuild — its files weren't touched.

What's next (not in this PR)

Per the agreed roadmap, the remaining easy-paging demos to add (each as its own PR):

  • `easy-paging-postgres-demo` — production-like setup with Docker Compose + Testcontainers against PostgreSQL
  • `easy-paging-reactive-demo` — reactive variant using the `easy-paging-spring-boot-starter-reactive` companion artifact (R2DBC + WebFlux)

Custom `PageResponseFactory` will be folded into the offset demo's README as an "advanced" section rather than getting its own dedicated demo.

Test plan

  • CI `detect` job identifies only `easy-paging-keyset-demo` as changed
  • CI `build` job runs `./gradlew build` from `easy-paging-keyset-demo/` and turns green (all 3 tests pass, including the cursor-walk invariant check)
  • After merge, README on `main` lists both demos with working links

Second demo. Showcases @KeysetPaginate — the cursor pagination path
of easy-paging-spring-boot-starter (0.4.0) — which is the strategy you
want for unbounded time-series streams (logs, audit events, location
pings) where OFFSET slows with depth and COUNT(*) is wasted work.

Companion to easy-paging-demo (offset pagination); same H2 in-memory
DB and same independent-Gradle-build layout, but different annotation,
return type, and WHERE-clause pattern.

Domain & seed:
- Location entity: (id, workerId UUID, time, lat, lng)
- 300 GPS pings for a fixed worker UUID, 1 minute apart, applied via
  data.sql on startup. Schema includes the covering index
  (worker_id, time DESC, id DESC) that a real PG/MySQL deployment
  wants for cursor pagination to stay O(log N) per page.

The interesting bits:
- Composite keyset on (time, id) — the id tiebreaker is what makes
  the walk stable when two pings share a timestamp.
- Service does the "size + 1" mapper fetch so KeysetPage.build can
  set hasNext correctly.
- application.yml leaves easy-paging.keyset.cursor-secret blank for
  demo inspectability, with a README note explaining why a missing
  secret is a vulnerability in production (and how to override via
  env var for a "production-like" run).

The smoke test (LocationControllerTest.walkingNextCursorCoversEveryRowExactlyOnce)
walks the entire cursor chain (4 pages of 80) and asserts the
no-overlap / no-gaps invariant — the property keyset pagination has
to guarantee and the one that breaks first when the WHERE-clause
tiebreaker is wrong.

Top-level README updated to list this demo alongside the offset one.
@jlc488
jlc488 merged commit 09eebd3 into main May 23, 2026
3 checks passed
@jlc488
jlc488 deleted the feat/easy-paging-keyset-demo branch May 23, 2026 05:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant