mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
### What?
Enable Effects to be evicted.
Currently `EffectInstance` transitively holds a
`ReadRef<PersistentFileContent>` which means eviction cannot not free
the memory for the outputs. Because the `ReadRefs` are captured by the
task producing the `Effect` we cannot just drop them (otherwise we could
need to somehow re-execute the write to recover them). So instead we
need a new 'effect processing phase'
Here is our new state machine:
* Phase 1: `emit` effects.
- At this point we capture the content as a `ResolvedVc` and the hash as
a value.
- In principle this means the hash and content can get out of sync, but
only within an eventual consistency race which callers should be
handling.
* Phase 2: `take_effects`.
- This will call `capture()` on all the effects which will conditionally
read the content cell if the hash doesn't match the `EffectStateStorage`
* Phase 3: `Effects::apply`.
- This will apply the effects for all effects whose hashes don't match
storage.
- At this point we may discover that a hash in storage changed between
`capture` and `apply` which will trigger a special `Retry` error and
`invalidate` `Phase 2`.
To facilitate the `Retry` logic a
`read_strongly_consistent_and_apply_effects` helper function handles it.
Which allows for a simple circuit breaker and some verbose
warnings/errors when we retry too many times.
Specifically:
- Split the `Effect` trait into `Effect` (emit-side, returns a
`Captured` payload) and `CapturedEffect` (apply-side). `
- Switch `Effects` to `cell = "new"`. Every producer re-execution
allocates a fresh cell value; the prior cell (and its captured payload)
drops on overwrite, cascade-releasing upstream
`ReadRef<PersistedFileContent>` strong counts. The captured payload also
drops naturally when the cell is evicted.
- Push the per-key state machine (`Unapplied` / `InProgress` / `Applied
{ value_hash, result }`, `EventGuard` panic recovery, `Event` listener
coordination) down into `EffectStateStorage::run_apply`.
`Effects::apply` becomes a thin aggregator over `dyn_apply()` calls.
- New `ApplyOutcome<E> { Failed(E), Retry }`. `CapturedEffect::apply`
returns this; `Retry` signals "capture elided content but storage
diverged before apply" — `Effects::apply` collects across the batch,
fires the producer's invalidator once, and surfaces
`EffectsError::Retry`.
- New `EffectStateStorage::matches_applied(key, hash)`.
`WriteEffect::capture` / `WriteLinkEffect::capture` consult this and
skip the `ReadRef<PersistedFileContent>` / `ReadRef<LinkContent>`
materialization when storage already holds `Applied { matching, Ok(())
}` — avoiding the disk-read + decompression hit on producer re-runs that
don't actually change output.
- 10 integration tests in
`turbopack/crates/turbo-tasks-backend/tests/effects.rs` cover the new
lifecycle: duplicate apply, sibling stomp re-apply, repeated apply with
unchanged state dedupe, `cell = "new"` producing distinct cells per
producer run, capture-skip on storage match, and the
capture-skip-then-stomp race that exercises the `Retry` path.
### Why?
A memory audit showed `EffectInstance` cells accumulating the majority
of the ram in an eviction session
#### Capture-time storage check (the perf story)
`Effect::capture` may consult `EffectStateStorage::matches_applied` and
elide the content materialization when storage already records the
target hash. `CapturedWriteEffect.content` becomes
`Option<ReadRef<PersistedFileContent>>` — `None` means "capture observed
`Applied { matching }`; no `body` to pass to `run_apply`". The
apply-side state machine then either dedup-hits (storage still matches →
cached `Ok`) or enters the no-body branch and returns
`ApplyOutcome::Retry`.
Reading shared mutable `EffectStateStorage` from inside a turbo-tasks
task is normally suspect (the state isn't a tracked input). It is sound
here because the apply-time re-check fires the producer's invalidator on
mismatch, turning the otherwise-untracked read into an
explicitly-tracked one via the `Retry` pathway. The producer reruns,
`capture` sees the new storage state, materializes content, and the next
`apply` succeeds.
#### `ApplyOutcome::Retry` and the invalidator
`Effects::apply` collects `Retry` signals across the parallel batch via
`AtomicBool`. `Failed(e)` errors fail-fast. After the batch, if any
`Retry` fired (and no `Failed`), the producer's invalidator runs once
and `EffectsError::Retry` propagates. Callers re-read the operation; the
producer reruns; fresh `capture` either materializes content (storage
diverged) or dedup-hits cleanly.
#### `turbo-tasks-fs`
`WriteEffect::capture` / `WriteLinkEffect::capture` call
`matches_applied` first and conditionally `.await?` the content
`ResolvedVc`. `CapturedWriteEffect::apply` /
`CapturedWriteLinkEffect::apply` dispatch through `run_apply(key, hash,
body)`, where `body` is `Some(closure)` iff content is `Some`.
`apply_inner` takes the unwrapped `&ReadRef<…>` as a parameter so the
option-unwrap is at the dispatch boundary (type-enforced).
### Risk: infinite retry loop
**Failure mode.** Two sibling producers A and B contend on the same key
with different hashes (e.g. two routes that both want to write
`chunks/foo.js` with different content). Each `Retry` from A's apply
invalidates A's producer; each `Retry` from B's apply invalidates B's
producer. In the worst case, A and B perpetually re-capture, race to
apply, stomp each other's storage state, and re-trigger Retry —
invalidating themselves on each cycle.
**Why we believe it terminates in practice.** A `Retry` only fires when
(a) capture observed `Applied { matching }` and elided content, and (b)
by apply time storage was stomped to a different hash. Each `Retry`
invalidates only its own producer (no cross-producer invalidation), so
without external input churn the system reaches a fixed point —
whichever producer applies last wins, and subsequent re-runs of the
loser's producer materialize content (because storage no longer matches)
and apply cleanly. The pathological case requires a real, ongoing race
fundamentally triggered by external invalidations (file edits).
<!-- NEXT_JS_LLM_PR -->
737 lines
26 KiB
Rust
737 lines
26 KiB
Rust
#![feature(arbitrary_self_types)]
|
|
#![feature(arbitrary_self_types_pointers)]
|
|
#![allow(clippy::needless_return)] // tokio macro-generated code doesn't respect this
|
|
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicU64, Ordering},
|
|
};
|
|
|
|
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use parking_lot::Mutex;
|
|
use rustc_hash::FxHashMap;
|
|
use turbo_rcstr::{RcStr, rcstr};
|
|
use turbo_tasks::{
|
|
ApplyError, CapturedEffect, Effect, EffectExt, EffectStateStorage, Effects, EffectsError,
|
|
NonLocalValue, OperationValue, ReadRef, ResolvedVc, State, TurboTasks, Vc, take_effects,
|
|
trace::TraceRawVcs,
|
|
};
|
|
use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
|
|
|
|
/// Per-test counters + `EffectStateStorage`. Tests assert against
|
|
/// `applies_by_key` to check how many times each effect ran.
|
|
///
|
|
/// A single `SharedState` can be associated with multiple `TestInput` cells (each
|
|
/// representing a separate producer task); they then share the same
|
|
/// `EffectStateStorage`, mirroring how on disk two producers writing the
|
|
/// same path contend on the same per-key state entry.
|
|
#[derive(TraceRawVcs, NonLocalValue)]
|
|
struct SharedState {
|
|
#[turbo_tasks(trace_ignore)]
|
|
applies_by_key: Mutex<FxHashMap<RcStr, u64>>,
|
|
#[turbo_tasks(trace_ignore)]
|
|
total_applies: AtomicU64,
|
|
/// Counts captures that materialized content (i.e. those where
|
|
/// `EffectStateStorage::matches_applied` returned false). Lets tests
|
|
/// assert that capture skipped content materialization on re-runs.
|
|
#[turbo_tasks(trace_ignore)]
|
|
captures_with_content: AtomicU64,
|
|
/// Shared `EffectStateStorage` used by both `matches_applied` (in `capture`)
|
|
/// and `run_apply` (in `apply`).
|
|
#[turbo_tasks(trace_ignore)]
|
|
state_storage: EffectStateStorage,
|
|
}
|
|
|
|
impl SharedState {
|
|
fn new() -> Arc<Self> {
|
|
Arc::new(Self {
|
|
applies_by_key: Mutex::new(Default::default()),
|
|
total_applies: AtomicU64::new(0),
|
|
captures_with_content: AtomicU64::new(0),
|
|
state_storage: EffectStateStorage::default(),
|
|
})
|
|
}
|
|
|
|
fn applies_for(&self, key: &RcStr) -> u64 {
|
|
self.applies_by_key.lock().get(key).copied().unwrap_or(0)
|
|
}
|
|
|
|
fn total(&self) -> u64 {
|
|
self.total_applies.load(Ordering::Relaxed)
|
|
}
|
|
|
|
fn captures_with_content(&self) -> u64 {
|
|
self.captures_with_content.load(Ordering::Relaxed)
|
|
}
|
|
}
|
|
// Emit-side effect. A `#[turbo_tasks::value]` cell implementing the `Effect` value_trait. Marked
|
|
// `serialization = "skip"` because it holds `Arc<SharedState>` (counters + storage, not
|
|
// serializable) — these tests use `noop_backing_storage`, so persistence isn't exercised.
|
|
#[turbo_tasks::value(serialization = "skip", cell = "new", eq = "manual")]
|
|
struct TestEffect {
|
|
key: RcStr,
|
|
value_hash: u128,
|
|
#[turbo_tasks(trace_ignore, debug_ignore)]
|
|
shared: Arc<SharedState>,
|
|
}
|
|
|
|
#[async_trait]
|
|
#[turbo_tasks::value_impl]
|
|
impl Effect for TestEffect {
|
|
async fn capture(&self) -> Result<Box<dyn CapturedEffect>> {
|
|
// Consult storage. If the per-key state already records `Applied { value_hash }`
|
|
// matching our hash, elide content materialization (`content = false`). Otherwise
|
|
// bump the captures-with-content counter (the test's stand-in for a `ReadRef` /
|
|
// disk-read at this point).
|
|
let content = if self
|
|
.shared
|
|
.state_storage
|
|
.matches_applied(self.key.as_bytes(), self.value_hash)
|
|
{
|
|
false
|
|
} else {
|
|
self.shared
|
|
.captures_with_content
|
|
.fetch_add(1, Ordering::Relaxed);
|
|
true
|
|
};
|
|
Ok(Box::new(TestEffectCaptured {
|
|
key: self.key.clone(),
|
|
value_hash: self.value_hash,
|
|
content,
|
|
shared: self.shared.clone(),
|
|
}) as Box<dyn CapturedEffect>)
|
|
}
|
|
}
|
|
|
|
// Post-capture effect — session-only plain struct.
|
|
#[derive(TraceRawVcs, NonLocalValue)]
|
|
struct TestEffectCaptured {
|
|
key: RcStr,
|
|
value_hash: u128,
|
|
/// Whether `capture` materialized content. When `false`, `apply` passes `None` to
|
|
/// `run_apply`; a non-matching storage state then triggers `ApplyOutcome::Retry`.
|
|
content: bool,
|
|
shared: Arc<SharedState>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CapturedEffect for TestEffectCaptured {
|
|
fn key(&self) -> Box<[u8]> {
|
|
self.key.as_bytes().into()
|
|
}
|
|
|
|
fn value_hash(&self) -> u128 {
|
|
self.value_hash
|
|
}
|
|
|
|
async fn apply(&self) -> Result<(), ApplyError> {
|
|
let body = if self.content {
|
|
Some(|| async {
|
|
self.shared.total_applies.fetch_add(1, Ordering::Relaxed);
|
|
*self
|
|
.shared
|
|
.applies_by_key
|
|
.lock()
|
|
.entry(self.key.clone())
|
|
.or_insert(0) += 1;
|
|
Ok::<(), TestError>(())
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
self.shared
|
|
.state_storage
|
|
.run_apply::<TestError, _, _>(self.key(), self.value_hash, body)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, TraceRawVcs, NonLocalValue)]
|
|
enum TestError {}
|
|
|
|
/// Spec for what the producer should emit: a list of `(key, value_hash)`
|
|
/// pairs. Stored inside `State<EmitSpec>` so mutating it invalidates the
|
|
/// producer.
|
|
#[derive(Clone, Default, PartialEq, Eq, Debug, TraceRawVcs, NonLocalValue, OperationValue)]
|
|
struct EmitSpec {
|
|
pairs: Vec<(RcStr, u128)>,
|
|
}
|
|
|
|
/// Input cell. Holds an `Arc<Shared>` (counters + `EffectStateStorage`) plus
|
|
/// its own `Arc<State<EmitSpec>>` driving what *this* producer emits. The
|
|
/// spec lives in an `Arc<State<…>>` rather than in the cell value so the
|
|
/// test body can mutate it from top-level via the handle returned by
|
|
/// `TestInput::new`, without going through a cached operation. Mutating from
|
|
/// inside a cached operation would make the operation non-deterministic.
|
|
///
|
|
/// Multiple `TestInput`s can share one `Arc<Shared>` (so they contend on the
|
|
/// same `EffectStateStorage`) while each carries its own independent spec —
|
|
/// this is what lets us simulate two sibling producers writing the same key
|
|
/// in the retry test.
|
|
#[turbo_tasks::value(eq = "manual", serialization = "skip")]
|
|
#[derive(Clone)]
|
|
struct TestInput {
|
|
#[turbo_tasks(trace_ignore, debug_ignore)]
|
|
shared: Arc<SharedState>,
|
|
#[turbo_tasks(trace_ignore, debug_ignore)]
|
|
spec: Arc<State<EmitSpec>>,
|
|
/// Side-channel `State` for invalidating the producer without changing
|
|
/// `spec`. Lets tests simulate upstream input changes that don't affect
|
|
/// emitted hashes (e.g. a comment edit that recompiles to identical bytes).
|
|
#[turbo_tasks(trace_ignore, debug_ignore)]
|
|
tick: Arc<State<u64>>,
|
|
}
|
|
|
|
impl PartialEq for TestInput {
|
|
fn eq(&self, _other: &Self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
impl TestInput {
|
|
/// Construct a fresh `TestInput` cell with a fresh `Shared`.
|
|
fn new() -> (ResolvedVc<Self>, Self) {
|
|
Self::new_with_shared(SharedState::new())
|
|
}
|
|
|
|
/// Construct a fresh `TestInput` cell that shares an existing `Shared`.
|
|
/// The new input has its own independent spec.
|
|
fn new_with_shared(shared: Arc<SharedState>) -> (ResolvedVc<Self>, Self) {
|
|
let spec = Arc::new(State::new(EmitSpec::default()));
|
|
let tick = Arc::new(State::new(0u64));
|
|
let input = Self { shared, spec, tick };
|
|
|
|
(input.clone().resolved_cell(), input)
|
|
}
|
|
}
|
|
|
|
#[turbo_tasks::function(operation, root)]
|
|
async fn producer_operation(input: ResolvedVc<TestInput>) -> Result<()> {
|
|
let input = input.await?;
|
|
let shared = input.shared.clone();
|
|
let spec = input.spec.get().clone();
|
|
// Track `tick` so callers can force a producer rerun without mutating `spec`.
|
|
let _tick: u64 = *input.tick.get();
|
|
for (key, value_hash) in spec.pairs {
|
|
TestEffect {
|
|
key,
|
|
value_hash,
|
|
shared: shared.clone(),
|
|
}
|
|
.resolved_cell()
|
|
.emit();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the current spec from `input` and emit the corresponding effects,
|
|
/// returning the captured `Effects`. State mutation must happen OUTSIDE this
|
|
/// operation (at top-level via the spec handle), otherwise the operation
|
|
/// becomes non-deterministic and turbo-tasks may re-run it with stale inputs.
|
|
#[turbo_tasks::function(operation, root)]
|
|
async fn extract_effects(input: ResolvedVc<TestInput>) -> Result<Vc<Effects>> {
|
|
let producer = producer_operation(input);
|
|
let _ = producer.resolve().strongly_consistent().await?;
|
|
Ok(take_effects(producer).await?.cell())
|
|
}
|
|
|
|
/// Mutate the spec via its top-level handle and return the resulting
|
|
/// `Effects`. State mutation is synchronous at top-level (so it doesn't make
|
|
/// any operation non-deterministic); only the `take_effects` step runs
|
|
/// inside an operation root so it gets strongly-consistent read semantics.
|
|
async fn emit_and_take(
|
|
spec: &State<EmitSpec>,
|
|
input: ResolvedVc<TestInput>,
|
|
pairs: Vec<(RcStr, u128)>,
|
|
) -> Result<ReadRef<Effects>> {
|
|
spec.set(EmitSpec { pairs });
|
|
extract_effects(input).read_strongly_consistent().await
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test harness
|
|
// =============================================================================
|
|
|
|
fn create_tt() -> Arc<TurboTasks<TurboTasksBackend>> {
|
|
TurboTasks::new(TurboTasksBackend::new(
|
|
BackendOptions::default(),
|
|
noop_backing_storage(),
|
|
))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
/// A duplicate sequential `.apply_for_testing()` on the same `Effects` must run each
|
|
/// underlying side-effect exactly once. The first call populates the per-key
|
|
/// `EffectStateStorage` with `Applied { value_hash }`; the second call sees
|
|
/// `Effects.captured == None` and falls through `apply_post_drop` where every
|
|
/// state entry is `Applied` with a matching hash → no `dyn_apply` invocation.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn duplicate_apply_runs_once() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { shared, spec, .. }) = TestInput::new();
|
|
|
|
let effects = emit_and_take(&spec, input, vec![(rcstr!("foo"), 0xAAAA)]).await?;
|
|
|
|
effects.apply_for_testing().await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
1,
|
|
"first apply runs the effect"
|
|
);
|
|
|
|
effects.apply_for_testing().await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
1,
|
|
"second apply on the same Effects must not re-run"
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Re-running the producer (e.g. because something upstream invalidated it)
|
|
/// with the same `(key, value_hash)` set produces a fresh `Effects` value
|
|
/// whose identity multiset matches the previous one. `.apply_for_testing()` on the new
|
|
/// `Effects` short-circuits through the per-key state machine because each
|
|
/// `EffectStateEntry` is still `Applied { value_hash: matching }`.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn reemit_unchanged_hash_does_not_reapply() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { shared, spec, .. }) = TestInput::new();
|
|
|
|
emit_and_take(
|
|
&spec,
|
|
input,
|
|
vec![(rcstr!("foo"), 0xAAAA), (rcstr!("bar"), 0xBBBB)],
|
|
)
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.total(), 2, "first emit runs both effects");
|
|
|
|
// Re-set with the same value. `State::set` is a no-op when the value
|
|
// hasn't changed (PartialEq), but the second `extract_effects` invocation
|
|
// is still a fresh root task — it re-takes the producer's collectibles.
|
|
emit_and_take(
|
|
&spec,
|
|
input,
|
|
vec![(rcstr!("foo"), 0xAAAA), (rcstr!("bar"), 0xBBBB)],
|
|
)
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(
|
|
shared.total(),
|
|
2,
|
|
"re-emit with same (key, hash) must not re-run any apply"
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Changing one effect's hash re-runs only that effect; the sibling stays
|
|
/// short-circuited by its `Applied { value_hash: matching }` state entry.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn hash_change_reapplies_only_changed_key() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { shared, spec, .. }) = TestInput::new();
|
|
|
|
emit_and_take(
|
|
&spec,
|
|
input,
|
|
vec![(rcstr!("foo"), 0xAAAA), (rcstr!("bar"), 0xBBBB)],
|
|
)
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 1);
|
|
assert_eq!(shared.applies_for(&rcstr!("bar")), 1);
|
|
|
|
// Change key 1's hash; leave key 2 alone.
|
|
emit_and_take(
|
|
&spec,
|
|
input,
|
|
vec![(rcstr!("foo"), 0xCCCC), (rcstr!("bar"), 0xBBBB)],
|
|
)
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
2,
|
|
"key 1 hash changed; apply must run again"
|
|
);
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("bar")),
|
|
1,
|
|
"key 2 unchanged; apply must short-circuit"
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Adding a brand-new effect to the emitted set only runs the new key's
|
|
/// apply; the pre-existing keys stay short-circuited.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn adding_effect_only_runs_new_key() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { shared, spec, .. }) = TestInput::new();
|
|
|
|
emit_and_take(&spec, input, vec![(rcstr!("foo"), 0xAAAA)])
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.total(), 1);
|
|
|
|
// Add a new effect, key 2.
|
|
emit_and_take(
|
|
&spec,
|
|
input,
|
|
vec![(rcstr!("foo"), 0xAAAA), (rcstr!("bar"), 0xBBBB)],
|
|
)
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
1,
|
|
"key 1 already applied"
|
|
);
|
|
assert_eq!(shared.applies_for(&rcstr!("bar")), 1, "key 2 newly added");
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Removing an effect from the emitted set must not re-run the surviving
|
|
/// effect, and the removed effect's apply must not fire again either.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn removing_effect_does_not_reapply_survivors() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { shared, spec, .. }) = TestInput::new();
|
|
|
|
emit_and_take(
|
|
&spec,
|
|
input,
|
|
vec![(rcstr!("foo"), 0xAAAA), (rcstr!("bar"), 0xBBBB)],
|
|
)
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.total(), 2);
|
|
|
|
// Drop key 2.
|
|
emit_and_take(&spec, input, vec![(rcstr!("foo"), 0xAAAA)])
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 1, "key 1 stays cached");
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("bar")),
|
|
1,
|
|
"key 2 was removed; its apply must not run again"
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// When sibling producers write the same key with different hashes, A's
|
|
/// re-apply (on the same `Effects` value) re-fires A's side effect because
|
|
/// the per-key state machine sees `Applied { H_B } != H_A` → break into
|
|
/// InProgress, run body, write back `Applied { H_A }`. Captured effects are
|
|
/// kept alive for the lifetime of the cell so re-apply always has a body to
|
|
/// run; there is no Retry signal in this scenario because the test effects
|
|
/// don't elide content at capture time.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn sibling_producer_overwrites_state_reapplies_on_call() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (
|
|
input_a,
|
|
TestInput {
|
|
shared,
|
|
spec: spec_a,
|
|
..
|
|
},
|
|
) = TestInput::new();
|
|
let (input_b, TestInput { spec: spec_b, .. }) = TestInput::new_with_shared(shared.clone());
|
|
|
|
// Step 1: A emits and applies (key=1, hash=H1).
|
|
spec_a.set(EmitSpec {
|
|
pairs: vec![(rcstr!("foo"), 0xAAAA)],
|
|
});
|
|
let op_a = extract_effects(input_a);
|
|
let effects_a = op_a.read_strongly_consistent().await?;
|
|
effects_a.apply_for_testing().await?;
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 1, "A's first apply ran");
|
|
|
|
// Step 2: B emits and applies (key=1, hash=H2). Different hash for the
|
|
// same key overwrites the per-key state entry.
|
|
emit_and_take(&spec_b, input_b, vec![(rcstr!("foo"), 0xBBBB)])
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
2,
|
|
"B's apply for the same key ran"
|
|
);
|
|
|
|
// Step 3: re-apply A's original Effects. Captured slice is still alive
|
|
// (Effects keeps captured for the cell's lifetime; cell="new" drops it
|
|
// when the producer reruns), so the state machine breaks into InProgress
|
|
// and re-runs A's body, then writes back Applied{H_A}.
|
|
effects_a.apply_for_testing().await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
3,
|
|
"A's re-apply against stomped state re-fires its body"
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// When `Effects::apply` is called twice in sequence on the same value, the
|
|
/// second call short-circuits via the per-key dedup hit. This works because
|
|
/// `Effects` keeps `captured` alive for the lifetime of the cell, so every
|
|
/// apply re-enters the state machine; with storage `Applied{H_A}` matching
|
|
/// the captured hash H_A, the state machine returns the cached result
|
|
/// without invoking the body a second time.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn repeated_apply_after_unchanged_state_dedupes() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { shared, spec, .. }) = TestInput::new();
|
|
|
|
spec.set(EmitSpec {
|
|
pairs: vec![(rcstr!("foo"), 0xAAAA)],
|
|
});
|
|
let op = extract_effects(input);
|
|
let effects = op.read_strongly_consistent().await?;
|
|
effects.apply_for_testing().await?;
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 1);
|
|
|
|
effects.apply_for_testing().await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
1,
|
|
"second apply with unchanged state must dedup-hit",
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Holding on to `effects_a`, deliberately suppress the test from holding
|
|
/// `ReadRef` references that would prevent eviction. We confirm referential
|
|
/// behavior: after a sibling stomps storage and A is invalidated and re-read,
|
|
/// the new `Effects` cell is a different value (cell = "new").
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn cell_new_produces_distinct_effects_per_producer_run() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (input, TestInput { spec, .. }) = TestInput::new();
|
|
|
|
spec.set(EmitSpec {
|
|
pairs: vec![(rcstr!("foo"), 0xAAAA)],
|
|
});
|
|
let op = extract_effects(input);
|
|
let effects_v1 = op.read_strongly_consistent().await?;
|
|
|
|
// Mutate spec to invalidate the producer.
|
|
spec.set(EmitSpec {
|
|
pairs: vec![(rcstr!("foo"), 0xBBBB)],
|
|
});
|
|
let effects_v2 = op.read_strongly_consistent().await?;
|
|
|
|
assert!(
|
|
!ReadRef::ptr_eq(&effects_v1, &effects_v2),
|
|
"cell = \"new\" must allocate a fresh cell value on producer rerun"
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// When the producer reruns with the same `(key, value_hash)` after the first
|
|
/// apply succeeded, `capture` observes storage `Applied{matching}` and elides
|
|
/// content materialization. The apply-side state machine still dedup-hits
|
|
/// because storage is unchanged, so the side effect does not re-fire.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn capture_skips_content_when_storage_matches() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (
|
|
input,
|
|
TestInput {
|
|
shared, spec, tick, ..
|
|
},
|
|
) = TestInput::new();
|
|
|
|
spec.set(EmitSpec {
|
|
pairs: vec![(rcstr!("foo"), 0xAAAA)],
|
|
});
|
|
let op = extract_effects(input);
|
|
op.read_strongly_consistent()
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 1, "first apply ran");
|
|
assert_eq!(
|
|
shared.captures_with_content(),
|
|
1,
|
|
"first capture had to materialize (storage was empty)"
|
|
);
|
|
|
|
// Force the producer to rerun without changing what it emits (simulates
|
|
// an upstream input change that doesn't affect the output). Storage
|
|
// still holds `Applied{0xAAAA}` so capture's `matches_applied` returns
|
|
// true and we skip content materialization. Apply dedup-hits via the
|
|
// state machine.
|
|
tick.set(1);
|
|
op.read_strongly_consistent()
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
1,
|
|
"re-emit with unchanged state must not re-run apply",
|
|
);
|
|
assert_eq!(
|
|
shared.captures_with_content(),
|
|
1,
|
|
"second capture must skip content materialization",
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Race scenario: A's capture observed `Applied{H_A}` and elided content
|
|
/// materialization. Before A's apply runs, B applies a different hash for the
|
|
/// same key, stomping storage to `Applied{H_B}`. A's apply now sees the
|
|
/// mismatch but has no content — `ApplyOutcome::Retry` propagates as
|
|
/// `EffectsError::Retry`. The producer's invalidator fires; re-reading A
|
|
/// produces a fresh `Effects` whose `capture` sees the mismatch and
|
|
/// materializes content this time, so the second apply succeeds.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn capture_skip_then_stomp_signals_retry() {
|
|
let tt = create_tt();
|
|
tt.run_once(async move {
|
|
let (
|
|
input_a,
|
|
TestInput {
|
|
shared,
|
|
spec: spec_a,
|
|
tick: tick_a,
|
|
},
|
|
) = TestInput::new();
|
|
let (input_b, TestInput { spec: spec_b, .. }) = TestInput::new_with_shared(shared.clone());
|
|
|
|
// T1: A applies (key=1, H_A). Storage = Applied{H_A}. Capture had to
|
|
// materialize (storage was empty).
|
|
spec_a.set(EmitSpec {
|
|
pairs: vec![(rcstr!("foo"), 0xAAAA)],
|
|
});
|
|
let op_a = extract_effects(input_a);
|
|
op_a.read_strongly_consistent()
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 1);
|
|
assert_eq!(shared.captures_with_content(), 1);
|
|
|
|
// T2: Force the producer to rerun by bumping `tick` (simulates an upstream
|
|
// input change that doesn't affect the emitted hashes). Storage still
|
|
// holds Applied{H_A}, so capture's matches_applied returns true and we
|
|
// elide content materialization.
|
|
tick_a.set(1);
|
|
let effects_a_skipped = op_a.read_strongly_consistent().await?;
|
|
assert_eq!(
|
|
shared.captures_with_content(),
|
|
1,
|
|
"second capture skipped materialization",
|
|
);
|
|
|
|
// T3: B applies (key=1, H_B). Different hash, so B's capture materializes
|
|
// and writes back Applied{H_B}.
|
|
emit_and_take(&spec_b, input_b, vec![(rcstr!("foo"), 0xBBBB)])
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(shared.applies_for(&rcstr!("foo")), 2);
|
|
|
|
// T4: A's apply on the content-elided Effects. State is Applied{H_B} ≠
|
|
// H_A; capture had no content → run_apply returns Retry.
|
|
let err = effects_a_skipped
|
|
.apply_for_testing()
|
|
.await
|
|
.expect_err("expected Retry");
|
|
assert!(
|
|
matches!(err, EffectsError::Retry { .. }),
|
|
"expected EffectsError::Retry, got {err:?}"
|
|
);
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
2,
|
|
"Retry path must not run A's side effect",
|
|
);
|
|
|
|
// T5: Re-read A. capture sees Applied{H_B} ≠ H_A so it materializes
|
|
// content. Apply succeeds and writes back Applied{H_A}.
|
|
op_a.read_strongly_consistent()
|
|
.await?
|
|
.apply_for_testing()
|
|
.await?;
|
|
assert_eq!(
|
|
shared.applies_for(&rcstr!("foo")),
|
|
3,
|
|
"fresh capture with content recovers A's apply",
|
|
);
|
|
assert!(
|
|
shared.captures_with_content() >= 2,
|
|
"recovery capture had to materialize at least once (storage diverged); got {}",
|
|
shared.captures_with_content(),
|
|
);
|
|
|
|
anyhow::Ok(())
|
|
})
|
|
.await
|
|
.unwrap()
|
|
}
|