Service API
Inject with the DEDUP_SERVICE token:
typescript
import { Injectable, Inject } from '@nestjs/common';
import { DEDUP_SERVICE, IDedupService } from '@nestjs-redisx/dedup';
import { Mailer } from './types';
@Injectable()
export class OrderEventsService {
constructor(
@Inject(DEDUP_SERVICE)
private readonly dedup: IDedupService,
private readonly mailer: Mailer,
) {}
// dedupe(): run the side effect for the FIRST occurrence only.
async onOrderCreated(orderId: string): Promise<void> {
await this.dedup.dedupe(`order-created:${orderId}`, () => this.mailer.sendOrderConfirmation(orderId));
}
// isFirst(): plain boolean check when you steer the flow yourself.
async shouldProcess(messageId: string): Promise<boolean> {
return this.dedup.isFirst(`queue:${messageId}`, { window: 600_000 });
}
// release(): manually re-admit a key (support tooling, forced retry).
async allowRetry(orderId: string): Promise<boolean> {
return this.dedup.release(`order-created:${orderId}`);
}
}IDedupService
| Method | Kind | Description |
|---|---|---|
isFirst(key, options?) | strict | Atomically mark-and-check; true exactly once per window across all instances |
dedupe(key, fn, options?) | gate | Run fn for the first occurrence only; duplicates resolve undefined |
debounce(key, fn, options?) | gate | Run fn once the key goes quiet for the window, on the LAST caller |
release(key) | strict | Remove a marker early (support tooling, forced retry) |
strict = store failures always throw DedupStoreError. gate = store failures follow the plugin errorPolicy.
dedupe failure semantics
A thrown first execution releases its own marker (CAS on the caller token - a marker set by a newer first is never erased), so the provider's retry can run instead of being locked out for the window:
attempt 1: markFirst ✓ → fn() throws → marker released → error propagates
attempt 2 (retry): markFirst ✓ → fn() succeeds
attempt 3 (duplicate): markFirst ✗ → undefinedA crash (no throw path) leaves the marker until the TTL - at-most-once within the window is preserved.
debounce semantics
typescript
import { Injectable, Inject } from '@nestjs/common';
import { DEDUP_SERVICE, IDedupService } from '@nestjs-redisx/dedup';
import { SearchIndex } from './types';
@Injectable()
export class IndexingService {
constructor(
@Inject(DEDUP_SERVICE)
private readonly dedup: IDedupService,
private readonly searchIndex: SearchIndex,
) {}
// Trailing debounce ACROSS INSTANCES: a burst of product updates triggers
// ONE rebuild, after the burst goes quiet for 30s, on the instance that
// received the LAST update. Superseded calls resolve undefined.
async onProductChanged(): Promise<void> {
await this.dedup.debounce('reindex:products', () => this.searchIndex.rebuild(), { window: 30_000 });
}
}- Every call overwrites the shared "last caller" slot and waits the window in-process (the returned promise settles after the quiet period).
- When the window elapses, the caller atomically claims the slot - only the one that is still last executes; superseded calls resolve
undefined. - A slot that expired before the claim (the process stalled past the ~5s grace) is a lost round: the call resolves
undefinedand a distinct warning is logged - never silently conflated with supersession. - The pending timer is unref'd and lives in the last caller's process: if that instance dies before the window elapses, the round does not execute (best-effort, exactly like an in-process debounce - the next call starts a new round).
Metrics
With the Metrics plugin:
| Metric | Type | Labels |
|---|---|---|
redisx_dedup_checks_total | counter | outcome = first | duplicate |