Skip to content

checkpoint: persist the one resume row per target with a guarded upsert and a typed load - #141

Merged
Kiran01bm merged 4 commits into
mainfrom
kiran01bm/cs10-checkpoint
Oct 5, 2026
Merged

Kiran01bm merged 4 commits into
mainfrom
kiran01bm/cs10-checkpoint

Conversation

@Kiran01bm

@Kiran01bm Kiran01bm commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

pkg/checkpoint gains a Store that keeps the one resume row per target in an engine-owned table, writes it under the target's table lock with a guarded upsert, and loads it with a typed outcome so a restarted run knows whether to resume, refuse, or start fresh.

Why

Build, copy, verify, gate, and cutover are landed, but nothing survives a process restart. D3 in docs/copy-and-swap-design.md requires a single checkpoint row per target in the target database; ST-1 / ST-2 in docs/invariants.md require that a restart never resumes from a row written for a different statement and never mistakes a failed read for "no checkpoint"; LK-1 requires that every write for a target happens under the table lock session that keeps a second engine off it. This is that storage layer; the orchestrator that drives phase transitions is a later PR.

What

  • Ensure makes pgsprite.pgsprite_checkpoint usable once per database. Under the table's advisory key it reads the owners of the schema and the table from pg_catalog, refuses ErrForeignObject if either exists under another role or the table's name is taken by a relation that is not a plain table, and creates only what is absent. A deployment that keeps database-level CREATE away from the engine role can therefore pre-provision both objects for it (docs/engine-role.md).
  • Save(ctx, lock, cp) writes the target's one row with INSERT … ON CONFLICT DO UPDATE guarded on format_version and both model fingerprints; zero rows affected means another statement's row is in the way, and Save returns *IncompatibleError (carrying the stored row's Identity) instead of overwriting it. The write runs under lock.Bind and confirms the lock from its own transaction, as the copier does per chunk.
  • Load returns exactly one of Checkpoint (resume), ErrNotFound (a completed read found no row), *IncompatibleError (another format or statement), ErrTableMissing (Ensure never ran here; wraps the server's 42P01), or a plain error after bounded retries.
  • Delete(ctx, lock, schema, table, stored) is the explicit fresh start: a compare-and-delete of the row the caller was shown (IncompatibleError.Stored or Checkpoint.Identity()). A row that changed in the meantime survives and comes back as a new *IncompatibleError; no row is the idempotent success.
  • The read retry loop classifies with a package-local readRetryable: everything dbconn.Retryable treats as transient, plus a session the server ended from outside it (57P01 / 57P02 / 57P03, how a failover looks from the client). Only the read widens the set; a write those codes interrupt has an ambiguous outcome and is not repeated.
  • Fingerprints are pkg/schemachange's model digests, not SQL text; phase is stored by name; updated_at comes from the injected clock.

Before / after

One run of ALTER TABLE orders DROP COLUMN note on a 2000-row table, killed after the copier reached key 1000. The only thing that changed is that the watermark now outlives the process; the copier, the shadow builder, and the preflight are untouched.

Before                                              After
──────                                              ─────
t0  BuildShadow, copier starts                      t0  BuildShadow, copier starts
t1  copier reaches key 1000                         t1  copier reaches key 1000
                                                        Store.Save(lock, cp) writes the row for public.orders:
    (nothing is written anywhere)                       watermark 1000, phase copying, both fingerprints
t2  process killed                                  t2  process killed
t3  new process starts                              t3  new process starts
    no row to read: the engine cannot tell              Store.Load(public.orders, same fingerprints)
    where the copy stopped                                → Checkpoint{Watermark: 1000, Phase: copying}
t4  only option: DropShadow and start over,         t4  copier resumes above key 1000, copies
    all 2000 rows copied again                          rows 1001–2000 and converges

If the new process runs a different statement against the same table, Load returns *IncompatibleError{Mismatch: MismatchTarget, Stored: …} and Save refuses to overwrite the row; Delete with that Stored identity is how the operator starts fresh. If the first engine is still alive but lost the table lock, its next Save is refused as ErrInvariantViolation (LK-1) rather than regressing the row. If the read itself fails after the bounded retries, the error is neither ErrNotFound nor incompatible, so a blip never triggers a fresh start.

Decisions worth a look

  • Server-ended sessions are retried by the read only. readRetryable adds 57P01 / 57P02 / 57P03 on top of dbconn.Retryable for Load; pkg/dbconn is unchanged, so no write path anywhere retries through those codes. ST-2 in docs/invariants.md records the split.
  • Ensure checks ownership strictly: the owner must be current_user. A schema or table owned by a role the engine is merely a member of is still refused. This keeps the rule the same as DropShadow's and makes the pre-provisioning recipe one line (ALTER … OWNER TO <engine>); loosening to membership can come later if a deployment needs it.
  • Delete compares the whole identity, not the fingerprints alone. A row from another row format is a row the caller was not shown either, so format_version is part of the match.
  • The table's shape is not versioned yet. format_version protects a row's meaning; the first column change will need Ensure to evolve the table and a typed answer for a table from another version. Tracked as an internal follow-up.

Tests

TestEnsureWaitsForAConcurrentFirstCreate holds a first engine's create open and proves the second waits on the advisory key (fails with 23505 when the lock is removed). TestEnsureAcceptsAPreProvisionedTableWithoutDatabaseCreate runs Ensure as a role with no database CREATE against objects it owns. TestEnsureRefusesASchemaAnotherRoleOwns / …ATableAnotherRoleOwns / …AViewUnderTheTableName pin ErrForeignObject. TestSaveAndDeleteRequireTheTargetsTableLock and TestSaveRefusesWhenAnotherBackendHoldsTheTable (the session's backend terminated, another backend takes the key) pin LK-1. TestDeleteRefusesARowTheCallerWasNotShown pins the compare-and-delete. TestWritesAndReadsWithoutEnsureReportTheMissingTable pins ErrTableMissing. Mutations verified against the new tests; go test -race and scripts/test-flaky.sh 5/5 on the two concurrency tests.

Stack

Based on main, merged forward after #139 (the cutover swap) landed. No capability, verdict, or CLI surface changes; demo/tour.sh is unaffected. The orchestrator that chains build → copy → verify → gate → cutover → drop, and the WAL-drain DrainFunc, are the next rows.

@Kiran01bm
Kiran01bm force-pushed the kiran01bm/cs10-checkpoint branch from 7d5fc03 to 3796a08 Compare October 1, 2026 11:34
…rt and a typed load (ST-1, ST-2)

Add pkg/checkpoint's Store over a pkg/dbconn pool. Ensure creates the
engine-owned pgsprite.pgsprite_checkpoint table on first use under the
table's own advisory key, so concurrent first use serializes. Save is a
single INSERT ... ON CONFLICT DO UPDATE whose WHERE clause refuses to
overwrite a row written for another format_version or another
source/target fingerprint, and reports the refusal as a typed
*IncompatibleError. Load distinguishes ErrNotFound (a completed read with
no row, the only outcome a caller may start fresh from) from
*IncompatibleError and from a transient failure, which it retries a
bounded number of times through an injected sleep before returning an
error that is neither. Delete is the explicit, idempotent fresh start.

The watermark column is NULL for a zero copier.Watermark; the LSN column
is pg_lsn, written from its text form and read back through
decode.ParseLSN; phase is the stable Phase name, so an operator reading
the row sees "copying", not a number.

dbconn.Retryable now treats 57P01/57P02/57P03 as transient. decode gains
ParseLSN.

Integration proof: a copy pinned mid-chunk is cancelled, the watermark
saved, loaded, and a new Copier started from it converges on the shadow
with exactly the rows the first run did not land.
57P01/57P02/57P03 are what a backend reports when the server ends the
session from outside it, which is how a failover looks from the client.
A write interrupted that way has an ambiguous outcome, so the engine-wide
dbconn.Retryable must not repeat it. Store.Load is a read, so repeating
it is safe: the checkpoint package widens the classifier for its own
retry loop instead of the engine doing so for everyone.
@Kiran01bm
Kiran01bm force-pushed the kiran01bm/cs10-checkpoint branch from 3796a08 to 9b5bd63 Compare October 4, 2026 23:09
@Kiran01bm
Kiran01bm marked this pull request as ready for review October 4, 2026 23:56
@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

aparajon commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

🤖 1/2: adversarial correctness review of 9b5bd63. I read Store (Ensure, Save, Load, Delete), the read classifier, ParseLSN, and every test, against ST-1, ST-2, LK-1 and OC-4. I ran the package on real PostgreSQL 16 (testcontainers), mutated each production change, and probed the gaps below with throwaway tests.

0 blocking, 5 non-blocking.

The core of the store is right. The primary key on (schema_name, table_name) is the conflict target, so there is one row per target (ST-1). A save is one statement in one transaction, so a crash before commit leaves the previous record. The guard compares the format version and both fingerprints, and none of the three is in the SET list, so a save cannot move a row from one statement to another. When the guard refuses, PostgreSQL has already locked the conflicting row, so the read-back inside the same transaction names exactly the row that refused. Load keeps its four outcomes apart (ST-2): ErrNotFound only on pgx.ErrNoRows, a typed IncompatibleError checked before decoding, and a plain error after bounded retries. A missing table is a plain 42P01, never "no checkpoint". A row that Save could not have written (an unknown phase, an LSN that does not parse) is an ErrInvariantViolation. Before this PR nothing persisted a checkpoint, so there are no rows from older builds to stay compatible with.

Non-blocking

1. The guard keys on the statement, not on the run that holds the table, so a stale writer of the same statement overwrites newer state. store.go:190-192, store.go:199, store.go:252

Two runs of the same ALTER against the same table have the same fingerprints, so the guard cannot tell them apart. Suppose an engine loses its table lock but keeps its pool, and a second engine takes over and saves verifying at the complete watermark. The first engine's next Save of copying at 1000 then lands, and the row goes backwards. It can go forwards too. If the second engine starts fresh (Delete, drop and rebuild the shadow), the stale Save can stamp watermark 1000 over a shadow that holds nothing below it. Delete is unconditional as well, so a fresh start can remove a row that changed after the operator read the IncompatibleError.

Every other write path in LK-1's Enforced list (shadow build, drop and inspect, the copier, the verifier) takes the *dbconn.TableLockSession, runs under its Bind context, and confirms the lock from its own transaction before writing. Save and Delete take none of that, and OC-4 asks that a stale actor never overwrite newer state. Nothing calls Save outside tests yet, so this is latent until the orchestrator lands. The CO-1 checksum gate would also catch rows missing below a watermark before any cutover. Still, the parameter is cheapest to add now, before the orchestrator and importers bind to the signature:

  • Save(ctx, lock *dbconn.TableLockSession, cp), running under lock.Bind and calling lock.Confirm from the save's own transaction, as the copier does per chunk.
  • Delete(ctx, lock, schema, table, have Fingerprints), as a compare-and-delete of the row the operator was shown.
  • Optionally, the shadow's OID from BuiltShadow stored and guarded, so a resume can prove the shadow it finds is the one the watermark describes.
Probe: a stale save of the same statement regresses the row (passes on 9b5bd63, showing the gap)
func TestProbeStaleSaveOfTheSameStatementRegressesTheRow(t *testing.T) {
	f := newStoreFixture(t)
	f.ensure(t)
	newer := ordersCheckpoint()
	newer.Watermark = copier.NewWatermark(1800)
	newer.Phase = checkpoint.PhaseVerifying
	require.NoError(t, f.store.Save(t.Context(), newer))

	stale := ordersCheckpoint() // watermark 1000, copying, same fingerprints
	require.NoError(t, f.store.Save(t.Context(), stale), "the stale save is accepted")
	got, err := f.store.Load(t.Context(), "app", "orders", newer.Fingerprints())
	require.NoError(t, err)
	assert.Equal(t, copier.NewWatermark(1000), got.Watermark)
	assert.Equal(t, checkpoint.PhaseCopying, got.Phase)
}

--- PASS: TestProbeStaleSaveOfTheSameStatementRegressesTheRow (0.95s), logging after stale save: watermark=1000 phase=copying.

2. Ensure accepts a pgsprite schema and table that another role owns, and that role's trigger then runs as the engine on every Save. store.go:150-171

CREATE … IF NOT EXISTS treats whatever is already under that name as the engine's own. It checks neither the owner nor the shape. Any role with CREATE on the database (the database owner always has it) can create pgsprite.pgsprite_checkpoint first, add a trigger, and grant the engine access. Ensure then succeeds, and the trigger runs with the engine role's privileges, which include membership in the owner of every table it changes (docs/engine-role.md). DropShadow already refuses a relation another role owns (TestDropShadowRefusesARelationAnotherRoleOwns). Ensure could do the same: read the owners of pgsprite and the table from pg_catalog and refuse, typed, if either exists under another owner. The same read also fixes 2/2's finding 1.

Probe: a foreign-owned table is accepted, and its trigger runs as the engine (passes on 9b5bd63, showing the gap)

Another role creates pgsprite, a table with the expected shape, and a BEFORE INSERT OR UPDATE trigger that records current_user. It then grants the engine USAGE, CREATE on the schema and ALL on the table. As the engine:

require.NoError(t, store.Ensure(t.Context()), "Ensure accepts the foreign-owned table")
require.NoError(t, store.Save(t.Context(), ordersCheckpoint()))
// tableowner = probe_other_…, trigger ran as = probe_eng_… (the engine)
assert.Equal(t, other, owner)
assert.Equal(t, eng, who)

--- PASS: TestProbeEnsureAcceptsAForeignOwnedTable (0.96s), logging table owner = probe_other_41000_2, trigger ran as = probe_eng_41000_2.

3. TestEnsureSerializesConcurrentFirstUse passes without the advisory lock. store_integration_test.go:101-114

I replaced ensureLockSQL with a no-op SELECT, and the test passed 10 of 10 runs. The eight Ensures finish too quickly to overlap. The race the lock exists for is real, though. If one engine's create is still uncommitted when a second engine's CREATE SCHEMA IF NOT EXISTS runs, the second fails with 23505 on pg_namespace_nspname_index. A test that holds the first create open makes the race happen every time:

Test that fails without the lock and passes on 9b5bd63
// A second engine's Ensure that arrives while the first engine's create is
// uncommitted waits on the advisory key, then finds the schema and succeeds.
func TestEnsureWaitsForAConcurrentFirstCreate(t *testing.T) {
	f := newStoreFixture(t)
	first, err := f.pool.Begin(t.Context())
	require.NoError(t, err)
	_, err = first.Exec(t.Context(), "SELECT pg_advisory_xact_lock($1, hashtext(quote_ident($2) || '.' || quote_ident($3)))",
		dbconn.TableLockClassID, checkpoint.SchemaName, checkpoint.TableName)
	require.NoError(t, err)
	_, err = first.Exec(t.Context(), `CREATE SCHEMA "pgsprite"`)
	require.NoError(t, err)

	second := make(chan error, 1)
	var wg sync.WaitGroup
	wg.Go(func() { second <- f.store.Ensure(t.Context()) })
	t.Cleanup(wg.Wait)
	const waitingDeadline = 2 * time.Second
	require.Eventually(t, func() bool {
		var waiting bool
		require.NoError(t, f.pool.QueryRow(t.Context(), "SELECT EXISTS (SELECT 1 FROM pg_locks WHERE NOT granted)").Scan(&waiting))
		return waiting
	}, waitingDeadline, 10*time.Millisecond, "the second Ensure should be waiting behind the first create")
	require.NoError(t, first.Commit(t.Context()))
	require.NoError(t, <-second)
	assert.True(t, f.tableExists(t))
}

--- PASS on 9b5bd63. With the lock removed it fails with --- FAIL: TestEnsureWaitsForAConcurrentFirstCreate (0.95s): duplicate key value violates unique constraint "pg_namespace_nspname_index" (SQLSTATE 23505). The existing test still passes against the same mutant.

4. Two SET columns are never checked after an upsert. store_integration_test.go:149-159

TestSaveUpsertsTheOneRowPerTarget sets PublicationName but asserts only SlotName. Deleting publication_name = EXCLUDED.publication_name from the SET list survives the whole package. So does deleting shadow_table = …. If the publication name is dropped, a resumed run no longer knows which publication it created, and nothing else records it. That is the same kind of leak ST-3 rules out for slots. One line pins it: assert.Equal(t, "pgsprite_0a1b2c3d", got.PublicationName). It passes at head and fails against that mutant (expected: "pgsprite_0a1b2c3d", actual: ""). Separately, ParseLSN's 32-bit bound on the high half is unpinned. Adding "100000000/0" to TestParseLSN's bad inputs kills the mutant that widens it to 64 bits.

5. Two descriptions of the retry classifier are wrong. docs/copy-and-swap-design.md:407 says Load retries "through dbconn.Retryable errors". It retries through readRetryable, which adds 57P01/57P02/57P03; invariants.md ST-2 already says so. The PR description has the opposite problem. "Decisions worth a look" says the three codes "come out of" dbconn.Retryable "in the same change". Net of both commits, though, this PR does not touch pkg/dbconn, because main never listed them (docs/testing.md:286 already calls the interruption terminal there). If the PR is squashed with that text, the commit message will describe a change that never happened.

Verified

  • Head passes. go test ./pkg/checkpoint/ ./pkg/decode/ passes against a real container, 6 of 6 runs, and go vet is clean.
  • ST-1 is upheld, and its enforcement moves from planned to live. The Enforced line names Store.Save and two tests that exist.
  • ST-2 is upheld. All five tests cited in its Enforced line exist, and the integration test does pin the widening: dropping 57P01 from readRetryable fails TestLoadRetriesAcrossATerminatedBackend as well as the unit tests.
  • LK-1 / OC-4: the checkpoint write path does not take the table lock yet (finding 1).
  • Mutations, each restored with git checkout:
Mutant Caught by
Guard AND → OR (source or target line) TestSaveRefusesToOverwriteAnotherStatementsRow
Zero-rows branch never taken TestSaveRefusesToOverwriteAnotherStatementsRow
Refused save skips the IncompatibleError TestSaveRefusesToOverwriteAnotherStatementsRow
Save skips validate TestSaveRefusesAnInvalidCheckpointBeforeTheDatabase
UpdatedAt from the record, not the clock TestSaveThenLoadRoundTripsTheRecord, TestSaveUpsertsTheOneRowPerTarget
No Commit 8 integration tests
No primary key 9 integration tests
Delete deletes nothing TestDeleteIsIdempotent, TestSaveRefusesToOverwriteAnotherStatementsRow
SET drops watermark / slot_name TestSaveUpsertsTheOneRowPerTarget (+ resume test)
SET drops publication_name / shadow_table survives (finding 4)
Ensure without the advisory lock survives 10/10 (finding 3)
ErrNoRows not mapped to ErrNotFound TestLoadReportsNoRowAsNotFound, TestLoadReportsNotFoundForATargetWithNoRow, TestDeleteIsIdempotent
Load skips the incompatibility check both …AsIncompatible integration tests
readRetryable drops 57P01 unit classifier tests + TestLoadRetriesAcrossATerminatedBackend
Retry every error TestRetryTransientReturnsPermanentErrorAtOnce, …ReturnsNoRowsAtOnce, TestLoadReportsNoRowAsNotFound
Flat backoff TestRetryTransientExhaustsItsAttempts, …RetriesThroughTransientErrors
Bad LSN decoded anyway TestRowCheckpointRefusesValuesSaveCannotHaveWritten
Source/target mismatch order swapped TestRowIncompatibilityOrdersFormatThenSourceThenTarget
parsePhase misses the last phase TestParsePhaseInvertsString
validate skips the shadow table TestCheckpointValidate
ParseLSN high half widened to 64 bits survives (finding 4)
Delete swallows its error survives (no test of Delete without Ensure)
Refused-save read-back via the pool instead of the transaction survives (behaves the same under the tests)

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

@aparajon

aparajon commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

🤖 2/2: OSS adoption and integration ease, at 9b5bd63. These are lenses, not correctness findings. 0 blocking, 3 non-blocking.

For an importer, the surface is in good shape. Load gives four outcomes a caller can route on without matching message text: Checkpoint, ErrNotFound, *IncompatibleError (a stable Mismatch name plus Have/Want), and an error that is neither. The fingerprints are model digests rather than SQL text. An orchestrator that re-plans the same change from a reworded statement therefore resumes instead of starting fresh, which is what SchemaBot needs when a PR is edited cosmetically mid-apply. The core imports nothing from an orchestrator.

1. A least-privilege deployment cannot pre-provision the table: Ensure demands database CREATE even when everything already exists. store.go:124

PostgreSQL checks the database-level CREATE privilege for CREATE SCHEMA IF NOT EXISTS before it checks whether the schema exists. So a DBA who creates pgsprite and its table for the engine role, so that the engine never holds database CREATE, still gets a refusal from Ensure. Save works fine on that same table. On PostgreSQL 16, with the schema and the table both owned by the engine and no database CREATE:

Ensure err = create "pgsprite"."pgsprite_checkpoint": ERROR: permission denied for database db_68821_1 (SQLSTATE 42501)
Save on pre-provisioned table: err = <nil>

Managed PostgreSQL shops that keep CREATE on the database away from service roles will hit this on first use. docs/engine-role.md describes the requirement but offers no way around it. The fix from 1/2's finding 2 covers this too: read pg_catalog first, return early when both objects exist and the engine owns them, and run the CREATE only when they are absent. The engine-role doc can then list pre-provisioning as the alternative to granting CREATE.

2. Load on a database where Ensure never ran is an untyped 42P01. load.go:27

load checkpoint for app.orders: ERROR: relation "pgsprite.pgsprite_checkpoint" does not exist (SQLSTATE 42P01)

Failing closed is right, since this must never read as ErrNotFound. An importer's read-only path, though, such as a status view showing whether a target has resume state, can only tell "this database never ran a copy-and-swap" from a real failure by calling Ensure, which needs CREATE, or by matching the SQLSTATE itself. The cheap option is for Load's doc comment to say that Ensure must run first. The adapter-friendly option is a typed sentinel for "no checkpoint table", kept distinct from ErrNotFound, so resume still refuses and a status reader can say "none".

3. The row format is versioned, but the table's shape is not. store.go:124-139

format_version protects a row's meaning. The columns themselves come from CREATE TABLE IF NOT EXISTS, which never alters a table that already exists. The first change to the columns will therefore need Ensure to evolve the table. Meanwhile an engine that meets a table written by another version will fail loadSQL with an untyped 42703, not the IncompatibleError{Mismatch: MismatchFormat} the design promises. This is latent until the format first changes. Committing now to additive-only columns, and recording the table's own format somewhere Ensure can check, would keep mixed-version fleets on the typed path.

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.

🤖 Approving 9b5bd63 with 0 blocking findings. The guarded upsert keeps one row per target, and a refused save leaves the row untouched (ST-1). Load keeps resume, no row, incompatible and a failed read apart (ST-2). I checked both on real PostgreSQL and with mutations. The 1/2 comment has 5 non-blocking findings. The main one: Save and Delete don't take the table lock, so a stale writer of the same statement can overwrite newer state, and the signature is cheapest to change before the orchestrator calls it. The others: Ensure trusts a pgsprite schema another role owns; the concurrency test passes without the advisory lock; two upsert columns go unchecked; and the retry docs drifted. The 2/2 comment has 3 non-blocking adoption notes.

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

…oint

* origin/main:
  schemachange: swap the verified shadow in under a bounded lock (D5, D8, D9, LK-2, LK-4) (#139)

# Conflicts:
#	docs/copy-and-swap-design.md
…, type the missing table

Save and Delete now take the target's *dbconn.TableLockSession, run under
its Bind context, and confirm from their own transaction that the session
still holds the table (LK-1). Two runs of the same statement share
fingerprints, so the guard alone could not stop a run whose lock had moved
to another engine from stamping stale state over the newer row.

Delete becomes a compare-and-delete on the row's Identity (format version
and both fingerprints) — the one the caller was shown as
IncompatibleError.Stored or as a loaded Checkpoint's Identity(). A row
another engine wrote in the meantime survives and comes back as a new
IncompatibleError; no row is still the idempotent success.

Ensure reads the owners of the engine schema and the checkpoint table from
pg_catalog under the advisory key before creating anything, and creates
only what is absent. A schema or table another role owns, or a relation
under the table's name that is not a plain table, is refused with
ErrForeignObject instead of adopted — a foreign trigger would otherwise
run with the engine's privileges on every Save. Because CREATE SCHEMA
checks database CREATE before existence, this also lets a deployment
pre-provision both objects for an engine role that never holds CREATE;
docs/engine-role.md describes that path.

Load, Save and Delete report a database where Ensure never ran as
ErrTableMissing, distinct from ErrNotFound and still carrying the
server's 42P01.

Tests: Ensure waits behind an uncommitted first create (fails without the
advisory lock), accepts a pre-provisioned table without database CREATE,
refuses a foreign-owned schema, a foreign-owned table, and a view under
the table's name; Save and Delete refuse a missing lock and a lock another
backend holds; Delete leaves a row the caller was not shown; the upsert
asserts publication_name and shadow_table; ParseLSN pins the 32-bit high
half.
@Kiran01bm

Copy link
Copy Markdown
Collaborator Author

🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/141, follow-up commit

Seven of the eight non-blocking findings are fixed in this PR; one (table-shape versioning) is tracked as an internal follow-up. The reviewer's TestEnsureWaitsForAConcurrentFirstCreate was taken as written, and go test -race -count=1 ./pkg/checkpoint/ ./pkg/decode/ is green with the two concurrency tests passing 5/5 under scripts/test-flaky.sh. The branch was also merged forward with main after #139 landed (one doc conflict in the design-doc package map).

# Finding Status Explanation
C1-1 The guard keys on the statement, not on the run that holds the table, so a stale writer of the same statement overwrites newer state (LK-1, OC-4) fixed Taken as proposed. Save(ctx, lock *dbconn.TableLockSession, cp) and Delete(ctx, lock, schema, table, stored Identity) refuse ErrInvariantViolation (LK-1) without a session, with a session for another table, or with a session that has reported loss; both run under lock.Bind and call lock.Confirm from the write's own transaction (new lock.go), so a server that says "no one" or "another backend" is the store's invariant violation. Delete is a compare-and-delete on the row's Identity (format_version + both fingerprints) — the one the caller was shown as IncompatibleError.Stored or as Checkpoint.Identity(); zero rows → read back → no row is the idempotent success, a differing row is a fresh *IncompatibleError. Tests: TestSaveAndDeleteRequireTheTargetsTableLock, TestSaveRefusesWhenAnotherBackendHoldsTheTable (session backend terminated, another backend takes the key → ErrInvariantViolation wrapping *dbconn.TableLockHeldError; a mutant that skips the confirm fails it), TestDeleteRefusesARowTheCallerWasNotShown. The probe's stale save is now refused at requireTableLock. The optional shadow-OID guard is not stored: the lock requirement already denies the stale writer, and InspectShadow proves the shadow against the model on resume; revisit with the orchestrator if a case appears that neither covers. LK-1's Enforced list and SAFETY.md's pkg/checkpoint row now name the checkpoint writes.
C1-2 Ensure accepts a pgsprite schema and table another role owns, and that role's trigger then runs as the engine on every Save fixed New ensure.go: under the advisory key, ownersSQL reads current_user, the schema owner, and the relation kind and owner of whatever wears the table's name (LEFT JOIN pg_class). A schema or table owned by another role, or a relation under the name that is not relkind = 'r', is refused with ErrForeignObject; nothing is created in that case. Ownership is strict current_user equality, as in DropShadow. Tests: TestEnsureRefusesASchemaAnotherRoleOwns, TestEnsureRefusesATableAnotherRoleOwns, TestEnsureRefusesAViewUnderTheTableName.
C1-3 TestEnsureSerializesConcurrentFirstUse passes without the advisory lock fixed Replaced by the reviewer's TestEnsureWaitsForAConcurrentFirstCreate (named deadlines, pg_locks wait check). Verified the mutant: with ensureLockSQL replaced by a no-op the test fails with 23505 on pg_namespace_nspname_index; at head it passes 5/5 under scripts/test-flaky.sh.
C1-4 Two SET columns are never checked after an upsert; ParseLSN's 32-bit high bound is unpinned fixed TestSaveUpsertsTheOneRowPerTarget asserts PublicationName and ShadowTable after the second save; "100000000/0" is added to TestParseLSN's bad inputs in pkg/decode.
C1-5 Two descriptions of the retry classifier are wrong fixed The design-doc package-map row now says Load retries through the package's readRetryable (dbconn.Retryable plus the server-ended-session codes) and names the lock requirement, ErrTableMissing, ErrForeignObject, and Identity. The PR body no longer claims the codes "come out of" dbconn.Retryable; it states that pkg/dbconn is unchanged and only the read widens the set.
C2-1 A least-privilege deployment cannot pre-provision the table: Ensure demands database CREATE even when everything exists fixed Same change as C1-2. Ensure creates only what the catalog read says is absent (CREATE SCHEMA / CREATE TABLE without IF NOT EXISTS), so a pre-provisioned schema and table owned by the engine role are accepted with no database CREATE. docs/engine-role.md lists pre-provisioning as the alternative to granting CREATE: run first use as a role that holds it, then ALTER SCHEMA pgsprite OWNER TO <engine> and ALTER TABLE pgsprite.pgsprite_checkpoint OWNER TO <engine>. Test: TestEnsureAcceptsAPreProvisionedTableWithoutDatabaseCreate (a LOGIN role with no CREATE on the database owns both objects; Ensure then Save succeed as that role).
C2-2 Load on a database where Ensure never ran is an untyped 42P01 fixed ErrTableMissing sentinel, distinct from ErrNotFound; missingTable(err) wraps the server's 42P01 so errors.As(*pgconn.PgError) still works, applied in Load, Save, and Delete. Load's doc comment names the outcome. Test: TestWritesAndReadsWithoutEnsureReportTheMissingTable.
C2-3 The row format is versioned, but the table's shape is not deferred Agreed. Committing to additive-only columns and recording the table's own format where Ensure can check it is a change to how Ensure evolves the table, not to the row contract this PR lands; nothing changes the columns yet. Tracked as an internal follow-up and noted in the PR body's "Decisions worth a look".
Verified ST-1 upheld and live; ST-2 upheld with the read-side widening pinned by TestLoadRetriesAcrossATerminatedBackend no action Unchanged. The three guard mutations in the review's table are still caught by TestSaveRefusesToOverwriteAnotherStatementsRow.

Source: block/pg-sprite#141, review comments 5986357613 and 5986358273, review 5409090188 at head 9b5bd63; fixes in the follow-up commit.

@Kiran01bm
Kiran01bm enabled auto-merge (squash) October 5, 2026 01:51
@Kiran01bm
Kiran01bm merged commit 1f46fb9 into main Oct 5, 2026
16 checks passed
@Kiran01bm
Kiran01bm deleted the kiran01bm/cs10-checkpoint branch October 5, 2026 01:54
Kiran01bm added a commit that referenced this pull request Oct 5, 2026
…m-progress

* origin/main:
  checksum: digest chunks with SHA-256 instead of md5 so a FIPS-mode OpenSSL does not fail the pass (#143)
  checkpoint: persist the one resume row per target with a guarded upsert and a typed load (#141)

# Conflicts:
#	docs/copy-and-swap-design.md
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.

2 participants