checksum: chunk verifier comparing source and shadow in one snapshot (CO-1) - #134
Conversation
…(CO-1) Verifier digests every chunk up to the copier's landed watermark on both sides inside one read-only REPEATABLE READ transaction, casting every column to the shadow's type (D7), under the copier's guard: owner role, catalog-only search_path, ACCESS SHARE on both relations before the snapshot, lock confirmation, relation-OID check. It reports the chunks that differ; policy, repair, and the proof constructors follow.
Resolves the pkg/checksum rows in SAFETY.md, docs/architecture.md, and docs/copy-and-swap-design.md against the copier progress-filler rows from #132: this branch's checksum text, main's copier and progress text.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 1/2: adversarial correctness review of 1 blocking, 3 non-blocking. The guard and the tiling hold. The chunker's last chunk is open above, so every pass reaches the watermark, including on an empty source. Blocking1. A float difference compares clean when the database or role sets The digest hashes each row's text rendering ( That setting is legal at the database or role level ( Measured: the default session finds the changed row. A pool on a database with Fix, one line, no other test changes: budgets := "SET LOCAL lock_timeout = " + strconv.FormatInt(opts.LockTimeout.Milliseconds(), 10) +
"; SET LOCAL statement_timeout = " + strconv.FormatInt(opts.StatementTimeout.Milliseconds(), 10) +
"; SET LOCAL extra_float_digits = 3" +
"; " + dbconn.LocalSearchPath("pg_catalog")This is the only lossy output setting I found. Test that fails on 8eae0a8 and passes with the fix// A float that differs in its last significant digit is a mismatch whatever
// extra_float_digits the database or role configures: the digest renders
// rows as text, and at extra_float_digits <= 0 that text rounds to 15
// digits, so 0.3 and 0.1+0.2 would hash the same.
func TestVerifierHashesFloatsAtFullPrecision(t *testing.T) {
f := newVerifierFixture(t)
f.exec(t, `CREATE TABLE %s.readings (id bigint PRIMARY KEY, v double precision NOT NULL, note text)`)
f.exec(t, `INSERT INTO %s.readings SELECT n, 0.3, 'r' FROM generate_series(1, 2500) n`)
target := f.prove(t, "readings")
lock := f.lock(t, "readings")
shadow := f.build(t, lock, target, `ALTER TABLE %s DROP COLUMN note`)
f.copy(t, target, shadow, lock)
f.exec(t, "UPDATE "+f.shadowName(shadow)+" SET v = 0.1::float8 + 0.2::float8 WHERE id = 7")
f.exec(t, `DO $$ BEGIN EXECUTE format('ALTER DATABASE %I SET extra_float_digits = 0', current_database()); END $$`)
pool, err := dbconn.NewPool(t.Context(), f.cfg)
require.NoError(t, err)
t.Cleanup(pool.Close)
report, err := f.verify(t, pool, target, shadow, lock, copier.NewWatermark(math.MaxInt64))
require.NoError(t, err)
require.Len(t, report.Mismatches, 1)
assert.Equal(t, chunk(t, math.MinInt64, 1000), report.Mismatches[0].Chunk)
}
Non-blocking2. The missing/extra-row test cannot tell source rows from shadow rows. The one missing and one extra shadow row cancel out: 999 + 1001 + 500 and 1000 + 1000 + 500 are both 2500. So Test change that passes on 8eae0a8 and fails with
|
|
🤖 2/2: OSS adoption and integration ease, at 1. Progress: a pass has no 2. Throughput: a pass is serial. Each chunk costs about ten round trips: the boundary query, begin, the budgets, the role, the lock, 3. Portability: The API is otherwise easy to adopt. It takes the same three proofs as the copier, refusals are This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving 8eae0a8d with 1 blocking finding in the 1/2 comment. Pin extra_float_digits in setVerifySession, or a database or role at <= 0 compares a float difference clean. The fix is one line, and the comment includes a test that fails today. The 2/2 comment has three non-blocking integration notes.
This stamp was left by Claude Code (claude-opus-5).
The digest hashes each row's text rendering, and for float4, float8, and the geometric types that rendering follows extra_float_digits: at zero or below the server rounds to fifteen significant digits, so a database or role configured there rendered two floats that differ only in their last digits the same and the pass compared them clean. The guarded session now pins the setting to its maximum alongside the timeouts and search_path. Tests now hold every guard property a mutation could drop: a float difference under a database at extra_float_digits = 0; a decoy bigint <= operator for the local search_path, which is the one layer the qualified functions cannot cover; one snapshot for both digests, with a shadow write injected from a query hook between them; the owner-role read, against a throwaway owner whose SELECT is revoked; and the refusal of a shadow that has lost a copy column. The missing/extra-row test drops two rows so the source and shadow counts differ and the report's row count is pinned to the source.
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/134, follow-up commit Verdict: the blocking finding is fixed in the follow-up commit with the suggested test; three of the four non-blocking correctness findings are fixed (the fourth is three parts fixed, one part rejected); the three integration-lens notes are tracked in the delivery plan rather than taken here.
Tests added or changed in the follow-up commit: Source: block/pg-sprite#134, review comments 5922753567 and 5922753763 and review 5373824351 at head |
|
🤖 Delta re-review of 0 blocking, 2 non-blocking. Finding 1 (blocking) is fixed. The survivors are now pinned. I reran the earlier survivors against the new head on real PostgreSQL. The full
Non-blocking1. The float test changes a database setting that other packages share.
Setting the parameter on the test pool alone tests the same property without touching shared state. For example, copy 2. Read-only mode is still unpinned. Dropping Invariants: unchanged from the first review. This extends CO-1 and CO-9 (the decoy operator is now named in CO-9), and LK-1, and upholds ST-6. This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving da5303c: the extra_float_digits finding is fixed and every guard property now has a test. Two non-blocking notes are in the delta comment.
This review was generated by Claude Code (claude-opus-5).
…er pool The full-precision float test set extra_float_digits with ALTER DATABASE, which reaches every later session on the server, including other packages' tests on a shared PG_DSN, and clobbers a pre-existing value. It now opens the pass's pool with the setting as a connection parameter, so the setting reaches no other pool and nothing is left behind. A new test proves the verify transaction is read-only: a write issued on the transaction's own connection between a chunk's two digests is refused with read_only_sql_transaction and the pass fails. The query hook now hands the connection it fired on so a test can act inside the transaction, not only beside it. Dropping AccessMode: pgx.ReadOnly from begin fails the test.
|
🤖 Response to the re-review of
Mutation table after this commit: every row in the re-review's table, including "drop Verification: |
Adds
checksum.Verifier: the per-chunk comparison of a source table with its copied shadow that the cutover gate will demand, reporting every chunk whose rows differ up to the copier's landed watermark.Why
Nothing yet compares what the copier wrote with what the source holds. The gate (CO-1) needs a comparison that is exact — both sides read at the same instant, through the types the shadow declares (D7), so a widened or converted column is not a false mismatch — and that holds no snapshot longer than one chunk, so a long table does not pin
xminfor the whole pass. Landing the comparison on its own keeps the divergence policy, the repair primitive, and theVerifiedShadow/CleanWatermarkconstructors, which decide what a difference means, in their own review.What
pkg/checksum:NewVerifier(target, shadow, lock, opts)takes the same three proofs as the copier and applies the same refusals (ST-6 shadow-proof checks, LK-1 lock-session checks, LK-2 timeout floor); the shadow is thecopier.Shadowshapeschemachange.BuiltShadowsatisfies.Verify(ctx, pool, through)reads the shadow's column types once, cuts its own chunks from the live source with acopier.Chunker, and digests each chunk in one read-onlyREPEATABLE READtransaction:LOCK TABLE source, shadow IN ACCESS SHARE MODEfirst so the snapshot is taken with both relations held, thenConfirmon the lock session and the relation-OID check, then the two reads. The chunk that straddles the watermark is clamped to it; nothing above is compared. Every transaction runs under the lock session'sBindcontext, and a lost lock is reported over whatever the read returned.count(*)andmd5(string_agg(md5(ROW(col::shadow_type, …)::text), '' ORDER BY pk))overpk BETWEEN $1::bigint AND $2::bigint, every functionpg_catalog-qualified and the transaction'ssearch_pathset topg_catalogalone (CO-9). Only the copy columns are compared; hashing generated columns present on both sides is a follow-up.Report{Through, Chunks, Rows, Mismatches}withMismatch{Chunk, Source, Shadow Digest};Report.Clean(). A report is not a proof: the constructors ofVerifiedShadowandCleanWatermarkstay private and unwired.SAFETY.mdpkg/checksumrow,copy-and-swap-design.mdpackage map and D7 "where enforced",invariants.mdCO-1 / CO-9 / LK-1 "enforced today",architecture.mdpackage table.integer → numeric(10,2)change compares clean; a watermark inside a chunk clamps it and ignores a difference above; a shadowingsearch_pathwith decoymd5andformat_typechanges no answer; a pool whose sessions start atextra_float_digits = 0still finds a float that differs beyond the fifteenth digit; a write issued on the verify transaction's own connection between a chunk's two digests is refusedread_only_sql_transaction; a replaced or dropped relation and a gone, rival, lost, or mid-pass-lost lock refuse fail-closed. Removing the casts fails the type andsearch_pathtests; unqualifyingmd5and dropping the localsearch_pathfails thesearch_pathtest.Before / after
References
docs/copy-and-swap-design.mdD7 (checksum through the destination types) and the package map.docs/invariants.mdCO-1, CO-9, LK-1.