Skip to content

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 ​

MethodKindDescription
isFirst(key, options?)strictAtomically mark-and-check; true exactly once per window across all instances
dedupe(key, fn, options?)gateRun fn for the first occurrence only; duplicates resolve undefined
debounce(key, fn, options?)gateRun fn once the key goes quiet for the window, on the LAST caller
release(key)strictRemove 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 ✗ → undefined

A 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 undefined and 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:

MetricTypeLabels
redisx_dedup_checks_totalcounteroutcome = first | duplicate

Released under the MIT License.