n8n-io/n8n · error · InvalidLifecycleOptionsError

maxConcurrentPasses must be a positive integer, got ${maxCon

Error message

maxConcurrentPasses must be a positive integer, got ${maxConcurrentPasses}

What it means

Thrown by createScheduler when lifecycleOptions.maxConcurrentPasses is not an integer >= 1. The value bounds how many scheduler passes (materializer/executor) may run at once; zero would never make progress, a fraction is meaningless for a count, and a negative value is invalid.

Source

Thrown at packages/@n8n/scheduler/src/core/factory.ts:183

		if (!(Number.isFinite(value) && value > 0)) {
			throw new InvalidLifecycleOptionsError(
				`${key} must be a positive number of seconds, got ${value}`,
			);
		}
	}

	if (
		lifecycleOptions.concurrencyMode !== 'sequential' &&
		lifecycleOptions.concurrencyMode !== 'concurrent'
	) {
		throw new InvalidLifecycleOptionsError(
			`concurrencyMode must be 'sequential' or 'concurrent', got ${String(lifecycleOptions.concurrencyMode)}`,
		);
	}

	const { maxConcurrentPasses } = lifecycleOptions;
	if (!(Number.isInteger(maxConcurrentPasses) && maxConcurrentPasses >= 1)) {
		throw new InvalidLifecycleOptionsError(
			`maxConcurrentPasses must be a positive integer, got ${maxConcurrentPasses}`,
		);
	}

	const emit = (level: SchedulerEventLevel, message: string, context: Record<string, unknown>) => {
		// The sink is the reporting channel itself: if it throws (a broken logger),
		// there is nothing left to report through, and the pass that emitted must
		// not be broken by its own observability.
		try {
			onEvent?.({ level, message, context });
		} catch {
			// Deliberately swallowed; see above.
		}
	};
	const described = (error: unknown) => ensureError(error).message;

	// Derived, not caller-chosen: the materializer must record a job's occurrences
	// early enough that the executor still has them in hand when it needs to fire.

View on GitHub (pinned to 5ac6606e81)

Solutions

  1. Set maxConcurrentPasses to an integer >= 1, e.g. Math.max(1, Math.floor(cpuCount)).
  2. Guard the CPU-count heuristic so it never returns below 1.
  3. Validate with Number.isInteger in your config loader.
  4. Omit the key to accept the DEFAULT_LIFECYCLE_OPTIONS value.

Example fix

// before
createScheduler({ lifecycle: { maxConcurrentPasses: os.cpus().length - 1 } }); // 0 on 1-core -> throws

// after - floor and clamp to a positive integer
const maxConcurrentPasses = Math.max(1, Math.floor(Number(process.env.SCHED_MAX_PASSES) || os.cpus().length));
createScheduler({ lifecycle: { maxConcurrentPasses } });
Defensive patterns

Strategy: validation

Validate before calling

function normalizeMaxPasses(raw: unknown): number {
  const n = Math.floor(Number(raw));
  if (!Number.isInteger(n) || n < 1) return 1;
  return n;
}

const maxConcurrentPasses = normalizeMaxPasses(process.env.SCHED_MAX_PASSES ?? os.cpus().length);

Type guard

function isPositiveInteger(v: unknown): v is number {
  return typeof v === 'number' && Number.isInteger(v) && v >= 1;
}

Prevention

When it happens

Trigger: Passing maxConcurrentPasses: 0, 1.5, -1, NaN, or a non-integer from a float config. Number.isInteger(maxConcurrentPasses) && maxConcurrentPasses >= 1 must hold.

Common situations: A CPU-count heuristic that returns 0 on a single-core/limited container (nproc - 1); a config expressed as a fraction of cores; NaN from parsing an unset env var; floats introduced by a percentage computation.

Related errors


AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12). Data as JSON: /api/errors/42572751a1e2b32f. Report an issue: GitHub.