Postgres engine: schema bootstrap — engine-owned schema, table family, idempotent DDL, schema-prefixed indexes (task pg-engine-schema)

This commit is contained in:
glm-5.3-flash committed 2026-10-09 05:11:48 +00:00
1 parent 4caea21098
commit 2f1353bd41
6 files changed
+951 -9

No files matched your search

+358
View File
@@ -0,0 +1,358 @@
//! Schema bootstrap (ADR-010 §8): the one engine-owned PostgreSQL
//! schema and its table family — the dependency root every later
//! wave-4 task rides.
//!
//! # Layout posture
//!
//! All engine-owned objects — job, dead, stream events, offsets,
//! schedule, locks — live in one PostgreSQL schema, default
//! [`DEFAULT_SCHEMA`] (`"alkstore"`), overridable per-engine option
//! (the `PgOpts::schema` knob the `open` task wires), co-tenanted
//! safely with consumer tables (deployment.md's co-tenancy posture;
//! the alkblobs ADR-008 precedent). Queues are rows in one job table —
//! no per-queue tables, no per-queue schemas (ADR-010 §8); per-queue
//! configuration is stamped onto job rows (ADR-010 §3a), never stored
//! in a registry. Queue creation is not a DDL operation.
//!
//! # Payload posture
//!
//! Payload columns are `BYTEA` — the engine stores exactly the bytes
//! `encode_payload` returned, byte for byte (ADR-020 §4). `JSONB` is
//! deliberately rejected: its normalization (key reordering,
//! duplicate-key folding, number canonicalization) promises no byte
//! identity, and the contract suite's byte-identical-rows row pins
//! that rows enqueueing the same `Value` store identical bytes.
//!
//! # No migration machinery in v1
//!
//! Tables are created fresh per schema via idempotent DDL
//! (`CREATE … IF NOT EXISTS`); this module carries no ALTER/migration
//! machinery. Append-column evolution is the SQLite substrate's
//! discipline for its in-place database files; a greenfield pg schema
//! needs none in v1 — wave 6's release pass revisits if the
//! deployment story demands in-place upgrades.
//!
//! # Column sets
//!
//! Column sets follow the contract's value shapes — the
//! `#[doc(hidden)]` engine constructors' field lists
//! (`alkstore::Job::from_row`, `alkstore::StreamEvent::from_row`,
//! `alkstore::Schedule::new`) are the lists of record (ADR-019 §3,
//! ADR-021 §2, ADR-015 §3), with the `QueueOpts` stamp columns per
//! ADR-010 §3a and the schedule row's boundary-state columns per
//! ADR-009 §3/§4 (the substrate's `__alkstore_scheduler_tasks`
//! concrete stamp set re-owned in pg form; `enabled` dropped — the
//! collapse surface has no pause/resume, ADR-009 §1). Timestamps are
//! unix-seconds `BIGINT` engine-wide — the engines' single clock
//! source, second-precision integer arithmetic (honker parity); no
//! `timestamptz`.
//!
//! # Identifier quoting
//!
//! The engine composes schema-qualified SQL from a consumer-supplied
//! schema name — [`quote_identifier`] is the injection boundary:
//! every identifier entering SQL text (schema name, table names,
//! index names) is quoted through it. Two column names are
//! reserved-word hazards in Postgres and must stay quoted in later
//! tasks' SQL: the offsets checkpoint column `"offset"` and the
//! events key column `"key"`.
//!
//! # Index-name qualification
//!
//! `CREATE INDEX` does not accept a schema-qualified index name — the
//! index lands in the table's schema automatically — and
//! `IF NOT EXISTS` on the resulting *bare* name checks existence
//! across every schema on the search path: a co-tenant's unrelated
//! `public.job_claim_idx` would silently skip our bootstrap's index
//! creation. The bootstrap therefore names every index
//! `{schema}_{table}_{suffix}` (schema-prefixed, quoted) — a name a
//! non-engine co-tenant will not carry, and unique among engine
//! instances with distinct schema names too. The canonical suffixes
//! ([`indexes`]) are what the later mechanism tasks' SQL reuses.
use std::fmt;
use thiserror::Error;
/// The default engine-owned schema name (ADR-010 §8).
pub const DEFAULT_SCHEMA: &str = "alkstore";
/// A bootstrap failure: a DDL statement errored server-side (or the
/// connection did).
///
/// The `open` constructor (the next task) maps this onto the engine's
/// `Error::Database` surface, source chain preserved.
#[derive(Debug, Error)]
#[error("schema bootstrap failed")]
pub struct BootstrapError(#[source] pub tokio_postgres::Error);
/// Result alias for [`bootstrap`].
pub type BootstrapResult = Result<(), BootstrapError>;
/// Quote a SQL identifier (Postgres rule: double embedded `"`).
///
/// Consumer-supplied schema names ride DDL text through this quoting;
/// an identifier is never interpolated into SQL unquoted.
pub fn quote_identifier(name: &str) -> String {
let mut quoted = String::with_capacity(name.len() + 2);
quoted.push('"');
for c in name.chars() {
if c == '"' {
quoted.push('"');
}
quoted.push(c);
}
quoted.push('"');
quoted
}
/// A schema-qualified table or index reference: `"schema"."name"`.
#[derive(Debug, Clone, Copy)]
pub struct QualifiedTable<'a> {
/// The schema name (unquoted form).
pub schema: &'a str,
/// The table/index name (unquoted form).
pub name: &'a str,
}
impl fmt::Display for QualifiedTable<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}.{}",
quote_identifier(self.schema),
quote_identifier(self.name)
)
}
}
/// The engine-owned object names inside the schema — fixed constants,
/// the one owner of the names the later mechanism tasks' SQL reuses.
///
/// Object names do not ride engine configuration (only the schema
/// name does, ADR-010 §8).
pub mod tables {
/// The job table (live rows — pending and processing).
pub const JOB: &str = "job";
/// The dead-letter table (physical move target, ADR-010 §4).
pub const DEAD: &str = "dead";
/// The stream events table (`id BIGSERIAL` = the offset).
pub const EVENTS: &str = "stream_events";
/// The per-consumer offset checkpoint table.
pub const OFFSETS: &str = "stream_offsets";
/// The schedule table (ADR-009's collapse shape).
pub const SCHEDULE: &str = "schedule";
/// The TTL lock table.
pub const LOCKS: &str = "locks";
}
/// The engine-owned index names inside the schema — schema-prefixed
/// (see the module docs' index-name qualification posture),
/// schema-parameterized at DDL-composition time by [`bootstrap_ddl`].
pub mod indexes {
/// The claim-ordering index (`priority DESC, run_at, id`).
pub const JOB_CLAIM: &str = "job_claim_idx";
/// The pending-ready-time hot-path index.
pub const JOB_PENDING: &str = "job_pending_idx";
/// The processing-claim-deadline index.
pub const JOB_DEADLINE: &str = "job_deadline_idx";
/// The dead-rows retention index (`queue, died_at`).
pub const DEAD_RETENTION: &str = "dead_retention_idx";
/// The events read index (`stream, id` — the offset ASC path).
pub const EVENTS_READ: &str = "events_read_idx";
/// The schedule due-boundary index (`next_fire_at`).
pub const SCHEDULE_FIRE: &str = "schedule_fire_idx";
/// The locks expiry index (lazy-acquire sweep support).
pub const LOCKS_EXPIRY: &str = "locks_expiry_idx";
}
/// Run the schema bootstrap for one engine-owned schema on `conn`.
/// Idempotent.
///
/// Creates the schema and the full table family + indexes (column
/// sets per the module docs) with `CREATE … IF NOT EXISTS` — a second
/// run is a no-op. Runnable at open on any connection; the deadpool
/// pooled client is a `tokio_postgres::Client` (POC-verified), so a
/// pool checkout passes directly.
///
/// The schema name is engine configuration (default
/// [`DEFAULT_SCHEMA`], decided at the `PgOpts` layer); object names
/// inside the schema are fixed ([`tables`]).
pub async fn bootstrap(conn: &tokio_postgres::Client, schema_name: &str) -> BootstrapResult {
conn.batch_execute(&bootstrap_ddl(schema_name))
.await
.map_err(BootstrapError)
}
fn bootstrap_ddl(schema: &str) -> String {
// Index names are bare at the `CREATE INDEX` statement (Postgres
// rejects a qualified index name there) but schema-prefixed in
// their *text* — the qualification posture that keeps a
// co-tenant's same-named index from silently skipping ours.
let schema_q = quote_identifier(schema);
let index = |suffix: &'static str| quote_identifier(&format!("{schema}_{suffix}"));
let job = QualifiedTable {
schema,
name: tables::JOB,
};
let dead = QualifiedTable {
schema,
name: tables::DEAD,
};
let events = QualifiedTable {
schema,
name: tables::EVENTS,
};
let offsets = QualifiedTable {
schema,
name: tables::OFFSETS,
};
let schedule = QualifiedTable {
schema,
name: tables::SCHEDULE,
};
let locks = QualifiedTable {
schema,
name: tables::LOCKS,
};
let job_claim_idx = index(indexes::JOB_CLAIM);
let job_pending_idx = index(indexes::JOB_PENDING);
let job_deadline_idx = index(indexes::JOB_DEADLINE);
let dead_retention_idx = index(indexes::DEAD_RETENTION);
let events_read_idx = index(indexes::EVENTS_READ);
let schedule_fire_idx = index(indexes::SCHEDULE_FIRE);
let locks_expiry_idx = index(indexes::LOCKS_EXPIRY);
format!(
"
CREATE SCHEMA IF NOT EXISTS {schema_q};
-- Job table (live rows, pending + processing; delete-on-ack —
-- ADR-010 §1). Columns = Job's field list of record (ADR-019 §3,
-- ADR-021 §2) + the stamped QueueOpts (ADR-010 §3a).
CREATE TABLE IF NOT EXISTS {job} (
id BIGSERIAL PRIMARY KEY,
queue TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
payload BYTEA NOT NULL,
priority BIGINT NOT NULL DEFAULT 0,
run_at BIGINT NOT NULL,
attempts BIGINT NOT NULL DEFAULT 0,
max_attempts BIGINT NOT NULL DEFAULT 3,
worker_id TEXT,
claimed_at BIGINT,
claim_expires_at BIGINT,
created_at BIGINT NOT NULL,
expires_at BIGINT,
visibility_timeout_s BIGINT NOT NULL DEFAULT 300,
backoff_base_s BIGINT NOT NULL DEFAULT 5,
dead_letter_retention_s BIGINT
);
-- Claim-ordering index (ADR-010 §1's ordering row: priority DESC,
-- run_at ASC, enqueue order = id). Partial: dead rows ride the dead
-- table, the claim path never scans them.
CREATE INDEX IF NOT EXISTS {job_claim_idx} ON {job} (queue, priority DESC, run_at, id)
WHERE state IN ('pending', 'processing');
CREATE INDEX IF NOT EXISTS {job_pending_idx} ON {job} (queue, run_at)
WHERE state = 'pending';
CREATE INDEX IF NOT EXISTS {job_deadline_idx} ON {job} (queue, claim_expires_at)
WHERE state = 'processing';
-- Dead table (ADR-010 §4): the job row's diagnosis fields + died_at;
-- physical move target, off the claim path. Retention index for the
-- sweep's dead-TTL enforcement (the only sweeper dead rows have).
CREATE TABLE IF NOT EXISTS {dead} (
id BIGINT PRIMARY KEY,
queue TEXT NOT NULL,
payload BYTEA NOT NULL,
priority BIGINT NOT NULL DEFAULT 0,
run_at BIGINT NOT NULL DEFAULT 0,
attempts BIGINT NOT NULL DEFAULT 0,
max_attempts BIGINT NOT NULL DEFAULT 0,
worker_id TEXT,
claimed_at BIGINT,
claim_expires_at BIGINT,
created_at BIGINT NOT NULL,
expires_at BIGINT,
visibility_timeout_s BIGINT NOT NULL DEFAULT 300,
backoff_base_s BIGINT NOT NULL DEFAULT 5,
dead_letter_retention_s BIGINT,
last_error TEXT,
died_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS {dead_retention_idx} ON {dead} (queue, died_at);
-- Stream events table (ADR-015 §3): `id BIGSERIAL` is the offset —
-- engine-assigned, monotone, immutable, never renumbered; global
-- FIFO per stream by id ASC; gaps legal after trim/delete. Nullable
-- key column = carried metadata (ADR-015 §1). created_at is
-- informational.
CREATE TABLE IF NOT EXISTS {events} (
id BIGSERIAL PRIMARY KEY,
stream TEXT NOT NULL,
\"key\" TEXT,
payload BYTEA NOT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS {events_read_idx} ON {events} (stream, id);
-- Offsets table: (stream, consumer) → offset, the monotone
-- checkpoint upsert target (the streams task's SQL owns the
-- monotonicity rule, GREATEST-guarded upsert, one owner). The
-- non-negative CHECK bounds the domain (cursors start at 0).
CREATE TABLE IF NOT EXISTS {offsets} (
stream TEXT NOT NULL,
consumer TEXT NOT NULL,
\"offset\" BIGINT NOT NULL DEFAULT 0 CHECK (\"offset\" >= 0),
PRIMARY KEY (stream, consumer)
);
-- Schedule table (ADR-009 §3/§5): name (unique — the PK), the
-- `@every` spec, target queue, payload, the ScheduleOpts stamps
-- (priority / max_attempts / expires_s) over the resolved QueueOpts
-- stamp set (the substrate's __alkstore_scheduler_tasks
-- concrete-stamp shape re-owned; `enabled` dropped — the collapse
-- surface has no pause/resume), and next_fire_at — the boundary
-- column the tick math advances (`next = last + interval`).
CREATE TABLE IF NOT EXISTS {schedule} (
name TEXT PRIMARY KEY,
spec TEXT NOT NULL,
queue TEXT NOT NULL,
payload BYTEA NOT NULL,
priority BIGINT NOT NULL DEFAULT 0,
expires_s BIGINT,
next_fire_at BIGINT NOT NULL,
max_attempts BIGINT NOT NULL DEFAULT 3,
visibility_timeout_s BIGINT NOT NULL DEFAULT 300,
backoff_base_s BIGINT NOT NULL DEFAULT 5,
dead_letter_retention_s BIGINT
);
CREATE INDEX IF NOT EXISTS {schedule_fire_idx} ON {schedule} (next_fire_at);
-- Locks table: the TTL lock machinery's storage (the substrate's
-- lock-table semantics re-derived: PK on name prevents dual
-- acquisition; expiry lazy — no sweeper, an expired lock is
-- acquirable; the locks task's ops own the SQL).
CREATE TABLE IF NOT EXISTS {locks} (
name TEXT PRIMARY KEY,
owner TEXT NOT NULL,
expires_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS {locks_expiry_idx} ON {locks} (expires_at);
",
schema_q = schema_q,
job = job,
job_claim_idx = job_claim_idx,
job_pending_idx = job_pending_idx,
job_deadline_idx = job_deadline_idx,
dead = dead,
dead_retention_idx = dead_retention_idx,
events = events,
events_read_idx = events_read_idx,
offsets = offsets,
schedule = schedule,
schedule_fire_idx = schedule_fire_idx,
locks = locks,
locks_expiry_idx = locks_expiry_idx,
)
}