Skip to main content

rivet/state/
mod.rs

1use rusqlite::Connection;
2
3use crate::error::Result;
4
5mod checkpoint;
6mod cursor;
7mod file_log;
8mod journal_store;
9mod metrics;
10mod progression;
11mod run_aggregate;
12mod schema;
13mod shape;
14
15// Re-export domain types so callers use `rivet::state::*` unchanged.
16// Items below may not be explicitly named by all internal callers (often used
17// as inferred return types), but are part of the public integration-test API.
18#[allow(unused_imports)]
19pub use checkpoint::ChunkTaskInfo;
20#[allow(unused_imports)]
21pub use file_log::FileRecord;
22#[allow(unused_imports)]
23pub use metrics::ExportMetric;
24pub use metrics::MetricRow;
25#[allow(unused_imports)]
26pub use progression::{Boundary, ExportProgression};
27#[allow(unused_imports)]
28pub use run_aggregate::{RunAggregate, RunAggregateEntry};
29#[allow(unused_imports)]
30pub use schema::{SchemaChange, SchemaColumn, arrow_schema_to_columns, schema_fingerprint};
31#[allow(unused_imports)]
32pub use shape::ShapeWarning;
33
34const STATE_DB_NAME: &str = ".rivet_state.db";
35
36/// Current schema version — always the last entry in `MIGRATIONS`.
37const SCHEMA_VERSION: i64 = MIGRATIONS[MIGRATIONS.len() - 1].0;
38
39/// Each entry is `(version, sql)`.  Applied in order when the DB is behind.
40const MIGRATIONS: &[(i64, &str)] = &[
41    // v1: core tables
42    (
43        1,
44        "CREATE TABLE IF NOT EXISTS export_state (
45            export_name TEXT PRIMARY KEY,
46            last_cursor_value TEXT,
47            last_run_at TEXT
48        );
49        CREATE TABLE IF NOT EXISTS export_metrics (
50            id INTEGER PRIMARY KEY AUTOINCREMENT,
51            export_name TEXT NOT NULL,
52            run_at TEXT NOT NULL,
53            duration_ms INTEGER NOT NULL,
54            total_rows INTEGER NOT NULL,
55            peak_rss_mb INTEGER,
56            status TEXT NOT NULL,
57            error_message TEXT,
58            tuning_profile TEXT,
59            format TEXT,
60            mode TEXT,
61            files_produced INTEGER DEFAULT 0,
62            bytes_written INTEGER DEFAULT 0,
63            retries INTEGER DEFAULT 0,
64            validated INTEGER,
65            schema_changed INTEGER,
66            run_id TEXT
67        );
68        CREATE TABLE IF NOT EXISTS export_schema (
69            export_name TEXT PRIMARY KEY,
70            columns_json TEXT NOT NULL,
71            updated_at TEXT NOT NULL
72        );
73        CREATE TABLE IF NOT EXISTS file_manifest (
74            id INTEGER PRIMARY KEY AUTOINCREMENT,
75            run_id TEXT NOT NULL,
76            export_name TEXT NOT NULL,
77            file_name TEXT NOT NULL,
78            row_count INTEGER NOT NULL,
79            bytes INTEGER NOT NULL,
80            format TEXT NOT NULL,
81            compression TEXT,
82            created_at TEXT NOT NULL
83        );",
84    ),
85    // v2: chunk checkpoint tables
86    (
87        2,
88        "CREATE TABLE IF NOT EXISTS chunk_run (
89            run_id TEXT PRIMARY KEY,
90            export_name TEXT NOT NULL,
91            plan_hash TEXT NOT NULL,
92            status TEXT NOT NULL,
93            max_chunk_attempts INTEGER NOT NULL DEFAULT 3,
94            created_at TEXT NOT NULL,
95            updated_at TEXT NOT NULL
96        );
97        CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
98            ON chunk_run(export_name, status);
99        CREATE TABLE IF NOT EXISTS chunk_task (
100            id INTEGER PRIMARY KEY AUTOINCREMENT,
101            run_id TEXT NOT NULL,
102            chunk_index INTEGER NOT NULL,
103            start_key TEXT NOT NULL,
104            end_key TEXT NOT NULL,
105            status TEXT NOT NULL,
106            attempts INTEGER NOT NULL DEFAULT 0,
107            last_error TEXT,
108            rows_written INTEGER,
109            file_name TEXT,
110            updated_at TEXT NOT NULL,
111            UNIQUE(run_id, chunk_index)
112        );
113        CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
114    ),
115    // v3: index on file_manifest for faster per-export lookups
116    (
117        3,
118        "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
119    ),
120    // v4: committed / verified boundary tracking (ADR-0008, Epic G)
121    (
122        4,
123        "CREATE TABLE IF NOT EXISTS export_progression (
124            export_name TEXT PRIMARY KEY,
125            last_committed_strategy TEXT,
126            last_committed_cursor TEXT,
127            last_committed_chunk_index INTEGER,
128            last_committed_run_id TEXT,
129            last_committed_at TEXT,
130            last_verified_strategy TEXT,
131            last_verified_cursor TEXT,
132            last_verified_chunk_index INTEGER,
133            last_verified_run_id TEXT,
134            last_verified_at TEXT
135        );",
136    ),
137    // v5: aggregate run summary
138    (
139        5,
140        "CREATE TABLE IF NOT EXISTS run_aggregate (
141            run_aggregate_id TEXT PRIMARY KEY,
142            started_at TEXT NOT NULL,
143            finished_at TEXT NOT NULL,
144            duration_ms INTEGER NOT NULL,
145            config_path TEXT,
146            parallel_mode TEXT NOT NULL,
147            total_exports INTEGER NOT NULL,
148            success_count INTEGER NOT NULL,
149            failed_count INTEGER NOT NULL,
150            skipped_count INTEGER NOT NULL,
151            total_rows INTEGER NOT NULL,
152            total_files INTEGER NOT NULL,
153            total_bytes INTEGER NOT NULL,
154            details_json TEXT NOT NULL
155        );
156        CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
157            ON run_aggregate(finished_at DESC);",
158    ),
159    // v6: per-column data shape stats
160    (
161        6,
162        "CREATE TABLE IF NOT EXISTS export_shape (
163            export_name TEXT NOT NULL,
164            column_name TEXT NOT NULL,
165            max_byte_len INTEGER NOT NULL,
166            updated_at TEXT NOT NULL,
167            PRIMARY KEY (export_name, column_name)
168        );",
169    ),
170    // v7: structured run journal
171    (
172        7,
173        "CREATE TABLE IF NOT EXISTS run_journal (
174            run_id TEXT PRIMARY KEY,
175            export_name TEXT NOT NULL,
176            finished_at TEXT NOT NULL,
177            journal_json TEXT NOT NULL
178        );
179        CREATE INDEX IF NOT EXISTS idx_run_journal_export
180            ON run_journal(export_name, finished_at DESC);",
181    ),
182    // v8: rename file_manifest → file_log.  The 0.7.0 cloud-output contract
183    // reclaims the "manifest" name for the public JSON artifact; the internal
184    // SQLite log of written files becomes `file_log` to remove the overload.
185    (
186        8,
187        "ALTER TABLE file_manifest RENAME TO file_log;
188        DROP INDEX IF EXISTS idx_file_manifest_export;
189        CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
190    ),
191    // v9: extended per-run metrics for post-pilot analysis — source harm
192    // (pg_temp_bytes_delta), completeness (reconciled, source_count,
193    // quality_passed), memory (batch_size[_memory_mb]), and config dimensions
194    // (chunk_size, parallel, source/destination type, rivet_version). All
195    // additive + nullable: old rows read NULL, no backfill, reads stay forward-
196    // compatible.
197    (
198        9,
199        "ALTER TABLE export_metrics ADD COLUMN files_committed INTEGER;
200        ALTER TABLE export_metrics ADD COLUMN reconciled INTEGER;
201        ALTER TABLE export_metrics ADD COLUMN source_count INTEGER;
202        ALTER TABLE export_metrics ADD COLUMN quality_passed INTEGER;
203        ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta INTEGER;
204        ALTER TABLE export_metrics ADD COLUMN batch_size INTEGER;
205        ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb INTEGER;
206        ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
207        ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
208        ALTER TABLE export_metrics ADD COLUMN chunk_size INTEGER;
209        ALTER TABLE export_metrics ADD COLUMN parallel INTEGER;
210        ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
211        ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
212        ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
213    ),
214    // v10: longest single-chunk wall time (ms) — the #5 source-harm lever,
215    // aggregated at finalize from the run journal's per-chunk timings.
216    (
217        10,
218        "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms INTEGER;",
219    ),
220    // v11: per-run source-harm deltas (locks, rows read, buffer misses, temp
221    // files) — one row per counter, keyed on run_id. Engine-neutral key/value so
222    // each engine's counter set lands without schema churn. Written from
223    // pipeline::job::harm_snapshot via source::{postgres,mysql,mssql}.
224    (
225        11,
226        "CREATE TABLE IF NOT EXISTS export_harm (
227            id INTEGER PRIMARY KEY AUTOINCREMENT,
228            run_id TEXT NOT NULL,
229            export_name TEXT NOT NULL,
230            metric TEXT NOT NULL,
231            delta INTEGER NOT NULL,
232            recorded_at TEXT NOT NULL
233        );
234        CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
235    ),
236    // v12: chunking diagnostics — the chunk KEY column. (The resolved strategy is
237    // already the `mode` column — `summary.mode` is `strategy.mode_label()`,
238    // "keyset"/"chunked"/etc. — and the span/window count are derivable from
239    // chunk_task.) A sparse-key post-mortem: mode='chunked' + chunk_key='id' →
240    // "which column was range-chunked". Whether that key is a PK (the "should have
241    // keyset-paged" signal) needs a run-time PK probe — a follow-up, so no field
242    // that would merely restate mode='keyset'.
243    (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
244];
245
246/// PostgreSQL-compatible DDL.  Column types differ from SQLite (BIGSERIAL,
247/// BOOLEAN); placeholder style is `$N` (handled by callers via `pg_sql()`).
248const PG_MIGRATIONS: &[(i64, &str)] = &[
249    (
250        1,
251        "CREATE TABLE IF NOT EXISTS export_state (
252            export_name TEXT PRIMARY KEY,
253            last_cursor_value TEXT,
254            last_run_at TEXT
255        );
256        CREATE TABLE IF NOT EXISTS export_metrics (
257            id BIGSERIAL PRIMARY KEY,
258            export_name TEXT NOT NULL,
259            run_at TEXT NOT NULL,
260            duration_ms BIGINT NOT NULL,
261            total_rows BIGINT NOT NULL,
262            peak_rss_mb BIGINT,
263            status TEXT NOT NULL,
264            error_message TEXT,
265            tuning_profile TEXT,
266            format TEXT,
267            mode TEXT,
268            files_produced BIGINT DEFAULT 0,
269            bytes_written BIGINT DEFAULT 0,
270            retries BIGINT DEFAULT 0,
271            validated BOOLEAN,
272            schema_changed BOOLEAN,
273            run_id TEXT
274        );
275        CREATE TABLE IF NOT EXISTS export_schema (
276            export_name TEXT PRIMARY KEY,
277            columns_json TEXT NOT NULL,
278            updated_at TEXT NOT NULL
279        );
280        CREATE TABLE IF NOT EXISTS file_manifest (
281            id BIGSERIAL PRIMARY KEY,
282            run_id TEXT NOT NULL,
283            export_name TEXT NOT NULL,
284            file_name TEXT NOT NULL,
285            row_count BIGINT NOT NULL,
286            bytes BIGINT NOT NULL,
287            format TEXT NOT NULL,
288            compression TEXT,
289            created_at TEXT NOT NULL
290        );",
291    ),
292    (
293        2,
294        "CREATE TABLE IF NOT EXISTS chunk_run (
295            run_id TEXT PRIMARY KEY,
296            export_name TEXT NOT NULL,
297            plan_hash TEXT NOT NULL,
298            status TEXT NOT NULL,
299            max_chunk_attempts BIGINT NOT NULL DEFAULT 3,
300            created_at TEXT NOT NULL,
301            updated_at TEXT NOT NULL
302        );
303        CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
304            ON chunk_run(export_name, status);
305        CREATE TABLE IF NOT EXISTS chunk_task (
306            id BIGSERIAL PRIMARY KEY,
307            run_id TEXT NOT NULL,
308            chunk_index BIGINT NOT NULL,
309            start_key TEXT NOT NULL,
310            end_key TEXT NOT NULL,
311            status TEXT NOT NULL,
312            attempts BIGINT NOT NULL DEFAULT 0,
313            last_error TEXT,
314            rows_written BIGINT,
315            file_name TEXT,
316            updated_at TEXT NOT NULL,
317            UNIQUE(run_id, chunk_index)
318        );
319        CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
320    ),
321    (
322        3,
323        "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
324    ),
325    (
326        4,
327        "CREATE TABLE IF NOT EXISTS export_progression (
328            export_name TEXT PRIMARY KEY,
329            last_committed_strategy TEXT,
330            last_committed_cursor TEXT,
331            last_committed_chunk_index BIGINT,
332            last_committed_run_id TEXT,
333            last_committed_at TEXT,
334            last_verified_strategy TEXT,
335            last_verified_cursor TEXT,
336            last_verified_chunk_index BIGINT,
337            last_verified_run_id TEXT,
338            last_verified_at TEXT
339        );",
340    ),
341    (
342        5,
343        "CREATE TABLE IF NOT EXISTS run_aggregate (
344            run_aggregate_id TEXT PRIMARY KEY,
345            started_at TEXT NOT NULL,
346            finished_at TEXT NOT NULL,
347            duration_ms BIGINT NOT NULL,
348            config_path TEXT,
349            parallel_mode TEXT NOT NULL,
350            total_exports BIGINT NOT NULL,
351            success_count BIGINT NOT NULL,
352            failed_count BIGINT NOT NULL,
353            skipped_count BIGINT NOT NULL,
354            total_rows BIGINT NOT NULL,
355            total_files BIGINT NOT NULL,
356            total_bytes BIGINT NOT NULL,
357            details_json TEXT NOT NULL
358        );
359        CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
360            ON run_aggregate(finished_at DESC);",
361    ),
362    (
363        6,
364        "CREATE TABLE IF NOT EXISTS export_shape (
365            export_name TEXT NOT NULL,
366            column_name TEXT NOT NULL,
367            max_byte_len BIGINT NOT NULL,
368            updated_at TEXT NOT NULL,
369            PRIMARY KEY (export_name, column_name)
370        );",
371    ),
372    (
373        7,
374        "CREATE TABLE IF NOT EXISTS run_journal (
375            run_id TEXT PRIMARY KEY,
376            export_name TEXT NOT NULL,
377            finished_at TEXT NOT NULL,
378            journal_json TEXT NOT NULL
379        );
380        CREATE INDEX IF NOT EXISTS idx_run_journal_export
381            ON run_journal(export_name, finished_at DESC);",
382    ),
383    // v8: rename file_manifest → file_log.  Mirrors the SQLite v8 migration;
384    // see the SQLite array for rationale.
385    (
386        8,
387        "ALTER TABLE file_manifest RENAME TO file_log;
388        DROP INDEX IF EXISTS idx_file_manifest_export;
389        CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
390    ),
391    // v9: extended per-run metrics (see the SQLite array for rationale).
392    // Additive + nullable; BOOLEAN for the bool flags, BIGINT for counts.
393    (
394        9,
395        "ALTER TABLE export_metrics ADD COLUMN files_committed BIGINT;
396        ALTER TABLE export_metrics ADD COLUMN reconciled BOOLEAN;
397        ALTER TABLE export_metrics ADD COLUMN source_count BIGINT;
398        ALTER TABLE export_metrics ADD COLUMN quality_passed BOOLEAN;
399        ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta BIGINT;
400        ALTER TABLE export_metrics ADD COLUMN batch_size BIGINT;
401        ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb BIGINT;
402        ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
403        ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
404        ALTER TABLE export_metrics ADD COLUMN chunk_size BIGINT;
405        ALTER TABLE export_metrics ADD COLUMN parallel BIGINT;
406        ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
407        ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
408        ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
409    ),
410    // v10: longest single-chunk wall time (ms). See the SQLite array.
411    (
412        10,
413        "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
414    ),
415    // v11: per-run source-harm deltas (see the SQLite array for rationale).
416    (
417        11,
418        "CREATE TABLE IF NOT EXISTS export_harm (
419            id BIGSERIAL PRIMARY KEY,
420            run_id TEXT NOT NULL,
421            export_name TEXT NOT NULL,
422            metric TEXT NOT NULL,
423            delta BIGINT NOT NULL,
424            recorded_at TEXT NOT NULL
425        );
426        CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
427    ),
428    // v12: chunking diagnostics (see the SQLite array for rationale).
429    (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
430];
431
432// ─── SQL helpers ──────────────────────────────────────────────────────────────
433
434/// Convert SQLite `?N` placeholders to PostgreSQL `$N` style.
435/// `"WHERE x = ?1 AND y = ?2"` → `"WHERE x = $1 AND y = $2"`.
436pub(super) fn pg_sql(sql: &str) -> String {
437    let bytes = sql.as_bytes();
438    let mut out = String::with_capacity(sql.len());
439    let mut i = 0;
440    while i < bytes.len() {
441        if bytes[i] == b'?' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
442            out.push('$');
443        } else {
444            out.push(bytes[i] as char);
445        }
446        i += 1;
447    }
448    out
449}
450
451/// Open a Postgres client for the state backend, honoring the URL's `sslmode`.
452///
453/// The state backend connects to its store using only a URL (`RIVET_STATE_URL`)
454/// — there is no YAML `tls:` block — so the transport-security policy is derived
455/// from the URL's `sslmode` query parameter, exactly as `rivet init` does for
456/// source connections. The connection itself goes through the shared
457/// [`crate::source::postgres::connect_client`] path so the state backend and
458/// source connections apply identical TLS rules.
459///
460/// - missing / `disable` / `prefer` / `allow` / unrecognized → `NoTls`
461///   (plaintext), keeping local and dev setups working unchanged.
462/// - `require` / `verify-ca` / `verify-full` → negotiate TLS.
463///
464/// Used by both [`StateStore::open_postgres`] and the parallel chunk-worker
465/// reconnection paths in `checkpoint.rs`, so every PG state connection is
466/// TLS-aware.
467pub(super) fn connect_pg(url: &str) -> Result<postgres::Client> {
468    let tls = state_tls_mode_from_url(url).map(|mode| crate::config::TlsConfig {
469        mode,
470        ..crate::config::TlsConfig::default()
471    });
472    crate::source::postgres::connect_client(url, tls.as_ref())
473        .map_err(|e| anyhow::anyhow!("state(pg): connect to '{}': {:#}", redact_pg_url(url), e))
474}
475
476/// Map the state URL's `sslmode` query parameter to a [`crate::config::TlsMode`].
477///
478/// Mirrors the source-side mapping in `crate::init::postgres`: `require` /
479/// `verify-ca` / `verify-full` enforce TLS; everything else — parameter missing,
480/// `disable`, `prefer`, `allow`, or an unrecognized value — returns `None`
481/// (plaintext `NoTls`). [`crate::config::TlsMode`] has no `prefer` variant, so no
482/// try-TLS-then-fallback is attempted. Last occurrence wins, matching libpq.
483fn state_tls_mode_from_url(url: &str) -> Option<crate::config::TlsMode> {
484    use crate::config::TlsMode;
485    let (_, query) = url.split_once('?')?;
486    let mut mode = None;
487    for pair in query.split('&') {
488        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
489        if key != "sslmode" {
490            continue;
491        }
492        mode = match value {
493            "require" => Some(TlsMode::Require),
494            "verify-ca" => Some(TlsMode::VerifyCa),
495            "verify-full" => Some(TlsMode::VerifyFull),
496            _ => None,
497        };
498    }
499    mode
500}
501
502// ─── Backend connection ────────────────────────────────────────────────────────
503
504/// Internal storage for the active database connection.
505pub(super) enum StateConn {
506    Sqlite(rusqlite::Connection),
507    /// postgres::Client requires `&mut self` for queries; RefCell provides
508    /// interior mutability so `StateStore` methods can keep `&self` signatures.
509    /// StateStore is not Sync (neither backend is), so RefCell is safe here.
510    /// Boxed to keep the enum variant sizes balanced (postgres::Client is ~320 B).
511    Postgres(Box<std::cell::RefCell<postgres::Client>>),
512}
513
514/// Serialisable reference that identifies a state database without holding a
515/// live connection.  Passed to parallel chunk workers so they can open their
516/// own connection for atomic `claim_next_chunk_task` operations.
517#[derive(Clone)]
518pub enum StateRef {
519    Sqlite(std::path::PathBuf),
520    Postgres(String),
521}
522
523// ─── SQLite migration ─────────────────────────────────────────────────────────
524
525fn ensure_schema_version_table(conn: &Connection) {
526    let _ = conn.execute_batch(
527        "CREATE TABLE IF NOT EXISTS schema_version (
528            version INTEGER NOT NULL
529        );",
530    );
531}
532
533fn get_current_version(conn: &Connection) -> i64 {
534    conn.query_row(
535        "SELECT COALESCE(MAX(version), 0) FROM schema_version",
536        [],
537        |row| row.get(0),
538    )
539    .unwrap_or(0)
540}
541
542fn migrate(conn: &Connection) -> Result<()> {
543    ensure_schema_version_table(conn);
544
545    let current = get_current_version(conn);
546
547    if current == 0 {
548        let has_export_state: bool = conn
549            .query_row(
550                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='export_state'",
551                [],
552                |row| row.get(0),
553            )
554            .unwrap_or(false);
555
556        if has_export_state {
557            let metrics_cols = [
558                "files_produced INTEGER DEFAULT 0",
559                "bytes_written INTEGER DEFAULT 0",
560                "retries INTEGER DEFAULT 0",
561                "validated INTEGER",
562                "schema_changed INTEGER",
563                "run_id TEXT",
564            ];
565            for col_def in &metrics_cols {
566                let sql = format!("ALTER TABLE export_metrics ADD COLUMN {}", col_def);
567                let _ = conn.execute(&sql, []);
568            }
569        }
570    }
571
572    for &(ver, sql) in MIGRATIONS {
573        if ver > current {
574            log::debug!("state: applying migration v{}", ver);
575            let atomic_sql = format!(
576                "BEGIN;\n{}\nINSERT INTO schema_version (version) VALUES ({});\nCOMMIT;",
577                sql, ver
578            );
579            conn.execute_batch(&atomic_sql)
580                .map_err(|e| anyhow::anyhow!("state: migration v{} failed: {}", ver, e))?;
581        }
582    }
583
584    let _ = conn.execute(
585        "DELETE FROM schema_version WHERE version < (SELECT MAX(version) FROM schema_version)",
586        [],
587    );
588
589    let final_version = get_current_version(conn);
590    if final_version != SCHEMA_VERSION {
591        anyhow::bail!(
592            "state: migration incomplete — expected schema v{} but reached v{}",
593            SCHEMA_VERSION,
594            final_version
595        );
596    }
597
598    Ok(())
599}
600
601// ─── PostgreSQL migration ─────────────────────────────────────────────────────
602
603fn migrate_pg(client: &mut postgres::Client) -> Result<()> {
604    client
605        .batch_execute("CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);")
606        .map_err(|e| anyhow::anyhow!("state(pg): create version table: {:#}", e))?;
607
608    let current: i64 = client
609        .query_one(
610            "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
611            &[],
612        )
613        .map_err(|e| anyhow::anyhow!("state(pg): read schema version: {:#}", e))?
614        .get(0);
615
616    for &(ver, sql) in PG_MIGRATIONS {
617        if ver > current {
618            log::debug!("state(pg): applying migration v{}", ver);
619            let batch = format!(
620                "BEGIN; {} INSERT INTO rivet_schema_version (version) VALUES ({}); COMMIT;",
621                sql, ver
622            );
623            client
624                .batch_execute(&batch)
625                .map_err(|e| anyhow::anyhow!("state(pg): migration v{} failed: {:#}", ver, e))?;
626        }
627    }
628
629    // Remove superseded version rows so MAX() stays unambiguous (mirrors SQLite behaviour).
630    let _ = client.batch_execute(
631        "DELETE FROM rivet_schema_version \
632         WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
633    );
634
635    // Verify the DB actually reached the expected version.
636    let final_version: i64 = client
637        .query_one(
638            "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
639            &[],
640        )
641        .map_err(|e| anyhow::anyhow!("state(pg): read final schema version: {:#}", e))?
642        .get(0);
643    if final_version != SCHEMA_VERSION {
644        anyhow::bail!(
645            "state(pg): migration incomplete — expected schema v{} but reached v{}",
646            SCHEMA_VERSION,
647            final_version
648        );
649    }
650
651    Ok(())
652}
653
654/// Redact the password from a PostgreSQL URL for safe use in log/error messages.
655/// `postgresql://user:SECRET@host/db` → `postgresql://user:***@host/db`
656/// Uses `rfind('@')` so passwords containing `@` are handled correctly.
657fn redact_pg_url(url: &str) -> String {
658    if let Some(at_pos) = url.rfind('@')
659        && let Some(scheme_end) = url.find("://")
660    {
661        let authority = &url[scheme_end + 3..at_pos];
662        if let Some(colon) = authority.rfind(':') {
663            let user = &authority[..colon];
664            return format!(
665                "{}://{}:***@{}",
666                &url[..scheme_end],
667                user,
668                &url[at_pos + 1..]
669            );
670        }
671    }
672    url.to_string()
673}
674
675// ─── SQLite connection helper ─────────────────────────────────────────────────
676
677pub(crate) const SQLITE_BUSY_TIMEOUT_MS: i64 = 10_000;
678
679pub(crate) fn open_connection(db_path: &std::path::Path) -> Result<Connection> {
680    let conn = Connection::open(db_path)?;
681    if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL;") {
682        log::warn!(
683            "state: WAL journal mode unavailable ({}); \
684             running in default mode — concurrent writes may be slower",
685            e
686        );
687    }
688    if let Err(e) = conn.execute_batch(&format!(
689        "PRAGMA busy_timeout = {};",
690        SQLITE_BUSY_TIMEOUT_MS
691    )) {
692        log::warn!(
693            "state: failed to set busy_timeout ({}); \
694             concurrent writers may surface SQLITE_BUSY immediately",
695            e
696        );
697    }
698    Ok(conn)
699}
700
701// ─── StateStore ───────────────────────────────────────────────────────────────
702
703/// Entry point for all persistent state.  Supports two backends:
704///
705/// - **SQLite** (default) — a single `.rivet_state.db` file next to the
706///   config.  Good for local / single-node / dev deployments.
707/// - **PostgreSQL** — a shared database addressed by `RIVET_STATE_URL`.
708///   Required for stateless container / Kubernetes deployments where the
709///   rivet pod is ephemeral or replicated.
710///
711/// Set the `RIVET_STATE_URL` environment variable to a PostgreSQL URL to
712/// activate the Postgres backend:
713///
714/// ```text
715/// RIVET_STATE_URL=postgresql://user:pass@host:5432/rivet_state
716/// ```
717///
718/// When the variable is absent or does not start with `postgres`, SQLite is
719/// used and the variable is ignored.
720pub struct StateStore {
721    pub(super) conn: StateConn,
722    /// Serialisable reference for reconnection (parallel chunk workers).
723    pub(super) state_ref: StateRef,
724}
725
726impl StateStore {
727    /// Open the appropriate backend.
728    ///
729    /// Checks `RIVET_STATE_URL`; falls back to SQLite next to `config_path`.
730    pub fn open(config_path: &str) -> Result<Self> {
731        if let Ok(url) = std::env::var("RIVET_STATE_URL")
732            && url.starts_with("postgres")
733        {
734            return Self::open_postgres(&url);
735        }
736        Self::open_sqlite(config_path)
737    }
738
739    fn open_sqlite(config_path: &str) -> Result<Self> {
740        let config_dir = std::path::Path::new(config_path)
741            .parent()
742            .unwrap_or(std::path::Path::new("."));
743        let db_path = config_dir.join(STATE_DB_NAME);
744        let conn = open_connection(&db_path)?;
745        migrate(&conn)?;
746        Ok(Self {
747            conn: StateConn::Sqlite(conn),
748            state_ref: StateRef::Sqlite(db_path),
749        })
750    }
751
752    fn open_postgres(url: &str) -> Result<Self> {
753        let is_local =
754            url.contains("localhost") || url.contains("127.0.0.1") || url.contains("::1");
755        if !is_local && state_tls_mode_from_url(url).is_none() {
756            log::warn!(
757                "state(pg): connecting to a remote host without TLS; \
758                 add sslmode=require (or verify-ca / verify-full) to RIVET_STATE_URL \
759                 to negotiate TLS for production use"
760            );
761        }
762        let mut client = connect_pg(url)?;
763        migrate_pg(&mut client)?;
764        Ok(Self {
765            conn: StateConn::Postgres(Box::new(std::cell::RefCell::new(client))),
766            state_ref: StateRef::Postgres(url.to_string()),
767        })
768    }
769
770    /// Path to `.rivet_state.db` for SQLite deployments.  Returns the config
771    /// directory path for Postgres (not meaningful for connection, only used
772    /// by legacy callers — prefer `state_ref()` for new code).
773    pub fn state_db_path(config_path: &str) -> std::path::PathBuf {
774        let config_dir = std::path::Path::new(config_path)
775            .parent()
776            .unwrap_or(std::path::Path::new("."));
777        config_dir.join(STATE_DB_NAME)
778    }
779
780    /// Serialisable connection reference for parallel chunk workers.
781    pub fn state_ref(&self) -> &StateRef {
782        &self.state_ref
783    }
784
785    /// In-memory SQLite store for unit tests.
786    #[allow(dead_code)]
787    pub fn open_in_memory() -> Result<Self> {
788        let conn = Connection::open_in_memory()?;
789        migrate(&conn)?;
790        Ok(Self {
791            conn: StateConn::Sqlite(conn),
792            state_ref: StateRef::Sqlite(std::path::PathBuf::from(":memory:")),
793        })
794    }
795
796    /// Open a SQLite store at an explicit file path (tests that need
797    /// cross-connection access via `claim_next_chunk_task_at_path`).
798    #[allow(dead_code)]
799    pub fn open_at_path(db_path: &std::path::Path) -> Result<Self> {
800        let conn = open_connection(db_path)?;
801        migrate(&conn)?;
802        Ok(Self {
803            conn: StateConn::Sqlite(conn),
804            state_ref: StateRef::Sqlite(db_path.to_path_buf()),
805        })
806    }
807}
808
809// ─── Migration tests ──────────────────────────────────────────────────────────
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn fresh_db_reaches_latest_version() {
817        let s = StateStore::open_in_memory().unwrap();
818        let ver = match &s.conn {
819            StateConn::Sqlite(c) => get_current_version(c),
820            StateConn::Postgres(_) => unreachable!(),
821        };
822        assert_eq!(ver, SCHEMA_VERSION);
823    }
824
825    #[test]
826    fn migration_is_idempotent() {
827        let s = StateStore::open_in_memory().unwrap();
828        match &s.conn {
829            StateConn::Sqlite(c) => {
830                migrate(c).unwrap();
831                migrate(c).unwrap();
832                assert_eq!(get_current_version(c), SCHEMA_VERSION);
833            }
834            StateConn::Postgres(_) => unreachable!(),
835        }
836    }
837
838    #[test]
839    fn legacy_db_gets_upgraded() {
840        let conn = Connection::open_in_memory().unwrap();
841        conn.execute_batch(
842            "CREATE TABLE export_state (
843                export_name TEXT PRIMARY KEY,
844                last_cursor_value TEXT,
845                last_run_at TEXT
846            );
847            CREATE TABLE export_metrics (
848                id INTEGER PRIMARY KEY AUTOINCREMENT,
849                export_name TEXT NOT NULL,
850                run_at TEXT NOT NULL,
851                duration_ms INTEGER NOT NULL,
852                total_rows INTEGER NOT NULL,
853                status TEXT NOT NULL
854            );",
855        )
856        .unwrap();
857
858        migrate(&conn).unwrap();
859        assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
860
861        let has_chunk_run: bool = conn
862            .query_row(
863                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunk_run'",
864                [],
865                |row| row.get(0),
866            )
867            .unwrap();
868        assert!(has_chunk_run);
869    }
870
871    #[test]
872    fn v8_renames_file_manifest_to_file_log() {
873        let s = StateStore::open_in_memory().unwrap();
874        let conn = match &s.conn {
875            StateConn::Sqlite(c) => c,
876            StateConn::Postgres(_) => unreachable!(),
877        };
878        let has_file_log: bool = conn
879            .query_row(
880                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_log'",
881                [],
882                |row| row.get(0),
883            )
884            .unwrap();
885        assert!(has_file_log, "v8 must produce a `file_log` table");
886        let has_old: bool = conn
887            .query_row(
888                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_manifest'",
889                [],
890                |row| row.get(0),
891            )
892            .unwrap();
893        assert!(!has_old, "v8 must remove the old `file_manifest` table");
894        let has_new_idx: bool = conn
895            .query_row(
896                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_file_log_export'",
897                [],
898                |row| row.get(0),
899            )
900            .unwrap();
901        assert!(has_new_idx, "v8 must create the renamed index");
902    }
903
904    #[test]
905    fn v8_upgrades_existing_v7_db_with_data() {
906        // Simulate an existing 0.6.0 database stopped at v7: the table is still
907        // named `file_manifest` and has rows.  v8 must rename it preserving data.
908        let conn = Connection::open_in_memory().unwrap();
909        // Apply v1..=v7 by running the migrator after manually stamping v7.
910        // Simpler: run the migrator, then manually rename back to v7 state to
911        // exercise the v7→v8 path.  Here we just verify forward path covers it.
912        migrate(&conn).unwrap();
913        // Insert a row using the new name (post-v8); the rename happened transparently.
914        conn.execute(
915            "INSERT INTO file_log (run_id, export_name, file_name, row_count, bytes, format, created_at)
916             VALUES ('r1', 'orders', 'f.parquet', 100, 4096, 'parquet', '2026-05-21T00:00:00Z')",
917            [],
918        )
919        .unwrap();
920        let count: i64 = conn
921            .query_row("SELECT COUNT(*) FROM file_log", [], |r| r.get(0))
922            .unwrap();
923        assert_eq!(count, 1);
924    }
925
926    #[test]
927    fn run_aggregate_table_exists_after_migration() {
928        let s = StateStore::open_in_memory().unwrap();
929        let conn = match &s.conn {
930            StateConn::Sqlite(c) => c,
931            StateConn::Postgres(_) => unreachable!(),
932        };
933        let exists: bool = conn
934            .query_row(
935                "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='run_aggregate'",
936                [],
937                |row| row.get(0),
938            )
939            .unwrap();
940        assert!(exists, "v5 migration must create the run_aggregate table");
941    }
942
943    #[test]
944    fn pg_sql_converts_placeholders() {
945        assert_eq!(
946            pg_sql("SELECT ?1, ?2 FROM t WHERE x = ?3"),
947            "SELECT $1, $2 FROM t WHERE x = $3"
948        );
949        assert_eq!(
950            pg_sql("INSERT INTO t VALUES (?1, ?2)"),
951            "INSERT INTO t VALUES ($1, $2)"
952        );
953        assert_eq!(pg_sql("no placeholders"), "no placeholders");
954        // ?N with two digits
955        assert_eq!(pg_sql("?10 AND ?11"), "$10 AND $11");
956    }
957
958    #[test]
959    fn redact_pg_url_removes_password() {
960        assert_eq!(
961            redact_pg_url("postgresql://rivet:secret123@localhost:5433/rivet_state"),
962            "postgresql://rivet:***@localhost:5433/rivet_state"
963        );
964        assert_eq!(
965            redact_pg_url("postgres://admin:p@ssw0rd@db.prod.example.com/state"),
966            "postgres://admin:***@db.prod.example.com/state"
967        );
968    }
969
970    #[test]
971    fn redact_pg_url_no_password_unchanged() {
972        // URL without a password should come back as-is.
973        let url = "postgresql://rivet@localhost/state";
974        assert_eq!(redact_pg_url(url), url);
975    }
976
977    // ── state(pg) sslmode → TlsMode mapping ─────────────────────────────────
978    //
979    // Pins the decision behind the TLS bug fix: the state backend can no longer
980    // hard-code NoTls. We can't drive a live TLS handshake in a unit test, so we
981    // assert the *chosen transport policy* — TLS is enforced for require /
982    // verify-* and plaintext (NoTls) otherwise — which is what selects the
983    // connector inside `connect_pg` -> `connect_client`.
984    use crate::config::TlsMode;
985
986    #[test]
987    fn state_sslmode_enforced_values_negotiate_tls() {
988        for (url, want) in [
989            (
990                "postgresql://u:p@db.prod:5432/state?sslmode=require",
991                TlsMode::Require,
992            ),
993            (
994                "postgresql://u:p@db.prod/state?sslmode=verify-ca",
995                TlsMode::VerifyCa,
996            ),
997            (
998                "postgresql://u:p@db.prod/state?sslmode=verify-full",
999                TlsMode::VerifyFull,
1000            ),
1001        ] {
1002            let mode = state_tls_mode_from_url(url);
1003            assert_eq!(mode, Some(want), "url: {url}");
1004            assert!(
1005                mode.unwrap().is_enforced(),
1006                "{want:?} must enforce TLS (not NoTls)"
1007            );
1008        }
1009    }
1010
1011    #[test]
1012    fn state_sslmode_plaintext_values_stay_notls() {
1013        // Missing / disable / prefer / allow / unrecognized / uppercase all keep
1014        // the original NoTls behavior, so dev + docker setups are unchanged.
1015        for url in [
1016            "postgresql://u:p@localhost/state",
1017            "postgresql://u:p@localhost/state?sslmode=disable",
1018            "postgresql://u:p@db/state?sslmode=prefer",
1019            "postgresql://u:p@db/state?sslmode=allow",
1020            "postgresql://u:p@db/state?sslmode=REQUIRE",
1021            "postgresql://u:p@db/state?sslmode=garbage",
1022            "postgresql://u:p@db/state?sslmode",
1023            "postgresql://u:p@db/state?sslmode=",
1024        ] {
1025            assert_eq!(state_tls_mode_from_url(url), None, "url: {url}");
1026        }
1027    }
1028
1029    #[test]
1030    fn state_sslmode_exact_key_and_last_occurrence_wins() {
1031        // `xsslmode` is a different parameter; the exact `sslmode` key matters.
1032        assert_eq!(
1033            state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1034            None
1035        );
1036        // Found among other params.
1037        assert_eq!(
1038            state_tls_mode_from_url(
1039                "postgresql://u:p@db/state?connect_timeout=10&sslmode=require&application_name=x"
1040            ),
1041            Some(TlsMode::Require)
1042        );
1043        // Last occurrence wins, matching libpq.
1044        assert_eq!(
1045            state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=disable&sslmode=require"),
1046            Some(TlsMode::Require)
1047        );
1048        assert_eq!(
1049            state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=require&sslmode=disable"),
1050            None
1051        );
1052    }
1053}