Skip to main content

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}