@n8n/scheduler 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +168 -31
  2. package/dist/build.tsbuildinfo +1 -1
  3. package/dist/core/errors.d.ts +31 -0
  4. package/dist/core/errors.js +60 -1
  5. package/dist/core/errors.js.map +1 -1
  6. package/dist/core/factory.d.ts +6 -0
  7. package/dist/core/factory.js +89 -0
  8. package/dist/core/factory.js.map +1 -1
  9. package/dist/core/index.d.ts +7 -3
  10. package/dist/core/index.js +15 -1
  11. package/dist/core/index.js.map +1 -1
  12. package/dist/core/lifecycle/options.d.ts +2 -0
  13. package/dist/core/lifecycle/options.js +2 -0
  14. package/dist/core/lifecycle/options.js.map +1 -1
  15. package/dist/core/materializer/coalesce-group.js +1 -3
  16. package/dist/core/materializer/coalesce-group.js.map +1 -1
  17. package/dist/core/materializer/index.d.ts +1 -0
  18. package/dist/core/materializer/index.js +4 -1
  19. package/dist/core/materializer/index.js.map +1 -1
  20. package/dist/core/materializer/owner-key.d.ts +17 -0
  21. package/dist/core/materializer/owner-key.js +17 -0
  22. package/dist/core/materializer/owner-key.js.map +1 -0
  23. package/dist/core/provisioning/index.d.ts +1 -1
  24. package/dist/core/provisioning/provisioner.d.ts +9 -3
  25. package/dist/core/provisioning/provisioner.js +15 -0
  26. package/dist/core/provisioning/provisioner.js.map +1 -1
  27. package/dist/core/reconciliation/index.d.ts +4 -0
  28. package/dist/core/reconciliation/index.js +10 -0
  29. package/dist/core/reconciliation/index.js.map +1 -0
  30. package/dist/core/reconciliation/options.d.ts +8 -0
  31. package/dist/core/reconciliation/options.js +12 -0
  32. package/dist/core/reconciliation/options.js.map +1 -0
  33. package/dist/core/reconciliation/owner.d.ts +10 -0
  34. package/dist/core/reconciliation/owner.js +34 -0
  35. package/dist/core/reconciliation/owner.js.map +1 -0
  36. package/dist/core/reconciliation/reconcile.d.ts +38 -0
  37. package/dist/core/reconciliation/reconcile.js +228 -0
  38. package/dist/core/reconciliation/reconcile.js.map +1 -0
  39. package/dist/core/reconciliation/store.d.ts +13 -0
  40. package/dist/core/reconciliation/store.js +3 -0
  41. package/dist/core/reconciliation/store.js.map +1 -0
  42. package/dist/core/recurrence/field.d.ts +2 -2
  43. package/dist/core/recurrence/field.js.map +1 -1
  44. package/dist/core/recurrence/kinds/cron.d.ts +2 -2
  45. package/dist/core/recurrence/kinds/cron.js.map +1 -1
  46. package/dist/core/recurrence/kinds/interval.d.ts +2 -2
  47. package/dist/core/recurrence/kinds/interval.js.map +1 -1
  48. package/dist/core/recurrence/kinds/one-off.d.ts +2 -2
  49. package/dist/core/recurrence/kinds/one-off.js.map +1 -1
  50. package/dist/core/recurrence/kinds/recurring-cron.d.ts +2 -2
  51. package/dist/core/recurrence/kinds/recurring-cron.js.map +1 -1
  52. package/dist/core/recurrence/resolve.d.ts +4 -2
  53. package/dist/core/recurrence/resolve.js +15 -0
  54. package/dist/core/recurrence/resolve.js.map +1 -1
  55. package/dist/core/scheduler.d.ts +2 -0
  56. package/dist/core/types/schedule.d.ts +2 -1
  57. package/dist/observability/attributes.d.ts +5 -0
  58. package/dist/observability/attributes.js +5 -0
  59. package/dist/observability/attributes.js.map +1 -1
  60. package/dist/observability/metrics.d.ts +1 -0
  61. package/dist/observability/metrics.js +1 -0
  62. package/dist/observability/metrics.js.map +1 -1
  63. package/package.json +7 -7
package/README.md CHANGED
@@ -14,13 +14,13 @@ restarts, the timers are gone. If you run several copies of the server for high
14
14
  availability, every copy fires the same timer and the work runs many times.
15
15
 
16
16
  This package takes a different approach: **write the work down before running it.**
17
- Every upcoming run is recorded in the database first, then picked up and fired from
18
- there. Because the source of truth lives in storage and not in one process's memory:
17
+ Every upcoming run is recorded in shared, durable storage first, then picked up and
18
+ fired from there. Because the source of truth lives in storage and not in one process's memory:
19
19
 
20
20
  - a restart or crash loses nothing (the run is still recorded and gets picked up
21
21
  again),
22
22
  - any server in the cluster can pick up any run, and
23
- - coordination in the database drives each run toward firing on **exactly one**
23
+ - coordination through the shared store drives each run toward firing on **exactly one**
24
24
  server, with safety mechanisms (claims, leases, recovery) to fall back on when a
25
25
  server crashes mid-run.
26
26
 
@@ -44,10 +44,9 @@ From then on it:
44
44
  - recovers runs that were stranded by a crashed server,
45
45
  - cleans up finished runs after a retention window.
46
46
 
47
- **How it is built.** The package is **pure logic**. It talks to no database and
48
- uses no dependency injection directly. Instead the host (in n8n, the `cli`
49
- package) hands it a small set of storage functions and configuration, and the
50
- scheduler composes its algorithms over them. This keeps every algorithm easy to
47
+ **How it is built.** The package is **pure logic**. It owns no storage and uses no
48
+ dependency injection directly. Instead the host hands it a small set of storage
49
+ functions and configuration, and the scheduler composes its algorithms over them. This keeps every algorithm easy to
51
50
  test in isolation with a fake store. The work is split into a few focused
52
51
  submodules, described below.
53
52
 
@@ -58,8 +57,10 @@ path during rollout.
58
57
  ## The moving parts
59
58
 
60
59
  The scheduler runs four small background routines, each on its own repeating
