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
10const 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#[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#[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#[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 pub vector_disabled_skips: u64,
56}
57
58#[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#[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#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¤t)
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}