Skip to content

collections: paginate by commit sequence - #224

Merged
bbockelm merged 2 commits into
mainfrom
feat/seq-cursor
Aug 24, 2026
Merged

collections: paginate by commit sequence#224
bbockelm merged 2 commits into
mainfrom
feat/seq-cursor

Conversation

@bbockelm

Copy link
Copy Markdown
Collaborator

First layer of resumable pagination for the mutable store, for the htcondordb mirror to serve paginated job queries instead of declining them. db and dbrpc follow; this is the primitive they need.

Why not the obvious cursors

Not a key range. There is no key order, and adding one means an ordered index over the mutable arena.

Not a physical position. A segment's id is its index in the shard's slice — compaction renumbers (baseID := uint32(len(sh.segs))) and reopen assigns by sort position (loaded[i].seg.id = uint32(i)), so ids are reused. A stale (segment, offset) cursor would not fail; it would silently address a different record.

Not a held iterator. A scan pins its segments and a pin defers compaction's reap — which is why colscan.go unpins each segment as soon as it is done rather than holding them across a scan. A cursor alive for minutes would defer reclamation for as long as a client sits on a page, in proportion to write traffic, and would not survive a restart.

What it uses instead

The order MVCC already maintains. Every record carries the sequence it was born at, records are appended in commit order (so each segment is a sorted run by sequence), and at a fixed snapshot each key has exactly one visible version — which makes (seq, key) a total order over what a scan can see. Sequences survive compaction because it carries the stamps onto the destination records; that is required for MVCC correctness, so the cursor rides on an invariant the store already has to keep.

QueryRawFromSeq(q, cursor, limit) yields records in that order, at most limit, plus the cursor to continue from.

The cursor is per shard, because the sequence is: each shard stamps from its own counter (seq := sh.commitSeq + 1), so sequences from different shards are unrelated numbers. A shard is finished before the next is opened, and each is frozen at the sequence it had when its first page was taken — the same guarantee Scan documents, which is likewise per shard.

Segments gain maxSeq beside minSeq, so a resume skips whole segments ending before its cursor instead of walking them to find out. Both are rebuilt by the recovery walk; both stay zero after a fast reopen, which is conservative — a zero costs a walk, it never skips a segment that should have been read — and matches how minSeq has always behaved.

Tests

Page-size sweep (1, 7, 64, n, 2n) for coverage and exactly-once; order stability across page boundaries; exactly-once under writes landing mid-pagination; a started shard staying frozen; the constraint being applied; pagination across a compaction rewrite; and maxSeq surviving recovery.

Each is set up to actually reach the path it names, which took a few tries: several segments per shard (a single active arena has nothing to merge), a churn ratio that makes Compact rewrite rather than no-op, and the directory snapshot removed so Open walks records instead of restoring. Two of these initially passed for the wrong reason and are noted in the commit.

Full collections suite passes (220s); golangci-lint reports nothing in the new files.

🤖 Generated with Claude Code

A caller that wants a page at a time has nothing to resume from. The
store has no key order, and adding one would mean an ordered index over
the mutable arena. It does not need one: MVCC already stamps every
record with the sequence it was born at, and records are appended in
commit order, so each segment is a sorted run by sequence. At a fixed
snapshot each key has exactly one visible version, which makes
(seq, key) a total order over what a scan can see.

QueryRawFromSeq walks that order, yielding at most limit records and a
cursor to continue from. The cursor is per shard, because the sequence
is: each shard stamps from its own counter, so a shard is finished
before the next is opened, and each is frozen at the sequence it had
when its first page was taken. That is the guarantee Scan already makes,
which is likewise per shard.

Deliberately not a physical position. A segment's id is its index in the
shard's slice: compaction renumbers ids and reopen assigns them by sort
order, so ids are reused and a stale (segment, offset) cursor would
silently address a different record instead of failing. Sequences
survive a rewrite, because compaction carries the stamps onto the
destination records for MVCC's sake.

Segments gain maxSeq beside minSeq so a resume skips whole segments that
end before its cursor. Both are rebuilt by the recovery walk; both stay
zero after a fast reopen, which is conservative (a zero costs a walk, it
never skips a segment that should have been read) and matches how minSeq
has always behaved.

Tests cover the page-size sweep, order stability across page boundaries,
exactly-once under concurrent writes, a started shard staying frozen,
the constraint being applied, pagination across a compaction rewrite,
and maxSeq surviving recovery — each set up to actually reach the path
it claims: several segments per shard, a churn ratio that makes Compact
rewrite rather than no-op, and the directory snapshot removed so Open
walks records instead of restoring.

SKIP=go-vet: the pre-commit hook vets from the root module, which cannot
see this separate module's package.
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

QueryRawProjectedFromSeq on the DB, opQueryRawProjSeq on the wire: at
most limit projected rows after a cursor, then a cursor trailer saying
where to continue and whether anything remains. A page costs a resume
rather than a re-scan, and the cursor names no physical position, so a
compaction between pages cannot invalidate it.

Mutable tables only. An archive already answers "the newest K" in one
shot -- ordered newest-first with the limit pushed down -- so the op
says that rather than pretending to paginate, and a view falls through
to the plain projection op. A server too old for the opcode answers
stBadReq, which the client turns into ErrPaginationUnsupported so a
caller can fall back to an unpaginated read.

The collections merge also needed a fix that only the wire test caught.
Records committed TOGETHER share a sequence and sit in the arena in
insertion order, not key order -- so within one commit a record
belonging after the cursor can sit physically before one belonging
before it. The merge had assumed each segment was a sorted run by
(seq, key) and lost records at every page boundary landing inside a
commit: 95 of 100 over the wire, 192 of 200 in a reproduction. It now
collects each commit's records across the segments, sorts that group by
key, and emits it, so the order it walks is the order a cursor can
resume from.

Every earlier test wrote one record per commit, which is why they all
passed. The regression test uses a transaction to get the shape
deterministically -- concurrent Puts do coalesce, but only when they
happen to queue together, which under suite load they stop doing, and a
test resting on that would pass or fail with the machine rather than
the code.

SKIP=go-vet: the pre-commit hook vets from the root module, which
cannot see these separate modules' packages.
@bbockelm
bbockelm merged commit 4c2dbfc into main Aug 24, 2026
11 checks passed
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