fix(drivers): don't tear down field I/O when a deploy's artifact reads back empty (#485)
The driver-side half of the same mistake the address-space fix corrected. ReconcileDrivers and PushDesiredSubscriptionsFromArtifact both read the artifact as `...FirstOrDefault() ?? Array.Empty<byte>()`. Their THROW paths already returned early, but empty bytes flowed onwards as a real answer: zero driver specs, so DriverSpawnPlanner planned every running child for StopChild, and then an empty desired set dropped each surviving driver's live subscription handle. A node lost its entire field I/O and still ACKed Applied. Same rule as the address space: no bytes is no answer, not "a configuration with no drivers". Nothing legitimate produces a zero-length blob — deleting the last driver still deploys a JSON document with empty arrays. Both sites now skip and keep what is running. The two guards are independent because PushDesiredSubscriptions does its OWN ConfigDb read, so the row can go missing between them. Also corrects the ReconcileDrivers doc comment, which claimed an empty blob made it "effectively a no-op" — with children running it was the opposite. Tests: DriverHostActorUnreadableArtifactTests, RED-first (verified failing — after the empty dispatch the driver list was empty). A zero-length ArtifactBlob reproduces byte-for-byte what the missing-row case delivers, so the race does not have to be constructed. Two controls, both load-bearing: - a READABLE driverless artifact still stops the driver, so the guard keys on "the read gave us nothing", not on "fewer drivers than before"; - dropping a driver's LAST TAG still clears its subscription. This one was added after the absence assertion was caught passing on a race: the unsubscribe is an async self-tell, so with the second guard deleted the test still went green. The control observes that same unsubscribe arriving well inside the settle window, which is what makes the absence meaningful — with the guard deleted the suite now correctly goes RED. SubscribableStubDriver gains an UnsubscribeCount for that observation. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -104,6 +104,10 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
||||
/// <summary>The reference set passed to the most recent <see cref="SubscribeAsync"/> call.</summary>
|
||||
public IReadOnlyList<string>? LastSubscribedRefs;
|
||||
|
||||
/// <summary>Number of times <see cref="UnsubscribeAsync"/> was called — the observable for a live
|
||||
/// subscription being torn down (an EMPTY desired set drops the handle rather than re-subscribing).</summary>
|
||||
public int UnsubscribeCount;
|
||||
|
||||
/// <summary>When true, <see cref="UnsubscribeAsync"/> genuinely yields (<c>await Task.Yield()</c>)
|
||||
/// before completing, so a <c>ConfigureAwait(false)</c> continuation in the actor resumes off the
|
||||
/// Akka ActorContext on a thread-pool thread — reproducing the no-ActorContext race that a
|
||||
@@ -127,6 +131,7 @@ internal sealed class SubscribableStubDriver : StubDriver, ISubscribable
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref UnsubscribeCount);
|
||||
if (UnsubscribeYields)
|
||||
{
|
||||
// Complete the awaited task from a fresh background thread that has NO Akka actor
|
||||
|
||||
Reference in New Issue
Block a user