Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 8f61fca

Browse files
fix(snapshot): isolate mutated snapshots from the cross-snapshot result cache
Sibling snapshots created from the same database share the parent's RuntimeId so that concurrent *read-only* snapshots can deduplicate in-flight work and adopt each other's results from the shared completed- result cache (keyed by RuntimeId + kind + key, deliberately revision-less). That sharing is unsound once a snapshot is *mutated*: each snapshot has an independent revision counter starting from the same base, so two snapshots that override an input produce colliding, incomparable revision numbers. A result computed in snapshot A (e.g. `changed_at = 42` in A's space) would be adopted by snapshot B, whose own override landed at a numerically-lower revision in B's space, making B's edit look stale. Symptom: a "what-if" overlay on a snapshot (dodeca's in-browser editor preview) renders a sibling snapshot's content instead of its own. Fix: a snapshot becomes "divergent" the first time one of its inputs is set/removed, at which point it switches from the shared family RuntimeId to a private cache-scope id for both the in-flight registry and the shared result cache. Read-only snapshots are unaffected and still dedup. Adds regression tests covering derived/singleton/transitive/registry-entity override invalidation on a snapshot, plus two divergent snapshots with independent overrides.
1 parent cc4da48 commit 8f61fca

5 files changed

Lines changed: 88 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/picante/src/ingredient/derived.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ impl DerivedCore {
430430
// 3) Check shared completed-result cache for cross-snapshot memoization.
431431
// Unlike the in-flight registry, this persists after the leader finishes.
432432
if let Some(record) =
433-
inflight::shared_cache_get(db.runtime().id(), self.kind, &requested.key)
433+
inflight::shared_cache_get(db.runtime().cache_scope_id(), self.kind, &requested.key)
434434
{
435435
let can_adopt = if record.verified_at == rev {
436436
true
@@ -456,7 +456,7 @@ impl DerivedCore {
456456

457457
// Update the shared cache's verified_at so future lookups can skip revalidation.
458458
inflight::shared_cache_put(
459-
db.runtime().id(),
459+
db.runtime().cache_scope_id(),
460460
self.kind,
461461
requested.key.clone(),
462462
SharedCacheRecord {
@@ -484,7 +484,7 @@ impl DerivedCore {
484484
// 3) Check global in-flight registry for cross-snapshot deduplication.
485485
// This allows concurrent queries from different snapshots to share work.
486486
let inflight_key = InFlightKey {
487-
runtime_id: db.runtime().id(),
487+
runtime_id: db.runtime().cache_scope_id(),
488488
revision: rev,
489489
kind: self.kind,
490490
key: requested.key.clone(),
@@ -551,7 +551,7 @@ impl DerivedCore {
551551

552552
// Store in shared completed-result cache for future snapshots.
553553
inflight::shared_cache_put(
554-
db.runtime().id(),
554+
db.runtime().cache_scope_id(),
555555
self.kind,
556556
requested.key.clone(),
557557
SharedCacheRecord {
@@ -677,7 +677,7 @@ impl DerivedCore {
677677

678678
// Store in shared completed-result cache for future snapshots.
679679
inflight::shared_cache_put(
680-
db.runtime().id(),
680+
db.runtime().cache_scope_id(),
681681
self.kind,
682682
requested.key.clone(),
683683
SharedCacheRecord {

crates/picante/src/ingredient/input.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,10 @@ where
413413

414414
// Value changed, take write lock
415415
let rev = db.runtime().bump_revision();
416+
// r[snapshot.divergent]
417+
// Mutating an input makes a snapshot diverge from its family, so it must
418+
// stop sharing computed results via the cross-snapshot cache.
419+
db.runtime().mark_divergent();
416420
{
417421
let mut entries = self.core.entries.write();
418422
entries.insert(
@@ -466,6 +470,8 @@ where
466470

467471
// Need to remove, take write lock
468472
let rev = db.runtime().bump_revision();
473+
// r[snapshot.divergent]
474+
db.runtime().mark_divergent();
469475
{
470476
let mut entries = self.core.entries.write();
471477
entries.insert(

crates/picante/src/runtime.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ impl RuntimeId {
3434
pub struct Runtime {
3535
/// Unique identifier for this runtime family (shared with snapshots).
3636
id: RuntimeId,
37+
/// Whether this runtime backs a snapshot (vs. a live database).
38+
is_snapshot: bool,
39+
// r[snapshot.divergent]
40+
/// A cross-snapshot cache scope id, assigned lazily the first time an input
41+
/// is mutated on a *snapshot* runtime. `0` means "not divergent": the runtime
42+
/// uses the shared family `id` for the inflight registry and shared result
43+
/// cache (so read-only snapshots still dedup). Once a snapshot is mutated its
44+
/// state diverges from its siblings/parent, so it switches to this private id
45+
/// and stops sharing computed results — which would otherwise be unsound,
46+
/// since sibling snapshots have independent revision counters from the same
47+
/// base and thus produce colliding, incomparable revisions.
48+
divergent_scope: AtomicU64,
3749
current_revision: AtomicU64,
3850
// r[event.channel]
3951
revision_tx: watch::Sender<Revision>,
@@ -63,6 +75,8 @@ impl Runtime {
6375
let (events_tx, _) = broadcast::channel(1024);
6476
Self {
6577
id: parent_id,
78+
is_snapshot: true,
79+
divergent_scope: AtomicU64::new(0),
6680
current_revision: AtomicU64::new(0),
6781
revision_tx,
6882
events_tx,
@@ -78,6 +92,38 @@ impl Runtime {
7892
self.id
7993
}
8094

95+
// r[snapshot.divergent]
96+
/// The scope id used to key the inflight registry and shared result cache.
97+
///
98+
/// Returns the shared family [`id`](Self::id) unless this is a snapshot that
99+
/// has been mutated, in which case it returns a private id so the snapshot's
100+
/// computed results neither leak into nor adopt from its siblings/parent.
101+
pub fn cache_scope_id(&self) -> RuntimeId {
102+
let scope = self.divergent_scope.load(Ordering::Acquire);
103+
if scope != 0 {
104+
RuntimeId(scope)
105+
} else {
106+
self.id
107+
}
108+
}
109+
110+
/// Mark this runtime as divergent (an input was mutated). No-op for a live
111+
/// database — only snapshots diverge from their family. Assigns a private
112+
/// cache scope id exactly once.
113+
pub fn mark_divergent(&self) {
114+
if !self.is_snapshot {
115+
return;
116+
}
117+
if self.divergent_scope.load(Ordering::Acquire) != 0 {
118+
return;
119+
}
120+
let fresh = RUNTIME_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
121+
// First writer wins; a concurrent caller may have set it already.
122+
let _ =
123+
self.divergent_scope
124+
.compare_exchange(0, fresh, Ordering::AcqRel, Ordering::Acquire);
125+
}
126+
81127
/// Read the current revision.
82128
pub fn current_revision(&self) -> Revision {
83129
Revision(self.current_revision.load(Ordering::Acquire))
@@ -269,6 +315,8 @@ impl Default for Runtime {
269315
let (events_tx, _) = broadcast::channel(1024);
270316
Self {
271317
id: RuntimeId::new_unique(),
318+
is_snapshot: false,
319+
divergent_scope: AtomicU64::new(0),
272320
current_revision: AtomicU64::new(0),
273321
revision_tx,
274322
events_tx,

crates/picante/tests/snapshot.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,30 @@ async fn snapshot_override_registry_entity_invalidates_outer() -> PicanteResult<
276276
assert_eq!(total_len(&db).await?, 2); // host untouched
277277
Ok(())
278278
}
279+
280+
/// Two snapshots derived from the same db share a RuntimeId (for inflight dedup)
281+
/// but have independent revision counters starting from the same base. A query
282+
/// result computed in snapshot A must NOT leak into snapshot B and make B's own
283+
/// override look stale. This mirrors dodeca's editor: an auto-preview snapshot
284+
/// followed by a user-edit snapshot.
285+
#[tokio_test_lite::test]
286+
async fn two_snapshots_independent_overrides() -> PicanteResult<()> {
287+
let db = Database::new();
288+
Config::set(&db, "base".into())?; // len 4
289+
assert_eq!(config_len(&db).await?, 4); // compute on host
290+
291+
// Snapshot A: override + compute (this is the "auto-preview").
292+
let snap_a = DatabaseSnapshot::from_database(&db).await;
293+
Config::set(&snap_a, "AAAAAAAAAAAAAAAAAAAA".into())?; // len 20
294+
assert_eq!(config_len(&snap_a).await?, 20);
295+
296+
// Snapshot B: a DIFFERENT override. Must reflect B's value, not A's.
297+
let snap_b = DatabaseSnapshot::from_database(&db).await;
298+
Config::set(&snap_b, "BBB".into())?; // len 3
299+
assert_eq!(
300+
config_len(&snap_b).await?,
301+
3,
302+
"snapshot B must see its own override, not snapshot A's leaked result"
303+
);
304+
Ok(())
305+
}

0 commit comments

Comments
 (0)