fix(calc): evaluate off the mux callback thread and out from under the state lock (Driver.Calculation-005)

OnDependencyValue ran every change-triggered dependent's script INLINE, under _lock,
via TimedScriptEvaluator.RunAsync(...).GetAwaiter().GetResult() — on the Akka dispatcher
thread of Runtime's DependencyConsumerMuxAdapter, for up to RunTimeout (2 s default) per
arrival, with the state lock held for the whole run.

The callback now records the value, PREPARES the affected tags under the lock (definition
+ warm-up gate + dependency snapshot taken at trigger time) and queues them for a single
serialized evaluation pump. _lock is a short-held state lock again; a new _evalGate
serializes the pump against the timer ticks and is taken only by evaluation workers, so
no host thread can wait on a script. Lock order is always _evalGate -> _lock.

Semantics preserved: per-tag ordering (one FIFO pump), the warm-up gate BEFORE the
evaluator runs, dedup/Bad-transition/recovery publishes, and — because the dependency
snapshot is taken when the trigger fires rather than when the pump catches up — a burst
of arrivals still evaluates against the values that triggered it.
This commit is contained in:
Joseph Doherty
2026-08-10 08:02:44 -04:00
parent 94b8633e68
commit 235e06a875
2 changed files with 305 additions and 54 deletions
@@ -41,6 +41,18 @@ public sealed class CalculationDriverTests
return (driver, published, logs);
}
/// <summary>
/// Wait until the driver's evaluation pump has drained. A change-triggered evaluation is queued by
/// <c>OnDependencyValue</c> and run on the driver's own worker (Driver.Calculation-005), so a test
/// that used to assert on the very next line now settles first. Quiescence, not a sleep: the work
/// is enqueued synchronously inside the callback, so "idle" can never be observed before it lands.
/// </summary>
private static async Task Settle(CalculationDriver driver)
{
var idle = await WaitUntil(() => driver.IsEvaluationIdle, TimeSpan.FromSeconds(10));
idle.ShouldBeTrue("the calc driver's evaluation pump should have drained");
}
// ── dependency-ref extraction ─────────────────────────────────────────────────────────────
[Fact]
@@ -64,10 +76,12 @@ public sealed class CalculationDriverTests
// Only one of two deps arrived → the change-gate holds.
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty();
// Second dep arrives → now it computes + publishes 30.
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var e).ShouldBeTrue();
e!.FullReference.ShouldBe("calc/e/sum");
@@ -90,12 +104,85 @@ public sealed class CalculationDriverTests
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty();
logs.ShouldBeEmpty("a still-missing declared dependency must hold the gate BEFORE evaluation, " +
"not merely before the publish");
}
// ── the dependency callback must not execute scripts (Driver.Calculation-005) ─────────────
/// <summary>
/// <c>OnDependencyValue</c> is called <b>directly and synchronously</b> from
/// <c>DependencyConsumerMuxAdapter.OnDependencyValueChanged</c> — i.e. on an Akka actor's own
/// dispatcher thread. Evaluating there means a script run (bounded only by <c>RunTimeout</c>,
/// default 2 s) occupies that thread, so the callback must queue the work and return instead.
/// <para>The publish is the observable: it can only happen after the script ran, so a publish on
/// the caller's own thread proves the script ran inline. The value assertion is the positive
/// control — without it this would pass just as happily against a driver that evaluates nothing.</para>
/// </summary>
[Fact]
public async Task A_dependency_arrival_does_not_run_the_script_on_the_callers_thread()
{
var (driver, published, _) = Build(
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
var publishThreadId = 0;
driver.OnDataChange += (_, _) => publishThreadId = Environment.CurrentManagedThreadId;
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var callerThreadId = Environment.CurrentManagedThreadId;
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow);
(await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(5)))
.ShouldBeTrue("the queued evaluation must still compute and publish the value");
published.TryDequeue(out var e).ShouldBeTrue();
e!.Snapshot.Value.ShouldBe(10.0);
publishThreadId.ShouldNotBe(callerThreadId,
"the calc script must run on the driver's own evaluation pump, never on the mux callback thread");
}
/// <summary>
/// The second half of Driver.Calculation-005: the evaluation also used to run <b>under the state
/// lock</b>, so a slow script froze every other lock-taking operation (here <c>ReadAsync</c>) for
/// its whole run as well as the callback itself. Both must now return while the script is still
/// executing; the trailing publish is the positive control that the script did run.
/// </summary>
[Fact]
public async Task A_slow_script_blocks_neither_the_dependency_callback_nor_a_concurrent_read()
{
// A CPU-bound busy-wait: the sandbox forbids Thread/Task, and DateTime is explicitly allowed.
const string slowScript =
"var deadline = System.DateTime.UtcNow.AddMilliseconds(600); " +
"while (System.DateTime.UtcNow < deadline) { } " +
"return (double)ctx.GetTag(\"src/a\").Value;";
var (driver, published, _) = Build(CalcTag("calc/e/slow", slowScript));
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var elapsed = System.Diagnostics.Stopwatch.StartNew();
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow);
var callbackElapsed = elapsed.Elapsed;
// ReadAsync takes the same state lock the inline evaluation used to hold for the whole run.
var snapshot = await driver.ReadAsync(["calc/e/slow"], TestContext.Current.CancellationToken);
var readElapsed = elapsed.Elapsed;
callbackElapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(300),
"the mux callback thread must not wait on the script run");
readElapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(300),
"the state lock must not be held across the script run");
snapshot[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData,
"the read got through while the evaluation was still in flight");
(await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(10)))
.ShouldBeTrue("the slow evaluation must still complete and publish");
published.TryDequeue(out var e).ShouldBeTrue();
e!.Snapshot.Value.ShouldBe(7.0);
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
// ── dedup + recompute ─────────────────────────────────────────────────────────────────────
[Fact]
@@ -105,9 +192,12 @@ public sealed class CalculationDriverTests
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
await driver.InitializeAsync("{}", default);
// Each arrival is queued with the dependency snapshot that triggered it, so the three evaluations
// see 5 / 5 / 7 in that order however far the pump lags behind them.
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (publish)
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (dedup, no publish)
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow); // → 14 (publish)
await Settle(driver);
published.Select(p => p.Snapshot.Value).ShouldBe(new object?[] { 10.0, 14.0 });
}
@@ -123,6 +213,7 @@ public sealed class CalculationDriverTests
// a = 0 → DivideByZero → Failure → Bad (carrying last-known = null) + one script-log.
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
@@ -133,11 +224,13 @@ public sealed class CalculationDriverTests
// a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.ShouldBeEmpty();
logs.Count.ShouldBe(2);
// a = 5 → 20 → Good recovery is force-published.
driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
await Settle(driver);
published.Count.ShouldBe(1);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.Value.ShouldBe(20);
@@ -175,6 +268,7 @@ public sealed class CalculationDriverTests
before[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
await Settle(driver);
var after = await driver.ReadAsync(new[] { "calc/e/x" }, default);
after[0].StatusCode.ShouldBe(StatusGood);
after[0].Value.ShouldBe(6.0);