Skip to content
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases before v0.27.0 predate the changelog.
## [Unreleased]

### Added

- A schema-drift guard keeps the SQLite (`control/lite/schema.sql`) and
Postgres (`control/pg/schema.sql`) control schemas aligned: same tables,
column names, foreign keys and indexes, with every deliberate difference
listed and reasoned in `control/schema_drift.rs` (partition children, the
xid8 outbox columns and their ordering index, the pg-only
`job_messages.job_timeout_s`, the shared `timeline_id`, and the
`runner_sessions.runner_id` FK). The guard fails on an undocumented
difference and on a stale entry, so aligning the backends shrinks the list
instead of letting it rot.
- Coverage for id and time storage on both backends: a run id and job
round-trip in lowercase-canonical form, a sub-microsecond instant reads
back as whole microseconds (nanosecond `bigint` stamps keep full
precision), SQLite accepts several attempts of one job sharing a
`timeline_id` (the deliberate pg-`UNIQUE` divergence) and its
`job_requests_timeline_cascade` trigger prunes the shared timeline with the
last request.

### Changed

- SQLite's `jobs_ready` key is now `(pool_key, priority DESC, run_order,
job_order)` — exactly the claim / ready-queue `ORDER BY`, the key Postgres
already carries. `namespace_id` sat between the pool key and the priority,
so SQLite sorted the last three ORDER BY terms on every poll; the claim
query now reads the ready front in index order (`EXPLAIN QUERY PLAN` goes
from `USE TEMP B-TREE FOR LAST 3 TERMS OF ORDER BY` to no sort). SQLite
also gains `job_requests_attempts (run_id, job_id, request_id DESC)`, the
same index Postgres has, which serves the latest-attempt lookups and the
`jobs` -> `job_requests` cascade that the partial inflight index cannot.
Both are fresh-database changes: an existing database keeps working
unchanged, because a missing index is never an error.
- The control schema no longer carries objects nothing uses:
`run_submissions.secret_refs`, `jobs.not_before`,
`provision_requests.lease_owner`/`leased_until`,
`runner_sessions.engine_node_id`, `log_files.byte_count`/`line_count`,
`artifacts.upload_token_hash` and Postgres's `runners_labels` GIN index
(label matching reads `runners.labels` in Rust and uses no operator a GIN
index serves). No code referenced any of them, so an existing control
database that still has the columns keeps working. The namespace
platform columns (`cell_generation`, `config_version`,
`namespace_limits.max_job_timeout_minutes`/`priority_tier`/
`run_history_retention_days`, `namespace_policies`) stay and are marked
platform-owned in the schema.

### Security

Expand Down
27 changes: 18 additions & 9 deletions crates/preloop-runner-server/src/control/lite/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ CREATE TABLE namespaces (
namespace_id TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN
('active','suspended','draining','deleted')),
-- Platform-owned: cell fencing and config push bookkeeping; the engine
-- neither reads nor writes these two.
cell_generation INTEGER NOT NULL DEFAULT 1,
config_version INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000000 AS INTEGER)),
Expand All @@ -33,6 +35,8 @@ CREATE TABLE namespace_limits (
max_running_jobs INTEGER,
submit_rate_per_minute INTEGER,
max_jobs_per_run INTEGER,
-- Platform-owned: written by the hosted platform; the engine does not
-- enforce these three yet.
max_job_timeout_minutes INTEGER,
priority_tier INTEGER NOT NULL DEFAULT 0,
run_history_retention_days INTEGER
Expand All @@ -45,6 +49,8 @@ CREATE TABLE namespace_pool_limits (
PRIMARY KEY (namespace_id, pool_key)
);

-- Platform-owned: the hosted platform writes per-tenant admission policy
-- here; the engine does not read it yet.
CREATE TABLE namespace_policies (
namespace_id TEXT PRIMARY KEY REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
fork_pr_policy TEXT NOT NULL DEFAULT 'untrusted' CHECK (fork_pr_policy IN
Expand Down Expand Up @@ -130,7 +136,6 @@ CREATE TABLE run_submissions (
github_context TEXT NOT NULL,
workspace_snapshot TEXT,
snapshot_timing TEXT,
secret_refs TEXT NOT NULL DEFAULT '{}',
-- Record-level per-job maps the agreed schema has no table for
-- (old backend's run_jobs): job_base_ids, job_names, job_needs,
-- job_check_run_ids, caller_plans, jobs_list, reusable_calls,
Expand Down Expand Up @@ -171,7 +176,6 @@ CREATE TABLE jobs (
priority INTEGER NOT NULL DEFAULT 0,
run_order INTEGER NOT NULL DEFAULT 0,
job_order INTEGER NOT NULL DEFAULT 0,
not_before INTEGER,
enqueued_at INTEGER,
claimed_by_runner_id INTEGER,
claimed_at INTEGER,
Expand All @@ -194,7 +198,11 @@ CREATE TABLE jobs (
FOREIGN KEY (run_id, parent_job_id) REFERENCES jobs(run_id, job_id)
ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX jobs_ready ON jobs(pool_key, namespace_id, priority DESC, run_order, job_order)
-- Claim order: `SELECT .. WHERE queue_state = 'ready' ORDER BY pool_key,
-- priority DESC, run_order, job_order`. The key columns are exactly the
-- ORDER BY (pg's index carries the same key), so the ready front is read in
-- order instead of sorting the whole ready set on every poll.
CREATE INDEX jobs_ready ON jobs(pool_key, priority DESC, run_order, job_order)
WHERE queue_state = 'ready';
CREATE INDEX jobs_pending_expansion ON jobs(enqueued_at) WHERE queue_state = 'pending_expansion';
CREATE INDEX jobs_run_active ON jobs(run_id, queue_state) WHERE queue_state <> 'none';
Expand Down Expand Up @@ -281,6 +289,11 @@ CREATE TABLE job_requests (
);
CREATE UNIQUE INDEX job_requests_inflight ON job_requests(run_id, job_id) WHERE result IS NULL;
CREATE INDEX job_requests_session ON job_requests(session_id) WHERE result IS NULL;
-- Latest-attempt lookups (`WHERE run_id = ? AND job_id = ? ORDER BY
-- request_id DESC LIMIT 1`) and the jobs -> job_requests cascade: the partial
-- inflight index cannot serve settled attempts. `request_id` is the rowid;
-- naming it keeps the key identical to pg's index.
CREATE INDEX job_requests_attempts ON job_requests(run_id, job_id, request_id DESC);

CREATE TABLE job_leases (
request_id INTEGER PRIMARY KEY REFERENCES job_requests(request_id) ON DELETE CASCADE,
Expand Down Expand Up @@ -345,8 +358,6 @@ CREATE TABLE log_files (
run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
plan_id TEXT NOT NULL REFERENCES job_requests(agent_job_id) ON DELETE CASCADE,
log_id INTEGER NOT NULL CHECK (log_id > 0),
byte_count INTEGER NOT NULL DEFAULT 0,
line_count INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000000 AS INTEGER)),
UNIQUE (plan_id, log_id)
);
Expand Down Expand Up @@ -378,7 +389,6 @@ CREATE TABLE runner_sessions (
protocol TEXT NOT NULL CHECK (protocol IN ('broker','azdo')),
client_id TEXT,
verified INTEGER NOT NULL DEFAULT 0,
engine_node_id TEXT,
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000000 AS INTEGER)),
last_seen_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000000 AS INTEGER))
);
Expand Down Expand Up @@ -426,8 +436,6 @@ CREATE TABLE provision_requests (
pool_key TEXT NOT NULL,
labels TEXT NOT NULL,
requested_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000000 AS INTEGER)),
leased_until INTEGER,
lease_owner TEXT,
PRIMARY KEY (run_id, job_id),
FOREIGN KEY (run_id, job_id) REFERENCES jobs(run_id, job_id) ON DELETE CASCADE
);
Expand Down Expand Up @@ -565,6 +573,8 @@ CREATE TABLE check_run_updates (
CREATE INDEX check_run_updates_queue ON check_run_updates(installation_id, not_before);

-- ── Artifacts ────────────────────────────────────────────────────────
-- Artifact catalog rows. Bytes live in the file-backed ArtifactStore; the
-- only statement today is the run-archive `DELETE FROM artifacts`.
CREATE TABLE artifacts (
artifact_id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace_id TEXT NOT NULL,
Expand All @@ -575,7 +585,6 @@ CREATE TABLE artifacts (
size_bytes INTEGER,
digest TEXT,
storage_key TEXT NOT NULL,
upload_token_hash BLOB,
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000000 AS INTEGER)),
finalized_at INTEGER,
expires_at INTEGER,
Expand Down
13 changes: 13 additions & 0 deletions crates/preloop-runner-server/src/control/lite/testview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,19 @@ impl TestDb<'_> {
self.0.execute(sql, params)
}

/// `EXPLAIN QUERY PLAN` detail lines for one statement, so index tests
/// can assert which access path (and whether a sort) the planner picks.
pub fn query_plan(&self, sql: &str) -> Vec<String> {
let mut stmt = self
.0
.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
.expect("prepare EXPLAIN QUERY PLAN");
let rows = stmt
.query_map([], |row| row.get::<_, String>(3))
.expect("run EXPLAIN QUERY PLAN");
rows.map(|row| row.expect("plan row")).collect()
}

/// `job_assignments` staleness: `assigned_at`/`first_assigned_at`.
pub fn set_assignment_times(
&self,
Expand Down
2 changes: 2 additions & 0 deletions crates/preloop-runner-server/src/control/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub(crate) mod logic;
pub mod logic;
pub(crate) mod pg;
#[cfg(test)]
mod schema_drift;
#[cfg(test)]
mod tests;
#[cfg(any(test, feature = "test-support"))]
pub mod testview;
Expand Down
27 changes: 12 additions & 15 deletions crates/preloop-runner-server/src/control/pg/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ CREATE TABLE namespaces (
-- suspended/deleted: neither (queued jobs stay queued, nothing starts).
state text NOT NULL DEFAULT 'active' CHECK (state IN
('active','suspended','draining','deleted')),
-- Platform-owned: cell fencing and config push bookkeeping; the engine
-- neither reads nor writes these two.
cell_generation bigint NOT NULL DEFAULT 1, -- bumped on cell move; fences stale writers
config_version bigint NOT NULL DEFAULT 0, -- last platform push applied
created_at timestamptz NOT NULL DEFAULT now(),
Expand All @@ -60,6 +62,8 @@ CREATE TABLE namespaces (
-- predicate (a capped claim locks this row first, so nodes cannot
-- overshoot). Not yet read: max_job_timeout_minutes, priority_tier,
-- run_history_retention_days.
-- Those three are platform-owned: written by the hosted platform, not yet
-- enforced by the engine.
CREATE TABLE namespace_limits (
namespace_id text PRIMARY KEY REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
max_queued_jobs integer,
Expand All @@ -80,6 +84,8 @@ CREATE TABLE namespace_pool_limits (
);

-- Admission and job-build restrictions.
-- Platform-owned: the hosted platform writes per-tenant admission policy
-- here; the engine does not read it yet.
CREATE TABLE namespace_policies (
namespace_id text PRIMARY KEY REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
fork_pr_policy text NOT NULL DEFAULT 'untrusted' CHECK (fork_pr_policy IN
Expand Down Expand Up @@ -169,16 +175,14 @@ CREATE TABLE workflow_run_numbers (
PRIMARY KEY (namespace_id, repository, workflow_path)
);

-- The request a run was created from. No secret values: `secret_refs`
-- names which secrets (and scopes) the run may use; values are resolved
-- from the SecretProvider when a job is acquired, never stored here.
-- The request a run was created from. No secret values are ever stored:
-- secrets are resolved from the SecretProvider when a job is acquired.
Comment on lines +178 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Schema diagrams still show removed objects

The schema diagrams still list secret_refs, engine_node_id, upload_token_hash, and runners_labels. Regenerate the derived diagrams alongside the SQL schema changes.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

CREATE TABLE run_submissions (
run_id uuid PRIMARY KEY REFERENCES runs(run_id) ON DELETE CASCADE,
submission jsonb NOT NULL, -- WorkflowSubmission minus secrets
github_context jsonb NOT NULL,
workspace_snapshot jsonb,
snapshot_timing jsonb, -- duration_ms, object_count, pack_bytes
secret_refs jsonb NOT NULL DEFAULT '{}', -- name -> {scope, version}
-- Record-level per-job maps the table layout has no column for (jobs
-- with no `jobs` row yet — a check run minted before its matrix leg
-- materializes). Mirrors lite's `run_submissions.record_details`.
Expand Down Expand Up @@ -225,7 +229,6 @@ CREATE TABLE jobs (
priority integer NOT NULL DEFAULT 0,
run_order bigint NOT NULL DEFAULT 0,
job_order integer NOT NULL DEFAULT 0,
not_before timestamptz,
enqueued_at timestamptz,
claimed_by_runner_id bigint,
claimed_at timestamptz,
Expand Down Expand Up @@ -414,14 +417,12 @@ CREATE TABLE timeline_records (

-- Per-plan log ids fit the official runner's 32-bit TaskLog.Id and remain
-- stable across nodes. The unique pair arbitrates concurrent allocations.
-- Content lives in file segments; this row tracks result-service counters.
-- Content and sizes live in file segments; this row only allocates ids.
CREATE TABLE log_files (
log_key text PRIMARY KEY,
run_id uuid NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
plan_id uuid NOT NULL REFERENCES job_requests(agent_job_id) ON DELETE CASCADE,
log_id integer NOT NULL CHECK (log_id > 0),
byte_count bigint NOT NULL DEFAULT 0,
line_count bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (plan_id, log_id)
);
Expand All @@ -443,15 +444,13 @@ CREATE TABLE runners (
registered_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz
);
CREATE INDEX runners_labels ON runners USING gin (labels);

CREATE TABLE runner_sessions (
session_id uuid PRIMARY KEY,
runner_id bigint REFERENCES runners(runner_id) ON DELETE CASCADE,
protocol text NOT NULL CHECK (protocol IN ('broker','azdo')),
client_id text,
verified boolean NOT NULL DEFAULT false,
engine_node_id text, -- node holding the long-poll (wake routing)
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now()
);
Expand Down Expand Up @@ -505,8 +504,6 @@ CREATE TABLE provision_requests (
pool_key text NOT NULL,
labels jsonb NOT NULL,
requested_at timestamptz NOT NULL DEFAULT now(),
leased_until timestamptz,
lease_owner text,
PRIMARY KEY (run_id, job_id),
FOREIGN KEY (run_id, job_id) REFERENCES jobs(run_id, job_id) ON DELETE CASCADE
);
Expand Down Expand Up @@ -669,8 +666,9 @@ CREATE TABLE check_run_updates (
CREATE INDEX check_run_updates_queue ON check_run_updates(installation_id, not_before);

-- ── Artifacts (replaces the artifact part of the `meta` blob) ────────
-- Blobs live in object storage; these rows are the shared index. Upload
-- tokens are stored as hashes, never raw.
-- Blobs live in object storage; upload state lives in the file-backed
-- ArtifactStore. These rows are the catalog; the only statement today is
-- the run-archive DELETE.
--
-- The Actions cache is deliberately NOT here: it is a bounded cache
-- colocated with the runner hosts (preloop-cache CAS: key index ->
Expand All @@ -686,7 +684,6 @@ CREATE TABLE artifacts (
size_bytes bigint,
digest text,
storage_key text NOT NULL,
upload_token_hash bytea,
created_at timestamptz NOT NULL DEFAULT now(),
finalized_at timestamptz,
expires_at timestamptz,
Expand Down
Loading