@n8n/scheduler 0.3.1 → 1.1.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 (69) hide show
  1. package/README.md +388 -40
  2. package/dist/build.tsbuildinfo +1 -1
  3. package/dist/core/clock-skew.d.ts +15 -0
  4. package/dist/core/clock-skew.js +20 -0
  5. package/dist/core/clock-skew.js.map +1 -0
  6. package/dist/core/errors.js.map +1 -1
  7. package/dist/core/executor/backoff.js.map +1 -1
  8. package/dist/core/executor/executor.js +20 -3
  9. package/dist/core/executor/executor.js.map +1 -1
  10. package/dist/core/executor/index.d.ts +1 -1
  11. package/dist/core/executor/index.js +2 -1
  12. package/dist/core/executor/index.js.map +1 -1
  13. package/dist/core/executor/precision-timer.js.map +1 -1
  14. package/dist/core/executor/store.d.ts +2 -1
  15. package/dist/core/executor/task-handler.d.ts +10 -1
  16. package/dist/core/executor/task-handler.js +14 -0
  17. package/dist/core/executor/task-handler.js.map +1 -1
  18. package/dist/core/factory.d.ts +5 -0
  19. package/dist/core/factory.js +47 -4
  20. package/dist/core/factory.js.map +1 -1
  21. package/dist/core/index.d.ts +8 -4
  22. package/dist/core/index.js +14 -2
  23. package/dist/core/index.js.map +1 -1
  24. package/dist/core/lifecycle/alarm.js.map +1 -1
  25. package/dist/core/lifecycle/index.d.ts +1 -1
  26. package/dist/core/lifecycle/index.js +2 -2
  27. package/dist/core/lifecycle/index.js.map +1 -1
  28. package/dist/core/lifecycle/lookahead.d.ts +1 -1
  29. package/dist/core/lifecycle/lookahead.js +2 -2
  30. package/dist/core/lifecycle/lookahead.js.map +1 -1
  31. package/dist/core/lifecycle/loop.js.map +1 -1
  32. package/dist/core/lifecycle/timeline.js.map +1 -1
  33. package/dist/core/materializer/materialize.js +2 -1
  34. package/dist/core/materializer/materialize.js.map +1 -1
  35. package/dist/core/materializer/options.d.ts +1 -0
  36. package/dist/core/materializer/options.js +1 -0
  37. package/dist/core/materializer/options.js.map +1 -1
  38. package/dist/core/materializer/plan.js.map +1 -1
  39. package/dist/core/materializer/transaction.d.ts +1 -1
  40. package/dist/core/provisioning/index.d.ts +1 -0
  41. package/dist/core/provisioning/index.js +3 -1
  42. package/dist/core/provisioning/index.js.map +1 -1
  43. package/dist/core/provisioning/provision.js +2 -22
  44. package/dist/core/provisioning/provision.js.map +1 -1
  45. package/dist/core/provisioning/provisioner.js.map +1 -1
  46. package/dist/core/provisioning/schedule-identity.d.ts +3 -0
  47. package/dist/core/provisioning/schedule-identity.js +36 -0
  48. package/dist/core/provisioning/schedule-identity.js.map +1 -0
  49. package/dist/core/reaper/reap.d.ts +7 -5
  50. package/dist/core/reaper/reap.js +14 -3
  51. package/dist/core/reaper/reap.js.map +1 -1
  52. package/dist/core/recurrence/field.js.map +1 -1
  53. package/dist/core/recurrence/kinds/cron.js.map +1 -1
  54. package/dist/core/recurrence/kinds/interval.js.map +1 -1
  55. package/dist/core/recurrence/kinds/one-off.js.map +1 -1
  56. package/dist/core/recurrence/kinds/recurring-cron.js.map +1 -1
  57. package/dist/core/recurrence/next-run.js.map +1 -1
  58. package/dist/core/recurrence/periods.js.map +1 -1
  59. package/dist/core/recurrence/resolve.js.map +1 -1
  60. package/dist/core/recurrence/validate.js.map +1 -1
  61. package/dist/core/retention/prune.js.map +1 -1
  62. package/dist/observability/attributes.d.ts +33 -33
  63. package/dist/observability/attributes.js.map +1 -1
  64. package/dist/observability/executor-tracing.js +4 -3
  65. package/dist/observability/executor-tracing.js.map +1 -1
  66. package/dist/observability/materializer-tracing.js.map +1 -1
  67. package/dist/observability/pass-tracing.js.map +1 -1
  68. package/dist/observability/provisioner-tracing.js.map +1 -1
  69. package/package.json +8 -8
