1use rusqlite::Connection;
2
3use crate::error::Result;
4
5mod cdc_snapshot_store;
6mod checkpoint;
7mod cursor;
8mod file_log;
9mod journal_store;
10mod load_journal_store;
11mod metrics;
12mod progression;
13mod run_aggregate;
14mod run_status_store;
15mod schema;
16mod shape;
17
18#[allow(unused_imports)]
22pub use checkpoint::ChunkTaskInfo;
23#[allow(unused_imports)]
24pub use file_log::FileRecord;
25pub use load_journal_store::LoadRecord;
26#[allow(unused_imports)]
27pub use metrics::ExportMetric;
28pub use metrics::MetricRow;
29#[allow(unused_imports)]
30pub use progression::{Boundary, ExportProgression};
31#[allow(unused_imports)]
32pub use run_aggregate::{RunAggregate, RunAggregateEntry};
33#[allow(unused_imports)]
34pub use schema::{SchemaChange, SchemaColumn, arrow_schema_to_columns, schema_fingerprint};
35#[allow(unused_imports)]
36pub use shape::ShapeWarning;
37
38const STATE_DB_NAME: &str = ".rivet_state.db";
39
40const SCHEMA_VERSION: i64 = MIGRATIONS[MIGRATIONS.len() - 1].0;
42
43const MIGRATIONS: &[(i64, &str)] = &[
45 (
47 1,
48 "CREATE TABLE IF NOT EXISTS export_state (
49 export_name TEXT PRIMARY KEY,
50 last_cursor_value TEXT,
51 last_run_at TEXT
52 );
53 CREATE TABLE IF NOT EXISTS export_metrics (
54 id INTEGER PRIMARY KEY AUTOINCREMENT,
55 export_name TEXT NOT NULL,
56 run_at TEXT NOT NULL,
57 duration_ms INTEGER NOT NULL,
58 total_rows INTEGER NOT NULL,
59 peak_rss_mb INTEGER,
60 status TEXT NOT NULL,
61 error_message TEXT,
62 tuning_profile TEXT,
63 format TEXT,
64 mode TEXT,
65 files_produced INTEGER DEFAULT 0,
66 bytes_written INTEGER DEFAULT 0,
67 retries INTEGER DEFAULT 0,
68 validated INTEGER,
69 schema_changed INTEGER,
70 run_id TEXT
71 );
72 CREATE TABLE IF NOT EXISTS export_schema (
73 export_name TEXT PRIMARY KEY,
74 columns_json TEXT NOT NULL,
75 updated_at TEXT NOT NULL
76 );
77 CREATE TABLE IF NOT EXISTS file_manifest (
78 id INTEGER PRIMARY KEY AUTOINCREMENT,
79 run_id TEXT NOT NULL,
80 export_name TEXT NOT NULL,
81 file_name TEXT NOT NULL,
82 row_count INTEGER NOT NULL,
83 bytes INTEGER NOT NULL,
84 format TEXT NOT NULL,
85 compression TEXT,
86 created_at TEXT NOT NULL
87 );",
88 ),
89 (
91 2,
92 "CREATE TABLE IF NOT EXISTS chunk_run (
93 run_id TEXT PRIMARY KEY,
94 export_name TEXT NOT NULL,
95 plan_hash TEXT NOT NULL,
96 status TEXT NOT NULL,
97 max_chunk_attempts INTEGER NOT NULL DEFAULT 3,
98 created_at TEXT NOT NULL,
99 updated_at TEXT NOT NULL
100 );
101 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
102 ON chunk_run(export_name, status);
103 CREATE TABLE IF NOT EXISTS chunk_task (
104 id INTEGER PRIMARY KEY AUTOINCREMENT,
105 run_id TEXT NOT NULL,
106 chunk_index INTEGER NOT NULL,
107 start_key TEXT NOT NULL,
108 end_key TEXT NOT NULL,
109 status TEXT NOT NULL,
110 attempts INTEGER NOT NULL DEFAULT 0,
111 last_error TEXT,
112 rows_written INTEGER,
113 file_name TEXT,
114 updated_at TEXT NOT NULL,
115 UNIQUE(run_id, chunk_index)
116 );
117 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
118 ),
119 (
121 3,
122 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
123 ),
124 (
126 4,
127 "CREATE TABLE IF NOT EXISTS export_progression (
128 export_name TEXT PRIMARY KEY,
129 last_committed_strategy TEXT,
130 last_committed_cursor TEXT,
131 last_committed_chunk_index INTEGER,
132 last_committed_run_id TEXT,
133 last_committed_at TEXT,
134 last_verified_strategy TEXT,
135 last_verified_cursor TEXT,
136 last_verified_chunk_index INTEGER,
137 last_verified_run_id TEXT,
138 last_verified_at TEXT
139 );",
140 ),
141 (
143 5,
144 "CREATE TABLE IF NOT EXISTS run_aggregate (
145 run_aggregate_id TEXT PRIMARY KEY,
146 started_at TEXT NOT NULL,
147 finished_at TEXT NOT NULL,
148 duration_ms INTEGER NOT NULL,
149 config_path TEXT,
150 parallel_mode TEXT NOT NULL,
151 total_exports INTEGER NOT NULL,
152 success_count INTEGER NOT NULL,
153 failed_count INTEGER NOT NULL,
154 skipped_count INTEGER NOT NULL,
155 total_rows INTEGER NOT NULL,
156 total_files INTEGER NOT NULL,
157 total_bytes INTEGER NOT NULL,
158 details_json TEXT NOT NULL
159 );
160 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
161 ON run_aggregate(finished_at DESC);",
162 ),
163 (
165 6,
166 "CREATE TABLE IF NOT EXISTS export_shape (
167 export_name TEXT NOT NULL,
168 column_name TEXT NOT NULL,
169 max_byte_len INTEGER NOT NULL,
170 updated_at TEXT NOT NULL,
171 PRIMARY KEY (export_name, column_name)
172 );",
173 ),
174 (
176 7,
177 "CREATE TABLE IF NOT EXISTS run_journal (
178 run_id TEXT PRIMARY KEY,
179 export_name TEXT NOT NULL,
180 finished_at TEXT NOT NULL,
181 journal_json TEXT NOT NULL
182 );
183 CREATE INDEX IF NOT EXISTS idx_run_journal_export
184 ON run_journal(export_name, finished_at DESC);",
185 ),
186 (
190 8,
191 "ALTER TABLE file_manifest RENAME TO file_log;
192 DROP INDEX IF EXISTS idx_file_manifest_export;
193 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
194 ),
195 (
202 9,
203 "ALTER TABLE export_metrics ADD COLUMN files_committed INTEGER;
204 ALTER TABLE export_metrics ADD COLUMN reconciled INTEGER;
205 ALTER TABLE export_metrics ADD COLUMN source_count INTEGER;
206 ALTER TABLE export_metrics ADD COLUMN quality_passed INTEGER;
207 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta INTEGER;
208 ALTER TABLE export_metrics ADD COLUMN batch_size INTEGER;
209 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb INTEGER;
210 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
211 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
212 ALTER TABLE export_metrics ADD COLUMN chunk_size INTEGER;
213 ALTER TABLE export_metrics ADD COLUMN parallel INTEGER;
214 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
215 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
216 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
217 ),
218 (
221 10,
222 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms INTEGER;",
223 ),
224 (
229 11,
230 "CREATE TABLE IF NOT EXISTS export_harm (
231 id INTEGER PRIMARY KEY AUTOINCREMENT,
232 run_id TEXT NOT NULL,
233 export_name TEXT NOT NULL,
234 metric TEXT NOT NULL,
235 delta INTEGER NOT NULL,
236 recorded_at TEXT NOT NULL
237 );
238 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
239 ),
240 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
248 (
253 13,
254 "CREATE TABLE IF NOT EXISTS load_run (
255 load_id TEXT PRIMARY KEY,
256 export_name TEXT NOT NULL,
257 target_table TEXT NOT NULL,
258 warehouse TEXT NOT NULL,
259 mode TEXT NOT NULL,
260 source_run_ids TEXT NOT NULL,
261 rows_loaded INTEGER NOT NULL,
262 status TEXT NOT NULL,
263 finished_at TEXT NOT NULL
264 );
265 CREATE INDEX IF NOT EXISTS idx_load_run_target
266 ON load_run(target_table, finished_at DESC);
267 CREATE TABLE IF NOT EXISTS loaded_source_run (
268 target_table TEXT NOT NULL,
269 source_run_id TEXT NOT NULL,
270 load_id TEXT NOT NULL,
271 loaded_at TEXT NOT NULL,
272 PRIMARY KEY (target_table, source_run_id)
273 );",
274 ),
275 (
280 14,
281 "CREATE TABLE IF NOT EXISTS cdc_snapshot (
282 export_name TEXT NOT NULL,
283 table_name TEXT NOT NULL,
284 run_id TEXT NOT NULL,
285 completed_at TEXT NOT NULL,
286 PRIMARY KEY (export_name, table_name)
287 );",
288 ),
289 (
300 15,
301 "UPDATE chunk_run SET status='interrupted'
302 WHERE status='in_progress' AND run_id NOT IN (
303 SELECT run_id FROM chunk_run c WHERE c.status='in_progress'
304 AND NOT EXISTS (
305 SELECT 1 FROM chunk_run c2
306 WHERE c2.export_name=c.export_name AND c2.status='in_progress'
307 AND (c2.created_at > c.created_at
308 OR (c2.created_at = c.created_at AND c2.run_id > c.run_id)))
309 );
310 CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_run_one_inprogress
311 ON chunk_run(export_name) WHERE status='in_progress';",
312 ),
313 (
319 16,
320 "ALTER TABLE export_state ADD COLUMN resume_run_id TEXT;",
321 ),
322 (
330 17,
331 "CREATE TABLE IF NOT EXISTS run_status (
332 run_id TEXT PRIMARY KEY,
333 export_name TEXT NOT NULL,
334 prefix TEXT NOT NULL,
335 status TEXT NOT NULL,
336 started_at TEXT NOT NULL,
337 finished_at TEXT
338 );
339 CREATE INDEX IF NOT EXISTS idx_run_status_prefix ON run_status(prefix);",
340 ),
341 (
348 18,
349 "ALTER TABLE export_metrics ADD COLUMN error_class TEXT;
350 ALTER TABLE export_metrics ADD COLUMN cursor_min TEXT;
351 ALTER TABLE export_metrics ADD COLUMN cursor_max TEXT;
352 ALTER TABLE export_metrics ADD COLUMN key_descriptor_json TEXT;
353 ALTER TABLE export_metrics ADD COLUMN offending_value TEXT;
354 ALTER TABLE export_metrics ADD COLUMN server_context_json TEXT;",
355 ),
356];
357
358const PG_MIGRATIONS: &[(i64, &str)] = &[
361 (
362 1,
363 "CREATE TABLE IF NOT EXISTS export_state (
364 export_name TEXT PRIMARY KEY,
365 last_cursor_value TEXT,
366 last_run_at TEXT
367 );
368 CREATE TABLE IF NOT EXISTS export_metrics (
369 id BIGSERIAL PRIMARY KEY,
370 export_name TEXT NOT NULL,
371 run_at TEXT NOT NULL,
372 duration_ms BIGINT NOT NULL,
373 total_rows BIGINT NOT NULL,
374 peak_rss_mb BIGINT,
375 status TEXT NOT NULL,
376 error_message TEXT,
377 tuning_profile TEXT,
378 format TEXT,
379 mode TEXT,
380 files_produced BIGINT DEFAULT 0,
381 bytes_written BIGINT DEFAULT 0,
382 retries BIGINT DEFAULT 0,
383 validated BOOLEAN,
384 schema_changed BOOLEAN,
385 run_id TEXT
386 );
387 CREATE TABLE IF NOT EXISTS export_schema (
388 export_name TEXT PRIMARY KEY,
389 columns_json TEXT NOT NULL,
390 updated_at TEXT NOT NULL
391 );
392 CREATE TABLE IF NOT EXISTS file_manifest (
393 id BIGSERIAL PRIMARY KEY,
394 run_id TEXT NOT NULL,
395 export_name TEXT NOT NULL,
396 file_name TEXT NOT NULL,
397 row_count BIGINT NOT NULL,
398 bytes BIGINT NOT NULL,
399 format TEXT NOT NULL,
400 compression TEXT,
401 created_at TEXT NOT NULL
402 );",
403 ),
404 (
405 2,
406 "CREATE TABLE IF NOT EXISTS chunk_run (
407 run_id TEXT PRIMARY KEY,
408 export_name TEXT NOT NULL,
409 plan_hash TEXT NOT NULL,
410 status TEXT NOT NULL,
411 max_chunk_attempts BIGINT NOT NULL DEFAULT 3,
412 created_at TEXT NOT NULL,
413 updated_at TEXT NOT NULL
414 );
415 CREATE INDEX IF NOT EXISTS idx_chunk_run_export_status
416 ON chunk_run(export_name, status);
417 CREATE TABLE IF NOT EXISTS chunk_task (
418 id BIGSERIAL PRIMARY KEY,
419 run_id TEXT NOT NULL,
420 chunk_index BIGINT NOT NULL,
421 start_key TEXT NOT NULL,
422 end_key TEXT NOT NULL,
423 status TEXT NOT NULL,
424 attempts BIGINT NOT NULL DEFAULT 0,
425 last_error TEXT,
426 rows_written BIGINT,
427 file_name TEXT,
428 updated_at TEXT NOT NULL,
429 UNIQUE(run_id, chunk_index)
430 );
431 CREATE INDEX IF NOT EXISTS idx_chunk_task_run_status ON chunk_task(run_id, status);",
432 ),
433 (
434 3,
435 "CREATE INDEX IF NOT EXISTS idx_file_manifest_export ON file_manifest(export_name, id DESC);",
436 ),
437 (
438 4,
439 "CREATE TABLE IF NOT EXISTS export_progression (
440 export_name TEXT PRIMARY KEY,
441 last_committed_strategy TEXT,
442 last_committed_cursor TEXT,
443 last_committed_chunk_index BIGINT,
444 last_committed_run_id TEXT,
445 last_committed_at TEXT,
446 last_verified_strategy TEXT,
447 last_verified_cursor TEXT,
448 last_verified_chunk_index BIGINT,
449 last_verified_run_id TEXT,
450 last_verified_at TEXT
451 );",
452 ),
453 (
454 5,
455 "CREATE TABLE IF NOT EXISTS run_aggregate (
456 run_aggregate_id TEXT PRIMARY KEY,
457 started_at TEXT NOT NULL,
458 finished_at TEXT NOT NULL,
459 duration_ms BIGINT NOT NULL,
460 config_path TEXT,
461 parallel_mode TEXT NOT NULL,
462 total_exports BIGINT NOT NULL,
463 success_count BIGINT NOT NULL,
464 failed_count BIGINT NOT NULL,
465 skipped_count BIGINT NOT NULL,
466 total_rows BIGINT NOT NULL,
467 total_files BIGINT NOT NULL,
468 total_bytes BIGINT NOT NULL,
469 details_json TEXT NOT NULL
470 );
471 CREATE INDEX IF NOT EXISTS idx_run_aggregate_finished
472 ON run_aggregate(finished_at DESC);",
473 ),
474 (
475 6,
476 "CREATE TABLE IF NOT EXISTS export_shape (
477 export_name TEXT NOT NULL,
478 column_name TEXT NOT NULL,
479 max_byte_len BIGINT NOT NULL,
480 updated_at TEXT NOT NULL,
481 PRIMARY KEY (export_name, column_name)
482 );",
483 ),
484 (
485 7,
486 "CREATE TABLE IF NOT EXISTS run_journal (
487 run_id TEXT PRIMARY KEY,
488 export_name TEXT NOT NULL,
489 finished_at TEXT NOT NULL,
490 journal_json TEXT NOT NULL
491 );
492 CREATE INDEX IF NOT EXISTS idx_run_journal_export
493 ON run_journal(export_name, finished_at DESC);",
494 ),
495 (
498 8,
499 "ALTER TABLE file_manifest RENAME TO file_log;
500 DROP INDEX IF EXISTS idx_file_manifest_export;
501 CREATE INDEX IF NOT EXISTS idx_file_log_export ON file_log(export_name, id DESC);",
502 ),
503 (
506 9,
507 "ALTER TABLE export_metrics ADD COLUMN files_committed BIGINT;
508 ALTER TABLE export_metrics ADD COLUMN reconciled BOOLEAN;
509 ALTER TABLE export_metrics ADD COLUMN source_count BIGINT;
510 ALTER TABLE export_metrics ADD COLUMN quality_passed BOOLEAN;
511 ALTER TABLE export_metrics ADD COLUMN pg_temp_bytes_delta BIGINT;
512 ALTER TABLE export_metrics ADD COLUMN batch_size BIGINT;
513 ALTER TABLE export_metrics ADD COLUMN batch_size_memory_mb BIGINT;
514 ALTER TABLE export_metrics ADD COLUMN skip_reason TEXT;
515 ALTER TABLE export_metrics ADD COLUMN schema_fingerprint TEXT;
516 ALTER TABLE export_metrics ADD COLUMN chunk_size BIGINT;
517 ALTER TABLE export_metrics ADD COLUMN parallel BIGINT;
518 ALTER TABLE export_metrics ADD COLUMN source_type TEXT;
519 ALTER TABLE export_metrics ADD COLUMN destination_type TEXT;
520 ALTER TABLE export_metrics ADD COLUMN rivet_version TEXT;",
521 ),
522 (
524 10,
525 "ALTER TABLE export_metrics ADD COLUMN longest_chunk_ms BIGINT;",
526 ),
527 (
529 11,
530 "CREATE TABLE IF NOT EXISTS export_harm (
531 id BIGSERIAL PRIMARY KEY,
532 run_id TEXT NOT NULL,
533 export_name TEXT NOT NULL,
534 metric TEXT NOT NULL,
535 delta BIGINT NOT NULL,
536 recorded_at TEXT NOT NULL
537 );
538 CREATE INDEX IF NOT EXISTS idx_export_harm_run ON export_harm(run_id);",
539 ),
540 (12, "ALTER TABLE export_metrics ADD COLUMN chunk_key TEXT;"),
542 (
544 13,
545 "CREATE TABLE IF NOT EXISTS load_run (
546 load_id TEXT PRIMARY KEY,
547 export_name TEXT NOT NULL,
548 target_table TEXT NOT NULL,
549 warehouse TEXT NOT NULL,
550 mode TEXT NOT NULL,
551 source_run_ids TEXT NOT NULL,
552 rows_loaded BIGINT NOT NULL,
553 status TEXT NOT NULL,
554 finished_at TEXT NOT NULL
555 );
556 CREATE INDEX IF NOT EXISTS idx_load_run_target
557 ON load_run(target_table, finished_at DESC);
558 CREATE TABLE IF NOT EXISTS loaded_source_run (
559 target_table TEXT NOT NULL,
560 source_run_id TEXT NOT NULL,
561 load_id TEXT NOT NULL,
562 loaded_at TEXT NOT NULL,
563 PRIMARY KEY (target_table, source_run_id)
564 );",
565 ),
566 (
568 14,
569 "CREATE TABLE IF NOT EXISTS cdc_snapshot (
570 export_name TEXT NOT NULL,
571 table_name TEXT NOT NULL,
572 run_id TEXT NOT NULL,
573 completed_at TEXT NOT NULL,
574 PRIMARY KEY (export_name, table_name)
575 );",
576 ),
577 (
588 15,
589 "UPDATE chunk_run SET status='interrupted'
590 WHERE status='in_progress' AND run_id NOT IN (
591 SELECT run_id FROM chunk_run c WHERE c.status='in_progress'
592 AND NOT EXISTS (
593 SELECT 1 FROM chunk_run c2
594 WHERE c2.export_name=c.export_name AND c2.status='in_progress'
595 AND (c2.created_at > c.created_at
596 OR (c2.created_at = c.created_at AND c2.run_id > c.run_id)))
597 );
598 CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_run_one_inprogress
599 ON chunk_run(export_name) WHERE status='in_progress';",
600 ),
601 (
607 16,
608 "ALTER TABLE export_state ADD COLUMN resume_run_id TEXT;",
609 ),
610 (
618 17,
619 "CREATE TABLE IF NOT EXISTS run_status (
620 run_id TEXT PRIMARY KEY,
621 export_name TEXT NOT NULL,
622 prefix TEXT NOT NULL,
623 status TEXT NOT NULL,
624 started_at TEXT NOT NULL,
625 finished_at TEXT
626 );
627 CREATE INDEX IF NOT EXISTS idx_run_status_prefix ON run_status(prefix);",
628 ),
629 (
632 18,
633 "ALTER TABLE export_metrics ADD COLUMN error_class TEXT;
634 ALTER TABLE export_metrics ADD COLUMN cursor_min TEXT;
635 ALTER TABLE export_metrics ADD COLUMN cursor_max TEXT;
636 ALTER TABLE export_metrics ADD COLUMN key_descriptor_json TEXT;
637 ALTER TABLE export_metrics ADD COLUMN offending_value TEXT;
638 ALTER TABLE export_metrics ADD COLUMN server_context_json TEXT;",
639 ),
640];
641
642pub(super) fn pg_sql(sql: &str) -> String {
647 let bytes = sql.as_bytes();
648 let mut out = String::with_capacity(sql.len());
649 let mut i = 0;
650 while i < bytes.len() {
651 if bytes[i] == b'?' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
652 out.push('$');
653 } else {
654 out.push(bytes[i] as char);
655 }
656 i += 1;
657 }
658 out
659}
660
661pub(super) fn connect_pg(url: &str) -> Result<postgres::Client> {
678 let tls = state_tls_mode_from_url(url).map(|mode| crate::config::TlsConfig {
679 mode,
680 ..crate::config::TlsConfig::default()
681 });
682 crate::source::postgres::connect_client(url, tls.as_ref())
683 .map_err(|e| anyhow::anyhow!("state(pg): connect to '{}': {:#}", redact_pg_url(url), e))
684}
685
686fn state_tls_mode_from_url(url: &str) -> Option<crate::config::TlsMode> {
694 use crate::config::TlsMode;
695 let (_, query) = url.split_once('?')?;
696 let mut mode = None;
697 for pair in query.split('&') {
698 let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
699 if key != "sslmode" {
700 continue;
701 }
702 mode = match value {
703 "require" => Some(TlsMode::Require),
704 "verify-ca" => Some(TlsMode::VerifyCa),
705 "verify-full" => Some(TlsMode::VerifyFull),
706 _ => None,
707 };
708 }
709 mode
710}
711
712pub(super) enum StateConn {
716 Sqlite(rusqlite::Connection),
717 Postgres(Box<std::cell::RefCell<postgres::Client>>),
722}
723
724#[derive(Clone)]
728pub enum StateRef {
729 Sqlite(std::path::PathBuf),
730 Postgres(String),
731}
732
733fn ensure_schema_version_table(conn: &Connection) {
736 let _ = conn.execute_batch(
737 "CREATE TABLE IF NOT EXISTS schema_version (
738 version INTEGER NOT NULL
739 );",
740 );
741}
742
743fn get_current_version(conn: &Connection) -> i64 {
744 conn.query_row(
745 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
746 [],
747 |row| row.get(0),
748 )
749 .unwrap_or(0)
750}
751
752fn migrate(conn: &Connection) -> Result<()> {
753 ensure_schema_version_table(conn);
754
755 let current = get_current_version(conn);
756
757 if current == 0 {
758 let has_export_state: bool = conn
759 .query_row(
760 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='export_state'",
761 [],
762 |row| row.get(0),
763 )
764 .unwrap_or(false);
765
766 if has_export_state {
767 let metrics_cols = [
768 "files_produced INTEGER DEFAULT 0",
769 "bytes_written INTEGER DEFAULT 0",
770 "retries INTEGER DEFAULT 0",
771 "validated INTEGER",
772 "schema_changed INTEGER",
773 "run_id TEXT",
774 ];
775 for col_def in &metrics_cols {
776 let sql = format!("ALTER TABLE export_metrics ADD COLUMN {}", col_def);
777 let _ = conn.execute(&sql, []);
778 }
779 }
780 }
781
782 for &(ver, sql) in MIGRATIONS {
783 if ver > current {
784 log::debug!("state: applying migration v{}", ver);
785 let atomic_sql = format!(
786 "BEGIN;\n{}\nINSERT INTO schema_version (version) VALUES ({});\nCOMMIT;",
787 sql, ver
788 );
789 conn.execute_batch(&atomic_sql)
790 .map_err(|e| anyhow::anyhow!("state: migration v{} failed: {}", ver, e))?;
791 }
792 }
793
794 let _ = conn.execute(
795 "DELETE FROM schema_version WHERE version < (SELECT MAX(version) FROM schema_version)",
796 [],
797 );
798
799 let final_version = get_current_version(conn);
800 if final_version != SCHEMA_VERSION {
801 anyhow::bail!(
802 "state: migration incomplete — expected schema v{} but reached v{}",
803 SCHEMA_VERSION,
804 final_version
805 );
806 }
807
808 Ok(())
809}
810
811fn migrate_pg(client: &mut postgres::Client) -> Result<()> {
814 client
815 .batch_execute("CREATE TABLE IF NOT EXISTS rivet_schema_version (version BIGINT NOT NULL);")
816 .map_err(|e| anyhow::anyhow!("state(pg): create version table: {:#}", e))?;
817
818 let current: i64 = client
819 .query_one(
820 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
821 &[],
822 )
823 .map_err(|e| anyhow::anyhow!("state(pg): read schema version: {:#}", e))?
824 .get(0);
825
826 for &(ver, sql) in PG_MIGRATIONS {
827 if ver > current {
828 log::debug!("state(pg): applying migration v{}", ver);
829 let batch = format!(
830 "BEGIN; {} INSERT INTO rivet_schema_version (version) VALUES ({}); COMMIT;",
831 sql, ver
832 );
833 client
834 .batch_execute(&batch)
835 .map_err(|e| anyhow::anyhow!("state(pg): migration v{} failed: {:#}", ver, e))?;
836 }
837 }
838
839 let _ = client.batch_execute(
841 "DELETE FROM rivet_schema_version \
842 WHERE version < (SELECT MAX(version) FROM rivet_schema_version);",
843 );
844
845 let final_version: i64 = client
847 .query_one(
848 "SELECT COALESCE(MAX(version), 0) FROM rivet_schema_version",
849 &[],
850 )
851 .map_err(|e| anyhow::anyhow!("state(pg): read final schema version: {:#}", e))?
852 .get(0);
853 if final_version != SCHEMA_VERSION {
854 anyhow::bail!(
855 "state(pg): migration incomplete — expected schema v{} but reached v{}",
856 SCHEMA_VERSION,
857 final_version
858 );
859 }
860
861 Ok(())
862}
863
864fn redact_pg_url(url: &str) -> String {
868 let Some(scheme_end) = url.find("://") else {
891 return url.to_string();
892 };
893 let after_scheme = &url[scheme_end + 3..];
894 let span_end = after_scheme
895 .find(char::is_whitespace)
896 .unwrap_or(after_scheme.len());
897 let span = &after_scheme[..span_end];
898 let Some(at_rel) = span.rfind('@') else {
900 return url.to_string();
901 };
902 let userinfo = &span[..at_rel];
903 let Some(colon) = userinfo.find(':') else {
905 return url.to_string();
906 };
907 let user = &userinfo[..colon];
908 let at_pos = scheme_end + 3 + at_rel;
909 format!(
910 "{}://{}:***@{}",
911 &url[..scheme_end],
912 user,
913 &url[at_pos + 1..]
914 )
915}
916
917pub(crate) const SQLITE_BUSY_TIMEOUT_MS: i64 = 10_000;
920
921pub(crate) fn open_connection(db_path: &std::path::Path) -> Result<Connection> {
922 let conn = Connection::open(db_path)?;
923 if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL;") {
924 log::warn!(
925 "state: WAL journal mode unavailable ({}); \
926 running in default mode — concurrent writes may be slower",
927 e
928 );
929 }
930 if let Err(e) = conn.execute_batch(&format!(
931 "PRAGMA busy_timeout = {};",
932 SQLITE_BUSY_TIMEOUT_MS
933 )) {
934 log::warn!(
935 "state: failed to set busy_timeout ({}); \
936 concurrent writers may surface SQLITE_BUSY immediately",
937 e
938 );
939 }
940 Ok(conn)
941}
942
943pub struct StateStore {
963 pub(super) conn: StateConn,
964 pub(super) state_ref: StateRef,
966}
967
968impl StateStore {
969 pub fn open(config_path: &str) -> Result<Self> {
973 if let Ok(url) = std::env::var("RIVET_STATE_URL")
974 && url.starts_with("postgres")
975 {
976 return Self::open_postgres(&url);
977 }
978 Self::open_sqlite(config_path)
979 }
980
981 fn open_sqlite(config_path: &str) -> Result<Self> {
982 let config_dir = std::path::Path::new(config_path)
983 .parent()
984 .unwrap_or(std::path::Path::new("."));
985 let db_path = config_dir.join(STATE_DB_NAME);
986 let conn = open_connection(&db_path)?;
987 migrate(&conn)?;
988 Ok(Self {
989 conn: StateConn::Sqlite(conn),
990 state_ref: StateRef::Sqlite(db_path),
991 })
992 }
993
994 fn open_postgres(url: &str) -> Result<Self> {
995 let is_local =
996 url.contains("localhost") || url.contains("127.0.0.1") || url.contains("::1");
997 if !is_local && state_tls_mode_from_url(url).is_none() {
998 log::warn!(
999 "state(pg): connecting to a remote host without TLS; \
1000 add sslmode=require (or verify-ca / verify-full) to RIVET_STATE_URL \
1001 to negotiate TLS for production use"
1002 );
1003 }
1004 let mut client = connect_pg(url)?;
1005 migrate_pg(&mut client)?;
1006 Ok(Self {
1007 conn: StateConn::Postgres(Box::new(std::cell::RefCell::new(client))),
1008 state_ref: StateRef::Postgres(url.to_string()),
1009 })
1010 }
1011
1012 pub fn state_db_path(config_path: &str) -> std::path::PathBuf {
1016 let config_dir = std::path::Path::new(config_path)
1017 .parent()
1018 .unwrap_or(std::path::Path::new("."));
1019 config_dir.join(STATE_DB_NAME)
1020 }
1021
1022 pub fn state_ref(&self) -> &StateRef {
1024 &self.state_ref
1025 }
1026
1027 #[allow(dead_code)]
1029 pub fn open_in_memory() -> Result<Self> {
1030 let conn = Connection::open_in_memory()?;
1031 migrate(&conn)?;
1032 Ok(Self {
1033 conn: StateConn::Sqlite(conn),
1034 state_ref: StateRef::Sqlite(std::path::PathBuf::from(":memory:")),
1035 })
1036 }
1037
1038 #[allow(dead_code)]
1041 pub fn open_at_path(db_path: &std::path::Path) -> Result<Self> {
1042 let conn = open_connection(db_path)?;
1043 migrate(&conn)?;
1044 Ok(Self {
1045 conn: StateConn::Sqlite(conn),
1046 state_ref: StateRef::Sqlite(db_path.to_path_buf()),
1047 })
1048 }
1049}
1050
1051#[cfg(test)]
1054mod tests {
1055 use super::*;
1056
1057 #[test]
1058 fn sqlite_and_postgres_migrations_define_the_same_tables_per_version() {
1059 use std::collections::{BTreeSet, HashMap};
1065 fn table_names(sql: &str) -> BTreeSet<String> {
1066 let lower = sql.to_lowercase();
1067 let mut rest = lower.as_str();
1068 let mut out = BTreeSet::new();
1069 while let Some(i) = rest.find("create table") {
1070 rest = &rest[i + "create table".len()..];
1071 let after = rest
1072 .trim_start()
1073 .strip_prefix("if not exists")
1074 .unwrap_or_else(|| rest.trim_start())
1075 .trim_start();
1076 let name: String = after
1077 .chars()
1078 .take_while(|c| c.is_alphanumeric() || *c == '_')
1079 .collect();
1080 if !name.is_empty() {
1081 out.insert(name);
1082 }
1083 }
1084 out
1085 }
1086 let mut pg: HashMap<i64, BTreeSet<String>> = HashMap::new();
1087 for &(v, sql) in PG_MIGRATIONS {
1088 pg.entry(v).or_default().extend(table_names(sql));
1089 }
1090 for &(v, sql) in MIGRATIONS {
1091 if let Some(pg_tables) = pg.get(&v) {
1092 assert_eq!(
1093 &table_names(sql),
1094 pg_tables,
1095 "migration v{v}: SQLite and Postgres define different tables"
1096 );
1097 }
1098 }
1099 }
1100
1101 #[test]
1102 fn fresh_db_reaches_latest_version() {
1103 let s = StateStore::open_in_memory().unwrap();
1104 let ver = match &s.conn {
1105 StateConn::Sqlite(c) => get_current_version(c),
1106 StateConn::Postgres(_) => unreachable!(),
1107 };
1108 assert_eq!(ver, SCHEMA_VERSION);
1109 }
1110
1111 #[test]
1112 fn migration_is_idempotent() {
1113 let s = StateStore::open_in_memory().unwrap();
1114 match &s.conn {
1115 StateConn::Sqlite(c) => {
1116 migrate(c).unwrap();
1117 migrate(c).unwrap();
1118 assert_eq!(get_current_version(c), SCHEMA_VERSION);
1119 }
1120 StateConn::Postgres(_) => unreachable!(),
1121 }
1122 }
1123
1124 #[test]
1125 fn legacy_db_gets_upgraded() {
1126 let conn = Connection::open_in_memory().unwrap();
1127 conn.execute_batch(
1128 "CREATE TABLE export_state (
1129 export_name TEXT PRIMARY KEY,
1130 last_cursor_value TEXT,
1131 last_run_at TEXT
1132 );
1133 CREATE TABLE export_metrics (
1134 id INTEGER PRIMARY KEY AUTOINCREMENT,
1135 export_name TEXT NOT NULL,
1136 run_at TEXT NOT NULL,
1137 duration_ms INTEGER NOT NULL,
1138 total_rows INTEGER NOT NULL,
1139 status TEXT NOT NULL
1140 );",
1141 )
1142 .unwrap();
1143
1144 migrate(&conn).unwrap();
1145 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1146
1147 let has_chunk_run: bool = conn
1148 .query_row(
1149 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='chunk_run'",
1150 [],
1151 |row| row.get(0),
1152 )
1153 .unwrap();
1154 assert!(has_chunk_run);
1155 }
1156
1157 #[test]
1158 fn upgrading_from_v12_adds_the_ledger_and_snapshot_tables_and_keeps_data() {
1159 let conn = Connection::open_in_memory().unwrap();
1163 ensure_schema_version_table(&conn);
1164 for &(ver, sql) in MIGRATIONS {
1165 if ver <= 12 {
1166 conn.execute_batch(&format!(
1167 "BEGIN;\n{sql}\nINSERT INTO schema_version (version) VALUES ({ver});\nCOMMIT;"
1168 ))
1169 .unwrap();
1170 }
1171 }
1172 assert_eq!(get_current_version(&conn), 12, "staged at v12");
1173 conn.execute(
1175 "INSERT INTO export_state (export_name, last_cursor_value, last_run_at) \
1176 VALUES ('orders', '42', '2026-01-01T00:00:00Z')",
1177 [],
1178 )
1179 .unwrap();
1180
1181 migrate(&conn).unwrap();
1183 assert_eq!(get_current_version(&conn), SCHEMA_VERSION);
1184
1185 for t in ["load_run", "loaded_source_run", "cdc_snapshot"] {
1187 let exists: bool = conn
1188 .query_row(
1189 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1190 [t],
1191 |r| r.get(0),
1192 )
1193 .unwrap();
1194 assert!(
1195 exists,
1196 "{t} missing after the v12→v{SCHEMA_VERSION} upgrade"
1197 );
1198 }
1199 let cursor: String = conn
1201 .query_row(
1202 "SELECT last_cursor_value FROM export_state WHERE export_name = 'orders'",
1203 [],
1204 |r| r.get(0),
1205 )
1206 .unwrap();
1207 assert_eq!(cursor, "42", "pre-upgrade data must survive");
1208 }
1209
1210 #[test]
1211 fn v8_renames_file_manifest_to_file_log() {
1212 let s = StateStore::open_in_memory().unwrap();
1213 let conn = match &s.conn {
1214 StateConn::Sqlite(c) => c,
1215 StateConn::Postgres(_) => unreachable!(),
1216 };
1217 let has_file_log: bool = conn
1218 .query_row(
1219 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_log'",
1220 [],
1221 |row| row.get(0),
1222 )
1223 .unwrap();
1224 assert!(has_file_log, "v8 must produce a `file_log` table");
1225 let has_old: bool = conn
1226 .query_row(
1227 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='file_manifest'",
1228 [],
1229 |row| row.get(0),
1230 )
1231 .unwrap();
1232 assert!(!has_old, "v8 must remove the old `file_manifest` table");
1233 let has_new_idx: bool = conn
1234 .query_row(
1235 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name='idx_file_log_export'",
1236 [],
1237 |row| row.get(0),
1238 )
1239 .unwrap();
1240 assert!(has_new_idx, "v8 must create the renamed index");
1241 }
1242
1243 #[test]
1244 fn v8_upgrades_existing_v7_db_with_data() {
1245 let conn = Connection::open_in_memory().unwrap();
1248 migrate(&conn).unwrap();
1252 conn.execute(
1254 "INSERT INTO file_log (run_id, export_name, file_name, row_count, bytes, format, created_at)
1255 VALUES ('r1', 'orders', 'f.parquet', 100, 4096, 'parquet', '2026-05-21T00:00:00Z')",
1256 [],
1257 )
1258 .unwrap();
1259 let count: i64 = conn
1260 .query_row("SELECT COUNT(*) FROM file_log", [], |r| r.get(0))
1261 .unwrap();
1262 assert_eq!(count, 1);
1263 }
1264
1265 #[test]
1266 fn run_aggregate_table_exists_after_migration() {
1267 let s = StateStore::open_in_memory().unwrap();
1268 let conn = match &s.conn {
1269 StateConn::Sqlite(c) => c,
1270 StateConn::Postgres(_) => unreachable!(),
1271 };
1272 let exists: bool = conn
1273 .query_row(
1274 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='run_aggregate'",
1275 [],
1276 |row| row.get(0),
1277 )
1278 .unwrap();
1279 assert!(exists, "v5 migration must create the run_aggregate table");
1280 }
1281
1282 #[test]
1283 fn v13_creates_the_load_ledger_tables() {
1284 let s = StateStore::open_in_memory().unwrap();
1285 let conn = match &s.conn {
1286 StateConn::Sqlite(c) => c,
1287 StateConn::Postgres(_) => unreachable!(),
1288 };
1289 for table in ["load_run", "loaded_source_run"] {
1290 let exists: bool = conn
1291 .query_row(
1292 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
1293 [table],
1294 |row| row.get(0),
1295 )
1296 .unwrap();
1297 assert!(exists, "v13 migration must create `{table}`");
1298 }
1299 }
1300
1301 #[test]
1302 fn v14_creates_the_cdc_snapshot_table() {
1303 let s = StateStore::open_in_memory().unwrap();
1304 let conn = match &s.conn {
1305 StateConn::Sqlite(c) => c,
1306 StateConn::Postgres(_) => unreachable!(),
1307 };
1308 let exists: bool = conn
1309 .query_row(
1310 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='cdc_snapshot'",
1311 [],
1312 |row| row.get(0),
1313 )
1314 .unwrap();
1315 assert!(exists, "v14 migration must create the cdc_snapshot table");
1316 }
1317
1318 #[test]
1319 fn pg_sql_converts_placeholders() {
1320 assert_eq!(
1321 pg_sql("SELECT ?1, ?2 FROM t WHERE x = ?3"),
1322 "SELECT $1, $2 FROM t WHERE x = $3"
1323 );
1324 assert_eq!(
1325 pg_sql("INSERT INTO t VALUES (?1, ?2)"),
1326 "INSERT INTO t VALUES ($1, $2)"
1327 );
1328 assert_eq!(pg_sql("no placeholders"), "no placeholders");
1329 assert_eq!(pg_sql("?10 AND ?11"), "$10 AND $11");
1331 }
1332
1333 #[test]
1334 fn redact_pg_url_removes_password() {
1335 assert_eq!(
1336 redact_pg_url("postgresql://rivet:secret123@localhost:5433/rivet_state"),
1337 "postgresql://rivet:***@localhost:5433/rivet_state"
1338 );
1339 assert_eq!(
1340 redact_pg_url("postgres://admin:p@ssw0rd@db.prod.example.com/state"),
1341 "postgres://admin:***@db.prod.example.com/state"
1342 );
1343 }
1344
1345 #[test]
1346 fn redact_pg_url_no_password_unchanged() {
1347 let url = "postgresql://rivet@localhost/state";
1349 assert_eq!(redact_pg_url(url), url);
1350 }
1351
1352 #[test]
1353 fn redact_pg_url_stray_at_in_query_does_not_leak_password() {
1354 let out = redact_pg_url("postgresql://u:secret@host:5432/db?opt=a@b");
1360 assert!(
1361 !out.contains("secret"),
1362 "password must not survive redaction with a stray '@' in the query: {out}"
1363 );
1364 assert_eq!(
1366 redact_pg_url("postgresql://u:secret@host:5432/db"),
1367 "postgresql://u:***@host:5432/db"
1368 );
1369 }
1370
1371 #[test]
1372 fn redact_pg_url_common_hostport_url_is_not_mangled() {
1373 let url = "postgresql://db.internal:5432/orders";
1378 assert_eq!(
1379 redact_pg_url(url),
1380 url,
1381 "a credential-free host:port URL is untouched"
1382 );
1383 assert_eq!(
1384 redact_pg_url("connecting to postgresql://db:5432/x then retry"),
1385 "connecting to postgresql://db:5432/x then retry"
1386 );
1387 }
1388
1389 #[test]
1390 fn redact_pg_url_at_or_colon_in_password_does_not_leak() {
1391 assert_eq!(
1397 redact_pg_url("postgresql://rivet:Kp@9x/Lm2z@db.prod:5432/orders"),
1398 "postgresql://rivet:***@db.prod:5432/orders",
1399 "'@'-before-'/' password tail must not leak"
1400 );
1401 assert_eq!(
1402 redact_pg_url("postgresql://rivet:a:b:c:secret@host:5432/state"),
1403 "postgresql://rivet:***@host:5432/state",
1404 "':'-bearing password prefix must not leak (FIRST-colon split)"
1405 );
1406 for u in [
1407 "postgresql://rivet:Kp@9x/Lm2z@db/orders",
1408 "postgresql://rivet:a:b:c:secret@host/state",
1409 "postgresql://u:p@w?rd@host/db",
1410 "postgresql://u:p@w#rd@host/db",
1411 ] {
1412 let out = redact_pg_url(u);
1413 assert!(
1414 !out.contains("Lm2z")
1415 && !out.contains("a:b:c")
1416 && !out.contains("w?rd")
1417 && !out.contains("w#rd"),
1418 "no password fragment may survive: {out}"
1419 );
1420 }
1421 }
1422
1423 #[test]
1424 fn redact_pg_url_password_with_raw_delimiters_does_not_leak() {
1425 assert_eq!(
1431 redact_pg_url("postgresql://u:pa/ss@host/db"),
1432 "postgresql://u:***@host/db",
1433 "'/' in password must be redacted, not leaked"
1434 );
1435 assert_eq!(
1436 redact_pg_url("postgresql://u:pa?ss@host/db"),
1437 "postgresql://u:***@host/db",
1438 "'?' in password must be redacted"
1439 );
1440 assert_eq!(
1441 redact_pg_url("postgresql://u:pa#ss@host/db"),
1442 "postgresql://u:***@host/db",
1443 "'#' in password must be redacted"
1444 );
1445 for u in [
1447 "postgresql://rivet:Xy/9Zq@db:5432/state",
1448 "postgres://admin:p/a?s#s@db.example.com/state",
1449 ] {
1450 assert!(
1451 !redact_pg_url(u).contains("Xy/9Zq") && !redact_pg_url(u).contains("p/a?s#s"),
1452 "no raw-delimiter password may survive: {}",
1453 redact_pg_url(u)
1454 );
1455 }
1456 }
1457
1458 use crate::config::TlsMode;
1466
1467 #[test]
1468 fn state_sslmode_enforced_values_negotiate_tls() {
1469 for (url, want) in [
1470 (
1471 "postgresql://u:p@db.prod:5432/state?sslmode=require",
1472 TlsMode::Require,
1473 ),
1474 (
1475 "postgresql://u:p@db.prod/state?sslmode=verify-ca",
1476 TlsMode::VerifyCa,
1477 ),
1478 (
1479 "postgresql://u:p@db.prod/state?sslmode=verify-full",
1480 TlsMode::VerifyFull,
1481 ),
1482 ] {
1483 let mode = state_tls_mode_from_url(url);
1484 assert_eq!(mode, Some(want), "url: {url}");
1485 assert!(
1486 mode.unwrap().is_enforced(),
1487 "{want:?} must enforce TLS (not NoTls)"
1488 );
1489 }
1490 }
1491
1492 #[test]
1493 fn state_sslmode_plaintext_values_stay_notls() {
1494 for url in [
1497 "postgresql://u:p@localhost/state",
1498 "postgresql://u:p@localhost/state?sslmode=disable",
1499 "postgresql://u:p@db/state?sslmode=prefer",
1500 "postgresql://u:p@db/state?sslmode=allow",
1501 "postgresql://u:p@db/state?sslmode=REQUIRE",
1502 "postgresql://u:p@db/state?sslmode=garbage",
1503 "postgresql://u:p@db/state?sslmode",
1504 "postgresql://u:p@db/state?sslmode=",
1505 ] {
1506 assert_eq!(state_tls_mode_from_url(url), None, "url: {url}");
1507 }
1508 }
1509
1510 #[test]
1511 fn state_sslmode_exact_key_and_last_occurrence_wins() {
1512 assert_eq!(
1514 state_tls_mode_from_url("postgresql://u:p@db/state?xsslmode=require"),
1515 None
1516 );
1517 assert_eq!(
1519 state_tls_mode_from_url(
1520 "postgresql://u:p@db/state?connect_timeout=10&sslmode=require&application_name=x"
1521 ),
1522 Some(TlsMode::Require)
1523 );
1524 assert_eq!(
1526 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=disable&sslmode=require"),
1527 Some(TlsMode::Require)
1528 );
1529 assert_eq!(
1530 state_tls_mode_from_url("postgresql://u:p@db/state?sslmode=require&sslmode=disable"),
1531 None
1532 );
1533 }
1534}