Skip to content

Commit c5900a0

Browse files
authored
feat(executor): caller-owned deadline, lockers, and backend PID for concurrent builds (#76)
`BuildIndexConcurrently` gains a caller-owned cancellation mode, the progress tracker becomes the operator's stop path for a running build, and progress snapshots report the lockers a concurrent build is waiting on. ## Why A concurrent index build on a large table can run for hours. Today the only bound the executor accepts is a fixed server-side `statement_timeout` (`ConcurrentBudget.Overall`), which fits a synchronous attempt but not an orchestrator that keeps a build alive for as long as it can renew a lease. That orchestrator's decision tree has three branches — *my lease lapsed* (retry later), *an operator stopped it* (do not retry), *the server's budget killed it* (retry with a different bound) — so the executor must report three distinguishable outcomes. Its operators also need to stop a running build without being handed a backend PID they could misuse after the build returns, and to see why a build in the "waiting for old snapshots" phase is not moving, which requires the lockers columns of `pg_stat_progress_create_index`. ## What - `ConcurrentBudget.CallerOwned`: the session runs with `statement_timeout = 0` and the caller's cancellable context is the statement's only bound. `Overall` must be zero (`ErrCallerOwnedOverallBudget`), and a context that cannot be cancelled is refused with `ErrCallerOwnedNeedsCancellableContext` before any session is acquired, so the statement remains bounded by construction (LK-2). The bounded mode and every existing caller are unchanged. - Cancellation is a three-way partition in both modes: the caller's own context ending is `ErrCancelledByCaller` (`cancelled-by-caller`) whichever of the server's 57014 or the client's context error arrives first; a 57014 under a live context is `ErrCancelledExternally` (`cancelled-externally`); a 57014 at the bounded mode's deadline is `*BudgetError`. - `progress.Tracker.CancelBuild(ctx)` signals the active build's backend over the tracker's reserved session, under the same lock that guards the build's lifecycle and only while the build is active (`ErrNoActiveBuild` otherwise; `ErrBuildNotRunning` when the backend had no statement to cancel). The tracker never exposes the PID itself. - `dbconn.ConcurrentIndexProgress` reads `lockers_total`, `lockers_done`, `current_locker_pid`; the snapshot carries them as `work.lockers_total` / `work.lockers_done` and `detail.current_locker_pid`. `format_version` bumps from 2 to 3 and `docs/progress-report.md` documents the new fields and the stop path. - LK-2 in `docs/invariants.md` records the caller-owned exception; capability, execution-model and TCB docs describe the caller-owned context as a bound different in kind, not an absence of one. - Integration tests on a real server: a caller-owned build completes once its blocker releases; a build stopped via `tracker.CancelBuild` returns `ErrCancelledExternally` with its invalid leftover reported and the tracker then refuses a second cancel; a build whose caller cancels returns `ErrCancelledByCaller`, never `ErrCancelledExternally` or a `*BudgetError`; a blocked build publishes `lockers_total ≥ 1` and the blocker's PID as `current_locker_pid`. ## Before / after ``` Before caller ── ConcurrentBudget{Overall: 30m} ──▶ SET statement_timeout = 30m ──▶ CREATE INDEX CONCURRENTLY (no way to say "for as long as my lease holds") 57014 before the deadline ──▶ ErrCancelledExternally (operator? caller? indistinguishable) tracker.Progress() ──▶ phase, blocks, tuples (no lockers, no stop path) After caller ── ConcurrentBudget{CallerOwned: true} + cancellable ctx ├─ ctx.Done() == nil ──▶ ErrCallerOwnedNeedsCancellableContext (refused) └─ SET statement_timeout = 0 ──▶ CREATE INDEX CONCURRENTLY ├─ caller's ctx ended ──▶ ErrCancelledByCaller (+ catalog verdict) ├─ tracker.CancelBuild ──▶ ErrCancelledExternally (+ catalog verdict) └─ (bounded mode only) deadline ──▶ *BudgetError tracker.CancelBuild(ctx) ──▶ pg_cancel_backend on the active build only; PID never leaves the tracker tracker.Progress() ──▶ phase, blocks, tuples, lockers_total/done, current_locker_pid ```
1 parent 04289ae commit c5900a0

12 files changed

Lines changed: 281 additions & 45 deletions

docs/capabilities.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ refused form would take, what an operator who accepts a maintenance window can d
3232

3333
pg-sprite is an **online schema-change engine** for PostgreSQL: it takes one table-shape
3434
change, classifies it against the live database, and either executes it through the
35-
safest known online pattern — bounded `lock_timeout`/`statement_timeout` on every
36-
session — or refuses with a typed reason. The measure of the tool is not how many object
35+
safest known online pattern — bounded server timeouts, or an explicit caller-owned
36+
cancellable context for concurrent index builds — or refuses with a typed reason. The measure of the tool is not how many object
3737
types it models but whether a change it accepts can hurt a production workload. The full
3838
positioning is [vision.md](vision.md); how it differs from planners and imperative
3939
copy tools by *problem class* is [architecture.md](architecture.md).
@@ -96,8 +96,9 @@ each matrix table answers *how* — the route the change takes (or will take) th
9696
engine:
9797

9898
- **native, as-is** — the statement is already online-safe (metadata-only, or already
99-
the online idiom); executed directly under bounded
100-
`lock_timeout`/`statement_timeout` sessions.
99+
the online idiom); executed directly under bounded sessions. Concurrent index builds
100+
may use a caller-owned cancellable context instead of `statement_timeout`; other native
101+
work uses `lock_timeout`/`statement_timeout`.
101102
- **native, safer sequence** — the blocking form is substituted with the equivalent
102103
online sequence before execution; the rewrites are catalogued in
103104
[safer-sequences.md](safer-sequences.md).

docs/execution-model.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ autocommit-each-step has two shapes in the executor:
6868
- **`CREATE INDEX CONCURRENTLY` (step kind `concurrent-index-build`)** is
6969
true autocommit on a dedicated budgeted session: it refuses to run inside
7070
any transaction block and internally manages multiple transactions of its
71-
own.
71+
own. Its bound is either the session's overall `statement_timeout` or, in
72+
explicit caller-owned mode, the caller's cancellable context while
73+
`statement_timeout` is disabled.
7274

7375
Each step's class is the `kind` field of its step report in the JSON
7476
verdict — the field retry logic branches on. A failed `brief` step means

docs/invariants.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ strong-lock acquisition (swap, catalog flips, trigger install in fallback mode)
141141
([mysql-vs-postgresql § the lock queue](mysql-vs-postgresql.md#why-ddl-is-dangerous-the-lock-queue)).
142142
**Exception policy required:** `CREATE INDEX CONCURRENTLY` and `REINDEX CONCURRENTLY` wait on
143143
other transactions via lock waits that a naive `lock_timeout` cancels — leaving an `INVALID`
144-
index — so they get their own wait policy (no per-lock timeout, one overall statement deadline)
145-
rather than the blanket timeout. `VALIDATE CONSTRAINT` is different in kind: its cancellation is
144+
index — so they get their own wait policy rather than the blanket timeout: no per-lock timeout,
145+
with either one overall server statement deadline or a caller-owned cancellable context as the
146+
statement's only bound. The executor refuses a non-cancellable context in caller-owned mode, so
147+
the statement remains bounded by construction. `VALIDATE CONSTRAINT` is different in kind: its cancellation is
146148
transactionally clean (the constraint simply stays `NOT VALID`; no debris), so the sequence
147149
executor's validate class deliberately keeps a bounded per-lock timeout — queueing behind a
148150
conflicting lock holder must not stall a sequence for the whole scan budget — while the scan

docs/progress-report.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ phase or operation value is a contract change and bumps `format_version`, even i
1616
is added or renamed.
1717

1818
Adding a field bumps `format_version` so a strict consumer can detect the new shape from the
19-
version. The current version is **2**: version 2 added `detail.statement`.
19+
version. The current version is **3**: version 2 added `detail.statement`; version 3 added
20+
`detail.current_locker_pid`, `work.lockers_total`, and `work.lockers_done`.
2021

2122
The [plan report](plan-report.md), [lint report](lint-report.md), and
2223
[suggest report](suggest-report.md) are separate contracts with their own `format_version`;
@@ -50,14 +51,16 @@ licenses a consumer to intervene in the change itself.
5051
| `server_phase` | string | active concurrent build only | PostgreSQL's own phase string from `pg_stat_progress_create_index`, verbatim. |
5152
| `active` | bool | always | Whether an operation is executing now. `false` with `phase: "running"` means a concurrent build's progress row has left the server view. |
5253
| `attempt` | int | bounded retries only | The current attempt number when the executor is inside its bounded retry loop. |
54+
| `current_locker_pid` | int | while waiting on a locker | PostgreSQL backend PID currently blocking the concurrent build; omitted when none is published. |
5355
| `work` | object | server-observed work only | Present exactly when the server published a progress row; then **every** counter below is present, so a fresh build reports honest zeros rather than an empty object. |
5456

5557
`statement` is the submitter's statement after qualification and canonicalization, so a
5658
consumer rendering it into a shared surface must clamp and escape it.
5759

5860
### Work counters
5961

60-
`blocks_done` / `blocks_total` and `tuples_done` / `tuples_total` come from
62+
`blocks_done` / `blocks_total`, `tuples_done` / `tuples_total`, and
63+
`lockers_done` / `lockers_total` come from
6164
`pg_stat_progress_create_index` during a concurrent index build. `rows_copied` /
6265
`rows_total` and `bytes_copied` / `bytes_total` are reserved for copy-and-swap and are `0`
6366
on every native operation — the engine never fabricates copy counters.
@@ -98,7 +101,7 @@ A poll during step 2 of a 3-step sequence, mid concurrent index build:
98101

99102
```json
100103
{
101-
"format_version": 2,
104+
"format_version": 3,
102105
"phase": "running",
103106
"step": 2,
104107
"total_steps": 3,
@@ -110,6 +113,7 @@ A poll during step 2 of a 3-step sequence, mid concurrent index build:
110113
"server_phase": "building index",
111114
"active": true,
112115
"attempt": 2,
116+
"current_locker_pid": 31337,
113117
"work": {
114118
"rows_copied": 0,
115119
"rows_total": 0,
@@ -118,7 +122,9 @@ A poll during step 2 of a 3-step sequence, mid concurrent index build:
118122
"blocks_done": 11,
119123
"blocks_total": 40,
120124
"tuples_done": 7,
121-
"tuples_total": 21
125+
"tuples_total": 21,
126+
"lockers_total": 3,
127+
"lockers_done": 1
122128
}
123129
}
124130
}