package/README.md CHANGED
@@ -1,65 +1,413 @@
1
1
  # @n8n/scheduler
2
2
 
3
- Durable, multi-main scheduling for n8n. Upcoming work is recorded in storage
4
- before it runs, then claimed, fired and recovered under leases and epoch
5
- fencing, so each occurrence executes once across the cluster and survives
6
- restarts and failovers. Occurrences are dispatched to handlers by `taskType`;
7
- the Schedule Trigger node is the first consumer. Everything sits behind
8
- `N8N_SCHEDULER_ENABLED` (default off) while the legacy engine stays in place.
9
-
10
- ## Surface
11
-
12
- The package is pure logic, with no DI and no database. It exposes exactly two
13
- things (plus the types their signatures reach):
14
-
15
- - **`Scheduler`**: `registerTaskHandler` plus one accessor per pass
16
- (`materialize`, `execute`, `reap`, `prune`, `stop`). The host drives each
17
- pass on its own cadence.
18
- - **`createScheduler(deps)`**: composes the internal algorithms (materializer,
19
- executor, reaper, retention) into a `Scheduler`.
20
-
21
- The host binds `deps`: a task store and a transaction runner (the `@n8n/db`
22
- repositories satisfy the store contracts structurally, so no adapters are
23
- needed), optional per-pass tuning, and an `onEvent` sink for described
24
- incidents. In n8n that host is the cli's `DurableScheduler`
25
- (`packages/cli/src/scheduling/`), which wires DI, config and instance identity,
26
- and routes events to the logger.
3
+ A durable, cluster-safe scheduler. You give it rules like "run this every Monday
4
+ at 09:00" or "run this once at midnight on New Year's Eve", and it makes sure the
5
+ work actually runs, at the right time, and (as close as a distributed system can
6
+ get) exactly once, even when n8n is running on several servers at once and even
7
+ across restarts and crashes.
8
+
9
+ ## TL;DR
10
+
11
+ **The principle.** Most schedulers keep their timers in memory: the process holds
12
+ a `setTimeout` for each upcoming run. That is simple but fragile. If the process
13
+ restarts, the timers are gone. If you run several copies of the server for high
14
+ availability, every copy fires the same timer and the work runs many times.
15
+
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:
19
+
20
+ - a restart or crash loses nothing (the run is still recorded and gets picked up
21
+ again),
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**
24
+ server, with safety mechanisms (claims, leases, recovery) to fall back on when a
25
+ server crashes mid-run.
26
+
27
+ **Two things you work with.**
28
+
29
+ - A **job** is a *rule*: it says *when* something should happen (the cron
30
+ expression, the interval, the one-off instant). Think of it as a recurring
31
+ calendar event.
32
+ - A **task** is *one concrete run* produced from that rule, scheduled for one
33
+ specific instant. Think of it as a single occurrence of that calendar event on a
34
+ specific date. Tasks are the unit that actually gets claimed, run, retried and
35
+ cleaned up.
36
+
37
+ **The functionality surface.** You register a *handler* (a function that does the
38
+ actual work) for a given task type, hand the scheduler your rules, and start it.
39
+ From then on it:
40
+
41
+ - turns each rule into concrete upcoming runs, ahead of time,
42
+ - claims each due run on one server and calls your handler at the right moment,
43
+ - retries a run that fails, with a growing backoff between attempts,
44
+ - recovers runs that were stranded by a crashed server,
45
+ - cleans up finished runs after a retention window.
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
51
+ test in isolation with a fake store. The work is split into a few focused
52
+ submodules, described below.
53
+
54
+ Everything sits behind the `N8N_SCHEDULER_ENABLED` flag (off by default). Turning
55
+ it off reverts n8n to its previous in-memory scheduling, which is the rollback
56
+ path during rollout.
57
+
58
+ ## The moving parts
59
+
60
+ 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.
63
+
64
+ ```mermaid
65
+ flowchart TD
66
+ P["Provisioning<br/><i>define & update rules</i>"] --> J[("Jobs<br/><i>the rules: when to run</i>")]
67
+
68
+ J -->|"claims rules whose<br/>next run is due"| M["Planner<br/><i>(materializer)</i>"]
69
+ M -->|"records upcoming<br/>runs, ahead of time"| T[("Tasks<br/><i>concrete queued runs</i>")]
70
+
71
+ T -->|"claims due runs<br/>for this server"| E["Runner<br/><i>(executor)</i>"]
72
+ E -->|"fires at the right<br/>instant"| H["Your handler<br/><i>does the actual work</i>"]
73
+ E -.->|"on failure: retry<br/>later with backoff"| T
74
+
75
+ RE["Recovery<br/><i>(reaper)</i>"] -.->|"rescues runs stranded<br/>by a crashed server"| T
76
+ T -.->|"finished runs,<br/>past their window"| C["Cleanup<br/><i>(retention)</i>"]
77
+ C -.->|deletes| T
78
+
79
+ classDef store fill:#2d3748,stroke:#4a5568,color:#fff;
80
+ class J,T store;
81
+ ```
82
+
83
+ ### Planner (materializer)
84
+
85
+ Turns rules into concrete runs. On each pass it claims the jobs whose next run is
86
+ due, computes their upcoming occurrences a little way into the future, records
87
+ those as tasks, and advances each job's clock to its next run. Recording runs
88
+ *ahead of time* means a frequent schedule doesn't need one planning pass per fire.
89
+
90
+ Failures are isolated: if one rule cannot be planned (for example a timezone this
91
+ server's runtime doesn't know about), that single job is set aside and retried
92
+ later, while every other job in the batch still gets planned. One broken rule
93
+ never wedges scheduling for everyone.
94
+
95
+ ### Runner (executor)
96
+
97
+ Runs the concrete tasks. On each pass it claims the tasks that are due for this
98
+ server and arms a precise in-memory timer for each, so it fires at its exact
99
+ instant. When the timer fires it calls your registered handler for that task type.
100
+ If the handler succeeds the task is marked done; if it throws and attempts remain,
101
+ the task is rescheduled a bit later (backoff); if it throws with no attempts left,
102
+ the task fails for good (a "dead letter").
103
+
104
+ ### Recovery (reaper)
105
+
106
+ Rescues stranded runs. When a server claims a task it takes a time-limited *lease*
107
+ on it. If that server crashes or stalls, the lease eventually expires and the task
108
+ is left stuck in the "running" state. The recovery pass finds those expired-lease
109
+ tasks and puts them back in line for another attempt (or fails them for good if
110
+ they are out of attempts). This is what makes a mid-run crash safe.
111
+
112
+ ### Cleanup (retention)
113
+
114
+ Deletes finished tasks once they are older than a retention window, oldest first,
115
+ in small bounded batches so it never holds long locks or monopolises a pass.
116
+ Successful and failed runs can be kept for different lengths of time.
117
+
118
+ ### Supporting submodules
119
+
120
+ - **Provisioning** keeps the stored set of rules in sync with what a scope
121
+ (for example a workflow) currently wants: it inserts new rules, rewrites changed
122
+ ones, and removes rules that are gone. Unchanged rules keep their identity, so
123
+ re-publishing a workflow never double-fires runs that were already queued.
124
+ - **Recurrence** is the pure date math: given a rule and a starting instant, what
125
+ is the next run? It handles cron expressions, intervals, one-offs, and the
126
+ "every N periods" variant described below, including timezone and daylight-saving
127
+ 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.
27
131
 
