fix(galaxy): #859 review — read path window, atomic promote, session epoch, per-call windows
1. ReadAsync (subscribe-once) now opens the early-event window around its SubscribeBulk + registration; an initial value that overtook the reply was dropped and the read returned BadTimeout. 2. Register/Rebind write the reverse map and promote parked events in ONE critical section under the park gate, and ResolveOrPark resolves under that gate whenever anything is held (lock-free fast path only when nothing is), so a later live event can no longer overtake the parked initial value. A resolved handle that still holds a parked list queues behind it. 3. Held events are fenced by a session epoch: each new EventPump calls StartNewEpoch(), discarding parked AND promoted-but-unreplayed events; an older pump can neither park nor replay. Item-handle reuse across a reconnect can no longer put an old-session value on a new tag. 4. ResubscribeAllAsync opens one window per subscription, not across the whole replay. 5. Promoted-but-unreplayed (ready) events count toward MaxParkedEvents. 6. Begin/End of a window run under the park gate, so N->0 discard and 0->1 open are atomic (no inherited leftovers). Documented that ParkedEventsReady may run under _registryGate/_syncLock and relies on the channel's AllowSynchronousContinuations=false. Tests use TestWait (#858); each new test was checked red against a mutation of the fix it pins. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -996,10 +996,6 @@ public sealed class GalaxyDriver
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tagCount = 0;
|
||||
|
||||
// #859: the re-issued handles' initial values (the whole point of a re-snapshot) can
|
||||
// overtake each SubscribeBulk reply; park them until the matching Rebind promotes them.
|
||||
using var earlyEventWindow = _subscriptions.BeginPendingSubscribe();
|
||||
foreach (var (subscriptionId, oldBindings) in entries)
|
||||
{
|
||||
if (Volatile.Read(ref _runtimeGeneration) != generation)
|
||||
@@ -1023,6 +1019,12 @@ public sealed class GalaxyDriver
|
||||
.ToArray();
|
||||
if (refs.Length == 0) continue;
|
||||
|
||||
// #859: the re-issued handles' initial values (the whole point of a re-snapshot) can
|
||||
// overtake this SubscribeBulk reply; park them until the Rebind below promotes them.
|
||||
// Scoped to this one iteration (the using is disposed at the end of the loop body): a
|
||||
// window held across all N round-trips would park every unrelated unknown-handle event
|
||||
// for the whole replay and could exhaust the bound before the genuine early values.
|
||||
using var earlyEventWindow = _subscriptions.BeginPendingSubscribe();
|
||||
var results = await _subscriber!
|
||||
.SubscribeBulkAsync(refs, _options.MxAccess.PublishingIntervalMs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -1457,75 +1459,89 @@ public sealed class GalaxyDriver
|
||||
|
||||
var bufferedIntervalMs = _options.MxAccess.PublishingIntervalMs;
|
||||
IReadOnlyList<SubscribeResult> results;
|
||||
// The RPC gets only the CALLER's token: the internal deadline bounds our wait, not the RPC, so a slow
|
||||
// gateway can never surface an OperationCanceledException to a caller that did not cancel, and the
|
||||
// handles it eventually creates are known (and unsubscribed below) rather than lost to a cancelled call.
|
||||
var subscribeTask = readSubscriber.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, callerToken);
|
||||
List<TagBinding> bindings;
|
||||
|
||||
// #859: a read waits ONLY for the initial value, and that value can overtake the SubscribeBulk
|
||||
// reply — dropped as stale, a slow-changing attribute would read BadTimeout. Open the early-event
|
||||
// window before the RPC and close it right after registration (the finally below), not after the
|
||||
// value wait: a window held for the whole wait would park every unrelated unknown-handle event.
|
||||
var earlyEventWindow = _subscriptions.BeginPendingSubscribe();
|
||||
try
|
||||
{
|
||||
results = await subscribeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
|
||||
{
|
||||
// Internal deadline fired while the subscribe RPC was still in flight.
|
||||
pump.OnDataChange -= handler;
|
||||
var subscriber = readSubscriber;
|
||||
var logger = _logger;
|
||||
_ = subscribeTask.ContinueWith(t =>
|
||||
// The RPC gets only the CALLER's token: the internal deadline bounds our wait, not the RPC, so a slow
|
||||
// gateway can never surface an OperationCanceledException to a caller that did not cancel, and the
|
||||
// handles it eventually creates are known (and unsubscribed below) rather than lost to a cancelled call.
|
||||
var subscribeTask = readSubscriber.SubscribeBulkAsync(fullReferences, bufferedIntervalMs, callerToken);
|
||||
try
|
||||
{
|
||||
if (!t.IsCompletedSuccessfully) return Task.CompletedTask;
|
||||
var late = t.Result.Where(r => r.WasSuccessful && r.ItemHandle > 0).Select(r => r.ItemHandle).ToArray();
|
||||
return late.Length == 0
|
||||
? Task.CompletedTask
|
||||
: subscriber.UnsubscribeBulkAsync(late, CancellationToken.None).ContinueWith(
|
||||
u => logger.LogWarning(u.Exception,
|
||||
"GalaxyDriver.ReadViaSubscribeOnceAsync late UnsubscribeBulk failed for {Count} handle(s).", late.Length),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}, TaskScheduler.Default).Unwrap();
|
||||
var now = DateTime.UtcNow;
|
||||
var timedOut = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < timedOut.Length; i++)
|
||||
timedOut[i] = new DataValueSnapshot(null, StatusCodeMap.BadTimeout, null, now);
|
||||
return timedOut;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pump.OnDataChange -= handler;
|
||||
throw;
|
||||
}
|
||||
|
||||
// Register bindings so the pump knows to dispatch events for these handles.
|
||||
// Index the SubscribeBulk results once and correlate to references in O(1)
|
||||
// instead of FirstOrDefault per element (O(n²) over the batch). On the
|
||||
// 50k-tag soak path this turns a 2.5G-comparison loop into a single
|
||||
// Dictionary build + linear scan.
|
||||
var resultIndex = BuildResultIndex(results);
|
||||
var bindings = new List<TagBinding>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var fullRef = fullReferences[i];
|
||||
var itemHandle = resultIndex.TryGetValue(fullRef, out var match) && match is { WasSuccessful: true }
|
||||
? match.ItemHandle
|
||||
: 0;
|
||||
bindings.Add(new TagBinding(fullRef, itemHandle));
|
||||
|
||||
// Tags the gw rejected up front — complete with Bad status now so the
|
||||
// wait below doesn't time out on them.
|
||||
if (itemHandle <= 0
|
||||
&& pendingByRef.TryGetValue(fullRef, out var rejectedTcs))
|
||||
results = await subscribeTask.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
|
||||
{
|
||||
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: StatusCodeMap.Bad,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
// Internal deadline fired while the subscribe RPC was still in flight.
|
||||
pump.OnDataChange -= handler;
|
||||
var subscriber = readSubscriber;
|
||||
var logger = _logger;
|
||||
_ = subscribeTask.ContinueWith(t =>
|
||||
{
|
||||
if (!t.IsCompletedSuccessfully) return Task.CompletedTask;
|
||||
var late = t.Result.Where(r => r.WasSuccessful && r.ItemHandle > 0).Select(r => r.ItemHandle).ToArray();
|
||||
return late.Length == 0
|
||||
? Task.CompletedTask
|
||||
: subscriber.UnsubscribeBulkAsync(late, CancellationToken.None).ContinueWith(
|
||||
u => logger.LogWarning(u.Exception,
|
||||
"GalaxyDriver.ReadViaSubscribeOnceAsync late UnsubscribeBulk failed for {Count} handle(s).", late.Length),
|
||||
TaskContinuationOptions.OnlyOnFaulted);
|
||||
}, TaskScheduler.Default).Unwrap();
|
||||
var now = DateTime.UtcNow;
|
||||
var timedOut = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < timedOut.Length; i++)
|
||||
timedOut[i] = new DataValueSnapshot(null, StatusCodeMap.BadTimeout, null, now);
|
||||
return timedOut;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pump.OnDataChange -= handler;
|
||||
throw;
|
||||
}
|
||||
|
||||
// Register bindings so the pump knows to dispatch events for these handles.
|
||||
// Index the SubscribeBulk results once and correlate to references in O(1)
|
||||
// instead of FirstOrDefault per element (O(n²) over the batch). On the
|
||||
// 50k-tag soak path this turns a 2.5G-comparison loop into a single
|
||||
// Dictionary build + linear scan.
|
||||
var resultIndex = BuildResultIndex(results);
|
||||
bindings = new List<TagBinding>(fullReferences.Count);
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var fullRef = fullReferences[i];
|
||||
var itemHandle = resultIndex.TryGetValue(fullRef, out var match) && match is { WasSuccessful: true }
|
||||
? match.ItemHandle
|
||||
: 0;
|
||||
bindings.Add(new TagBinding(fullRef, itemHandle));
|
||||
|
||||
// Tags the gw rejected up front — complete with Bad status now so the
|
||||
// wait below doesn't time out on them.
|
||||
if (itemHandle <= 0
|
||||
&& pendingByRef.TryGetValue(fullRef, out var rejectedTcs))
|
||||
{
|
||||
rejectedTcs.TrySetResult(new DataValueSnapshot(
|
||||
Value: null,
|
||||
StatusCode: StatusCodeMap.Bad,
|
||||
SourceTimestampUtc: null,
|
||||
ServerTimestampUtc: DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
if (!TryRegisterForGeneration(subscriptionId, bindings, generation))
|
||||
{
|
||||
pump.OnDataChange -= handler;
|
||||
await ReleaseOrphanedHandlesAsync(readSubscriber, bindings).ConfigureAwait(false);
|
||||
throw TornDownDuringSubscribe();
|
||||
}
|
||||
}
|
||||
if (!TryRegisterForGeneration(subscriptionId, bindings, generation))
|
||||
finally
|
||||
{
|
||||
pump.OnDataChange -= handler;
|
||||
await ReleaseOrphanedHandlesAsync(readSubscriber, bindings).ConfigureAwait(false);
|
||||
throw TornDownDuringSubscribe();
|
||||
earlyEventWindow.Dispose();
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -138,9 +138,16 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
SingleWriter = false,
|
||||
});
|
||||
_clientTag = new KeyValuePair<string, object?>("galaxy.client", clientName ?? "<unknown>");
|
||||
|
||||
// One pump per gateway session: a new epoch discards whatever an earlier session left held
|
||||
// (#859 review — item handles are reused across sessions), and this pump never parks or
|
||||
// replays under any other epoch.
|
||||
_epoch = _registry.StartNewEpoch();
|
||||
_registry.ParkedEventsReady += WakeForReadyEvents;
|
||||
}
|
||||
|
||||
private readonly int _epoch;
|
||||
|
||||
/// <summary>
|
||||
/// Marker written into the channel to wake the dispatch loop after a registration promoted
|
||||
/// parked early events (#859). Never dispatched itself; compared by reference.
|
||||
@@ -149,9 +156,12 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Wakes the dispatch loop so promoted early events replay even if no further event arrives.
|
||||
/// Runs on the registering thread, possibly under the driver's <c>_registryGate</c> or the probe
|
||||
/// watcher's <c>_syncLock</c> — safe because the channel keeps the default
|
||||
/// <c>AllowSynchronousContinuations = false</c>, so the write never runs the dispatch loop inline.
|
||||
/// A failed write is harmless: the channel is either full (the loop is busy and replays the
|
||||
/// ready queue before its next data-change dispatch) or completed (the pump is dead, and the
|
||||
/// next pump on this registry replays them).
|
||||
/// ready queue before its next dispatch) or completed (the pump is dead; its held events
|
||||
/// belong to a session the next pump's new epoch discards).
|
||||
/// </summary>
|
||||
private void WakeForReadyEvents() => _channel.Writer.TryWrite(WakeSentinel);
|
||||
|
||||
@@ -353,11 +363,13 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
|
||||
private void DispatchDataChange(MxEvent ev)
|
||||
{
|
||||
var outcome = _registry.ResolveOrPark(ev, out var subscribers);
|
||||
var outcome = _registry.ResolveOrPark(ev, _epoch, out var subscribers);
|
||||
|
||||
// Replay promoted early events (#859) AFTER the resolve and BEFORE fanning this event out:
|
||||
// any registration that let this event resolve has already moved its parked (older) events
|
||||
// to the ready queue, so they reach the handler first and per-handle order is preserved.
|
||||
// Replay promoted early events (#859) AFTER the resolve and BEFORE fanning this event out.
|
||||
// A registration writes the reverse map and promotes its parked events in ONE critical
|
||||
// section under the registry's park gate, and ResolveOrPark resolves under that gate
|
||||
// whenever anything is held. So if this event resolved, every older parked event for its
|
||||
// handle is already in the ready queue, and replaying it first preserves per-handle order.
|
||||
DispatchReadyEvents();
|
||||
|
||||
switch (outcome)
|
||||
@@ -386,7 +398,7 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
/// </summary>
|
||||
private void DispatchReadyEvents()
|
||||
{
|
||||
var ready = _registry.TakeReadyEvents();
|
||||
var ready = _registry.TakeReadyEvents(_epoch);
|
||||
if (ready.Count == 0) return;
|
||||
foreach (var early in ready)
|
||||
{
|
||||
|
||||
@@ -70,17 +70,27 @@ internal sealed class SubscriptionRegistry
|
||||
/// <param name="bindings">The tag bindings for the subscription.</param>
|
||||
public void Register(long subscriptionId, IReadOnlyList<TagBinding> bindings)
|
||||
{
|
||||
var entry = new SubscriptionEntry(subscriptionId, bindings);
|
||||
_bySubscriptionId[subscriptionId] = entry;
|
||||
foreach (var binding in bindings)
|
||||
bool promoted;
|
||||
// The reverse-map writes and the early-event promotion are ONE critical section under the
|
||||
// park gate (#859 review): a dispatch that resolves a handle through the map must never
|
||||
// run between the write and the promotion, or a later live event would overtake the
|
||||
// parked initial value. ResolveOrPark takes the same gate whenever anything is held.
|
||||
lock (_parkGate)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
|
||||
_subscribersByItemHandle.AddOrUpdate(
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
var entry = new SubscriptionEntry(subscriptionId, bindings);
|
||||
_bySubscriptionId[subscriptionId] = entry;
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
|
||||
_subscribersByItemHandle.AddOrUpdate(
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
}
|
||||
AfterMapWriteForTests?.Invoke();
|
||||
promoted = PromoteParkedEventsLocked(bindings);
|
||||
}
|
||||
PromoteParkedEvents(bindings);
|
||||
if (promoted) ParkedEventsReady?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -143,8 +153,8 @@ internal sealed class SubscriptionRegistry
|
||||
lock (_parkGate)
|
||||
{
|
||||
_parkedByItemHandle.Clear();
|
||||
_parkedCount = 0;
|
||||
_ready.Clear();
|
||||
Volatile.Write(ref _heldCount, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,60 +180,110 @@ internal sealed class SubscriptionRegistry
|
||||
// the handle — so per-handle value order is preserved. When the last in-flight subscribe ends,
|
||||
// whatever is still parked belonged to no subscription and is discarded, so the genuine
|
||||
// stale-after-unsubscribe drop is unchanged outside the window.
|
||||
//
|
||||
// Item handles are only meaningful within one gateway session, and a new session reuses small
|
||||
// integers. Every held event therefore belongs to an EPOCH: each EventPump (one per session —
|
||||
// reconnect replay recreates it) starts a new epoch, which discards everything held, and an
|
||||
// event parked or replayed by a pump of an older epoch is dropped. Otherwise an old-session
|
||||
// value could be promoted onto whichever tag now owns the reused handle number.
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on events parked across all in-flight subscribes. Sized to hold the initial
|
||||
/// values of a large bulk subscribe (MXAccess sends one per advised item); overflow is
|
||||
/// dropped and counted by the pump, i.e. it degrades to the pre-#859 behaviour.
|
||||
/// Upper bound on events held (parked + promoted-but-not-yet-replayed) across all in-flight
|
||||
/// subscribes. Sized to hold the initial values of a large bulk subscribe (MXAccess sends
|
||||
/// one per advised item); overflow is dropped and counted by the pump, i.e. it degrades to
|
||||
/// the pre-#859 behaviour.
|
||||
/// </summary>
|
||||
public const int MaxParkedEvents = 100_000;
|
||||
|
||||
private readonly Lock _parkGate = new();
|
||||
private readonly Dictionary<int, List<MxEvent>> _parkedByItemHandle = new();
|
||||
private readonly Queue<MxEvent> _ready = new();
|
||||
private int _parkedCount;
|
||||
|
||||
// Parked + ready. Written only under _parkGate; read lock-free (Volatile) by the dispatch fast path.
|
||||
private int _heldCount;
|
||||
|
||||
// Open windows. Written only under _parkGate (so the 0→N / N→0 transitions and the discard of
|
||||
// the previous window's leftovers are atomic); read lock-free by the dispatch fast path.
|
||||
private int _pendingSubscribes;
|
||||
private int _epoch;
|
||||
|
||||
/// <summary>
|
||||
/// Raised (outside any lock) when a registration promoted parked events to the ready queue,
|
||||
/// so the <see cref="EventPump"/> can wake its dispatch loop even if no further event arrives.
|
||||
/// Test seam (#859 review): invoked inside the registration critical section, between the
|
||||
/// reverse-map writes and the promotion of parked events.
|
||||
/// </summary>
|
||||
internal Action? AfterMapWriteForTests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a registration promoted parked events to the ready queue, so the
|
||||
/// <see cref="EventPump"/> can wake its dispatch loop even if no further event arrives.
|
||||
/// Raised after the park gate is released but still on the registering thread, which may hold
|
||||
/// the driver's <c>_registryGate</c> or the probe watcher's <c>_syncLock</c>: a handler must
|
||||
/// only signal (the pump's handler is a channel <c>TryWrite</c>, safe there because the
|
||||
/// channel keeps the default <c>AllowSynchronousContinuations = false</c>, so the dispatch
|
||||
/// loop never runs inline under those locks).
|
||||
/// </summary>
|
||||
public event Action? ParkedEventsReady;
|
||||
|
||||
/// <summary>Gets the number of subscribes currently inside their reply window.</summary>
|
||||
public int PendingSubscribeCount => Volatile.Read(ref _pendingSubscribes);
|
||||
|
||||
/// <summary>Gets the number of events currently parked for not-yet-registered item handles.</summary>
|
||||
public int ParkedEventCount
|
||||
/// <summary>Gets the number of events currently held (parked or awaiting replay).</summary>
|
||||
public int ParkedEventCount => Volatile.Read(ref _heldCount);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new session epoch: discards every held event and makes events from any earlier
|
||||
/// epoch unparkable and unreplayable. Called by each new <see cref="EventPump"/>.
|
||||
/// </summary>
|
||||
/// <returns>The new epoch.</returns>
|
||||
public int StartNewEpoch()
|
||||
{
|
||||
get { lock (_parkGate) return _parkedCount; }
|
||||
lock (_parkGate)
|
||||
{
|
||||
_parkedByItemHandle.Clear();
|
||||
_ready.Clear();
|
||||
Volatile.Write(ref _heldCount, 0);
|
||||
return ++_epoch;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the early-event window for one subscribe. Call BEFORE issuing <c>SubscribeBulk</c>
|
||||
/// and dispose AFTER the returned handles are registered (or the attempt was abandoned):
|
||||
/// while any window is open, events for unknown handles are parked rather than dropped.
|
||||
/// and dispose AFTER the returned handles are registered (or the attempt was abandoned) —
|
||||
/// not later: while any window is open, events for unknown handles are parked, not dropped.
|
||||
/// </summary>
|
||||
/// <returns>A token that closes the window when disposed (idempotent).</returns>
|
||||
public IDisposable BeginPendingSubscribe()
|
||||
{
|
||||
Interlocked.Increment(ref _pendingSubscribes);
|
||||
lock (_parkGate)
|
||||
{
|
||||
// 0→1: anything still parked belongs to an earlier, closed window. EndPendingSubscribe
|
||||
// already discards on N→0 under this same gate, so this is belt-and-braces.
|
||||
if (_pendingSubscribes == 0) DiscardParkedLocked();
|
||||
Volatile.Write(ref _pendingSubscribes, _pendingSubscribes + 1);
|
||||
}
|
||||
return new PendingSubscribeScope(this);
|
||||
}
|
||||
|
||||
private void EndPendingSubscribe()
|
||||
{
|
||||
if (Interlocked.Decrement(ref _pendingSubscribes) != 0) return;
|
||||
lock (_parkGate)
|
||||
{
|
||||
// Re-checked under the gate: a subscribe that began after the decrement may already
|
||||
// have parked its own early events, which must survive.
|
||||
if (Volatile.Read(ref _pendingSubscribes) != 0) return;
|
||||
_parkedByItemHandle.Clear();
|
||||
_parkedCount = 0;
|
||||
Volatile.Write(ref _pendingSubscribes, _pendingSubscribes - 1);
|
||||
// N→0: what is still parked was claimed by no registration. Promoted (ready) events are
|
||||
// kept — they belong to a registered handle and are awaiting replay.
|
||||
if (_pendingSubscribes == 0) DiscardParkedLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void DiscardParkedLocked()
|
||||
{
|
||||
if (_parkedByItemHandle.Count == 0) return;
|
||||
var parked = 0;
|
||||
foreach (var list in _parkedByItemHandle.Values) parked += list.Count;
|
||||
_parkedByItemHandle.Clear();
|
||||
Volatile.Write(ref _heldCount, _heldCount - parked);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the subscribers for <paramref name="ev"/>'s item handle, or — when the handle is
|
||||
/// unknown while a subscribe is in flight — park the event for replay. Atomic with the
|
||||
@@ -231,22 +291,49 @@ internal sealed class SubscriptionRegistry
|
||||
/// promotion already ran (it would otherwise be stranded until the window closed).
|
||||
/// </summary>
|
||||
/// <param name="ev">The data-change event.</param>
|
||||
/// <param name="epoch">The calling pump's epoch (<see cref="StartNewEpoch"/>).</param>
|
||||
/// <param name="subscribers">The subscribers when the outcome is <see cref="EarlyEventOutcome.Resolved"/>.</param>
|
||||
/// <returns>What happened to the event.</returns>
|
||||
public EarlyEventOutcome ResolveOrPark(
|
||||
MxEvent ev, out IReadOnlyList<(long SubscriptionId, string FullReference)> subscribers)
|
||||
MxEvent ev, int epoch, out IReadOnlyList<(long SubscriptionId, string FullReference)> subscribers)
|
||||
{
|
||||
subscribers = ResolveSubscribers(ev.ItemHandle);
|
||||
if (subscribers.Count > 0) return EarlyEventOutcome.Resolved;
|
||||
if (Volatile.Read(ref _pendingSubscribes) == 0) return EarlyEventOutcome.Stale;
|
||||
|
||||
lock (_parkGate)
|
||||
// Lock-free fast path ONLY while nothing is held: then no older event for this handle can be
|
||||
// waiting, so resolving through the map cannot overtake one. While anything is held, the
|
||||
// resolve happens under the gate, i.e. never inside a registration's map-write→promotion
|
||||
// critical section.
|
||||
if (Volatile.Read(ref _heldCount) == 0)
|
||||
{
|
||||
// A registration may have bound the handle between the lookup above and the gate.
|
||||
subscribers = ResolveSubscribers(ev.ItemHandle);
|
||||
if (subscribers.Count > 0) return EarlyEventOutcome.Resolved;
|
||||
if (Volatile.Read(ref _pendingSubscribes) == 0) return EarlyEventOutcome.Stale;
|
||||
if (_parkedCount >= MaxParkedEvents) return EarlyEventOutcome.Overflow;
|
||||
}
|
||||
|
||||
lock (_parkGate)
|
||||
{
|
||||
if (epoch != _epoch)
|
||||
{
|
||||
subscribers = [];
|
||||
return EarlyEventOutcome.Stale; // a dying pump of an earlier session
|
||||
}
|
||||
|
||||
subscribers = ResolveSubscribers(ev.ItemHandle);
|
||||
if (subscribers.Count > 0)
|
||||
{
|
||||
// Defensive: registration promotes atomically with its map writes, so a resolved
|
||||
// handle should hold no parked list — but if one ever does, queue behind it rather
|
||||
// than overtake it (promotion moves the whole list in order). Not bounded: dropping
|
||||
// this newer value would leave the older parked one as the node's last word.
|
||||
if (_parkedByItemHandle.TryGetValue(ev.ItemHandle, out var pending))
|
||||
{
|
||||
pending.Add(ev);
|
||||
Volatile.Write(ref _heldCount, _heldCount + 1);
|
||||
subscribers = [];
|
||||
return EarlyEventOutcome.Parked;
|
||||
}
|
||||
return EarlyEventOutcome.Resolved;
|
||||
}
|
||||
if (_pendingSubscribes == 0) return EarlyEventOutcome.Stale;
|
||||
if (_heldCount >= MaxParkedEvents) return EarlyEventOutcome.Overflow;
|
||||
|
||||
if (!_parkedByItemHandle.TryGetValue(ev.ItemHandle, out var list))
|
||||
{
|
||||
@@ -254,43 +341,42 @@ internal sealed class SubscriptionRegistry
|
||||
_parkedByItemHandle[ev.ItemHandle] = list;
|
||||
}
|
||||
list.Add(ev);
|
||||
_parkedCount++;
|
||||
Volatile.Write(ref _heldCount, _heldCount + 1);
|
||||
return EarlyEventOutcome.Parked;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dequeue every event a registration promoted, in arrival order. Called only from the
|
||||
/// EventPump's single dispatch thread.
|
||||
/// EventPump's single dispatch thread. A pump of an earlier epoch gets nothing.
|
||||
/// </summary>
|
||||
/// <param name="epoch">The calling pump's epoch.</param>
|
||||
/// <returns>The promoted events; empty when none are waiting.</returns>
|
||||
public IReadOnlyList<MxEvent> TakeReadyEvents()
|
||||
public IReadOnlyList<MxEvent> TakeReadyEvents(int epoch)
|
||||
{
|
||||
if (Volatile.Read(ref _heldCount) == 0) return [];
|
||||
lock (_parkGate)
|
||||
{
|
||||
if (_ready.Count == 0) return [];
|
||||
if (epoch != _epoch || _ready.Count == 0) return [];
|
||||
var taken = _ready.ToArray();
|
||||
_ready.Clear();
|
||||
Volatile.Write(ref _heldCount, _heldCount - taken.Length);
|
||||
return taken;
|
||||
}
|
||||
}
|
||||
|
||||
private void PromoteParkedEvents(IReadOnlyList<TagBinding> bindings)
|
||||
private bool PromoteParkedEventsLocked(IReadOnlyList<TagBinding> bindings)
|
||||
{
|
||||
if (_parkedByItemHandle.Count == 0) return false;
|
||||
var promoted = false;
|
||||
lock (_parkGate)
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (_parkedCount == 0) return;
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
if (!_parkedByItemHandle.Remove(binding.ItemHandle, out var list)) continue;
|
||||
foreach (var ev in list) _ready.Enqueue(ev);
|
||||
_parkedCount -= list.Count;
|
||||
promoted = true;
|
||||
}
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
if (!_parkedByItemHandle.Remove(binding.ItemHandle, out var list)) continue;
|
||||
foreach (var ev in list) _ready.Enqueue(ev); // stays counted in _heldCount until replayed
|
||||
promoted = true;
|
||||
}
|
||||
if (promoted) ParkedEventsReady?.Invoke();
|
||||
return promoted;
|
||||
}
|
||||
|
||||
private sealed class PendingSubscribeScope(SubscriptionRegistry owner) : IDisposable
|
||||
@@ -328,32 +414,38 @@ internal sealed class SubscriptionRegistry
|
||||
/// <param name="newBindings">The new tag bindings after reconnection.</param>
|
||||
public void Rebind(long subscriptionId, IReadOnlyList<TagBinding> newBindings)
|
||||
{
|
||||
if (!_bySubscriptionId.TryGetValue(subscriptionId, out var oldEntry)) return;
|
||||
|
||||
// Drop this subscription from every reverse-map set it currently appears in. The
|
||||
// pre-reconnect item handles are stale once the gw re-issues fresh ones.
|
||||
foreach (var binding in oldEntry.Bindings)
|
||||
bool promoted;
|
||||
lock (_parkGate) // map writes + promotion atomic — see Register
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var set)) continue;
|
||||
var remaining = set.Remove(subscriptionId);
|
||||
if (remaining.IsEmpty)
|
||||
if (!_bySubscriptionId.TryGetValue(subscriptionId, out var oldEntry)) return;
|
||||
|
||||
// Drop this subscription from every reverse-map set it currently appears in. The
|
||||
// pre-reconnect item handles are stale once the gw re-issues fresh ones.
|
||||
foreach (var binding in oldEntry.Bindings)
|
||||
{
|
||||
_subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
|
||||
if (binding.ItemHandle <= 0) continue;
|
||||
if (!_subscribersByItemHandle.TryGetValue(binding.ItemHandle, out var set)) continue;
|
||||
var remaining = set.Remove(subscriptionId);
|
||||
if (remaining.IsEmpty)
|
||||
{
|
||||
_subscribersByItemHandle.TryRemove(binding.ItemHandle, out _);
|
||||
}
|
||||
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
|
||||
}
|
||||
else _subscribersByItemHandle[binding.ItemHandle] = remaining;
|
||||
}
|
||||
|
||||
_bySubscriptionId[subscriptionId] = new SubscriptionEntry(subscriptionId, newBindings);
|
||||
foreach (var binding in newBindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
|
||||
_subscribersByItemHandle.AddOrUpdate(
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
_bySubscriptionId[subscriptionId] = new SubscriptionEntry(subscriptionId, newBindings);
|
||||
foreach (var binding in newBindings)
|
||||
{
|
||||
if (binding.ItemHandle <= 0) continue; // failed gw subscribe — no events expected
|
||||
_subscribersByItemHandle.AddOrUpdate(
|
||||
binding.ItemHandle,
|
||||
_ => [subscriptionId],
|
||||
(_, set) => set.Add(subscriptionId));
|
||||
}
|
||||
AfterMapWriteForTests?.Invoke();
|
||||
promoted = PromoteParkedEventsLocked(newBindings);
|
||||
}
|
||||
PromoteParkedEvents(newBindings);
|
||||
if (promoted) ParkedEventsReady?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user