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 (see native.go).
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 ¶
- Variables
- func ExecuteNative(ctx context.Context, pool *pgxpool.Pool, pt preflight.PreflightedTable, ...) error
- func ExecuteNativeWithProgress(ctx context.Context, pool *pgxpool.Pool, pt preflight.PreflightedTable, ...) (err error)
- type Budget
- type BudgetCause
- type BudgetError
- type Code
- type ConcurrentBudget
- type IndexBuildReport
- type InvalidIndexError
- type RetryPolicy
- type SequenceBudget
- type SequenceReport
- type SequenceStepError
- type StepKind
- type StepReport
- type ValidateBudget
Constants ¶
This section is empty.
Variables ¶
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 CREATE INDEX CONCURRENTLY // IF NOT EXISTS. The clause checks only the name: it succeeds as a // no-op while an invalid or unrelated index owns that name, so the // executor could report success over an index it cannot vouch for. ErrIfNotExistsUnsupported = errors.New("CREATE INDEX CONCURRENTLY IF NOT EXISTS is not supported: a name-only no-op cannot prove the existing index is valid or even the requested one") // ErrPreexistingInvalidIndex is returned when an invalid index with the // requested name already exists in the target schema — on any table. // The executor cannot prove who owns that entry — an in-progress // concurrent build by another actor is invalid until it finishes — and // after a failure of its own it could never distinguish that entry // from its own leftover, so it refuses to build. This state is not a // drop instruction: the index may be healthy and mid-build (see // docs/invalid-index-recovery.md). ErrPreexistingInvalidIndex = errors.New("an invalid index with this name already exists in the target schema") // 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 executor never removes it automatically: PostgreSQL // drops by name, not identity, so an automatic drop could destroy // another actor's index registered under the same name in the same // window. The recovery is the operator's explicit DROP INDEX // CONCURRENTLY (see docs/invalid-index-recovery.md). ErrBuildLeftInvalidIndex = errors.New("the build left an invalid index behind") // 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 // the build session and the verdict session at once. The verdict is a // correctness dependency, not a nicety: without a reserved second // connection, every failed build would resolve indeterminate. Like an // unbounded budget, an unusable verdict is refused by construction. ErrPoolTooSmall = errors.New("concurrent index build needs a pool of at least two connections: one for the build session, one reserved for the catalog verdict") // ErrCancelledExternally is returned when the build's statement was // cancelled (SQLSTATE 57014) before its overall budget elapsed: the // executor's statement_timeout cannot have fired yet, so the // cancellation came from outside — an operator's pg_cancel_backend, an // administrative tool. 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 before its budget elapsed") )
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).
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.
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 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. 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 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
}
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.
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" // CodeCancelledExternally: the build's statement was cancelled from // outside the executor before its budget elapsed. 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" // CodeInvalidIndexPreexisting: an invalid index under the requested // name predates this run; it may be another actor's build in progress. CodeInvalidIndexPreexisting Code = "invalid-index-preexisting" // 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 INDEX CONCURRENTLY IF NOT EXISTS // cannot prove what its no-op would mean. CodeIfNotExistsUnsupported Code = "if-not-exists-unsupported" // 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 OutcomeCode ¶
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.
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
}
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 overall deadline. 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 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. It never drops an index: PostgreSQL drops by name, not identity, so no automatic drop can prove it is destroying this build's own debris rather than another actor's same-name index registered in the same window (the engine's one-migration-per-table lease — planned invariant LK-1 — would close that; once it exists this API should demand its proof). Every invalid index is instead surfaced as a typed, fail-closed outcome carrying state-specific operator guidance (see docs/invalid-index-recovery.md):
- a pre-existing invalid index with the requested name refuses to build (ErrPreexistingInvalidIndex);
- a failed build that left an invalid entry behind reports it (ErrBuildLeftInvalidIndex) 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.
Cancellation by the overall budget surfaces as a *BudgetError; a cancellation arriving before the budget elapsed cannot be the budget's own statement_timeout and surfaces as ErrCancelledExternally instead. 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 InvalidIndexError ¶
type InvalidIndexError struct {
// Schema and Index identify the possibly-invalid index.
Schema string
Index string
// 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, proven preexisting, 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 name-based drop is named only when the entry is proven this build's own leftover (ErrBuildLeftInvalidIndex) — the same ownership standard the executor holds itself to when it refuses to drop automatically. An unproven or uninspected state gets investigation steps, never a statement to copy-paste: the index under that name may be healthy, or another actor's build still in progress.
func (*InvalidIndexError) Unwrap ¶
func (e *InvalidIndexError) Unwrap() []error
Unwrap exposes the underlying failures to errors.Is/As.
type RetryPolicy ¶
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 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.
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.