Skip to content

feat: add debounce — distributed first-signal-fires debounce primitive#62

Closed
freshlogic wants to merge 1 commit intomainfrom
feat/debounce
Closed

feat: add debounce — distributed first-signal-fires debounce primitive#62
freshlogic wants to merge 1 commit intomainfrom
feat/debounce

Conversation

@freshlogic
Copy link
Copy Markdown
Member

Summary

Adds pettyCache.debounce(key, { wait }, fn) — a distributed debounce primitive backed by Redis. Async/await only.

The pattern: first signal in a quiet wait window invokes the callback; subsequent signals during the window are absorbed. After TTL expires, the next signal can invoke again. If the callback throws, the state is cleared so the next caller can retry immediately.

Why

The first concrete use case is Stores.com's segmentation-service rollup pipeline — 5 source services emit events that all signal "refresh accountId X." We want one refresh per ~5-min burst, not 50. Inline implementation gets fiddly fast (race conditions on check+set, partial failure cleanup, etc.). Centralizing it here keeps the convention enforceable across services.

Other expected callers: catalog reindexing on item updates, dashboard recomputes, webhook re-fires.

Reference

Imitates BullMQ's job deduplication mechanic — single SET NX PX for atomicity. Differences from BullMQ:

  • Callback API instead of queue events — works with any scheduler (Service Bus, BullMQ, setTimeout, cron)
  • Caller gets refreshDate (the scheduled fire time) — useful for scheduledEnqueueTimeUtc, idempotency keys
  • DEL on callback throw — recovery semantic BullMQ doesn't address (it gets retry-on-error from the job queue itself)

API

await pettyCache.debounce('refresh-account.123', { wait: 5 * 60 * 1000 }, async (refreshDate) => {
    const message = serviceBusClient.createMessage({ id: '123' });
    message.scheduledEnqueueTimeUtc = refreshDate;
    await serviceBusClient.sendMessageAsync(topic, message);
});

Test plan

  • First call invokes the callback with a future `refreshDate`
  • Subsequent calls within the window are absorbed (no-op return)
  • Calls after window expiry invoke the callback again
  • Callback throw clears state → next caller fires immediately
  • Different keys are independent
  • Async callbacks are awaited
  • Async rejection propagates as Promise rejection

What was considered and rejected

  • Callback-style API — async/await only is consistent with where new petty-cache code is heading
  • `signal()` + `consume()` two-step API — adds a step the subscriber must remember; partial-failure cliff if subscriber crashes between consume and work
  • Lua script with `extend` (BullMQ-style sliding window) — not the semantic we want; subsequent signals should be absorbed, not reset the timer
  • Each caller holds a `setTimeout` (`node-distributed-debounce` pattern) — doesn't scale; loses work on process death
  • Mutex-wrapped check-then-set — redundant when SETNX is atomic

🤖 Generated with Claude Code

@coveralls
Copy link
Copy Markdown

coveralls commented Apr 25, 2026

Coverage Report for CI Build 24942513116

Coverage decreased (-0.2%) to 99.751%

Details

  • Coverage decreased (-0.2%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (43 of 45 lines covered, 95.56%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
index.js 45 43 95.56%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1194
Covered Lines: 1192
Line Coverage: 99.83%
Relevant Branches: 413
Covered Branches: 411
Branch Coverage: 99.52%
Branches in Coverage %: Yes
Coverage Strength: 49.34 hits per line

💛 - Coveralls

@freshlogic freshlogic force-pushed the feat/debounce branch 15 times, most recently from 316541d to 560936d Compare April 26, 2026 01:46
Coalesces calls for the same key across multiple processes via Redis so
that the callback runs at most once per `wait` window. Uses a single
SETNX with PX TTL for atomicity. Cleans up state on callback throw so the
next caller can retry immediately.

Reference: imitates BullMQ's job-deduplication mechanic (SET NX PX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@freshlogic
Copy link
Copy Markdown
Member Author

Closing — pettyCache.mutex.lock already provides everything we need (SETNX with TTL). The debounce primitive added complexity (UUID stamps, in-process setTimeout, mutex coordination) without enough benefit for the use cases we've actually identified. Will reopen if a real need surfaces.

@freshlogic freshlogic closed this Apr 26, 2026
@freshlogic freshlogic deleted the feat/debounce branch April 26, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants