fix(scripted-alarms): resolve Medium code-review finding (Core.ScriptedAlarms-004)
Split the LoadAsync seed-read + subscribe loop: ReadTag seed fills _valueCache first, then persisted-state restore runs, then _loaded = true, then SubscribeTag is called. Any synchronous initial push from the upstream now arrives after _alarms is fully initialised and _loaded = true, so ReevaluateAsync will queue correctly behind the gate rather than racing the half-built state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,12 +143,17 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
+ string.Join("\n ", compileFailures));
|
+ string.Join("\n ", compileFailures));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed the value cache with current upstream values + subscribe for changes.
|
// Seed the value cache with current tag values before subscribing. The
|
||||||
|
// ReadTag calls happen first so that the initial predicate evaluation below
|
||||||
|
// (startup recovery, decision #14) uses a consistent snapshot.
|
||||||
|
// Subscriptions are established AFTER _loaded = true so that any synchronous
|
||||||
|
// initial-push an ITagUpstreamSource delivers from inside SubscribeTag arrives
|
||||||
|
// when _alarms is fully initialised. Before _loaded = true, a synchronous push
|
||||||
|
// would race the in-progress state restore and could overwrite the carefully
|
||||||
|
// seeded cache with a push that has no defined ordering relative to ReadTag.
|
||||||
|
// (Core.ScriptedAlarms-004)
|
||||||
foreach (var path in _alarmsReferencing.Keys)
|
foreach (var path in _alarmsReferencing.Keys)
|
||||||
{
|
|
||||||
_valueCache[path] = _upstream.ReadTag(path);
|
_valueCache[path] = _upstream.ReadTag(path);
|
||||||
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore persisted state, falling back to Fresh where nothing was saved,
|
// Restore persisted state, falling back to Fresh where nothing was saved,
|
||||||
// then re-derive ActiveState from the current predicate per decision #14.
|
// then re-derive ActiveState from the current predicate per decision #14.
|
||||||
@@ -163,6 +168,14 @@ public sealed class ScriptedAlarmEngine : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
|
|
||||||
|
// Subscribe after _loaded = true and full state restore. If an upstream
|
||||||
|
// implementation pushes its initial value synchronously from inside
|
||||||
|
// SubscribeTag, OnUpstreamChange will queue a ReevaluateAsync that acquires
|
||||||
|
// _evalGate — it will correctly block until LoadAsync releases the gate, then
|
||||||
|
// re-evaluate against the fully-populated _alarms dict.
|
||||||
|
foreach (var path in _alarmsReferencing.Keys)
|
||||||
|
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
|
||||||
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
|
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
|
||||||
|
|
||||||
// Dispose any previously-created timer before reassigning; a second LoadAsync
|
// Dispose any previously-created timer before reassigning; a second LoadAsync
|
||||||
|
|||||||
Reference in New Issue
Block a user