diff --git a/README.md b/README.md index d3384d2..e372b99 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,31 @@ Run the baseline: ```bash npm test ``` + +## Scheduler contract + +`runJobs(jobs, worker, options)` always resolves to results in input order. +Each result is either `{ status: "fulfilled", value }` or +`{ status: "rejected", reason }`. Omitting `options` preserves the original +sequential, no-retry behavior. + +Options: + +- `concurrency` is a positive safe integer and defaults to `1`. +- `maxRetries` is a non-negative integer below + `Number.MAX_SAFE_INTEGER` and defaults to `0`. +- `timeoutMs` is an optional per-attempt timeout from `0` through + `2_147_483_647`. +- `signal` is an optional caller `AbortSignal`. + +When options are supplied, the worker receives +`(job, index, { attempt, signal })`. Omitting options keeps the original +two-argument callback shape. Attempts are one-based and each retry gets a fresh +signal. Failures and timeouts are retried up to `maxRetries`; caller +cancellation is never retried. After caller cancellation, active attempts +receive an aborted signal and unclaimed jobs receive a stable rejected result. + +Timeout and caller cancellation release the scheduler's logical slot even when +a worker promise has not settled. JavaScript cannot terminate work that ignores +its aborted signal, so physical resource bounding after cancellation requires +cooperative workers. diff --git a/src/scheduler.js b/src/scheduler.js index a75a081..e64687e 100644 --- a/src/scheduler.js +++ b/src/scheduler.js @@ -1,6 +1,172 @@ "use strict"; -async function runJobs(jobs, worker) { +const MAX_TIMEOUT_MS = 2_147_483_647; +const ACTIVE_ABORT_REASON = "Job aborted"; +const NOT_STARTED_ABORT_REASON = "Job not started because the caller aborted"; + +function rejectionReason(error) { + try { + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + return String(error); + } catch (_coercionError) { + return "Worker rejected with an unprintable reason"; + } +} + +function rejected(reason) { + return { status: "rejected", reason }; +} + +function isAbortSignal(signal) { + return typeof AbortSignal !== "undefined" && signal instanceof AbortSignal; +} + +function normalizeOptions(options) { + const passContext = options !== undefined; + if (options === undefined) options = {}; + if (options === null || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("options must be an object"); + } + + const concurrency = + options.concurrency === undefined ? 1 : options.concurrency; + const maxRetries = + options.maxRetries === undefined ? 0 : options.maxRetries; + const timeoutMs = options.timeoutMs === undefined ? null : options.timeoutMs; + const signal = options.signal === undefined ? null : options.signal; + + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new RangeError("concurrency must be a positive safe integer"); + } + if ( + !Number.isSafeInteger(maxRetries) || + maxRetries < 0 || + maxRetries >= Number.MAX_SAFE_INTEGER + ) { + throw new RangeError( + "maxRetries must be a non-negative integer below Number.MAX_SAFE_INTEGER", + ); + } + if ( + options.timeoutMs !== undefined && + (!Number.isSafeInteger(timeoutMs) || + timeoutMs < 0 || + timeoutMs > MAX_TIMEOUT_MS) + ) { + throw new RangeError( + `timeoutMs must be an integer from 0 through ${MAX_TIMEOUT_MS}`, + ); + } + if (options.signal !== undefined && !isAbortSignal(signal)) { + throw new TypeError("signal must be AbortSignal-compatible"); + } + + return { concurrency, maxRetries, timeoutMs, signal, passContext }; +} + +function runAttempt(job, index, attempt, worker, settings) { + const attemptController = new AbortController(); + + return new Promise((resolve) => { + let settled = false; + let timeoutId; + let onCallerAbort; + + function cleanup() { + if (timeoutId !== undefined) clearTimeout(timeoutId); + if (onCallerAbort) { + settings.signal.removeEventListener("abort", onCallerAbort); + } + } + + function finish(outcome, abortReason) { + if (settled) return; + settled = true; + cleanup(); + if (abortReason && !attemptController.signal.aborted) { + attemptController.abort(abortReason); + } + resolve(outcome); + } + + if (settings.signal) { + onCallerAbort = () => { + const error = new Error(ACTIVE_ABORT_REASON); + error.name = "AbortError"; + finish({ kind: "caller-aborted", reason: error.message }, error); + }; + settings.signal.addEventListener("abort", onCallerAbort, { once: true }); + if (settings.signal.aborted) { + onCallerAbort(); + return; + } + } + + if (settings.timeoutMs !== null) { + timeoutId = setTimeout(() => { + const error = new Error( + `Job timed out after ${settings.timeoutMs}ms`, + ); + error.name = "TimeoutError"; + finish({ kind: "timed-out", reason: error.message }, error); + }, settings.timeoutMs); + } + + if (settings.signal?.aborted) { + onCallerAbort(); + return; + } + + let workerResult; + try { + workerResult = settings.passContext + ? worker(job, index, { + attempt, + signal: attemptController.signal, + }) + : worker(job, index); + } catch (error) { + finish({ kind: "failed", reason: rejectionReason(error) }); + return; + } + + Promise.resolve(workerResult).then( + (value) => finish({ kind: "fulfilled", value }), + (error) => finish({ kind: "failed", reason: rejectionReason(error) }), + ); + }); +} + +async function runOneJob(job, index, worker, settings) { + for (let attempt = 1; attempt <= settings.maxRetries + 1; attempt += 1) { + if (settings.signal?.aborted) { + return rejected(ACTIVE_ABORT_REASON); + } + + const outcome = await runAttempt( + job, + index, + attempt, + worker, + settings, + ); + if (outcome.kind === "fulfilled") { + return { status: "fulfilled", value: outcome.value }; + } + if (outcome.kind === "caller-aborted") { + return rejected(outcome.reason); + } + if (attempt > settings.maxRetries) { + return rejected(outcome.reason); + } + } + + throw new Error("unreachable scheduler state"); +} + +async function runJobs(jobs, worker, options) { if (!Array.isArray(jobs)) { throw new TypeError("jobs must be an array"); } @@ -8,16 +174,42 @@ async function runJobs(jobs, worker) { throw new TypeError("worker must be a function"); } - const results = []; - for (let index = 0; index < jobs.length; index += 1) { - try { - const value = await worker(jobs[index], index); - results.push({ status: "fulfilled", value }); - } catch (error) { - results.push({ - status: "rejected", - reason: error instanceof Error ? error.message : String(error), - }); + if (options === undefined) { + const legacyResults = []; + for (let index = 0; index < jobs.length; index += 1) { + try { + const value = await worker(jobs[index], index); + legacyResults.push({ status: "fulfilled", value }); + } catch (error) { + legacyResults.push(rejected(rejectionReason(error))); + } + } + return legacyResults; + } + + const settings = normalizeOptions(options); + if (jobs.length === 0) return []; + + const queue = jobs.slice(); + const results = new Array(queue.length); + let nextIndex = 0; + + async function runLane() { + while (true) { + if (settings.signal?.aborted) return; + const index = nextIndex; + if (index >= queue.length) return; + nextIndex += 1; + results[index] = await runOneJob(queue[index], index, worker, settings); + } + } + + const laneCount = Math.min(settings.concurrency, queue.length); + await Promise.all(Array.from({ length: laneCount }, runLane)); + + for (let index = 0; index < results.length; index += 1) { + if (results[index] === undefined) { + results[index] = rejected(NOT_STARTED_ABORT_REASON); } } return results; diff --git a/test/scheduler.test.js b/test/scheduler.test.js index d46329c..954d0dd 100644 --- a/test/scheduler.test.js +++ b/test/scheduler.test.js @@ -37,3 +37,254 @@ test("validates arguments", async () => { await assert.rejects(() => runJobs(null, async () => {}), /jobs must be an array/); await assert.rejects(() => runJobs([], null), /worker must be a function/); }); + +test("omitting options preserves the two-argument callback shape", async () => { + const observed = []; + + await runJobs([1], function (job, index) { + observed.push([job, index, arguments.length]); + }); + + assert.deepEqual(observed, [[1, 0, 2]]); +}); + +test("omitting options preserves the legacy dynamic array loop", async () => { + const jobs = [0, 1, 2]; + + const result = await runJobs(jobs, async (job) => { + if (job === 0) jobs.length = 1; + return job; + }); + + assert.deepEqual(result, [{ status: "fulfilled", value: 0 }]); +}); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +test("bounds concurrency and preserves input order", async () => { + const started = Array.from({ length: 4 }, () => deferred()); + const gates = Array.from({ length: 4 }, () => deferred()); + let active = 0; + let peak = 0; + + const resultPromise = runJobs( + [0, 1, 2, 3], + async (job) => { + active += 1; + peak = Math.max(peak, active); + started[job].resolve(); + try { + return await gates[job].promise; + } finally { + active -= 1; + } + }, + { concurrency: 2 }, + ); + + await Promise.all([started[0].promise, started[1].promise]); + gates[1].resolve("one"); + await started[2].promise; + gates[0].resolve("zero"); + await started[3].promise; + gates[3].resolve("three"); + gates[2].resolve("two"); + + assert.equal(peak, 2); + assert.deepEqual(await resultPromise, [ + { status: "fulfilled", value: "zero" }, + { status: "fulfilled", value: "one" }, + { status: "fulfilled", value: "two" }, + { status: "fulfilled", value: "three" }, + ]); +}); + +test("retries failures with one-based attempts and fresh signals", async () => { + const attempts = []; + const signals = []; + + const result = await runJobs( + ["job"], + (_job, _index, context) => { + attempts.push(context.attempt); + signals.push(context.signal); + if (context.attempt < 3) throw new Error(`failed-${context.attempt}`); + return "done"; + }, + { maxRetries: 2 }, + ); + + assert.deepEqual(attempts, [1, 2, 3]); + assert.equal(new Set(signals).size, 3); + assert.deepEqual(result, [{ status: "fulfilled", value: "done" }]); +}); + +test("timeout aborts an attempt, releases the lane, and consumes late failure", async () => { + const late = deferred(); + const seen = []; + + const result = await runJobs( + ["slow", "next"], + (job, _index, { attempt, signal }) => { + seen.push([job, attempt, signal]); + if (job === "slow" && attempt === 1) return late.promise; + return `${job}-${attempt}`; + }, + { concurrency: 1, maxRetries: 1, timeoutMs: 5 }, + ); + + assert.equal(seen[0][2].aborted, true); + assert.deepEqual( + seen.map(([job, attempt]) => [job, attempt]), + [["slow", 1], ["slow", 2], ["next", 1]], + ); + assert.deepEqual(result, [ + { status: "fulfilled", value: "slow-2" }, + { status: "fulfilled", value: "next-1" }, + ]); + + late.reject(new Error("late failure")); + await new Promise((resolve) => setImmediate(resolve)); +}); + +test("caller aborts active attempts and prevents new launches", async () => { + const controller = new AbortController(); + const started = []; + const activeSignals = []; + + const resultPromise = runJobs( + [0, 1, 2, 3], + (job, _index, { signal }) => { + started.push(job); + activeSignals.push(signal); + return new Promise(() => {}); + }, + { concurrency: 2, signal: controller.signal }, + ); + + assert.deepEqual(started, [0, 1]); + controller.abort(); + const result = await resultPromise; + + assert.ok(activeSignals.every((signal) => signal.aborted)); + assert.deepEqual(started, [0, 1]); + assert.deepEqual(result, [ + { status: "rejected", reason: "Job aborted" }, + { status: "rejected", reason: "Job aborted" }, + { + status: "rejected", + reason: "Job not started because the caller aborted", + }, + { + status: "rejected", + reason: "Job not started because the caller aborted", + }, + ]); +}); + +test("a pre-aborted signal starts no workers", async () => { + const controller = new AbortController(); + controller.abort(); + let calls = 0; + + const result = await runJobs( + [1, 2], + () => { + calls += 1; + }, + { signal: controller.signal }, + ); + + assert.equal(calls, 0); + assert.deepEqual( + result, + Array.from({ length: 2 }, () => ({ + status: "rejected", + reason: "Job not started because the caller aborted", + })), + ); +}); + +test("worker-triggered caller abort wins over a synchronous throw", async () => { + const controller = new AbortController(); + const started = []; + + const result = await runJobs( + [0, 1], + (job) => { + started.push(job); + controller.abort(); + throw new Error("worker failure after abort"); + }, + { maxRetries: 2, signal: controller.signal }, + ); + + assert.deepEqual(started, [0]); + assert.deepEqual(result, [ + { status: "rejected", reason: "Job aborted" }, + { + status: "rejected", + reason: "Job not started because the caller aborted", + }, + ]); +}); + +test("invalid options reject before a worker starts", async () => { + let calls = 0; + const worker = () => { + calls += 1; + }; + const invalid = [ + null, + [], + { concurrency: 0 }, + { concurrency: null }, + { concurrency: 1.5 }, + { maxRetries: -1 }, + { maxRetries: null }, + { timeoutMs: -1 }, + { timeoutMs: null }, + { timeoutMs: 2_147_483_648 }, + { maxRetries: Number.MAX_SAFE_INTEGER }, + { signal: null }, + { signal: {} }, + { + signal: { + aborted: false, + addEventListener() {}, + removeEventListener() {}, + }, + }, + ]; + + for (const options of invalid) { + await assert.rejects(() => runJobs([1], worker, options)); + } + assert.equal(calls, 0); +}); + +test("non-coercible rejection reasons settle without an unhandled rejection", async () => { + const hostileReason = Object.create(null); + + const result = await runJobs( + ["hostile"], + () => Promise.reject(hostileReason), + {}, + ); + + assert.deepEqual(result, [ + { + status: "rejected", + reason: "Worker rejected with an unprintable reason", + }, + ]); + await new Promise((resolve) => setImmediate(resolve)); +});