Skip to content
Merged
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
24 changes: 24 additions & 0 deletions docs/execution-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,30 @@ concurrently is.
| `invariant-violation` | yes | A breach of the invariant registry; never a retry candidate |
| `execution-failed` | no | Fallback for a failure outside the typed set — an operational error to investigate, not a refusal to branch on |

### Create-shape causes

`executor.CreateShapeCauses()` enumerates the closed vocabulary of reasons the create
path refuses a desired statement by its shape; `executor.CreateShapeCauseOf(err)` reads
the cause off a refusal. Like outcome codes, a cause is a stable identity for automation
to branch on, never prose.

| Cause | Meaning |
| --- | --- |
| `partition-of` | Attaching a partition locks a parent the absence proof does not cover |
| `inherits` | `INHERITS` binds to an existing parent the absence proof does not cover |
| `like` | `LIKE` reads an existing source table the absence proof does not cover |
| `of-type` | `OF` binds to an existing composite type the absence proof does not cover |
| `if-not-exists` | A name-only no-op cannot prove the existing relation has the requested shape or is valid |
| `concurrently` | A table born this run needs no concurrent index build |
| `duplicate-name` | The desired set claims the same relation name twice |
| `multiple-operations` | The statement and operation parse boundaries disagree about the operation count |
| `unsupported-kind` | The statement is not a create kind the create path can run |

`concurrently`, `multiple-operations`, and `unsupported-kind` re-verify preconditions
`statement.ParseDesired` already enforces — a desired file that passed admission cannot
produce them. They are published so the vocabulary is closed, not because automation should
expect them; a consumer seeing one has a desired schema that bypassed admission.

