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

196 lines 9.95 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const index_1 = require("./index"); const createError = (jobID) => ({ 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; let nextDelayCalculatorSpy; let lastThrownError; /** * 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 = (justFinishedExecutionDurationMs, justFinishedExecutionError) => { if (justFinishedExecutionError) { expect(justFinishedExecutionError).toBe(lastThrownError); expect(justFinishedExecutionError).toEqual(lastThrownError); } else { expect(lastThrownError).toBeUndefined(); } if (justFinishedExecutionDurationMs === index_1.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(); 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; const job = () => new Promise(res => completeCurrentJob = res); const jobSpy = jest.fn() .mockImplementation(job); const scheduler = new index_1.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; const job = () => new Promise((_, rej) => failCurrentJob = rej); const jobSpy = jest.fn() .mockImplementation(job); const scheduler = new index_1.NonOverlappingPeriodicJobScheduler(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; // Either resolves or rejects. const job = () => new Promise((res, rej) => { lastThrownError = undefined; const shouldSucceed = ithJobExecution % 2 === 0; completeCurrentJob = () => { if (shouldSucceed) { res(); } else { lastThrownError = createError(ithJobExecution); rej(lastThrownError); } }; ++ithJobExecution; }); const jobSpy = jest.fn() .mockImplementation(job); const scheduler = new index_1.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); 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 = () => new Promise(_ => { wasExcetued = true; // The flow should not reach this point. }); const jobSpy = jest.fn() .mockImplementation(job); const scheduler = new index_1.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); }); }); }); //# sourceMappingURL=non-overlapping-periodic-job-scheduler.test.js.map