28
132
  ## Schedule kinds
29
133
 
30
- A scheduled job says *when* something should run. There are four kinds:
134
+ A job says *when* to run. There are four kinds:
31
135
 
32
136
  | Kind | Meaning | Example |
33
137
  |---|---|---|
34
138
  | `cron` | Fires at fixed clock times, written as a cron expression. | `0 0 9 * * 1` = every Monday at 09:00 |
35
139
  | `interval` | Fires every N seconds of elapsed time. | every 3600s = once an hour |
36
140
  | `one_off` | Fires once, at a single instant, then never again. | fire at 2026-01-01 00:00 |
37
- | `recurring_cron` | A cron *anchor* thinned by an "every N periods" gate, for cadences plain cron cannot express. | every 3 weeks on Monday |
141
+ | `recurring_cron` | A cron pattern thinned by an "every N periods" filter, for cadences plain cron cannot express. | every 5 hours; every 3 weeks on Monday |
142
+
143
+ A `cron` schedule is evaluated as *wall-clock* time in a timezone, so it follows
144
+ daylight-saving shifts (09:00 stays 09:00). An `interval` schedule counts *elapsed*
145
+ time, so daylight saving never moves it.
38
146
 
39
147
  ### Why `recurring_cron`?
40
148
 
41
- Plain cron can say "every Monday", but not "every *third* Monday".
42
- `recurring_cron` builds that from two parts:
149
+ A cron expression can only pick times *within* a repeating calendar (which minute,
150
+ hour, day-of-week, and so on). It cannot count across those cycles. So it can say
151
+ "every Monday", but not "every *third* Monday", and it can say "at minute 0 of some
152
+ hours", but not "every 5 hours" starting from a fixed point (the day has 24 hours,
153
+ which 5 does not divide evenly, so no fixed set of hour values repeats cleanly).
43
154
 
44
- - an **anchor**: a normal cron that lists candidate dates (often too many),
45
- - a **gate**: keeps a candidate only once at least `recurrenceSize` periods of
46
- `recurrenceUnit` (`hours` / `days` / `weeks` / `months`) have passed since the
47
- last kept fire.
155
+ `recurring_cron` covers exactly those cases by combining two parts:
48
156
 
49
- "Every 3 weeks on Monday" = anchor "every Monday" + gate "1 per 3 weeks":
157
+ - an **anchor**: a normal cron pattern that lists candidate times (often too many),
158
+ - a **filter**: keeps a candidate only once at least N periods (hours, days, weeks
159
+ or months) have passed since the last kept run.
160
+
161
+ The most common case is a plain "every N hours". "Every 5 hours" = anchor "every
162
+ hour" + filter "1 per 5 hours":
163
+
164
+ ```
165
+ Anchor (every hour): H H H H H H H H H H H
166
+ Filter (1 per 5 hours): ✓ ✗ ✗ ✗ ✗ ✓ ✗ ✗ ✗ ✗ ✓
167
+ Fires: H H H
168
+ ```
169
+
170
+ The same shape expresses calendar cadences too. "Every 3 weeks on Monday" = anchor
171
+ "every Monday" + filter "1 per 3 weeks":
50
172
 
51
173
  ```
52
174
  Anchor (every Monday): M M M M M M M
53
- Gate (1 per 3 weeks): ✓ ✗ ✗ ✓ ✗ ✗ ✓
175
+ Filter (1 per 3 weeks): ✓ ✗ ✗ ✓ ✗ ✗ ✓
54
176
  Fires: M M M
55
177
  ```
56
178
 
57
- The gate looks only at the previous fire, with no stored counter. So any
58
- instance in the cluster can compute the next run, and a restart never loses the
179
+ The filter looks only at the *previous* run, with no stored counter. So any server
180
+ in the cluster can compute the next run on its own, and a restart never loses the
59
181
  cadence.
60
182
 
