n8n/packages/cli/test/integration/scheduling
Emilia e2054f984c
feat(core): Route poll triggers through the durable scheduler (no-changelog) (#34733)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:32:08 +00:00
..
shared test(core): Add cli integration tests for the durable scheduler (no-changelog) (#33927) 2026-07-10 13:43:28 +00:00
durable-scheduler-effect-boundary.test.ts refactor(core): Require task handlers to return an explicit dispatch decision (#34276) 2026-07-16 14:43:33 +00:00
durable-scheduler-lifecycle.test.ts feat(core): Route poll triggers through the durable scheduler (no-changelog) (#34733) 2026-07-27 07:32:08 +00:00
executor-repository.test.ts fix(core): Close the durable scheduler's record-then-dispatch effect boundary (#34014) 2026-07-16 09:47:10 +00:00
README.md test(core): Add durable scheduler performance benchmarks (#34279) 2026-07-16 15:37:35 +00:00
scheduled-repositories.test.ts feat(core): Add N8N_SCHEDULER_MAX_ATTEMPTS to configure scheduler dead-letter threshold (#34633) 2026-07-21 14:00:50 +00:00
scheduler-benchmarks.test.ts test(core): Add durable scheduler performance benchmarks (#34279) 2026-07-16 15:37:35 +00:00
scheduler-cascade-delete.test.ts refactor(core): Require task handlers to return an explicit dispatch decision (#34276) 2026-07-16 14:43:33 +00:00
scheduler-concurrent-loops.test.ts refactor(core): Require task handlers to return an explicit dispatch decision (#34276) 2026-07-16 14:43:33 +00:00
scheduler-execute.test.ts feat(core): Add N8N_SCHEDULER_MAX_ATTEMPTS to configure scheduler dead-letter threshold (#34633) 2026-07-21 14:00:50 +00:00
scheduler-materialize.test.ts fix(core): Claim scheduler jobs ahead of due to remove materializer window-boundary lag (no-changelog) (#34331) 2026-07-16 11:33:57 +00:00
scheduler-multi-main.test.ts refactor(core): Require task handlers to return an explicit dispatch decision (#34276) 2026-07-16 14:43:33 +00:00
scheduler-query-benchmarks.test.ts feat(core): Add N8N_SCHEDULER_MAX_ATTEMPTS to configure scheduler dead-letter threshold (#34633) 2026-07-21 14:00:50 +00:00
scheduler-retention.test.ts feat(core): Run durable scheduler lifecycle loops on every main (no-changelog) (#33764) 2026-07-08 09:57:14 +00:00
scheduler-trigger-handler.test.ts fix(core): Close the durable scheduler's record-then-dispatch effect boundary (#34014) 2026-07-16 09:47:10 +00:00

Durable scheduler benchmarks (CAT-3623)

Two opt-in suites, run against both SQLite (single-writer) and Postgres (SKIP LOCKED, dead-tuple churn), informing the minimum-interval policy decision.

  • scheduler-benchmarks.test.ts — operator KPIs (capacity, punctuality, recovery, health).
  • scheduler-query-benchmarks.test.ts — per-query latency and EXPLAIN plans for ScheduledJobRepository / ScheduledTaskRepository.

They live here beside the other scheduling integration tests, not in @n8n/performance or @n8n/benchmark.

KPIs

KPI Reports Pass condition
Capacity sustained fires/sec on one node (claim → start → complete) every fire ran once; ≥ floor
Punctuality lateness vs due time (p50/p95/p99) under a due-at-once burst whole burst fired; p99 under the 30s warning
Recovery wall-clock to drain a crashed-node backlog all recovered once; within budget
Health peak finished-but-unpruned rows while retention prunes concurrently stays bounded; fully drained at end

Concurrency per dialect: Postgres runs each worker as its own DataSource (parallel claims kept disjoint by FOR UPDATE SKIP LOCKED). SQLite is single-instance — its pooled driver funnels every write through one connection, so workers share one DataSource and claims queue on that writer.

Running

Set N8N_SCHEDULER_BENCHMARK=1 (both suites are describe.runIf-skipped without it) and reuse the scheduling-integration scripts, filtered by file name.

# SQLite
N8N_SCHEDULER_BENCHMARK=1 pnpm --filter n8n test:sqlite scheduler-benchmarks

# Postgres via testcontainers (needs Docker; on Colima add the socket override)
N8N_SCHEDULER_BENCHMARK=1 \
  TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock \
  pnpm --filter n8n test:postgres:integration:tc scheduler-benchmarks

# Postgres against a running instance (DB_POSTGRESDB_* set)
N8N_SCHEDULER_BENCHMARK=1 pnpm --filter n8n test:postgres scheduler-benchmarks

Default run: ~50s SQLite, ~110s Postgres (laptop-class). Results print to stdout via console.log, one block per benchmark; nothing is written to disk. Vitest buffers per-test logs, so capture with a redirect:

N8N_SCHEDULER_BENCHMARK=1 pnpm --filter n8n test:sqlite scheduler-benchmarks > bench.log 2>&1
grep -A20 'scheduler-benchmark ·' bench.log

The tables here and in benchmark-baseline.md are hand-captured from that stdout.

Workload env vars

Env var Default Meaning
N8N_SCHEDULER_BENCHMARK_BATCH 100 rows per claim statement (all KPIs)
N8N_SCHEDULER_BENCHMARK_CAPACITY_WORKERS 8 instances (KPI 1)
N8N_SCHEDULER_BENCHMARK_CAPACITY_BACKLOG 50000 schedules to fire (KPI 1)
N8N_SCHEDULER_BENCHMARK_CAPACITY_MIN_FPS 50 capacity floor (fires/sec)
N8N_SCHEDULER_BENCHMARK_PUNCTUALITY_WORKERS 8 instances (KPI 2)
N8N_SCHEDULER_BENCHMARK_PUNCTUALITY_BURST 20000 schedules due at once (KPI 2)
N8N_SCHEDULER_BENCHMARK_PUNCTUALITY_MAX_P99_MS 30000 lateness ceiling (p99)
N8N_SCHEDULER_BENCHMARK_RECOVERY_WORKERS 8 recovering instances (KPI 3)
N8N_SCHEDULER_BENCHMARK_RECOVERY_STRANDED 20000 crashed-node backlog (KPI 3)
N8N_SCHEDULER_BENCHMARK_RECOVERY_MAX_SECONDS 120 recovery-time budget
N8N_SCHEDULER_BENCHMARK_CHURN_WORKERS 8 instances (KPI 4)
N8N_SCHEDULER_BENCHMARK_CHURN_CYCLES 20 × batch = total fires (KPI 4)
N8N_SCHEDULER_BENCHMARK_CHURN_BATCH 5000 fires per cycle; also the pending-backlog watermark
N8N_SCHEDULER_BENCHMARK_CHURN_MIN_FPS 50 churn floor (fires/sec)
N8N_SCHEDULER_BENCHMARK_CHURN_RETENTION_LIMIT 1000 rows pruned per retention sweep
N8N_SCHEDULER_BENCHMARK_CHURN_RETENTION_INTERVAL_MS 100 delay between retention sweeps
N8N_SCHEDULER_BENCHMARK_CHURN_SAMPLE_INTERVAL_MS 50 live/finished sampling interval
N8N_SCHEDULER_BENCHMARK_CHURN_MAX_FINISHED_ROWS 5000 ceiling on finished-but-unpruned rows

Reading the results (min-interval decision)

  • Capacity ÷ schedule count = safe floor on the shared minimum interval; keep a wide margin. SQLite's single writer warrants more conservative cadences than Postgres despite its lower local-file latency.
  • Punctuality p99 is the worst-case lateness in a thundering herd (e.g. all on 0 * * * *); nearing the 30s threshold means the cadence is too tight.
  • Recovery is the crash blast-radius. The reaper takes no row lock, so many concurrent recoverers duplicate work — prefer a modest reaper count.
  • Health peak unpruned rows is the bloat signal (PG dead tuples / SQLite file growth); staying well below total fired proves retention keeps pace.

Observed numbers (local, laptop-class, default workload)

Indicative only, hardware-dependent — for orientation, not thresholds.

KPI SQLite Postgres
Capacity (fires/sec, one node) ~5,100 ~2,500
Punctuality p99 under a 20k burst ~3.5s ~7.8s
Recovery of 20k stranded schedules ~11s ~35s
Health peak unpruned (of 100k fired) ~700 ~600

Postgres capacity is lower because each fire is three sequential write round-trips to a container over loopback; the point is the per-dialect shape.

Query profiling

scheduler-query-benchmarks.test.ts runs each hot query against a large mixed corpus (default 100k tasks / 50k jobs: mostly terminal history, some pending/due, some running/expired) and reports latency p50/p95/p99 and the EXPLAIN plan, flagged index vs full scan. A full scan is reported as an optimization candidate, not asserted. Sizes override via N8N_SCHEDULER_QUERY_TASK_ROWS, _JOB_ROWS, _ITERS, _BATCH, _WRITE_BATCH, _WRITE_JOB_BATCH.

Reads — p99 (ms) and plan:

Query SQLite Postgres Plan
ScheduledTask.getMetricSnapshot ~9 ~12 index (runAt + leaseExpiresAt partials)
ScheduledTask.claimDueTasks ~5 ~6 index (runAt partial)
ScheduledTask.findExpiredLeases <1 ~1 index (leaseExpiresAt partial)
ScheduledTask.deleteFinishedOlderThan ~1.5 ~15 index (finishedAt partial) + status filter
ScheduledJob.claimDue ~1.5 ~3.5 index (nextRunAt partial)

Writes — rows/sec (SQLite / Postgres): insertIgnoringDuplicates ~100k / ~34k, insertMany ~33k / ~17k, advanceMany ~127k / ~49k.