Skip to main content

linkrs_sync/
sqlite_outbox.rs

1use std::path::Path;
2use std::str::FromStr;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use linkrs_core::types::{CommitLsn, LeaseEpoch, TargetId};
6use linkrs_core::wal::{IndexMutation, OutboxIntent};
7use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
8use sqlx::{Row, SqliteConnection, SqlitePool};
9
10/// Never update version in dev phase
11const OUTBOX_SCHEMA_VERSION: i64 = 1;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ClaimedEvent {
15    pub event_id: i64,
16    pub commit_lsn: CommitLsn,
17    pub intent_sequence: u32,
18    pub mutation: IndexMutation,
19    pub lease_owner: String,
20    pub lease_epoch: LeaseEpoch,
21}
22
23/// One materialized outbox event for rebuild catch-up replay.
24#[derive(Debug, Clone)]
25pub struct RebuildEvent {
26    pub commit_lsn: CommitLsn,
27    pub mutation: IndexMutation,
28}
29
30#[derive(Debug, Clone)]
31pub struct SqliteOutbox {
32    pool: SqlitePool,
33    path: std::path::PathBuf,
34}
35
36/// Immutable copy of the SQLite projection used by checkpoint recovery.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct OutboxSnapshot {
39    pub path: std::path::PathBuf,
40    pub size_bytes: u64,
41    pub checksum: u32,
42    pub materialized_lsn: CommitLsn,
43}
44
45/// Point-in-time operational state of the durable outbox.
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47pub struct SyncDiagnostics {
48    pub materialized_lsn: CommitLsn,
49    pub targets: Vec<TargetSyncDiagnostics>,
50    pub indexes: Vec<IndexSyncDiagnostics>,
51    /// Total vector change items skipped due to disabled engine (delivery-plane
52    /// accounting). Sourced from `VectorSyncCoordinator::disabled_skip_count`
53    /// and merged by `SyncManager::sync_diagnostics`; 0 when vector is not
54    /// configured.
55    pub vector_disabled_skips: u64,
56}
57
58/// Delivery health for one synchronization target.
59#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct TargetSyncDiagnostics {
61    pub target: String,
62    pub applied_lsn: CommitLsn,
63    pub frontier_lag: u64,
64    pub pending: u64,
65    pub retrying: u64,
66    pub leased: u64,
67    pub dead_lettered: u64,
68    pub oldest_event_age_ms: Option<u64>,
69    pub degraded: bool,
70}
71
72/// Generation and frontier health for one secondary index target.
73#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
74pub struct IndexSyncDiagnostics {
75    pub target: String,
76    pub index_id: u64,
77    pub generation: u64,
78    pub state: String,
79    pub barrier_lsn: Option<CommitLsn>,
80    pub applied_lsn: CommitLsn,
81    pub frontier_lag: u64,
82    pub degraded: bool,
83}
84
85/// One dead-letter entry joined with its event metadata.
86#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub struct DeadLetterRow {
88    pub event_id: i64,
89    pub target: String,
90    pub index_id: u64,
91    pub generation: u64,
92    pub commit_lsn: CommitLsn,
93    pub retry_count: u64,
94    pub failed_at_ms: u64,
95    pub error: String,
96}
97
98/// One degraded range entry.
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
100pub struct DegradedRangeRow {
101    pub target: String,
102    pub index_id: u64,
103    pub generation: u64,
104    pub start_lsn: CommitLsn,
105    pub end_lsn: CommitLsn,
106    pub reason: String,
107    pub created_at_ms: u64,
108}
109
110impl SqliteOutbox {
111    pub async fn open(path: impl AsRef<Path>) -> Result<Self, String> {
112        if let Some(parent) = path.as_ref().parent() {
113            std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
114        }
115        let url = format!("sqlite://{}", path.as_ref().display());
116        let options = SqliteConnectOptions::from_str(&url)
117            .map_err(|error| error.to_string())?
118            .create_if_missing(true)
119            .foreign_keys(true)
120            .journal_mode(SqliteJournalMode::Wal)
121            .synchronous(SqliteSynchronous::Full);
122        let pool = SqlitePoolOptions::new()
123            .max_connections(8)
124            .connect_with(options)
125            .await
126            .map_err(|error| error.to_string())?;
127        let outbox = Self {
128            pool,
129            path: path.as_ref().to_path_buf(),
130        };
131        outbox.migrate().await?;
132        Ok(outbox)
133    }
134
135    pub fn pool(&self) -> &SqlitePool {
136        &self.pool
137    }
138
139    pub fn path(&self) -> &Path {
140        &self.path
141    }
142
143    /// Create an immutable SQLite backup at `destination`.
144    ///
145    /// `VACUUM INTO` runs against a consistent SQLite snapshot and includes
146    /// the WAL contents in the resulting file. The temporary file is synced
147    /// before an atomic rename so a crash cannot publish a partial backup.
148    pub async fn create_snapshot(
149        &self,
150        destination: impl AsRef<Path>,
151    ) -> Result<OutboxSnapshot, String> {
152        let destination = destination.as_ref();
153        if let Some(parent) = destination.parent() {
154            std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
155        }
156        let temporary = destination.with_extension(format!(
157            "tmp-{}",
158            SystemTime::now()
159                .duration_since(UNIX_EPOCH)
160                .map_err(|error| error.to_string())?
161                .as_nanos()
162        ));
163        if temporary.exists() {
164            std::fs::remove_file(&temporary).map_err(|error| error.to_string())?;
165        }
166        let destination_sql = temporary.to_string_lossy().replace('\'', "''");
167        sqlx::query(&format!("VACUUM INTO '{}'", destination_sql))
168            .execute(&self.pool)
169            .await
170            .map_err(|error| error.to_string())?;
171        let bytes = std::fs::read(&temporary).map_err(|error| error.to_string())?;
172        let file = std::fs::OpenOptions::new()
173            .read(true)
174            .write(true)
175            .open(&temporary)
176            .map_err(|error| error.to_string())?;
177        file.sync_all().map_err(|error| error.to_string())?;
178        if destination.exists() {
179            std::fs::remove_file(destination).map_err(|error| error.to_string())?;
180        }
181        std::fs::rename(&temporary, destination).map_err(|error| error.to_string())?;
182        let checksum_path = destination.with_extension("checksum");
183        let checksum_temporary = checksum_path.with_extension("checksum.tmp");
184        std::fs::write(&checksum_temporary, crc32fast::hash(&bytes).to_string())
185            .map_err(|error| error.to_string())?;
186        std::fs::File::open(&checksum_temporary)
187            .and_then(|file| file.sync_all())
188            .map_err(|error| error.to_string())?;
189        std::fs::rename(&checksum_temporary, &checksum_path).map_err(|error| error.to_string())?;
190        if let Some(parent) = destination.parent() {
191            std::fs::File::open(parent)
192                .and_then(|directory| directory.sync_all())
193                .map_err(|error| error.to_string())?;
194        }
195        let materialized_lsn = self.materialized_lsn().await?;
196        Ok(OutboxSnapshot {
197            path: destination.to_path_buf(),
198            size_bytes: bytes.len() as u64,
199            checksum: crc32fast::hash(&bytes),
200            materialized_lsn,
201        })
202    }
203
204    pub fn verify_snapshot(snapshot: &OutboxSnapshot) -> Result<(), String> {
205        let bytes = std::fs::read(&snapshot.path).map_err(|error| error.to_string())?;
206        if bytes.len() as u64 != snapshot.size_bytes {
207            return Err("outbox snapshot size mismatch".to_string());
208        }
209        if crc32fast::hash(&bytes) != snapshot.checksum {
210            return Err("outbox snapshot checksum mismatch".to_string());
211        }
212        Ok(())
213    }
214
215    /// Restore a verified snapshot before opening the live database.
216    pub fn restore_snapshot(
217        snapshot: &OutboxSnapshot,
218        destination: impl AsRef<Path>,
219    ) -> Result<(), String> {
220        Self::verify_snapshot(snapshot)?;
221        let destination = destination.as_ref();
222        if let Some(parent) = destination.parent() {
223            std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
224        }
225        let temporary = destination.with_extension("restore.tmp");
226        std::fs::copy(&snapshot.path, &temporary).map_err(|error| error.to_string())?;
227        let file = std::fs::OpenOptions::new()
228            .read(true)
229            .write(true)
230            .open(&temporary)
231            .map_err(|error| error.to_string())?;
232        file.sync_all().map_err(|error| error.to_string())?;
233        if destination.exists() {
234            std::fs::remove_file(destination).map_err(|error| error.to_string())?;
235        }
236        std::fs::rename(&temporary, destination).map_err(|error| error.to_string())?;
237        if let Some(parent) = destination.parent() {
238            std::fs::File::open(parent)
239                .and_then(|directory| directory.sync_all())
240                .map_err(|error| error.to_string())?;
241        }
242        Ok(())
243    }
244
245    async fn migrate(&self) -> Result<(), String> {
246        let mut connection = self
247            .pool
248            .acquire()
249            .await
250            .map_err(|error| error.to_string())?;
251        sqlx::query(
252            "CREATE TABLE IF NOT EXISTS schema_migrations (\
253                version INTEGER PRIMARY KEY, applied_at_ms INTEGER NOT NULL\
254            )",
255        )
256        .execute(&mut *connection)
257        .await
258        .map_err(|error| error.to_string())?;
259        sqlx::query(
260            "CREATE TABLE IF NOT EXISTS events (\
261                id INTEGER PRIMARY KEY AUTOINCREMENT,\
262                transaction_id INTEGER NOT NULL,\
263                intent_sequence INTEGER NOT NULL,\
264                target TEXT NOT NULL,\
265                index_id INTEGER NOT NULL,\
266                generation INTEGER NOT NULL,\
267                mutation BLOB NOT NULL,\
268                commit_lsn INTEGER NOT NULL,\
269                idempotency_key TEXT NOT NULL,\
270                ordering_key TEXT NOT NULL,\
271                status TEXT NOT NULL DEFAULT 'pending',\
272                next_attempt_at_ms INTEGER NOT NULL DEFAULT 0,\
273                lease_owner TEXT,\
274                lease_until_ms INTEGER NOT NULL DEFAULT 0,\
275                lease_epoch INTEGER NOT NULL DEFAULT 0,\
276                retry_count INTEGER NOT NULL DEFAULT 0,\
277                created_at_ms INTEGER NOT NULL DEFAULT 0,\
278                last_error TEXT,\
279                UNIQUE(target, idempotency_key),\
280                UNIQUE(transaction_id, intent_sequence)\
281            )",
282        )
283        .execute(&mut *connection)
284        .await
285        .map_err(|error| error.to_string())?;
286        let has_created_at: i64 = sqlx::query_scalar(
287            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('events') WHERE name = 'created_at_ms')",
288        )
289        .fetch_one(&mut *connection)
290        .await
291        .map_err(|error| error.to_string())?;
292        if has_created_at == 0 {
293            sqlx::query("ALTER TABLE events ADD COLUMN created_at_ms INTEGER NOT NULL DEFAULT 0")
294                .execute(&mut *connection)
295                .await
296                .map_err(|error| error.to_string())?;
297        }
298        sqlx::query(
299            "CREATE INDEX IF NOT EXISTS events_claim_idx \
300             ON events(target, status, next_attempt_at_ms, commit_lsn, intent_sequence)",
301        )
302        .execute(&mut *connection)
303        .await
304        .map_err(|error| error.to_string())?;
305        sqlx::query(
306            "CREATE INDEX IF NOT EXISTS events_ordering_idx \
307             ON events(target, ordering_key, commit_lsn, intent_sequence)",
308        )
309        .execute(&mut *connection)
310        .await
311        .map_err(|error| error.to_string())?;
312        sqlx::query(
313            "CREATE INDEX IF NOT EXISTS events_rebuild_idx \
314             ON events(target, index_id, commit_lsn, intent_sequence)",
315        )
316        .execute(&mut *connection)
317        .await
318        .map_err(|error| error.to_string())?;
319        sqlx::query(
320            "CREATE TABLE IF NOT EXISTS commit_targets (\
321                commit_lsn INTEGER NOT NULL,\
322                target TEXT NOT NULL,\
323                event_count INTEGER NOT NULL,\
324                applied_count INTEGER NOT NULL DEFAULT 0,\
325                completed INTEGER NOT NULL DEFAULT 0,\
326                degraded INTEGER NOT NULL DEFAULT 0,\
327                PRIMARY KEY(commit_lsn, target)\
328            )",
329        )
330        .execute(&mut *connection)
331        .await
332        .map_err(|error| error.to_string())?;
333        sqlx::query(
334            "CREATE TABLE IF NOT EXISTS idempotency (\
335                target TEXT NOT NULL,\
336                idempotency_key TEXT NOT NULL,\
337                commit_lsn INTEGER NOT NULL,\
338                PRIMARY KEY(target, idempotency_key)\
339            )",
340        )
341        .execute(&mut *connection)
342        .await
343        .map_err(|error| error.to_string())?;
344        sqlx::query(
345            "CREATE TABLE IF NOT EXISTS projection_state (\
346                singleton INTEGER PRIMARY KEY CHECK(singleton = 1),\
347                materialized_lsn INTEGER NOT NULL,\
348                source_checkpoint INTEGER NOT NULL DEFAULT 0\
349            )",
350        )
351        .execute(&mut *connection)
352        .await
353        .map_err(|error| error.to_string())?;
354        sqlx::query(
355            "INSERT OR IGNORE INTO projection_state(singleton, materialized_lsn) VALUES(1, 0)",
356        )
357        .execute(&mut *connection)
358        .await
359        .map_err(|error| error.to_string())?;
360        sqlx::query(
361            "CREATE TABLE IF NOT EXISTS target_state (\
362                target TEXT PRIMARY KEY,\
363                applied_lsn INTEGER NOT NULL DEFAULT 0,\
364                degraded INTEGER NOT NULL DEFAULT 0,\
365                last_error TEXT\
366            )",
367        )
368        .execute(&mut *connection)
369        .await
370        .map_err(|error| error.to_string())?;
371        sqlx::query(
372            "CREATE TABLE IF NOT EXISTS generation_state (\
373             target TEXT NOT NULL,\
374             index_id INTEGER NOT NULL,\
375             generation INTEGER NOT NULL,\
376             state TEXT NOT NULL,\
377             barrier_lsn INTEGER,\
378             PRIMARY KEY(target, index_id, generation)\
379             )",
380        )
381        .execute(&mut *connection)
382        .await
383        .map_err(|error| error.to_string())?;
384        sqlx::query(
385            "CREATE TABLE IF NOT EXISTS index_frontier (\
386             target TEXT NOT NULL,\
387             index_id INTEGER NOT NULL,\
388             generation INTEGER NOT NULL,\
389             applied_lsn INTEGER NOT NULL DEFAULT 0,\
390             degraded INTEGER NOT NULL DEFAULT 0,\
391             PRIMARY KEY(target, index_id, generation)\
392             )",
393        )
394        .execute(&mut *connection)
395        .await
396        .map_err(|error| error.to_string())?;
397        sqlx::query(
398            "CREATE TABLE IF NOT EXISTS dead_letters (\
399             event_id INTEGER PRIMARY KEY,\
400             failed_at_ms INTEGER NOT NULL,\
401             error TEXT NOT NULL,\
402             FOREIGN KEY(event_id) REFERENCES events(id)\
403             )",
404        )
405        .execute(&mut *connection)
406        .await
407        .map_err(|error| error.to_string())?;
408        sqlx::query(
409            "CREATE TABLE IF NOT EXISTS degraded_ranges (\
410              target TEXT NOT NULL,\
411              index_id INTEGER NOT NULL,\
412              generation INTEGER NOT NULL,\
413              start_lsn INTEGER NOT NULL,\
414              end_lsn INTEGER NOT NULL,\
415              reason TEXT NOT NULL,\
416              created_at_ms INTEGER NOT NULL,\
417              PRIMARY KEY(target, index_id, generation, start_lsn, end_lsn)\
418              )",
419        )
420        .execute(&mut *connection)
421        .await
422        .map_err(|error| error.to_string())?;
423        // Retention: add retention_lsn to projection_state, archive table, index.
424        let has_retention_lsn: i64 = sqlx::query_scalar(
425            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('projection_state') WHERE name = 'retention_lsn')",
426        )
427        .fetch_one(&mut *connection)
428        .await
429        .map_err(|error| error.to_string())?;
430        if has_retention_lsn == 0 {
431            sqlx::query(
432                "ALTER TABLE projection_state ADD COLUMN retention_lsn INTEGER NOT NULL DEFAULT 0",
433            )
434            .execute(&mut *connection)
435            .await
436            .map_err(|error| error.to_string())?;
437        }
438        sqlx::query(
439            "CREATE TABLE IF NOT EXISTS dead_letters_archive (\
440              event_id INTEGER PRIMARY KEY,\
441              target TEXT NOT NULL,\
442              index_id INTEGER NOT NULL,\
443              generation INTEGER NOT NULL,\
444              commit_lsn INTEGER NOT NULL,\
445              failed_at_ms INTEGER NOT NULL,\
446              error TEXT NOT NULL,\
447              archived_at_ms INTEGER NOT NULL\
448              )",
449        )
450        .execute(&mut *connection)
451        .await
452        .map_err(|error| error.to_string())?;
453        sqlx::query(
454            "CREATE INDEX IF NOT EXISTS events_retention_idx ON events(status, commit_lsn)",
455        )
456        .execute(&mut *connection)
457        .await
458        .map_err(|error| error.to_string())?;
459        sqlx::query("INSERT OR IGNORE INTO schema_migrations(version, applied_at_ms) VALUES(?, 0)")
460            .bind(OUTBOX_SCHEMA_VERSION)
461            .execute(&mut *connection)
462            .await
463            .map_err(|error| error.to_string())?;
464        Ok(())
465    }
466
467    pub async fn register_target(&self, target: &TargetId) -> Result<(), String> {
468        sqlx::query("INSERT OR IGNORE INTO target_state(target) VALUES(?)")
469            .bind(target.as_str())
470            .execute(&self.pool)
471            .await
472            .map_err(|error| error.to_string())?;
473        Ok(())
474    }
475
476    pub async fn create_index_generation(
477        &self,
478        target: &TargetId,
479        index_id: u64,
480        generation: u64,
481        snapshot_lsn: CommitLsn,
482    ) -> Result<(), String> {
483        sqlx::query(
484            "INSERT INTO generation_state(target, index_id, generation, state, barrier_lsn) \
485             VALUES(?, ?, ?, 'creating', ?) \
486             ON CONFLICT(target, index_id, generation) DO UPDATE SET state = 'creating'",
487        )
488        .bind(target.as_str())
489        .bind(to_sql_i64(index_id, "index ID")?)
490        .bind(to_sql_i64(generation, "index generation")?)
491        .bind(to_sql_i64(snapshot_lsn.get(), "snapshot LSN")?)
492        .execute(&self.pool)
493        .await
494        .map_err(|error| error.to_string())?;
495        Ok(())
496    }
497
498    pub async fn transition_generation_to_backfilling(
499        &self,
500        target: &TargetId,
501        index_id: u64,
502        generation: u64,
503    ) -> Result<(), String> {
504        sqlx::query(
505            "UPDATE generation_state SET state = 'backfilling' \
506             WHERE target = ? AND index_id = ? AND generation = ? AND state = 'creating'",
507        )
508        .bind(target.as_str())
509        .bind(to_sql_i64(index_id, "index ID")?)
510        .bind(to_sql_i64(generation, "index generation")?)
511        .execute(&self.pool)
512        .await
513        .map_err(|error| error.to_string())?;
514        Ok(())
515    }
516
517    pub async fn transition_generation_to_catching_up(
518        &self,
519        target: &TargetId,
520        index_id: u64,
521        generation: u64,
522        barrier_lsn: CommitLsn,
523    ) -> Result<(), String> {
524        sqlx::query(
525            "UPDATE generation_state SET state = 'catching_up', barrier_lsn = ? \
526             WHERE target = ? AND index_id = ? AND generation = ? AND state IN ('creating', 'backfilling')",
527        )
528        .bind(to_sql_i64(barrier_lsn.get(), "barrier LSN")?)
529        .bind(target.as_str())
530        .bind(to_sql_i64(index_id, "index ID")?)
531        .bind(to_sql_i64(generation, "index generation")?)
532        .execute(&self.pool)
533        .await
534        .map_err(|error| error.to_string())?;
535        Ok(())
536    }
537
538    pub async fn transition_generation_to_publishing(
539        &self,
540        target: &TargetId,
541        index_id: u64,
542        generation: u64,
543        barrier_lsn: CommitLsn,
544    ) -> Result<(), String> {
545        let mut connection = self
546            .pool
547            .acquire()
548            .await
549            .map_err(|error| error.to_string())?;
550        begin_immediate(&mut connection).await?;
551        let result = async {
552            let current_lsn: i64 = sqlx::query_scalar(
553                "SELECT barrier_lsn FROM generation_state \
554                 WHERE target = ? AND index_id = ? AND generation = ? AND state = 'catching_up'",
555            )
556            .bind(target.as_str())
557            .bind(to_sql_i64(index_id, "index ID")?)
558            .bind(to_sql_i64(generation, "index generation")?)
559            .fetch_optional(&mut *connection)
560            .await
561            .map_err(|error| error.to_string())?
562            .ok_or_else(|| "Generation not in catching_up state".to_string())?;
563
564            if current_lsn < to_sql_i64(barrier_lsn.get(), "barrier LSN")? {
565                return Err(format!(
566                    "Generation has not caught up to barrier LSN {} < {}",
567                    current_lsn,
568                    barrier_lsn.get()
569                ));
570            }
571
572            sqlx::query(
573                "UPDATE generation_state SET state = 'publishing', barrier_lsn = ? \
574                 WHERE target = ? AND index_id = ? AND generation = ? AND state = 'catching_up'",
575            )
576            .bind(to_sql_i64(barrier_lsn.get(), "barrier LSN")?)
577            .bind(target.as_str())
578            .bind(to_sql_i64(index_id, "index ID")?)
579            .bind(to_sql_i64(generation, "index generation")?)
580            .execute(&mut *connection)
581            .await
582            .map_err(|error| error.to_string())?;
583            Ok(())
584        }
585        .await;
586        finish_transaction(&mut connection, result).await
587    }
588
589    pub async fn activate_generation(
590        &self,
591        target: &TargetId,
592        index_id: u64,
593        generation: u64,
594        barrier_lsn: CommitLsn,
595    ) -> Result<(), String> {
596        let mut connection = self
597            .pool
598            .acquire()
599            .await
600            .map_err(|error| error.to_string())?;
601        begin_immediate(&mut connection).await?;
602        let result = async {
603            let row = sqlx::query(
604                "SELECT barrier_lsn FROM generation_state \
605                 WHERE target = ? AND index_id = ? AND generation = ? \
606                 AND state IN ('catching_up', 'publishing')",
607            )
608            .bind(target.as_str())
609            .bind(to_sql_i64(index_id, "index ID")?)
610            .bind(to_sql_i64(generation, "index generation")?)
611            .fetch_optional(&mut *connection)
612            .await
613            .map_err(|error| error.to_string())?
614            .ok_or_else(|| "Generation not in catching_up or publishing state".to_string())?;
615
616            let current_lsn: i64 = row.get("barrier_lsn");
617            if current_lsn < to_sql_i64(barrier_lsn.get(), "barrier LSN")? {
618                return Err(format!(
619                    "Generation has not caught up to barrier LSN {} < {}",
620                    current_lsn,
621                    barrier_lsn.get()
622                ));
623            }
624
625            sqlx::query(
626                "UPDATE generation_state SET state = 'active', barrier_lsn = NULL \
627                 WHERE target = ? AND index_id = ? AND generation = ?",
628            )
629            .bind(target.as_str())
630            .bind(to_sql_i64(index_id, "index ID")?)
631            .bind(to_sql_i64(generation, "index generation")?)
632            .execute(&mut *connection)
633            .await
634            .map_err(|error| error.to_string())?;
635            Ok(())
636        }
637        .await;
638        finish_transaction(&mut connection, result).await
639    }
640
641    pub async fn drain_generation(
642        &self,
643        target: &TargetId,
644        index_id: u64,
645        generation: u64,
646    ) -> Result<(), String> {
647        sqlx::query(
648            "UPDATE generation_state SET state = 'draining' \
649             WHERE target = ? AND index_id = ? AND generation = ? AND state = 'active'",
650        )
651        .bind(target.as_str())
652        .bind(to_sql_i64(index_id, "index ID")?)
653        .bind(to_sql_i64(generation, "index generation")?)
654        .execute(&self.pool)
655        .await
656        .map_err(|error| error.to_string())?;
657        Ok(())
658    }
659
660    pub async fn drop_generation(
661        &self,
662        target: &TargetId,
663        index_id: u64,
664        generation: u64,
665    ) -> Result<(), String> {
666        sqlx::query(
667            "UPDATE generation_state SET state = 'dropped' \
668             WHERE target = ? AND index_id = ? AND generation = ? \
669             AND state IN ('active', 'draining', 'publishing', 'failed')",
670        )
671        .bind(target.as_str())
672        .bind(to_sql_i64(index_id, "index ID")?)
673        .bind(to_sql_i64(generation, "index generation")?)
674        .execute(&self.pool)
675        .await
676        .map_err(|error| error.to_string())?;
677        Ok(())
678    }
679
680    pub async fn fail_generation(
681        &self,
682        target: &TargetId,
683        index_id: u64,
684        generation: u64,
685    ) -> Result<(), String> {
686        sqlx::query(
687            "UPDATE generation_state SET state = 'failed' \
688             WHERE target = ? AND index_id = ? AND generation = ? AND state NOT IN ('failed', 'dropped')",
689        )
690        .bind(target.as_str())
691        .bind(to_sql_i64(index_id, "index ID")?)
692        .bind(to_sql_i64(generation, "index generation")?)
693        .execute(&self.pool)
694        .await
695        .map_err(|error| error.to_string())?;
696        Ok(())
697    }
698
699    pub async fn get_generation_state(
700        &self,
701        target: &TargetId,
702        index_id: u64,
703        generation: u64,
704    ) -> Result<Option<(String, Option<CommitLsn>)>, String> {
705        let row = sqlx::query(
706            "SELECT state, barrier_lsn FROM generation_state \
707                               WHERE target = ? AND index_id = ? AND generation = ?",
708        )
709        .bind(target.as_str())
710        .bind(to_sql_i64(index_id, "index ID")?)
711        .bind(to_sql_i64(generation, "index generation")?)
712        .fetch_optional(&self.pool)
713        .await
714        .map_err(|error| error.to_string())?;
715
716        row.map(|row| {
717            let state: String = row.get("state");
718            let barrier_lsn: Option<i64> = row.get("barrier_lsn");
719            let barrier = barrier_lsn
720                .map(|lsn| CommitLsn::new(from_sql_i64(lsn, "barrier LSN").unwrap_or(0)));
721            Ok((state, barrier))
722        })
723        .transpose()
724    }
725
726    pub async fn get_active_generation(
727        &self,
728        target: &TargetId,
729        index_id: u64,
730    ) -> Result<Option<u64>, String> {
731        let row = sqlx::query(
732            "SELECT generation FROM generation_state \
733                               WHERE target = ? AND index_id = ? AND state = 'active' \
734                               ORDER BY generation DESC LIMIT 1",
735        )
736        .bind(target.as_str())
737        .bind(to_sql_i64(index_id, "index ID")?)
738        .fetch_optional(&self.pool)
739        .await
740        .map_err(|error| error.to_string())?;
741
742        row.map(|row| {
743            let generation: i64 = row.get("generation");
744            from_sql_i64(generation, "index generation")
745        })
746        .transpose()
747    }
748
749    /// List all known generations (with states) for one index. Used by the
750    /// rebuild driver to detect and fail generations left behind by crashes.
751    pub async fn list_index_generations(
752        &self,
753        target: &TargetId,
754        index_id: u64,
755    ) -> Result<Vec<(u64, String)>, String> {
756        let rows = sqlx::query(
757            "SELECT generation, state FROM generation_state \
758             WHERE target = ? AND index_id = ? ORDER BY generation",
759        )
760        .bind(target.as_str())
761        .bind(to_sql_i64(index_id, "index ID")?)
762        .fetch_all(&self.pool)
763        .await
764        .map_err(|error| error.to_string())?;
765        rows.into_iter()
766            .map(|row| {
767                let generation: i64 = row.get("generation");
768                let state: String = row.get("state");
769                Ok((from_sql_i64(generation, "index generation")?, state))
770            })
771            .collect()
772    }
773
774    /// Fetch materialized events for catch-up replay in a commit-LSN range.
775    /// Status-agnostic on purpose: retried, dead-lettered, or skipped events
776    /// in the range are replayed idempotently so the rebuilt index converges
777    /// even when the live delivery plane degraded mid-rebuild.
778    pub async fn fetch_rebuild_events(
779        &self,
780        target: &TargetId,
781        index_id: u64,
782        after_lsn: CommitLsn,
783        upto_lsn: CommitLsn,
784        limit: u64,
785    ) -> Result<Vec<RebuildEvent>, String> {
786        let rows = sqlx::query(
787            "SELECT commit_lsn, intent_sequence, mutation FROM events \
788             WHERE target = ? AND index_id = ? AND commit_lsn > ? AND commit_lsn <= ? \
789             ORDER BY commit_lsn, intent_sequence LIMIT ?",
790        )
791        .bind(target.as_str())
792        .bind(to_sql_i64(index_id, "index ID")?)
793        .bind(to_sql_i64(after_lsn.get(), "after LSN")?)
794        .bind(to_sql_i64(upto_lsn.get(), "upto LSN")?)
795        .bind(to_sql_i64(limit, "fetch limit")?)
796        .fetch_all(&self.pool)
797        .await
798        .map_err(|error| error.to_string())?;
799        rows.into_iter()
800            .map(|row| {
801                let commit_lsn: i64 = row.get("commit_lsn");
802                let mutation_bytes: Vec<u8> = row.get("mutation");
803                let mutation: IndexMutation =
804                    postcard::from_bytes(&mutation_bytes).map_err(|error| error.to_string())?;
805                Ok(RebuildEvent {
806                    commit_lsn: CommitLsn::new(from_sql_i64(commit_lsn, "commit LSN")?),
807                    mutation,
808                })
809            })
810            .collect()
811    }
812
813    pub async fn materialize_commit(
814        &self,
815        commit_lsn: CommitLsn,
816        intents: &[OutboxIntent],
817        targets: &[TargetId],
818    ) -> Result<(), String> {
819        validate_intents(intents)?;
820        let lsn = to_sql_i64(commit_lsn.get(), "commit LSN")?;
821        let mut connection = self
822            .pool
823            .acquire()
824            .await
825            .map_err(|error| error.to_string())?;
826        begin_immediate(&mut connection).await?;
827        let result = async {
828            let current: i64 = sqlx::query_scalar(
829                "SELECT materialized_lsn FROM projection_state WHERE singleton = 1",
830            )
831            .fetch_one(&mut *connection)
832            .await
833            .map_err(|error| error.to_string())?;
834            if lsn < current {
835                return Ok(());
836            }
837            let materialized_at_ms = SystemTime::now()
838                .duration_since(UNIX_EPOCH)
839                .map_err(|error| error.to_string())?
840                .as_millis() as u64;
841
842            // A normal data mutation can arrive before an explicit index
843            // rebuild has registered a generation. The first generation for
844            // a live target is registered as active atomically with the
845            // projection row. Existing lifecycle state is preserved, so a
846            // rebuilding generation remains fenced until its barrier is
847            // published.
848            for intent in intents {
849                sqlx::query(
850                    "INSERT OR IGNORE INTO generation_state(\
851                        target, index_id, generation, state, barrier_lsn\
852                     ) VALUES(?, ?, ?, 'active', NULL)",
853                )
854                .bind(intent.mutation.target.as_str())
855                .bind(to_sql_i64(intent.mutation.index_id, "index ID")?)
856                .bind(to_sql_i64(
857                    intent.mutation.index_generation.get(),
858                    "index generation",
859                )?)
860                .execute(&mut *connection)
861                .await
862                .map_err(|error| error.to_string())?;
863            }
864
865            for target in targets {
866                sqlx::query("INSERT OR IGNORE INTO target_state(target) VALUES(?)")
867                    .bind(target.as_str())
868                    .execute(&mut *connection)
869                    .await
870                    .map_err(|error| error.to_string())?;
871                let event_count = intents
872                    .iter()
873                    .filter(|intent| intent.mutation.target == *target)
874                    .count();
875                sqlx::query(
876                    "INSERT OR IGNORE INTO commit_targets(\
877                        commit_lsn, target, event_count, completed\
878                     ) VALUES(?, ?, ?, ?)",
879                )
880                .bind(lsn)
881                .bind(target.as_str())
882                .bind(to_sql_i64(event_count as u64, "event count")?)
883                .bind(i64::from(event_count == 0))
884                .execute(&mut *connection)
885                .await
886                .map_err(|error| error.to_string())?;
887            }
888
889            for intent in intents {
890                let mutation =
891                    postcard::to_allocvec(&intent.mutation).map_err(|error| error.to_string())?;
892                sqlx::query(
893                    "INSERT OR IGNORE INTO events(\
894                        transaction_id, intent_sequence, target, index_id, generation, mutation,\
895                        commit_lsn, idempotency_key, ordering_key, created_at_ms\
896                     ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
897                )
898                .bind(to_sql_i64(
899                    intent.transaction_id.as_u64(),
900                    "transaction ID",
901                )?)
902                .bind(i64::from(intent.intent_sequence))
903                .bind(intent.mutation.target.as_str())
904                .bind(to_sql_i64(intent.mutation.index_id, "index ID")?)
905                .bind(to_sql_i64(
906                    intent.mutation.index_generation.get(),
907                    "index generation",
908                )?)
909                .bind(mutation)
910                .bind(lsn)
911                .bind(intent.mutation.idempotency_key.as_str())
912                .bind(intent.mutation.ordering_key.as_str())
913                .bind(to_sql_i64(materialized_at_ms, "event creation timestamp")?)
914                .execute(&mut *connection)
915                .await
916                .map_err(|error| error.to_string())?;
917                sqlx::query(
918                    "INSERT OR IGNORE INTO idempotency(target, idempotency_key, commit_lsn) \
919                     VALUES(?, ?, ?)",
920                )
921                .bind(intent.mutation.target.as_str())
922                .bind(intent.mutation.idempotency_key.as_str())
923                .bind(lsn)
924                .execute(&mut *connection)
925                .await
926                .map_err(|error| error.to_string())?;
927            }
928            sqlx::query(
929                "UPDATE projection_state SET materialized_lsn = MAX(materialized_lsn, ?) \
930                 WHERE singleton = 1",
931            )
932            .bind(lsn)
933            .execute(&mut *connection)
934            .await
935            .map_err(|error| error.to_string())?;
936            Ok(())
937        }
938        .await;
939        finish_transaction(&mut connection, result).await
940    }
941
942    pub async fn claim_next(
943        &self,
944        target: &TargetId,
945        owner: &str,
946        now_ms: u64,
947        lease_duration_ms: u64,
948    ) -> Result<Option<ClaimedEvent>, String> {
949        if owner.is_empty() {
950            return Err("Lease owner cannot be empty".to_string());
951        }
952        let now = to_sql_i64(now_ms, "current time")?;
953        let lease_until = to_sql_i64(now_ms.saturating_add(lease_duration_ms), "lease deadline")?;
954        let mut connection = self
955            .pool
956            .acquire()
957            .await
958            .map_err(|error| error.to_string())?;
959        begin_immediate(&mut connection).await?;
960        let result = async {
961            let row = sqlx::query(
962                "SELECT e.id, e.commit_lsn, e.intent_sequence, e.mutation, e.lease_epoch \
963                 FROM events e \
964                 WHERE e.target = ? \
965                   AND e.status IN ('pending', 'retry', 'leased') \
966                   AND e.next_attempt_at_ms <= ? \
967                   AND (e.lease_owner IS NULL OR e.lease_until_ms <= ?) \
968                   AND EXISTS (\
969                       SELECT 1 FROM generation_state g \
970                       WHERE g.target = e.target AND g.index_id = e.index_id \
971                         AND g.generation = e.generation AND g.state = 'active'\
972                   ) \
973                   AND NOT EXISTS (\
974                       SELECT 1 FROM events earlier \
975                       WHERE earlier.target = e.target \
976                         AND earlier.ordering_key = e.ordering_key \
977                         AND (earlier.commit_lsn < e.commit_lsn OR (\
978                             earlier.commit_lsn = e.commit_lsn \
979                             AND earlier.intent_sequence < e.intent_sequence\
980                         )) \
981                         AND earlier.status NOT IN ('applied', 'skipped')\
982                   ) \
983                 ORDER BY e.commit_lsn, e.intent_sequence LIMIT 1",
984            )
985            .bind(target.as_str())
986            .bind(now)
987            .bind(now)
988            .fetch_optional(&mut *connection)
989            .await
990            .map_err(|error| error.to_string())?;
991            let Some(row) = row else {
992                return Ok(None);
993            };
994            let event_id: i64 = row.get("id");
995            let previous_epoch: i64 = row.get("lease_epoch");
996            let lease_epoch = previous_epoch
997                .checked_add(1)
998                .ok_or_else(|| "Lease epoch overflow".to_string())?;
999            let updated = sqlx::query(
1000                "UPDATE events SET status = 'leased', lease_owner = ?, lease_until_ms = ?, \
1001                    lease_epoch = ? WHERE id = ? AND lease_epoch = ?",
1002            )
1003            .bind(owner)
1004            .bind(lease_until)
1005            .bind(lease_epoch)
1006            .bind(event_id)
1007            .bind(previous_epoch)
1008            .execute(&mut *connection)
1009            .await
1010            .map_err(|error| error.to_string())?;
1011            if updated.rows_affected() != 1 {
1012                return Err(format!("Failed to fence claimed event {}", event_id));
1013            }
1014            let mutation_bytes: Vec<u8> = row.get("mutation");
1015            let mutation: IndexMutation =
1016                postcard::from_bytes(&mutation_bytes).map_err(|error| error.to_string())?;
1017            mutation.validate()?;
1018            Ok(Some(ClaimedEvent {
1019                event_id,
1020                commit_lsn: CommitLsn::new(from_sql_i64(row.get("commit_lsn"), "commit LSN")?),
1021                intent_sequence: u32::try_from(row.get::<i64, _>("intent_sequence"))
1022                    .map_err(|_| "Intent sequence is out of range".to_string())?,
1023                mutation,
1024                lease_owner: owner.to_string(),
1025                lease_epoch: LeaseEpoch::new(from_sql_i64(lease_epoch, "lease epoch")?),
1026            }))
1027        }
1028        .await;
1029        finish_transaction(&mut connection, result).await
1030    }
1031
1032    pub async fn acknowledge(&self, event: &ClaimedEvent) -> Result<bool, String> {
1033        let mut connection = self
1034            .pool
1035            .acquire()
1036            .await
1037            .map_err(|error| error.to_string())?;
1038        begin_immediate(&mut connection).await?;
1039        let result = async {
1040            let updated = sqlx::query(
1041                "UPDATE events SET status = 'applied', lease_owner = NULL, lease_until_ms = 0 \
1042                 WHERE id = ? AND status = 'leased' AND lease_owner = ? AND lease_epoch = ?",
1043            )
1044            .bind(event.event_id)
1045            .bind(&event.lease_owner)
1046            .bind(to_sql_i64(event.lease_epoch.get(), "lease epoch")?)
1047            .execute(&mut *connection)
1048            .await
1049            .map_err(|error| error.to_string())?;
1050            if updated.rows_affected() == 0 {
1051                return Ok(false);
1052            }
1053            let lsn = to_sql_i64(event.commit_lsn.get(), "commit LSN")?;
1054            sqlx::query(
1055                "UPDATE commit_targets SET applied_count = applied_count + 1, \
1056                    completed = (applied_count + 1 >= event_count) \
1057                 WHERE commit_lsn = ? AND target = ?",
1058            )
1059            .bind(lsn)
1060            .bind(event.mutation.target.as_str())
1061            .execute(&mut *connection)
1062            .await
1063            .map_err(|error| error.to_string())?;
1064            advance_frontier(&mut connection, &event.mutation.target).await?;
1065            advance_index_frontier(
1066                &mut connection,
1067                &event.mutation.target,
1068                event.mutation.index_id,
1069                event.mutation.index_generation.get(),
1070            )
1071            .await?;
1072            Ok(true)
1073        }
1074        .await;
1075        finish_transaction(&mut connection, result).await
1076    }
1077
1078    pub async fn retry(
1079        &self,
1080        event: &ClaimedEvent,
1081        next_attempt_at_ms: u64,
1082        error: &str,
1083    ) -> Result<bool, String> {
1084        let updated = sqlx::query(
1085            "UPDATE events SET status = 'retry', next_attempt_at_ms = ?, retry_count = retry_count + 1, \
1086                last_error = ?, lease_owner = NULL, lease_until_ms = 0 \
1087             WHERE id = ? AND status = 'leased' AND lease_owner = ? AND lease_epoch = ?",
1088        )
1089        .bind(to_sql_i64(next_attempt_at_ms, "next attempt time")?)
1090        .bind(error)
1091        .bind(event.event_id)
1092        .bind(&event.lease_owner)
1093        .bind(to_sql_i64(event.lease_epoch.get(), "lease epoch")?)
1094        .execute(&self.pool)
1095        .await
1096        .map_err(|error| error.to_string())?;
1097        Ok(updated.rows_affected() == 1)
1098    }
1099
1100    pub async fn dead_letter(
1101        &self,
1102        event: &ClaimedEvent,
1103        failed_at_ms: u64,
1104        error: &str,
1105    ) -> Result<bool, String> {
1106        let mut connection = self
1107            .pool
1108            .acquire()
1109            .await
1110            .map_err(|error| error.to_string())?;
1111        begin_immediate(&mut connection).await?;
1112        let result = async {
1113            let updated = sqlx::query(
1114                "UPDATE events SET status = 'dead_letter', last_error = ?, lease_owner = NULL, \
1115                    lease_until_ms = 0 \
1116                 WHERE id = ? AND status = 'leased' AND lease_owner = ? AND lease_epoch = ?",
1117            )
1118            .bind(error)
1119            .bind(event.event_id)
1120            .bind(&event.lease_owner)
1121            .bind(to_sql_i64(event.lease_epoch.get(), "lease epoch")?)
1122            .execute(&mut *connection)
1123            .await
1124            .map_err(|error| error.to_string())?;
1125            if updated.rows_affected() == 0 {
1126                return Ok(false);
1127            }
1128            sqlx::query(
1129                "INSERT INTO dead_letters(event_id, failed_at_ms, error) VALUES(?, ?, ?) \
1130                 ON CONFLICT(event_id) DO UPDATE SET failed_at_ms = excluded.failed_at_ms, \
1131                    error = excluded.error",
1132            )
1133            .bind(event.event_id)
1134            .bind(to_sql_i64(failed_at_ms, "failure time")?)
1135            .bind(error)
1136            .execute(&mut *connection)
1137            .await
1138            .map_err(|error| error.to_string())?;
1139            Ok(true)
1140        }
1141        .await;
1142        finish_transaction(&mut connection, result).await
1143    }
1144
1145    pub async fn requeue_dead_letter(&self, event_id: i64) -> Result<bool, String> {
1146        let mut connection = self
1147            .pool
1148            .acquire()
1149            .await
1150            .map_err(|error| error.to_string())?;
1151        begin_immediate(&mut connection).await?;
1152        let result = async {
1153            let updated = sqlx::query(
1154                "UPDATE events SET status = 'pending', next_attempt_at_ms = 0, \
1155                 lease_owner = NULL, lease_until_ms = 0, last_error = NULL \
1156                 WHERE id = ? AND status = 'dead_letter'",
1157            )
1158            .bind(event_id)
1159            .execute(&mut *connection)
1160            .await
1161            .map_err(|error| error.to_string())?;
1162            if updated.rows_affected() == 0 {
1163                return Ok(false);
1164            }
1165            sqlx::query("DELETE FROM dead_letters WHERE event_id = ?")
1166                .bind(event_id)
1167                .execute(&mut *connection)
1168                .await
1169                .map_err(|error| error.to_string())?;
1170            Ok(true)
1171        }
1172        .await;
1173        finish_transaction(&mut connection, result).await
1174    }
1175
1176    /// Batch requeue dead letters filtered by target/index/generation.
1177    /// Returns number of events requeued.
1178    pub async fn requeue_dead_letters_batch(
1179        &self,
1180        target: Option<&TargetId>,
1181        index_id: Option<u64>,
1182        generation: Option<u64>,
1183        limit: usize,
1184    ) -> Result<usize, String> {
1185        let limit = limit.clamp(1, 1000) as i64;
1186        // Fetch candidate event_ids first, then requeue one by one to reuse transaction fencing.
1187        let mut query = String::from(
1188            "SELECT e.id FROM events e JOIN dead_letters d ON d.event_id = e.id WHERE e.status = 'dead_letter'",
1189        );
1190        if target.is_some() {
1191            query.push_str(" AND e.target = ?");
1192        }
1193        if index_id.is_some() {
1194            query.push_str(" AND e.index_id = ?");
1195        }
1196        if generation.is_some() {
1197            query.push_str(" AND e.generation = ?");
1198        }
1199        query.push_str(" ORDER BY e.commit_lsn, e.intent_sequence LIMIT ?");
1200        let mut q = sqlx::query_scalar::<_, i64>(&query);
1201        if let Some(t) = target {
1202            q = q.bind(t.as_str());
1203        }
1204        if let Some(id) = index_id {
1205            q = q.bind(to_sql_i64(id, "index ID")?);
1206        }
1207        if let Some(gen) = generation {
1208            q = q.bind(to_sql_i64(gen, "index generation")?);
1209        }
1210        q = q.bind(limit);
1211        let ids: Vec<i64> = q.fetch_all(&self.pool).await.map_err(|e| e.to_string())?;
1212        let mut requeued = 0usize;
1213        for id in ids {
1214            if self.requeue_dead_letter(id).await? {
1215                requeued += 1;
1216            }
1217        }
1218        Ok(requeued)
1219    }
1220
1221    pub async fn list_dead_letters(
1222        &self,
1223        target: Option<&TargetId>,
1224        index_id: Option<u64>,
1225        generation: Option<u64>,
1226        limit: usize,
1227        offset: usize,
1228    ) -> Result<Vec<DeadLetterRow>, String> {
1229        let limit = limit.clamp(1, 1000) as i64;
1230        let offset = offset as i64;
1231        let mut query = String::from(
1232            "SELECT e.id, e.target, e.index_id, e.generation, e.commit_lsn, e.retry_count, d.failed_at_ms, d.error \
1233             FROM events e JOIN dead_letters d ON d.event_id = e.id WHERE e.status = 'dead_letter'",
1234        );
1235        if target.is_some() {
1236            query.push_str(" AND e.target = ?");
1237        }
1238        if index_id.is_some() {
1239            query.push_str(" AND e.index_id = ?");
1240        }
1241        if generation.is_some() {
1242            query.push_str(" AND e.generation = ?");
1243        }
1244        query.push_str(" ORDER BY e.commit_lsn, e.intent_sequence LIMIT ? OFFSET ?");
1245        let mut q = sqlx::query(&query);
1246        if let Some(t) = target {
1247            q = q.bind(t.as_str());
1248        }
1249        if let Some(id) = index_id {
1250            q = q.bind(to_sql_i64(id, "index ID")?);
1251        }
1252        if let Some(gen) = generation {
1253            q = q.bind(to_sql_i64(gen, "index generation")?);
1254        }
1255        q = q.bind(limit).bind(offset);
1256        let rows = q.fetch_all(&self.pool).await.map_err(|e| e.to_string())?;
1257        let mut out = Vec::with_capacity(rows.len());
1258        for row in rows {
1259            out.push(DeadLetterRow {
1260                event_id: row.get("id"),
1261                target: row.get("target"),
1262                index_id: from_sql_i64(row.get("index_id"), "index ID")?,
1263                generation: from_sql_i64(row.get("generation"), "index generation")?,
1264                commit_lsn: CommitLsn::new(from_sql_i64(row.get("commit_lsn"), "commit LSN")?),
1265                retry_count: from_sql_i64(row.get("retry_count"), "retry count")?,
1266                failed_at_ms: from_sql_i64(row.get("failed_at_ms"), "failure time")?,
1267                error: row.get("error"),
1268            });
1269        }
1270        Ok(out)
1271    }
1272
1273    pub async fn list_degraded_ranges(
1274        &self,
1275        target: Option<&TargetId>,
1276        index_id: Option<u64>,
1277        generation: Option<u64>,
1278    ) -> Result<Vec<DegradedRangeRow>, String> {
1279        let mut query = String::from(
1280            "SELECT target, index_id, generation, start_lsn, end_lsn, reason, created_at_ms FROM degraded_ranges WHERE 1=1",
1281        );
1282        if target.is_some() {
1283            query.push_str(" AND target = ?");
1284        }
1285        if index_id.is_some() {
1286            query.push_str(" AND index_id = ?");
1287        }
1288        if generation.is_some() {
1289            query.push_str(" AND generation = ?");
1290        }
1291        query.push_str(" ORDER BY created_at_ms DESC");
1292        let mut q = sqlx::query(&query);
1293        if let Some(t) = target {
1294            q = q.bind(t.as_str());
1295        }
1296        if let Some(id) = index_id {
1297            q = q.bind(to_sql_i64(id, "index ID")?);
1298        }
1299        if let Some(gen) = generation {
1300            q = q.bind(to_sql_i64(gen, "index generation")?);
1301        }
1302        let rows = q.fetch_all(&self.pool).await.map_err(|e| e.to_string())?;
1303        let mut out = Vec::with_capacity(rows.len());
1304        for row in rows {
1305            out.push(DegradedRangeRow {
1306                target: row.get("target"),
1307                index_id: from_sql_i64(row.get("index_id"), "index ID")?,
1308                generation: from_sql_i64(row.get("generation"), "index generation")?,
1309                start_lsn: CommitLsn::new(from_sql_i64(row.get("start_lsn"), "start LSN")?),
1310                end_lsn: CommitLsn::new(from_sql_i64(row.get("end_lsn"), "end LSN")?),
1311                reason: row.get("reason"),
1312                created_at_ms: from_sql_i64(row.get("created_at_ms"), "created time")?,
1313            });
1314        }
1315        Ok(out)
1316    }
1317
1318    pub async fn clear_degraded_range(
1319        &self,
1320        target: &TargetId,
1321        index_id: u64,
1322        generation: u64,
1323        start_lsn: CommitLsn,
1324        end_lsn: CommitLsn,
1325    ) -> Result<bool, String> {
1326        let result = sqlx::query(
1327            "DELETE FROM degraded_ranges WHERE target = ? AND index_id = ? AND generation = ? AND start_lsn = ? AND end_lsn = ?",
1328        )
1329        .bind(target.as_str())
1330        .bind(to_sql_i64(index_id, "index ID")?)
1331        .bind(to_sql_i64(generation, "index generation")?)
1332        .bind(to_sql_i64(start_lsn.get(), "start LSN")?)
1333        .bind(to_sql_i64(end_lsn.get(), "end LSN")?)
1334        .execute(&self.pool)
1335        .await
1336        .map_err(|e| e.to_string())?;
1337        Ok(result.rows_affected() > 0)
1338    }
1339
1340    pub async fn delivery_targets(&self) -> Result<Vec<TargetId>, String> {
1341        let rows = sqlx::query_scalar::<_, String>(
1342            "SELECT DISTINCT target FROM events WHERE status IN ('pending', 'retry', 'leased') \
1343             ORDER BY target",
1344        )
1345        .fetch_all(&self.pool)
1346        .await
1347        .map_err(|error| error.to_string())?;
1348        rows.into_iter().map(TargetId::new).collect()
1349    }
1350
1351    /// Return aggregate delivery statistics from the durable projection.
1352    ///
1353    /// The in-memory transaction staging map is intentionally not used here:
1354    /// once a commit has crossed the WAL and SQLite materialization fences,
1355    /// SQLite is the source of truth for backlog, leases, retries, and
1356    /// dead-letter state.
1357    pub async fn stats(&self) -> Result<crate::OutboxStats, String> {
1358        let row = sqlx::query(
1359            "SELECT \
1360                COALESCE(SUM(CASE WHEN status IN ('pending', 'retry') THEN 1 ELSE 0 END), 0) AS pending, \
1361                COALESCE(SUM(retry_count), 0) AS retries, \
1362                COALESCE(SUM(CASE WHEN status = 'leased' THEN 1 ELSE 0 END), 0) AS leased, \
1363                COALESCE(SUM(CASE WHEN status = 'dead_letter' THEN 1 ELSE 0 END), 0) AS dead_lettered, \
1364                MIN(CASE WHEN status IN ('pending', 'retry', 'leased') AND created_at_ms > 0 \
1365                         THEN created_at_ms END) AS oldest_created_at_ms \
1366             FROM events",
1367        )
1368        .fetch_one(&self.pool)
1369        .await
1370        .map_err(|error| error.to_string())?;
1371        let now_ms = SystemTime::now()
1372            .duration_since(UNIX_EPOCH)
1373            .map_err(|error| error.to_string())?
1374            .as_millis() as u64;
1375        let oldest_created_at_ms: Option<i64> = row.get("oldest_created_at_ms");
1376        let oldest_event_age_ms = oldest_created_at_ms
1377            .map(|created_at_ms| from_sql_i64(created_at_ms, "event creation timestamp"))
1378            .transpose()?
1379            .map(|created_at_ms| now_ms.saturating_sub(created_at_ms));
1380        let projection_size = std::fs::metadata(&self.path)
1381            .map(|metadata| metadata.len())
1382            .unwrap_or(0);
1383        let durable_rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events")
1384            .fetch_one(&self.pool)
1385            .await
1386            .map_err(|error| error.to_string())?;
1387
1388        Ok(crate::OutboxStats {
1389            pending: from_sql_i64(row.get("pending"), "pending event count")?
1390                .try_into()
1391                .map_err(|_| "pending event count exceeds usize range".to_string())?,
1392            retries: from_sql_i64(row.get("retries"), "retry count")?,
1393            oldest_event_age_ms: oldest_event_age_ms.unwrap_or(0),
1394            dead_lettered: from_sql_i64(row.get("dead_lettered"), "dead letter count")?
1395                .try_into()
1396                .map_err(|_| "dead letter count exceeds usize range".to_string())?,
1397            leased: from_sql_i64(row.get("leased"), "leased event count")?
1398                .try_into()
1399                .map_err(|_| "leased event count exceeds usize range".to_string())?,
1400            write_amplification_bytes: projection_size,
1401            persist_operations: from_sql_i64(durable_rows, "durable event row count")?,
1402            ..Default::default()
1403        })
1404    }
1405
1406    pub async fn prune_applied_events(&self, retention_lsn: CommitLsn) -> Result<u64, String> {
1407        let retention = to_sql_i64(retention_lsn.get(), "retention LSN")?;
1408        let result = sqlx::query(
1409            "DELETE FROM events WHERE status IN ('applied','skipped') AND commit_lsn <= ?",
1410        )
1411        .bind(retention)
1412        .execute(&self.pool)
1413        .await
1414        .map_err(|error| error.to_string())?;
1415        Ok(result.rows_affected())
1416    }
1417
1418    pub async fn retention_lsn(&self) -> Result<CommitLsn, String> {
1419        let value: i64 =
1420            sqlx::query_scalar("SELECT retention_lsn FROM projection_state WHERE singleton = 1")
1421                .fetch_one(&self.pool)
1422                .await
1423                .map_err(|error| error.to_string())?;
1424        Ok(CommitLsn::new(from_sql_i64(value, "retention LSN")?))
1425    }
1426
1427    pub async fn update_retention_lsn(&self, retention_lsn: CommitLsn) -> Result<(), String> {
1428        sqlx::query(
1429            "UPDATE projection_state SET retention_lsn = MAX(retention_lsn, ?) WHERE singleton = 1",
1430        )
1431        .bind(to_sql_i64(retention_lsn.get(), "retention LSN")?)
1432        .execute(&self.pool)
1433        .await
1434        .map_err(|error| error.to_string())?;
1435        Ok(())
1436    }
1437
1438    /// Archive dead letters older than retention_lsn into dead_letters_archive and remove from events.
1439    pub async fn archive_dead_letters(&self, retention_lsn: CommitLsn) -> Result<u64, String> {
1440        let retention = to_sql_i64(retention_lsn.get(), "retention LSN")?;
1441        let now_ms = SystemTime::now()
1442            .duration_since(UNIX_EPOCH)
1443            .map_err(|e| e.to_string())?
1444            .as_millis() as u64;
1445        // Insert into archive
1446        let _archived = sqlx::query(
1447            "INSERT OR IGNORE INTO dead_letters_archive(event_id, target, index_id, generation, commit_lsn, failed_at_ms, error, archived_at_ms) \
1448             SELECT e.id, e.target, e.index_id, e.generation, e.commit_lsn, d.failed_at_ms, d.error, ? \
1449             FROM events e JOIN dead_letters d ON d.event_id = e.id WHERE e.status = 'dead_letter' AND e.commit_lsn <= ?",
1450        )
1451        .bind(to_sql_i64(now_ms, "archived time")?)
1452        .bind(retention)
1453        .execute(&self.pool)
1454        .await
1455        .map_err(|e| e.to_string())?;
1456        // Delete archived events (cascade via foreign key not set, so delete dead_letters first)
1457        sqlx::query(
1458            "DELETE FROM dead_letters WHERE event_id IN (SELECT id FROM events WHERE status = 'dead_letter' AND commit_lsn <= ?)",
1459        )
1460        .bind(retention)
1461        .execute(&self.pool)
1462        .await
1463        .map_err(|e| e.to_string())?;
1464        let deleted =
1465            sqlx::query("DELETE FROM events WHERE status = 'dead_letter' AND commit_lsn <= ?")
1466                .bind(retention)
1467                .execute(&self.pool)
1468                .await
1469                .map_err(|e| e.to_string())?;
1470        Ok(deleted.rows_affected())
1471    }
1472
1473    /// Prune degraded ranges older than max age.
1474    pub async fn prune_degraded_ranges(&self, max_age_ms: u64) -> Result<u64, String> {
1475        let now_ms = SystemTime::now()
1476            .duration_since(UNIX_EPOCH)
1477            .map_err(|e| e.to_string())?
1478            .as_millis() as u64;
1479        let cutoff = now_ms.saturating_sub(max_age_ms);
1480        let result = sqlx::query("DELETE FROM degraded_ranges WHERE created_at_ms < ?")
1481            .bind(to_sql_i64(cutoff, "cutoff time")?)
1482            .execute(&self.pool)
1483            .await
1484            .map_err(|e| e.to_string())?;
1485        Ok(result.rows_affected())
1486    }
1487
1488    /// Compute a safe retention LSN based on min applied frontier.
1489    /// Safe = min(target frontiers, index frontiers) - grace, at least 0.
1490    pub async fn compute_safe_retention_lsn(
1491        &self,
1492        grace_lsn_distance: u64,
1493    ) -> Result<CommitLsn, String> {
1494        let diag = self.diagnostics().await?;
1495        let mut min_lsn = diag.materialized_lsn;
1496        for t in &diag.targets {
1497            if t.applied_lsn < min_lsn {
1498                min_lsn = t.applied_lsn;
1499            }
1500        }
1501        for idx in &diag.indexes {
1502            if idx.applied_lsn < min_lsn {
1503                min_lsn = idx.applied_lsn;
1504            }
1505        }
1506        let safe = min_lsn.get().saturating_sub(grace_lsn_distance);
1507        Ok(CommitLsn::new(safe))
1508    }
1509
1510    pub async fn retry_count(&self, event_id: i64) -> Result<u64, String> {
1511        let value: i64 = sqlx::query_scalar("SELECT retry_count FROM events WHERE id = ?")
1512            .bind(event_id)
1513            .fetch_one(&self.pool)
1514            .await
1515            .map_err(|error| error.to_string())?;
1516        from_sql_i64(value, "retry count")
1517    }
1518
1519    pub async fn materialized_lsn(&self) -> Result<CommitLsn, String> {
1520        let value: i64 =
1521            sqlx::query_scalar("SELECT materialized_lsn FROM projection_state WHERE singleton = 1")
1522                .fetch_one(&self.pool)
1523                .await
1524                .map_err(|error| error.to_string())?;
1525        Ok(CommitLsn::new(from_sql_i64(value, "materialized LSN")?))
1526    }
1527
1528    pub async fn target_frontier(&self, target: &TargetId) -> Result<CommitLsn, String> {
1529        let value: Option<i64> =
1530            sqlx::query_scalar("SELECT applied_lsn FROM target_state WHERE target = ?")
1531                .bind(target.as_str())
1532                .fetch_optional(&self.pool)
1533                .await
1534                .map_err(|error| error.to_string())?;
1535        Ok(CommitLsn::new(from_sql_i64(
1536            value.unwrap_or(0),
1537            "target frontier",
1538        )?))
1539    }
1540
1541    pub async fn index_frontier(
1542        &self,
1543        target: &TargetId,
1544        index_id: u64,
1545        generation: u64,
1546    ) -> Result<CommitLsn, String> {
1547        let value: Option<i64> = sqlx::query_scalar(
1548            "SELECT applied_lsn FROM index_frontier \
1549             WHERE target = ? AND index_id = ? AND generation = ?",
1550        )
1551        .bind(target.as_str())
1552        .bind(to_sql_i64(index_id, "index ID")?)
1553        .bind(to_sql_i64(generation, "index generation")?)
1554        .fetch_optional(&self.pool)
1555        .await
1556        .map_err(|error| error.to_string())?;
1557        Ok(CommitLsn::new(from_sql_i64(
1558            value.unwrap_or(0),
1559            "index frontier",
1560        )?))
1561    }
1562
1563    pub async fn wait_for_minimum_lsn(
1564        &self,
1565        target: &TargetId,
1566        index_id: u64,
1567        generation: u64,
1568        minimum_lsn: CommitLsn,
1569        timeout_ms: u64,
1570    ) -> Result<bool, String> {
1571        let deadline = SystemTime::now()
1572            .duration_since(UNIX_EPOCH)
1573            .map_err(|e| e.to_string())?
1574            .as_millis() as u64
1575            + timeout_ms;
1576
1577        loop {
1578            if self
1579                .has_degraded_range_through(target, index_id, generation, minimum_lsn)
1580                .await?
1581            {
1582                return Err(format!(
1583                    "Consistency frontier for target {} index {} generation {} is degraded through LSN {}",
1584                    target.as_str(),
1585                    index_id,
1586                    generation,
1587                    minimum_lsn
1588                ));
1589            }
1590            let frontier = self.index_frontier(target, index_id, generation).await?;
1591            if frontier >= minimum_lsn {
1592                return Ok(true);
1593            }
1594            let now = SystemTime::now()
1595                .duration_since(UNIX_EPOCH)
1596                .map_err(|e| e.to_string())?
1597                .as_millis() as u64;
1598            if now >= deadline {
1599                return Ok(false);
1600            }
1601            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1602        }
1603    }
1604
1605    pub async fn skip_event_degraded(
1606        &self,
1607        event: &ClaimedEvent,
1608        reason: &str,
1609    ) -> Result<bool, String> {
1610        let mut connection = self
1611            .pool
1612            .acquire()
1613            .await
1614            .map_err(|error| error.to_string())?;
1615        begin_immediate(&mut connection).await?;
1616        let result = async {
1617            let updated = sqlx::query(
1618                "UPDATE events SET status = 'skipped', last_error = ?, lease_owner = NULL, \
1619                     lease_until_ms = 0 \
1620                 WHERE id = ? AND status = 'leased' AND lease_owner = ? AND lease_epoch = ?",
1621            )
1622            .bind(reason)
1623            .bind(event.event_id)
1624            .bind(&event.lease_owner)
1625            .bind(to_sql_i64(event.lease_epoch.get(), "lease epoch")?)
1626            .execute(&mut *connection)
1627            .await
1628            .map_err(|error| error.to_string())?;
1629            if updated.rows_affected() == 0 {
1630                return Ok(false);
1631            }
1632            let lsn = to_sql_i64(event.commit_lsn.get(), "commit LSN")?;
1633            sqlx::query(
1634                "UPDATE commit_targets SET applied_count = applied_count + 1, \
1635                     degraded = 1, completed = (applied_count + 1 >= event_count) \
1636                 WHERE commit_lsn = ? AND target = ?",
1637            )
1638            .bind(lsn)
1639            .bind(event.mutation.target.as_str())
1640            .execute(&mut *connection)
1641            .await
1642            .map_err(|error| error.to_string())?;
1643
1644            sqlx::query(
1645                "UPDATE index_frontier SET degraded = 1 \
1646                 WHERE target = ? AND index_id = ? AND generation = ?",
1647            )
1648            .bind(event.mutation.target.as_str())
1649            .bind(to_sql_i64(event.mutation.index_id, "index ID")?)
1650            .bind(to_sql_i64(
1651                event.mutation.index_generation.get(),
1652                "index generation",
1653            )?)
1654            .execute(&mut *connection)
1655            .await
1656            .map_err(|error| error.to_string())?;
1657
1658            let now_ms = SystemTime::now()
1659                .duration_since(UNIX_EPOCH)
1660                .map_err(|error| error.to_string())?
1661                .as_millis() as u64;
1662            sqlx::query(
1663                "INSERT INTO degraded_ranges(\
1664                     target, index_id, generation, start_lsn, end_lsn, reason, created_at_ms\
1665                 ) VALUES(?, ?, ?, ?, ?, ?, ?) \
1666                 ON CONFLICT(target, index_id, generation, start_lsn, end_lsn) \
1667                 DO UPDATE SET reason = excluded.reason, created_at_ms = excluded.created_at_ms",
1668            )
1669            .bind(event.mutation.target.as_str())
1670            .bind(to_sql_i64(event.mutation.index_id, "index ID")?)
1671            .bind(to_sql_i64(
1672                event.mutation.index_generation.get(),
1673                "index generation",
1674            )?)
1675            .bind(lsn)
1676            .bind(lsn)
1677            .bind(reason)
1678            .bind(to_sql_i64(now_ms, "degraded range timestamp")?)
1679            .execute(&mut *connection)
1680            .await
1681            .map_err(|error| error.to_string())?;
1682
1683            advance_frontier(&mut connection, &event.mutation.target).await?;
1684            advance_index_frontier(
1685                &mut connection,
1686                &event.mutation.target,
1687                event.mutation.index_id,
1688                event.mutation.index_generation.get(),
1689            )
1690            .await?;
1691            Ok(true)
1692        }
1693        .await;
1694        finish_transaction(&mut connection, result).await
1695    }
1696
1697    pub async fn has_degraded_range(
1698        &self,
1699        target: &TargetId,
1700        index_id: u64,
1701        generation: u64,
1702    ) -> Result<bool, String> {
1703        let value: Option<i64> = sqlx::query_scalar(
1704            "SELECT degraded FROM index_frontier \
1705             WHERE target = ? AND index_id = ? AND generation = ?",
1706        )
1707        .bind(target.as_str())
1708        .bind(to_sql_i64(index_id, "index ID")?)
1709        .bind(to_sql_i64(generation, "index generation")?)
1710        .fetch_optional(&self.pool)
1711        .await
1712        .map_err(|error| error.to_string())?;
1713        Ok(value.unwrap_or(0) != 0)
1714    }
1715
1716    pub async fn has_degraded_range_through(
1717        &self,
1718        target: &TargetId,
1719        index_id: u64,
1720        generation: u64,
1721        minimum_lsn: CommitLsn,
1722    ) -> Result<bool, String> {
1723        let value: i64 = sqlx::query_scalar(
1724            "SELECT EXISTS(\
1725                 SELECT 1 FROM degraded_ranges \
1726                 WHERE target = ? AND index_id = ? AND generation = ? AND start_lsn <= ?\
1727             )",
1728        )
1729        .bind(target.as_str())
1730        .bind(to_sql_i64(index_id, "index ID")?)
1731        .bind(to_sql_i64(generation, "index generation")?)
1732        .bind(to_sql_i64(minimum_lsn.get(), "minimum LSN")?)
1733        .fetch_one(&self.pool)
1734        .await
1735        .map_err(|error| error.to_string())?;
1736        Ok(value != 0)
1737    }
1738
1739    pub async fn dead_lettered_count(&self, target: &TargetId) -> Result<u64, String> {
1740        let value: i64 = sqlx::query_scalar(
1741            "SELECT COUNT(*) FROM events e \
1742             JOIN dead_letters d ON d.event_id = e.id \
1743             WHERE e.target = ?",
1744        )
1745        .bind(target.as_str())
1746        .fetch_one(&self.pool)
1747        .await
1748        .map_err(|error| error.to_string())?;
1749        from_sql_i64(value, "dead letter count")
1750    }
1751
1752    /// Return a single consistent view of outbox backlog, delivery frontiers,
1753    /// degraded ranges, and index-generation progress.
1754    pub async fn diagnostics(&self) -> Result<SyncDiagnostics, String> {
1755        let materialized_lsn = self.materialized_lsn().await?;
1756        let now_ms = SystemTime::now()
1757            .duration_since(UNIX_EPOCH)
1758            .map_err(|error| error.to_string())?
1759            .as_millis() as u64;
1760        let target_rows = sqlx::query(
1761            "SELECT s.target, s.applied_lsn, s.degraded, \
1762                    SUM(CASE WHEN e.status = 'pending' THEN 1 ELSE 0 END) AS pending, \
1763                    SUM(CASE WHEN e.status = 'retry' THEN 1 ELSE 0 END) AS retrying, \
1764                    SUM(CASE WHEN e.status = 'leased' THEN 1 ELSE 0 END) AS leased, \
1765                    SUM(CASE WHEN e.status = 'dead_letter' THEN 1 ELSE 0 END) AS dead_lettered, \
1766                    MIN(CASE WHEN e.status IN ('pending', 'retry', 'leased') \
1767                              AND e.created_at_ms > 0 THEN e.created_at_ms END) AS oldest_created_at_ms \
1768             FROM target_state s LEFT JOIN events e ON e.target = s.target \
1769             GROUP BY s.target, s.applied_lsn, s.degraded ORDER BY s.target",
1770        )
1771        .fetch_all(&self.pool)
1772        .await
1773        .map_err(|error| error.to_string())?;
1774        let mut targets = Vec::with_capacity(target_rows.len());
1775        for row in target_rows {
1776            let applied_lsn =
1777                CommitLsn::new(from_sql_i64(row.get("applied_lsn"), "target frontier")?);
1778            let oldest_created_at_ms: Option<i64> = row.get("oldest_created_at_ms");
1779            let oldest_event_age_ms = oldest_created_at_ms
1780                .map(|created_at_ms| from_sql_i64(created_at_ms, "event creation timestamp"))
1781                .transpose()?
1782                .map(|created_at_ms| now_ms.saturating_sub(created_at_ms));
1783            targets.push(TargetSyncDiagnostics {
1784                target: row.get("target"),
1785                applied_lsn,
1786                frontier_lag: materialized_lsn.get().saturating_sub(applied_lsn.get()),
1787                pending: from_sql_i64(row.get("pending"), "pending event count")?,
1788                retrying: from_sql_i64(row.get("retrying"), "retry event count")?,
1789                leased: from_sql_i64(row.get("leased"), "leased event count")?,
1790                dead_lettered: from_sql_i64(row.get("dead_lettered"), "dead-letter event count")?,
1791                oldest_event_age_ms,
1792                degraded: row.get::<i64, _>("degraded") != 0,
1793            });
1794        }
1795
1796        let index_rows = sqlx::query(
1797            "SELECT g.target, g.index_id, g.generation, g.state, g.barrier_lsn, \
1798                    COALESCE(f.applied_lsn, 0) AS applied_lsn, COALESCE(f.degraded, 0) AS degraded \
1799             FROM generation_state g LEFT JOIN index_frontier f \
1800                  ON f.target = g.target AND f.index_id = g.index_id AND f.generation = g.generation \
1801             ORDER BY g.target, g.index_id, g.generation",
1802        )
1803        .fetch_all(&self.pool)
1804        .await
1805        .map_err(|error| error.to_string())?;
1806        let mut indexes = Vec::with_capacity(index_rows.len());
1807        for row in index_rows {
1808            let applied_lsn =
1809                CommitLsn::new(from_sql_i64(row.get("applied_lsn"), "index frontier")?);
1810            let barrier_lsn: Option<i64> = row.get("barrier_lsn");
1811            indexes.push(IndexSyncDiagnostics {
1812                target: row.get("target"),
1813                index_id: from_sql_i64(row.get("index_id"), "index ID")?,
1814                generation: from_sql_i64(row.get("generation"), "index generation")?,
1815                state: row.get("state"),
1816                barrier_lsn: barrier_lsn
1817                    .map(|value| from_sql_i64(value, "generation barrier LSN"))
1818                    .transpose()?
1819                    .map(CommitLsn::new),
1820                applied_lsn,
1821                frontier_lag: materialized_lsn.get().saturating_sub(applied_lsn.get()),
1822                degraded: row.get::<i64, _>("degraded") != 0,
1823            });
1824        }
1825        Ok(SyncDiagnostics {
1826            materialized_lsn,
1827            targets,
1828            indexes,
1829            vector_disabled_skips: 0,
1830        })
1831    }
1832}
1833
1834fn validate_intents(intents: &[OutboxIntent]) -> Result<(), String> {
1835    for (expected, intent) in intents.iter().enumerate() {
1836        intent.validate()?;
1837        if intent.intent_sequence as usize != expected {
1838            return Err(format!(
1839                "Intent sequence is not contiguous: expected {}, got {}",
1840                expected, intent.intent_sequence
1841            ));
1842        }
1843    }
1844    Ok(())
1845}
1846
1847async fn begin_immediate(connection: &mut SqliteConnection) -> Result<(), String> {
1848    sqlx::query("BEGIN IMMEDIATE")
1849        .execute(&mut *connection)
1850        .await
1851        .map_err(|error| error.to_string())?;
1852    Ok(())
1853}
1854
1855async fn finish_transaction<T>(
1856    connection: &mut SqliteConnection,
1857    result: Result<T, String>,
1858) -> Result<T, String> {
1859    match result {
1860        Ok(value) => {
1861            sqlx::query("COMMIT")
1862                .execute(&mut *connection)
1863                .await
1864                .map_err(|error| error.to_string())?;
1865            Ok(value)
1866        }
1867        Err(error) => {
1868            let _ = sqlx::query("ROLLBACK").execute(&mut *connection).await;
1869            Err(error)
1870        }
1871    }
1872}
1873
1874async fn advance_frontier(
1875    connection: &mut SqliteConnection,
1876    target: &TargetId,
1877) -> Result<(), String> {
1878    loop {
1879        let current: i64 =
1880            sqlx::query_scalar("SELECT applied_lsn FROM target_state WHERE target = ?")
1881                .bind(target.as_str())
1882                .fetch_one(&mut *connection)
1883                .await
1884                .map_err(|error| error.to_string())?;
1885        let next = sqlx::query(
1886            "SELECT commit_lsn, completed, degraded FROM commit_targets \
1887             WHERE target = ? AND commit_lsn > ? ORDER BY commit_lsn LIMIT 1",
1888        )
1889        .bind(target.as_str())
1890        .bind(current)
1891        .fetch_optional(&mut *connection)
1892        .await
1893        .map_err(|error| error.to_string())?;
1894        let Some(next) = next else {
1895            return Ok(());
1896        };
1897        let completed: i64 = next.get("completed");
1898        let degraded: i64 = next.get("degraded");
1899        if completed == 0 {
1900            return Ok(());
1901        }
1902        let next_lsn: i64 = next.get("commit_lsn");
1903        sqlx::query(
1904            "UPDATE target_state SET applied_lsn = ?, degraded = MAX(degraded, ?) \
1905             WHERE target = ?",
1906        )
1907        .bind(next_lsn)
1908        .bind(degraded)
1909        .bind(target.as_str())
1910        .execute(&mut *connection)
1911        .await
1912        .map_err(|error| error.to_string())?;
1913    }
1914}
1915
1916async fn advance_index_frontier(
1917    connection: &mut SqliteConnection,
1918    target: &TargetId,
1919    index_id: u64,
1920    generation: u64,
1921) -> Result<(), String> {
1922    let index_id_i64 = to_sql_i64(index_id, "index ID")?;
1923    let generation_i64 = to_sql_i64(generation, "index generation")?;
1924
1925    sqlx::query(
1926        "INSERT INTO index_frontier(target, index_id, generation) \
1927         VALUES(?, ?, ?) ON CONFLICT DO NOTHING",
1928    )
1929    .bind(target.as_str())
1930    .bind(index_id_i64)
1931    .bind(generation_i64)
1932    .execute(&mut *connection)
1933    .await
1934    .map_err(|error| error.to_string())?;
1935
1936    loop {
1937        let current: i64 = sqlx::query_scalar(
1938            "SELECT applied_lsn FROM index_frontier \
1939             WHERE target = ? AND index_id = ? AND generation = ?",
1940        )
1941        .bind(target.as_str())
1942        .bind(index_id_i64)
1943        .bind(generation_i64)
1944        .fetch_one(&mut *connection)
1945        .await
1946        .map_err(|error| error.to_string())?;
1947
1948        let next = sqlx::query(
1949            "SELECT c.commit_lsn, \
1950                    (SELECT COUNT(*) FROM events e \
1951                     WHERE e.target = c.target AND e.commit_lsn = c.commit_lsn \
1952                       AND e.index_id = ? AND e.generation = ?) AS total_count, \
1953                    (SELECT COUNT(*) FROM events e \
1954                     WHERE e.target = c.target AND e.commit_lsn = c.commit_lsn \
1955                       AND e.index_id = ? AND e.generation = ? \
1956                       AND e.status IN ('applied', 'skipped')) AS terminal_count, \
1957                    (SELECT COUNT(*) FROM events e \
1958                     WHERE e.target = c.target AND e.commit_lsn = c.commit_lsn \
1959                       AND e.index_id = ? AND e.generation = ? \
1960                       AND e.status = 'skipped') AS skipped_count \
1961             FROM commit_targets c \
1962             WHERE c.target = ? AND c.commit_lsn > ? \
1963             ORDER BY c.commit_lsn LIMIT 1",
1964        )
1965        .bind(index_id_i64)
1966        .bind(generation_i64)
1967        .bind(index_id_i64)
1968        .bind(generation_i64)
1969        .bind(index_id_i64)
1970        .bind(generation_i64)
1971        .bind(target.as_str())
1972        .bind(current)
1973        .fetch_optional(&mut *connection)
1974        .await
1975        .map_err(|error| error.to_string())?;
1976
1977        let Some(next) = next else {
1978            return Ok(());
1979        };
1980        let terminal_count: i64 = next.get("terminal_count");
1981        let total_count: i64 = next.get("total_count");
1982        let skipped_count: i64 = next.get("skipped_count");
1983
1984        if terminal_count != total_count {
1985            return Ok(());
1986        }
1987
1988        let next_lsn: i64 = next.get("commit_lsn");
1989        sqlx::query(
1990            "UPDATE index_frontier SET applied_lsn = ?, degraded = MAX(degraded, ?) \
1991             WHERE target = ? AND index_id = ? AND generation = ?",
1992        )
1993        .bind(next_lsn)
1994        .bind(i64::from(skipped_count != 0))
1995        .bind(target.as_str())
1996        .bind(index_id_i64)
1997        .bind(generation_i64)
1998        .execute(&mut *connection)
1999        .await
2000        .map_err(|error| error.to_string())?;
2001    }
2002}
2003
2004fn to_sql_i64(value: u64, name: &str) -> Result<i64, String> {
2005    i64::try_from(value).map_err(|_| format!("{} exceeds SQLite integer range", name))
2006}
2007
2008fn from_sql_i64(value: i64, name: &str) -> Result<u64, String> {
2009    u64::try_from(value).map_err(|_| format!("{} cannot be negative", name))
2010}
2011
2012#[cfg(test)]
2013mod tests {
2014    use linkrs_core::types::{
2015        IdempotencyKey, IndexGeneration, OrderingKey, TargetId, TransactionId, VertexId,
2016    };
2017    use linkrs_core::wal::{
2018        EntityRef, IndexMutation, IndexOperation, OutboxIntent, WAL_SYNC_WIRE_VERSION,
2019    };
2020    use tempfile::TempDir;
2021
2022    use super::SqliteOutbox;
2023    use linkrs_core::types::CommitLsn;
2024
2025    fn intent(sequence: u32, entity: i64, target: &TargetId) -> OutboxIntent {
2026        OutboxIntent {
2027            wire_version: WAL_SYNC_WIRE_VERSION,
2028            transaction_id: TransactionId::new(1),
2029            intent_sequence: sequence,
2030            mutation: IndexMutation {
2031                wire_version: WAL_SYNC_WIRE_VERSION,
2032                target: target.clone(),
2033                index_id: 1,
2034                index_generation: IndexGeneration::new(1),
2035                entity_ref: EntityRef::Vertex(
2036                    VertexId::try_from_int64(entity).expect("test vertex id"),
2037                ),
2038                operation: IndexOperation::Upsert,
2039                document_or_vector: vec![entity as u8],
2040                idempotency_key: IdempotencyKey::new(format!("event-{sequence}"))
2041                    .expect("key should be valid"),
2042                ordering_key: OrderingKey::new(format!("entity-{entity}"))
2043                    .expect("key should be valid"),
2044            },
2045        }
2046    }
2047
2048    #[tokio::test]
2049    async fn materialize_claim_and_ack_are_durable() {
2050        let directory = TempDir::new().expect("temporary directory should be created");
2051        let path = directory.path().join("outbox.sqlite");
2052        let target = TargetId::new("fulltext").expect("target should be valid");
2053        let outbox = SqliteOutbox::open(&path).await.expect("outbox should open");
2054        outbox
2055            .create_index_generation(&target, 1, 1, CommitLsn::ZERO)
2056            .await
2057            .expect("generation should be created");
2058        outbox
2059            .transition_generation_to_backfilling(&target, 1, 1)
2060            .await
2061            .expect("generation should transition to backfilling");
2062        outbox
2063            .transition_generation_to_catching_up(&target, 1, 1, CommitLsn::ZERO)
2064            .await
2065            .expect("generation should transition to catching up");
2066        outbox
2067            .activate_generation(&target, 1, 1, CommitLsn::ZERO)
2068            .await
2069            .expect("generation should activate");
2070        outbox
2071            .materialize_commit(
2072                CommitLsn::new(100),
2073                &[intent(0, 7, &target)],
2074                std::slice::from_ref(&target),
2075            )
2076            .await
2077            .expect("commit should materialize");
2078        let event = outbox
2079            .claim_next(&target, "worker-1", 10, 100)
2080            .await
2081            .expect("claim should succeed")
2082            .expect("event should be available");
2083        assert!(outbox
2084            .acknowledge(&event)
2085            .await
2086            .expect("ack should succeed"));
2087        assert_eq!(
2088            outbox
2089                .target_frontier(&target)
2090                .await
2091                .expect("frontier should load"),
2092            CommitLsn::new(100)
2093        );
2094        drop(outbox);
2095        let reopened = SqliteOutbox::open(&path)
2096            .await
2097            .expect("outbox should reopen");
2098        assert_eq!(
2099            reopened
2100                .materialized_lsn()
2101                .await
2102                .expect("materialized LSN should load"),
2103            CommitLsn::new(100)
2104        );
2105    }
2106
2107    #[tokio::test]
2108    async fn materialization_registers_a_missing_first_generation() {
2109        let directory = TempDir::new().expect("temporary directory should be created");
2110        let target = TargetId::new("fulltext").expect("target should be valid");
2111        let outbox = SqliteOutbox::open(directory.path().join("outbox.sqlite"))
2112            .await
2113            .expect("outbox should open");
2114        outbox
2115            .materialize_commit(
2116                CommitLsn::new(10),
2117                &[intent(0, 1, &target)],
2118                std::slice::from_ref(&target),
2119            )
2120            .await
2121            .expect("commit should materialize");
2122
2123        let state = outbox
2124            .get_generation_state(&target, 1, 1)
2125            .await
2126            .expect("generation state should load")
2127            .expect("generation should be registered");
2128        assert_eq!(state.0, "active");
2129        assert!(outbox
2130            .claim_next(&target, "worker-1", 0, 100)
2131            .await
2132            .expect("event should be claimable")
2133            .is_some());
2134    }
2135
2136    #[tokio::test]
2137    async fn frontier_does_not_cross_an_unacknowledged_commit() {
2138        let directory = TempDir::new().expect("temporary directory should be created");
2139        let target = TargetId::new("vector").expect("target should be valid");
2140        let outbox = SqliteOutbox::open(directory.path().join("outbox.sqlite"))
2141            .await
2142            .expect("outbox should open");
2143        outbox
2144            .create_index_generation(&target, 1, 1, CommitLsn::ZERO)
2145            .await
2146            .expect("generation should be created");
2147        outbox
2148            .transition_generation_to_backfilling(&target, 1, 1)
2149            .await
2150            .expect("generation should transition to backfilling");
2151        outbox
2152            .transition_generation_to_catching_up(&target, 1, 1, CommitLsn::ZERO)
2153            .await
2154            .expect("generation should transition to catching up");
2155        outbox
2156            .activate_generation(&target, 1, 1, CommitLsn::ZERO)
2157            .await
2158            .expect("generation should activate");
2159        outbox
2160            .materialize_commit(
2161                CommitLsn::new(100),
2162                &[intent(0, 1, &target)],
2163                std::slice::from_ref(&target),
2164            )
2165            .await
2166            .expect("first commit should materialize");
2167        let mut second = intent(0, 2, &target);
2168        second.transaction_id = TransactionId::new(2);
2169        second.mutation.idempotency_key =
2170            IdempotencyKey::new("second").expect("key should be valid");
2171        outbox
2172            .materialize_commit(
2173                CommitLsn::new(200),
2174                &[second],
2175                std::slice::from_ref(&target),
2176            )
2177            .await
2178            .expect("second commit should materialize");
2179
2180        let first = outbox
2181            .claim_next(&target, "worker-1", 10, 100)
2182            .await
2183            .expect("first claim should succeed")
2184            .expect("first event should exist");
2185        let second = outbox
2186            .claim_next(&target, "worker-2", 10, 100)
2187            .await
2188            .expect("second claim should succeed")
2189            .expect("second event should exist");
2190        assert_eq!(second.commit_lsn, CommitLsn::new(200));
2191        assert!(outbox
2192            .acknowledge(&second)
2193            .await
2194            .expect("second ack should succeed"));
2195        assert_eq!(
2196            outbox
2197                .target_frontier(&target)
2198                .await
2199                .expect("frontier should load"),
2200            CommitLsn::ZERO
2201        );
2202        assert!(outbox
2203            .acknowledge(&first)
2204            .await
2205            .expect("first ack should succeed"));
2206        assert_eq!(
2207            outbox
2208                .target_frontier(&target)
2209                .await
2210                .expect("frontier should load"),
2211            CommitLsn::new(200)
2212        );
2213    }
2214
2215    #[tokio::test]
2216    async fn stale_lease_cannot_ack() {
2217        let directory = TempDir::new().expect("temporary directory should be created");
2218        let target = TargetId::new("fulltext").expect("target should be valid");
2219        let outbox = SqliteOutbox::open(directory.path().join("outbox.sqlite"))
2220            .await
2221            .expect("outbox should open");
2222        outbox
2223            .create_index_generation(&target, 1, 1, CommitLsn::ZERO)
2224            .await
2225            .expect("generation should be created");
2226        outbox
2227            .transition_generation_to_backfilling(&target, 1, 1)
2228            .await
2229            .expect("generation should transition to backfilling");
2230        outbox
2231            .transition_generation_to_catching_up(&target, 1, 1, CommitLsn::ZERO)
2232            .await
2233            .expect("generation should transition to catching up");
2234        outbox
2235            .activate_generation(&target, 1, 1, CommitLsn::ZERO)
2236            .await
2237            .expect("generation should activate");
2238        outbox
2239            .materialize_commit(
2240                CommitLsn::new(10),
2241                &[intent(0, 1, &target)],
2242                std::slice::from_ref(&target),
2243            )
2244            .await
2245            .expect("commit should materialize");
2246        let stale = outbox
2247            .claim_next(&target, "worker-1", 0, 10)
2248            .await
2249            .expect("claim should succeed")
2250            .expect("event should exist");
2251        let current = outbox
2252            .claim_next(&target, "worker-2", 11, 10)
2253            .await
2254            .expect("reclaim should succeed")
2255            .expect("event should be reclaimed");
2256        assert!(!outbox
2257            .acknowledge(&stale)
2258            .await
2259            .expect("stale ack should be rejected cleanly"));
2260        assert!(outbox
2261            .acknowledge(&current)
2262            .await
2263            .expect("current ack should succeed"));
2264    }
2265
2266    #[tokio::test]
2267    async fn snapshot_is_atomic_and_checksum_verified() {
2268        let directory = TempDir::new().expect("temporary directory");
2269        let path = directory.path().join("outbox.sqlite");
2270        let snapshot_path = directory.path().join("checkpoints/outbox.sqlite");
2271        let outbox = SqliteOutbox::open(&path).await.expect("outbox should open");
2272        let snapshot = outbox
2273            .create_snapshot(&snapshot_path)
2274            .await
2275            .expect("snapshot should be created");
2276        SqliteOutbox::verify_snapshot(&snapshot).expect("snapshot checksum should verify");
2277        let mut bytes = std::fs::read(&snapshot_path).expect("snapshot should be readable");
2278        bytes[0] ^= 0xff;
2279        std::fs::write(&snapshot_path, bytes).expect("snapshot should be corrupted");
2280        assert!(SqliteOutbox::verify_snapshot(&snapshot).is_err());
2281    }
2282
2283    #[tokio::test]
2284    async fn diagnostics_report_backlog_and_generation_frontiers() {
2285        let directory = TempDir::new().expect("temporary directory should be created");
2286        let target = TargetId::new("fulltext").expect("target should be valid");
2287        let outbox = SqliteOutbox::open(directory.path().join("outbox.sqlite"))
2288            .await
2289            .expect("outbox should open");
2290        outbox
2291            .create_index_generation(&target, 1, 1, CommitLsn::ZERO)
2292            .await
2293            .expect("generation should be created");
2294        outbox
2295            .transition_generation_to_backfilling(&target, 1, 1)
2296            .await
2297            .expect("generation should transition to backfilling");
2298        outbox
2299            .transition_generation_to_catching_up(&target, 1, 1, CommitLsn::ZERO)
2300            .await
2301            .expect("generation should transition to catching up");
2302        outbox
2303            .activate_generation(&target, 1, 1, CommitLsn::ZERO)
2304            .await
2305            .expect("generation should activate");
2306        outbox
2307            .materialize_commit(
2308                CommitLsn::new(42),
2309                &[intent(0, 1, &target)],
2310                std::slice::from_ref(&target),
2311            )
2312            .await
2313            .expect("commit should materialize");
2314
2315        let diagnostics = outbox.diagnostics().await.expect("diagnostics should load");
2316        assert_eq!(diagnostics.materialized_lsn, CommitLsn::new(42));
2317        assert_eq!(diagnostics.targets.len(), 1);
2318        assert_eq!(diagnostics.targets[0].target, "fulltext");
2319        assert_eq!(diagnostics.targets[0].pending, 1);
2320        assert_eq!(diagnostics.targets[0].frontier_lag, 42);
2321        assert!(diagnostics.targets[0].oldest_event_age_ms.is_some());
2322        assert_eq!(diagnostics.indexes.len(), 1);
2323        assert_eq!(diagnostics.indexes[0].state, "active");
2324        assert_eq!(diagnostics.indexes[0].frontier_lag, 42);
2325
2326        let stats = outbox.stats().await.expect("outbox stats should load");
2327        assert_eq!(stats.pending, 1);
2328        assert_eq!(stats.leased, 0);
2329        assert_eq!(stats.dead_lettered, 0);
2330        assert_eq!(stats.retries, 0);
2331
2332        let event = outbox
2333            .claim_next(&target, "stats-worker", 0, 1_000)
2334            .await
2335            .expect("event should be claimable")
2336            .expect("event should exist");
2337        let leased = outbox.stats().await.expect("leased stats should load");
2338        assert_eq!(leased.pending, 0);
2339        assert_eq!(leased.leased, 1);
2340        outbox
2341            .retry(&event, 1_001, "temporary failure")
2342            .await
2343            .expect("event should be retryable");
2344        let retrying = outbox.stats().await.expect("retry stats should load");
2345        assert_eq!(retrying.pending, 1);
2346        assert_eq!(retrying.leased, 0);
2347        assert_eq!(retrying.retries, 1);
2348    }
2349}