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:
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:
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):
// 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.
// 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]:
Feature description
Add a new
jobKeyModethat combines debounce with a maximum delay — i.e., each new call pushesrun_atforward (likereplace), but never beyond a configurable upper bound relative to when the job was first created.Proposed API:
Behavior:
run_atcreated_at = T+0srun_atpushed forward (debounce), still ≤created_at + 10sImplementation idea:
The existing
_private_jobstable already has acreated_atcolumn that is set once when the job row is inserted and is not updated by subsequentaddJobcalls with the samejobKey. This is exactly the "first enqueued at" timestamp needed for clamping.When
jobKeyMode = "debounce": updaterun_atto the new value (likereplace), but clamp it:No schema changes required — the feature can be implemented purely as logic in the
add_jobfunction.Alternative API shapes (open to discussion):
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.
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_atcolumn on_private_jobsalready provides the necessary "first enqueued" anchor. The feature can be implemented as a newjobKeyModevalue ("debounce") with an additive option (maxDebounceSeconds), requiring no schema migration and no changes to existing modes.Supporting development
I [tick all that apply]: