Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/schemabot-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,13 @@ and PostgreSQL `lock_timeout`). Contract points for the PostgreSQL adapter:
rejects `AllowDestructive` (`ErrAllowDestructiveNotSupported`) and keeps refusing a
destructive plan whole, so a declarative drop the operator approves runs as its own
imperative statement.
- **`RunDesired` takes no acknowledgement.** Each of `Force`, `AcceptBlocking`,
`AllowDestructive`, `CopySwap.Restart`, and `CopySwap.BlockWrites` accepts a loss for one
statement the operator wrote, and a desired-state run executes statements the plan derived.
Setting any of them returns its own sentinel before any database work:
`ErrForceNotSupported`, `ErrAcceptBlockingNotSupported`, `ErrAllowDestructiveNotSupported`,
`ErrRestartNotSupported`, or `ErrBlockWritesNotSupported`. An adapter that reuses one
`Options` value for both front doors clears them before calling `RunDesired`.
- **Plan-time verdicts are advisory; the executor re-resolves.** The stored verdict exists so
operators learn about engine limitations at plan time, but it is estimate-based and stale by
apply time. SchemaBot's own engines re-evaluate the refusal/policy predicate per statement
Expand Down
6 changes: 6 additions & 0 deletions pkg/migrate/accept_blocking.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ var (
// where REINDEX TABLE needs a table — so there is no lock to
// acknowledge. Nothing has executed.
ErrAcceptBlockingWrongRelationKind = errors.New("the accepted statement names a relation of the wrong kind")
// ErrAcceptBlockingNotSupported is returned by [RunDesired] when
// Options.AcceptBlocking is set: the operator accepts one blocking
// statement they wrote, and desired-state execution runs statements the
// plan derived.
ErrAcceptBlockingNotSupported = errors.New(
"the accept-blocking acknowledgement applies to the imperative front door only; desired-state execution runs no blocking statement it was not told to")
)

// AcceptedRefusal returns the refusal's in-process proof when the
Expand Down
8 changes: 8 additions & 0 deletions pkg/migrate/copy_swap_restart.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package migrate

import (
"errors"
"fmt"
"log/slog"

"github.com/block/pg-sprite/pkg/router"
"github.com/block/pg-sprite/pkg/statement"
)

// ErrRestartNotSupported is returned by [RunDesired] when
// Options.CopySwap.Restart is set: rerunning a desired schema resumes a
// stopped copy-and-swap, and discarding its progress is a decision about
// one statement the operator names on the imperative front door.
var ErrRestartNotSupported = errors.New(
"the restart acknowledgement applies to the imperative front door only; desired-state execution resumes a stopped copy-and-swap")