The three cancellation codes partition one server signal, SQLSTATE `57014`,
in a fixed precedence. `budget-statement-exceeded` wins when the server's
cancel arrives at or past the overall budget, even if the caller's own
Expand Down
73 changes: 30 additions & 43 deletions pkg/executor/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ var (
// 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("desired set claims the same relation name twice")
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("CREATE TABLE PARTITION OF is not supported by the create path: attaching a partition locks the partitioned parent, which the absence proof does not cover")
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 —
Expand Down Expand Up @@ -285,7 +285,7 @@ func checkCreateSteps(schema string, ds statement.DesiredSchema) ([]createStep,
for _, name := range step.claims {
if _, taken := claimed[name]; taken {
if step.refusal == nil {
step.refusal = fmt.Errorf("%w: %q", ErrDuplicateCreateName, name)
step.refusal = &CreateShapeError{Cause: CreateShapeDuplicateName, Name: name}
}
continue
}
Expand Down Expand Up @@ -332,79 +332,66 @@ func checkCreateStepShape(schema, table, sql string) (createStep, error) {
if len(ops) != 1 {
// ParseOne admitted a single statement, so a differing op count
// means the two parse boundaries disagree about the same SQL.
step.refusal = fmt.Errorf("%w: statement carries %d operations", ErrUnsupportedCreateStep, len(ops))
step.refusal = &CreateShapeError{Cause: CreateShapeMultipleOperations}
return step, nil
}
op := ops[0]
switch st.Kind() {
case statement.KindCreateTable:
step.claims, step.refusal = checkCreateTableShape(qualified, st.Table(), op)
// The table claims its own name plus the first-choice relation
// names of its constraints and column-owned sequences. ParseOne
// already admitted this SQL as a CREATE TABLE, so a failure to read
// those names means the two parse boundaries disagree: a parse
// failure like any other, not a shape, so no positional result is
// safe and the step carries no refusal.
implicit, err := statement.ImplicitRelationNames(qualified)
if err != nil {
return createStep{}, fmt.Errorf("implicit relation names: %w", err)
}
step.claims = append([]string{st.Table()}, implicit...)
step.refusal = createTableShapeRefusal(op)
case statement.KindCreateIndex:
step.claims, step.refusal = checkCreateIndexShape(op)
// An explicit index name is the step's claim; an unnamed index
// claims nothing decidable because the server invents the name.
if op.Name != "" {
step.claims = []string{op.Name}
}
step.refusal = createIndexShapeRefusal(op)
default:
step.refusal = fmt.Errorf("%w: kind %q", ErrUnsupportedCreateStep, st.Kind())
step.refusal = &CreateShapeError{Cause: CreateShapeUnsupportedKind}
}
return step, nil
}

// checkCreateTableShape refuses the CREATE TABLE clauses that bind to a
// secondary relation or type and returns the names the table will claim:
// its own plus the first-choice relation names of its constraints and
// column-owned sequences.
// The claims are returned with the refusal so a later statement colliding
// with a refused table is still reported.
func checkCreateTableShape(qualified, table string, op statement.Op) ([]string, error) {
implicit, err := statement.ImplicitRelationNames(qualified)
if err != nil {
// ParseOne already admitted this SQL as a CREATE TABLE, so a
// refusal here means the two parse boundaries disagree.
return nil, fmt.Errorf("%w: %w", ErrUnsupportedCreateStep, err)
}
return append([]string{table}, implicit...), createTableShapeRefusal(op)
}

// createTableShapeRefusal names the CREATE TABLE clause that keeps the
// statement off the create path, nil when the shape is admitted.
func createTableShapeRefusal(op statement.Op) error {
if op.PartitionOf {
return ErrPartitionOfUnsupported
return &CreateShapeError{Cause: CreateShapePartitionOf}
}
if op.Inherits {
return fmt.Errorf("%w: INHERITS binds to an existing parent the absence proof does not cover", ErrUnsupportedCreateStep)
return &CreateShapeError{Cause: CreateShapeInherits}
}
if op.Like {
return fmt.Errorf("%w: LIKE reads an existing source table the absence proof does not cover", ErrUnsupportedCreateStep)
return &CreateShapeError{Cause: CreateShapeLike}
}
if op.OfType {
return fmt.Errorf("%w: OF binds to an existing composite type the absence proof does not cover", ErrUnsupportedCreateStep)
return &CreateShapeError{Cause: CreateShapeOfType}
}
if op.IfNotExists {
return ErrIfNotExistsUnsupported
return &CreateShapeError{Cause: CreateShapeIfNotExists}
}
return nil
}

// checkCreateIndexShape refuses index builds that cannot run against a
// table born this run and returns the explicit index name as the step's
// claim; an unnamed index claims nothing decidable. The claim is returned
// with the refusal so a later statement colliding with a refused index is
// still reported.
func checkCreateIndexShape(op statement.Op) ([]string, error) {
var claims []string
if op.Name != "" {
claims = []string{op.Name}
}
return claims, createIndexShapeRefusal(op)
}

// createIndexShapeRefusal names the CREATE INDEX clause that keeps the
// statement off the create path, nil when the shape is admitted.
func createIndexShapeRefusal(op statement.Op) error {
if op.Concurrent {
return fmt.Errorf("%w: a concurrent build is refused on a table born this run", ErrUnsupportedCreateStep)
return &CreateShapeError{Cause: CreateShapeConcurrently}
}
if op.IfNotExists {
return ErrIfNotExistsUnsupported
return &CreateShapeError{Cause: CreateShapeIfNotExists}
}
return nil
}
Expand Down
54 changes: 54 additions & 0 deletions pkg/executor/create_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// White-box tests for create-step shape checking: the refusal causes the
// create path assigns below the ParseDesired boundary, which already turns
// away the shapes that reach them, so they are provable only here.

package executor

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCheckCreateStepShapeAssignsCause(t *testing.T) {
tests := []struct {
name string
sql string
claims []string
cause CreateShapeCause
}{
{
name: "concurrent index build",
sql: "CREATE INDEX CONCURRENTLY t_v_idx ON t (v)",
claims: []string{"t_v_idx"},
cause: CreateShapeConcurrently,
},
{
name: "alter table is not a create kind",
sql: "ALTER TABLE t ADD COLUMN v int",
cause: CreateShapeUnsupportedKind,
},
{
name: "admitted table claims its implicit names",
sql: "CREATE TABLE t (id serial PRIMARY KEY)",
claims: []string{"t", "t_id_seq", "t_pkey"},
},
{
name: "unnamed index claims nothing",
sql: "CREATE INDEX ON t (v)",
cause: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
step, err := checkCreateStepShape("app", "t", tc.sql)
require.NoError(t, err)
assert.Equal(t, tc.claims, step.claims)
assert.Equal(t, tc.cause, CreateShapeCauseOf(step.refusal))
if tc.cause == "" {
assert.NoError(t, step.refusal)
}
})
}
}
134 changes: 134 additions & 0 deletions pkg/executor/create_shape.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// This file is the create path's shape-refusal vocabulary: every reason the
// create path refuses a desired statement by its shape maps to exactly one
// flat kebab-case CreateShapeCause, the same treatment code.go gives
// executor outcomes. A plan or verdict consumer branches on the cause —
// never on error prose — and each cause owns its one human sentence, so the
// sentinel errors, the typed refusal, and every renderer read the same text.

package executor

import (
"errors"
"fmt"
)

// 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.
type CreateShapeCause string

// The causes for which the create path can refuse a desired statement.
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"
)

// CreateShapeCauses returns the closed set of create-shape refusal causes.
func CreateShapeCauses() []CreateShapeCause {
return []CreateShapeCause{
CreateShapePartitionOf,
CreateShapeInherits,
CreateShapeLike,
CreateShapeOfType,
CreateShapeIfNotExists,
CreateShapeConcurrently,
CreateShapeDuplicateName,
CreateShapeMultipleOperations,
CreateShapeUnsupportedKind,
}
}

// Description returns the human-facing sentence for a create-shape cause.
func (c CreateShapeCause) Description() string {
switch c {
case CreateShapePartitionOf:
return "CREATE TABLE PARTITION OF is not supported by the create path: attaching a partition locks the partitioned parent, which the absence proof does not cover"
case CreateShapeInherits:
return "INHERITS binds to an existing parent the absence proof does not cover"
case CreateShapeLike:
return "LIKE reads an existing source table the absence proof does not cover"
case CreateShapeOfType:
return "OF binds to an existing composite type the absence proof does not cover"
case CreateShapeIfNotExists:
return "IF NOT EXISTS is not supported: a name-only no-op cannot prove the existing relation is the requested one, or even valid"
case CreateShapeConcurrently:
return "a concurrent build is refused on a table born this run"
case CreateShapeDuplicateName:
return "desired set claims the same relation name twice"
case CreateShapeMultipleOperations:
return "statement carries multiple operations"
case CreateShapeUnsupportedKind:
return "statement kind is not supported by the create path"
default:
return "unknown create-shape refusal"
}
}

// CreateShapeError identifies a create-shape refusal. Name is populated only
// when Cause is CreateShapeDuplicateName.
type CreateShapeError struct {
Cause CreateShapeCause
Name string
}

// Error renders the create-shape refusal for a human reader.
func (e *CreateShapeError) Error() string {
if e.Cause == CreateShapeDuplicateName && e.Name != "" {
return fmt.Sprintf("%s: %q", e.Cause.Description(), e.Name)
}
return e.Cause.Description()
}

// Unwrap exposes the existing sentinel boundary for errors.Is callers.
func (e *CreateShapeError) Unwrap() error {
switch e.Cause {
case CreateShapePartitionOf:
return ErrPartitionOfUnsupported
case CreateShapeIfNotExists:
return ErrIfNotExistsUnsupported
case CreateShapeDuplicateName:
return ErrDuplicateCreateName
case CreateShapeInherits, CreateShapeLike, CreateShapeOfType, CreateShapeConcurrently,
CreateShapeMultipleOperations, CreateShapeUnsupportedKind:
return ErrUnsupportedCreateStep
default:
return nil
}
}

// 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 CreateShapeCauseOf(err error) CreateShapeCause {
var shapeErr *CreateShapeError
if errors.As(err, &shapeErr) {
return shapeErr.Cause
}
return ""
}
Loading
Loading