docs/tcb-model.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ Performance. When a trade-off is hard, the higher priority wins. This is
114114

115115
**Put a limit on everything (TIGER_STYLE).** Every loop bounded, every queue bounded, every
116116
retry counted, every wait deadlined. Existing instances include bounded native attempts, retry
117-
budgets, and `lock_timeout` / `statement_timeout` on every session. The future copy-and-swap
117+
budgets, bounded session timeouts, and caller-owned cancellable contexts where a server
118+
statement timeout is explicitly disabled. The future copy-and-swap
118119
path will also bound its change buffer, chunk target time, and slot-lag ceiling. The rule makes
119120
limits the *default*: an unbounded anything in a TCB package is a review-blocking defect. Where a
120121
loop is intentionally endless (the applier's consume loop), that must be stated and its exit

pkg/dbconn/dbconn.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,14 @@ func ServerMajor(ctx context.Context, pool *pgxpool.Pool) (int, error) {
119119

120120
// IndexBuildProgress is one server observation of a concurrent index build.
121121
type IndexBuildProgress struct {
122-
Phase string
123-
BlocksDone uint64
124-
BlocksTotal uint64
125-
TuplesDone uint64
126-
TuplesTotal uint64
122+
Phase string
123+
BlocksDone uint64
124+
BlocksTotal uint64
125+
TuplesDone uint64
126+
TuplesTotal uint64
127+
LockersTotal uint64
128+
LockersDone uint64
129+
CurrentLockerPID uint32
127130
}
128131

129132
// RowQuerier is the session capability needed for a progress observation.
@@ -136,15 +139,21 @@ type RowQuerier interface {
136139
// has already left the progress view.
137140
func ConcurrentIndexProgress(ctx context.Context, session RowQuerier, backendPID uint32) (IndexBuildProgress, bool, error) {
138141
var p IndexBuildProgress
139-
err := session.QueryRow(ctx, `SELECT phase, blocks_done, blocks_total, tuples_done, tuples_total
142+
var currentLockerPID *int32
143+
err := session.QueryRow(ctx, `SELECT phase, blocks_done, blocks_total, tuples_done, tuples_total,
144+
lockers_total, lockers_done, current_locker_pid
140145
FROM pg_catalog.pg_stat_progress_create_index WHERE pid = $1`, backendPID).
141-
Scan(&p.Phase, &p.BlocksDone, &p.BlocksTotal, &p.TuplesDone, &p.TuplesTotal)
146+
Scan(&p.Phase, &p.BlocksDone, &p.BlocksTotal, &p.TuplesDone, &p.TuplesTotal,
147+
&p.LockersTotal, &p.LockersDone, &currentLockerPID)
142148
if errors.Is(err, pgx.ErrNoRows) {
143149
return p, false, nil
144150
}
145151
if err != nil {
146152
return p, false, fmt.Errorf("read concurrent index progress for backend %d: %w", backendPID, err)
147153
}
154+
if currentLockerPID != nil {
155+
p.CurrentLockerPID = uint32(*currentLockerPID)
156+
}
148157
return p, true, nil
149158
}
150159

pkg/executor/native.go

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ var (
8888
// connection, every failed build would resolve indeterminate. Like an
8989
// unbounded budget, an unusable verdict is refused by construction.
9090
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")
91+
// ErrCallerOwnedNeedsCancellableContext is returned when caller-owned
92+
// mode has no cancellation signal to bound the statement.
93+
ErrCallerOwnedNeedsCancellableContext = errors.New("a caller-owned build needs a cancellable context: with statement_timeout disabled the context is the statement's only bound")
9194
// ErrCancelledExternally is returned when the build's statement was
9295
// cancelled (SQLSTATE 57014) before its overall budget elapsed: the
9396
// executor's statement_timeout cannot have fired yet, so the
@@ -121,15 +124,21 @@ const verdictTimeout = 30 * time.Second
121124
// waits by implementation, so a session lock_timeout would cancel a healthy
122125
// build mid-wait — and that cancellation is exactly what creates the invalid
123126
// index this executor exists to prevent. The statement therefore runs with
124-
// lock_timeout disabled and one overall deadline. That is safe with respect
125-
// to the lock queue: the SHARE UPDATE EXCLUSIVE lock a concurrent build
126-
// waits for does not block normal reads or writes queued behind it.
127+
// lock_timeout disabled and one bound on the whole statement: a server
128+
// deadline, or in caller-owned mode the caller's cancellable context. That
129+
// is safe with respect to the lock queue: the SHARE UPDATE EXCLUSIVE lock a
130+
// concurrent build waits for does not block normal reads or writes queued
131+
// behind it.
127132
type ConcurrentBudget struct {
128133
// Overall bounds the whole statement, waits included, via
129134
// statement_timeout. It must be at least one millisecond (PostgreSQL's
130135
// granularity); expect index builds on large tables to need a generous
131136
// value.
132137
Overall time.Duration
138+
// CallerOwned makes the caller's cancellable context the statement's only
139+
// bound: the session runs with statement_timeout disabled and Overall must
140+
// be zero. The executor refuses a context that cannot be cancelled.
141+
CallerOwned bool
133142
}
134143

135144
// maxOverallBudget is PostgreSQL's ceiling for statement_timeout (the
@@ -140,6 +149,14 @@ const maxOverallBudget = time.Duration(math.MaxInt32) * time.Millisecond
140149

141150
// validate rejects budgets that would leave the statement unbounded.
142151
func (b ConcurrentBudget) validate() error {
152+
if b.CallerOwned {
153+
// INV: LK-2 — the bound moves from the server timer to the caller's
154+
// cancellable context, checked before a session is acquired.
155+
if b.Overall != 0 {
156+
return fmt.Errorf("caller-owned budget requires Overall to be zero, got %s", b.Overall)
157+
}
158+
return nil
159+
}
143160
// INV: LK-2 — the build is bounded by construction; below one
144161
// millisecond the setting would round to zero, which disables
145162
// statement_timeout entirely.
@@ -252,9 +269,12 @@ func (e *InvalidIndexError) Unwrap() []error {
252269
// - a failed build that provably left nothing returns its failure alone
253270
// — a retry can start immediately.
254271
//
255-
// Cancellation by the overall budget surfaces as a *BudgetError; a
272+
// Cancellation by the server-owned overall budget surfaces as a *BudgetError; a
256273
// cancellation arriving before the budget elapsed cannot be the budget's
257274
// own statement_timeout and surfaces as ErrCancelledExternally instead.
275+
// In caller-owned mode the cancellable context is the only bound and
276+
// statement_timeout is disabled; SQLSTATE 57014 always surfaces as
277+
// ErrCancelledExternally.
258278
// Caller cancellation is a race: the client returns while the cancel signal
259279
// travels to the server, so a build cancelled at the finish line may still
260280
// complete. The guarantee is about the catalog, not the race: after this
@@ -288,6 +308,11 @@ func buildIndexConcurrently(ctx context.Context, pool *pgxpool.Pool, sql string,
288308
if err := b.validate(); err != nil {
289309
return rep, err
290310
}
311+
// INV: LK-2 — caller-owned mode is bounded by the caller's cancellation
312+
// signal, so a context without one is refused before any session use.
313+
if b.CallerOwned && ctx.Done() == nil {
314+
return rep, ErrCallerOwnedNeedsCancellableContext
315+
}
291316
build, err := admitConcurrentIndexBuild(sql)
292317
if err != nil {
293318
return rep, err
@@ -693,12 +718,15 @@ func acquireBudgetedSession(ctx context.Context, pool *pgxpool.Pool, b Concurren
693718

694719
// INV: LK-2 — the CONCURRENTLY exception policy: no per-lock timeout
695720
// (a lock_timeout would cancel the statement's snapshot waits and leave
696-
// the invalid index this executor exists to prevent); one overall
697-
// statement deadline bounds every statement instead. A bare integer is
721+
// the invalid index this executor exists to prevent); either one overall
722+
// statement deadline or caller cancellation bounds the build instead. A bare integer is
698723
// milliseconds to PostgreSQL; the settings are applied here regardless
699724
// of the pool's defaults.
700-
budgets := "SET lock_timeout = 0; SET statement_timeout = " +
701-
strconv.FormatInt(b.Overall.Milliseconds(), 10)
725+
statementTimeout := b.Overall.Milliseconds()
726+
if b.CallerOwned {
727+
statementTimeout = 0
728+
}
729+
budgets := "SET lock_timeout = 0; SET statement_timeout = " + strconv.FormatInt(statementTimeout, 10)
702730
if _, err := conn.Exec(ctx, budgets); err != nil {
703731
// The two SETs may have partially applied — PostgreSQL runs a
704732
// simple-query batch statement by statement — so the session must
@@ -742,6 +770,10 @@ func acquireBudgetedSession(ctx context.Context, pool *pgxpool.Pool, b Concurren
742770
func asConcurrentBudgetError(err error, b ConcurrentBudget, elapsed time.Duration) error {
743771
var pgErr *pgconn.PgError
744772
if errors.As(err, &pgErr) && pgErr.Code == sqlstateQueryCanceled {
773+
if b.CallerOwned {
774+
return fmt.Errorf("%w (after %s, caller-owned deadline): %w",
775+
ErrCancelledExternally, elapsed.Round(time.Millisecond), err)
776+
}
745777
if elapsed >= b.Overall {
746778
return &BudgetError{Cause: CauseStatement, Budget: b.Overall}
747779
}

0 commit comments

Comments
 (0)