From 62025592bab17380eab84f26c497ae7dd9df5d5d Mon Sep 17 00:00:00 2001 From: edmand46 Date: Sun, 6 Sep 2026 09:53:45 +0300 Subject: [PATCH] Effects client reconciles pending effects: drop stale wait deadlines and seen keys, emit completion for vanished runs; run_not_active is terminal --- Services/EffectsService.cs | 57 +++++++++++++++++-- .../rudder-csharp-sdk/reference/scenarios.md | 10 +++- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/Services/EffectsService.cs b/Services/EffectsService.cs index 60db8eb..dbb7f02 100644 --- a/Services/EffectsService.cs +++ b/Services/EffectsService.cs @@ -21,7 +21,7 @@ public sealed class EffectsService private readonly RudderClient _client; private readonly object _gate = new(); - private readonly HashSet<(string RunId, string NodeId)> _seen = new(); + private readonly Dictionary<(string RunId, string NodeId), string> _seen = new(); private readonly Dictionary<(string RunId, string NodeId), DateTimeOffset> _waitDeadlines = new(); private bool _refreshDue; @@ -99,8 +99,9 @@ public sealed class EffectsService continue; var key = (effect.RunId, effect.NodeId); - if (!_seen.Add(key)) + if (_seen.ContainsKey(key)) continue; + _seen[key] = effect.ScenarioId; if (string.Equals(effect.Type, EffectTypes.Wait, StringComparison.Ordinal)) { @@ -215,7 +216,12 @@ public sealed class EffectsService "/sdk/v1/scenarios/pending", CancellationToken.None).ConfigureAwait(false); - Ingest(response?.Effects); + if (response != null) + { + Ingest(response.Effects); + Reconcile(response.Effects); + } + lock (_gate) _nextHeartbeat = _client.Clock.UtcNow + HeartbeatInterval; } @@ -280,7 +286,7 @@ public sealed class EffectsService lock (_gate) { var toRemove = new List<(string RunId, string NodeId)>(); - foreach (var key in _seen) + foreach (var key in _seen.Keys) { if (key.RunId == runId) toRemove.Add(key); @@ -296,6 +302,45 @@ public sealed class EffectsService Emit(OnScenarioFailed, new ScenarioFailedEffect(runId, scenarioId, nodeId, exception)); } + private void Reconcile(IEnumerable? effects) + { + var incoming = new HashSet<(string RunId, string NodeId)>(); + if (effects != null) + { + foreach (var effect in effects) + { + if (effect == null || string.IsNullOrEmpty(effect.RunId) || string.IsNullOrEmpty(effect.NodeId)) + continue; + + incoming.Add((effect.RunId, effect.NodeId)); + } + } + + var finished = new Dictionary(); + lock (_gate) + { + var stale = new List<(string RunId, string NodeId)>(); + foreach (var key in _seen.Keys) + { + if (!incoming.Contains(key)) + stale.Add(key); + } + + foreach (var key in stale) + { + finished[key.RunId] = _seen[key]; + _seen.Remove(key); + _waitDeadlines.Remove(key); + } + + foreach (var key in _seen.Keys) + finished.Remove(key.RunId); + } + + foreach (var entry in finished) + Emit(OnScenarioCompleted, new ScenarioCompletedEffect(entry.Key, entry.Value)); + } + private void ForgetWait(string runId, string nodeId) { lock (_gate) @@ -347,7 +392,9 @@ public sealed class EffectsService return true; return ex is RudderApiException api - && (api.Code == RudderErrorCodes.UnknownRun || api.Code == RudderErrorCodes.RunExpired); + && (api.Code == RudderErrorCodes.UnknownRun + || api.Code == RudderErrorCodes.RunExpired + || api.Code == RudderErrorCodes.RunNotActive); } } diff --git a/skills/rudder-csharp-sdk/reference/scenarios.md b/skills/rudder-csharp-sdk/reference/scenarios.md index 06a387d..176e88a 100644 --- a/skills/rudder-csharp-sdk/reference/scenarios.md +++ b/skills/rudder-csharp-sdk/reference/scenarios.md @@ -27,7 +27,9 @@ effect. There is no local plan, no `IPlanStateStore`, and no restore. - This SDK does **not** fire a login scenario event (`loginEvent` / `player_login` is a web-SDK option only). Trigger login-gated scenarios yourself with `TriggerAsync`. -- An effect with an already-seen `(runId, nodeId)` is not re-emitted. +- An effect with an already-seen `(runId, nodeId)` is not re-emitted. The + seen set is pruned against every pending fetch: keys the server no longer + lists are dropped, so the server stays the source of truth. - There is no local plan persistence. Pending work lives on the server. ## `ScenarioService` (`client.Scenario`) @@ -210,7 +212,9 @@ public sealed class ScenarioFailedEffect ``` `OnScenarioCompleted` fires when a callback / completed-counter response has -no next effect (the run finished all its nodes). +no next effect (the run finished all its nodes). It also fires when a run +disappears from the pending list with no keys left for it — the server +advanced a wait, the run expired, or another device completed it. ## Reliability @@ -219,7 +223,7 @@ no next effect (the run finished all its nodes). `onPurchase`, `onDecline`, `onEnd`, `onClaim`, `onComplete`, `onLevelUp`, `onPremiumPurchase`. A non-null `effect` in the response is ingested as the next node; a null/missing effect completes the run. -- `unknown_run`, `run_expired` (`RudderErrorCodes`), and HTTP 404 +- `unknown_run`, `run_expired`, `run_not_active` (`RudderErrorCodes`), and HTTP 404 (`RudderNotFoundException`) on a callback or counter call **drop that run** (forget its seen keys and wait deadlines) and fire `OnScenarioFailed`.