Skip to content

Support debounce with max delay: new jobKeyMode to push run_at forward with an upper bound #583

@IlyaSemenov

Description

@IlyaSemenov

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]:

  • am interested in building this feature myself
  • am interested in collaborating on building this feature
  • am willing to help testing this feature before it's released
  • am willing to write a test-driven test suite for this feature (before it exists)
  • am a Graphile sponsor ❤️
  • have an active support or consultancy contract with Graphile

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions