executor

package
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 16, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package executor runs schema changes against the database. It holds the optimistic front door — attempt the change directly under a tight lock_timeout and statement_timeout so it can only succeed if it is effectively an instant / in-place change; a budget overrun cancels the statement cleanly, nothing is executed, and a typed BudgetError surfaces for the caller to turn into a not-native-safe verdict — and the native executors for the classified safe idioms, starting with the concurrent index build (BuildIndexConcurrently). The files of the concurrent index creation path — the build, its budget, its typed refusals, the backend inspection, and the invalid-index outcome — carry the cic_ prefix.

This is a safety-critical core package: see SAFETY.md. It never trusts the caller's classification — its own protections are the budget, applied with SET LOCAL inside the attempt's transaction regardless of session defaults, and the statement binding: it accepts only a parsed statement.Statement (exactly one statement by construction) whose target matches the preflighted table (invariant ST-7).

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidBlockingBudget means an accepted-blocking bound would be
	// disabled or cannot be represented by PostgreSQL.
	ErrInvalidBlockingBudget = errors.New("invalid accepted-blocking budget")
	// ErrUnsupportedAcceptedBlocking means the statement is not one of the
	// single-relation blocking index forms this executor admits, or the
	// server will not run it inside the engine-owned transaction. Retrying
	// the same statement reproduces it; the outcome is permanent.
	ErrUnsupportedAcceptedBlocking = errors.New("statement is not an accepted blocking index statement")
)
View Source
var (
	// ErrNotConcurrentIndexBuild is returned when the statement is not a
	// single CREATE INDEX ... CONCURRENTLY.
	ErrNotConcurrentIndexBuild = errors.New("statement is not a CREATE INDEX CONCURRENTLY")
	// ErrUnnamedIndex is returned when the concurrent build does not name
	// its index: without a deterministic identity there is no idempotent
	// way to find and recover the invalid leftover of a failed build.
	ErrUnnamedIndex = errors.New("concurrent index build must name its index")
	// ErrUnqualifiedTable is returned when the target table is not
	// schema-qualified. The post-failure catalog verdict re-resolves the
	// table on another session, where search_path cannot be proven
	// identical to the build session's — an unqualified name could resolve
	// to a different table and turn the verdict into a false clean.
	ErrUnqualifiedTable = errors.New("concurrent index build must schema-qualify its table")
	// ErrIfNotExistsUnsupported is returned for any CREATE ... IF NOT
	// EXISTS. The clause checks only the name: it succeeds as a no-op
	// while an unrelated relation — or, for a concurrent build, an
	// invalid index — owns that name, so an executor could report
	// success over a relation it cannot vouch for.
	ErrIfNotExistsUnsupported = errors.New(CreateShapeIfNotExists.Description())
	// ErrInvalidIndexBuildInFlight is returned (inside an *InvalidIndexError
	// carrying the builder's PID) when the invalid index under the
	// requested name is another backend's concurrent build still in
	// progress: pg_stat_progress_create_index reports a live command on
	// that exact index OID. A concurrent build is invalid until it
	// finishes, so the entry may be healthy and hours in; nothing here may
	// touch it, and the build refuses because the name is occupied. The
	// answer is to wait for the builder to finish or fail (see
	// docs/invalid-index-recovery.md).
	ErrInvalidIndexBuildInFlight = errors.New("an invalid index with this name is another backend's concurrent build still in progress")
	// ErrAbandonedInvalidIndex is returned (inside an *InvalidIndexError)
	// when an invalid index under the requested name sits on the target
	// table with no backend building it: the debris of an earlier failed
	// build, this executor's or anyone's. It cannot become valid on its
	// own, so the build refuses — the name is occupied — but the state is
	// recoverable: RebuildAbandonedIndex proves the abandonment under lock,
	// removes the entry, and builds the requested index.
	ErrAbandonedInvalidIndex = errors.New("an abandoned invalid index with this name already exists on the target table")
	// ErrInvalidIndexOnOtherTable is returned (inside an *InvalidIndexError)
	// when the invalid index under the requested name sits on a different
	// table in the target schema, with no backend building it. A failed
	// build's debris carries the requested name whatever table it ended up
	// on, so the build refuses; but another table's debris is not this
	// change's to remove — the recovery refuses it too, and that table's
	// own change recovers it.
	ErrInvalidIndexOnOtherTable = errors.New("an invalid index with this name exists on a different table in the target schema")
	// ErrInvalidIndexNotDroppable is returned (inside an *InvalidIndexError)
	// when the invalid index under the requested name sits on the target
	// table but is not an index DROP INDEX CONCURRENTLY can remove: a
	// partitioned table's index (invalid by design until every partition
	// has an attached index, the documented CREATE INDEX ... ON ONLY
	// workflow), an index partition attached to such a parent, or an index
	// backing a constraint. None of these is the debris of a failed
	// concurrent build whatever its validity flag says, and no recovery
	// here can be proven through to a removal, so the build refuses and
	// the recovery leaves the entry exactly as it found it — only an
	// operator who knows what the index is can decide.
	ErrInvalidIndexNotDroppable = errors.New("an invalid index with this name exists on the target table and is not a droppable index: it is a partitioned table's index, an index partition, or backs a constraint")
	// ErrInvalidIndexBuilderUnobservable is returned (inside an
	// *InvalidIndexError) when an invalid index under the requested name
	// sits on the target table, no backend is visibly building it, and
	// this session cannot vouch for that silence: pg_stat_progress_create_index
	// withholds every column but the PID and database of another role's
	// command from a reader without pg_read_all_stats, and records nothing
	// at all for a session running with track_activities off. The entry may
	// be abandoned or may be a build in progress that this role cannot see,
	// so the build refuses without claiming either. The state is still
	// recoverable: RebuildAbandonedIndex proves abandonment under the table
	// lock every concurrent index command holds, which no visibility rule
	// can hide. An operator with pg_read_all_stats can classify it exactly.
	ErrInvalidIndexBuilderUnobservable = errors.New("an invalid index with this name exists on the target table and this role cannot observe whether a backend is building it")
	// ErrBuildLeftInvalidIndex is returned (inside an *InvalidIndexError)
	// when this executor's own build left an invalid catalog entry behind:
	// after a failure, once the build's backend provably stopped, or after
	// a reported success whose validity verification found the entry
	// invalid. The build never removes it in the same call: PostgreSQL
	// drops by name, not identity, so a drop without a fresh ownership
	// proof could destroy another actor's index registered under the same
	// name in the same window. The recovery is RebuildAbandonedIndex, which
	// carries that proof, or the operator's explicit DROP INDEX
	// CONCURRENTLY (see docs/invalid-index-recovery.md).
	ErrBuildLeftInvalidIndex = errors.New("the build left an invalid index behind")
	// ErrAbandonmentUnproven is returned (inside an *InvalidIndexError)
	// when a recovery could not carry its proof through to the removal:
	// the entry changed name or identity between two verification points,
	// or the drop left it in place. Nothing was destroyed; the state is
	// indeterminate and fails closed, and a later recovery starts over.
	ErrAbandonmentUnproven = errors.New("the invalid index could not be proven abandoned through to its removal")
	// ErrTargetIdentityChanged is returned (inside an *InvalidIndexError)
	// when the target table no longer resolves to the OID the build was
	// admitted against: it was dropped, replaced, or renamed while the
	// build ran. The catalog can no longer prove whether the failed build
	// left debris — the verdict is indeterminate, and indeterminate fails
	// closed.
	ErrTargetIdentityChanged = errors.New("the target table was dropped or replaced during the build: the catalog cannot prove whether the failed build left an invalid index")
	// ErrTableNotFound is returned when the statement's qualified table
	// resolves to nothing before the build starts; it distinguishes a
	// missing table from an inspection failure so callers can branch with
	// errors.Is instead of matching message text.
	ErrTableNotFound = errors.New("table not found")
	// ErrPoolTooSmall is returned at admission when the pool cannot hold
	// every session the operation needs at once — for a build, the build
	// session and the verdict session (buildMinConns); for a rebuild
	// recovery, its own session on top of those (recoveryMinConns); for a
	// drop-only recovery, its own session and one drop session
	// (dropRecoveryMinConns). The message names the sessions the refused
	// operation holds at its peak. The verdict is a correctness
	// dependency, not a nicety: without a reserved connection, every
	// failed build would resolve indeterminate, and a pool one connection
	// short would not fail but wait on itself for as long as the caller's
	// context allows. Like an unbounded budget, an unusable pool is
	// refused by construction.
	ErrPoolTooSmall = errors.New("the pool cannot hold every session the operation needs at once")
	// ErrCallerOwnedNeedsCancellableContext is returned when caller-owned
	// mode has no cancellation signal to bound the statement.
	ErrCallerOwnedNeedsCancellableContext = errors.New("a caller-owned build needs a cancellable context: with statement_timeout disabled the context is the statement's only bound")
	// ErrCallerOwnedOverallBudget is returned when a caller-owned budget
	// also carries a server deadline: the two bounds would contradict each
	// other, so the combination is refused before any session is acquired.
	ErrCallerOwnedOverallBudget = errors.New("a caller-owned build has no server deadline: Overall must be zero")
	// ErrUnboundedBudget is returned when the overall budget would leave
	// the statement without a working statement_timeout.
	ErrUnboundedBudget = errors.New("the overall budget would leave the statement unbounded")
	// ErrCancelledByCaller is returned when the build's statement was
	// cancelled because the caller's own context ended — cancelled or past
	// its deadline — while the statement ran. The cancellation reaches the
	// executor in one of two forms, the server's SQLSTATE 57014 or the
	// client's context error, and both are typed the same way: the cause is
	// the caller, in either budget mode — with one precedence in bounded
	// mode: a 57014 arriving once the overall budget has elapsed is the
	// budget's statement_timeout firing and is typed *BudgetError even if
	// the caller's context ended in the same instant, so the server's 57014
	// is the caller's only below the budget. An orchestrator reads this
	// error as its own lease lapsing, not as an operator's intervention
	// (ErrCancelledExternally).
	ErrCancelledByCaller = errors.New("the build was cancelled by its caller's context")
	// ErrCancelledExternally is returned when the build's statement was
	// cancelled (SQLSTATE 57014) while the caller's context was still live
	// and, in bounded mode, before its overall budget elapsed: neither the
	// caller nor the executor's statement_timeout can have done it, so the
	// cancellation came from outside — an operator's pg_cancel_backend, an
	// administrative tool. In caller-owned mode there is no server deadline,
	// so every 57014 under a live context is external. It is deliberately
	// not a *BudgetError: a budget exhaustion invites escalation to a
	// heavier strategy, while a deliberate cancel usually means the change
	// should be left alone.
	ErrCancelledExternally = errors.New("the build was cancelled from outside the executor")
)

Typed admission refusals for the concurrent index build. The executor re-verifies the statement shape itself — a caller's classification is a request, not a proof (see SAFETY.md).

View Source
var (
	// ErrCreateCollision is returned when a claimed name is already taken.
	// A catalog probe after admission checks the desired file's explicit
	// index names and first-choice constraint-index and sequence names before
	// the first step executes, refusing the whole set with no *SequenceStepError;
	// duplicate-name SQLSTATEs backstop time-of-check races for explicit
	// names and arrive wrapped in one. Server-chosen names have no such
	// backstop. The remedy is on the desired file's side — drop or rename the
	// occupant, name a constraint's index explicitly, or for a sequence use
	// an explicitly named sequence or a non-serial column —
	// then re-diff; a re-plan alone reproduces the refusal.
	ErrCreateCollision = errors.New("a name the create path needs is already taken")
	// ErrDuplicateCreateName is returned when the desired set claims the
	// same relation name twice — two indexes under one name, or an index
	// named after the table. The conflict is decidable before anything
	// runs, so admission refuses the whole set rather than letting a
	// mid-run step fail after a prefix committed.
	ErrDuplicateCreateName = errors.New(CreateShapeDuplicateName.Description())
	// ErrPartitionOfUnsupported is returned for CREATE TABLE ... PARTITION
	// OF: attaching a partition takes a lock on the partitioned parent,
	// an existing table the absence proof says nothing about.
	ErrPartitionOfUnsupported = errors.New(CreateShapePartitionOf.Description())
	// ErrUnsupportedCreateStep is returned when a desired statement is not
	// a shape the create path can run: a plain CREATE TABLE or a plain
	// CREATE INDEX on the new table. CONCURRENTLY is refused deliberately —
	// the table is born this run with no traffic to protect, and a plain
	// build cannot leave an INVALID index behind a failure.
	ErrUnsupportedCreateStep = errors.New("statement is not a shape the create path can run")
	// ErrCreateNameMismatch is returned when the CREATE TABLE committed but
	// the table does not own a first-choice relation name the desired file
	// claimed. PostgreSQL never raises a duplicate-name error for a
	// constraint index or column-owned sequence whose first choice is taken
	// — it appends a numeric suffix — so an occupant that took the name
	// between the catalog probe and the step is visible only afterwards,
	// in the names the table actually owns. The run stops at the CREATE
	// TABLE with the table left in place: dropping a table this executor
	// just created is a destructive action the create path never takes.
	// The error arrives wrapped in a *SequenceStepError at step 1 as a
	// *CreateNameMismatchError naming the claimed names the table lacks
	// and the names it owns instead. The remedy is an operator's: free the
	// first-choice name and rename the owned relation to it, or drop the
	// table, then re-diff.
	ErrCreateNameMismatch = errors.New("the CREATE TABLE committed but the table does not own a first-choice relation name the desired file claims")
	// ErrCreateNamesUnverified is returned when the CREATE TABLE committed
	// but the read of the relation names the table owns did not complete,
	// so whether every first-choice claim was honoured is unknown. It wraps
	// the read's own error as the cause — a cancelled context, a lost
	// connection, a table no longer at its name — and arrives in a
	// *SequenceStepError at step 1 under its own code, with the table left
	// in place; an unproven name set is not a passing one, and the executor
	// never drops a table it has just created.
	ErrCreateNamesUnverified = errors.New("the CREATE TABLE committed but the relation names the table owns could not be read")
	// ErrCreateOwnerMismatch is returned when the committed table's catalog
	// owner differs from the creation-role proof. The executor never repairs
	// this invariant with ALTER OWNER.
	ErrCreateOwnerMismatch = errors.New("the CREATE TABLE committed with an unexpected owner")
	// ErrCreateOwnerUnverified is returned when the committed table's owner
	// could not be read back. The table is committed either way, and an
	// unproven owner is not a passing one.
	ErrCreateOwnerUnverified = errors.New("the CREATE TABLE committed but its owner could not be read")
)

Typed refusals and failures for the create path. Admission covers every desired statement before the first executes, so a creation this executor cannot finish is never started.

View Source
var (
	// ErrEmptySequence is returned for a sequence with no steps: reporting
	// success over nothing executed would be a false proof.
	ErrEmptySequence = errors.New("sequence has no steps")
	// ErrUnsupportedSequenceStep is returned when a step is not one of the
	// shapes this executor can run safely: an ALTER TABLE step, or a
	// CREATE INDEX CONCURRENTLY step. The concurrent forms of other
	// statements are refused deliberately — a DROP INDEX CONCURRENTLY or
	// REINDEX CONCURRENTLY is not driven yet, and a cancelled
	// DETACH PARTITION CONCURRENTLY leaves a detach-pending partition
	// state this executor does not own detecting or recovering.
	ErrUnsupportedSequenceStep = errors.New("step is not a shape the sequence executor can run safely")
	// ErrUnsupportedPartitionedParent is returned when the target is a
	// partitioned parent and the admitted sequence is not supported there.
	ErrUnsupportedPartitionedParent = errors.New("sequence is not supported on a partitioned parent")
)

Typed admission refusals for the sequence executor. Admission covers the whole sequence before the first step executes, so a sequence that cannot be finished is never started.

View Source
var ErrInvariantViolation = errors.New("invariant violation")

ErrInvariantViolation is the fail-closed error class for breaches of the registry in docs/invariants.md (see SAFETY.md); the message names the invariant ID. It is never a warning and never retried.

Functions

func CreateShapeRefusals added in v0.3.0

func CreateShapeRefusals(schema string, ds statement.DesiredSchema) ([]error, error)

CreateShapeRefusals checks the connection-free create-path rules in desired statement order. A nil entry is admitted; a non-nil entry identifies the shape refusal for that statement. Parse failures and violated DesiredSchema invariants are returned separately because no positional plan is safe.

func ExecuteNative

func ExecuteNative(ctx context.Context, pool *pgxpool.Pool, pt preflight.PreflightedTable, st statement.Statement, b Budget, retry RetryPolicy) error

ExecuteNative runs transactional native DDL under transaction-local budgets and retries only lock_timeout failures, bounded by retry. The table must have passed preflight and the statement must target it — both proofs make the unsafe call unrepresentable: a statement.Statement can only come from ParseOne (exactly one statement, parsed by the real grammar), and a target mismatch is refused before anything executes, so a proof for one table cannot smuggle SQL against another. Each attempt is a new transaction, so neither an aborted transaction nor its settings can leak through the pool. When the proof carries a schema, each attempt runs with search_path pinned to that schema then public, so the statement's unqualified secondary names resolve in the target schema — the same resolution the create path and the introspection read path use. On success the change is committed: it was effectively instant. If the lock budget is exhausted across all bounded attempts, a *BudgetError carrying the attempt count is returned. Statement timeouts and all other failures return immediately: repeating work that exceeded its execution budget is not a lock-acquisition strategy.

func ExecuteNativeWithProgress

func ExecuteNativeWithProgress(ctx context.Context, pool *pgxpool.Pool, pt preflight.PreflightedTable, st statement.Statement, b Budget, retry RetryPolicy, tracker *progress.Tracker) (err error)

ExecuteNativeWithProgress runs an optimistic native attempt while updating tracker. The caller may poll tracker concurrently with this blocking call.

Types

type BlockingBudget added in v0.3.3

type BlockingBudget struct {
	LockTimeout      time.Duration
	StatementTimeout time.Duration
}

BlockingBudget bounds one operator-accepted blocking statement.

type BlockingExecutionError added in v0.3.3

type BlockingExecutionError struct{ Err error }

BlockingExecutionError reports a PostgreSQL failure after the accepted statement was submitted. Err retains the server SQLSTATE.

func (*BlockingExecutionError) Error added in v0.3.3

func (e *BlockingExecutionError) Error() string

func (*BlockingExecutionError) Unwrap added in v0.3.3

func (e *BlockingExecutionError) Unwrap() error

type BlockingOutcomeUnknownError added in v0.3.3

type BlockingOutcomeUnknownError struct{ Err error }

BlockingOutcomeUnknownError means the client cannot establish whether the transaction committed; the catalog must be inspected before retrying.

func (*BlockingOutcomeUnknownError) Error added in v0.3.3

func (*BlockingOutcomeUnknownError) Unwrap added in v0.3.3

func (e *BlockingOutcomeUnknownError) Unwrap() error

type BlockingReport added in v0.3.3

type BlockingReport struct {
	SQL              string        `json:"sql"`
	LockTimeout      time.Duration `json:"lock_timeout_ns"`
	StatementTimeout time.Duration `json:"statement_timeout_ns"`
	Duration         time.Duration `json:"duration_ns"`
}

BlockingReport records a committed accepted-blocking statement and its engine-owned bounds.

func ExecuteAcceptedBlocking added in v0.3.3

func ExecuteAcceptedBlocking(ctx context.Context, pool *pgxpool.Pool, sql string, b BlockingBudget) (BlockingReport, error)

ExecuteAcceptedBlocking runs one admitted blocking index statement in one engine-owned transaction with explicit lock and statement bounds.

type Budget

type Budget struct {
	// LockTimeout bounds how long the attempt may wait in the lock queue.
	LockTimeout time.Duration
	// StatementTimeout bounds how long the statement may run once started.
	StatementTimeout time.Duration
}

Budget bounds one optimistic attempt. Both limits must be at least minBudget: an unbounded attempt is exactly the stall the front door exists to prevent.

type BudgetCause

type BudgetCause int

BudgetCause says which budget the optimistic attempt exceeded.

const (
	// CauseLock means the lock was not granted within lock_timeout: the
	// table is too contended for a blind attempt right now.
	CauseLock BudgetCause = iota + 1
	// CauseStatement means the statement ran past statement_timeout: the
	// change is doing real work (a rewrite), not an in-place catalog change.
	CauseStatement
)

The two budgets an attempt runs under.

func (BudgetCause) String

func (c BudgetCause) String() string

String returns the human-readable budget name.

type BudgetError

type BudgetError struct {
	// Cause is the budget that was exceeded.
	Cause BudgetCause
	// Budget is the configured limit for that cause.
	Budget time.Duration
	// Attempts is the number of bounded transactions tried. It is greater
	// than one when lock acquisition retries were exhausted.
	Attempts int
	// contains filtered or unexported fields
}

BudgetError reports that an execution attempt exceeded one of its budgets and was cancelled cleanly by the server: the statement did not take effect. It is a refusal input, not an operational failure.

func (*BudgetError) Code

func (e *BudgetError) Code() Code

Code returns the budget outcome's stable code.

func (*BudgetError) Error

func (e *BudgetError) Error() string

Error implements the error interface.

func (*BudgetError) Unwrap added in v0.3.0

func (e *BudgetError) Unwrap() error

Unwrap exposes the server error behind the budget verdict.

type Code

type Code string

Code is the stable string identity of one executor outcome; automation branches on it, never on error text. Codes are part of the report contract: existing values never change meaning, new outcomes add new codes.

const (
	// CodeBudgetLockExceeded: the lock was not granted within
	// lock_timeout; nothing was executed.
	CodeBudgetLockExceeded Code = "budget-lock-exceeded"
	// CodeBudgetStatementExceeded: the statement ran past
	// statement_timeout and was cancelled; the change does real work.
	CodeBudgetStatementExceeded Code = "budget-statement-exceeded"
	// CodeBlockingOutcomeUnknown requires catalog inspection before retry.
	CodeBlockingOutcomeUnknown Code = "blocking-outcome-unknown"
	// CodeInvalidBlockingBudget identifies an unrepresentable or disabled bound.
	CodeInvalidBlockingBudget Code = "invalid-blocking-budget"
	// CodeUnsupportedAcceptedBlocking identifies a statement outside this narrow path.
	CodeUnsupportedAcceptedBlocking Code = "unsupported-accepted-blocking"
	// CodeCancelledByCaller: the build's statement was cancelled because
	// the caller's own context ended while it ran.
	CodeCancelledByCaller Code = "cancelled-by-caller"
	// CodeCancelledExternally: the build's statement was cancelled from
	// outside the executor — not by its caller, not by its budget.
	CodeCancelledExternally Code = "cancelled-externally"
	// CodeInvalidIndexOwnLeftover: the failed build's own invalid index
	// remains and is proven this run's leftover; the recovery runbook
	// applies.
	CodeInvalidIndexOwnLeftover Code = "invalid-index-own-leftover"
	// CodeInvalidIndexBuildInFlight: the invalid index under the requested
	// name is another backend's concurrent build still in progress; wait.
	CodeInvalidIndexBuildInFlight Code = "invalid-index-build-in-flight"
	// CodeInvalidIndexAbandoned: an abandoned invalid index under the
	// requested name sits on the target table with no builder;
	// RebuildAbandonedIndex recovers it.
	CodeInvalidIndexAbandoned Code = "invalid-index-abandoned"
	// CodeInvalidIndexOtherTable: an invalid index under the requested name
	// sits on a different table in the target schema; this change refuses
	// and does not remove it.
	CodeInvalidIndexOtherTable Code = "invalid-index-other-table"
	// CodeInvalidIndexNotDroppable: the invalid index under the requested
	// name sits on the target table but is a partitioned table's index, an
	// index partition, or a constraint's index — not the debris of a
	// failed concurrent build, and not removable by DROP INDEX
	// CONCURRENTLY; this change leaves it in place for an operator.
	CodeInvalidIndexNotDroppable Code = "invalid-index-not-droppable"
	// CodeInvalidIndexBuilderUnobservable: an invalid index under the
	// requested name sits on the target table and this role cannot observe
	// whether a backend is building it; RebuildAbandonedIndex proves the
	// state under the table lock, or a role with pg_read_all_stats can
	// classify it.
	CodeInvalidIndexBuilderUnobservable Code = "invalid-index-builder-unobservable"
	// CodeInvalidIndexUnproven: an invalid index may remain but the
	// catalog state could not be proven; an operator must inspect.
	CodeInvalidIndexUnproven Code = "invalid-index-unproven"
	// CodeEmptySequence: the sequence had no steps to run.
	CodeEmptySequence Code = "empty-sequence"
	// CodeUnsupportedSequenceStep: a step is not a shape the sequence
	// executor can run safely.
	CodeUnsupportedSequenceStep Code = "unsupported-sequence-step"
	// CodeUnsupportedPartitionedParent identifies partitioned-parent
	// admission refusals.
	CodeUnsupportedPartitionedParent Code = "unsupported-partitioned-parent"
	// CodeNotConcurrentIndexBuild: the statement handed to the concurrent
	// build executor is not a CREATE INDEX CONCURRENTLY.
	CodeNotConcurrentIndexBuild Code = "not-concurrent-index-build"
	// CodeUnnamedIndex: the concurrent build does not name its index, so
	// its outcome could not be verified.
	CodeUnnamedIndex Code = "unnamed-index"
	// CodeUnqualifiedTable: the target table is not schema-qualified at
	// the library boundary.
	CodeUnqualifiedTable Code = "unqualified-table"
	// CodeIfNotExistsUnsupported: CREATE ... IF NOT EXISTS cannot prove
	// what its no-op would mean.
	CodeIfNotExistsUnsupported Code = "if-not-exists-unsupported"
	// CodeCreateCollision: a name the create path needs is already taken
	// on the server; the caller re-diffs the live catalog rather than
	// assuming the occupant's shape.
	CodeCreateCollision Code = "create-collision"
	// CodeCreateNameMismatch: the CREATE TABLE committed but the table does
	// not own a first-choice constraint-index or sequence name the desired
	// file claimed — an occupant took it inside the catalog probe's window
	// and the server chose a suffixed name instead; the table remains for an
	// operator to rename the relation or drop, then re-diff.
	CodeCreateNameMismatch Code = "create-name-mismatch"
	// CodeCreateNamesUnverified: the CREATE TABLE committed but the read of
	// the relation names the table owns did not complete, so whether every
	// first-choice claim was honoured is unknown; the table remains for an
	// operator to compare its names against the desired file, then re-diff.
	// The read's own failure is the cause in the error text, never the code:
	// the code names the state the step left, and that state is a standing
	// table whose names are unproven.
	CodeCreateNamesUnverified Code = "create-names-unverified"
	// CodeCreateOwnerMismatch: the CREATE TABLE committed but the table's
	// catalog owner is not the create owner the proof named; the table
	// remains as committed for an operator to inspect, transfer, or drop —
	// the executor never repairs ownership with ALTER ... OWNER TO.
	CodeCreateOwnerMismatch Code = "create-owner-mismatch"
	// CodeCreateOwnerUnverified: the CREATE TABLE committed but the read of
	// its catalog owner did not complete, so whether it was born under the
	// named owner is unknown; the table remains for an operator to compare
	// its owner against the configured one. As with the unverified names,
	// the read's own failure is the cause in the error text, never the code.
	CodeCreateOwnerUnverified Code = "create-owner-unverified"
	// CodeDuplicateCreateName: the desired set claims the same relation
	// name twice; the conflict is decidable at admission and refused
	// before anything runs.
	CodeDuplicateCreateName Code = "duplicate-create-name"
	// CodePartitionOfUnsupported: CREATE TABLE PARTITION OF locks the
	// partitioned parent, which the absence proof does not cover.
	CodePartitionOfUnsupported Code = "partition-of-unsupported"
	// CodeUnsupportedCreateStep: a desired statement is not a shape the
	// create path can run.
	CodeUnsupportedCreateStep Code = "unsupported-create-step"
	// CodePoolTooSmall: the pool cannot hold the build session and the
	// verdict connection at once.
	CodePoolTooSmall Code = "pool-too-small"
	// CodeTableNotFound: the statement's qualified table does not exist.
	CodeTableNotFound Code = "table-not-found"
	// CodeInvariantViolation: a breach of the invariant registry; never a
	// retry candidate.
	CodeInvariantViolation Code = "invariant-violation"
	// CodeExecutionFailed: the fallback for a failure outside the typed
	// set — a server error surfaced as-is, a connection failure, a
	// context cancellation. Consumers treat it as an operational error to
	// investigate, not a refusal to branch on.
	CodeExecutionFailed Code = "execution-failed"
)

The codes an executor outcome can carry.

func Codes added in v0.2.0

func Codes() []Code

Codes returns the closed set of outcome codes. It is part of the report contract: adapters enumerate it to know every outcome they must render, and the docs test pins every code into the execution-model page so the documented vocabulary cannot drift from this one.

func OutcomeCode

func OutcomeCode(err error) Code

OutcomeCode maps an error returned by this package to its stable code. A nil error has no outcome code and maps to the empty Code. A *SequenceStepError carries its failed step's own cause, so it maps to that underlying code — the step position and committed prefix ride on the struct itself, not the vocabulary. An error outside the typed set maps to CodeExecutionFailed.

func (Code) Permanent added in v0.3.0

func (c Code) Permanent() bool

Permanent reports whether the outcome is decided by the statement, the caller's configuration, or the standing catalog: retrying the same call unchanged reproduces it, and no executor entry point changes it — an author or operator has to act first. It is the floor an adapter's retry policy stands on, not its ceiling: a code that is not permanent may still be one a particular adapter declines to retry (a statement budget it sized as a lease, for one), but a permanent code retried unchanged loops for ever. The invalid-index family splits on exactly this line: an entry RebuildAbandonedIndex can prove abandoned, a build to wait out, or a proof to re-take is not permanent; an entry on another table or one the server will not drop concurrently is. The empty code (no error) is not permanent.

type ConcurrentBudget

type ConcurrentBudget struct {
	// Overall bounds the whole statement, waits included, via
	// statement_timeout. It must be at least one millisecond (PostgreSQL's
	// granularity); expect index builds on large tables to need a generous
	// value.
	Overall time.Duration
	// CallerOwned makes the caller's cancellable context the statement's only
	// bound: the session runs with statement_timeout disabled and Overall must
	// be zero. The executor refuses a context that cannot be cancelled.
	CallerOwned bool
}

ConcurrentBudget bounds one CONCURRENTLY statement (index build or drop).

CONCURRENTLY statements get their own wait policy instead of the blanket per-lock timeout: their waits for other transactions' snapshots are lock waits by implementation, so a session lock_timeout would cancel a healthy build mid-wait — and that cancellation is exactly what creates the invalid index this executor exists to prevent. The statement therefore runs with lock_timeout disabled and one bound on the whole statement: a server deadline, or in caller-owned mode the caller's cancellable context. That is safe with respect to the lock queue: the SHARE UPDATE EXCLUSIVE lock a concurrent build waits for does not block normal reads or writes queued behind it.

type CreateNameMismatchError added in v0.3.2

type CreateNameMismatchError struct {
	Schema    string
	Table     string
	Missing   []string
	Unclaimed []string
}

CreateNameMismatchError reports the owned relation names that differ from the create path's first-choice claims after the CREATE TABLE committed. Missing lists the claimed constraint-index and sequence names the table does not own; Unclaimed lists the names it owns that no claim predicted — the server's suffixed replacements. Both are sorted.

func (*CreateNameMismatchError) Error added in v0.3.2

func (e *CreateNameMismatchError) Error() string

Error names the committed table, the claimed names it lacks, and the names it owns instead, so an operator can rename the relation to its first choice once the occupant is gone or drop the table and re-diff.

func (*CreateNameMismatchError) Unwrap added in v0.3.2

func (e *CreateNameMismatchError) Unwrap() error

Unwrap exposes the sentinel boundary for errors.Is callers.

type CreateOwnerMismatchError added in v0.3.3

type CreateOwnerMismatchError struct{ Expected, Actual string }

CreateOwnerMismatchError reports the expected and catalog-resolved owner.

func (*CreateOwnerMismatchError) Error added in v0.3.3

func (e *CreateOwnerMismatchError) Error() string

func (*CreateOwnerMismatchError) Unwrap added in v0.3.3

func (e *CreateOwnerMismatchError) Unwrap() error

type CreateShapeCause added in v0.3.1

type CreateShapeCause string

CreateShapeCause is the stable identity of why the create path refuses a desired statement's shape. Automation branches on it, never on error text. Existing values never change meaning; new refusals add new values.

const (
	// CreateShapePartitionOf refuses attaching a partition because it locks
	// the partitioned parent, which the absence proof does not cover.
	CreateShapePartitionOf CreateShapeCause = "partition-of"
	// CreateShapeInherits refuses binding to an existing parent because the
	// absence proof does not cover it.
	CreateShapeInherits CreateShapeCause = "inherits"
	// CreateShapeLike refuses reading an existing source table because the
	// absence proof does not cover it.
	CreateShapeLike CreateShapeCause = "like"
	// CreateShapeOfType refuses binding to an existing composite type because
	// the absence proof does not cover it.
	CreateShapeOfType CreateShapeCause = "of-type"
	// CreateShapeIfNotExists refuses a name-only no-op because it cannot prove
	// the existing relation has the requested shape or is valid.
	CreateShapeIfNotExists CreateShapeCause = "if-not-exists"
	// CreateShapeConcurrently refuses a concurrent build because a table born
	// this run has no traffic to protect and a plain build cannot leave an
	// invalid index behind a failure.
	CreateShapeConcurrently CreateShapeCause = "concurrently"
	// CreateShapeDuplicateName refuses a desired set that claims the same
	// relation name twice before any statement runs.
	CreateShapeDuplicateName CreateShapeCause = "duplicate-name"
	// CreateShapeMultipleOperations refuses a statement when the statement and
	// operation parse boundaries disagree about its operation count.
	CreateShapeMultipleOperations CreateShapeCause = "multiple-operations"
	// CreateShapeUnsupportedKind refuses a statement kind outside the plain
	// CREATE TABLE and CREATE INDEX shapes the create path can run.
	CreateShapeUnsupportedKind CreateShapeCause = "unsupported-kind"
)

The causes for which the create path can refuse a desired statement.

func CreateShapeCauseOf added in v0.3.1

func CreateShapeCauseOf(err error) CreateShapeCause

CreateShapeCauseOf returns the create-shape cause carried by err, or the empty cause when err is nil or carries no create-shape refusal. Wrappers, including a *SequenceStepError naming the failed step, are read through.

func CreateShapeCauses added in v0.3.1

func CreateShapeCauses() []CreateShapeCause

CreateShapeCauses returns the closed set of create-shape refusal causes.

func (CreateShapeCause) Description added in v0.3.1

func (c CreateShapeCause) Description() string

Description returns the human-facing sentence for a create-shape cause.

type CreateShapeError added in v0.3.1

type CreateShapeError struct {
	Cause CreateShapeCause
	Name  string
}

CreateShapeError identifies a create-shape refusal. Name is populated only when Cause is CreateShapeDuplicateName.

func (*CreateShapeError) Error added in v0.3.1

func (e *CreateShapeError) Error() string

Error renders the create-shape refusal for a human reader.

func (*CreateShapeError) Unwrap added in v0.3.1

func (e *CreateShapeError) Unwrap() error

Unwrap exposes the existing sentinel boundary for errors.Is callers.

type DroppedIndex added in v0.3.0

type DroppedIndex struct {
	// Schema is the schema the entry lived in.
	Schema string `json:"schema"`
	// Index is the entry's name at drop time — its identity-derived
	// quarantine name, not the name the failed build gave it.
	Index string `json:"index"`
	// IndexOID is the removed entry's catalog identity.
	IndexOID uint32 `json:"index_oid"`
	// Duration is the wall-clock time of the DROP INDEX CONCURRENTLY
	// statement itself. It encodes as integer nanoseconds.
	Duration time.Duration `json:"duration_ns"`
}

DroppedIndex records one abandoned invalid index the recovery removed.

type IndexBuildReport

type IndexBuildReport struct {
	// Schema is the schema the index lives in, resolved from the target
	// table (an index is always created in its table's schema).
	Schema string `json:"schema"`
	// Index is the index name from the statement.
	Index string `json:"index"`
	// IndexOID is the verified index's catalog identity: the durable
	// handle a later reconciliation can use where the name alone could
	// have been reassigned.
	IndexOID uint32 `json:"index_oid"`
	// Duration is the wall-clock time of the build statement itself,
	// excluding session setup and the validity verification. It encodes
	// as integer nanoseconds.
	Duration time.Duration `json:"duration_ns"`
	// ServerVersion is the server_version of the PostgreSQL server that
	// ran the build.
	ServerVersion string `json:"server_version"`
}

IndexBuildReport says what a concurrent build did, machine-readably. It is returned only after the executor re-read the catalog and verified the built index is valid — it means "verified valid", not "the server did not complain" — so it is evidence an orchestrator can store or forward.

func BuildIndexConcurrently

func BuildIndexConcurrently(ctx context.Context, pool *pgxpool.Pool, sql string, b ConcurrentBudget) (IndexBuildReport, error)

BuildIndexConcurrently runs one named CREATE INDEX ... CONCURRENTLY on a schema-qualified table, on its own session, outside any transaction, bounded by b. The pool must come from pkg/dbconn (raw pools may carry a reset baseline the session hygiene here cannot vouch for) and must allow at least two connections: the post-failure verdict's session is acquired up front, alongside the build session, so the verdict can never starve on a busy pool — a pool configured below two is refused at admission (ErrPoolTooSmall), and both acquisitions are bounded by ctx. The build never drops an index: PostgreSQL drops by name, not identity, so a drop here could not prove it is destroying this build's own debris rather than another actor's same-name index registered in the same window. That proof is RebuildAbandonedIndex's job — it holds the table lock every concurrent build needs while it renames the entry by identity and drops it by that unique name. Every invalid index the build meets is surfaced as a typed, fail-closed *InvalidIndexError whose Cleanup says which state it is (see docs/invalid-index-recovery.md):

  • an invalid index with the requested name that another backend is still building refuses to build and says wait (ErrInvalidIndexBuildInFlight, carrying the builder's PID);
  • an abandoned invalid index with the requested name on the target table refuses to build and names the recovery (ErrAbandonedInvalidIndex, Recoverable);
  • an invalid index with the requested name on another table in the schema refuses to build and leaves it to that table's own change (ErrInvalidIndexOnOtherTable);
  • an invalid index with the requested name on the target table whose builder this role cannot observe — another role's progress row is hidden without pg_read_all_stats, and none is recorded with track_activities off — refuses to build without claiming it abandoned (ErrInvalidIndexBuilderUnobservable, Recoverable: the recovery's lock proof does not depend on the view);
  • a failed build that left an invalid entry behind reports it (ErrBuildLeftInvalidIndex, Recoverable) after proving the build's own backend stopped, so the catalog verdict cannot race the dying statement;
  • a failed build that provably left nothing returns its failure alone — a retry can start immediately.

A cancelled build is typed by its cause. A server cancellation at or past the server-owned overall budget is that budget's own statement_timeout and surfaces as a *BudgetError, whatever the caller's context did meanwhile. Below the budget, the caller's own context ending surfaces as ErrCancelledByCaller in either mode, and a cancellation under a live context cannot be the budget's or the caller's and surfaces as ErrCancelledExternally. In caller-owned mode the cancellable context is the only bound and statement_timeout is disabled, so a 57014 under a live context is always ErrCancelledExternally. Caller cancellation is a race: the client returns while the cancel signal travels to the server, so a build cancelled at the finish line may still complete. The guarantee is about the catalog, not the race: after this function returns without an *InvalidIndexError, the index is either valid or absent — success is returned only after re-reading the catalog and verifying pg_index.indisvalid on the built index, a guard against server-version drift in what "success" leaves behind.

Unlike the optimistic attempt, no size-guard proof is required: the size guard exists because a blocking attempt holds ACCESS EXCLUSIVE for its whole budget, while a concurrent build takes only SHARE UPDATE EXCLUSIVE — long builds on large tables are its purpose.

func BuildIndexConcurrentlyWithProgress

func BuildIndexConcurrentlyWithProgress(ctx context.Context, pool *pgxpool.Pool, sql string, b ConcurrentBudget, tracker *progress.Tracker) (rep IndexBuildReport, err error)

BuildIndexConcurrentlyWithProgress runs a concurrent build while updating tracker. The caller may poll tracker concurrently with this blocking call.

type IndexRecoveryReport added in v0.3.0

type IndexRecoveryReport struct {
	// Dropped lists the abandoned invalid indexes removed before the build,
	// in removal order; empty when the table carried none.
	Dropped []DroppedIndex `json:"dropped"`
	// Skipped lists the quarantined entries on the table that DROP INDEX
	// CONCURRENTLY cannot remove — an entry that became a partitioned
	// index's partition or a constraint's index after it was quarantined —
	// which the sweep leaves in place for an operator and steps over, so
	// one such entry never blocks every later recovery on the table. Empty
	// when there were none.
	Skipped []QuarantinedIndex `json:"skipped"`
	// Build is the verified report of the requested index build. It is zero
	// when returned by DropAbandonedIndex, which does not run a build.
	Build IndexBuildReport `json:"build"`
	// Duration is the wall-clock time of the whole recovery call — proof
	// and drops, plus the build when requested — so a caller that sized
	// the budget as a lease can see what the recovery actually spent. It
	// is set on every return, a refusal included, so a refusal that waited
	// out a lock bound reports the wait. It encodes as integer nanoseconds.
	Duration time.Duration `json:"duration_ns"`
}

IndexRecoveryReport says what an abandoned-index recovery did: which abandoned entries it removed, which quarantined entries it left alone, and, for RebuildAbandonedIndex, the verified build.

func DropAbandonedIndex added in v0.3.2

