360 lines
14 KiB
Rust
360 lines
14 KiB
Rust
//! 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, WHERE-guarded upsert, one owner). The boundary
|
|
-- argument is total (ADR-023 §2): no domain CHECK — a negative seed
|
|
-- is stored raw, reads anchor at it and resume forward identically.
|
|
CREATE TABLE IF NOT EXISTS {offsets} (
|
|
stream TEXT NOT NULL,
|
|
consumer TEXT NOT NULL,
|
|
\"offset\" BIGINT NOT NULL DEFAULT 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,
|
|
)
|
|
}
|