Postgres engine: schema bootstrap — engine-owned schema, table family, idempotent DDL, schema-prefixed indexes (task pg-engine-schema)
This commit is contained in:
1 parent
4caea21098
commit
2f1353bd41
6 files changed
+951
-9
No files matched your search
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user