func DropAbandonedIndex(ctx context.Context, pool *pgxpool.Pool, sql string, b ConcurrentBudget) (IndexRecoveryReport, error)

DropAbandonedIndex proves that the invalid index occupying the name in a CREATE INDEX ... CONCURRENTLY statement is abandoned, quarantines it by OID, and drops it concurrently without running the requested build. It has the same admission rules, fail-closed invalid-index verdicts, and proof and drop budgets as RebuildAbandonedIndex. It needs two pool connections: the recovery session and a drop session. Build in the returned report is always zero.

func RebuildAbandonedIndex added in v0.3.0

func RebuildAbandonedIndex(ctx context.Context, pool *pgxpool.Pool, sql string, b ConcurrentBudget) (IndexRecoveryReport, error)

RebuildAbandonedIndex removes the abandoned invalid index occupying the name a CREATE INDEX ... CONCURRENTLY statement asks for, then runs the build. It accepts exactly the statements BuildIndexConcurrently accepts under the same budget, needs a pool one connection larger (its own session stays open across the drops and the build), and is the recovery the build's ErrAbandonedInvalidIndex, ErrBuildLeftInvalidIndex, and ErrInvalidIndexBuilderUnobservable outcomes name. With no invalid index under the name and no quarantined debris on the table it is the build alone.

The removal is safe because it is proven, not because it is named:

  1. Under a bounded SHARE UPDATE EXCLUSIVE lock on the target table — the lock every concurrent index command holds for its whole life, so holding it means no build, drop, or reindex of any index on the table is in flight — the entry is re-verified by OID (still under the requested name, still invalid, still on this table in this schema, still an index the server will drop concurrently, no builder) and renamed to a name derived from that OID. A lock not granted within the bound is reported as a *BudgetError (CauseLock) and nothing is touched.
  2. The rename commits, and every invalid index on the table whose name is its own quarantine name is dropped with DROP INDEX CONCURRENTLY, each verified by OID before and after. The quarantine name can belong to nothing but the entry it was derived from, so the drop by name is a drop by identity; and because the sweep keys on the name pattern, a crash between rename and drop leaves debris a later recovery removes on its own — and a plain BuildIndexConcurrently on the table refuses until it does, so the debris stays loud.
  3. The requested build runs exactly as BuildIndexConcurrently.

The recovery refuses, touching nothing, when the invalid index under the name is visibly another backend's build still in progress (ErrInvalidIndexBuildInFlight), sits on a different table (ErrInvalidIndexOnOtherTable), or is not an index the server will drop concurrently (ErrInvalidIndexNotDroppable: a partitioned table's index, an index partition, a constraint's index — invalid for reasons of their own, never a failed build's debris) — see (*InvalidIndexError).Recoverable — and fails closed with ErrAbandonmentUnproven when the entry changes between two verification points or a drop leaves it in place. A builder this role cannot observe (ErrInvalidIndexBuilderUnobservable at the build) is not a refusal here: the lock in step 1 is the proof, and a hidden build holds the lock like any other, so the recovery reports the lock budget instead of waiting behind it. It never removes a valid index.

Time is bounded by construction. Every lock the recovery waits for on its own behalf — the proof lock and the drops' waits — is bounded by recoveryLockTimeout; the whole sweep of drops shares one Overall budget (each drop gets what the earlier ones left), and the build gets Overall once more, so the recovery takes at most the lock bound plus twice the budget plus the build's own bounded verdict. In caller-owned mode the caller's cancellation bounds the drops and the build alike. The report's Duration says what was actually spent.

type InvalidIndexError

type InvalidIndexError struct {
	// Schema and Index identify the possibly-invalid index.
	Schema string
	Index  string
	// Table is the table the invalid index sits on, when the catalog
	// inspection saw the entry; empty when the state could not be
	// inspected.
	Table string
	// BuilderPID is the backend running a concurrent build or reindex of
	// this exact index (pg_stat_progress_create_index), when the entry is
	// in flight; zero otherwise.
	BuilderPID uint32
	// Build is the build failure that left the index invalid; nil when
	// there is no build failure to carry.
	Build error
	// Cleanup is why the recovery drop was refused, failed, or could not
	// be verified.
	Cleanup error
}

InvalidIndexError reports that an invalid index exists (or may remain) and the executor will not or cannot remove it: the one outcome that needs an operator. Build carries the failure that produced the leftover, nil when there is no build failure to carry (the entry predates this run, or a reported success failed its validity verification); Cleanup carries why automatic recovery was refused, failed, or could not be proven.

func (*InvalidIndexError) Code

func (e *InvalidIndexError) Code() Code

Code returns the invalid-index outcome's stable code, derived from the same cleanup state the error's rendering distinguishes: proven own leftover, another backend's build in flight, abandoned on the target table, on another table, not droppable, builder unobservable, or unproven.

func (*InvalidIndexError) Error

func (e *InvalidIndexError) Error() string

Error implements the error interface. The advice is as state-specific as the type: a removal is named only in the states where the recovery can prove ownership before it drops — the same standard the executor holds itself to. An in-flight build says wait; another table's debris, an index the server will not drop concurrently, and an unproven state get investigation steps, never a statement to copy-paste: the index under that name may be healthy, or may be exactly what it is meant to be.

func (*InvalidIndexError) Recoverable added in v0.3.0

func (e *InvalidIndexError) Recoverable() bool

Recoverable reports whether RebuildAbandonedIndex can remove the entry and build the requested index: true for this build's own proven leftover, for an abandoned entry on the target table, and for an entry on the target table whose builder this role cannot observe — the recovery's proof is a table lock, not the progress view, so it decides what the view could not. A visibly in-flight build, another table's debris, an index the server will not drop concurrently, and an unproven state are not this actor's to recover.

func (*InvalidIndexError) Unwrap

func (e *InvalidIndexError) Unwrap() []error

Unwrap exposes the underlying failures to errors.Is/As.

type QuarantinedIndex added in v0.3.0

type QuarantinedIndex struct {
	// Schema is the schema the entry lives in.
	Schema string `json:"schema"`
	// Index is the entry's identity-derived quarantine name.
	Index string `json:"index"`
	// IndexOID is the entry's catalog identity.
	IndexOID uint32 `json:"index_oid"`
}

QuarantinedIndex records one quarantined entry the recovery found on the table and deliberately left in place.

type RetryPolicy

type RetryPolicy struct {
	MaxAttempts    int
	InitialBackoff time.Duration
	MaxBackoff     time.Duration
}

RetryPolicy bounds retries after lock_timeout expires. Backoff doubles after each failed attempt and is capped at MaxBackoff.

func DefaultRetryPolicy

func DefaultRetryPolicy() RetryPolicy

DefaultRetryPolicy returns the safe native-DDL retry policy used by callers that do not need to tune lock acquisition.

type SequenceBudget

type SequenceBudget struct {
	// Brief bounds every brief catalog step.
	Brief Budget
	// Concurrent bounds every concurrent index build step.
	Concurrent ConcurrentBudget
	// Validate bounds every VALIDATE CONSTRAINT step.
	Validate ValidateBudget
}

SequenceBudget bounds one sequence run: each admitted step class carries its own budget, because the classes have opposite needs — a brief catalog step must be cancelled fast, a validation scan and a concurrent build must be allowed to run long.

type SequenceReport

type SequenceReport struct {
	// Steps are the per-step reports, in execution order.
	Steps []StepReport `json:"steps"`
}

SequenceReport is the record of a sequence run: one report per committed step, in execution order. On success it covers every step; alongside a *SequenceStepError it covers exactly the committed prefix, so a caller can disclose what already happened.

func ExecuteCreate added in v0.2.0

ExecuteCreate runs the desired schema's statements against the verified-absent target: the CREATE TABLE first, then its indexes in input order, each step a bounded transactional run under the brief budgets, exactly like an optimistic attempt, with its search_path pinned to the proof's schema then public — the same policy the introspection read path sets — so the desired file's unqualified references resolve exactly as the diff resolved them. The pool must come from pkg/dbconn and must be the session the proofs were minted on: cr proves that session's role can create in the proof's schema, and like the absence proof it is time-of-check — a grant revoked after minting fails with the server's own error. Every desired statement is qualified into the proof's schema, re-parsed, and admitted by shape and target, and every index and constraint-index name the file states is probed free in pg_class, before the first step executes. A refusal from either check returns with an empty report and no *SequenceStepError: an occupied name is ErrCreateCollision, an inadmissible shape one of the admission sentinels, and a probe that could not complete (a cancelled context, a lost connection) is that error, wrapped — not a collision. On success every step committed and the report says what each did. On failure the run stops at the failing step and returns a typed *SequenceStepError; the committed prefix remains — a rerun's absence check then refuses with preflight.ErrRelationExists, and the caller re-diffs the live catalog to apply the remainder. retry bounds lock_timeout retries on each step, exactly as in ExecuteNative.

func ExecuteCreateWithProgress added in v0.2.0

func ExecuteCreateWithProgress(ctx context.Context, pool *pgxpool.Pool, at preflight.AbsentTarget, cr preflight.CreationRole, ds statement.DesiredSchema, b Budget, retry RetryPolicy, tracker *progress.Tracker) (rep SequenceReport, err error)

ExecuteCreateWithProgress runs the create path while updating tracker with the current step. The caller may poll concurrently.

func RunSequence

func RunSequence(ctx context.Context, pool *pgxpool.Pool, pt preflight.PreflightedTable, steps []string, b SequenceBudget, retry RetryPolicy) (SequenceReport, error)

RunSequence runs steps in order against the preflighted table, each step in its own implicit or bounded transaction — the autocommit-each-step contract a planner-produced safer sequence carries. The pool must come from pkg/dbconn and, when the sequence contains a concurrent index build, must allow at least two connections (see BuildIndexConcurrently). The whole sequence is admitted before the first step executes: every step is re-parsed, its shape classified, and its target verified against the preflight proof, so a sequence this executor cannot finish is never started. On success every step committed and the report says what each did. On failure the run stops at the failing step and returns a typed *SequenceStepError; the committed prefix remains, per the planner's documented partial-failure contracts, and the returned report covers exactly that prefix.

Like the concurrent build — and unlike a blind optimistic attempt — no size-guard proof is required beyond the preflight itself: long scans on large tables are the sequence pattern's purpose, and every brief step is still individually bounded by the brief budgets. retry bounds lock_timeout retries on each owner-gated step, exactly as in ExecuteNative.

func RunSequenceWithProgress

func RunSequenceWithProgress(ctx context.Context, pool *pgxpool.Pool, pt preflight.PreflightedTable, steps []string, b SequenceBudget, retry RetryPolicy, tracker *progress.Tracker) (rep SequenceReport, err error)

RunSequenceWithProgress runs a sequence while updating tracker with the current step and its execution class. The caller may poll concurrently.

type SequenceStepError

type SequenceStepError struct {
	// Step is the failed step's 1-based position, matching the numbering
	// the planner's partial-failure contracts use.
	Step int
	// Total is the number of steps the sequence was admitted with.
	Total int
	// Kind is the execution class the failed step ran under.
	Kind StepKind
	// SQL is the failed step's statement.
	SQL string
	// Err is the step's underlying failure.
	Err error
}

SequenceStepError reports that a step failed and the run stopped there. The steps before it committed and their partial state remains — the planner's sequence constructors document what each failed step leaves behind and how a retry resumes. Err carries the step's own typed failure (*BudgetError, *InvalidIndexError, a server error) for errors.Is/As.

func (*SequenceStepError) Code

func (e *SequenceStepError) Code() Code

Code returns the failed step's own cause code; the step position and the committed prefix ride on the struct's fields.

func (*SequenceStepError) Error

func (e *SequenceStepError) Error() string

Error implements the error interface. It names the failed step and the committed prefix, because "what already happened" is the first triage question a partial sequence raises.

func (*SequenceStepError) Unwrap

func (e *SequenceStepError) Unwrap() error

Unwrap exposes the step's failure to errors.Is/As.

type StepKind

type StepKind string

StepKind is the typed execution class a step was admitted under; automation branches on it, never on the step's SQL text.

const (
	// StepBrief: a bounded transactional run under the brief budgets — a
	// catalog change that must prove itself effectively instant, exactly
	// like an optimistic attempt.
	StepBrief StepKind = "brief"
	// StepConcurrentIndexBuild: a CREATE INDEX CONCURRENTLY delegated to
	// the dedicated concurrent build executor, with its wait policy and
	// invalid-index verdict.
	StepConcurrentIndexBuild StepKind = "concurrent-index-build"
	// StepValidateConstraint: an ALTER TABLE ... VALIDATE CONSTRAINT — a
	// long online scan under SHARE UPDATE EXCLUSIVE, bounded by the
	// validate budget rather than the brief one.
	StepValidateConstraint StepKind = "validate-constraint"
)

The execution classes a step can be admitted under.

func StepKinds added in v0.2.0

func StepKinds() []StepKind

StepKinds returns the closed set of StepKind values. It is part of the documented contract: docs/execution-model.md names every kind.

type StepReport

type StepReport struct {
	// SQL is the step's statement as submitted.
	SQL string `json:"sql"`
	// Kind is the execution class the step ran under.
	Kind StepKind `json:"kind"`
	// Duration is the wall-clock time of the step, session setup and
	// verification included. It encodes as integer nanoseconds.
	Duration time.Duration `json:"duration_ns"`
	// Index carries the concurrent build's verified report; nil for every
	// other step kind.
	Index *IndexBuildReport `json:"index,omitempty"`
}

StepReport says what one committed step did, machine-readably.

type ValidateBudget

type ValidateBudget struct {
	// LockTimeout bounds how long the step may wait in the lock queue.
	LockTimeout time.Duration
	// Overall bounds the whole validation scan via statement_timeout;
	// expect large tables to need a generous value.
	Overall time.Duration
}

ValidateBudget bounds one VALIDATE CONSTRAINT step. The validation scan is long by design — it is the online half of the NOT VALID pattern — so it gets its own overall bound instead of the brief statement budget, while lock acquisition stays tightly bounded: the SHARE UPDATE EXCLUSIVE it takes conflicts with other DDL, and queueing behind one must not stall the sequence for the whole scan budget.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL