1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
//! 0.8.20 Slice 5 fix-2 (codex ยง9 [P2]) โ the shared `erase_source` /
//! `excise_source` path must DRAIN BEFORE IT FREEZES.
//!
//! **The defect.** `erase_source_shared` froze the projection scanner and only
//! then called `drain`. `drain` โ `wait_for_idle` consults
//! `database_has_pending_projection_work`, which reads the DATABASE, not the
//! in-memory queue: a row written moments earlier is "pending" until a worker
//! projects it. But the dispatcher loop parks while `state.frozen` is set, so it
//! can never scan and enqueue that row. The result is a deadlock-by-construction
//! that only ends when `LIFECYCLE_DRAIN_TIMEOUT_MS` (30s) elapses, after which
//! the caller gets `EngineError::Scheduler` โ for the ordinary first-use
//! sequence "write a vector-indexed row, then erase it".
//!
//! `purge` already carries the correct ordering (settle every pending
//! projection UNFROZEN first, then freeze, then confirm idle, then erase) with a
//! comment naming this exact failure mode. This file pins that the shared source
//! erasure path has it too.
//!
//! **Why the fixture strands work deterministically.** `GatedEmbedder::embed`
//! blocks until the test releases it, so no projection job can complete before
//! the erasure call. The dispatcher can therefore enqueue at most
//! `PROJECTION_INFLIGHT_LIMIT` (2 workers ร 16 commit batch = 32) rows before it
//! parks on the in-flight budget; the fixture writes far more than that, so a
//! large remainder is provably still UNSCANNED at the instant `erase_source` is
//! entered. On the broken ordering that remainder is unreachable forever. A
//! fixture that wrote only a row or two would race the dispatcher and could pass
//! on the broken code, which is worthless.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use fathomdb_embedder_api::{Embedder, EmbedderError, EmbedderIdentity, Vector};
use fathomdb_engine::{Engine, EngineError, PreparedWrite};
use fathomdb_schema::SQLITE_SUFFIX;
use tempfile::TempDir;
/// More than `PROJECTION_INFLIGHT_LIMIT` (32) so the dispatcher cannot have
/// enqueued the whole batch by the time the erasure freezes the scanner.
const SEEDED_ROWS: usize = 96;
/// How long the fixture holds every `embed()` call before releasing it. Long
/// enough that the erasure verb is unambiguously entered first; short enough
/// that the GREEN path stays well under a second of blocking.
const GATE_HOLD: Duration = Duration::from_millis(150);
/// The erasure must finish far faster than the 30s lifecycle drain timeout the
/// broken ordering waits out. This budget is the test's real signal.
const PROMPT_BUDGET: Duration = Duration::from_secs(15);
/// An embedder whose `embed()` parks until the test opens the gate. Holding the
/// workers guarantees a backlog of un-enqueued projection work.
#[derive(Debug)]
struct GatedEmbedder {
identity: EmbedderIdentity,
vector: Vector,
open: Mutex<bool>,
cvar: Condvar,
/// Set once any embed actually ran, so the test can prove the fixture was
/// exercised rather than silently no-op.
embedded: AtomicBool,
}
impl GatedEmbedder {
fn new(dim: u32) -> Self {
let mut vector = vec![0.0_f32; dim as usize];
vector[0] = 1.0;
Self {
identity: EmbedderIdentity::new("drain-order-test", "rev-a", dim),
vector,
open: Mutex::new(false),
cvar: Condvar::new(),
embedded: AtomicBool::new(false),
}
}
fn release(&self) {
if let Ok(mut open) = self.open.lock() {
*open = true;
self.cvar.notify_all();
}
}
}
impl Embedder for GatedEmbedder {
fn identity(&self) -> EmbedderIdentity {
self.identity.clone()
}
fn embed(&self, _text: &str) -> Result<Vector, EmbedderError> {
let mut open = self
.open
.lock()
.map_err(|_| EmbedderError::Failed { message: "gate poisoned".to_string() })?;
while !*open {
open = self
.cvar
.wait(open)
.map_err(|_| EmbedderError::Failed { message: "gate poisoned".to_string() })?;
}
self.embedded.store(true, Ordering::SeqCst);
Ok(self.vector.clone())
}
}
fn seed_rows(engine: &Engine, source_id: &str) {
let writes: Vec<PreparedWrite> = (0..SEEDED_ROWS)
.map(|i| PreparedWrite::Node {
kind: "doc".to_string(),
body: format!("drain ordering body {i}"),
source_id: fathomdb_engine::SourceId::new(source_id).expect("test source id"),
logical_id: None,
state: fathomdb_engine::InitialState::Active,
reason: None,
valid_from: None,
valid_until: None,
})
.collect();
engine.write(&writes).expect("write");
}
/// codex ยง9 [P2] โ `erase_source` immediately after writing vector-indexed rows
/// must succeed promptly, not stall for the full lifecycle drain timeout and
/// return `EngineError::Scheduler`.
#[test]
fn erase_source_drains_before_freezing() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join(format!("erase_drain_order{SQLITE_SUFFIX}"));
let embedder = Arc::new(GatedEmbedder::new(8));
let opened =
Engine::open_with_embedder_for_test(&path, embedder.clone()).expect("open with embedder");
opened.engine.configure_vector_kind_for_test("doc").expect("vector kind");
seed_rows(&opened.engine, "S1");
// Open the gate shortly AFTER the erasure verb has been entered, so the
// verb's own ordering decides whether the backlog can still be projected.
let releaser = {
let embedder = embedder.clone();
std::thread::spawn(move || {
std::thread::sleep(GATE_HOLD);
embedder.release();
})
};
let started = Instant::now();
let result = opened.engine.erase_source("S1");
let elapsed = started.elapsed();
releaser.join().expect("releaser thread");
assert!(
!matches!(result, Err(EngineError::Scheduler)),
"erase_source froze the projection scanner before draining, so the dispatcher \
could never enqueue the {SEEDED_ROWS} just-written rows and drain timed out \
into Scheduler after {elapsed:?}"
);
let report = result.expect("erase_source must succeed");
assert!(
report.nodes_excised > 0,
"fixture: the erasure must actually have removed the seeded rows, got {report:?}"
);
assert!(
elapsed < PROMPT_BUDGET,
"erase_source must complete promptly, not wait out the lifecycle drain timeout; \
took {elapsed:?}"
);
assert!(
embedder.embedded.load(Ordering::SeqCst),
"fixture sanity: the gated embedder must have been exercised, otherwise the test \
never created the backlog it claims to"
);
opened.engine.close().expect("close");
}
/// The `operator` spelling shares one implementation with `erase_source`, so it
/// inherits the same ordering. Pinned separately because the two verbs are
/// separately reachable and a future refactor could split the path.
#[cfg(feature = "operator")]
#[test]
fn excise_source_drains_before_freezing() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join(format!("excise_drain_order{SQLITE_SUFFIX}"));
let embedder = Arc::new(GatedEmbedder::new(8));
let opened =
Engine::open_with_embedder_for_test(&path, embedder.clone()).expect("open with embedder");
opened.engine.configure_vector_kind_for_test("doc").expect("vector kind");
seed_rows(&opened.engine, "S1");
let releaser = {
let embedder = embedder.clone();
std::thread::spawn(move || {
std::thread::sleep(GATE_HOLD);
embedder.release();
})
};
let started = Instant::now();
let result = opened.engine.excise_source("S1");
let elapsed = started.elapsed();
releaser.join().expect("releaser thread");
assert!(
!matches!(result, Err(EngineError::Scheduler)),
"excise_source froze the projection scanner before draining; drain timed out into \
Scheduler after {elapsed:?}"
);
result.expect("excise_source must succeed");
assert!(elapsed < PROMPT_BUDGET, "excise_source must complete promptly; took {elapsed:?}");
opened.engine.close().expect("close");
}