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:
- Manually track processed message IDs.
- Persist that tracking state.
- 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
- 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.
- TTL granularity: TTL is a coarse cleanup mechanism. Some users want strict "never re-process". Recommend: configurable; document trade-offs.
- 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.
- HTTP idempotency unification: rebuild HTTP idempotency on top of Inbox? Or keep separate? Recommend separate (different lifecycle / different storage).
Test plan
- Same dedup-key 3× → handler runs 1×, 2 dropped.
- Different dedup-keys → all run.
- TTL expiry → after TTL window, same key runs again.
- Memory cache eviction → LRU evicts cold keys; storage backend hit for re-check.
- Actor restart → in-memory cache empty; storage backend rehydrates on first lookup.
- Concurrent same-key calls → race — only one runs (sync via internal mutex per key).
- Storage backend failure → behaviour: log + handle (don't drop legitimate messages because storage is flaky).
withInbox Behaviors combinator works.
Acceptance criteria
Size / Priority
Rationale
actor-ts already has HTTP idempotency (
src/http/cache/IdempotencyKey.ts) — drops duplicate HTTP requests with the sameIdempotency-Keyheader.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:
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:
Reference: what Vlingo does
The inbox is durable (persists processed keys across restarts) and has TTL (so keys don't pile up forever).
Design sketch — actor-ts equivalent
Usage:
Or, more integrated via a
withInboxBehaviors combinator:Integration with existing actor-ts subsystems
src/http/cache/IdempotencyKey.ts: similar pattern; share storage if possible.Cacheextension:InboxStorecan be implemented as a thin wrapper overCache(Redis/Memcached/InMemory).PersistentActor: optionalwithInboxmixin; 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).inbox_duplicates_dropped_total,inbox_cache_size.Out of scope / non-goals
Open design questions
Test plan
withInboxBehaviors combinator works.Acceptance criteria
Inboxclass +InboxStoreinterface.Behaviors.withInboxcombinator.