1use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
16use std::path::Path;
17use std::sync::Arc;
18use std::time::Duration;
19
20use firstpass_core::{DeferredVerdict, GENESIS_HASH, Score, Trace, Verdict};
21use rusqlite::{Connection, OptionalExtension as _};
22use tokio::sync::mpsc;
23use tokio::task::JoinHandle;
24
25use crate::config::ReceiptsMode;
26
27fn connect(db_path: impl AsRef<Path>) -> Result<Connection, StoreError> {
30 let conn = Connection::open(db_path.as_ref())?;
31 conn.busy_timeout(Duration::from_secs(5))?;
32 Ok(conn)
33}
34
35#[derive(Debug, thiserror::Error)]
37pub enum StoreError {
38 #[error("sqlite error: {0}")]
40 Sqlite(#[from] rusqlite::Error),
41 #[error("trace error: {0}")]
43 Trace(#[from] firstpass_core::Error),
44 #[error("json error: {0}")]
46 Json(#[from] serde_json::Error),
47 #[error("spill I/O error: {0}")]
49 Io(#[from] std::io::Error),
50}
51
52pub type SpillHandle = Arc<std::sync::Mutex<std::fs::File>>;
60
61pub(crate) fn spill_path(db_path: &Path) -> std::path::PathBuf {
63 let mut s = db_path.as_os_str().to_owned();
64 s.push(".spill.jsonl");
65 std::path::PathBuf::from(s)
66}
67
68pub fn append_to_spill(handle: &SpillHandle, trace: &Trace) -> Result<(), StoreError> {
77 let line = serde_json::to_string(trace)?;
78 let mut guard = handle.lock().unwrap_or_else(|e| e.into_inner());
81 writeln!(guard, "{line}")?;
82 guard.sync_data()?;
84 Ok(())
85}
86
87pub type TraceSender = mpsc::Sender<Trace>;
91
92pub const TRACE_CHANNEL_CAP: usize = 8192;
95
96pub fn open(db_path: impl AsRef<Path>) -> Result<(TraceSender, JoinHandle<()>), StoreError> {
108 let (tx, _spill, handle) = open_with_receipts(db_path, ReceiptsMode::BestEffort)?;
109 Ok((tx, handle))
110}
111
112pub fn open_with_receipts(
126 db_path: impl AsRef<Path>,
127 receipts_mode: ReceiptsMode,
128) -> Result<(TraceSender, Option<SpillHandle>, JoinHandle<()>), StoreError> {
129 let db_path = db_path.as_ref();
130 let conn = connect(db_path)?;
131 migrate(&conn)?;
132
133 let spill = if receipts_mode == ReceiptsMode::Durable {
134 let path = spill_path(db_path);
135 let file = std::fs::OpenOptions::new()
136 .create(true)
137 .append(true)
138 .read(true)
139 .open(&path)?;
140 Some(Arc::new(std::sync::Mutex::new(file)))
141 } else {
142 None
143 };
144
145 let (tx, rx) = mpsc::channel::<Trace>(TRACE_CHANNEL_CAP);
146 let spill_writer = spill.clone();
147 let handle = tokio::task::spawn_blocking(move || writer_loop(conn, rx, spill_writer));
148 Ok((tx, spill, handle))
149}
150
151fn migrate(conn: &Connection) -> Result<(), StoreError> {
152 conn.pragma_update(None, "journal_mode", "WAL")?;
155 conn.execute_batch(
156 "CREATE TABLE IF NOT EXISTS traces (
157 seq INTEGER PRIMARY KEY AUTOINCREMENT,
158 trace_id TEXT NOT NULL,
159 ts TEXT NOT NULL,
160 prev_hash TEXT NOT NULL,
161 hash TEXT NOT NULL,
162 tenant TEXT NOT NULL,
163 session TEXT NOT NULL,
164 body TEXT NOT NULL
165 );
166 CREATE INDEX IF NOT EXISTS traces_session_idx ON traces(session);
167 -- Tenant-scoped reads (ADR 0004 §D3) filter on `tenant`; index it with `seq` so a
168 -- per-tenant scan stays ordered and cheap. Existing rows keep their `tenant` value.
169 CREATE INDEX IF NOT EXISTS traces_tenant_seq_idx ON traces(tenant, seq);
170 -- Deferred verdicts live in their OWN table, keyed by trace_id. They are NEVER folded
171 -- into the sealed, hashed `traces.body`, so a late outcome can't alter a past record and
172 -- the tamper-evident chain stays valid. They are merged onto a trace only on read.
173 CREATE TABLE IF NOT EXISTS deferred_verdicts (
174 id INTEGER PRIMARY KEY AUTOINCREMENT,
175 trace_id TEXT NOT NULL,
176 gate_id TEXT NOT NULL,
177 verdict TEXT NOT NULL,
178 score REAL,
179 reported_at TEXT NOT NULL,
180 reporter TEXT NOT NULL
181 );
182 CREATE INDEX IF NOT EXISTS deferred_trace_idx ON deferred_verdicts(trace_id);",
183 )?;
184 Ok(())
185}
186
187fn write_trace(conn: &Connection, trace: &mut Trace, head: &mut String) {
190 trace.prev_hash = head.clone();
191 let hash = match trace.hash() {
192 Ok(h) => h,
193 Err(err) => {
194 tracing::error!(%err, trace_id = %trace.trace_id, "trace writer: hash failed, dropping");
195 return;
196 }
197 };
198 if let Err(err) = insert(conn, trace, &hash) {
199 tracing::error!(%err, trace_id = %trace.trace_id, "trace writer: insert failed, dropping");
200 return;
201 }
202 *head = hash;
203}
204
205fn drain_spill(conn: &Connection, spill: &SpillHandle, head: &mut String) {
212 let mut guard = spill.lock().unwrap_or_else(|e| e.into_inner());
215
216 if let Err(e) = guard.seek(SeekFrom::Start(0)) {
218 tracing::error!(%e, "drain_spill: seek failed");
219 return;
220 }
221
222 let lines: Vec<String> = {
224 let reader = BufReader::new(&*guard);
225 reader
226 .lines()
227 .filter_map(|l| match l {
228 Ok(s) if !s.trim().is_empty() => Some(s),
229 Ok(_) => None,
230 Err(e) => {
231 tracing::error!(%e, "drain_spill: read error");
232 None
233 }
234 })
235 .collect()
236 };
237
238 if lines.is_empty() {
239 return;
240 }
241
242 let n = lines.len();
243 for line in &lines {
244 let mut trace: Trace = match serde_json::from_str(line) {
245 Ok(t) => t,
246 Err(e) => {
247 tracing::error!(%e, "drain_spill: bad JSON line, skipping");
248 continue;
249 }
250 };
251 write_trace(conn, &mut trace, head);
252 }
253 tracing::info!(drained = n, "spill file drained into trace store");
254
255 if let Err(e) = guard.seek(SeekFrom::Start(0)) {
257 tracing::error!(%e, "drain_spill: post-drain seek failed");
258 return;
259 }
260 if let Err(e) = guard.set_len(0) {
261 tracing::error!(%e, "drain_spill: truncate failed — spill file may replay on next boot");
262 }
263}
264
265fn writer_loop(conn: Connection, mut rx: mpsc::Receiver<Trace>, spill: Option<SpillHandle>) {
272 let mut head = match current_head(&conn) {
273 Ok(h) => h,
274 Err(err) => {
275 tracing::error!(%err, "trace writer: failed to load chain head, stopping");
276 return;
277 }
278 };
279
280 if let Some(ref s) = spill {
282 drain_spill(&conn, s, &mut head);
283 }
284
285 loop {
286 let Some(mut trace) = rx.blocking_recv() else {
287 break;
288 };
289 write_trace(&conn, &mut trace, &mut head);
290
291 loop {
294 match rx.try_recv() {
295 Ok(mut t) => write_trace(&conn, &mut t, &mut head),
296 Err(mpsc::error::TryRecvError::Empty) => {
297 if let Some(ref s) = spill {
298 drain_spill(&conn, s, &mut head);
299 }
300 break;
301 }
302 Err(mpsc::error::TryRecvError::Disconnected) => return,
303 }
304 }
305 }
306
307 if let Some(ref s) = spill {
309 drain_spill(&conn, s, &mut head);
310 }
311}
312
313fn current_head(conn: &Connection) -> Result<String, StoreError> {
314 let mut stmt = conn.prepare("SELECT hash FROM traces ORDER BY seq DESC LIMIT 1")?;
315 let mut rows = stmt.query([])?;
316 match rows.next()? {
317 Some(row) => Ok(row.get(0)?),
318 None => Ok(GENESIS_HASH.to_owned()),
319 }
320}
321
322fn insert(conn: &Connection, trace: &Trace, hash: &str) -> Result<(), StoreError> {
323 let body = serde_json::to_string(trace)?;
324 conn.execute(
325 "INSERT INTO traces (trace_id, ts, prev_hash, hash, tenant, session, body)
326 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
327 rusqlite::params![
328 trace.trace_id.to_string(),
329 trace.ts.to_string(),
330 trace.prev_hash,
331 hash,
332 trace.tenant_id,
333 trace.session_id,
334 body,
335 ],
336 )?;
337 Ok(())
338}
339
340pub fn load_all_traces(db_path: impl AsRef<Path>) -> Result<Vec<Trace>, StoreError> {
354 let conn = connect(db_path.as_ref())?;
355 let mut stmt = conn.prepare("SELECT body FROM traces ORDER BY seq ASC")?;
356 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
357 let mut traces = Vec::new();
358 for row in rows {
359 traces.push(serde_json::from_str(&row?)?);
360 }
361 Ok(traces)
362}
363
364pub fn load_tenant_traces(
371 db_path: impl AsRef<Path>,
372 tenant: &str,
373) -> Result<Vec<Trace>, StoreError> {
374 let conn = connect(db_path.as_ref())?;
375 let mut stmt = conn.prepare("SELECT body FROM traces WHERE tenant = ?1 ORDER BY seq ASC")?;
376 let rows = stmt.query_map([tenant], |row| row.get::<_, String>(0))?;
377 let mut traces = Vec::new();
378 for row in rows {
379 traces.push(serde_json::from_str(&row?)?);
380 }
381 Ok(traces)
382}
383
384pub fn trace_exists(
392 db_path: impl AsRef<Path>,
393 tenant: &str,
394 trace_id: &str,
395) -> Result<bool, StoreError> {
396 let conn = connect(db_path.as_ref())?;
397 let n: i64 = conn.query_row(
398 "SELECT COUNT(1) FROM traces WHERE tenant = ?1 AND trace_id = ?2",
399 [tenant, trace_id],
400 |row| row.get(0),
401 )?;
402 Ok(n > 0)
403}
404
405pub fn trace_route(
414 db_path: impl AsRef<Path>,
415 tenant: &str,
416 trace_id: &str,
417) -> Result<Option<Option<u32>>, StoreError> {
418 let conn = connect(db_path.as_ref())?;
419 let body: Option<String> = conn
420 .query_row(
421 "SELECT body FROM traces WHERE tenant = ?1 AND trace_id = ?2",
422 [tenant, trace_id],
423 |row| row.get(0),
424 )
425 .optional()?;
426 Ok(body.map(|b: String| {
427 serde_json::from_str::<firstpass_core::Trace>(&b)
428 .ok()
429 .and_then(|t| t.route_ix)
430 }))
431}
432
433pub fn append_deferred(
440 db_path: impl AsRef<Path>,
441 trace_id: &str,
442 v: &DeferredVerdict,
443) -> Result<(), StoreError> {
444 let conn = connect(db_path.as_ref())?;
445 conn.execute(
446 "INSERT INTO deferred_verdicts (trace_id, gate_id, verdict, score, reported_at, reporter)
447 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
448 rusqlite::params![
449 trace_id,
450 v.gate_id,
451 v.verdict.as_str(),
452 v.score.map(Score::value),
453 v.reported_at.to_string(),
454 v.reporter,
455 ],
456 )?;
457 Ok(())
458}
459
460pub fn load_deferred(
466 db_path: impl AsRef<Path>,
467 trace_id: &str,
468) -> Result<Vec<DeferredVerdict>, StoreError> {
469 let conn = connect(db_path.as_ref())?;
470 let mut stmt = conn.prepare(
471 "SELECT gate_id, verdict, score, reported_at, reporter
472 FROM deferred_verdicts WHERE trace_id = ?1 ORDER BY id ASC",
473 )?;
474 let rows = stmt.query_map([trace_id], |row| {
475 Ok((
476 row.get::<_, String>(0)?,
477 row.get::<_, String>(1)?,
478 row.get::<_, Option<f64>>(2)?,
479 row.get::<_, String>(3)?,
480 row.get::<_, String>(4)?,
481 ))
482 })?;
483
484 let mut out = Vec::new();
485 for row in rows {
486 let (gate_id, verdict_s, score, reported_s, reporter) = row?;
487 let verdict = match verdict_s.as_str() {
488 "pass" => Verdict::Pass,
489 "fail" => Verdict::Fail,
490 "abstain" => Verdict::Abstain,
491 other => {
492 tracing::warn!(verdict = %other, %trace_id, "skipping deferred row with bad verdict");
493 continue;
494 }
495 };
496 let Ok(reported_at) = reported_s.parse::<jiff::Timestamp>() else {
497 tracing::warn!(%trace_id, "skipping deferred row with bad timestamp");
498 continue;
499 };
500 out.push(DeferredVerdict {
501 gate_id,
502 verdict,
503 score: score.and_then(|s| Score::new(s).ok()),
504 reported_at,
505 reporter,
506 });
507 }
508 Ok(out)
509}
510
511pub fn load_trace_view(
521 db_path: impl AsRef<Path>,
522 tenant: &str,
523 trace_id: &str,
524) -> Result<Option<Trace>, StoreError> {
525 let conn = connect(db_path.as_ref())?;
526 let body: Option<String> = conn
527 .query_row(
528 "SELECT body FROM traces WHERE tenant = ?1 AND trace_id = ?2",
529 [tenant, trace_id],
530 |row| row.get(0),
531 )
532 .ok();
533 let Some(body) = body else { return Ok(None) };
534 let mut trace: Trace = serde_json::from_str(&body)?;
535 trace.deferred = load_deferred(db_path, trace_id)?;
536 Ok(Some(trace))
537}
538
539#[cfg(test)]
540mod tests {
541 use firstpass_core::{
542 Attempt, Features, FinalOutcome, GENESIS_HASH, PolicyRef, RequestInfo, ServedFrom,
543 TaskKind, Verdict, verify_chain,
544 };
545
546 use super::*;
547
548 fn sample_trace(tenant: &str, session: &str) -> Trace {
549 let attempt = Attempt {
550 rung: 0,
551 model: "claude-haiku-4-5".to_owned(),
552 provider: "anthropic".to_owned(),
553 in_tokens: 10,
554 out_tokens: 5,
555 cost_usd: 0.001,
556 latency_ms: 12,
557 gates: vec![],
558 verdict: Verdict::Pass,
559 };
560 let mut trace = Trace {
561 trace_id: uuid::Uuid::now_v7(),
562 prev_hash: GENESIS_HASH.to_owned(),
563 tenant_id: tenant.to_owned(),
564 session_id: session.to_owned(),
565 ts: jiff::Timestamp::now(),
566 mode: firstpass_core::Mode::Observe,
567 policy: PolicyRef {
568 id: "observe-passthrough@v0".to_owned(),
569 explore: false,
570 propensity: None,
571 mode_profile: None,
572 },
573 request: RequestInfo {
574 api: "anthropic.messages".to_owned(),
575 prompt_hash: "deadbeef".to_owned(),
576 features: Features::new(TaskKind::Other),
577 },
578 attempts: vec![attempt],
579 deferred: Vec::new(),
580 final_: FinalOutcome {
581 served_rung: Some(0),
582 served_from: ServedFrom::Attempt,
583 total_cost_usd: 0.001,
584 gate_cost_usd: 0.0,
585 total_latency_ms: 12,
586 escalations: 0,
587 counterfactual_baseline_usd: 0.001,
588 savings_usd: 0.0,
589 },
590 probe: None,
591 rollout: None,
592 shadow: None,
593 route_ix: None,
594 predicted_pass: None,
595 elastic: None,
596 };
597 trace.recompute_savings();
598 trace
599 }
600
601 #[tokio::test]
602 async fn writer_assigns_prev_hash_and_forms_a_valid_chain() {
603 let db_path =
604 std::env::temp_dir().join(format!("firstpass-store-test-{}.db", uuid::Uuid::now_v7()));
605 let (tx, handle) = open(&db_path).unwrap();
606
607 tx.try_send(sample_trace("tenant-a", "session-1")).unwrap();
608 tx.try_send(sample_trace("tenant-a", "session-1")).unwrap();
609 drop(tx);
610 handle.await.unwrap();
611
612 let traces = load_all_traces(&db_path).unwrap();
613 assert_eq!(traces.len(), 2);
614 assert_eq!(traces[0].prev_hash, GENESIS_HASH);
615 assert_eq!(traces[1].prev_hash, traces[0].hash().unwrap());
616 verify_chain(&traces, GENESIS_HASH).unwrap();
617
618 let _ = std::fs::remove_file(&db_path);
619 }
620
621 #[tokio::test]
625 async fn tenant_scoped_reads_never_cross_the_boundary() {
626 let db_path =
627 std::env::temp_dir().join(format!("firstpass-isolation-{}.db", uuid::Uuid::now_v7()));
628 let (tx, handle) = open(&db_path).unwrap();
629
630 let a0 = sample_trace("tenant-a", "sa-0");
632 let a1 = sample_trace("tenant-a", "sa-1");
633 let b0 = sample_trace("tenant-b", "sb-0");
634 let (a0_id, a1_id, b0_id) = (
635 a0.trace_id.to_string(),
636 a1.trace_id.to_string(),
637 b0.trace_id.to_string(),
638 );
639 tx.try_send(a0).unwrap();
640 tx.try_send(b0).unwrap();
641 tx.try_send(a1).unwrap();
642 drop(tx);
643 handle.await.unwrap();
644
645 let a_traces = load_tenant_traces(&db_path, "tenant-a").unwrap();
647 assert_eq!(a_traces.len(), 2, "A must see only A's traces");
648 assert!(a_traces.iter().all(|t| t.tenant_id == "tenant-a"));
649 let b_traces = load_tenant_traces(&db_path, "tenant-b").unwrap();
650 assert_eq!(b_traces.len(), 1, "B must see only B's trace");
651 assert!(b_traces.iter().all(|t| t.tenant_id == "tenant-b"));
652
653 assert!(trace_exists(&db_path, "tenant-a", &a0_id).unwrap());
655 assert!(!trace_exists(&db_path, "tenant-a", &b0_id).unwrap());
656 assert!(trace_exists(&db_path, "tenant-b", &b0_id).unwrap());
657 assert!(!trace_exists(&db_path, "tenant-b", &a1_id).unwrap());
658
659 assert!(
661 load_trace_view(&db_path, "tenant-a", &a0_id)
662 .unwrap()
663 .is_some()
664 );
665 assert!(
666 load_trace_view(&db_path, "tenant-a", &b0_id)
667 .unwrap()
668 .is_none()
669 );
670 assert!(
671 load_trace_view(&db_path, "tenant-b", &a0_id)
672 .unwrap()
673 .is_none()
674 );
675
676 assert!(load_tenant_traces(&db_path, "ghost").unwrap().is_empty());
678
679 let all = load_all_traces(&db_path).unwrap();
681 assert_eq!(all.len(), 3);
682 verify_chain(&all, GENESIS_HASH).unwrap();
683
684 let _ = std::fs::remove_file(&db_path);
685 }
686
687 #[tokio::test]
688 async fn deferred_verdicts_attach_on_read_without_breaking_the_chain() {
689 let db_path = std::env::temp_dir().join(format!(
690 "firstpass-deferred-test-{}.db",
691 uuid::Uuid::now_v7()
692 ));
693 let (tx, handle) = open(&db_path).unwrap();
694 let t0 = sample_trace("acme", "run-1");
695 let t1 = sample_trace("acme", "run-1");
696 let (id0, id1) = (t0.trace_id.to_string(), t1.trace_id.to_string());
697 tx.try_send(t0).unwrap();
698 tx.try_send(t1).unwrap();
699 drop(tx);
700 handle.await.unwrap();
701
702 let dv = DeferredVerdict {
704 gate_id: "tests".to_owned(),
705 verdict: Verdict::Pass,
706 score: Some(Score::new(1.0).unwrap()),
707 reported_at: jiff::Timestamp::now(),
708 reporter: "ci".to_owned(),
709 };
710 append_deferred(&db_path, &id0, &dv).unwrap();
711 assert!(trace_exists(&db_path, "acme", &id0).unwrap());
712 assert!(!trace_exists(&db_path, "acme", "no-such-trace").unwrap());
713 assert!(!trace_exists(&db_path, "other-tenant", &id0).unwrap());
715
716 let view = load_trace_view(&db_path, "acme", &id0).unwrap().unwrap();
718 assert_eq!(view.deferred.len(), 1);
719 assert_eq!(view.deferred[0].gate_id, "tests");
720 assert_eq!(view.deferred[0].verdict, Verdict::Pass);
721 assert!(
723 load_trace_view(&db_path, "acme", &id1)
724 .unwrap()
725 .unwrap()
726 .deferred
727 .is_empty()
728 );
729 assert!(
731 load_trace_view(&db_path, "other-tenant", &id0)
732 .unwrap()
733 .is_none()
734 );
735
736 let traces = load_all_traces(&db_path).unwrap();
739 assert!(
740 traces.iter().all(|t| t.deferred.is_empty()),
741 "sealed records stay deferred-free"
742 );
743 verify_chain(&traces, GENESIS_HASH).unwrap();
744
745 let _ = std::fs::remove_file(&db_path);
746 }
747
748 #[test]
752 fn durable_append_to_spill_writes_valid_json_line() {
753 let db_path =
754 std::env::temp_dir().join(format!("firstpass-spill-write-{}.db", uuid::Uuid::now_v7()));
755 let spill_p = spill_path(&db_path);
756 let file = std::fs::OpenOptions::new()
757 .create(true)
758 .append(true)
759 .read(true)
760 .open(&spill_p)
761 .unwrap();
762 let handle = Arc::new(std::sync::Mutex::new(file));
763
764 let t = sample_trace("t", "s");
765 append_to_spill(&handle, &t).unwrap();
766
767 let content = std::fs::read_to_string(&spill_p).unwrap();
768 let lines: Vec<&str> = content.lines().collect();
769 assert_eq!(lines.len(), 1, "one line per spilled trace");
770 let parsed: Trace = serde_json::from_str(lines[0]).unwrap();
771 assert_eq!(parsed.trace_id, t.trace_id);
772
773 let _ = std::fs::remove_file(&spill_p);
774 }
775
776 #[tokio::test]
779 async fn drain_on_boot_restores_spilled_traces_and_chain_is_valid() {
780 let db_path =
781 std::env::temp_dir().join(format!("firstpass-drain-boot-{}.db", uuid::Uuid::now_v7()));
782 let spill_p = spill_path(&db_path);
783
784 let t1 = sample_trace("t", "s1");
786 let t2 = sample_trace("t", "s2");
787 {
788 let file = std::fs::OpenOptions::new()
789 .create(true)
790 .append(true)
791 .read(true)
792 .open(&spill_p)
793 .unwrap();
794 let h = Arc::new(std::sync::Mutex::new(file));
795 append_to_spill(&h, &t1).unwrap();
796 append_to_spill(&h, &t2).unwrap();
797 }
798
799 let (tx, _spill, handle) = open_with_receipts(&db_path, ReceiptsMode::Durable).unwrap();
801 drop(tx); handle.await.unwrap();
803
804 let traces = load_all_traces(&db_path).unwrap();
805 assert_eq!(traces.len(), 2, "both spilled traces recovered");
806 verify_chain(&traces, GENESIS_HASH).unwrap();
807
808 let remaining = std::fs::read_to_string(&spill_p).unwrap();
810 assert!(
811 remaining.trim().is_empty(),
812 "spill file must be empty after drain"
813 );
814
815 let _ = std::fs::remove_file(&db_path);
816 let _ = std::fs::remove_file(&spill_p);
817 }
818
819 #[tokio::test]
822 async fn spilled_traces_land_before_later_channel_traces() {
823 let db_path =
824 std::env::temp_dir().join(format!("firstpass-spill-order-{}.db", uuid::Uuid::now_v7()));
825 let spill_p = spill_path(&db_path);
826
827 let spilled = sample_trace("t", "spilled");
829 {
830 let file = std::fs::OpenOptions::new()
831 .create(true)
832 .append(true)
833 .read(true)
834 .open(&spill_p)
835 .unwrap();
836 let h = Arc::new(std::sync::Mutex::new(file));
837 append_to_spill(&h, &spilled).unwrap();
838 }
839
840 let (tx, _spill, handle) = open_with_receipts(&db_path, ReceiptsMode::Durable).unwrap();
842 let channel_trace = sample_trace("t", "channel");
844 tx.try_send(channel_trace.clone()).unwrap();
845 drop(tx);
846 handle.await.unwrap();
847
848 let traces = load_all_traces(&db_path).unwrap();
849 assert_eq!(traces.len(), 2, "spilled + channel trace");
850 assert_eq!(
852 traces[0].trace_id, spilled.trace_id,
853 "spilled trace must precede channel trace"
854 );
855 assert_eq!(
856 traces[1].trace_id, channel_trace.trace_id,
857 "channel trace must follow spilled trace"
858 );
859 verify_chain(&traces, GENESIS_HASH).unwrap();
860
861 let _ = std::fs::remove_file(&db_path);
862 let _ = std::fs::remove_file(&spill_p);
863 }
864}