feat(core): Leave the poll timeout headroom under the scheduler lease (no-changelog)
Some checks failed
CI: Python / Checks (push) Has been cancelled

This commit is contained in:
Lorent Lempereur 2026-08-24 15:57:16 +02:00
parent 4d87c502b7
commit e00a09a9b4
No known key found for this signature in database
5 changed files with 31 additions and 13 deletions

View File

@ -43,7 +43,7 @@ describe('SchedulerConfig', () => {
expect(scheduler.allowSkipDurableScheduler).toBe(false);
expect(scheduler.maxAttempts).toBe(5);
expect(scheduler.enabledForPollTriggers).toBe(false);
expect(scheduler.pollTimeoutSeconds).toBe(60);
expect(scheduler.pollTimeoutSeconds).toBe(45);
});
});

View File

@ -270,20 +270,22 @@ export class SchedulerConfig {
/**
* How long, in seconds, a single poll of an external source (an inbox, an API)
* may take before it is abandoned. Defaults to 60 seconds.
* may take before it is abandoned. Defaults to 45 seconds.
*
* An abandoned poll skips no data: its position in the source is left where it
* was and the next scheduled poll covers the same ground. It counts as a poll
* failure, so a source that keeps timing out is polled at a widening interval.
* Guards against a poll stuck on an unresponsive source running indefinitely.
*
* Keep it at or below {@link leaseDurationSeconds}: a poll allowed to run
* longer than the claim on its run can still be in flight when that claim
* expires and another instance takes the run over. The scheduler warns at
* startup if it doesn't. Must be greater than 0.
* Keep it below {@link leaseDurationSeconds}: the deadline only starts after
* the occurrence's setup reads, so a poll allowed to run as long as the claim
* on its run can still be in flight when that claim expires and another
* instance takes the run over. The default leaves that headroom, and the
* scheduler warns at startup when the timeout reaches the lease duration.
* Must be greater than 0.
*/
@Env('N8N_SCHEDULER_POLL_TIMEOUT', positiveIntSchema)
pollTimeoutSeconds: number = Time.minutes.toSeconds;
pollTimeoutSeconds: number = 45;
/**
* Whether a poll trigger's cursor advance and the execution it produced are saved

View File

@ -478,7 +478,7 @@ describe('GlobalConfig', () => {
maxConcurrentPasses: 10,
triggerNodeMode: 'legacy',
enabledForPollTriggers: false,
pollTimeoutSeconds: 60,
pollTimeoutSeconds: 45,
allowSkipDurableScheduler: false,
maxAttempts: 5,
misfireGraceSeconds: 60,

View File

@ -32,7 +32,7 @@ describe('DurableScheduler', () => {
materializationWindowSeconds = 60,
misfireGraceSeconds = 60,
enabledForPollTriggers = false,
pollTimeoutSeconds = 60,
pollTimeoutSeconds = 45,
leaseDurationSeconds = 60,
} = {}) {
const inner = mock<Scheduler & SchedulerPasses>();
@ -185,13 +185,28 @@ describe('DurableScheduler', () => {
);
});
it('does not warn when the timeout fits inside the lease', () => {
// The poll deadline starts after the occurrence's setup reads, so a timeout
// equal to the lease already lets a full-length poll outlive it.
it('warns when the timeout equals the lease', () => {
const { logger } = makeScheduler({
enabledForPollTriggers: true,
pollTimeoutSeconds: 60,
leaseDurationSeconds: 60,
});
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('poll timeout'),
expect.objectContaining({ pollTimeoutSeconds: 60, leaseDurationSeconds: 60 }),
);
});
it('does not warn when the timeout fits inside the lease', () => {
const { logger } = makeScheduler({
enabledForPollTriggers: true,
pollTimeoutSeconds: 45,
leaseDurationSeconds: 60,
});
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('poll timeout'),
expect.anything(),

View File

@ -166,13 +166,14 @@ function warnOnDrainRate(logger: Logger, config: GlobalConfig['scheduler']): voi
/**
* Warn when a poll may still be in flight after the lease on its occurrence has
* expired: the reaper can then reclaim the occurrence and another instance can
* start the same poll while the first one is still running.
* start the same poll while the first one is still running. Equality counts
* too, since the poll deadline only starts after the occurrence's setup reads.
*/
function warnOnPollTimeout(logger: Logger, config: GlobalConfig['scheduler']): void {
const { enabledForPollTriggers, pollTimeoutSeconds, leaseDurationSeconds } = config;
if (enabledForPollTriggers && pollTimeoutSeconds > leaseDurationSeconds) {
if (enabledForPollTriggers && pollTimeoutSeconds >= leaseDurationSeconds) {
logger.warn(
'Scheduler poll timeout is above the lease duration; a poll can still be running when its lease expires and another instance takes the run over',
'Scheduler poll timeout reaches the lease duration; a poll can still be running when its lease expires and another instance takes the run over',
{ pollTimeoutSeconds, leaseDurationSeconds },
);
}