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:
@@ -413,6 +413,34 @@ public sealed class CalculationDriverTests
|
||||
report.SkippedCount.ShouldBe(1, "the reinit payload's tag has no scriptId, so it is no longer a calc tag");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a LIVE driver with a new config when an ApplyDelta lands
|
||||
/// mid-connect (<c>DriverInstanceActor.AdoptConfigDuringInit</c>). It used to ignore the JSON, so the
|
||||
/// driver kept its constructor tag set while the deploy reported the change applied. A config with a body
|
||||
/// now rebuilds the tag table (and the dependency-ref set the host re-registers from).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_on_a_live_driver_adopts_a_config_with_a_body()
|
||||
{
|
||||
var (driver, published, _) = Build(CalcTag("calc/e/a", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
driver.DependencyRefs.ShouldBe(new[] { "src/a" });
|
||||
|
||||
var adopted = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
rawTags = new[] { CalcTag("calc/e/b", "return (double)ctx.GetTag(\"src/b\").Value + 1.0;") },
|
||||
});
|
||||
await driver.InitializeAsync(adopted, TestContext.Current.CancellationToken);
|
||||
|
||||
driver.DependencyRefs.ShouldBe(new[] { "src/b" });
|
||||
driver.OnDependencyValue("src/b", 4.0, StatusGood, DateTime.UtcNow);
|
||||
await Settle(driver);
|
||||
published.ShouldContain(e => e.FullReference == "calc/e/b" && Equals(e.Snapshot.Value, 5.0));
|
||||
published.ShouldNotContain(e => e.FullReference == "calc/e/a");
|
||||
|
||||
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Minimal capturing <see cref="ILogger{T}"/> so a test can assert on what the driver's own
|
||||
/// logger received (mirrors the equivalent helper in CalculationEvaluatorTests).</summary>
|
||||
private sealed class CapturingLogger : ILogger<CalculationDriver>
|
||||
|
||||
@@ -168,7 +168,9 @@ public sealed class GalaxyDriverFactoryTests
|
||||
dataWriter: null, subscriber: new NoopSubscriber());
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
|
||||
// #664: InitializeAsync now refuses a config that differs from the live options, so this uses the
|
||||
// "{}" lifecycle shape (MinimalConfig's ApiKeySecretRef differs from BuildOptions()).
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||||
|
||||
@@ -206,6 +208,38 @@ public sealed class GalaxyDriverFactoryTests
|
||||
driver.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(firstStamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #664 — <c>InitializeAsync</c> re-runs on a live driver with a NEW config when an ApplyDelta lands
|
||||
/// mid-connect. Galaxy cannot hot-swap its options, and it used to ignore the JSON — connecting on the OLD
|
||||
/// endpoint while the deploy reported the change applied. A non-equivalent config is now refused, exactly
|
||||
/// as <c>ReinitializeAsync</c> refuses it; an equivalent one still initialises.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_refuses_a_non_equivalent_config_and_accepts_an_equivalent_one()
|
||||
{
|
||||
const string moved = """
|
||||
{
|
||||
"Gateway": { "Endpoint": "https://elsewhere.test:5001", "ApiKeySecretRef": "key" },
|
||||
"MxAccess": { "ClientName": "OtOpcUa-A" }
|
||||
}
|
||||
""";
|
||||
const string same = """
|
||||
{
|
||||
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "key" },
|
||||
"MxAccess": { "ClientName": "OtOpcUa-A" }
|
||||
}
|
||||
""";
|
||||
using var driver = new GalaxyDriver(
|
||||
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
|
||||
dataWriter: null, subscriber: new NoopSubscriber());
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() => driver.InitializeAsync(moved, CancellationToken.None));
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Unknown, "a refused init must not report Healthy");
|
||||
|
||||
await driver.InitializeAsync(same, CancellationToken.None);
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static GalaxyDriverOptions BuildOptions() => new(
|
||||
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
||||
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
||||
@@ -283,7 +317,8 @@ public sealed class GalaxyDriverFactoryTests
|
||||
using var driver = new GalaxyDriver(
|
||||
"g", BuildOptions(), hierarchySource: null, dataReader: null,
|
||||
dataWriter: null, subscriber: new NoopSubscriber());
|
||||
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
|
||||
// #664: "{}" — MinimalConfig is not equivalent to BuildOptions(), and InitializeAsync now refuses that.
|
||||
await driver.InitializeAsync("{}", CancellationToken.None);
|
||||
driver.GetHostStatuses().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -338,6 +338,40 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
|
||||
driver.InitConfigs.ShouldContain(good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #664 — a mid-connect adoption sends <see cref="DriverInstanceActor.DeltaApplied"/> once the
|
||||
/// driver has initialised on the ADOPTED config, so the host re-registers a dependency consumer's mux
|
||||
/// interest (the Calculation driver's refs change with its tag set). It used to send nothing, leaving the
|
||||
/// old interest registered. The notification is one-shot: a later reconnect on the same config sends none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_mid_connect_adoption_notifies_DeltaApplied_once_after_the_adopted_init_succeeds()
|
||||
{
|
||||
const string bad = "{\"v\":\"bad\"}";
|
||||
const string good = "{\"v\":\"good\"}";
|
||||
var driver = new SubscribableStubDriver
|
||||
{
|
||||
InitBehavior = cfg => cfg == good ? Task.CompletedTask : throw new InvalidOperationException("bad-cfg"),
|
||||
};
|
||||
var parent = CreateTestProbe();
|
||||
parent.IgnoreMessages(m => m is not DriverInstanceActor.DeltaApplied);
|
||||
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(50)));
|
||||
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
||||
|
||||
actor.Tell(new DriverInstanceActor.InitializeRequested(bad));
|
||||
AwaitCondition(() => driver.InitializeCount >= 2, PresenceBudget);
|
||||
|
||||
actor.Tell(new DriverInstanceActor.ApplyDelta(good, CorrelationId.NewId()));
|
||||
parent.ExpectMsg<DriverInstanceActor.DeltaApplied>(PresenceBudget);
|
||||
driver.InitConfigs.ShouldContain(good, "the notification must follow an init on the adopted config");
|
||||
|
||||
// A reconnect on the same config re-runs InitializeSucceeded; the positive control is its re-subscribe.
|
||||
var subscribes = driver.SubscribeCount;
|
||||
actor.Tell(new DriverInstanceActor.ForceReconnect());
|
||||
AwaitCondition(() => driver.SubscribeCount > subscribes, PresenceBudget);
|
||||
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
/// <summary>A stale InitializeSucceeded from an old (superseded) config cannot hijack the state:
|
||||
/// while a gated old-config init is pending in Connecting, an ApplyDelta adopts a new config; the
|
||||
/// old init completing afterwards is ignored, and only the new config drives Connected.</summary>
|
||||
|
||||
Reference in New Issue
Block a user