fix(driver-calc): rebuild calc tag state on ReinitializeAsync + re-register deps post-apply (review HIGH)

A calc-tag add/remove/edit or a script edit changes the merged DriverConfig,
which DriverSpawnPlanner routes to an in-place ApplyDelta (not a respawn), i.e.
CalculationDriver.ReinitializeAsync. The old ReinitializeAsync ignored the new
config: the tag table + dependency-ref set were built once at construction and
never rebuilt, so after a redeploy added tags never computed, removed tags kept
publishing, and edited scripts served stale results (deploy reported success).

Part 1 (HIGH): extract RebuildTagTable(config) — shared by the ctor and reinit
so they cannot drift — and have ReinitializeAsync re-bind the new config
(ParseOptions) and rebuild _tagsByRawPath/_changeDependents/_dependencyRefs/
_stateByRawPath under _lock, preserving last-known state for surviving tags,
dropping removed tags, and starting new tags fresh. _dependencyRefs now reflects
the new authored tags. Corrected the now-false ReinitializeAsync comment.

Part 2 (MEDIUM ordering hazard): the host's ReRegister was Tell-ed synchronously
alongside the ApplyDelta Tell, so the adapter re-read DependencyRefs before the
async ReinitializeAsync rebuilt them (stale set, no self-correction). Now
DriverInstanceActor sends DeltaApplied(driverInstanceId) to DriverHostActor
AFTER ReinitializeAsync completes, and HandleDeltaApplied re-registers only that
driver's adapter — sequenced after the rebuild. Removed the inline loop.

Test: added a redeploy test to CalculationDependencyFlowTests exercising the
real DependencyMuxActor + adapter + driver — a new tag reading a NEW dep flows +
computes, a removed tag stops publishing, an edited script serves the new
result, and DependencyRefs re-registers the new set. Fails on pre-fix code
(DependencyRefs stays stale ["src/a"]), passes after the fix.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 05:01:58 -04:00
parent 2e6e89f4a5
commit af5e062ba4
4 changed files with 231 additions and 19 deletions
@@ -1,3 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
@@ -46,15 +48,17 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
private readonly ScriptRootLogger _scriptRoot;
private readonly CalculationEvaluator _evaluator;
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built once at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter).
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
private readonly Dictionary<string, List<string>> _changeDependents = new(StringComparer.Ordinal);
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
private readonly IReadOnlyCollection<string> _dependencyRefs;
// Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
private IReadOnlyCollection<string> _dependencyRefs = Array.Empty<string>();
// Live evaluation state. Guarded by _lock: OnDependencyValue (actor thread) and timer ticks
// (pool threads) can both drive an evaluation, so all state mutation is serialized.
@@ -94,16 +98,29 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
_scriptRoot,
_options.RunTimeout);
BuildTagTable();
_dependencyRefs = _tagsByRawPath.Values
.SelectMany(t => t.DependencyRefs)
.Distinct(StringComparer.Ordinal)
.ToArray();
RebuildTagTable(_options);
}
private void BuildTagTable()
/// <summary>
/// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
/// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
/// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
/// and reinit paths share ONE construction routine and cannot drift.
/// <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known
/// value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
/// under <see cref="_lock"/> when invoked post-construction (the ctor runs single-threaded).</para>
/// </summary>
/// <param name="config">The (new) bound options carrying the authored raw calc tags.</param>
private void RebuildTagTable(CalculationDriverOptions config)
{
foreach (var entry in _options.RawTags)
_tagsByRawPath.Clear();
_changeDependents.Clear();
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
var retainedState = new Dictionary<string, TagState>(StringComparer.Ordinal);
foreach (var entry in config.RawTags)
{
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
@@ -120,7 +137,9 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
_driverInstanceId, entry.RawPath, def.ScriptId);
}
_tagsByRawPath[entry.RawPath] = def;
_stateByRawPath[entry.RawPath] = new TagState();
// Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
retainedState[entry.RawPath] =
_stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
if (def.ChangeTriggered)
{
foreach (var dep in def.DependencyRefs)
@@ -131,6 +150,64 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
}
}
}
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
_stateByRawPath.Clear();
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
_dependencyRefs = _tagsByRawPath.Values
.SelectMany(t => t.DependencyRefs)
.Distinct(StringComparer.Ordinal)
.ToArray();
}
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
/// current tags so a malformed delta can never blank the driver.</summary>
private CalculationDriverOptions ParseOptions(string driverConfigJson)
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
try
{
var dto = JsonSerializer.Deserialize<ReinitConfigDto>(driverConfigJson, ReinitJsonOptions);
if (dto is not null)
{
return new CalculationDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
RunTimeout = _options.RunTimeout,
};
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex,
"Calculation {Driver}: reinit config parse failed; keeping the current tag table",
_driverInstanceId);
}
}
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
}
/// <summary>Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
/// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
/// <c>CalculationDriverConfigDto</c>.</summary>
private sealed class ReinitConfigDto
{
public List<RawTagEntry>? RawTags { get; init; }
public int? RunTimeoutMs { get; init; }
}
// ---- IDriver ----
@@ -155,10 +232,22 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// The authored tag table is fixed at construction (a RawTags change respawns the driver, mirroring
// the fleet's factory-built-options pattern). Reinit drops compiled scripts (IScriptCacheOwner
// contract — stops collectible ALCs accreting) + re-arms timers so the calc set stays live.
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
StopTimers();
var newOptions = ParseOptions(driverConfigJson);
lock (_lock)
{
RebuildTagTable(newOptions);
}
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
// recompile from each tag's NEW ScriptSource) + re-arm timers off the rebuilt tag set.
_evaluator.ClearCompiledScripts();
_running = true;
StartTimers();