Bladeren bron

fix: evaluate ALTER TABLE clock defaults at one statement instant

ADD COLUMN sampled the clock in the CHECK/UNIQUE validation pass and
again in the row rewrite. With a literal timestamp boundary in a CHECK,
a clock crossing between the two passes could publish rows that violated
the constraint the statement had just validated.

AddColumn now samples one instant for the whole statement and passes it
through the default-resolution path: the CHECK validation, the UNIQUE
prediction (for constant and absent defaults), and the rewrite all
observe the same value. DefaultEvaluator takes the instant explicitly,
and SchemaManager gains a session clock installed by executor.New so the
injected session clock still drives storage-side evaluation. DML keeps
its once-per-statement instant.

Tests: the ADD COLUMN backfill and clock-default CHECK/UNIQUE tests now
assert statement-time behavior, and a stepping-clock CHECK regression
proves the DDL cannot publish a violating row.
Danilo Fragoso 1 dag geleden
bovenliggende
commit
39e6cc1552

+ 3 - 3
pkg/capabilities/capabilities.go

@@ -171,12 +171,12 @@ func Current() *Document {
 			CompositePK:   Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "declare a single-column primary key"},
 			ForeignKey:    Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "inline and table-level FOREIGN KEY are rejected; enforce referential integrity in the application, cascades are not implemented"},
 			Check:         Feature{Supported: true, Status: "enforced", SQLState: "23514", Notes: "inline and table-level CHECK are evaluated on INSERT, multi-row INSERT, UPDATE, and ON CONFLICT DO UPDATE; NULL/unknown results pass; predicates are restricted to deterministic, subquery-free expressions over unqualified columns; exposed in sqlite_master/sqlite_schema table SQL only (pg_constraint and information_schema.check_constraints are not emulated)"},
-			DefaultValues: Feature{Supported: true, Status: "evaluated_at_write_time", SQLState: "0A000", Notes: "constant defaults are stored as their evaluated value; CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, datetime('now'), date('now'), and time('now') are stored as SQL text and evaluated once per statement on write (per row for an ADD COLUMN backfill), so they are never frozen at DDL time; any other non-constant default is rejected with 0A000"},
+			DefaultValues: Feature{Supported: true, Status: "evaluated_at_write_time", SQLState: "0A000", Notes: "constant defaults are stored as their evaluated value; CURRENT_TIMESTAMP, CURRENT_DATE, CURRENT_TIME, datetime('now'), date('now'), and time('now') are stored as SQL text and evaluated once per statement on write (once per ALTER statement for an ADD COLUMN backfill), so they are never frozen at DDL time; any other non-constant default is rejected with 0A000"},
 		},
 
 		DDL: DDL{
-			AddColumn:              Feature{Supported: true, Status: "materializes_defaults", Notes: "adds one nullable column; a constant DEFAULT is written into every existing row, and a whitelisted clock default is evaluated per rewritten row, so the rewrite cost is O(rows) and holds the table's exclusive gate"},
-			AddColumnBackfill:      Feature{Supported: true, Status: "rewrites_rows", Notes: "a DEFAULT is materialized into existing rows during the rewrite: constants as their stored value, whitelisted clock defaults evaluated per row; ADD COLUMN ... NOT NULL without a DEFAULT fails with 23502 on a non-empty table, ADD COLUMN ... UNIQUE conflicts with 23505 when backfilling a non-NULL default into more than one row (a constant default is predicted before the rewrite, a clock default is validated against the values the rewrite writes), ADD COLUMN ... CHECK fails with 23514 when an existing row or the backfilled default violates the predicate, and the column is published together with its CHECK constraints and UNIQUE index in one atomic schema write"},
+			AddColumn:              Feature{Supported: true, Status: "materializes_defaults", Notes: "adds one nullable column; a constant DEFAULT is written into every existing row, and a whitelisted clock default is evaluated once per ALTER statement, so the rewrite cost is O(rows) and holds the table's exclusive gate"},
+			AddColumnBackfill:      Feature{Supported: true, Status: "rewrites_rows", Notes: "a DEFAULT is materialized into existing rows during the rewrite: constants as their stored value, whitelisted clock defaults evaluated at one instant per ALTER statement; ADD COLUMN ... NOT NULL without a DEFAULT fails with 23502 on a non-empty table, ADD COLUMN ... UNIQUE conflicts with 23505 when backfilling a non-NULL default into more than one row (a constant default is predicted before the rewrite, a clock default is validated against the values the rewrite writes), ADD COLUMN ... CHECK fails with 23514 when an existing row or the backfilled default violates the predicate, and the column is published together with its CHECK constraints and UNIQUE index in one atomic schema write"},
 			DropColumn:             Feature{Supported: true, Status: "rewrites_rows", Notes: "rejected with 0A000 when a persisted CHECK constraint references the column; the stored predicate text cannot be rewritten"},
 			RenameColumn:           Feature{Supported: true, Status: "rewrites_rows", Notes: "rejected with 0A000 when a persisted CHECK constraint or expression index references the column; the stored expression text cannot be rewritten"},
 			RenameColumnIndexes:    Feature{Supported: true, Status: "rewrites_index_metadata", Notes: "column references in index definitions are rewritten in place, including composite and unique indexes; index names are unchanged; RENAME COLUMN is rejected when an expression index references the column (drop that index, rename, then recreate it)"},

+ 49 - 7
pkg/executor/check_constraint_test.go

@@ -262,26 +262,35 @@ func TestAlterTableAddColumnCheckValidatesExistingRows(t *testing.T) {
 
 // A clock-reading default must reach an ADD COLUMN ... CHECK predicate as the
 // value the backfill writes, not as NULL (which CHECK always passes). Validation
-// resolves the default through the same clock-aware evaluator as the rewrite, so
-// a per-row clock default is checked per row.
+// resolves the default at the statement instant, and the rewrite writes that
+// same instant, so every backfilled row carries the value the predicate checked.
 func TestAlterTableAddColumnCheckSeesClockDefault(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
-	e.clock = (&steppingClock{now: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}).Now
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
 
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
 	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
 	execMust(t, e, "ALTER TABLE t ADD COLUMN ts TEXT DEFAULT CURRENT_TIMESTAMP CHECK (ts IS NOT NULL)")
 
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want once per ALTER statement", calls)
+	}
 	res := execMust(t, e, "SELECT id, ts FROM t ORDER BY id")
 	if res.RowCount != 2 {
 		t.Fatalf("rows = %d, want 2", res.RowCount)
 	}
-	if res.Rows[0][1] == nil || res.Rows[1][1] == nil {
-		t.Fatalf("backfilled ts = %v, want evaluated timestamps", res.Rows)
+	if got, want := res.Rows[0][1], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("row 1 ts = %v, want the statement instant %q", got, want)
 	}
-	if res.Rows[0][1] == res.Rows[1][1] {
-		t.Fatalf("both rows got ts %v; the backfill evaluates per row", res.Rows[0][1])
+	if res.Rows[0][1] != res.Rows[1][1] {
+		t.Fatalf("timestamps %v and %v differ; the backfill must use one statement instant", res.Rows[0][1], res.Rows[1][1])
 	}
 
 	stored, err := schema.GetSchema("t")
@@ -297,6 +306,7 @@ func TestAlterTableAddColumnCheckSeesClockDefault(t *testing.T) {
 
 	// A clock default that violates the predicate rejects the DDL. NULL passes
 	// CHECK, so this also proves the predicate saw the evaluated value.
+	e.clock = newTestClock().Now
 	_, err = execSQL(e, "ALTER TABLE t ADD COLUMN bad_ts TEXT DEFAULT CURRENT_TIMESTAMP CHECK (bad_ts < '2026-01-02 03:04:05')")
 	requireCheckViolation(t, err, "")
 	stored, err = schema.GetSchema("t")
@@ -311,6 +321,38 @@ func TestAlterTableAddColumnCheckSeesClockDefault(t *testing.T) {
 	}
 }
 
+// One instant is sampled per ALTER statement and shared by the CHECK validation
+// and the rewrite. A stepping clock reproduces the old race: a second sample in
+// the rewrite could cross a literal boundary and publish a row that violates the
+// predicate the statement just validated.
+func TestAlterTableAddColumnCheckUsesOneInstantForValidationAndRewrite(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	boundary := "2026-01-02 03:04:06"
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
+
+	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
+	execMust(t, e, "ALTER TABLE t ADD COLUMN ts TEXT DEFAULT CURRENT_TIMESTAMP CHECK (ts < '"+boundary+"')")
+
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want one instant per ALTER statement", calls)
+	}
+	res := execMust(t, e, "SELECT ts FROM t WHERE id = 1")
+	if got := res.Rows[0][0]; got == nil || got == boundary || compare(got, boundary) >= 0 {
+		t.Fatalf("backfilled ts = %v, want the pre-boundary statement instant %q", got, base.Format("2006-01-02 15:04:05"))
+	}
+	if len(execMust(t, e, "SELECT ts FROM t").Rows) != 1 {
+		t.Fatal("ADD COLUMN ... CHECK published an unexpected row set")
+	}
+}
+
 // A constant default that violates an ADD COLUMN ... CHECK still fails with
 // 23514 and publishes nothing.
 func TestAlterTableAddColumnCheckRejectsViolatingConstantDefault(t *testing.T) {

+ 2 - 8
pkg/executor/ddl_defaults.go

@@ -181,15 +181,9 @@ func (e *Executor) statementDefaults(schema *storage.Schema) (map[string]interfa
 	return defaults, nil
 }
 
-// evalStorageDefault is the storage-facing evaluator. ADD COLUMN evaluates a
-// default once per rewritten row, so every call samples the executor clock;
-// that keeps a storage-side backfill on the same clock as the session.
-func (e *Executor) evalStorageDefault(expression string) (interface{}, error) {
-	return evalDefaultExpressionAt(expression, e.statementNow())
-}
-
 // statementNow returns the instant one statement's expression defaults use.
-// Tests inject e.clock to make that instant deterministic.
+// The executor samples it once per INSERT/UPDATE; the storage layer samples it
+// once per ALTER TABLE statement. Tests inject e.clock to make it deterministic.
 func (e *Executor) statementNow() time.Time {
 	if e.clock != nil {
 		return e.clock().UTC()

+ 34 - 28
pkg/executor/ddl_defaults_test.go

@@ -202,25 +202,33 @@ func TestMultiRowInsertSharesStatementDefault(t *testing.T) {
 	}
 }
 
-func TestAlterTableAddColumnBackfillsNonConstantDefaultPerRow(t *testing.T) {
+func TestAlterTableAddColumnBackfillsNonConstantDefaultOncePerStatement(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
-	// The storage-side evaluator samples the executor clock for every rewritten
-	// row, so a stepping clock shows per-row evaluation without patching the
-	// schema manager.
-	e.clock = (&steppingClock{now: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}).Now
+	// A stepping clock makes a per-row evaluation visible: every backfilled row
+	// must share the one instant sampled for the ALTER statement.
+	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+	calls := 0
+	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
+		calls++
+		return at
+	}
 
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
 	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
 	execMust(t, e, "ALTER TABLE t ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
 
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want once per ALTER statement", calls)
+	}
 	res := execMust(t, e, "SELECT id, created_at FROM t ORDER BY id")
 	first, second := res.Rows[0][1], res.Rows[1][1]
-	if first == second {
-		t.Fatalf("both rows were stamped %v; the ADD COLUMN rewrite must evaluate each row", first)
+	if first != second {
+		t.Fatalf("timestamps %v and %v differ; the backfill must use one statement instant", first, second)
 	}
-	if compare(first, second) >= 0 {
-		t.Fatalf("timestamps %v then %v are not ordered", first, second)
+	if got, want := first, "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("created_at = %v, want the statement instant %q", got, want)
 	}
 
 	col, ok := e.schemaColumn(t, "t", "created_at")
@@ -305,41 +313,39 @@ func TestAlterTableAddColumnUniqueClockDefaultRejectsDuplicates(t *testing.T) {
 	}
 }
 
-func TestAlterTableAddColumnUniqueClockDefaultValidatesTheRewrite(t *testing.T) {
+func TestAlterTableAddColumnUniqueClockDefaultUsesStatementInstant(t *testing.T) {
 	_, schema, table := newTestDB(t)
 	e := newExec(schema, table)
 
-	// The first two samples (the rewrite of rows 1 and 2) are distinct; later
-	// samples collide. A separate prediction scan would have seen the distinct
-	// values and then published an index over the colliding rewrite values.
 	base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
 	calls := 0
 	e.clock = func() time.Time {
+		at := base.Add(time.Duration(calls) * time.Second)
 		calls++
-		if calls == 2 {
-			return base.Add(time.Second)
-		}
-		if calls > 2 {
-			return base.Add(2 * time.Second)
-		}
-		return base
+		return at
 	}
 
 	execMust(t, e, "CREATE TABLE t (id INTEGER PRIMARY KEY)")
-	execMust(t, e, "INSERT INTO t (id) VALUES (1), (2)")
+	execMust(t, e, "INSERT INTO t (id) VALUES (1)")
 	execMust(t, e, "ALTER TABLE t ADD COLUMN tag TEXT DEFAULT CURRENT_TIMESTAMP UNIQUE")
 
-	if calls != 2 {
-		t.Fatalf("clock sampled %d times, want once per rewritten row (2); the UNIQUE check must validate the rewrite itself", calls)
+	if calls != 1 {
+		t.Fatalf("clock sampled %d times, want once per ALTER statement", calls)
 	}
 	res := execMust(t, e, "SELECT id, tag FROM t ORDER BY id")
-	if res.Rows[0][1] == res.Rows[1][1] {
-		t.Fatalf("both rows got tag %v; the published index must cover distinct rewrite values", res.Rows[0][1])
+	if got, want := res.Rows[0][1], "2026-01-02 03:04:05"; got != want {
+		t.Fatalf("tag = %v, want the statement instant %q", got, want)
+	}
+
+	// A later INSERT samples its own statement instant, so it does not collide.
+	execMust(t, e, "INSERT INTO t (id) VALUES (2)")
+	next := execMust(t, e, "SELECT tag FROM t WHERE id = 2")
+	if next.Rows[0][0] == res.Rows[0][1] {
+		t.Fatalf("later INSERT tag = %v, want a new statement instant", next.Rows[0][0])
 	}
 
-	// The published index enforces uniqueness for later writes.
-	execMust(t, e, "INSERT INTO t (id) VALUES (3)")
-	if _, err := execSQL(e, "INSERT INTO t (id, tag) VALUES (4, '"+res.Rows[0][1].(string)+"')"); err == nil {
+	// The published index still rejects an explicit duplicate.
+	if _, err := execSQL(e, "INSERT INTO t (id, tag) VALUES (3, '"+res.Rows[0][1].(string)+"')"); err == nil {
 		t.Fatal("expected the published UNIQUE index to reject a duplicate")
 	}
 }

+ 8 - 6
pkg/executor/executor.go

@@ -132,10 +132,12 @@ func New(schema *storage.SchemaManager, table *storage.TableManager) *Executor {
 	table.SetExpressionEvaluator(EvalStoredExpression)
 	// Non-constant DEFAULT expressions are likewise stored as SQL text; storage
 	// evaluates them when it backfills ADD COLUMN rows or completes a row image
-	// that predates the column. The evaluator closes over this executor so the
-	// session clock also drives storage-side evaluation. Ordinary writes
-	// evaluate them once per statement in this package.
-	schema.SetDefaultEvaluator(executor.evalStorageDefault)
+	// that predates the column. Storage samples the clock once per ALTER
+	// statement through this executor, so the session clock also drives
+	// storage-side evaluation; ordinary writes evaluate defaults once per
+	// statement in this package.
+	schema.SetDefaultEvaluator(evalDefaultExpressionAt)
+	schema.SetDefaultClock(executor.statementNow)
 	schema.SetCheckEvaluator(EvalCheckExpression)
 
 	// Register the main database
@@ -4151,8 +4153,8 @@ func (e *Executor) executeAlterTable(stmt *parser.AlterTableStmt) (*Result, erro
 // into every existing row (see SchemaManager.AddColumn), so the column reads
 // the default on rows that predate the statement as well as on later INSERTs
 // that omit it. A constant default is copied from the catalog; a non-constant
-// one is evaluated per rewritten row, so CURRENT_TIMESTAMP stamps the backfill
-// at rewrite time.
+// one is evaluated at a single instant sampled for the ALTER statement, so
+// validation and the backfill write the same CURRENT_TIMESTAMP value.
 func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddColumnAction) (*Result, error) {
 	schema, err := e.schema.GetSchema(table)
 	if err != nil {

+ 6 - 4
pkg/storage/constraints.go

@@ -3,6 +3,7 @@ package storage
 import (
 	"fmt"
 	"strings"
+	"time"
 )
 
 // PrimaryKeyViolationError preserves duplicate-key identity through wrapped
@@ -106,7 +107,7 @@ func (m *SchemaManager) applySchemaDefaults(schema *Schema, row Row) error {
 		if _, ok := rowColumnValue(row, column.Name); ok {
 			continue
 		}
-		value, ok, err := m.columnDefaultValue(column)
+		value, ok, err := m.columnDefaultValue(column, m.defaultNow())
 		if err != nil {
 			return err
 		}
@@ -120,14 +121,15 @@ func (m *SchemaManager) applySchemaDefaults(schema *Schema, row Row) error {
 // columnDefaultValue returns the value a row receives for a column it omits.
 // A column with no default reports ok=false. DefaultExpr takes precedence over
 // Default; the two are never both set by DDL, which keeps constant defaults in
-// the catalog as values and clock-reading defaults as SQL text.
-func (m *SchemaManager) columnDefaultValue(column Column) (interface{}, bool, error) {
+// the catalog as values and clock-reading defaults as SQL text. now is the
+// instant the caller sampled for its statement; a constant default ignores it.
+func (m *SchemaManager) columnDefaultValue(column Column, now time.Time) (interface{}, bool, error) {
 	if column.DefaultExpr != "" {
 		eval := m.defaultEvaluator()
 		if eval == nil {
 			return nil, false, fmt.Errorf("column %q has a DEFAULT expression but no evaluator is installed", column.Name)
 		}
-		value, err := eval(column.DefaultExpr)
+		value, err := eval(column.DefaultExpr, now)
 		if err != nil {
 			return nil, false, fmt.Errorf("evaluate DEFAULT for column %q: %w", column.Name, err)
 		}

+ 66 - 26
pkg/storage/schema.go

@@ -136,14 +136,23 @@ type SchemaManager struct {
 	// defaultEval evaluates a persisted non-constant DEFAULT expression. The
 	// SQL executor registers one so storage can backfill ADD COLUMN rows and
 	// complete row images without importing the parser or executor.
+	// defaultClock supplies the instant a DDL statement samples once for every
+	// expression default it touches.
 	defaultEvalMu sync.RWMutex
 	defaultEval   DefaultEvaluator
+	defaultClock  DefaultClock
 }
 
 // DefaultEvaluator evaluates a persisted non-constant DEFAULT expression, such
-// as CURRENT_TIMESTAMP, to the value a row receives. Expression defaults are
-// never frozen at DDL time: each write reads the clock again.
-type DefaultEvaluator func(expression string) (interface{}, error)
+// as CURRENT_TIMESTAMP, at the supplied instant. Expression defaults are never
+// frozen at DDL time; the caller samples one instant per statement so every row
+// it touches sees the same value.
+type DefaultEvaluator func(expression string, now time.Time) (interface{}, error)
+
+// DefaultClock supplies the instant a statement's expression defaults use. The
+// SQL executor registers a clock backed by the session so a storage-side ADD
+// COLUMN backfill matches statement-time now().
+type DefaultClock func() time.Time
 
 // SetDefaultEvaluator installs the evaluator used for expression defaults.
 func (m *SchemaManager) SetDefaultEvaluator(eval DefaultEvaluator) {
@@ -152,12 +161,33 @@ func (m *SchemaManager) SetDefaultEvaluator(eval DefaultEvaluator) {
 	m.defaultEvalMu.Unlock()
 }
 
+// SetDefaultClock installs the clock sampled once per DDL statement for
+// expression defaults.
+func (m *SchemaManager) SetDefaultClock(clock DefaultClock) {
+	m.defaultEvalMu.Lock()
+	m.defaultClock = clock
+	m.defaultEvalMu.Unlock()
+}
+
 func (m *SchemaManager) defaultEvaluator() DefaultEvaluator {
 	m.defaultEvalMu.RLock()
 	defer m.defaultEvalMu.RUnlock()
 	return m.defaultEval
 }
 
+// defaultNow samples the registered clock, falling back to the wall clock.
+// Callers sample it once per statement and pass the instant down so validation
+// and the rewrite observe the same value.
+func (m *SchemaManager) defaultNow() time.Time {
+	m.defaultEvalMu.RLock()
+	clock := m.defaultClock
+	m.defaultEvalMu.RUnlock()
+	if clock != nil {
+		return clock().UTC()
+	}
+	return time.Now().UTC()
+}
+
 // CheckEvaluator reports whether a persisted CHECK expression holds for a row.
 // A false result is a violation; NULL and other unknown results must be
 // reported as true, matching SQL three-valued logic. The SQL executor registers
@@ -1054,12 +1084,13 @@ func (m *SchemaManager) removeFromIndexList(name string) error {
 // published together in one batch write so a crash cannot leave the column
 // visible without the constraints that validate it. A constant default is
 // deterministic, so its UNIQUE prediction can run as a cheap pre-scan. A
-// clock-reading default cannot be predicted: the UNIQUE duplicate set is
-// collected during the rewrite itself, because a separate scan could observe a
-// different instant and publish an index over duplicate values; the CHECK
-// pre-scan resolves the same clock default for each row it validates. A
-// nullable column without a default needs no rewrite because a missing row
-// field already reads as NULL. Like DROP COLUMN, the statement holds the
+// clock-reading default is evaluated at one instant sampled for this ALTER
+// statement, and that instant is passed to every validation scan and to the
+// rewrite, so a clock crossing between them cannot publish a row that violates
+// the constraint the statement adds; the UNIQUE duplicate set is still
+// collected during the rewrite so it covers exactly the values the index will
+// see. A nullable column without a default needs no rewrite because a missing
+// row field already reads as NULL. Like DROP COLUMN, the statement holds the
 // catalog lock for the whole scan, so the cost is one table scan per validating
 // constraint plus a table-wide stall per ADD COLUMN that has a default, an
 // index, or a check.
@@ -1071,6 +1102,12 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
+	// One instant for the whole ALTER statement: every validation scan and the
+	// row rewrite must observe the same value, or a clock crossing between them
+	// could publish rows that violate the CHECK or UNIQUE constraint this
+	// statement adds.
+	now := m.defaultNow()
+
 	schema, err := m.getSchemaUnsafe(table)
 	if err != nil {
 		return err
@@ -1085,16 +1122,17 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 		if m.indexExistsUnsafe(unique.Name) {
 			return fmt.Errorf("index already exists: %s", unique.Name)
 		}
-		// A clock-reading default is validated during the rewrite below, which
-		// samples the same values it writes. Predicting them in a separate scan
-		// could disagree across a second boundary and publish duplicates.
+		// A clock-reading default is validated during the rewrite below so the
+		// duplicate set covers exactly the values this statement writes. The
+		// pre-scan is kept for constant and absent defaults, which are
+		// deterministic.
 		if column.DefaultExpr == "" {
-			if err := m.validateAddColumnUnique(table, column, unique); err != nil {
+			if err := m.validateAddColumnUnique(table, column, unique, now); err != nil {
 				return err
 			}
 		}
 	}
-	if err := m.validateAddColumnChecks(table, column, checks); err != nil {
+	if err := m.validateAddColumnChecks(table, column, checks, now); err != nil {
 		return err
 	}
 
@@ -1110,9 +1148,10 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 		}
 	}
 
-	// An expression default is evaluated per rewritten row. When the column is
-	// also UNIQUE, collect the values this rewrite writes and abort on a
-	// duplicate, so validation cannot disagree with the published rows.
+	// An expression default is evaluated at the ALTER statement's instant. When
+	// the column is also UNIQUE, collect the values this rewrite writes and
+	// abort on a duplicate, so the index is validated against exactly the values
+	// it will see.
 	var seen map[string]struct{}
 	if unique != nil && column.DefaultExpr != "" {
 		seen = make(map[string]struct{})
@@ -1122,7 +1161,7 @@ func (m *SchemaManager) AddColumn(table string, column Column, checks []CheckCon
 			value, present := rowColumnValue(row, column.Name)
 			if !present {
 				var err error
-				value, _, err = m.columnDefaultValue(column)
+				value, _, err = m.columnDefaultValue(column, now)
 				if err != nil {
 					return false, err
 				}
@@ -1683,13 +1722,13 @@ func (m *SchemaManager) applyRows(table string, transform func(Row) (bool, error
 // its values cannot be predicted. The caller holds the table's exclusive gate,
 // so no writer can insert a conflicting row between this check and the
 // publication of the index.
-func (m *SchemaManager) validateAddColumnUnique(table string, column Column, index *Index) error {
+func (m *SchemaManager) validateAddColumnUnique(table string, column Column, index *Index, now time.Time) error {
 	seen := make(map[string]struct{})
 	return m.applyRows(table, func(row Row) (bool, error) {
 		value, ok := rowColumnValue(row, column.Name)
 		if !ok {
 			var err error
-			value, _, err = m.columnDefaultValue(column)
+			value, _, err = m.columnDefaultValue(column, now)
 			if err != nil {
 				return false, err
 			}
@@ -1716,11 +1755,12 @@ func columnHasDefault(column Column) bool {
 // already false for an existing row. Like validateAddColumnUnique it reads rows
 // as they will look after the default backfill, and it runs before that rewrite
 // so a rejected DDL leaves no row carrying the new field. The default is
-// resolved through columnDefaultValue, so a clock-reading default reaches the
-// predicate as the value the backfilled row will receive instead of NULL. The
-// caller holds the table's exclusive gate, so no writer can insert a violating
-// row between this validation and the publication of the check.
-func (m *SchemaManager) validateAddColumnChecks(table string, column Column, checks []CheckConstraint) error {
+// resolved through columnDefaultValue at the instant sampled once for the
+// ALTER statement, so a clock-reading default reaches the predicate as the value
+// the backfilled row will receive instead of NULL. The caller holds the table's
+// exclusive gate, so no writer can insert a violating row between this
+// validation and the publication of the check.
+func (m *SchemaManager) validateAddColumnChecks(table string, column Column, checks []CheckConstraint, now time.Time) error {
 	if len(checks) == 0 {
 		return nil
 	}
@@ -1730,7 +1770,7 @@ func (m *SchemaManager) validateAddColumnChecks(table string, column Column, che
 	}
 	return m.applyRows(table, func(row Row) (bool, error) {
 		if _, ok := rowColumnValue(row, column.Name); !ok {
-			value, hasDefault, err := m.columnDefaultValue(column)
+			value, hasDefault, err := m.columnDefaultValue(column, now)
 			if err != nil {
 				return false, err
 			}

+ 2 - 2
pkg/storage/table.go

@@ -540,7 +540,7 @@ func (m *TableManager) prepareInsert(table string, row Row) (Row, string, error)
 		if _, ok := normalizedRow[col.Name]; ok {
 			continue
 		}
-		value, hasDefault, err := m.schema.columnDefaultValue(col)
+		value, hasDefault, err := m.schema.columnDefaultValue(col, m.schema.defaultNow())
 		if err != nil {
 			return nil, "", err
 		}
@@ -728,7 +728,7 @@ func (m *TableManager) InsertBulkWithLastRowID(table string, rows []Row) (int, i
 			if _, ok := nr[col.Name]; ok {
 				continue
 			}
-			value, hasDefault, err := m.schema.columnDefaultValue(col)
+			value, hasDefault, err := m.schema.columnDefaultValue(col, m.schema.defaultNow())
 			if err != nil {
 				return 0, 0, err
 			}