perf(runtime): O(1) attribute resolution, precomputed types, coalesced static writes, shared JSON options

This commit is contained in:
Joseph Doherty
2026-08-14 19:57:26 -04:00
parent ee193cd2bb
commit 125055d998
7 changed files with 350 additions and 83 deletions
@@ -242,6 +242,77 @@ public class InstanceActorTests : TestKit, IDisposable
Assert.Equal("100.0", overrides["Temperature"]);
}
/// <summary>
/// Perf remediation (arch-review WP1.5): a burst of rapid static-attribute writes must
/// coalesce into ONE batched SQLite transaction on a short single-shot timer — mirroring
/// NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape — rather than a write
/// per <see cref="SetStaticAttributeCommand"/>. Pinned two ways: (1) nothing is persisted
/// before the flush interval elapses, even though every write already replied
/// synchronously and updated in-memory state; (2) only the LATEST value per attribute
/// survives, across writes to two different attributes coalesced into the same flush.
/// </summary>
[Fact]
public async Task InstanceActor_RapidStaticWrites_CoalesceIntoSingleFlush()
{
var config = new FlattenedConfiguration
{
InstanceUniqueName = "PumpCoalesce1",
Attributes =
[
new ResolvedAttribute { CanonicalName = "Counter", Value = "0", DataType = "Int32" },
new ResolvedAttribute { CanonicalName = "Label", Value = "Idle", DataType = "String" }
]
};
var flushInterval = TimeSpan.FromSeconds(1);
var actor = ActorOf(Props.Create(() => new InstanceActor(
"PumpCoalesce1",
JsonSerializer.Serialize(config),
_storage,
_compilationService,
_sharedScriptLibrary,
null,
_options,
NullLogger<InstanceActor>.Instance,
null, // dclManager
null, // healthCollector
null, // serviceProvider
flushInterval)));
// Five rapid writes to "Counter" plus one to "Label", all sent back-to-back with no
// delay between them (well inside the 1s coalescing window).
for (var i = 1; i <= 5; i++)
{
actor.Tell(new SetStaticAttributeCommand(
$"corr-counter-{i}", "PumpCoalesce1", "Counter", i.ToString(), DateTimeOffset.UtcNow));
}
actor.Tell(new SetStaticAttributeCommand(
"corr-label", "PumpCoalesce1", "Label", "Running", DateTimeOffset.UtcNow));
// Every write replies synchronously (in-memory state is authoritative regardless of
// when the buffered persist lands) — six writes, six responses.
for (var i = 0; i < 6; i++)
{
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
Assert.True(response.Success);
}
// Well before the flush interval elapses, NOTHING has reached SQLite yet — proves the
// writes did not each fire their own persist (the old per-write fire-and-forget shape
// would already show a row here).
await Task.Delay(200);
var beforeFlush = await _storage.GetStaticOverridesAsync("PumpCoalesce1");
Assert.Empty(beforeFlush);
// After the flush interval elapses, exactly one coalesced write has landed: only the
// LATEST value per attribute survives.
await Task.Delay(1500);
var afterFlush = await _storage.GetStaticOverridesAsync("PumpCoalesce1");
Assert.Equal(2, afterFlush.Count);
Assert.Equal("5", afterFlush["Counter"]);
Assert.Equal("Running", afterFlush["Label"]);
}
[Fact]
public async Task InstanceActor_LoadsStaticOverridesFromSQLite()
{