Support debounce with max delay: new jobKeyMode to push run_at forward with an upper bound
- Dominant language
- TypeScript
- Stars
- 2.4k
- Forks
- 126
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 9
Description
### Feature description
Add a new `jobKeyMode` that combines **debounce with a maximum delay** — i.e., each new call pushes `run_at` forward (like `replace`), but never beyond a configurable upper bound relative to when the job was first created.
**Proposed API:**
```ts
addJob(payload, {
jobKey: "my-key",
runAt: addSeconds(new Date(), 2), // debounce window
jobKeyMode: "debounce",
maxDebounceSeconds: 10, // upper bound since first creation
});
```
**Behavior:**
| Event | `run_at` | Notes |
|---|---|---|
| T+0s — first call | T+2s | Job created, `created_at = T+0s` |
| T+1s — second call | T+3s | `run_at` pushed forward (debounce), still ≤ `created_at + 10s` |
| T+3s — third call | T+5s | Debounce continues |
| T+9s — fourth call | T+10s | Would be T+11s, but clamped to T+10s |
| T+10s | — | Job executes. Next call starts a fresh cycle |
**Implementation idea:**
The existing `_private_jobs` table already has a `created_at` column that is set once when the job row is inserted and is not updated by subsequent `addJob` calls with the same `jobKey`. This is exactly the "first enqueued at" timestamp needed for clamping.
When `jobKeyMode = "debounce"`: update `run_at` to the new value (like `replace`), but clamp it:
```sql
run_at = LEAST(new_run_at, created_at + (maxDebounceSeconds || ' seconds')::interval)
```
No schema changes required — the feature can be implemented purely as logic in the `add_job` function.
**Alternative API shapes (open to discussion):**
```ts
// Option A: explicit new mode
jobKeyMode: "debounce",
maxDebounceSeconds: 10,
// Option B: extend preserve_run_at with a tolerance
jobKeyMode: "preserve_run_at",
maxRunAtDrift: 10,
// Option C: generic clamp on replace
jobKeyMode: "replace",
maxDelaySeconds: 10,
```
No strong preference — whatever fits the library's design philosophy best.
### Motivating example
Debounce-with-max-delay is an extremely common pattern in event-driven systems. A concrete case: recalculating derived data after user edits.
```ts
// Users vote for ideas in rapid succession — we want to debounce
// the expensive recalculation, but not starve it indefinitely.
addJob("rubrics.recalculateRating", { rubricId }, {
jobKey: `rubrics.recalculateIdeasRating:${rubricId}`,
runAt: addSeconds(new Date(), 2),
jobKeyMode: "preserve_run_at", // compromise: throttle, not debounce
});
```
The current options force a choice between two extremes:
- **`replace`** — true debounce, but if events arrive every second, the job is postponed forever.
- **`preserve_run_at`** — guaranteed execution at the original time, but ignores recency entirely (throttle, not debounce).
Neither captures the desired semantics: "wait for a pause in activity, but guarantee execution within N seconds."
Other use cases where this applies: webhook coalescing, search index rebuilding, cache invalidation batching, notification grouping, analytics aggregation after user activity bursts.
### Breaking changes
None. The existing `created_at` column on `_private_jobs` already provides the necessary "first enqueued" anchor. The feature can be implemented as a new `jobKeyMode` value (`"debounce"`) with an additive option (`maxDebounceSeconds`), requiring no schema migration and no changes to existing modes.
### Supporting development
I [tick all that apply]:
- [x] am interested in building this feature myself
- [x] am interested in collaborating on building this feature
- [x] am willing to help testing this feature before it's released
- [x] am willing to write a test-driven test suite for this feature (before it exists)
- [ ] am a [Graphile sponsor](https://www.graphile.org/sponsor/) ❤️
- [ ] have an active [support or consultancy contract](https://www.graphile.org/support/) with Graphile
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.