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:
@@ -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;
|
||||
|
||||
@@ -348,9 +348,18 @@ public sealed class GalaxyDriver
|
||||
ReplayAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #664: <c>InitializeAsync</c> is reachable on a live driver with a NEW config —
|
||||
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> re-runs it when an ApplyDelta lands mid-connect. The
|
||||
/// driver cannot hot-swap its options (they shape the gw session; see <see cref="ReinitializeAsync"/>), so a
|
||||
/// non-equivalent config with a real body is REFUSED here too rather than silently ignored — the driver
|
||||
/// would otherwise connect on its old endpoint while the deploy reported the change applied. A
|
||||
/// <c>"{}"</c> / empty document is the unit-test lifecycle shape and keeps the constructor options.
|
||||
/// </remarks>
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (HasConfigBody(driverConfigJson)) RefuseNonEquivalentConfig(driverConfigJson);
|
||||
|
||||
// Tests inject seams via the internal ctor; production InitializeAsync builds
|
||||
// the gateway client + session + per-capability runtime components from
|
||||
@@ -650,48 +659,65 @@ public sealed class GalaxyDriver
|
||||
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||
{
|
||||
// In-place config reapply. The driver does not currently support hot-swapping
|
||||
// GalaxyDriverOptions at runtime — changing the gateway endpoint, MxAccess
|
||||
// client name, or reconnect policy requires tearing down the gw session,
|
||||
// supervisor, event pump, and address space. The host stack handles that via
|
||||
// DriverInstance restart, so this method only accepts an equivalent config (no
|
||||
// meaningful change) and refreshes health; a non-equivalent reapply throws
|
||||
// NotSupportedException so the caller knows the change wasn't applied.
|
||||
//
|
||||
// Materialise the incoming config and compare against the live options. We
|
||||
// refuse any change that would require a session teardown rather than
|
||||
// pretending to apply it.
|
||||
GalaxyDriverOptions incoming;
|
||||
try
|
||||
{
|
||||
// Reuse the factory's parse pipeline so any missing-required-field
|
||||
// error surfaces with the same diagnostic text as InitializeAsync.
|
||||
var transient = GalaxyDriverFactoryExtensions.CreateInstance(_driverInstanceId, driverConfigJson);
|
||||
incoming = transient.Options;
|
||||
// The transient instance never started a runtime — disposing is cheap.
|
||||
transient.Dispose();
|
||||
}
|
||||
catch (Exception ex) when (ex is not NotSupportedException and not ObjectDisposedException)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"GalaxyDriver.ReinitializeAsync could not parse the incoming DriverConfig JSON for '{_driverInstanceId}': {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (!OptionsAreEquivalent(_options, incoming))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"GalaxyDriver.ReinitializeAsync does not support hot-swapping driver options at runtime " +
|
||||
"(gateway endpoint, MxAccess client name, reconnect policy, etc.). Restart the DriverInstance " +
|
||||
"through the host stack to apply a config change.");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(driverConfigJson)) RefuseNonEquivalentConfig(driverConfigJson);
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>True when the supplied DriverConfig JSON carries a real body (not empty / <c>"{}"</c> / <c>"[]"</c>).</summary>
|
||||
private static bool HasConfigBody(string? driverConfigJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||||
var trimmed = driverConfigJson.Trim();
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="NotSupportedException"/> unless <paramref name="driverConfigJson"/> describes options
|
||||
/// runtime-equivalent to the live ones (see <see cref="OptionsAreEquivalent"/>). Shared by
|
||||
/// <see cref="ReinitializeAsync"/> and <see cref="InitializeAsync"/> (#664) so neither path can silently
|
||||
/// ignore a config change it cannot apply.
|
||||
/// </summary>
|
||||
private void RefuseNonEquivalentConfig(string driverConfigJson)
|
||||
{
|
||||
// In-place config reapply. The driver does not currently support hot-swapping
|
||||
// GalaxyDriverOptions at runtime — changing the gateway endpoint, MxAccess
|
||||
// client name, or reconnect policy requires tearing down the gw session,
|
||||
// supervisor, event pump, and address space. The host stack handles that via
|
||||
// DriverInstance restart, so this method only accepts an equivalent config (no
|
||||
// meaningful change) and refreshes health; a non-equivalent reapply throws
|
||||
// NotSupportedException so the caller knows the change wasn't applied.
|
||||
//
|
||||
// Materialise the incoming config and compare against the live options. We
|
||||
// refuse any change that would require a session teardown rather than
|
||||
// pretending to apply it.
|
||||
GalaxyDriverOptions incoming;
|
||||
try
|
||||
{
|
||||
// Reuse the factory's parse pipeline so any missing-required-field
|
||||
// error surfaces with the same diagnostic text as InitializeAsync.
|
||||
var transient = GalaxyDriverFactoryExtensions.CreateInstance(_driverInstanceId, driverConfigJson);
|
||||
incoming = transient.Options;
|
||||
// The transient instance never started a runtime — disposing is cheap.
|
||||
transient.Dispose();
|
||||
}
|
||||
catch (Exception ex) when (ex is not NotSupportedException and not ObjectDisposedException)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"GalaxyDriver could not parse the incoming DriverConfig JSON for '{_driverInstanceId}': {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (!OptionsAreEquivalent(_options, incoming))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"GalaxyDriver does not support hot-swapping driver options at runtime " +
|
||||
"(gateway endpoint, MxAccess client name, reconnect policy, etc.). Restart the DriverInstance " +
|
||||
"through the host stack to apply a config change.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare two <see cref="GalaxyDriverOptions"/> for runtime equivalence — every
|
||||
/// field that drives gw session shape or reconnect behaviour must match. Compared
|
||||
|
||||
Reference in New Issue
Block a user