Data-rate shaping
Shape high-frequency sample streams with a write-behind buffer (batch to the database) and a keyed conflater (latest-wins to the UI).
Between "a device produces samples many times per second" and "the database and the UI consume them"
sit two small concurrency problems every telemetry gateway hand-rolls: batching writes so the database
sees hundreds of rows per call instead of hundreds of calls per second, and throttling live values so
the UI sees at most a few updates per second per key — without ever getting stuck on a stale one. Both
are ~100 lines that are easy to get subtly wrong (flush/publish races, shutdown flush, timer lifecycle),
so Elarion core ships them once, in the Elarion.Buffering namespace
(ADR-0055):
| Helper | Contract | Natural delegate body |
|---|---|---|
WriteBehindBuffer<T> | Accumulate, flush in batches by count or interval — whichever first | ExecuteInsertAsync |
KeyedConflater<TKey, TValue> | Latest wins per key, at most one emit per key per interval | IClientEventPublisher.PublishAsync |
Both are BCL-only, need no DI registration (construct them wherever the data path lives — typically an
actor or gateway component), take a TimeProvider for deterministic tests,
and flush on DisposeAsync so shutdown writes the tail instead of dropping it.
WriteBehindBuffer<T> — batch samples to the database
var buffer = new WriteBehindBuffer<Measurement>(
async (batch, ct) => await WriteBatchAsync(batch, ct), // e.g. ExecuteInsertAsync on a fresh scope
new WriteBehindBufferOptions {
MaxItems = 500, // flush when the batch is full…
FlushInterval = TimeSpan.FromSeconds(5), // …or when the oldest item is this old
},
onFlushError: (ex, batch) => logger.LogWarning(ex, "Dropped {Count} samples", batch.Count));
buffer.Add(measurement); // any thread, never blocks on the flush target
await buffer.FlushAsync(ct); // explicit flush — delegate failures rethrow here
await buffer.DisposeAsync(); // stops the timer, flushes the tail, never throwsThe contract is loss-tolerant samples:
- The buffer is bounded — past
Capacity(default 4 ×MaxItems) the oldest unflushed item is dropped, so a slow or down database bounds memory instead of growing it.DroppedCountmeters the pressure; a climbing value means the flush target can't keep up. - A failed flush drops its batch rather than retrying it (a poisoned batch must never wedge the
pipeline). Explicit
FlushAsyncrethrows to its caller; background and dispose flushes route to theonFlushErrorcallback — without one they are swallowed, so supply it in anything beyond a prototype. - Flushes are single-flight: items added while a flush runs coalesce into the next drain pass, so a slow target gets fewer, larger calls — not a stack of concurrent ones.
Samples that must not be lost don't belong here — record them transactionally in the handler and let the outbox do the deferring.
KeyedConflater<TKey, TValue> — latest-wins to the UI
var conflater = new KeyedConflater<string, QuoteUpdated>(
async (symbol, quote, ct) => await clientEvents.PublishAsync(quote, ClientEventScope.Global, ct),
new KeyedConflaterOptions { MinInterval = TimeSpan.FromMilliseconds(250) });
conflater.Post("ACME", update); // any thread, never blocks on the publish target
await conflater.DisposeAsync(); // publishes every pending latest, then drops late postsEach key emits at most once per MinInterval:
- The first post of an idle key emits immediately (leading edge) — a dashboard shows the first value without waiting a window.
- Posts inside the window conflate: only the newest value survives, and it emits when the window elapses (trailing edge). A quiet key therefore always publishes its final value — conflation never ends on a stale reading.
- Emissions for one key never overlap: a publish slower than the window lowers the effective rate instead of stacking calls. Keys are independent, and idle keys retire automatically, so unbounded key spaces (device ids, symbols) don't leak.
- Delivery is at-most-once, matching the client-event contract: a publish failure drops that
emission to the optional
onPublishErrorcallback, and the next post heals.
Composition: the actor telemetry path
The recommended owner is the actor that already serializes the hot state
(the write-behind and live-telemetry use cases): it
constructs both helpers as activation state, Adds/Posts from its turns, and disposes them in
OnDeactivateAsync — one sample stream, two shaped consumers:
public Task RecordAsync(Measurement m) {
_buffer.Add(m); // → batched ExecuteInsertAsync every few seconds
_conflater.Post(m.Sensor, m); // → at most 4 client events/s per sensor (MinInterval = 250 ms)
return Task.CompletedTask;
}These two shapes are deliberately the whole surface — windows, joins, or replay are the trigger for a real reactive/streaming library, not for growing these helpers. For ordered, gap-visible streams where element identity matters, use ordered streams instead of conflation.
Device identity
Pairing codes, per-device keys, and the connect-time HMAC handshake — the provisioning chain every device gateway needs, without hand-rolling the security-relevant parts.
Entity Framework Core
Optional source generation for DbSet properties and entity configuration application — driven by [EntityConfiguration], applied to your concrete DbContext, explicit and AOT-friendly.