Skip to content

feat(copier): keyset chunker over the proven primary key - #127

Open
Kiran01bm wants to merge 3 commits into
mainfrom
kiran01bm/cs5-copier
Open

Kiran01bm wants to merge 3 commits into
mainfrom
kiran01bm/cs5-copier

Conversation

@Kiran01bm

@Kiran01bm Kiran01bm commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Add the keyset Chunker to pkg/copier: it cuts the proven primary-key space into consecutive row-sized chunks that tile the whole int64 range, reports the cut frontier the applier's CO-4 discard rule reads, and sizes chunks toward the D12 time target from each measured chunk.

Why

The copy-and-swap copier needs chunks that give equal work per chunk on sparse and dense key spaces, and that cover every key a row can carry so that "not yet read by the copier" always names a chunk still to come (CO-4). Cutting by key width fails the first; closing the first and last chunks at the table's current min/max fails the second.

Under concurrent copy workers the landed watermark (the contiguous prefix of landed chunks, what is checkpointed) lags behind the chunks the copier has already read, so an applier that discarded changes "above the watermark" would lose a change to a key inside an already-read chunk. The chunker therefore exposes the cut frontier (Cut) as the discard boundary; the watermark stays the checkpoint/resume position. Likewise, feedback from concurrent workers must not compound: each report sizes from the chunk it measured, not from whatever the current size has become.

The bigint parameter cast is needed because PostgreSQL infers the bound's type from the key column, so a bound outside a smallint/integer key's range could not otherwise be sent.

What

  • Chunker (NewChunker(target, from, opts), Next, Cut, Feedback(chunk, elapsed), Rows): keyset boundary query (ORDER BY pk OFFSET rows-1 LIMIT 1) against the live table; first chunk open below (MinInt64), last open above (MaxInt64); resume from a Watermark; a watermark at MaxInt64 means copy complete. Cut derives the frontier from existing state (next-1, MaxInt64 once done, ok=false before the first cut). Requires a non-zero CopySwapTarget proof; fail-closed ErrInvariantViolation naming the invariant (ST-6, CO-4) otherwise. // INV: CO-4 at the open-below start, the open-above close, and the no-rows boundary.
  • Chunk.Rows() records the size a chunk was cut to; Feedback scales that size toward the target by at most ×0.5..×2 per step within min/max bounds, and refuses a chunk no chunker cut.
  • ChunkerOptions: zero values take defaults fitted to whatever bounds the caller set ({MaxRows: 500} yields initial 500; {MaxRows: 7} yields floor 7), so setting one bound alone always validates; explicitly set values are validated as given.
  • Tests: unit tests for options (incl. one-bound fitting), startAfter, Cut before any query, concurrent-feedback settling (four reports on one 1000-row chunk settle at 2000, not 16000), nextRows; integration tests for coverage (disjoint, gap-free, sums to row count), Cut equal to each returned chunk's upper bound, even-division trailing chunk, empty table, resume, feedback resizing incl. a late report sizing from its own chunk, smallint/integer keys with an EXPLAIN-based proof that the PK index still serves the query, exact-string freeze of the boundary SQL, and error propagation. Green on PG 14/16/18.
  • Docs: SAFETY.md copier row, D12 and package map in docs/copy-and-swap-design.md, CO-4 cut-frontier vs landed-watermark paragraph and "enforced today" in docs/invariants.md.

Before / after

before                                    after
┌──────────────────────────────┐          ┌──────────────────────────────────────────────────┐
│ pkg/copier                   │          │ pkg/copier                                       │
│  Chunk, Watermark (types)    │          │  Chunk{lower, upper, rows}, Watermark            │
│  (no way to produce chunks)  │          │  Chunker ── Next(ctx, db) ──▶ Chunk, ok          │
└──────────────────────────────┘          │     │      Cut() ──▶ frontier (discard boundary) │
                                          │     ▲ Feedback(chunk, elapsed) (D12, ×0.5..×2)   │
                                          │  CopySwapTarget proof required                   │
                                          └──────────────────────────────────────────────────┘
key space:  ... nothing covered ...       [MinInt64 ── c1 ─┤├── c2 ─┤├── ... ─┤├── cN ── MaxInt64]
                                           every key in exactly one chunk; cN open above
                                           landed watermark ≤ Cut(); equal with one worker

References

🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.

Kiran01bm and others added 3 commits September 26, 2026 15:17
Chunks are sized in rows and cut from the live table; the first is open
below and the last open above so every key belongs to exactly one chunk
(CO-4 coverage) and a watermark at the largest int64 means copy complete.
Boundary parameters are declared bigint so bounds outside a smallint or
integer key's range can be sent while the key index still serves the query.
The landed watermark lags the chunks already read under concurrent
workers, so the applier's CO-4 discard rule needs Chunker.Cut, not the
watermark. Feedback scales the measured chunk's own size so concurrent
reports agree instead of compounding; INV markers, ST-6 ids, and
one-bound defaults fitting address the remaining review findings.
@Kiran01bm
Kiran01bm marked this pull request as ready for review September 26, 2026 22:19
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@aparajon

