non-overlapping-periodic-job-scheduler
Version:
Periodic job scheduler for Node.js that ensures non-overlapping executions and supports graceful teardown with resource deallocation. It dynamically calculates intervals based on user-defined logic, considering the duration and any error thrown by the las
223 lines (201 loc) • 9.15 kB
text/typescript
/**
* Copyright 2025 Ori Cohen https://github.com/ori88c
* https://github.com/ori88c/non-overlapping-periodic-job-scheduler
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ComputeNextDelay,
IPreviousExecutionMetadata,
ActivityStatus,
NO_PREVIOUS_EXECUTION,
} from './types';
/**
* A slim, dependency-free periodic job scheduler focused on three key aspects often overlooked:
* 1. **Non-overlapping executions**
* 2. **Graceful teardown**
* 3. **Dynamic delay between executions**
*
* ### Non-Overlapping Executions
* Ensures that job executions never overlap, making it suitable for use cases where concurrent runs
* could cause race conditions or degrade performance.
*
* ### Graceful / Deterministic Teardown
* Proper resource deallocation is critical when *stopping* periodic executions. This scheduler
* guarantees that any ongoing execution is completed before shutdown, preventing memory leaks or
* incomplete state. Without graceful teardown, leftover references from unfinished executions can lead
* to unpredictable behavior - especially in unit tests, where isolated clean state is essential.
*
* ### Dynamic Execution Interval
* The user provides a custom delay calculator, invoked immediately *after* each execution finishes.
* It receives runtime metadata - such as duration and any thrown error - and determines the delay
* before the next run. This allows flexible interval strategies based on application-specific logic.
* By delegating this responsibility, the scheduler adheres to:
* 1. **Information Expert Principle**: The user defines how the interval should adapt.
* 2. **Single Responsibility Principle**: The scheduler solely manages job execution and timing.
*
* ### Zero Overengineering, No External Dependencies
* `setInterval` often falls short with fixed intervals, overlapping executions, and non-deterministic
* termination of the last execution. This class offers a minimalistic and efficient alternative
* without introducing runtime dependencies.
*
* ### Robust Error Handling
* If a job throws, the error is forwarded to the delay calculator. This allows the user to adjust
* behavior accordingly (e.g., retry faster after failures). The scheduler itself performs no logging,
* remaining agnostic to logging tools or conventions.
*
* ### Tests
* This class is fully covered by an extensive suite of unit tests.
*/
export class NonOverlappingPeriodicJobScheduler<JobError = Error> {
private _status: ActivityStatus = 'inactive';
private _nextExecutionTimer?: NodeJS.Timeout;
private _currentExecutionPromise?: Promise<void>;
// The `setTimeout` callback is deliberately non-async, to prevent dangling promises.
// Such are undesired, as they cannot be awaited, which is crucial for a graceful
// (deterministic) `stop` operation.
private readonly _initiateExecutionCycle = (): void => {
this._currentExecutionPromise = this._runAndScheduleNext();
};
/**
* @param _periodicJob The asynchronous job to execute periodically.
* @param _computeNextDelay A function that determines the delay (in milliseconds) until the next
* execution, based on the duration and any error of the previous execution.
*/
constructor(
private readonly _periodicJob: () => Promise<void>,
private readonly _computeNextDelay: ComputeNextDelay<JobError>,
) {}
/**
* Indicates whether the periodic job is actively running, as opposed to being between executions.
*
* @returns `true` if the periodic job is currently executing, otherwise `false`.
*/
public get isCurrentlyExecuting(): boolean {
return this._currentExecutionPromise !== undefined;
}
/**
* Returns the current instance status, which can be one of the following:
* - `active`: Currently managing recurring executions.
* - `inactive`: Not managing any recurring executions.
* - `terminating`: A stop attempt was made, but the last execution from the
* previous session is still ongoing.
*
* @returns One of the following values: `active`, `inactive`, or `terminating`.
*/
public get status(): ActivityStatus {
return this._status;
}
/**
* Initiates the scheduling of periodic jobs.
*
* ### Idempotency
* This method is idempotent: calling it multiple times while the instance is already
* active will *not* alter its state or trigger additional scheduling.
*
* ### Border Case: Invocation During a 'terminating' Status
* If called while the instance is in a 'terminating' status (a rare scenario), this method
* will first await a status change before determining whether the instance is active.
* Please note that a well-designed application should strive to *avoid* such edge cases.
*
* ### Concurrency Considerations
* The instance can transition between `active` and `inactive` states through successive calls
* to `start` and `stop`, where each `start`-`stop` pair defines a **session**.
* In **rare cases**, one task may stop an active instance while another concurrently attempts
* to restart it, even as the final execution from the previous session is still ongoing.
* While most real-world use cases involve a *single session throughout the application's
* lifecycle*, this scenario is accounted for to ensure robustness.
*/
public async start(): Promise<void> {
while (this._status === 'terminating') {
await this.waitUntilCurrentExecutionCompletes();
}
if (this._status === 'active') {
return;
}
// Toggle from inactive to active.
this._status = 'active';
const syntheticPreviousExecutionMetadata: IPreviousExecutionMetadata<JobError> = {
durationMs: NO_PREVIOUS_EXECUTION,
};
const firstExecutionDelay = this._computeNextDelay(syntheticPreviousExecutionMetadata);
this._nextExecutionTimer = setTimeout(this._initiateExecutionCycle, firstExecutionDelay);
}
/**
* Stops the scheduling of periodic jobs. If called during an ongoing execution, it resolves
* only after that execution completes. This behavior ensures determinism and enables graceful
* teardown.
*
* ### Idempotency
* This method is **idempotent**: calling it multiple times while the status is not `active`
* (either `inactive` or `terminating`) will *not* alter its state. It only deactivates job
* scheduling if the instance is `active`.
* In case the instance is in a `terminating` status (i.e., awaiting completion of the last
* execution), a redundant call will wait for the ongoing execution to complete before resolving.
* Please note that a well-designed application should strive to *avoid* such edge cases.
*/
public async stop(): Promise<void> {
if (this._status === 'inactive') {
return;
}
if (this._status === 'terminating') {
// A concurrent stop attempt was made. Wait for the ongoing execution to complete.
await this.waitUntilCurrentExecutionCompletes();
return;
}
// Toggle from active to terminating.
this._status = 'terminating';
if (this._nextExecutionTimer !== undefined) {
clearTimeout(this._nextExecutionTimer);
this._nextExecutionTimer = undefined;
}
await this.waitUntilCurrentExecutionCompletes();
this._status = 'inactive';
}
/**
* If an execution is in progress, resolves when it completes (regardless of success or failure).
* If no execution is in progress, resolves immediately.
*/
public waitUntilCurrentExecutionCompletes(): Promise<void> {
return this._currentExecutionPromise ?? Promise.resolve();
}
private async _runAndScheduleNext(): Promise<void> {
this._nextExecutionTimer = undefined;
let thrownError: JobError = undefined;
const startTime = Date.now();
try {
await this._periodicJob();
} catch (err) {
thrownError = err;
}
this._currentExecutionPromise = undefined;
if (this._status !== 'active') {
return;
}
const durationMs = Date.now() - startTime;
try {
const nextDelayMs = this._computeNextDelay({
durationMs,
error: thrownError,
});
this._nextExecutionTimer = setTimeout(this._initiateExecutionCycle, nextDelayMs);
} catch (err) {
// The calculator should *never* throw an error.
// However, we handle this scenario to maintain robustness.
this._status = 'inactive';
// Propagating an error back to the calling stack can be critical and may crash
// the application. Given the severity, this behavior is appropriate.
throw err;
}
}
}