Skip to content

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 StatusBatch instead — 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:

route.SkipReplayed<SdkAuditEvent, uint>(e => e.Id, cursor);

How It Works

SkipReplayed registers a replay gate on the router. On each Dispatch call:

  1. Gate checks cursor.HasPassed(id) — if id ≤ LastSeen, the event is silently dropped.
  2. If the event passes, it flows through normal routing.
  3. After routing, the gate calls cursor.Advance(id) — if id > 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.