Copy link
Copy Markdown
Collaborator

🤖 1/2: adversarial correctness. I reviewed pkg/copier/chunker.go, the Chunk.rows addition in types.go, both test files, and the CO-4, D12 and SAFETY.md edits at 3246738. What I ran:

  • the full package, go test -race ./pkg/copier/ against PostgreSQL via testcontainers (ok … 11.8s)
  • 17 mutations of chunker.go
  • one probe test of my own

0 blocking, 3 non-blocking.

All 17 mutations are killed. They cover:

  • the Cut off-by-one, and its done and nothing-cut branches
  • startAfter without its +1, and without its MaxInt64 branch
  • the no-rows branch closing at lower instead of open above
  • >= weakened to >
  • OFFSET rows instead of rows-1
  • c.next = upper instead of upper + 1
  • dropping the ::bigint casts
  • dropping chunk.rows
  • Feedback scaling c.rows instead of the measured chunk
  • removing the per-step clamp, the ceiling, the floor fit, and the rows <= 0 guard
  • a zero elapsed time shrinking instead of growing

The tests reach every branch they claim to cover.

Splitting the cut frontier from the landed watermark is the right call. The CO-4 text now says which position governs the discard rule. Before, it said "the copier's watermark", which becomes wrong the moment a second worker exists.

Invariants:

  • CO-4, extends enforcement. The coverage half, "every key belongs to exactly one chunk", is now enforced in Chunker and tested. The applier half stays planned, and the Enforced today / Planned enforcement split says so accurately.
  • ST-6, upholds. An empty proof is refused at chunker.go:149, and a NULL boundary at chunker.go:240.
  • D12, now has a named enforcement site in the design doc.

Non-blocking

1. Resuming from the landed watermark leaves Cut too low for the shadow it resumes into. This is latent: it fires once the resume path (Phase 8) and the applier exist. NewChunker(target, W, …) reports Cut() == W (chunker.go:157, :197). But the run that crashed may already have landed chunks above W: out of order under concurrency, or ahead of the last checkpoint even with one worker. So "not yet cut" no longer implies "no shadow row".

A concrete run:

  • The crashed run lands [2001, 3000] while [1001, 2000] is still in flight, and checkpoints W = 1000.
  • Row 2500 is then updated.
  • On resume, the applier replays that change. 2500 > Cut(), so it discards it.
  • The re-copy of [2001, 3000] is ON CONFLICT (pk) DO NOTHING (invariants.md:75), so the stale pre-crash image stays.

The checksum would catch it before cutover, but low-level-design.md is explicit that the protocol must converge without that backstop.

The discard rule's premise is "the copier will read the current row", and that needs "and nothing is in the shadow for that key yet". invariants.md:95-96 now names the watermark as what is resumed from, so that is the place to record what resume owes. Either of these would restore the premise:

  • clear shadow rows above W before the first Next
  • checkpoint the cut frontier and treat (W, cut] as apply-not-discard on resume

Neither is this PR's code to write, but a sentence there keeps Phase 8 from inheriting the gap.

Test case (not run here)

This one crosses components that do not exist yet: resume, the applier, and the copy step. The shape it would take:

  • Seed 1..3000, and copy with two workers where the chunk [1001, 2000] is held open.
  • Let [2001, 3000] land, checkpoint W = 1000, and update 2500 on the source.
  • Kill the run, then resume from the checkpoint.
  • Assert the shadow's 2500 matches the source before the checksum runs.

2. Cut and Feedback wait behind Next's boundary query. Also latent: it matters once the applier calls Cut on its hot path. Next holds c.mu across the database round trip (chunker.go:204-209), and Cut, Feedback and Rows take the same lock. So a boundary query slowed by I/O or a stalled connection stalls the applier's discard decision, and every worker's feedback, for as long as the query runs.

Next needs to serialize with other Next callers, not with readers. A second mutex that serializes Next, with mu taken only to snapshot next/rows before the query and to publish after it, keeps chunks consecutive and frees Cut.

Test case: a boundary query that does not return
type slowBoundary struct{ started, release chan struct{} }
type slowRow struct{ q *slowBoundary }

func (q *slowBoundary) QueryRow(context.Context, string, ...any) pgx.Row {
	close(q.started)
	return slowRow{q}
}

func (r slowRow) Scan(dest ...any) error {
	<-r.q.release
	v := int64(999)
	*(dest[0].(**int64)) = &v
	return nil
}

func TestChunkerCutDoesNotWaitOnBoundaryQuery(t *testing.T) {
	c := &Chunker{opts: ChunkerOptions{}.withDefaults(), next: math.MinInt64, rows: 1000}
	q := &slowBoundary{started: make(chan struct{}), release: make(chan struct{})}
	nextDone := make(chan struct{})
	go func() {
		defer close(nextDone)
		_, _, _ = c.Next(context.Background(), q)
	}()
	<-q.started

	cutDone := make(chan struct{})
	go func() { defer close(cutDone); c.Cut() }()
	select {
	case <-cutDone:
	case <-time.After(2 * time.Second):
		close(q.release)
		<-nextDone
		require.Fail(t, "Cut blocked for the duration of an in-flight boundary query")
	}
	close(q.release)
	<-nextDone
	upper, ok := c.Cut()
	assert.True(t, ok)
	assert.Equal(t, int64(999), upper)
}

On 3246738:

--- FAIL: TestChunkerCutDoesNotWaitOnBoundaryQuery (2.00s)
    Error: Cut blocked for the duration of an in-flight boundary query

With the two-mutex split described above, it passes, and so does the rest of go test -race ./pkg/copier/.

3. Feedback's doc says it refuses a chunk this chunker did not cut; it refuses only a chunk that no chunker cut. chunker.go:272 makes the stronger claim, but :275 checks only rows > 0. A chunk cut by a second Chunker, for example one from before a resume, is accepted, and it resizes this one. The effect is bounded by the clamp, so the fix is the doc sentence, unless the stronger check is wanted.

This review was generated by Claude Code (claude-opus-5-5).

@aparajon

Copy link
Copy Markdown
Collaborator

🤖 2/2: the two lenses. OSS adoption, and integration ease for block/schemabot and other importers, at 3246738. Nothing here is blocking; the correctness pass is in 1/2.

0 blocking, 2 non-blocking.

Lens 1: OSS adoption

Chunker is the first piece of pkg/copier that does work rather than declare a contract. Its doc comment is the best explanation anywhere in the repo of why a keyset chunker needs a cut frontier separate from the landed watermark. It also explains why the first and last chunks are open-ended. An outside reader can learn the copy-and-swap ordering problem from go doc alone.

The package synopsis has not caught up. doc.go:1 still reads "defines chunked shadow-table copy contracts enforcing CO-4 and LK-3". That is the one line pkg.go.dev shows in the package list. It describes contracts that are no longer all there is, through two IDs a newcomer cannot expand without opening docs/invariants.md. Something like "cuts a table's primary-key space into row-count chunks for the shadow-table copy (CO-4, LK-3)" would say what the package is for.

Lens 2: integration ease for importers

The exported surface grows by additions only, and nothing outside pkg/copier changes. So no importer breaks.

pkg/copier mints a third ErrInvariantViolation. chunker.go:44 is a fresh errors.New("invariant violation"). pkg/executor aliases dbconn's instead, so the two are one error class. schemabot's PostgreSQL engine already branches on executor.ErrInvariantViolation. A copier violation surfaced through a future copy-and-swap executor would fail that errors.Is, even though the message and SAFETY.md:82 both describe a single class.

This is the same split I raised for pkg/schemachange on #124. It is cheaper to settle here, before the copier has callers, with var ErrInvariantViolation = dbconn.ErrInvariantViolation. pkg/copier already imports dbconn.

This review was generated by Claude Code (claude-opus-5-5).

@aparajon aparajon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Stamped: 0 blocking, 5 non-blocking. See the review comments above.

This stamp was left by Claude Code (claude-opus-5-5).

@morgo morgo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated adversarial review, posted on Morgan Tocker's behalf.

Approving. The cut-frontier-vs-landed-watermark distinction is the right call and the description makes the case for it better than the code alone could. The tiling is what earns CO-4 rather than merely claiming it.

Things I checked rather than assumed, because each would have been a coverage hole:

  • c.next == math.MinInt64 really does mean "nothing cut yet". Cut uses it as the sentinel for ok=false, which would be wrong if a resume could land there. It cannot: startAfter returns from.Value()+1 for a valid watermark, so reaching MinInt64 would need a watermark of MinInt64-1, which is not representable. The sentinel is sound.
  • The discard rule is safe in both directions. A key at or below Cut is in a chunk already cut, so it is applied or deferred — that is the case the landed watermark got wrong. A key above Cut is in a region not yet cut, and since cutting precedes copying, the eventual read is strictly later in real time than the Cut call that authorised the discard, so the copier sees the committed change. No window either way.
  • Even division does not lose the open-above chunk. When exactly rows keys remain, the boundary query returns the last one, next advances past it, and the following call takes the ErrNoRows path to produce [L+1, MaxInt64]. The trailing chunk is empty but it exists, which is what coverage requires.
  • upper + 1 cannot overflow — the MaxInt64 case takes the done branch instead.
  • Error paths leave the cursor where it was. Neither a failed boundary query nor a failed NewChunk advances next, so a retry re-cuts the same chunk rather than skipping a range. For a chunker whose whole job is gap-free coverage, that is the property that matters most on the failure path, and it holds by construction rather than by comment.
  • The ::bigint rationale is right, and pinning it with an EXPLAIN assertion on smallint/integer keys is the correct way to keep a future edit from silently turning the boundary query into a sequential scan.