61
- timer. Each one is a separate submodule with a single responsibility. Their code
62
- names are in parentheses; the rest of this document uses the plain names.
60
+ timer, plus an optional fifth when owner reconciliation is configured (see
61
+ [The reconciliation sweep](#the-reconciliation-sweep)). Each one is a separate
62
+ submodule with a single responsibility. Their code names are in parentheses; the
63
+ rest of this document uses the plain names.
63
64
 
64
65
  ```mermaid
65
66
  flowchart TD
@@ -76,6 +77,8 @@ flowchart TD
76
77
  T -.->|"finished runs,<br/>past their window"| C["Cleanup<br/><i>(retention)</i>"]
77
78
  C -.->|deletes| T
78
79
 
80
+ RC["Reconciliation<br/><i>(owner sweep, optional)</i>"] -.->|"quarantines rules<br/>whose owner is gone"| J
81
+
79
82
  classDef store fill:#2d3748,stroke:#4a5568,color:#fff;
80
83
  class J,T store;
81
84
  ```
@@ -125,9 +128,9 @@ Successful and failed runs can be kept for different lengths of time.
125
128
  is the next run? It handles cron expressions, intervals, one-offs, and the
126
129
  "every N periods" variant described below, including timezone and daylight-saving
127
130
  behaviour.
128
- - **Lifecycle** is the set of repeating loops that drive the four passes above,
129
- each on its own interval with a little randomness ("jitter") so multiple servers
130
- don't all fire their passes in lockstep.
131
+ - **Lifecycle** is the set of repeating loops that drive the four passes above
132
+ (five with reconciliation), each on its own interval with a little randomness
133
+ ("jitter") so multiple servers don't all fire their passes in lockstep.
131
134
 
132
135
  ## Schedule kinds
133
136
 
@@ -216,18 +219,152 @@ Schedule and poll triggers both use `skip`: it matches what n8n did before the
216
219
  durable scheduler, where a missed run was never replayed. Running late is opt-in
217
220
  per job, which is where `coalesce` and `coalesce_owner` come in.
218
221
 
222
+ ## Owning scheduled jobs
223
+
224
+ Every scheduled job belongs to something: a published workflow, an instance-level
225
+ maintenance task, an agent. That something is its **owner**, and it is plain data on
226
+ the job row — three fields, none of which this package interprets:
227
+
228
+ | Field | Meaning |
229
+ |---|---|
230
+ | `ownerType` | what kind of thing owns the job (`workflow`, `system-task`, `agent`, …) |
231
+ | `ownerId` | which one (a workflow id, a task name, an agent id) |
232
+ | `ownerMemberId` | optionally, which part of it (the trigger node for a workflow, the scheduled task for an agent) |
233
+
234
+ Because ownership is data rather than a schema relationship, any part of the product
235
+ can own scheduled jobs without the scheduler learning what its owners are, and
236
+ without a migration. A job with nothing else to own it is **self-owned**: its
237
+ `ownerId` is its own name. There is no such thing as an ownerless job, so there is
238
+ no second code path for one.
239
+
240
+ ### The two obligations of a new owner type
241
+
242
+ Ownership is a contract, not just a label. A module that owns scheduled jobs takes on
243
+ **both** of the following. Neither substitutes for the other, and shipping an owner
244
+ type without them leaves schedules running for things that no longer exist.
245
+
246
+ **1. Deprovision inside your own delete transaction.**
247
+
248
+ There is no storage-level cascade behind these rows. When your owner goes away,
249
+ *you* delete its jobs, in the same transaction as the write that removes the owner:
250
+
251
+ ```ts
252
+ // Inside the transaction that stops the owner from existing.
253
+ await myOwnerRepository.remove(trx, ownerId);
254
+ await jobProvisioner.deprovisionOwnerInTransaction(trx, { ownerType: 'my-thing', ownerId });
255
+ ```
256
+
257
+ Committing the two together is what makes the guarantee: a crash can never leave a job
258
+ firing for an owner that is already gone. Deprovisioning is idempotent, so a retried
259
+ teardown is safe, and a path that no longer knows which of an owner's parts
260
+ provisioned what can delete the whole owner at once.
261
+
262
+ Where the teardown genuinely cannot share one transaction, **deprovision first** and
263
+ remove the owner second. A crash between the two then leaves an owner with no
264
+ schedules, which the next attempt fixes, rather than schedules with no owner, which
265
+ only the sweep below would ever find.
266
+
267
+ n8n's own `workflow` owner type is the worked example of both shapes: a deactivation
268
+ deprovisions inside the transaction that writes `active = false` (`WorkflowService`),
269
+ while an unpublish deprovisions the workflow's jobs immediately before removing the
270
+ `workflow_published_version` mapping that made it an owner
271
+ (`WorkflowPublicationApplier`).
272
+
273
+ **2. Register a liveness resolver.**
274
+
275
+ The scheduler cannot tell whether one of your owners still exists, so you answer that
276
+ question for it: register a resolver for your owner type that, given owner ids, says
277
+ which of them are still there.
278
+
279
+ ```ts
280
+ registry.register('my-thing', {
281
+ async findExisting(ownerIds) {
282
+ return await myOwners.findExistingIds(ownerIds); // a Set of the ids that remain
283
+ },
284
+ });
285
+ ```
286
+
287
+ Build the registry in one place, a single manifest the host owns: declare every
288
+ owner module there, and let both readers (the provisioner and the scheduler's
289
+ reconciliation pass) build theirs from it, so neither can see an owner the other
290
+ does not.
291
+
292
+ Two rules make the answer safe to act on:
293
+
294
+ - **Absence means gone.** An id you leave out of the result is read as a positive
295
+ statement that the owner no longer exists. Never omit an id your lookup simply did
296
+ not cover.
297
+ - **Throw when you cannot tell.** If the lookup fails (a data source is unreachable, a
298
+ dependency is not ready), throw. "Could not tell" is never treated as "gone", and
299
+ the sweep leaves every job of your owner type exactly as it was.
300
+
301
+ Provisioning **refuses** an owner type with no registered resolver, so a new kind of
302
+ owner cannot ship without a cleanup story. The refusal is built into
303
+ `createJobProvisioner` itself: the registry is one of its required dependencies.
304
+
305
+ ### The reconciliation sweep
306
+
307
+ The resolvers feed a periodic sweep, the safety net behind obligation 1. It exists for
308
+ what synchronous deprovisioning cannot cover: a process killed between two writes, a
309
+ teardown path written before it knew about scheduled jobs, an owner deleted straight
310
+ from storage.
311
+
312
+ It runs as a fifth lifecycle loop, composed only when the host passes `reconciliation`
313
+ to `createScheduler`, so a deployment without it pays nothing. It follows the same
314
+ jittered cadence as the other passes, every fifteen minutes by default with a five
315
+ minute timeout, and pages through live jobs in bounded batches so one pass never scans
316
+ the whole table.
317
+
318
+ Because it acts on an answer from code it does not own, it never deletes on first
319
+ sight:
320
+
321
+ ```mermaid
322
+ stateDiagram-v2
323
+ [*] --> Running: rule provisioned
324
+ Running --> Quarantined: owner reported gone<br/>(clock stopped, queued runs withdrawn)
325
+ Quarantined --> Running: owner reported alive again<br/>(clock recomputed)
326
+ Quarantined --> [*]: still gone past the grace period<br/>(deleted)
327
+ Running --> Running: owner reported alive
328
+ ```
329
+
330
+ - A job whose owner is reported gone has its **clock stopped and is stamped**, and its
331
+ already-queued runs are withdrawn, so it stops firing immediately. Its `enabled` flag is
332
+ left as it was: the sweep never turns a job on or off.
333
+ - It is **deleted only once that stamp is older than the quarantine grace** (a day by
334
+ default), and only while its owner is still reported gone.
335
+ - A stamped job whose owner turns out to exist after all is **revived** with a freshly
336
+ computed clock, so a resolver bug corrected inside the grace window destroys nothing. A
337
+ job that was disabled before the quarantine comes back disabled, with no clock.
338
+
339
+ A resolver answers whether an owner exists, not what it currently wants, so a revival
340
+ restores the job exactly as it was stored. A job the owner would no longer provision
341
+ (a part of it dropped while its jobs were quarantined) comes back too, and stays
342
+ runnable until the owner provisions or deprovisions again and removes it. Provisioning
343
+ is what reconciles an owner's set of jobs; the sweep only decides whether they may run
344
+ at all.
345
+
346
+ Two more guardrails: an owner type whose resolver throws, or that nothing claimed, is
347
+ left entirely alone; and jobs younger than a short settle period are skipped, so an
348
+ owner written just after its jobs is never mistaken for one that never existed.
349
+
350
+ The third layer, behind both of these, is the handler itself: a task whose subject has
351
+ disappeared should no-op and report "not dispatched" rather than fail. n8n's trigger
352
+ handlers do that for a node they can no longer resolve and for an occurrence already
353
+ executed, but not yet for a workflow whose published version is gone: that one fails the
354
+ occurrence, so it retries and dead-letters until the sweep retires the job.
355
+
219
356
  ## Durable and distributed
220
357
 
221
358
  Two properties describe the whole design, and everything else follows from them.
222
359
 
223
360
  **Durable** means nothing important lives only in memory. The rules, every upcoming
224
- run, and each run's current state all live in the database. A process can restart
361
+ run, and each run's current state all live in the shared store. A process can restart
225
362
  or crash at any moment and the scheduler simply picks up where the stored state left
226
363
  off. Compare an in-memory scheduler, where a restart forgets every pending timer.
227
364
 
228
365
  **Distributed and leaderless** means there is no elected "scheduler node" that the
229
366
  others depend on. Every server runs the same passes over the same shared state, and
230
- they coordinate purely through the database. There is no leader to elect, fail over,
367
+ they coordinate purely through the shared store. There is no leader to elect, fail over,
231
368
  or lose. Adding or removing a server changes throughput, not correctness: any server
232
369
  can plan any rule and run any due task.
233
370
 
@@ -240,9 +377,9 @@ over never repeating it.** In rare recovery situations that can mean a run execu
240
377
  more than once, so handlers should tolerate being called again for the same run.
241
378
  The design leans on a few ideas working together.
242
379
 
243
- - **Claim.** A run is only ever picked up through an atomic database claim: a
244
- single statement that flips exactly one waiting row to "running" and stamps it
245
- with the server that claimed it. Because the database resolves the race, only one
380
+ - **Claim.** A run is only ever picked up through an atomic claim in the shared
381
+ store: a single statement that flips exactly one waiting row to "running" and stamps it
382
+ with the server that claimed it. Because the store resolves the race, only one
246
383
  server can win a given run, no matter how many try at once.
247
384
  - **Lease.** A claim comes with an expiry (a *lease*). While the lease is valid the
248
385
  run belongs to that server. If the server dies, the lease lapses and the recovery
@@ -328,8 +465,8 @@ stateDiagram-v2
328
465
  ## What this package is (and is not)
329
466
 
330
467
  It is a **library of scheduling logic**, not a running service on its own. It owns
331
- the algorithms and the schedule math; it does not own a database connection, a DI
332
- container, or a configuration source. A **host** provides those and drives it.
468
+ the algorithms and the schedule math; it does not own storage, a DI container, or
469
+ a configuration source. A **host** provides those and drives it.
333
470
 
334
471
  The public surface is small:
335
472
 
@@ -345,7 +482,7 @@ The public surface is small:
345
482
  - A few pure helpers are exported too: schedule validation, next-run computation,
346
483
  and the provisioning entry points.
347
484
 
348
- In n8n, that host is the `cli` package's durable-scheduler module, which wires up
485
+ In n8n, that host is the server's durable-scheduler module, which wires up
349
486
  DI, configuration and instance identity, and routes the scheduler's events to the
350
487
  logger. While `N8N_SCHEDULER_ENABLED` is off, the previous in-memory engine stays
351
488
  in place; turning the flag off again is the rollback.
@@ -354,7 +491,7 @@ The boundary this section describes is enforced, not just documented.
354
491
  `src/__tests__/dependency-purity.test.ts` fails if the package ever gains a
355
492
  forbidden dependency, whether a new `package.json` entry outside the allowlist or
356
493
  an `import` of something effectful (`@n8n/db`, TypeORM, `n8n-core`, the DI
357
- container, the `cli` package, or a Node I/O built-in). Its opening doc comment is
494
+ container, the host server package, or a Node I/O built-in). Its opening doc comment is
358
495
  the living explanation of the approach: why scheduling is split from execution and
359
496
  storage, why each excluded dependency is excluded, and how far the idea could be
360
497
  taken. Read it first if you want the reasoning behind the split above.
@@ -394,7 +531,7 @@ A few things that are not obvious from the code but save a lot of confusion.
394
531
  event sink at `warn` level, so they land in the host's logs:
395
532
  - a **clock-skew warning** at start-up. Due-ness and leases are judged on the
396
533
  clock the scheduler coordinates on (the host supplies it via `now`, e.g. the
397
- database clock), but fire timers are armed on this instance's own clock. When
534
+ shared store's clock), but fire timers are armed on this instance's own clock. When
398
535
  `start` samples the two and they differ by more than the threshold (default
399
536
  `1s`, `CLOCK_SKEW_WARN_THRESHOLD_MS`), it warns: tasks may fire early or late,
400
537
  and the instance clock should be synchronised (e.g. via NTP). The sample runs
@@ -412,7 +549,7 @@ A few things that are not obvious from the code but save a lot of confusion.
412
549
  to stop already-queued runs, unless the rule-writing path withdraws them (which
413
550
  provisioning does).
414
551
 
415
- - **Each background pass runs on its own timer, with jitter.** The four passes are
552
+ - **Each background pass runs on its own timer, with jitter.** The passes are
416
553
  independent and slightly randomised, so several servers do not all run their
417
554
  passes in lockstep, and one slow pass does not stall the others.
418
555
 
@@ -420,10 +557,10 @@ A few things that are not obvious from the code but save a lot of confusion.
420
557
 
421
558
  - **A dedicated host module.** Today the wiring that turns this pure library into a
422
559
  running scheduler (DI, configuration, instance identity, event routing) lives
423
- inside the large `cli` package. Extracting it into its own module, for example
424
- `@n8n/scheduler-cli` or `@n8n/scheduler-features`, would keep the integration
425
- concerns together and out of `cli`, and mirror the clean split this package
426
- already draws between algorithm and host.
560
+ inside n8n's large server package. Extracting it into its own module, for example
561
+ `@n8n/scheduler-features`, would keep the integration concerns together on their
562
+ own, and mirror the clean split this package already draws between algorithm and
563
+ host.
427
564
  - **Lease renewal for long handlers.** A claim's lease is fixed for now, so a
428
565
  handler that runs longer than its lease risks being recovered and re-run. A
429
566
  heartbeat that extends the lease while a handler is genuinely still working would
@@ -433,14 +570,14 @@ A few things that are not obvious from the code but save a lot of confusion.
433
570
 
434
571
  Pushed all the way, that host module becomes its own deployable worker. On a due
435
572
  task it does not run the workflow, it creates an execution and enqueues a job (Bull
436
- and Redis) for the regular workers, so the real work is decomposing `cli`. The
437
- result is far leaner than `cli`, which bundles every node's dependencies (1Password,
438
- S3, and more). The worker only needs:
573
+ and Redis) for the regular workers, so the real work is decomposing the server
574
+ package. The result is far leaner than that package, which bundles every node's
575
+ dependencies (1Password, S3, and more). The worker only needs:
439
576
 
440
577
  | Needs | Package | Pulls in |
441
578
  | --- | --- | --- |
442
579
  | Core logic | `@n8n/scheduler` | luxon, cron-parser |
443
- | Task store + transactions | `@n8n/db` | `@n8n/typeorm` + a database driver |
580
+ | Task store + transactions | `@n8n/db` | `@n8n/typeorm` + its driver |
444
581
  | Enqueue executions (a new, extracted publisher) | `@n8n/…-publisher` | Bull + ioredis, execution writes, trigger-context |
445
582
  | Logging + lifecycle | `@n8n/backend-common` | winston |
446
583
  | Wiring + types | `@n8n/config`, `@n8n/di`, `@n8n/decorators`, `n8n-workflow` | reflect-metadata |