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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
//! Pending edits buffer for incremental index updates.
//!
//! `PendingEdits` accumulates file changes and deletions between commits.
//! Nothing here is visible to queries until `commit_batch()` drains this buffer.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use roaring::RoaringBitmap;
use super::overlay::EditKind;
/// Thread-safe buffer for pending edits. Invisible to queries until committed.
pub struct PendingEdits {
inner: Mutex<PendingState>,
}
/// Edits buffered since the last `commit_batch()`.
struct PendingState {
/// Edits buffered since the last `commit_batch()`.
uncommitted: Vec<super::overlay::FileEdit>,
}
impl Default for PendingEdits {
fn default() -> Self {
Self::new()
}
}
impl PendingEdits {
/// Create a new, empty pending edits collector.
pub fn new() -> Self {
PendingEdits {
inner: Mutex::new(PendingState {
uncommitted: Vec::new(),
}),
}
}
/// Buffer a file change. NOT visible to queries until `commit_batch()`.
/// Only records the path; file content is read at commit time.
///
/// # Poison recovery
/// Recovery via `into_inner` is safe here: the only operation under the lock
/// is `Vec::push`, which cannot panic except on OOM (which aborts, not
/// unwinds, under the default allocator). The same reasoning applies to
/// `notify_delete`, `reset`, `has_uncommitted`, and `uncommitted_count`.
///
/// `take_for_commit` is theoretically riskier: it uses `std::mem::take`
/// (a pointer swap, cannot fail) and then rebuilds changed/deleted sets via
/// `HashSet::insert`. If unwinding occurred mid-rebuild (e.g. a custom
/// allocator that unwinds on OOM during `HashSet::insert`), the drained
/// edits in the local `drained` Vec would be dropped, losing them. In
/// practice this cannot happen with the default global allocator.
///
/// Contrast with `SymbolIndex` (symbol/mod.rs), which deliberately does NOT
/// recover from poison because its `rusqlite::Connection` may hold open
/// transactions or inconsistent prepared-statement cache state.
pub fn notify_change(&self, path: &Path) {
let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
state.uncommitted.push(super::overlay::FileEdit {
path: path.to_path_buf(),
kind: EditKind::Changed,
});
}
/// Buffer a file deletion. NOT visible to queries until `commit_batch()`.
pub fn notify_delete(&self, path: &Path) {
let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
state.uncommitted.push(super::overlay::FileEdit {
path: path.to_path_buf(),
kind: EditKind::Deleted,
});
}
/// Drain uncommitted edits and return a summary for the commit, capturing
/// the raw edits in [`TakeResult::drained`] for re-queueing on failure.
///
/// `newly_changed`/`newly_deleted` are paths touched since the last
/// `commit_batch()`. A file changed then deleted in the same batch counts
/// as deleted only.
///
/// `drained` holds the exact raw edits in original insertion order. If
/// `commit_batch` fails after this call, the caller MUST pass `drained`
/// back to [`PendingEdits::requeue_uncommitted`] so the edits are not
/// silently lost (staying stale until a full rebuild). On success, the
/// caller drops `drained`.
pub fn take_for_commit(&self) -> TakeResult {
let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
// Snapshot the raw edits before clearing the buffer. Re-queueing these
// on failure preserves the caller's edit stream verbatim, which is
// simpler and safer than re-deriving changed/deleted sets from a
// partially-applied commit.
let drained = std::mem::take(&mut state.uncommitted);
// Deduplicate uncommitted into changed/deleted sets.
// A file changed then deleted counts as deleted only.
let mut newly_changed: HashSet<PathBuf> = HashSet::new();
let mut newly_deleted: HashSet<PathBuf> = HashSet::new();
for edit in &drained {
match edit.kind {
EditKind::Changed => {
newly_deleted.remove(&edit.path);
newly_changed.insert(edit.path.clone());
}
EditKind::Deleted => {
newly_changed.remove(&edit.path);
newly_deleted.insert(edit.path.clone());
}
}
}
TakeResult {
newly_changed,
newly_deleted,
drained,
}
}
/// Re-queue edits drained by [`take_for_commit`] when the commit fails.
///
/// Prepends `edits` to the front of the uncommitted buffer so that any
/// edits buffered concurrently with the failed commit are applied after
/// them (preserving the original arrival order relative to each other).
/// Idempotent if `edits` is empty.
///
/// Deduplicates to at most one edit per path (last occurrence wins, matching
/// [`take_for_commit`]'s last-wins fold). Without this, repeated commit
/// failures (e.g. a persistent `OverlayFull` in a watcher that keeps calling
/// `notify_change`) re-prepend the same drained edits every cycle while new
/// notifies append, growing the buffer with duplicate `PathBuf`s unboundedly
/// until a full rebuild. The commit result is already correct either way
/// (`take_for_commit` folds into sets); this bounds memory.
pub fn requeue_uncommitted(&self, edits: Vec<super::overlay::FileEdit>) {
if edits.is_empty() {
return;
}
let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
// Splice the requeued edits before any edits that arrived during the
// failed commit: `edits` were observed first and must stay first.
let mut combined = edits;
combined.append(&mut state.uncommitted);
// Keep only the last edit per path. Walk in reverse so the last
// occurrence is retained, then restore forward order. Order among
// distinct paths does not affect correctness (take_for_commit builds
// sets), only which kind wins per path, which last-occurrence preserves.
let mut seen: HashSet<PathBuf> = HashSet::with_capacity(combined.len());
let mut deduped: Vec<super::overlay::FileEdit> = Vec::with_capacity(combined.len());
for edit in combined.into_iter().rev() {
if seen.insert(edit.path.clone()) {
deduped.push(edit);
}
}
deduped.reverse();
state.uncommitted = deduped;
}
/// Clear all accumulated state. Call after a full index rebuild.
pub fn reset(&self) {
let mut state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
state.uncommitted.clear();
}
/// Whether there are uncommitted edits pending.
pub fn has_uncommitted(&self) -> bool {
let state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
!state.uncommitted.is_empty()
}
/// Number of uncommitted edits.
pub fn uncommitted_count(&self) -> usize {
let state = self.inner.lock().unwrap_or_else(|p| p.into_inner());
state.uncommitted.len()
}
}
/// Result of draining uncommitted edits from `PendingEdits`.
pub struct TakeResult {
/// Paths changed since the last `commit_batch()`.
pub newly_changed: HashSet<PathBuf>,
/// Paths deleted since the last `commit_batch()`.
pub newly_deleted: HashSet<PathBuf>,
/// Raw drained edits (in arrival order). Pass back to
/// [`PendingEdits::requeue_uncommitted`] if the commit fails so the edits
/// are not silently lost. Drop on success.
pub drained: Vec<super::overlay::FileEdit>,
}
/// Compute the delete_set: base doc_ids that are invalidated by overlay
/// changes (modified or deleted files).
///
/// # Precondition: `modified_paths` and `deleted_paths` must be disjoint
///
/// `take_for_commit` guarantees mutual exclusivity: a path changed then
/// deleted in the same batch is placed in `deleted_paths` only. If a caller
/// passes overlapping sets, `RoaringBitmap::insert` is idempotent so the result
/// is still correct, but it indicates a bug in the caller's logic.
///
/// Starts from `prev` (the previous snapshot's delete_set) and adds entries
/// for the current delta only. The base is immutable between full builds, so
/// the delete_set is monotonically growing and this is always correct.
pub fn compute_delete_set(
base_path_doc_ids: &HashMap<PathBuf, Vec<u32>>,
modified_paths: &HashSet<PathBuf>,
deleted_paths: &HashSet<PathBuf>,
prev: &RoaringBitmap,
) -> RoaringBitmap {
let mut delete_set = prev.clone();
for path in modified_paths.iter().chain(deleted_paths.iter()) {
if let Some(doc_ids) = base_path_doc_ids.get(path) {
for &doc_id in doc_ids {
delete_set.insert(doc_id);
}
}
}
delete_set
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reset_clears_uncommitted() {
let pe = PendingEdits::new();
pe.notify_change(Path::new("a.rs"));
pe.notify_change(Path::new("b.rs"));
pe.reset();
assert_eq!(pe.uncommitted_count(), 0, "reset() must clear uncommitted");
}
#[test]
fn take_for_commit_after_reset_returns_empty() {
let pe = PendingEdits::new();
pe.notify_change(Path::new("a.rs"));
pe.reset();
let result = pe.take_for_commit();
assert!(result.newly_changed.is_empty());
assert!(result.newly_deleted.is_empty());
}
#[test]
fn requeue_dedups_and_stays_bounded() {
let pe = PendingEdits::new();
// Simulate repeated commit failures: drain, then requeue the same edits
// while a new notify arrives each cycle.
pe.notify_change(Path::new("a.rs"));
for _ in 0..100 {
let taken = pe.take_for_commit();
pe.notify_change(Path::new("b.rs")); // concurrent arrival
pe.requeue_uncommitted(taken.drained);
}
// Without dedup this would be ~100+ entries; bounded to one per path.
assert!(
pe.uncommitted_count() <= 2,
"requeue must dedup per path, got {}",
pe.uncommitted_count()
);
}
#[test]
fn requeue_last_kind_wins_per_path() {
use super::super::overlay::{EditKind, FileEdit};
let pe = PendingEdits::new();
// Requeue changed-then-deleted for the same path; deleted must win.
pe.requeue_uncommitted(vec![
FileEdit {
path: PathBuf::from("x.rs"),
kind: EditKind::Changed,
},
FileEdit {
path: PathBuf::from("x.rs"),
kind: EditKind::Deleted,
},
]);
assert_eq!(pe.uncommitted_count(), 1);
let taken = pe.take_for_commit();
assert!(taken.newly_deleted.contains(&PathBuf::from("x.rs")));
assert!(taken.newly_changed.is_empty());
}
#[test]
fn compute_delete_set_is_incremental() {
let mut base: HashMap<PathBuf, Vec<u32>> = HashMap::new();
base.insert(PathBuf::from("a.rs"), vec![1]);
base.insert(PathBuf::from("b.rs"), vec![2]);
base.insert(PathBuf::from("c.rs"), vec![3]);
// First commit: only a.rs changed.
let prev = RoaringBitmap::new();
let changed: HashSet<PathBuf> = [PathBuf::from("a.rs")].into();
let deleted: HashSet<PathBuf> = HashSet::new();
let ds1 = compute_delete_set(&base, &changed, &deleted, &prev);
assert!(ds1.contains(1));
assert!(!ds1.contains(2));
// Second commit: b.rs deleted. Previous delete_set carried forward.
let changed2: HashSet<PathBuf> = HashSet::new();
let deleted2: HashSet<PathBuf> = [PathBuf::from("b.rs")].into();
let ds2 = compute_delete_set(&base, &changed2, &deleted2, &ds1);
assert!(ds2.contains(1), "a.rs entry must persist");
assert!(ds2.contains(2), "b.rs entry must be added");
assert!(!ds2.contains(3));
}
}