One finding worth acting on before the applier is written, plus three smaller notes.

Next holds the mutex across the database round trip, so Cut blocks on it

func (c *Chunker) Next(ctx context.Context, db dbconn.RowQuerier) (Chunk, bool, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	...
	upper, err := c.boundary(ctx, db, c.next, c.rows)   // network round trip, under the lock

Cut takes the same mutex and reads two fields. Per the invariants change, the applier consults the cut frontier per captured change — "the applier therefore discards only for keys above the cut frontier". So every discard decision on the apply path can block behind a live query against the source table.

Failure scenario. Four copy workers, defaults, a large table with a sparse key space. A worker calls Next; the boundary query is ORDER BY pk OFFSET rows-1 LIMIT 1, which walks rows-1 index entries before returning one — with feedback having grown the size toward MaxRows that is up to 100,000 index tuples, and on a table under concurrent write load it can take hundreds of milliseconds. For that whole window every Cut call blocks, and so does every Feedback from the other three workers. At DefaultTargetChunkTime of 500ms with four workers, cuts are frequent enough that the applier spends a meaningful fraction of its time parked on a mutex it only ever wanted to read two int64s from.

Nothing about the design requires this. Serialising Next against Next is deliberate and correct — chunks must stay consecutive — but Cut and Feedback are dragged into that serialisation incidentally. Splitting the two concerns fixes it: one mutex held across the query purely to serialise cutting, and a second short-lived one (or atomics) guarding next/rows/done, taken only to read the cursor before the query and to publish the result after it. Cut then never waits on the database.

I am raising this now rather than as a follow-up because the applier does not exist yet — the invariants entry lists the registry and flush scheduling as "planned enforcement". Changing the locking discipline is free today and awkward once there is a hot path built on top of it. It is a liveness property, not a correctness one: nothing here returns a wrong answer.

Feedback does not check what it says it checks

A chunk this chunker did not cut is refused.

The code checks chunk.rows <= 0, which refuses a chunk built by the exported NewChunk (rows zero by construction) but accepts any chunk cut by any chunker. Two chunkers over two tables, and feeding one's chunk to the other resizes the wrong one silently — no error, just a size derived from work on a different table.

Whether that is worth a real identity check is a judgement call; the cheap version is to weaken the comment to what the guard actually establishes ("a chunk that no chunker sized"). As written the comment promises an ownership check that a reader will assume is there.

Setting InitialRows alone fails, while setting either bound alone succeeds

withDefaults fits the defaults around MinRows and MaxRows — that is what makes {MaxRows: 7} yield a floor of 7 rather than a contradiction. InitialRows gets no such treatment: it is only defaulted when zero, never fitted.

So ChunkerOptions{InitialRows: 50} is refused with initial rows 50 is outside [100, 100000], naming a floor and ceiling the caller never chose. A caller wanting small chunks — most obviously in a test — writes exactly that, and the error reads as though they set a bound they did not set.

The type comment says "a floor or ceiling given on its own pulls the other defaults inside it, so setting one bound never makes the defaults contradict it", which is accurate but leaves the reader to notice that InitialRows is not a bound. Either fit the default bounds around an explicit InitialRows the same way, or say in the comment that InitialRows is validated against the bounds and so may need them set alongside it.

Nit: a legal MaxRows makes fast chunks shrink instead of grow

ChunkerOptions{MaxRows: math.MaxInt64} passes validate today. Once feedback has grown rows into that range, a fast chunk takes ratio = 2.0, and int64(math.Round(float64(rows) * 2.0)) overflows. The conversion is not defined to saturate, and in practice it lands at MinInt64, so the clamp min(MaxRows, max(MinRows, scaled)) returns MinRows.

A chunk that completed far under target therefore collapses the next chunk to the floor — the exact opposite of the intended response. Clamping the scaled value in float64 before converting, or rejecting a MaxRows above some sane ceiling in validate, closes it. Nobody sets a row count to nine quintillion on purpose, which is why this is a nit and not a finding, but the current bound check is what makes it reachable at all.

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.

3 participants