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
32 changes: 32 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- [Storage Dialect](#storage-dialect)
- [Storage Connection Pool](#storage-connection-pool)
- [Spirit Run Settings](#spirit-run-settings)
- [Postgres](#postgres)
- [PlanetScale mTLS](#planetscale-mtls)
- [Storage Schema Changes](#storage-schema-changes)
- [Support Channel](#support-channel)
Expand Down Expand Up @@ -581,6 +582,37 @@ These settings only apply where this server constructs the Spirit engine
itself — local-mode MySQL databases. Databases routed to a remote deployment
over gRPC run with that deployment's engine settings.

## Postgres

The `postgres:` block sets the largest table on which the PostgreSQL engine
will execute native-safe DDL. The limit is expressed in bytes and defaults to
1 GiB:

```yaml
postgres:
native_safe_table_size_limit_bytes: 4294967296
```

The ceiling is SchemaBot's own conservatism about how much work to attempt
under an exclusive lock, not a PostgreSQL limit. A native-safe `ALTER` that
rewrites a table rebuilds its heap, its indexes, and its TOAST data while
holding `ACCESS EXCLUSIVE`, and the size compared against the ceiling is the
total relation size summed across the table's partition tree — indexes and
TOAST included — so an index-heavy table cannot slip under it. Raising the
ceiling converts an up-front refusal into a bounded attempt, not an unbounded
lock: every apply still runs under short `lock_timeout` and
`statement_timeout` budgets, so above the ceiling it is the statement
timeout, not the ceiling, that stops a runaway rewrite.

The server fails startup validation when
`native_safe_table_size_limit_bytes` is zero or negative.

The ceiling is process-wide: every PostgreSQL database this server drives
shares the same value, and a database cannot override it in its own metadata.
These settings only apply where this server constructs the PostgreSQL engine
itself — local-mode PostgreSQL databases. Databases routed to a remote
deployment over gRPC run with that deployment's engine settings.

## PlanetScale mTLS

Some PlanetScale-compatible endpoints require mutual TLS: every MySQL
Expand Down
31 changes: 31 additions & 0 deletions pkg/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"unicode/utf8"

"github.com/block/schemabot/pkg/engine"
postgresengine "github.com/block/schemabot/pkg/engine/postgres"
"github.com/block/schemabot/pkg/engine/spirit"
"github.com/block/schemabot/pkg/inventory"
"github.com/block/schemabot/pkg/pendingdrops"
Expand Down Expand Up @@ -185,6 +186,10 @@ type ServerConfig struct {
// Vitess database this server drives, unlike the per-database tls block,
// which only covers statically registered databases.
PlanetScale PlanetScaleConfig `yaml:"planetscale,omitempty"`

// Postgres configures process-wide behavior for every PostgreSQL database
// this server drives directly.
Postgres PostgresConfig `yaml:"postgres,omitempty"`
}

// PendingDropsConfig configures the pending drops quarantine for MySQL/Spirit
Expand Down Expand Up @@ -1099,6 +1104,29 @@ type PlanetScaleMTLSConfig struct {
ClientKey string `yaml:"client_key"`
}

// PostgresConfig holds process-wide settings for the PostgreSQL engine.
type PostgresConfig struct {
// NativeSafeTableSizeLimitBytes is the largest table on which the engine
// will execute native-safe DDL. When unset,
// postgres.DefaultNativeSafeTableSizeLimitBytes applies.
NativeSafeTableSizeLimitBytes *int64 `yaml:"native_safe_table_size_limit_bytes,omitempty"`
}

// NativeSafeTableSizeLimit returns the configured limit or its default.
func (c PostgresConfig) NativeSafeTableSizeLimit() int64 {
if c.NativeSafeTableSizeLimitBytes == nil {
return postgresengine.DefaultNativeSafeTableSizeLimitBytes
}
return *c.NativeSafeTableSizeLimitBytes
}

func (c PostgresConfig) validate() error {
if c.NativeSafeTableSizeLimitBytes != nil && *c.NativeSafeTableSizeLimitBytes <= 0 {
return fmt.Errorf("postgres.native_safe_table_size_limit_bytes must be positive, got %d", *c.NativeSafeTableSizeLimitBytes)
}
return nil
}

// validate checks that an mtls block, when present, names all three
// certificate paths. File readability is checked at startup registration, not
// here, so config validation stays filesystem-independent.
Expand Down Expand Up @@ -1331,6 +1359,9 @@ func (c *ServerConfig) Validate() error {
if err := c.PlanetScale.validate(); err != nil {
return err
}
if err := c.Postgres.validate(); err != nil {
return err
}
if err := c.validateRequiredChecksNotAggregate(); err != nil {
return err
}
Expand Down
59 changes: 59 additions & 0 deletions pkg/api/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4238,3 +4238,62 @@ planetscale:
ClientKey: "/etc/secrets/pca/tls.key",
}, cfg.PlanetScale.MTLS)
}

func TestLoadServerConfigPostgresNativeSafeTableSizeLimit(t *testing.T) {
t.Run("unset uses default", func(t *testing.T) {
cfg := PostgresConfig{}
assert.Equal(t, int64(1<<30), cfg.NativeSafeTableSizeLimit())
})

t.Run("configured bytes", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
databases:
mydb:
type: postgres
environments:
staging:
dsn: postgres://localhost/mydb
postgres:
native_safe_table_size_limit_bytes: 4294967296
`), 0o600))

cfg, err := LoadServerConfigFromFile(path)
require.NoError(t, err)
assert.Equal(t, int64(4<<30), cfg.Postgres.NativeSafeTableSizeLimit())
})

t.Run("non-positive fails validation", func(t *testing.T) {
limit := int64(0)
cfg := ServerConfig{Databases: map[string]DatabaseConfig{
"mydb": {
Type: storage.DatabaseTypePostgres,
Environments: map[string]EnvironmentConfig{
"staging": {DSN: "postgres://localhost/mydb"},
},
},
}}
cfg.Postgres.NativeSafeTableSizeLimitBytes = &limit

err := cfg.Validate()
require.ErrorContains(t, err, "postgres.native_safe_table_size_limit_bytes must be positive")
})

t.Run("unparseable fails config load", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(`
databases:
mydb:
type: postgres
environments:
staging:
dsn: postgres://localhost/mydb
postgres:
native_safe_table_size_limit_bytes: 4GiB
`), 0o600))

_, err := LoadServerConfigFromFile(path)
require.ErrorContains(t, err, "parse config file")
require.ErrorContains(t, err, "cannot unmarshal")
})
}
13 changes: 7 additions & 6 deletions pkg/api/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,12 +600,13 @@ func (s *Service) newLocalTernClient(key, database, dbType string, envConfig Env
}
maps.Copy(metadata, directMetadata)
client, err := tern.NewLocalClient(tern.LocalConfig{
Database: database,
Type: dbType,
TargetDSN: targetDSN,
Metadata: metadata,
WakeOperator: s.wakeOperator,
EngineFactories: s.engineFactories,
Database: database,
Type: dbType,
TargetDSN: targetDSN,
Metadata: metadata,
PostgresNativeSafeTableSizeLimitBytes: s.config.Postgres.NativeSafeTableSizeLimit(),
WakeOperator: s.wakeOperator,
EngineFactories: s.engineFactories,
}, s.storage, s.logger)
if err != nil {
return nil, fmt.Errorf("create local tern client for %s: %w", key, err)
Expand Down
20 changes: 20 additions & 0 deletions pkg/api/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"

"github.com/block/schemabot/pkg/engine"
postgresengine "github.com/block/schemabot/pkg/engine/postgres"
ternv1 "github.com/block/schemabot/pkg/proto/ternv1"
"github.com/block/schemabot/pkg/storage"
"github.com/block/schemabot/pkg/tern"
Expand Down Expand Up @@ -468,3 +469,22 @@ func TestNewLocalTernClient_AcceptsWellFormedTokenReference(t *testing.T) {
require.NoError(t, err)
assert.NotNil(t, client)
}

// The server-level postgres ceiling reaches the engine of every local client
// the control plane builds, so a configured value governs native-safe DDL
// instead of silently reverting to the default.
func TestNewLocalTernClient_ConfiguresPostgresTableSizeLimit(t *testing.T) {
limit := int64(4 << 30)
cfg := &ServerConfig{Postgres: PostgresConfig{NativeSafeTableSizeLimitBytes: &limit}}
service := New(nil, cfg, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))

envConfig := EnvironmentConfig{DSN: "postgres://localhost:5432/orders"}
client, err := service.newLocalTernClient("orders-staging", "orders", storage.DatabaseTypePostgres, envConfig)
require.NoError(t, err)

lc, ok := client.(*tern.LocalClient)
require.True(t, ok)
eng, ok := lc.Engine().(*postgresengine.Engine)
require.True(t, ok)
assert.Equal(t, limit, eng.TableSizeLimit())
}
7 changes: 3 additions & 4 deletions pkg/engine/postgres/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import (
)

const (
optimisticTableSizeLimit = int64(1 << 30)
optimisticLockTimeout = 3 * time.Second
optimisticStatementLimit = 30 * time.Second

Expand Down Expand Up @@ -122,7 +121,7 @@ func (e *Engine) runOptimisticApply(ctx context.Context, conn targetConn, change
// survive the request), so boundedness comes from the ceiling instead.
ctx, cancel := context.WithTimeout(ctx, optimisticApplyCeiling)
defer cancel()
err := executeOptimistic(ctx, conn, change)
err := executeOptimistic(ctx, conn, change, e.tableSizeLimit)
if err == nil {
e.publishProgress(key, progressResult(engine.StateCompleted, "completed", started, change, ""), logger)
return
Expand Down Expand Up @@ -204,7 +203,7 @@ func classifyRefusal(err error, table string) *refusal {
return nil
}

func executeOptimistic(ctx context.Context, conn targetConn, change nativeApply) error {
func executeOptimistic(ctx context.Context, conn targetConn, change nativeApply, tableSizeLimit int64) error {
poolCfg, err := spritePoolConfig(conn.dsn, conn.caCertPath)
if err != nil {
return fmt.Errorf("prepare pg-sprite apply pool for table %q: %w", change.table, err)
Expand All @@ -226,7 +225,7 @@ func executeOptimistic(ctx context.Context, conn targetConn, change nativeApply)
if _, err := preflight.CheckPrivileges(ctx, pool, change.namespace, change.table, preflight.Requirement{Tier: tier}); err != nil {
return fmt.Errorf("check privileges for PostgreSQL table %q: %w", change.table, err)
}
table, err := preflight.CheckTable(ctx, pool, change.namespace, change.table, optimisticTableSizeLimit)
table, err := preflight.CheckTable(ctx, pool, change.namespace, change.table, tableSizeLimit)
if err != nil {
return fmt.Errorf("preflight PostgreSQL table %q: %w", change.table, err)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/engine/postgres/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func TestExecuteOptimisticRefusesUnreadableCABundle(t *testing.T) {
caCertPath: filepath.Join(t.TempDir(), "missing.pem"),
}

err := executeOptimistic(t.Context(), conn, nativeApply{namespace: "public", table: "widgets", sql: "CREATE TABLE widgets (id bigint PRIMARY KEY)"})
err := executeOptimistic(t.Context(), conn, nativeApply{namespace: "public", table: "widgets", sql: "CREATE TABLE widgets (id bigint PRIMARY KEY)"}, DefaultNativeSafeTableSizeLimitBytes)

require.Error(t, err)
assert.Contains(t, err.Error(), "open pg-sprite apply pool")
Expand Down
29 changes: 26 additions & 3 deletions pkg/engine/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,36 @@ type Engine struct {
// progressKey (the apply's ResumeState.MigrationContext). One engine is
// shared for the lifetime of a target, so Progress must answer for the
// apply the caller identifies — never for whichever apply wrote last.
progress *engine.ProgressResult
progressKey string
progress *engine.ProgressResult
progressKey string
tableSizeLimit int64
}

// DefaultNativeSafeTableSizeLimitBytes preserves the native-safe execution
// ceiling when the server does not configure one.
const DefaultNativeSafeTableSizeLimitBytes = int64(1 << 30)

// New creates a new PostgreSQL engine.
func New() *Engine {
return &Engine{}
return NewWithTableSizeLimit(DefaultNativeSafeTableSizeLimitBytes)
}

// NewWithTableSizeLimit creates a PostgreSQL engine with the native-safe
// table size ceiling expressed in bytes. Zero means unset and adopts
// DefaultNativeSafeTableSizeLimitBytes. A negative value is kept as-is
// rather than silently replaced with a ceiling the caller did not choose:
// the preflight check rejects a non-positive limit loudly at apply time,
// and server config validation rejects it at startup.
func NewWithTableSizeLimit(tableSizeLimit int64) *Engine {
if tableSizeLimit == 0 {
tableSizeLimit = DefaultNativeSafeTableSizeLimitBytes
}
return &Engine{tableSizeLimit: tableSizeLimit}
}

// TableSizeLimit exposes the native-safe ceiling for wiring verification and observability.
func (e *Engine) TableSizeLimit() int64 {
return e.tableSizeLimit
}

// Name returns the engine identifier.
Expand Down
17 changes: 17 additions & 0 deletions pkg/engine/postgres/postgres_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,23 @@ func TestEngineApplyTableNotFoundRefusal(t *testing.T) {
assert.Contains(t, progress.ErrorMessage, "missing_users")
}

// TestEngineApplyTableSizeRefusal proves the configured native-safe ceiling
// reaches preflight and permanently refuses an ALTER when the table exceeds it.
func TestEngineApplyTableSizeRefusal(t *testing.T) {
dsn, db := testutil.StartPostgres(t, "size_limit_test")
_, err := db.ExecContext(t.Context(), "CREATE TABLE public.users (id bigint PRIMARY KEY)")
require.NoError(t, err)

eng := NewWithTableSizeLimit(1)
_, err = eng.Apply(t.Context(), applyRequest(dsn, "users", "ALTER TABLE public.users ADD COLUMN email text"))
require.NoError(t, err)
progress := awaitPostgresProgress(t, eng, "users")
assert.Equal(t, engine.StateFailed, progress.State)
assert.Equal(t, "refused", progress.Metadata["phase"])
assert.False(t, progress.Retryable, "a size refusal is permanent until the ceiling or target changes")
assert.Contains(t, progress.ErrorMessage, "1-byte threshold")
}

// TestEngineApplyOperationalFailure proves an execution-path error — here an
// unreachable target — remains a failed operation rather than being
// misclassified as a safety refusal.
Expand Down
7 changes: 7 additions & 0 deletions pkg/engine/postgres/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,10 @@ func TestLifecycleControlsDeclineAsUnsupported(t *testing.T) {
})
}
}

// A zero ceiling means unset and adopts the default, so a zero-valued client
// config preserves the stock ceiling instead of disabling the size guard.
func TestNewWithTableSizeLimitTreatsZeroAsUnset(t *testing.T) {
assert.Equal(t, DefaultNativeSafeTableSizeLimitBytes, NewWithTableSizeLimit(0).TableSizeLimit())
assert.Equal(t, int64(42), NewWithTableSizeLimit(42).TableSizeLimit())
}
9 changes: 9 additions & 0 deletions pkg/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,14 @@ func Build(ctx context.Context, cfg *api.ServerConfig, opts ...Option) (*Server,
return nil, err
}

// The postgres ceiling only surfaces at apply time (a refusal names it;
// an attempt below it names nothing), so state the effective value once
// at startup where operators can correlate it across pods and config
// revisions.
logger.Info("PostgreSQL engine native-safe table size ceiling in effect",
"limit_bytes", cfg.Postgres.NativeSafeTableSizeLimit(),
"configured", cfg.Postgres.NativeSafeTableSizeLimitBytes != nil)

// Get storage DSN from config (with fallback to the STORAGE_DSN env var,
// then MYSQL_DSN)
dsn, err := cfg.StorageDSN()
Expand Down Expand Up @@ -777,6 +785,7 @@ func grpcLocalClientFactory(config *api.ServerConfig, wakeOperator func(applyIde
if cfg.Metadata == nil {
cfg.Metadata = map[string]string{}
}
cfg.PostgresNativeSafeTableSizeLimitBytes = config.Postgres.NativeSafeTableSizeLimit()
// Stated either way rather than only when disabled: a data plane that
// predates the opt-in default reads an absent key as "quarantine", so
// leaving it out during a rolling deploy would quarantine on a
Expand Down
25 changes: 25 additions & 0 deletions pkg/serve/serve_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (

"github.com/block/schemabot/pkg/api"
"github.com/block/schemabot/pkg/engine"
postgresengine "github.com/block/schemabot/pkg/engine/postgres"
"github.com/block/schemabot/pkg/storage"
"github.com/block/schemabot/pkg/storage/mysqlstore"
"github.com/block/schemabot/pkg/tern"
)
Expand Down Expand Up @@ -81,6 +83,29 @@ func TestGRPCLocalClientFactoryMergesIntoExistingFactories(t *testing.T) {
"a non-nil per-config factory map must not drop the embedder registry")
}

// The data-plane client factory applies the server-level postgres ceiling to
// every LocalClient it builds, so a configured value governs native-safe DDL
// on the gRPC/router path instead of silently reverting to the default.
func TestGRPCLocalClientFactoryConfiguresPostgresTableSizeLimit(t *testing.T) {
limit := int64(4 << 30)
factory := grpcLocalClientFactory(&api.ServerConfig{
Postgres: api.PostgresConfig{NativeSafeTableSizeLimitBytes: &limit},
}, nil, nil)

client, err := factory(tern.LocalConfig{
Database: "orders",
Type: storage.DatabaseTypePostgres,
TargetDSN: "postgres://localhost:5432/orders",
}, mysqlstore.New(nil), slog.New(slog.DiscardHandler))
require.NoError(t, err)

lc, ok := client.(*tern.LocalClient)
require.True(t, ok)
eng, ok := lc.Engine().(*postgresengine.Engine)
require.True(t, ok)
assert.Equal(t, limit, eng.TableSizeLimit())
}

// Without a registered engine, the data-plane client factory fails closed for a
// custom database type rather than building a client with no engine.
func TestGRPCLocalClientFactoryFailsClosedForUnregisteredType(t *testing.T) {
Expand Down
Loading
Loading