v0.3.0
Concurrent index builds learn to run under an orchestrator — a caller-owned deadline instead of a fixed server timeout, a gated stop path for operators, lock-aware progress, and a proven recovery for the invalid index a dead build leaves behind. Around that, pg-sprite pull baselines a live schema into desired files, greenfield plans disclose exactly what the create path runs, and the create path refuses shape and name problems before anything executes.
Highlights
Concurrent index builds an orchestrator can own
ConcurrentBudget.CallerOwnedrunsCREATE INDEX CONCURRENTLYwithstatement_timeout = 0and the caller's cancellable context as the statement's only bound — for a build that should live as long as a lease can be renewed rather than a fixed number of minutes. A context that cannot be cancelled is refused before any session is acquired, so the statement stays bounded by construction;lock_timeoutstays disabled in both modes, and both settings are reset before the session returns to the pool. The bounded mode and every existing caller are unchanged. (#76, #78)- Cancellation is a three-way partition in both modes: the caller's own context ending is
cancelled-by-callerwhichever of the server's 57014 or the client's context error arrives first; a 57014 under a live context iscancelled-externally; a 57014 at the bounded deadline is a*BudgetError. An orchestrator can now tell my lease lapsed, an operator stopped it, and the server's budget killed it apart. (#76, #78) progress.Tracker.CancelBuild(ctx)is the operator's stop path:pg_cancel_backendover the tracker's reserved session, under the lock that guards the build's lifecycle, only while the build is active and only against a backend that is running a statement. The backend PID never leaves the tracker. (#76, #78)- Progress snapshots report lockers:
work.lockers_total,work.lockers_done,detail.current_locker_pidfrompg_stat_progress_create_index, so a build stuck in the "waiting for old snapshots" phase names what it is waiting on. Snapshots also carrydetail.statement, the canonical SQL of the step in flight — including on the terminalfailedsnapshot. (#72, #76)
Proven recovery of abandoned invalid indexes
executor.RebuildAbandonedIndex(library-only) removes and rebuilds the invalid index a dead concurrent build leaves behind, after proving abandonment: a boundedLOCK TABLE … IN SHARE UPDATE EXCLUSIVE MODEexcludes any live concurrent index command — visible or hidden — then the entry is re-verified by OID, quarantined aspgsprite_abandoned_<oid>, dropped withDROP INDEX CONCURRENTLYunder OID verification, and the index rebuilt. The result is anIndexRecoveryReport. (#79)- The classifier never reads an unobservable builder as absent.
pg_stat_progress_create_indexhides another role's command withoutpg_read_all_statsand records nothing undertrack_activities = off; that case is now its own code,invalid-index-builder-unobservable, and the lock proof decides. An invalid entry the server will not drop concurrently — a partitioned table's index (invalid by design when builtON ONLY), an index partition, or a constraint's index — isinvalid-index-not-droppableand is left for an operator, never quarantined.invalid-index-preexistingis gone — see upgrade notes. (#79)
Baseline export: pg-sprite pull
pg-sprite pull --url=… [--schema public] [-o schema]writes one desired-state.sqlfile per ordinary or partitioned table in a schema, with per-tablePULLED/REFUSED/ERRORoutcomes and a summary — not fail-fast. Constructs the declarative model cannot express refuse per table. The demo tour proves the loop:pull, thendiff --jsonagainst the export returns zero statements. (#67, #74)
Greenfield plans say what will run
- Greenfield plan statements disclose the create path's execution:
exec_sqlis the statement as written, decisions aremetadata-only, and noCONCURRENTLYsubstitution is shown for a table that does not exist yet — the create path runs the desired file's builds verbatim under boundedlock_timeout/statement_timeout. The disclosure is a no-op unless the report proves the table absent, so a live table's online sequence cannot be collapsed. (#69) - Shape refusals surface at plan time:
PARTITION OF,INHERITS,LIKE,OF,IF NOT EXISTS, and a set that claims the same relation name twice are refused on the plan asunsupported-statement, from the same rules the executor applies at admission;diff --sqlcomments out the refused statement with its cause. (#70) - Claimed relation names are proven free before the first step: explicit index names, the first-choice name of each
PRIMARY KEY/UNIQUE/EXCLUDEconstraint index, and eachserial/ identity column's<table>_<column>_seqare probed inpg_class; an occupant is acreate-collisionwith nothing executed. (#71)
Verified against Aurora-shaped failures
- The AWS-boundary tier now pins reader read-only behaviour (SQLSTATE 25006 is terminal), connection loss mid-change (terminal
execution-failed, no catalog trace), and metadata failover with an open writer session. (#75)
Upgrade notes
- Progress report
format_versionmoves from 1 to 3. New fields:detail.statement;work.lockers_total,work.lockers_done,detail.current_locker_pid. A strict consumer should re-sync from docs/progress-report.md. - Greenfield plan fingerprints change value (the fingerprint hashes
exec_sql, which now commits to what runs).format_versionstays 2. A consumer holding a pre-upgrade greenfield fingerprint gets oneplan-fingerprint-mismatchand re-plans; fingerprints for existing tables are unaffected. - Implicit-name collisions are now refused — a policy change. A desired file whose
PRIMARY KEY/UNIQUEindex orserialsequence first-choice name is already taken used to converge with a server-suffixed name (t_pkey1,t_id_seq1); it now refuses withcreate-collisionuntil the occupant is dropped or renamed, the constraint's index is named explicitly, or the column uses a named sequence or non-serial type. - Outcome-code vocabulary changed — a consumer with a total switch over
executor.Codes()will not build until it routes the new members.invalid-index-preexistingis removed;invalid-index-build-in-flight,invalid-index-abandoned,invalid-index-other-table,invalid-index-not-droppableandinvalid-index-builder-unobservableare added alongside the existinginvalid-index-own-leftoverandinvalid-index-unproven, andcancelled-by-callerjoinscancelled-externally. The invalid-index family is not one retry class:Code.Permanent()says which members a retry unchanged would reproduce (other-table,not-droppable) and which wait on a recovery, a builder, or a re-taken proof; see docs/invalid-index-recovery.md.cancelled-by-calleris the caller's own context ending and belongs with the operational outcomes, not the refusals. - Library API.
progress.Tracker.StartSteptakes the step's statement (StartStep(step, operation, statement)).ConcurrentBudgetgainsCallerOwned(mutually exclusive withOverall;ErrCallerOwnedOverallBudget,ErrCallerOwnedNeedsCancellableContext).statement.ImplicitIndexNamesis renamedstatement.ImplicitRelationNamesand now also returns the sequence names aserial/ identity column owns. New:executor.RebuildAbandonedIndex,executor.CreateShapeRefusals,preflight.CheckNamesAbsent,plan.DiscloseGreenfieldExecution,plan.RefuseUnsupportedCreateShape,progress.Tracker.CancelBuild. New error sentinels:ErrCancelledByCaller,ErrNoActiveBuild,ErrBuildNotRunning,ErrUnboundedBudget. - Refusal-reason vocabulary:
unsupported-statementnow also covers plan-time create-shape refusals;create-collisionnow also covers index, constraint-index and sequence names, not only the table.
Docs
- docs/capabilities.md — the canonical matrix, now with an Engine path column per row, a
DROP TABLErow, and who owns undeclared tables (#66, #77) - docs/pull.md — baseline export and the zero-diff verification loop (#74)
- docs/invalid-index-recovery.md — the seven-code ownership model,
Code.Permanent(), and what recovery handles (#79) - docs/execution-model.md — caller-owned builds and the cancellation partition (#76, #78)
- docs/mysql-vs-postgresql.md, docs/binary-coercible-type-changes.md — MySQL comparison, three-bucket cost model, when
ALTER COLUMN TYPErelabels versus rewrites (#68, #73)
Contributors
Full changelog: v0.2.0...v0.3.0