genegraph_storage/generations.rs
1//! # Transactional generations (genegraph-storage #93, RFC #81 phase P5).
2//!
3//! Artifact generations are **immutable**: every publish (build, append,
4//! recompute) mints a fresh generation `{logical}__g{N}` — its artifacts are
5//! written once and never overwritten — and commits by atomically publishing
6//! the generation's metadata JSON (the single commit pointer, mirroring the
7//! snapshot/consensus-index pattern of segmented logs).
8//!
9//! Invariants:
10//! - A generation without a metadata file **was never committed**: readers
11//! and discovery ignore it; the sweep API sees it for garbage collection.
12//! - The commit itself is a single atomic filesystem operation
13//! ([`write_json_atomic`]): readers observe either the previous file or
14//! the complete new one, never a partial write.
15//! - Reader isolation (#97): pin acquisition (validation + registration) and
16//! the sweep's check-to-retirement transition are serialized by a
17//! per-generation state lock — a sweep never removes artifacts under a
18//! registered reader, and a pin never registers for a retired generation.
19//! - `__g{digits}` at the end of an instance name is reserved.
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex as StdMutex, OnceLock, Weak};
24
25use crate::{StorageError, StorageResult};
26
27/// Separator between a logical dataset name and its generation number.
28pub const GENERATION_SEP: &str = "__g";
29
30/// Logical dataset name: strips a trailing `__g{digits}` generation suffix.
31pub fn logical_name(name: &str) -> &str {
32 if let Some(pos) = name.rfind(GENERATION_SEP) {
33 let suffix = &name[pos + GENERATION_SEP.len()..];
34 if !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit()) {
35 return &name[..pos];
36 }
37 }
38 name
39}
40
41/// Generation-qualified instance name: `{logical}__g{gen}`.
42pub fn generation_name(logical: &str, generation: u64) -> String {
43 format!("{logical}{GENERATION_SEP}{generation}")
44}
45
46/// Parse the generation number out of a gen-qualified instance name.
47/// Returns `None` for plain logical names or malformed suffixes.
48///
49/// Note (#95): digit suffixes above `u64::MAX` parse to `None` here; the
50/// scanners combine this with a `unwrap_or(u64::MAX)` fallback so such names
51/// sort last consistently instead of crashing discovery.
52pub fn parse_generation(name: &str) -> Option<u64> {
53 let pos = name.rfind(GENERATION_SEP)?;
54 let suffix = &name[pos + GENERATION_SEP.len()..];
55 if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
56 return None;
57 }
58 suffix.parse().ok()
59}
60
61/// Reject instance names carrying the reserved generation suffix
62/// (`__g{digits}`). Only [`generation_name`] may mint such names; a
63/// user-created logical instance with a reserved suffix would collide with
64/// the generation discovery and sweep machinery (#117-3).
65pub fn validate_logical_name(name: &str) -> StorageResult<()> {
66 if parse_generation(name).is_some() {
67 return Err(StorageError::Invalid(format!(
68 "instance name `{name}` carries the reserved generation suffix \
69 `{GENERATION_SEP}<n>`; generation handles are minted only via \
70 scoped_generation"
71 )));
72 }
73 Ok(())
74}
75
76/// File name of a per-instance artifact dataset: `{instance}_{key}.lance`.
77///
78/// Single source of the on-disk naming convention (#117-3): every producer
79/// (paths, registered `FileInfo` names) builds it here so the two roles
80/// cannot drift apart.
81pub fn artifact_file_name(instance: &str, key: &str) -> String {
82 format!("{instance}_{key}.lance")
83}
84
85/// File name of the per-instance metadata registry: `{instance}_metadata.json`.
86pub fn metadata_file_name(instance: &str) -> String {
87 format!("{instance}_metadata.json")
88}
89
90/// Full artifact dataset path: `{base}/{instance}_{key}.lance`.
91pub fn artifact_file_path(base: &Path, instance: &str, key: &str) -> PathBuf {
92 base.join(artifact_file_name(instance, key))
93}
94
95/// Full metadata registry path: `{base}/{instance}_metadata.json`.
96pub fn metadata_file_path(base: &Path, instance: &str) -> PathBuf {
97 base.join(metadata_file_name(instance))
98}
99
100/// A committed generation: its number and the metadata file that pins it.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct GenerationInfo {
103 pub generation: u64,
104 pub metadata_path: PathBuf,
105}
106
107/// Committed generations of `logical` under `base`, ascending by generation
108/// number. A generation is committed iff its `{logical}__g{N}_metadata.json`
109/// exists; pre-commit crash residue is invisible here.
110pub async fn list_generations(base: &Path, logical: &str) -> StorageResult<Vec<GenerationInfo>> {
111 let mut out = Vec::new();
112 for (generation, metadata_path) in scan_generation_paths(base, logical).await? {
113 if metadata_path.is_file() {
114 out.push(GenerationInfo {
115 generation,
116 metadata_path,
117 });
118 }
119 }
120 out.sort_by_key(|g| g.generation);
121 Ok(out)
122}
123
124/// Generation numbers with **any** artifact present under `base`, committed
125/// or not (orphans from a pre-commit crash included), ascending.
126pub async fn list_artifact_generations(base: &Path, logical: &str) -> StorageResult<Vec<u64>> {
127 let mut gens = std::collections::BTreeSet::new();
128 let mut rd = tokio::fs::read_dir(base)
129 .await
130 .map_err(|e| StorageError::Io(e.to_string()))?;
131 let artifact_prefix = format!("{logical}{GENERATION_SEP}");
132 while let Some(entry) = rd
133 .next_entry()
134 .await
135 .map_err(|e| StorageError::Io(e.to_string()))?
136 {
137 let name = entry.file_name();
138 let name = name.to_string_lossy();
139 let Some(rest) = name.strip_prefix(&artifact_prefix) else {
140 continue;
141 };
142 let Some(num) = rest.split('_').next() else {
143 continue;
144 };
145 if !num.is_empty() && num.bytes().all(|b| b.is_ascii_digit()) {
146 gens.insert(num.parse::<u64>().unwrap_or(u64::MAX));
147 }
148 }
149 Ok(gens.into_iter().collect())
150}
151
152/// Delete every file of a generation — artifacts and, if present, the
153/// metadata commit pointer. Prefix matching is exact on
154/// `{logical}__g{gen}_`, so sibling datasets (`ds` vs `ds2`) are untouched.
155/// Safe to call on orphaned generations (no metadata) and on missing ones.
156///
157/// Reader isolation (#97): the pin check through the *complete removal* runs
158/// inside one per-generation state lock — the retirement transition is
159/// atomic with respect to [`pin_generation`], which validates and registers
160/// under that same lock. A pin attempt that races a sweep either precedes
161/// the sweep's lock acquisition (the sweep then sees the pin and is refused
162/// with [`StorageError::InvalidState`]) or waits for retirement to finish
163/// (and then fails validation, because the commit pointer is gone). A sweep
164/// can therefore never delete underneath a registered reader. Callers retry
165/// a refused sweep once their readers have released.
166pub async fn delete_generation(base: &Path, logical: &str, generation: u64) -> StorageResult<()> {
167 let prefix = format!("{logical}{GENERATION_SEP}{generation}_");
168 let metadata_path = base.join(format!("{prefix}metadata.json"));
169 // Resolve (or mint) the generation's state lock up front, then run the
170 // whole check-to-retirement critical section synchronously under it on
171 // the blocking pool: std locks must not be held across awaits.
172 let state = crate::commit::weak_lookup(
173 &GENERATION_STATES,
174 metadata_path.to_string_lossy().to_string(),
175 Arc::new(StdMutex::new(GenerationState::default())),
176 );
177 sweep_gate(SWEEP_GATE_PRE_LOCK);
178 let base = base.to_path_buf();
179 let logical = logical.to_string();
180 tokio::task::spawn_blocking(move || -> StorageResult<()> {
181 let state = state
182 .lock()
183 .unwrap_or_else(|poisoned| poisoned.into_inner());
184 if state.pins > 0 {
185 return Err(StorageError::InvalidState(format!(
186 "generation {generation} of '{logical}' is pinned by an in-flight reader; \
187 sweep refused until all readers release"
188 )));
189 }
190 // Past the check and still under the lock: no pin can register for
191 // this generation until the removals complete (test hook forces a
192 // pin attempt exactly in this window).
193 sweep_gate(SWEEP_GATE_POST_CHECK);
194 let rd = std::fs::read_dir(&base)
195 .map_err(|e| StorageError::Io(format!("read {base:?}: {e}")))?;
196 let mut removed = Vec::new();
197 for entry in rd {
198 let entry = entry.map_err(|e| StorageError::Io(e.to_string()))?;
199 if !entry.file_name().to_string_lossy().starts_with(&prefix) {
200 continue;
201 }
202 removed.push(entry.path());
203 }
204 for path in removed {
205 if path.is_dir() {
206 std::fs::remove_dir_all(&path)
207 .map_err(|e| StorageError::Io(format!("remove {path:?}: {e}")))?;
208 } else {
209 std::fs::remove_file(&path)
210 .map_err(|e| StorageError::Io(format!("remove {path:?}: {e}")))?;
211 }
212 }
213 Ok(())
214 })
215 .await
216 .map_err(|e| StorageError::Io(format!("generation sweep task failed: {e}")))?
217}
218
219// ---------------------------------------------------------------------------
220// Reader pins (#97)
221// ---------------------------------------------------------------------------
222
223/// Per-generation pin state; the lock guarding the state serializes pin
224/// registration/validation against the sweep's retirement transition.
225#[derive(Debug, Default)]
226struct GenerationState {
227 pins: usize,
228}
229
230/// Weak-valued registry of per-generation state locks (the #98 discipline):
231/// entries live only while a pin or an in-flight sweep holds the `Arc`.
232static GENERATION_STATES: OnceLock<StdMutex<HashMap<String, Weak<StdMutex<GenerationState>>>>> =
233 OnceLock::new();
234
235/// RAII pin on a committed generation (#97).
236///
237/// Acquired through [`pin_generation`]; while alive, it keeps one reference
238/// on the generation's state so a concurrent [`delete_generation`] refuses
239/// to touch it. Dropping the guard releases the reference.
240#[derive(Debug)]
241pub struct GenerationGuard {
242 state: Arc<StdMutex<GenerationState>>,
243}
244
245impl Drop for GenerationGuard {
246 fn drop(&mut self) {
247 let mut state = self
248 .state
249 .lock()
250 .unwrap_or_else(|poisoned| poisoned.into_inner());
251 state.pins -= 1;
252 }
253}
254
255/// Pins a committed generation for the duration of a read (#97).
256///
257/// Registration is refcounted per generation: several readers may pin the
258/// same generation concurrently and all succeed; a sweep is refused while
259/// any pin is alive. Validation (commit pointer exists) and registration
260/// happen atomically with respect to [`delete_generation`]'s retirement
261/// transition — both hold the generation's state lock, so a racing sweep
262/// either observes this pin (and is refused) or has already retired the
263/// generation (and this call fails validation).
264///
265/// Fails with [`StorageError::Invalid`] if the generation's metadata file
266/// (the commit pointer) does not exist — orphaned generations cannot be
267/// pinned. Pin and sweep must address the generation through the same
268/// `base` path: state is keyed by the metadata file path.
269pub fn pin_generation(info: &GenerationInfo) -> StorageResult<GenerationGuard> {
270 let state = crate::commit::weak_lookup(
271 &GENERATION_STATES,
272 info.metadata_path.to_string_lossy().to_string(),
273 Arc::new(StdMutex::new(GenerationState::default())),
274 );
275 {
276 let mut guard = state
277 .lock()
278 .unwrap_or_else(|poisoned| poisoned.into_inner());
279 // Validation under the state lock: a concurrent sweep either
280 // finished its retirement (metadata gone -> rejected here) or waits
281 // for this pin to register (and is then refused).
282 if !info.metadata_path.is_file() {
283 return Err(StorageError::Invalid(format!(
284 "generation {} is not committed (no metadata at {:?}); \
285 only committed generations can be pinned",
286 info.generation, info.metadata_path
287 )));
288 }
289 guard.pins += 1;
290 }
291 Ok(GenerationGuard { state })
292}
293
294/// Atomic JSON publish: write to a unique `{path}.tmp`, fsync, rename over
295/// `path`, then fsync the parent directory (#101).
296///
297/// The rename is the single commit point (POSIX-atomic within a directory):
298/// concurrent readers observe either the previous file or the complete new
299/// one, never a truncated write. The post-rename directory fsync makes the
300/// rename itself durable — without it, a crash can revert the metadata
301/// commit pointer (the single atomic commit of a generation, #93) to the
302/// previous generation, or lose it entirely on a first commit.
303///
304/// This is the ONLY sanctioned way to publish a metadata file. The tmp name
305/// is uuid-unique (#95) so concurrent publishers of the same path cannot
306/// corrupt each other's staging file; the last rename wins, which makes
307/// last-writer-wins explicit instead of interleaved.
308pub fn write_json_atomic(path: &Path, contents: &str) -> StorageResult<()> {
309 use std::io::Write;
310
311 let tmp = path.with_extension(format!("json.tmp.{}", uuid::Uuid::new_v4().simple()));
312 let parent = path.parent().unwrap_or_else(|| Path::new("."));
313 // `parent()` yields "" for bare filenames; resolve it to the working
314 // directory so the fsync discipline below has a real handle to sync.
315 let parent = if parent.as_os_str().is_empty() {
316 Path::new(".")
317 } else {
318 parent
319 };
320 if !parent.exists() {
321 std::fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?;
322 // The fresh directory itself must be durable before anything is
323 // published inside it: fsync it and its parent so both directory
324 // entries survive a crash (#101).
325 if let Some(grandparent) = parent.parent()
326 && !grandparent.as_os_str().is_empty()
327 {
328 fsync_dir(grandparent)?;
329 }
330 fsync_dir(parent)?;
331 }
332 {
333 let mut f = std::fs::File::create(&tmp).map_err(|e| StorageError::Io(e.to_string()))?;
334 f.write_all(contents.as_bytes())
335 .map_err(|e| StorageError::Io(e.to_string()))?;
336 f.sync_all().map_err(|e| StorageError::Io(e.to_string()))?;
337 }
338 std::fs::rename(&tmp, path).map_err(|e| StorageError::Io(e.to_string()))?;
339 fsync_dir(parent)
340}
341
342/// fsyncs a directory so a completed rename is itself durable
343/// (directory-entry durability, #101 / lancefmt #95-3). No-op off unix.
344pub(crate) fn fsync_dir(dir: &Path) -> StorageResult<()> {
345 #[cfg(unix)]
346 {
347 let f = std::fs::File::open(dir)
348 .map_err(|e| StorageError::Io(format!("open dir {dir:?} for fsync: {e}")))?;
349 f.sync_all()
350 .map_err(|e| StorageError::Io(format!("fsync dir {dir:?}: {e}")))?;
351 }
352 #[cfg(not(unix))]
353 let _ = dir;
354 Ok(())
355}
356
357/// `(generation, metadata_path)` pairs implied by `{logical}__g{N}_metadata.json`
358/// names under `base` (the file itself may or may not exist).
359async fn scan_generation_paths(base: &Path, logical: &str) -> StorageResult<Vec<(u64, PathBuf)>> {
360 let mut out = Vec::new();
361 let mut rd = tokio::fs::read_dir(base)
362 .await
363 .map_err(|e| StorageError::Io(e.to_string()))?;
364 let prefix = format!("{logical}{GENERATION_SEP}");
365 while let Some(entry) = rd
366 .next_entry()
367 .await
368 .map_err(|e| StorageError::Io(e.to_string()))?
369 {
370 let name = entry.file_name();
371 let name = name.to_string_lossy().into_owned();
372 let Some(rest) = name.strip_prefix(&prefix) else {
373 continue;
374 };
375 let Some(meta) = rest.strip_suffix("_metadata.json") else {
376 continue;
377 };
378 if !meta.is_empty() && meta.bytes().all(|b| b.is_ascii_digit()) {
379 out.push((meta.parse().unwrap_or(u64::MAX), entry.path()));
380 }
381 }
382 Ok(out)
383}
384
385// ---------------------------------------------------------------------------
386// Test-only sweep gates (PR #99 review): force deterministic interleavings
387// of pin attempts against the sweep's critical section.
388// ---------------------------------------------------------------------------
389
390pub(crate) const SWEEP_GATE_PRE_LOCK: usize = 0;
391pub(crate) const SWEEP_GATE_POST_CHECK: usize = 1;
392
393// Non-test builds compile the gate calls away entirely.
394#[cfg(not(test))]
395fn sweep_gate(_stage: usize) {}
396
397#[cfg(test)]
398fn sweep_gates() -> &'static StdMutex<[Option<SweepGate>; 2]> {
399 static GATES: OnceLock<StdMutex<[Option<SweepGate>; 2]>> = OnceLock::new();
400 GATES.get_or_init(|| StdMutex::new([None, None]))
401}
402
403/// A gate the sweep must pass through: it signals `arrived` (so tests know
404/// the sweep is parked exactly at the stage) and blocks until `release`.
405#[cfg(test)]
406struct SweepGate {
407 arrived: std::sync::mpsc::Sender<()>,
408 release: std::sync::mpsc::Receiver<()>,
409}
410
411/// Arms a gate: the next sweep to reach `stage` signals the returned
412/// receiver and parks until the returned sender fires. Fires once.
413#[cfg(test)]
414pub(crate) fn arm_sweep_gate(
415 stage: usize,
416) -> (std::sync::mpsc::Receiver<()>, std::sync::mpsc::Sender<()>) {
417 let (arrived_tx, arrived_rx) = std::sync::mpsc::channel();
418 let (release_tx, release_rx) = std::sync::mpsc::channel();
419 let mut gates = sweep_gates()
420 .lock()
421 .unwrap_or_else(|poisoned| poisoned.into_inner());
422 gates[stage] = Some(SweepGate {
423 arrived: arrived_tx,
424 release: release_rx,
425 });
426 (arrived_rx, release_tx)
427}
428
429/// Blocks the sweep at `stage` if a gate is armed there (consumes it).
430#[cfg(test)]
431fn sweep_gate(stage: usize) {
432 let gate = sweep_gates()
433 .lock()
434 .unwrap_or_else(|poisoned| poisoned.into_inner())
435 .get_mut(stage)
436 .unwrap()
437 .take();
438 if let Some(gate) = gate {
439 // park: the test now knows exactly where the sweep is
440 let _ = gate.arrived.send(());
441 let _ = gate.release.recv();
442 }
443}