UNPKG

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

258 lines (218 loc) 9.78 kB
import { NonOverlappingPeriodicJobScheduler, PeriodicJob, CalculateDelayTillNextExecution, NO_PREVIOUS_EXECUTION } from './index'; type PromiseResolveCallbackType = (value?: unknown) => void; type PromiseRejectCallbackType = (reason?: Error) => void; interface CustomJobError extends Error { jobID: number; } const createError = (jobID: number): CustomJobError => ({ name: 'CustomJobError', message: `Job no. ${jobID} has failed`, jobID }); /** * resolveFast * * The one-and-only purpose of this function, is triggerring an event-loop iteration. * It is relevant whenever a test needs to simulate tasks from the Node.js' micro-tasks queue. */ const resolveFast = async () => { expect(14).toBeGreaterThan(3); }; const MOCK_FIRST_EXECUTION_MS_DELAY = 500; const MOCK_MS_DELAY_AFTER_FAILED_JOB = 3 * 1000; const MOCK_INTERVAL_BETWEEN_CONSECUTIVE_STARTS = 5 * 1000; describe('NonOverlappingPeriodicJobScheduler tests', () => { let setTimeoutSpy: jest.SpyInstance; let nextDelayCalculatorSpy: jest.MockedFunction<CalculateDelayTillNextExecution>; let lastThrownError: CustomJobError; /** * mockCalculateDelayTillNextExecution * * This implementation is provided as an illustrative example for the context of these tests. * While a basic Jest mock function would suffice, this implementation demonstrates a potential * real-world scenario. * Since we use Jest's mock timers, the mock delay times do not affect the tests' execution time. * * This function mimics a real-life scenario by maintaining a fixed interval between the * start times of consecutive executions, similar to `setInterval`. * If an execution lasts longer than expected, the next execution is scheduled according * to the originally planned start time. */ const mockCalculateDelayTillNextExecution: CalculateDelayTillNextExecution<CustomJobError> = ( justFinishedExecutionDurationMs: number, justFinishedExecutionError?: CustomJobError ): number => { if (justFinishedExecutionError) { expect(justFinishedExecutionError).toBe(lastThrownError); expect(justFinishedExecutionError).toEqual(lastThrownError); } else { expect(lastThrownError).toBeUndefined(); } if (justFinishedExecutionDurationMs === NO_PREVIOUS_EXECUTION) { return MOCK_FIRST_EXECUTION_MS_DELAY; } if (justFinishedExecutionError) { return MOCK_MS_DELAY_AFTER_FAILED_JOB; } // For example, if a just-finished execution took 1000ms, and the desired interval-between-starts // is 5000ms, the next execution should start within 4000ms. return MOCK_INTERVAL_BETWEEN_CONSECUTIVE_STARTS - (justFinishedExecutionDurationMs % MOCK_INTERVAL_BETWEEN_CONSECUTIVE_STARTS); }; beforeEach(() => { jest.useFakeTimers(); setTimeoutSpy = jest.spyOn(global, 'setTimeout'); lastThrownError = undefined; nextDelayCalculatorSpy = jest.fn() as jest.MockedFunction<CalculateDelayTillNextExecution>; nextDelayCalculatorSpy.mockImplementation(mockCalculateDelayTillNextExecution); }); afterEach(() => { jest.restoreAllMocks(); jest.useRealTimers(); }); describe('Happy path tests', () => { test('should trigger executions as expected when all jobs succeed', async () => { // We create unresolved promises, simulating an async work in progress. // They will be resolved later, once we want to simulate a successful completion of the async work. let completeCurrentJob: PromiseResolveCallbackType; const job: PeriodicJob = (): Promise<void> => new Promise(res => completeCurrentJob = res); const jobSpy = (jest.fn() as jest.MockedFunction<PeriodicJob>) .mockImplementation(job); const scheduler = new NonOverlappingPeriodicJobScheduler( jobSpy, nextDelayCalculatorSpy ); // Not started yet. expect(scheduler.isStopped).toBe(true); expect(scheduler.isCurrentlyExecuting).toBe(false); scheduler.start(); const numberOfExecutions = 14; for (let ithExecution = 1; ithExecution <= numberOfExecutions; ++ithExecution) { expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(false); expect(setTimeoutSpy).toHaveBeenCalledTimes(ithExecution); expect(nextDelayCalculatorSpy).toHaveBeenCalledTimes(ithExecution); // The next-execution's `setTimeout` callback will be invoked now. jest.runOnlyPendingTimers(); await resolveFast(); expect(jobSpy).toHaveBeenCalledTimes(ithExecution); expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(true); // Until we resolve, the promise is in a pending state. completeCurrentJob(); await scheduler.waitUntilCurrentExecutionCompletes(); expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(false); } await scheduler.stop(); expect(scheduler.isStopped).toBe(true); expect(scheduler.isCurrentlyExecuting).toBe(false); }); test('should handle job rejections and trigger executions as expected', async () => { // We create unresolved promises, simulating an async work in progress. // They will be rejected later, once we want to simulate a failed-completion of the async work. let failCurrentJob: PromiseRejectCallbackType; const job: PeriodicJob = (): Promise<void> => new Promise((_, rej) => failCurrentJob = rej); const jobSpy = (jest.fn() as jest.MockedFunction<PeriodicJob>) .mockImplementation(job); const scheduler = new NonOverlappingPeriodicJobScheduler<CustomJobError>( jobSpy, nextDelayCalculatorSpy ); // Not started yet. expect(scheduler.isStopped).toBe(true); expect(scheduler.isCurrentlyExecuting).toBe(false); scheduler.start(); const numberOfExecutions = 15; for (let ithExecution = 1; ithExecution <= numberOfExecutions; ++ithExecution) { expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(false); expect(setTimeoutSpy).toHaveBeenCalledTimes(ithExecution); expect(nextDelayCalculatorSpy).toHaveBeenCalledTimes(ithExecution); // The next-execution's `setTimeout` callback will be invoked now. jest.runOnlyPendingTimers(); await resolveFast(); expect(jobSpy).toHaveBeenCalledTimes(ithExecution); expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(true); // Until we reject, the promise is in a pending state. lastThrownError = createError(ithExecution); failCurrentJob(lastThrownError); await scheduler.waitUntilCurrentExecutionCompletes(); } await scheduler.stop(); expect(scheduler.isStopped).toBe(true); expect(scheduler.isCurrentlyExecuting).toBe(false); }); test('should handle mixed job outcomes (success or failure) and trigger executions as expected', async () => { let ithJobExecution = 1; let completeCurrentJob: () => void; // Either resolves or rejects. const job: PeriodicJob = () => new Promise((res, rej) => { lastThrownError = undefined; const shouldSucceed = ithJobExecution % 2 === 0; completeCurrentJob = (): void => { if (shouldSucceed) { res(); } else { lastThrownError = createError(ithJobExecution); rej(lastThrownError); } }; ++ithJobExecution; }); const jobSpy = (jest.fn() as jest.MockedFunction<PeriodicJob>) .mockImplementation(job); const scheduler = new NonOverlappingPeriodicJobScheduler<CustomJobError>( jobSpy, nextDelayCalculatorSpy ); // Not started yet. expect(scheduler.isStopped).toBe(true); expect(scheduler.isCurrentlyExecuting).toBe(false); scheduler.start(); const numberOfExecutions = 14; for (let ithExecution = 1; ithExecution <= numberOfExecutions; ++ithExecution) { expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(false); expect(setTimeoutSpy).toHaveBeenCalledTimes(ithExecution); expect(nextDelayCalculatorSpy).toHaveBeenCalledTimes(ithExecution); // The next-execution's `setTimeout` callback will be invoked now. jest.runOnlyPendingTimers(); await resolveFast(); expect(jobSpy).toHaveBeenCalledTimes(ithExecution); expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(true); completeCurrentJob(); await scheduler.waitUntilCurrentExecutionCompletes(); } await scheduler.stop(); expect(scheduler.isStopped).toBe(true); expect(scheduler.isCurrentlyExecuting).toBe(false); }); }); describe('Negative path tests', () => { test('should throw when starting an already started instance', () => { let wasExcetued = false; const job: PeriodicJob = (): Promise<void> => new Promise(_ => { wasExcetued = true; // The flow should not reach this point. }); const jobSpy = (jest.fn() as jest.MockedFunction<PeriodicJob>) .mockImplementation(job); const scheduler = new NonOverlappingPeriodicJobScheduler( jobSpy, nextDelayCalculatorSpy ); scheduler.start(); expect(scheduler.isStopped).toBe(false); expect(scheduler.isCurrentlyExecuting).toBe(false); expect(jobSpy).toHaveBeenCalledTimes(0); expect(() => scheduler.start()).toThrow(); expect(wasExcetued).toBe(false); }); }); });