fix(drivers): mid-connect ApplyDelta adopts config in Calculation, refuses it in Galaxy (#664)

AdoptConfigDuringInit re-runs InitializeAsync(newConfig) and acks success, but
Calculation and Galaxy ignored the JSON there: Calculation kept its old tag
set, and Galaxy connected on its old endpoint. Calculation now rebuilds its tag
table from a config with a body (sharing AdoptConfig with ReinitializeAsync and
stopping any running timer scheduler first). Galaxy, which cannot hot-swap its
options, refuses a non-equivalent one the way ReinitializeAsync does. The actor
now sends DeltaApplied once the adopted init succeeds, so a dependency
consumer's mux interest is re-registered. Unreachable from deploys since #516.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 11:05:58 -04:00
parent 1355049f50
commit 97fcf624ae
8 changed files with 234 additions and 52 deletions
@@ -239,6 +239,35 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
}
/// <summary>Re-bind the tag table from <paramref name="driverConfigJson"/> — shared by
/// <see cref="ReinitializeAsync"/> and (for a config with a body) <see cref="InitializeAsync"/> so the two
/// adoption paths cannot drift. Timers must already be stopped.</summary>
private void AdoptConfig(string driverConfigJson)
{
var newOptions = ParseOptions(driverConfigJson);
lock (_lock)
{
// Queued evaluations were prepared against the OUTGOING tag table (old script source, old
// dependency set), so they are stale the moment the rebuild lands — drop them rather than
// publish one last pre-edit value after the delta was applied.
_pending.Clear();
RebuildTagTable(newOptions);
}
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
// recompile from each tag's NEW ScriptSource).
_evaluator.ClearCompiledScripts();
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes a
/// populated document; unit tests pass <c>"{}"</c> to exercise lifecycle shape — those keep the
/// constructor-supplied tags (an empty document would otherwise bind to an EMPTY tag table).</summary>
private static bool HasConfigBody(string? driverConfigJson)
{
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
var trimmed = driverConfigJson.Trim();
return trimmed is not "{}" and not "[]";
}
/// <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>
@@ -257,8 +286,17 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
public string DriverType => DriverTypeNames.Calculation;
/// <inheritdoc />
/// <remarks>
/// #664: <c>InitializeAsync</c> is reachable on a LIVE driver — <c>DriverInstanceActor.AdoptConfigDuringInit</c>
/// re-runs it with a NEW config when an ApplyDelta lands mid-connect — so it adopts a config carrying a
/// real body (the #516 convention every other driver follows), and disposes any running timer scheduler
/// before arming a fresh one (#630: re-initialise without teardown must not leak). A <c>"{}"</c> / empty
/// document keeps the constructor-supplied tags, as unit tests rely on.
/// </remarks>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
StopTimers();
if (HasConfigBody(driverConfigJson)) AdoptConfig(driverConfigJson);
_running = true;
StartTimers();
_logger.LogInformation(
@@ -279,18 +317,8 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
// 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)
{
// Queued evaluations were prepared against the OUTGOING tag table (old script source, old
// dependency set), so they are stale the moment the rebuild lands — drop them rather than
// publish one last pre-edit value after the delta was applied.
_pending.Clear();
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();
AdoptConfig(driverConfigJson);
// Re-arm timers off the rebuilt tag set.
_running = true;
StartTimers();
return Task.CompletedTask;