61
- ## Status
183
+ ## Durable and distributed
184
+
185
+ Two properties describe the whole design, and everything else follows from them.
186
+
187
+ **Durable** means nothing important lives only in memory. The rules, every upcoming
188
+ run, and each run's current state all live in the database. A process can restart
189
+ or crash at any moment and the scheduler simply picks up where the stored state left
190
+ off. Compare an in-memory scheduler, where a restart forgets every pending timer.
191
+
192
+ **Distributed and leaderless** means there is no elected "scheduler node" that the
193
+ others depend on. Every server runs the same passes over the same shared state, and
194
+ they coordinate purely through the database. There is no leader to elect, fail over,
195
+ or lose. Adding or removing a server changes throughput, not correctness: any server
196
+ can plan any rule and run any due task.
197
+
198
+ ## Aiming for exactly-once across a cluster
199
+
200
+ In a distributed system, servers crash mid-run, clocks drift, and network calls
201
+ time out. A scheduler cannot make those failures impossible, so this one is built to
202
+ **target exactly-once and, when something does go wrong, to prefer not losing work
203
+ over never repeating it.** In rare recovery situations that can mean a run executes
204
+ more than once, so handlers should tolerate being called again for the same run.
205
+ The design leans on a few ideas working together.
206
+
207
+ - **Claim.** A run is only ever picked up through an atomic database claim: a
208
+ single statement that flips exactly one waiting row to "running" and stamps it
209
+ with the server that claimed it. Because the database resolves the race, only one
210
+ server can win a given run, no matter how many try at once.
211
+ - **Lease.** A claim comes with an expiry (a *lease*). While the lease is valid the
212
+ run belongs to that server. If the server dies, the lease lapses and the recovery
213
+ pass can safely take the run back. Without leases a crashed server would strand
214
+ its runs forever.
215
+ - **Fencing.** Each claim carries a version number (an *epoch*) that increases every
216
+ time a run is claimed. Every final write ("mark succeeded", "mark failed") is
217
+ guarded by that number. So if a slow server comes back from the dead after its
218
+ run was already recovered and re-run elsewhere, its late write matches nothing and
219
+ is harmlessly ignored. It cannot overwrite the newer result.
220
+
221
+ The claim, the lease and the recovery pass are the safety net: a crash at any point
222
+ is recoverable rather than silently lost. A few supporting choices reinforce it:
223
+
224
+ - **Identity and idempotency.** Each concrete run is unique on its
225
+ `(job, instant)` pair, so the same instant can never be queued twice, even if two
226
+ planning passes race.
227
+ - **Recomputable recurrence.** The next run is derived from the last one, never from
228
+ a counter held in memory, so any server can compute it and a restart never drifts.
229
+ - **Shared backoff.** A retry after a handler failure and a retry after a crash use
230
+ the same backoff curve, so recovery behaves consistently however a run failed.
231
+
232
+ ## Lifecycle of a job (a rule)
233
+
234
+ A job carries a "clock" (its next-run time). The planner keeps advancing that clock
235
+ as it queues runs.
236
+
237
+ ```mermaid
238
+ stateDiagram-v2
239
+ [*] --> Active: rule provisioned
240
+ Active --> Active: planner queues upcoming runs, advances the clock
241
+ Active --> Deferred: schedule can't be planned<br/>(unknown timezone, corrupt row)
242
+ Deferred --> Active: retried on a later pass
243
+ Active --> Active: rule edited<br/>(queued runs withdrawn, clock reset)
244
+ Active --> Exhausted: last run queued (a one-off that fired)
245
+ Active --> [*]: rule removed
246
+ Exhausted --> [*]: rule removed
247
+ ```
248
+
249
+ - **Active**: has a next-run time; the planner keeps materialising it.
250
+ - **Deferred**: planning failed for now; the clock is pushed out and retried later,
251
+ so a transient cause (a fixed timezone, updated timezone data) heals on its own.
252
+ - **Exhausted**: no future runs left (a one-off that has fired); the clock is empty.
253
+ - Editing or removing a rule is handled by provisioning, which withdraws any runs
254
+ that were queued under the old definition.
255
+
256
+ ## Lifecycle of a task (a concrete run)
257
+
258
+ A task is created by the planner and moves through claim, run, and a final outcome.
259
+
260
+ ```mermaid
261
+ stateDiagram-v2
262
+ [*] --> Pending: planner queues the run
263
+
264
+ Pending --> Running: a server claims it (takes a lease)
265
+
266
+ Running --> Succeeded: handler finished
267
+ Running --> Pending: handler failed, attempts left<br/>(retry after backoff)
268
+ Running --> Failed: handler failed, no attempts left
269
+
270
+ Running --> Pending: server crashed, lease expired,<br/>attempts left (recovery)
271
+ Running --> Failed: server crashed, lease expired,<br/>no attempts left (recovery)
272
+
273
+ Pending --> Cancelled: its rule was edited or removed
274
+ Pending --> Missed: too stale to run (host policy)
275
+
276
+ Succeeded --> [*]: deleted by cleanup
277
+ Failed --> [*]: deleted by cleanup
278
+ Cancelled --> [*]: deleted by cleanup
279
+ Missed --> [*]: deleted by cleanup
280
+ ```
281
+
282
+ - **Pending**: queued and waiting for its instant.
283
+ - **Running**: claimed and owned by one server for the duration of its lease.
284
+ - **Succeeded / Failed**: the two normal final outcomes. "Failed" means all attempts
285
+ were used up, whether from repeated handler errors or repeated crashes.
286
+ - **Cancelled**: the run's rule changed or was removed before it fired, so the
287
+ queued run is withdrawn.
288
+ - **Missed**: a run so old it is no longer worth firing; whether a stale run still
289
+ runs or is marked missed is the host's policy, not the scheduler's.
290
+ - Finished runs (the four final states) are eventually deleted by the cleanup pass.
291
+
292
+ ## What this package is (and is not)
293
+
294
+ It is a **library of scheduling logic**, not a running service on its own. It owns
295
+ the algorithms and the schedule math; it does not own a database connection, a DI
296
+ container, or a configuration source. A **host** provides those and drives it.
297
+
298
+ The public surface is small:
299
+
300
+ - **`createScheduler(deps)`** composes the submodules into a scheduler. `deps` is
301
+ the host's contribution: a task store and a transaction runner (the `@n8n/db`
302
+ repositories fit these shapes directly, so no adapter layer is needed), an
303
+ instance identity, optional per-pass tuning, and optional sinks for events,
304
+ tracing and metrics.
305
+ - The resulting **`Scheduler`** exposes `registerTaskHandler` (bind a handler to a
306
+ task type), `start` (begin the repeating passes) and `stop` (drain them
307
+ gracefully). Individual passes can also be triggered one at a time, which is used
308
+ mainly for tests and manual driving.
309
+ - A few pure helpers are exported too: schedule validation, next-run computation,
310
+ and the provisioning entry points.
311
+
312
+ In n8n, that host is the `cli` package's durable-scheduler module, which wires up
313
+ DI, configuration and instance identity, and routes the scheduler's events to the
314
+ logger. While `N8N_SCHEDULER_ENABLED` is off, the previous in-memory engine stays
315
+ in place; turning the flag off again is the rollback.
316
+
317
+ The boundary this section describes is enforced, not just documented.
318
+ `src/__tests__/dependency-purity.test.ts` fails if the package ever gains a
319
+ forbidden dependency, whether a new `package.json` entry outside the allowlist or
320
+ an `import` of something effectful (`@n8n/db`, TypeORM, `n8n-core`, the DI
321
+ container, the `cli` package, or a Node I/O built-in). Its opening doc comment is
322
+ the living explanation of the approach: why scheduling is split from execution and
323
+ storage, why each excluded dependency is excluded, and how far the idea could be
324
+ taken. Read it first if you want the reasoning behind the split above.
325
+
326
+ ## Good to know
327
+
328
+ A few things that are not obvious from the code but save a lot of confusion.
329
+
330
+ - **"Processing a task" is *scheduling* it, not doing the work.** From the
331
+ scheduler's point of view, handling a due task means claiming it and arming a
332
+ precise timer so it fires at its exact instant. The actual work is whatever your
333
+ registered handler does; the scheduler only hands the task to it at the right
334
+ moment and records the outcome. So a fast scheduler pass does not mean the work is
335
+ fast, and a slow handler does not block the passes that queue and claim other
336
+ tasks.
337
+
338
+ - **A plain cron expression cannot count across cycles.** It selects times within a
339
+ repeating calendar (minutes, hours, days-of-week), so cadences like "every third
340
+ Monday" or "every 5 hours" are simply not expressible in cron. That is the whole
341
+ reason `recurring_cron` exists: it wraps a cron anchor with an "every N periods"
342
+ filter to cover those cases. If a schedule fits plain cron, use `cron`; reach for
343
+ `recurring_cron` only when the cadence spans cycles.
344
+
345
+ - **The package never logs, traces or measures on its own.** Instead it reports
346
+ through optional hooks the host supplies, so observability is entirely the host's
347
+ choice:
348
+ - an **event sink** (`onEvent`) receives already-handled milestones and incidents
349
+ as plain, described messages, which the host typically routes to its logger;
350
+ - a **tracer** wraps each pass and each handler run in a span;
351
+ - a **metrics** sink records counts and timings (dispatches, retries, dead
352
+ letters, and so on).
353
+
354
+ All three default to no-ops, and a throwing hook is swallowed so a broken logger
355
+ or exporter can never break the scheduling it was only meant to observe.
356
+
357
+ - **Two warnings tell an operator their timing is off.** Both arrive through the
358
+ event sink at `warn` level, so they land in the host's logs:
359
+ - a **clock-skew warning** at start-up. Due-ness and leases are judged on the
360
+ clock the scheduler coordinates on (the host supplies it via `now`, e.g. the
361
+ database clock), but fire timers are armed on this instance's own clock. When
362
+ `start` samples the two and they differ by more than the threshold (default
363
+ `1s`, `CLOCK_SKEW_WARN_THRESHOLD_MS`), it warns: tasks may fire early or late,
364
+ and the instance clock should be synchronised (e.g. via NTP). The sample runs
365
+ detached, so it never delays the loops, and a slow read cannot trip it on its
366
+ own (the offset must also exceed half the round-trip). Skipped entirely when no
367
+ `now` is supplied.
368
+ - a **dispatch-lag warning** when a task fires at least
369
+ `DEFAULT_DISPATCH_LAG_WARN_THRESHOLD_SECONDS` (default `30s`) past its scheduled
370
+ time, once per late fire. This flags a genuinely late dispatch (a blocked event
371
+ loop, a skewed clock), not routine sub-second jitter.
372
+
373
+ - **Runs are recorded ahead of time, within a window.** Because upcoming runs are
374
+ queued in advance, a frequent schedule does not need one planning pass per fire.
375
+ The flip side: disabling or editing a rule may take until the end of that window
376
+ to stop already-queued runs, unless the rule-writing path withdraws them (which
377
+ provisioning does).
378
+
379
+ - **Each background pass runs on its own timer, with jitter.** The four passes are
380
+ independent and slightly randomised, so several servers do not all run their
381
+ passes in lockstep, and one slow pass does not stall the others.
382
+
383
+ ## Future ideas
384
+
385
+ - **A dedicated host module.** Today the wiring that turns this pure library into a
386
+ running scheduler (DI, configuration, instance identity, event routing) lives
387
+ inside the large `cli` package. Extracting it into its own module, for example
388
+ `@n8n/scheduler-cli` or `@n8n/scheduler-features`, would keep the integration
389
+ concerns together and out of `cli`, and mirror the clean split this package
390
+ already draws between algorithm and host.
391
+ - **Lease renewal for long handlers.** A claim's lease is fixed for now, so a
392
+ handler that runs longer than its lease risks being recovered and re-run. A
393
+ heartbeat that extends the lease while a handler is genuinely still working would
394
+ lift that constraint.
395
+
396
+ ### Exploring the idea: a standalone `@n8n/scheduler-worker`
397
+
398
+ Pushed all the way, that host module becomes its own deployable worker. On a due
399
+ task it does not run the workflow, it creates an execution and enqueues a job (Bull
400
+ and Redis) for the regular workers, so the real work is decomposing `cli`. The
401
+ result is far leaner than `cli`, which bundles every node's dependencies (1Password,
402
+ S3, and more). The worker only needs:
403
+
404
+ | Needs | Package | Pulls in |
405
+ | --- | --- | --- |
406
+ | Core logic | `@n8n/scheduler` | luxon, cron-parser |
407
+ | Task store + transactions | `@n8n/db` | `@n8n/typeorm` + a database driver |
408
+ | Enqueue executions (a new, extracted publisher) | `@n8n/…-publisher` | Bull + ioredis, execution writes, trigger-context |
409
+ | Logging + lifecycle | `@n8n/backend-common` | winston |
410
+ | Wiring + types | `@n8n/config`, `@n8n/di`, `@n8n/decorators`, `n8n-workflow` | reflect-metadata |
411
+ | Instance identity (today) | `n8n-core` | the execution engine, unless extracted |
62
412
 
63
- Early foundation. The algorithms, schedule math and composition surface are in
64
- place; the driver that runs the passes on their cadences in the main process
65
- lands in later milestones.
413
+ Full reasoning in the doc comment of `src/__tests__/dependency-purity.test.ts`.