UNPKG

non-overlapping-recurring-task

Version:

A modern `setInterval` substitute tailored for asynchronous tasks, ensuring non-overlapping executions by skipping attempts if a previous execution is still in progress. Features execution status getters, graceful teardown, and a fixed delay between runs.

350 lines (324 loc) 16.2 kB
/** * Copyright 2025 Ori Cohen https://github.com/ori88c * https://github.com/ori88c/non-overlapping-recurring-task * * 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. */ /** * Configuration options for a `NonOverlappingRecurringTask`. */ export interface INonOverlappingRecurringTaskOptions { /** * A positive number representing the interval, in milliseconds, between the * **start times** of consecutive execution attempts. * * The term "attempts" is used because, due to this component's **non-overlapping** * nature, an attempt may be skipped if a previous execution is still ongoing. */ intervalMs: number; /** * When set to `true`, the first execution occurs **immediately** upon calling `start()`. * Otherwise, similar to `setInterval`, the scheduler waits for the first interval before * executing. */ immediateFirstRun: boolean; } export type ActivityStatus = 'active' | 'inactive' | 'terminating'; /** * The `NonOverlappingRecurringTask` class provides a recurring asynchronous task scheduler * with a focus on the following key aspects: * 1. **Guaranteed Non-Overlapping Executions**. * 2. **Graceful and Deterministic Teardown**. * 3. **Fixed Delay Between Executions**, similar to JavaScript's built-in `setInterval`. * * ### Non-Overlapping Executions * Ensures that task executions do not overlap, preventing race conditions and potential * performance issues. * In many cases, recurring tasks are assumed to never overlap due to a sufficiently long * interval. As a result, the task's business logic may not account for overlapping executions. * By **eliminating this possibility at the scheduler level**, the task can focus solely on its * intended logic without the need for additional safeguards. * This built-in guarantee reinforces Separation of Concerns and the Single Responsibility * Principle, enhancing overall robustness. * * ### Graceful and Deterministic Teardown * Task execution promises are tracked by the instance, ensuring no dangling promises. This * enables a graceful teardown via the `stop` method, in scenarios where it is essential to * **ensure that any ongoing execution is completed before proceeding**. * Examples include: * - Application shutdowns (e.g., `onModuleDestroy` in NestJS applications) where tasks should * complete before termination. For instance, ensuring a bulk-write to a database is finished * instead of abruptly terminating the operation by forcefully exiting the application. * - Unit tests, where a clean state is essential to prevent ongoing tasks from interfering with * subsequent tests. * * ### Fixed Delay Between Executions, similar to JavaScript's built-in `setInterval` * Like JavaScript’s built-in `setInterval`, this scheduler ensures a **fixed interval between * execution start times**. That is, for an absolute timestamp T, execution start times follow * the formula `T + i * intervalMs` where i is a non-negative integer. * However, there are two key differences: * - Immediate First Run (`immediateFirstRun` flag): * When enabled, the first execution occurs immediately after invoking `start`. In contrast, * `setInterval` waits for the first interval before executing. * - Non-Overlapping Guarantee: * If an execution exceeds the interval duration, subsequent executions are skipped until the * ongoing execution completes. * * #### Example * - Suppose `T` is the timestamp when `start` is invoked, the interval is 100ms, and * `immediateFirstRun` is enabled. * - The first execution starts immediately and runs for **350ms**. * - Since start times adhere to the formula `T + 100 * i`, the scheduler **skips** cycles * where i = 1,2,3. * - The next execution begins at `T + 400ms`. * * ### Zero Over-Engineering, No External Dependencies * While `setInterval` is useful for recurring tasks, it falls short for asynchronous tasks * due to **overlapping executions** and **non-deterministic termination** of the last execution. * Many custom solutions or third-party libraries introduce **unnecessary runtime dependencies**, * increasing project size and complexity. * This class provides a **lightweight, dependency-free solution** while ensuring predictable * execution. Additionally, it can serve as a foundation for more advanced implementations if * needed. * * ### Error Handling * If a periodic task throws an error, it is passed to an optional error handler callback, if * provided. This component does **not** perform any logging, as it is designed to be agnostic * of user preferences, such as specific loggers or logging styles. * A typical `_onTaskError` implementation logs errors based on the user's logging strategy. * If the periodic task already handles its own errors, this handler can be omitted. * * ### Tests * This class is fully covered by extensive unit tests. */ export class NonOverlappingRecurringTask<UncaughtErrorType = Error> { private _status: ActivityStatus = 'inactive'; private _timerHandle?: NodeJS.Timeout; private _currentExecutionPromise?: Promise<void>; /** * @param _asyncTask An asynchronous task to be executed periodically. * @param _options Execution options. Refer to the `NonOverlappingRecurringTaskOptions` * documentation for detailed information. * @param _onTaskError (Optional) An error handler for cases where the task might reject * with an error. This handler should **not throw**, as doing so would * cause the error to propagate up the event loop, potentially crashing * the application. * @throws Error if any of the provided parameters are invalid. */ constructor( private readonly _asyncTask: () => Promise<void>, private readonly _options: Readonly<INonOverlappingRecurringTaskOptions>, private readonly _onTaskError?: (err: UncaughtErrorType) => void, ) { this._validateOptions(); } /** * 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; } /** * Indicates whether the recurring task is currently executing, as opposed to being * in between executions. * * @returns `true` if the task is currently executing; otherwise, `false`. */ public get isCurrentlyExecuting(): boolean { return this._currentExecutionPromise !== undefined; } /** * Initiates the scheduling of recurring tasks. * * ### Idempotency * This method is idempotent: calling it multiple times while the instance is already * active will not alter its state or trigger additional scheduling. It only activates * the task if the instance is not already active. * * ### 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. * * ### 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. * * @returns `true` if recurring executions were scheduled (i.e., the instance's status changed * from inactive to active); * `false` if the instance was already active and the invocation had no effect. */ public async start(): Promise<boolean> { while (this._status === 'terminating') { await this.waitUntilCurrentExecutionCompletes(); } if (this._status === 'active') { return false; } // Toggle from inactive to active. this._status = 'active'; if (this._options.immediateFirstRun) { this._currentExecutionPromise = this._executeTaskAndUpdateState(); } this._timerHandle = setInterval((): void => { if (this._currentExecutionPromise === undefined) { this._currentExecutionPromise = this._executeTaskAndUpdateState(); } }, this._options.intervalMs); return true; } /** * 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. * * ### Never Rejects * This method **never rejects** or throws, even if a currently ongoing execution * encounters an error. * * @returns A promise that resolves when the current execution completes. If called during * an ongoing execution, it resolves once the execution finishes. If no execution * is in progress, it resolves immediately. */ public waitUntilCurrentExecutionCompletes(): Promise<void> { return this._currentExecutionPromise ?? Promise.resolve(); } /** * Stops the scheduling of recurring tasks. * * ### Graceful Teardown * If this method is invoked during an ongoing execution, it resolves only after the * current execution completes. This guarantee ensures **determinism** and allows for * a **graceful teardown**. If the `shouldExecuteFinalRun` flag is enabled, the method * also waits for the final (digest) run to complete. * Use cases where it is essential to complete any ongoing execution before proceeding include: * - **Application Shutdowns**: In cases like `onModuleDestroy` in NestJS applications, where * tasks should complete before termination. For instance, ensuring a bulk-write to a database * is finished instead of abruptly terminating the operation by forcefully exiting the application. * - **Unit Tests**: Ensuring a clean state is maintained between tests, preventing ongoing tasks * from interfering with subsequent tests. * * ### Idempotency * This method is **idempotent**: calling it multiple times while the instance is already inactive * will not alter its state. It only deactivates task scheduling if the instance is active. * In case the instance is in a termination status (i.e., awaiting completion of the last execution), * a redundant call will wait for the ongoing execution to complete before resolving. * * ### Optional: Force an Additional Final Run * Enabling the `shouldExecuteFinalRun` flag triggers **one final execution** before resolving. * This is particularly useful for tasks that accumulate state between executions and require * a final flush (write operation) to **ensure no unprocessed data remains**. * #### When This is Relevant * - Flushing Batched Writes: A log aggregator that periodically writes accumulated logs to a * database should execute a final flush before stopping to prevent data loss. * - Committing Transactions: A system that batches updates should perform one last batch * before stopping to ensure all changes are committed. * #### When This is Less Relevant * - Periodic Data Fetches: If the task refreshes external configurations at regular intervals, * such as feature flags, an additional fetch before stopping provides no meaningful benefit. * * @param shouldExecuteFinalRun - If `true`, ensures that one final execution occurs as part of the * stop process. This is particularly useful for tasks that **accumulate * state between executions** and require a final flush to avoid leaving * unprocessed data. To eliminate any ambiguity, when this flag is enabled, * the `stop` method resolves only **after** the final execution completes. * Defaults to `false`. * @returns `true` if recurring executions were stopped by this invocation (i.e., the instance's * status changed from 'active' to 'inactive'); * `false` if the instance was already inactive or in termination status, and the * invocation had no effect. Note that even when `false` is returned, the call * still waits for the last run to complete if invoked while the instance is in a * 'terminating' status. */ public async stop(shouldExecuteFinalRun?: boolean): Promise<boolean> { if (this._status === 'inactive') { return false; } if (this._status === 'terminating') { // A concurrent stop attempt was made. Wait for the ongoing execution // to complete (and an additional final run if `shouldExecuteFinalRun` // is enabled), but indicate that this call did *not* initiate the stop phase. while (this._currentExecutionPromise) { await this._currentExecutionPromise; } return false; } // Toggle from active to terminating. this._status = 'terminating'; clearInterval(this._timerHandle); this._timerHandle = undefined; // The last execution may still be ongoing. if (this._currentExecutionPromise) { await this._currentExecutionPromise; } // Executing a final digest run to ensure no unprocessed data remains // when state is accumulated between executions and requires flushing. if (shouldExecuteFinalRun === true) { this._currentExecutionPromise = this._executeTaskAndUpdateState(); await this._currentExecutionPromise; } this._status = 'inactive'; return true; } /** * Executes the task in a controlled manner: * - Triggers the optional error handler (if provided) when the task rejects with an error. * - After execution, regardless of its outcome (resolved or rejected), updates the internal * state of the instance to indicate that no execution is currently in progress. * This is crucial to ensure subsequent execution attempts can succeed. */ private async _executeTaskAndUpdateState(): Promise<void> { try { await this._asyncTask(); } catch (err) { // Although a well-designed task should handle its own errors, this logic prevents // uncaught errors from propagating up the event loop. // If an error handler is provided, it will be invoked. this._onTaskError?.(err); } this._currentExecutionPromise = undefined; } private _validateOptions(): void { const { intervalMs, immediateFirstRun } = this._options; if (!isNaturalNumber(intervalMs)) { // prettier-ignore throw new Error( `${NonOverlappingRecurringTask.name} expects a natural number for ` + `intervalMs, received ${intervalMs}`, ); } if (typeof immediateFirstRun !== 'boolean') { // prettier-ignore throw new Error( `${NonOverlappingRecurringTask.name} expects a boolean type for ` + `immediateFirstRun, received ${immediateFirstRun}`, ); } } } function isNaturalNumber(num: number): boolean { if (typeof num !== 'number') { return false; } const floored = Math.floor(num); return floored >= 1 && floored === num; }