petgraph-live 0.3.0

Generic generation-keyed graph cache, disk snapshot, and graph algorithms for petgraph 0.8
Documentation
---
title: "live module"
summary: "GraphState<G> — composes GenerationCache and snapshot into a single managed lifecycle with cold/warm start, stale-key rebuild, and snapshot rotation."
read_when:
  - Implementing or modifying GraphState or GraphStateBuilder
  - Understanding init/get/get_fresh/rebuild flows
  - Reasoning about concurrency guarantees in live module
  - Writing tests for the live module
status: implemented
last_updated: "2026-05-02"
---

# Specification: `live` module

**Crate:** `petgraph-live`
**Feature flag:** `snapshot` (live is always included when snapshot is enabled)
**Status:** implemented
**Depends on:** `cache` module, `snapshot` module

## Purpose

`GraphState<G>` composes `GenerationCache<G>` and the `snapshot` module into a
single managed lifecycle. The caller supplies two functions: how to compute the
current validity key, and how to build the graph from scratch. `GraphState`
handles cold start, warm start from snapshot, cache hit on hot path, rebuild on key change, snapshot save after rebuild, and rotation.

## Scope

In scope:
- Cold start: no snapshot → build → save
- Warm start: snapshot key matches → load from disk, skip build
- Hot path `get()`: no key check, return cached `Arc<G>`
- Stale-check `get_fresh()`: compare `key_fn()` to `current_key`, rebuild if different
- Forced `rebuild()`: unconditional rebuild + save
- Builder pattern for ergonomic setup
- `SnapshotConfig::key = None` enforced — key management is internal

Out of scope:
- Async rebuild (background thread / tokio task)
- Multiple concurrent rebuild coalescing (v0.2)
- TTL-based expiry

## Concurrency model

`build_fn` runs outside any lock. Only the cache-swap (store new `Arc` + bump generation) acquires a write lock, for microseconds. Concurrent `get()` callers read the stale `Arc` until the swap completes — no blocking.

Two concurrent callers both detecting a stale key will each call `build_fn`. Last writer wins. Coalescing deferred to v0.2. Callers who must prevent duplicate builds serialize externally:

```rust
let _guard = rebuild_mutex.lock().unwrap();
state.get_fresh()
```

## Public types

### `GraphStateConfig`

```rust
pub struct GraphStateConfig {
    pub snapshot: SnapshotConfig,
}

impl GraphStateConfig {
    pub fn new(snapshot: SnapshotConfig) -> Self;
}
```

`snapshot.key` must be `None` — `GraphState` manages keys internally.
`GraphStateBuilder::init()` returns `Err` if `snapshot.key` is `Some`.

### `GraphState<G>`

```rust
impl<G> GraphState<G>
where
    G: Serialize + DeserializeOwned + Send + Sync + 'static,
{
    pub fn builder(config: GraphStateConfig) -> GraphStateBuilder<G>;

    /// Hot path. No key check. Returns Err only if cache is empty (precondition
    /// violation — should not occur after successful init()).
    pub fn get(&self) -> Result<Arc<G>, SnapshotError>;

    /// Check key_fn() against current_key. Rebuild if different.
    pub fn get_fresh(&self) -> Result<Arc<G>, SnapshotError>;

    /// Unconditional rebuild and snapshot save.
    pub fn rebuild(&self) -> Result<Arc<G>, SnapshotError>;

    /// Key of currently cached graph.
    pub fn current_key(&self) -> String;

    /// Process-lifetime generation counter.
    pub fn generation(&self) -> u64;
}
```

### `GraphStateBuilder<G>`

```rust
pub struct GraphStateBuilder<G> { /* private */ }

impl<G> GraphStateBuilder<G>
where
    G: Serialize + DeserializeOwned + Send + Sync + 'static,
{
    pub fn key_fn(
        self,
        f: impl Fn() -> Result<String, SnapshotError> + Send + Sync + 'static,
    ) -> Self;

    pub fn build_fn(
        self,
        f: impl Fn() -> Result<G, SnapshotError> + Send + Sync + 'static,
    ) -> Self;

    /// Provide current key directly — avoids calling key_fn at init.
    pub fn current_key(self, key: impl Into<String>) -> Self;

    /// Consume builder. Returns Err if key_fn or build_fn not set, or if
    /// config.snapshot.key != None.
    pub fn init(self) -> Result<GraphState<G>, SnapshotError>;
}
```

## Init flow

```
init():
  1. Validate: key_fn set, build_fn set, snapshot.key == None → else Err
  2. current_key = builder.current_key if provided, else key_fn()
  3. load(snapshot_cfg with key = Some(current_key))
       Ok(Some(g))                      → warm start
       Ok(None) | Err(KeyNotFound)      → build_fn() → g, then save
  4. Store g in GenerationCache at generation = 1
```

## `get_fresh` flow

```
get_fresh():
  1. new_key = key_fn()
  2. Read-lock: if new_key == current_key → return get()
  3. Drop lock; build_fn() → g  [outside any lock]
  4. save(new_key, &g)
  5. Write-lock: bump generation, current_key = new_key
  6. cache.invalidate(); cache.get_or_build(generation, || Ok(g))
```

## `rebuild` flow

```
rebuild():
  1. current_key = key_fn()
  2. build_fn() → g
  3. save(current_key, &g)
  4. Write-lock: bump generation
  5. cache.invalidate(); store new Arc
```

## Files

| Path                     | Responsibility                                             |
| ------------------------ | ---------------------------------------------------------- |
| `src/live/mod.rs`        | Re-exports, module-level rustdoc                           |
| `src/live/config.rs`     | `GraphStateConfig`                                         |
| `src/live/state.rs`      | `GraphState<G>`, `GraphStateBuilder<G>`, `GraphStateInner` |
| `tests/live.rs`          | Integration tests                                          |
| `examples/live_basic.rs` | End-to-end demo                                            |

## Test matrix

| Test                                       | Verifies                                   |
| ------------------------------------------ | ------------------------------------------ |
| `test_config_new`                          | Config construction                        |
| `test_builder_missing_key_fn_errors`       | init() without key_fn → Err                |
| `test_builder_missing_build_fn_errors`     | init() without build_fn → Err              |
| `test_builder_key_some_errors`             | snapshot.key = Some → init() Err           |
| `test_init_cold_start_no_snapshot`         | Empty dir → build called, snapshot written |
| `test_init_warm_start_from_snapshot`       | Key matches → load, build NOT called       |
| `test_init_snapshot_key_mismatch_rebuilds` | Key mismatch → build called                |
| `test_get_returns_cached`                  | Two get() calls → same Arc::ptr_eq         |
| `test_current_key_and_generation`          | Values correct after init                  |
| `test_get_fresh_same_key_no_rebuild`       | Same key → no rebuild                      |
| `test_get_fresh_new_key_triggers_rebuild`  | New key → rebuild, snapshot written        |
| `test_get_fresh_saves_snapshot`            | Snapshot file exists after rebuild         |
| `test_rebuild_forces_new_graph`            | rebuild() → new Arc, different value       |
| `test_concurrent_get`                      | 8 threads × 100 reads, no deadlock         |