Skip to content

[Feature] Inbox — actor-level dedup for non-actor callers #181

Description

@pathosDev

Size / Priority

Rationale

actor-ts already has HTTP idempotency (src/http/cache/IdempotencyKey.ts) — drops duplicate HTTP requests with the same Idempotency-Key header.

Inbox is the actor-level analog: messages arriving from non-actor sources (cron job, external webhook, message broker consumer, polling worker) often need at-least-once delivery with idempotency. Today the actor would need to:

  1. Manually track processed message IDs.
  2. Persist that tracking state.
  3. Drop duplicates.

The Inbox abstraction wraps it: caller supplies a dedup-key with the message; the actor's Inbox layer tracks processed keys (with TTL); duplicates dropped silently.

Use cases:

  • Kafka consumer → actor handler. Kafka delivers at-least-once; dedup at the actor.
  • Webhook receiver → multiple deliveries from a flaky external system. Dedup by webhook-id.
  • Cron-driven tasks → if the cron fires twice (overlap window), dedup.

Reference: what Vlingo does

@Override
public void handle(IncomingMessage msg) {
  if (inbox.handle(msg.dedupKey)) {
    // first time — process
  }
  // duplicate — drop
}

The inbox is durable (persists processed keys across restarts) and has TTL (so keys don't pile up forever).

Design sketch — actor-ts equivalent

// src/inbox/Inbox.ts (new)

export interface InboxOptions {
  /** How long to remember a dedup-key.  Default: 24h. */
  readonly ttlMs?: number;
  /** Max entries cached in memory before LRU eviction.  Default: 10_000. */
  readonly memoryCacheSize?: number;
  /** Storage backend.  Default: in-memory (test-only — use Sqlite/Cassandra in prod). */
  readonly store?: InboxStore;
}

export interface InboxStore {
  /** Has this key been seen?  Cheap lookup expected. */
  has(actorPath: string, dedupKey: string): Promise<boolean>;
  /** Record a key as processed with TTL. */
  record(actorPath: string, dedupKey: string, ttlMs: number): Promise<void>;
  /** Periodic cleanup of expired keys. */
  cleanupExpired(): Promise<number>;
}

// Mixin / decorator for PersistentActor or Actor:
export class Inbox {
  constructor(private readonly options: InboxOptions);

  /**
   * Run `handler(msg)` only if `dedupKey` hasn't been processed yet.
   * Returns true if handled; false if duplicate.
   */
  async handle<T>(actorPath: string, dedupKey: string, handler: () => Promise<T>): Promise<boolean>;
}

Usage:

class WebhookActor extends Actor<WebhookMsg> {
  private readonly inbox = new Inbox({ ttlMs: 7 * 86400e3 });

  override async onReceive(msg: WebhookMsg) {
    const handled = await this.inbox.handle(this.path.toString(), msg.webhookId, async () => {
      await this.processWebhook(msg.payload);
    });
    if (!handled) this.log.debug(`duplicate webhook ${msg.webhookId} dropped`);
  }
}

Or, more integrated via a withInbox Behaviors combinator:

const behaviour = Behaviors.withInbox(
  { ttlMs: 7 * 86400e3, dedupKeyOf: (msg: WebhookMsg) => msg.webhookId },
  Behaviors.receiveMessage<WebhookMsg>(msg => this.processWebhook(msg.payload)),
);

Integration with existing actor-ts subsystems

  • src/http/cache/IdempotencyKey.ts: similar pattern; share storage if possible.
  • Cache extension: InboxStore can be implemented as a thin wrapper over Cache (Redis/Memcached/InMemory).
  • PersistentActor: optional withInbox mixin; durable storage of processed keys.
  • Broker actors (Kafka, NATS, MQTT): provide dedup-key in their envelope by default if available (Kafka offset, NATS msg-id).
  • Metrics: inbox_duplicates_dropped_total, inbox_cache_size.

Out of scope / non-goals

  • Strict exactly-once across cluster — at-most-once dedup per actor with TTL; cluster-wide unique-dedup is harder (would need a cluster-wide lookup before processing).
  • Cross-restart memory cache — in-memory LRU is per-process; durability via the InboxStore backend.

Open design questions

  1. Storage default: in-memory (test only) vs requiring user to provide. Recommend: in-memory default with a loud warning on Production usage; require explicit store for prod.
  2. TTL granularity: TTL is a coarse cleanup mechanism. Some users want strict "never re-process". Recommend: configurable; document trade-offs.
  3. Pre-handler vs post-handler dedup recording: record before handler runs (avoid re-process on crash) or after (avoid record-then-fail). Recommend: record before, with sweep-on-crash for failed-but-recorded entries.
  4. HTTP idempotency unification: rebuild HTTP idempotency on top of Inbox? Or keep separate? Recommend separate (different lifecycle / different storage).

Test plan

  1. Same dedup-key 3× → handler runs 1×, 2 dropped.
  2. Different dedup-keys → all run.
  3. TTL expiry → after TTL window, same key runs again.
  4. Memory cache eviction → LRU evicts cold keys; storage backend hit for re-check.
  5. Actor restart → in-memory cache empty; storage backend rehydrates on first lookup.
  6. Concurrent same-key calls → race — only one runs (sync via internal mutex per key).
  7. Storage backend failure → behaviour: log + handle (don't drop legitimate messages because storage is flaky).
  8. withInbox Behaviors combinator works.

Acceptance criteria

  • Inbox class + InboxStore interface.
  • InMemory + SQLite + Redis backends.
  • Behaviors.withInbox combinator.
  • Race-safe concurrent same-key handling.
  • Metrics.
  • Documentation: "Inbox vs HTTP-Idempotency vs Outbox" decision guide.
  • Test suite (8 cases).
  • CHANGELOG entry under "New: Inbox for actor-level dedup".

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: lowNice-to-have / niche / demand-driven

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions