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