non-overlapping-periodic-job-scheduler
Version:
Periodic job scheduler for Node.js projects, ensuring non-overlapping executions and deterministic termination. Features a dynamically chosen interval between executions, determined by a user-injected calculator function, which can take into account the d
177 lines (160 loc) • 7.33 kB
text/typescript
import { PeriodicJob, CalculateDelayTillNextExecution, NO_PREVIOUS_EXECUTION } from './types';
/**
* NonOverlappingPeriodicJobScheduler
*
* This class implements a slim periodic-job scheduler, focusing on three aspects often overlooked:
* 1. **Non-overlapping executions**.
* 2. **Deterministic termination**.
* 3. **Dynamic delay between executions**.
*
* ## Non-Overlapping Executions
* Ensures that executions do not overlap. This is suitable for scenarios where overlapping executions
* may cause race conditions or negatively impact performance.
*
* ## Deterministic / Graceful Termination
* When stopping periodic executions, it is crucial to ensure that any ongoing execution is completed
* before termination. This deterministic termination approach ensures that no unfinished executions
* leave objects in memory, which could otherwise lead to unexpected behavior.
* Without deterministic termination, leftover references from incomplete executions can cause
* issues, such as unexpected behavior during unit tests. A clean state is necessary for each test,
* and ongoing jobs from a previous test can interfere with subsequent tests.
*
* ## Dynamic Execution Interval
* User provides a custom calculator function, to determine the delay until the next execution, based on
* the runtime metadata of the just-finished execution (duration, error if thrown).
* This calculator is invoked at the **end** of each execution, enabling flexible interval policies based
* on user-defined criteria. This approach ensures that the scheduler remains agnostic of scheduling-policy
* preferences, focusing solely on the scheduling process. In this way, we adhere to the following principles:
* 1. **Information Expert Principle**: The interval policy is defined by the user.
* 2. **Single Responsibility Principle**: The scheduler's sole responsibility is to manage the scheduling
* process.
*
* ## Zero Over-Engineering, No External Dependencies
* `setInterval` often falls short with fixed intervals, overlapping executions, and non-deterministic
* termination of the last execution. Custom solutions or external libraries usually come with numerous
* runtime dependencies, which can unnecessarily increase the project's size.
* This class offers a lightweight, dependency-free solution. It can also serve as a building block for
* more advanced implementations, if necessary.
*
* ## Error Handling
* If a periodic job throws an error, the error will be passed to the calculator function. The scheduler
* does *not* perform any logging, as it is designed to be agnostic of user preferences, such as specific
* loggers or logging styles.
*
* ## Tests
* This class is fully covered by extensive unit tests.
*
*/
export class NonOverlappingPeriodicJobScheduler<JobError = Error> {
private _isStopped: boolean = true;
private _nextExecutionTimer: NodeJS.Timeout = undefined;
private _currentExecutionPromise: Promise<void> = undefined;
// The `setTimeout` callback is deliberately non-async, to prevent dangling promises.
// Such are undesired, as they cannot be awaited, which is crucial for a deterministic
// (graceful) `stop` operation.
private readonly _triggerExecution = (): void => {
this._currentExecutionPromise = this._triggerCurrentExecutionAndScheduleNext();
};
/**
* constructor
*
* @param _periodicJob A periodic job.
* @param _calculateDelayTillNextExecution Function to calculate the delay until the
* next execution, based on the duration and
* any error thrown by the previous execution.
*/
constructor(
private readonly _periodicJob: PeriodicJob,
private readonly _calculateDelayTillNextExecution: CalculateDelayTillNextExecution<JobError>,
) { }
/**
* isCurrentlyExecuting
*
* 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;
}
/**
* isStopped
*
* Indicates whether the instance is currently *not* managing periodic executions.
*
* @returns `true` if the instance has no periodic executions currently scheduled
* or in progress, otherwise `false`.
*/
public get isStopped(): boolean {
return this._isStopped;
}
/**
* start
*
* Initiates the scheduling of periodic jobs.
*/
public start(): void {
if (!this._isStopped) {
throw new Error('Cannot start an already started NonOverlappingPeriodicJobScheduler instance');
}
this._isStopped = false;
const firstExecutionDelay = this._calculateDelayTillNextExecution(NO_PREVIOUS_EXECUTION);
this._nextExecutionTimer = setTimeout(this._triggerExecution, firstExecutionDelay);
}
/**
* waitUntilCurrentExecutionCompletes
*
* Resolves when the current execution completes, whether it resolves or rejects, if
* called during an ongoing execution. If no execution is in progress, it resolves immediately.
*/
public waitUntilCurrentExecutionCompletes(): Promise<void> {
return this._currentExecutionPromise ?? Promise.resolve();
}
/**
* stop
*
* Stops the scheduling of periodic jobs. If this method is invoked during an ongoing execution,
* it resolves once the current execution is complete. This guarantee provides determinism and
* allows for graceful termination.
*/
public stop(): Promise<void> {
this._isStopped = true;
if (this._nextExecutionTimer) {
clearTimeout(this._nextExecutionTimer);
this._nextExecutionTimer = undefined;
}
return this.waitUntilCurrentExecutionCompletes();
}
private async _triggerCurrentExecutionAndScheduleNext(): 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._isStopped) {
return;
}
const justFinishedExecutionDurationMs = Date.now() - startTime;
try {
const delayTillNextExecution = this._calculateDelayTillNextExecution(
justFinishedExecutionDurationMs,
thrownError
);
this._nextExecutionTimer = setTimeout(
this._triggerExecution,
delayTillNextExecution
);
} catch (err) {
// The calculator should never throw an error.
// However, we handle this scenario to maintain robustness.
this._isStopped = true;
// 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;
}
}
}