@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.
- package/README.md +388 -40
- package/dist/build.tsbuildinfo +1 -1
- package/dist/core/clock-skew.d.ts +15 -0
- package/dist/core/clock-skew.js +20 -0
- package/dist/core/clock-skew.js.map +1 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/executor/backoff.js.map +1 -1
- package/dist/core/executor/executor.js +20 -3
- package/dist/core/executor/executor.js.map +1 -1
- package/dist/core/executor/index.d.ts +1 -1
- package/dist/core/executor/index.js +2 -1
- package/dist/core/executor/index.js.map +1 -1
- package/dist/core/executor/precision-timer.js.map +1 -1
- package/dist/core/executor/store.d.ts +2 -1
- package/dist/core/executor/task-handler.d.ts +10 -1
- package/dist/core/executor/task-handler.js +14 -0
- package/dist/core/executor/task-handler.js.map +1 -1
- package/dist/core/factory.d.ts +5 -0
- package/dist/core/factory.js +47 -4
- package/dist/core/factory.js.map +1 -1
- package/dist/core/index.d.ts +8 -4
- package/dist/core/index.js +14 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/lifecycle/alarm.js.map +1 -1
- package/dist/core/lifecycle/index.d.ts +1 -1
- package/dist/core/lifecycle/index.js +2 -2
- package/dist/core/lifecycle/index.js.map +1 -1
- package/dist/core/lifecycle/lookahead.d.ts +1 -1
- package/dist/core/lifecycle/lookahead.js +2 -2
- package/dist/core/lifecycle/lookahead.js.map +1 -1
- package/dist/core/lifecycle/loop.js.map +1 -1
- package/dist/core/lifecycle/timeline.js.map +1 -1
- package/dist/core/materializer/materialize.js +2 -1
- package/dist/core/materializer/materialize.js.map +1 -1
- package/dist/core/materializer/options.d.ts +1 -0
- package/dist/core/materializer/options.js +1 -0
- package/dist/core/materializer/options.js.map +1 -1
- package/dist/core/materializer/plan.js.map +1 -1
- package/dist/core/materializer/transaction.d.ts +1 -1
- package/dist/core/provisioning/index.d.ts +1 -0
- package/dist/core/provisioning/index.js +3 -1
- package/dist/core/provisioning/index.js.map +1 -1
- package/dist/core/provisioning/provision.js +2 -22
- package/dist/core/provisioning/provision.js.map +1 -1
- package/dist/core/provisioning/provisioner.js.map +1 -1
- package/dist/core/provisioning/schedule-identity.d.ts +3 -0
- package/dist/core/provisioning/schedule-identity.js +36 -0
- package/dist/core/provisioning/schedule-identity.js.map +1 -0
- package/dist/core/reaper/reap.d.ts +7 -5
- package/dist/core/reaper/reap.js +14 -3
- package/dist/core/reaper/reap.js.map +1 -1
- package/dist/core/recurrence/field.js.map +1 -1
- package/dist/core/recurrence/kinds/cron.js.map +1 -1
- package/dist/core/recurrence/kinds/interval.js.map +1 -1
- package/dist/core/recurrence/kinds/one-off.js.map +1 -1
- package/dist/core/recurrence/kinds/recurring-cron.js.map +1 -1
- package/dist/core/recurrence/next-run.js.map +1 -1
- package/dist/core/recurrence/periods.js.map +1 -1
- package/dist/core/recurrence/resolve.js.map +1 -1
- package/dist/core/recurrence/validate.js.map +1 -1
- package/dist/core/retention/prune.js.map +1 -1
- package/dist/observability/attributes.d.ts +33 -33
- package/dist/observability/attributes.js.map +1 -1
- package/dist/observability/executor-tracing.js +4 -3
- package/dist/observability/executor-tracing.js.map +1 -1
- package/dist/observability/materializer-tracing.js.map +1 -1
- package/dist/observability/pass-tracing.js.map +1 -1
- package/dist/observability/provisioner-tracing.js.map +1 -1
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -1,65 +1,413 @@
|
|
|
1
1
|
# @n8n/scheduler
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
+
Filter (1 per 3 weeks): ✓ ✗ ✗ ✓ ✗ ✗ ✓
|
|
54
176
|
Fires: M M M
|
|
55
177
|
```
|
|
56
178
|
|
|
57
|
-
The
|
|
58
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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`.
|