// checkRestartAck confirms that the restart acknowledgement names the
// resolved target table, exactly as the force acknowledgement must: a
// typed table name is what stops the flag from being left on a script and
Expand Down
21 changes: 8 additions & 13 deletions pkg/migrate/desired.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,12 @@ func (r DesiredResult) refused(ref verdict.Refusal, detail string) DesiredResult
// the operational error. An error with a zero result means the pipeline
// stopped before planning or executing anything.
//
// Options.Force, Options.AllowDestructive and Options.CopySwap.BlockWrites
// are rejected: the declarative front door never runs a submitted form
// blind, refuses a destructive plan whole, and never takes a table offline
// for writes. A destructive, force-worthy or offline change belongs on the
// imperative front door where the operator states it explicitly.
// Every typed acknowledgement — Options.Force, AcceptBlocking,
// AllowDestructive, CopySwap.Restart and CopySwap.BlockWrites — is rejected
// with its own sentinel: each accepts a loss for one statement the operator
// wrote, and the declarative front door runs statements the plan derived. A
// destructive, blocking, force-worthy, restarted or offline change belongs
// on the imperative front door where the operator states it explicitly.
//
// Every planned statement runs back through [Run], so Run's note on the
// pool's search_path applies to the pool passed here.
Expand All @@ -197,14 +198,8 @@ func RunDesired(ctx context.Context, pool *pgxpool.Pool, req DesiredRequest, opt
if err := opts.validate(); err != nil {
return DesiredResult{}, err
}
if opts.Force != "" {
return DesiredResult{}, ErrForceNotSupported
}
if opts.AllowDestructive != "" {
return DesiredResult{}, ErrAllowDestructiveNotSupported
}
if opts.CopySwap.BlockWrites != "" {
return DesiredResult{}, ErrBlockWritesNotSupported
if err := rejectDesiredAcks(opts); err != nil {
return DesiredResult{}, err
}
report, err := diffplan.Plan(ctx, pool, diffplan.Request{Schema: req.Schema, Desired: req.Desired})
if ref, ok := desiredShapeRefusal(err); ok {
Expand Down
183 changes: 183 additions & 0 deletions pkg/migrate/irreversible_registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package migrate

import (
"github.com/block/pg-sprite/pkg/planner"
"github.com/block/pg-sprite/pkg/statement"
"github.com/block/pg-sprite/pkg/verdict"
)

// This file is the irreversible-operations registry: every operation
// pg-sprite can run that cannot be undone, what it loses, and the typed
// acknowledgement that lifts its refusal on the imperative front door. Each
// acknowledgement names the resolved SCHEMA.TABLE, so one left on a script
// cannot reach another table. Desired-state execution takes none of them:
// it refuses a plan holding a destructive statement whole, and rejects
// every acknowledgement before any database work. The destructive operation
// shapes are not listed here but derived from the planner, the one answer
// both destructive gates use. TestIrreversibleOperationRegistryIsComplete
// fails when an acknowledgement or a destructive shape arrives without an
// entry.

// ackKind is one typed acknowledgement the imperative front door takes.
// Its value is the migrate command's flag name.
type ackKind string

const (
ackForce ackKind = "force"
ackAcceptBlocking ackKind = "accept-blocking"
ackAllowDestructive ackKind = "allow-destructive"
ackRestart ackKind = "restart"
ackBlockWrites ackKind = "block-writes"
)

// ackKinds is the closed set of acknowledgements, in the order RunDesired
// rejects them.
func ackKinds() []ackKind {
return []ackKind{ackForce, ackAcceptBlocking, ackAllowDestructive, ackRestart, ackBlockWrites}
}

// value is the acknowledgement as opts carries it; empty means not given.
// The bool is false only for a kind this switch does not name, which the
// registry's completeness test rejects.
func (a ackKind) value(opts Options) (string, bool) {
switch a {
case ackForce:
return opts.Force, true
case ackAcceptBlocking:
return opts.AcceptBlocking, true
case ackAllowDestructive:
return opts.AllowDestructive, true
case ackRestart:
return opts.CopySwap.Restart, true
case ackBlockWrites:
return opts.CopySwap.BlockWrites, true
default:
return "", false
}
}

// desiredErr is the sentinel RunDesired returns when the acknowledgement
// is given.
func (a ackKind) desiredErr() error {
switch a {
case ackForce:
return ErrForceNotSupported
case ackAcceptBlocking:
return ErrAcceptBlockingNotSupported
case ackAllowDestructive:
return ErrAllowDestructiveNotSupported
case ackRestart:
return ErrRestartNotSupported
case ackBlockWrites:
return ErrBlockWritesNotSupported
default:
return nil
}
}

// rejectDesiredAcks returns the sentinel for the first acknowledgement opts
// carries, or nil when it carries none.
func rejectDesiredAcks(opts Options) error {
for _, a := range ackKinds() {
if v, _ := a.value(opts); v != "" {
return a.desiredErr()
}
}
return nil
}

// loss is what an irreversible operation cannot give back.
type loss string

const (
// lossStructure: the planner marks the shape destructive — a dropped
// column's values, a constraint's or NOT NULL's guarantee, an index.
lossStructure loss = "data-or-structure"
// lossAvailability: the table's reads or writes stop for the length of
// the operation.
lossAvailability loss = "availability"
// lossProgress: a stopped copy-and-swap's shadow, checkpoint row, and
// write fence or replication slot.
lossProgress loss = "progress"
)

// desiredHandling is what desired-state execution does about an entry.
type desiredHandling string

const (
// desiredRefusesPlan: the planner can derive the operation, and
// admission refuses any plan holding it, whole.
desiredRefusesPlan desiredHandling = "refuses-plan"
// desiredRejectsAck: no plan derives the operation on its own, and the
// acknowledgement that would allow it is rejected.
desiredRejectsAck desiredHandling = "rejects-ack"
)

// irreversibleOp is one registry entry.
type irreversibleOp struct {
// key names the operation: a destructive shape, a blocking statement
// kind, the submitted form, or a copy-and-swap lifecycle action.
key string
// loss is what the operation cannot give back.
loss loss
// ack lifts the refusal on the imperative front door.
ack ackKind
// refusal is the reason the operation is refused with when ack is
// missing. Empty only for a progress loss: without the acknowledgement
// the run resumes, so nothing is discarded and nothing is refused.
refusal verdict.Reason
// desired is what desired-state execution does about it.
desired desiredHandling
}

// irreversibleOps is the registry: one entry per destructive operation
// shape, derived from the planner, followed by the operations no shape
// classifies.
func irreversibleOps() []irreversibleOp {
var ops []irreversibleOp
for _, k := range planner.DestructiveOpKinds() {
ops = append(ops, irreversibleOp{
key: "shape: " + statement.Op{Kind: k}.Describe(),
loss: lossStructure,
ack: ackAllowDestructive,
refusal: verdict.ReasonDestructiveChange,
desired: desiredRefusesPlan,
})
}
return append(ops,
irreversibleOp{
key: "blocking DROP INDEX",
loss: lossAvailability,
ack: ackAcceptBlocking,
refusal: verdict.ReasonIndexStatement,
desired: desiredRejectsAck,
},
irreversibleOp{
key: "blocking REINDEX",
loss: lossAvailability,
ack: ackAcceptBlocking,
refusal: verdict.ReasonIndexStatement,
desired: desiredRejectsAck,
},
irreversibleOp{
key: "submitted form that blocks the table",
loss: lossAvailability,
ack: ackForce,
refusal: verdict.ReasonRewriteRequired,
desired: desiredRejectsAck,
},
irreversibleOp{
key: "copy-and-swap restart",
loss: lossProgress,
ack: ackRestart,
desired: desiredRejectsAck,
},
irreversibleOp{
key: "copy-and-swap with the table's writes blocked",
loss: lossAvailability,
ack: ackBlockWrites,
refusal: verdict.ReasonCopyAndSwapRefused,
desired: desiredRejectsAck,
},
)
}
Loading
Loading