graphile / graphile/worker

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

Open
#583 0 comments 4 reactions 0 assignees View on GitHub
enhancement
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.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.