Pattern 17: Event Replay Deduplication¶
Some devices send startup event replay — they deliver historical events from GetAllEvents() or similar on every connection. Without deduplication, the same events are published to the audit log on every restart, creating duplicate records.
Use route.SkipReplayed with IEventCursor<TKey> to deduplicate in one line. The cursor is a high-watermark: it records the highest event ID seen, persists it across restarts via LiteDB, and silently drops any event at or below the watermark on the next connection.
When to Use¶
Use SkipReplayed when:
- The device replays historical events on every connect (startup/reconnect replay)
- Events have a monotonically increasing numeric identifier
- The same event must not appear twice in the audit log
Do not use for:
- Status/state callbacks (these are not audit events; route them to
StatusBatchinstead — see RULE-024) - Real-time push events that cannot replay (no dedup needed)
- Events without a monotonic ID
Setup¶
Declare IEventCursor<TKey> as a constructor parameter on your ConnectionFunction or routing class. The framework resolves and scopes it per device automatically.
public partial class EventRouting(
ILogger<EventRouting> logger,
IEventCursor<uint> cursor)
{
partial void ConfigureRoutes(IEventRoute<SdkEvent> route)
{
route.SkipReplayed(e => e.Id, cursor);
route.When(e => e.Type == SdkEventType.DoorOpened)
.WithAddress<DoorThing, int>(e => e.DoorId)
.Handle(async (door, e) =>
{
await door.Opened(DeviceTimestamp.FromDevice(e.Timestamp, door.TimeZone));
return true;
});
// ... other routes
}
}
For object routers where only one subtype carries the replay stream, use the two-type overload:
How It Works¶
SkipReplayed registers a replay gate on the router. On each Dispatch call:
- Gate checks
cursor.HasPassed(id)— ifid ≤ LastSeen, the event is silently dropped. - If the event passes, it flows through normal routing.
- After routing, the gate calls
cursor.Advance(id)— ifid > LastSeen, persists the new watermark.
The gate fires before the interceptor (command-response pattern), so replayed events never trigger pending waiters either.
What Not To Do¶
// WRONG: manual HashSet dedup in Protocol
private readonly HashSet<uint> _seenEventIds = [];
bool TryRememberEvent(SdkAuditEvent evt)
{
if (!_seenEventIds.Add(evt.Id))
return false;
// ... bounded eviction logic
return true;
}
// and in dispatch:
if (!TryRememberEvent(e))
return;
// WRONG: manual IPersistentSettings wiring in Panel/Thing
public Panel(IPersistentSettings<EventStoreIndex> eventStore) { ... }
// ... ConfigureEventPersistence(eventStore) in Connect()
Both patterns require 5–8 boilerplate lines per adapter and are replaced by a single SkipReplayed call. See RULE-025 in compliance-rules/events.md.
Multiple Event Streams¶
If the device exposes separate active-event and history-event streams with overlapping IDs, route them through the same router with the same IEventCursor so the gate covers both. If IDs are independent per stream, use a separate IEventCursor per stream (declare two parameters with distinct type arguments, or scope by a different TKey type).
See RULE-024 for how to separate status streams from audit streams.