diff --git a/.oxlintrc.json b/.oxlintrc.json index 0c2408a..c03d031 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -70,8 +70,7 @@ "expectTypeOf", "assert", "assertType", - "assert.**", - "**.noErrVal" + "assert.**" ] } ] diff --git a/README.md b/README.md index 7a0a15b..a4d2e29 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ More importantly, this library has been rewritten with modern-day JS (courtesy o ### Breaking changes in v4 +- The callback-style `submit()` method has been removed — Bottleneck is now Promise-only. Use `schedule()`, wrapping callback-style functions with [`util.promisify`](https://nodejs.org/api/util.html#utilpromisifyoriginal). The `Bottleneck.Callback` type is gone from the typings. - The ES5 build has been removed (`require("bottleneck/es5")` no longer exists). If you need broad-browser support, use the UMD `@sderrow/bottleneck/light` build instead. - Cluster mode now requires `redis` v4+ (drops v2/v3) or `ioredis` v5+. The unsupported `redis` v2/v3 client API has been removed. - `ioredis` and `redis` are now optional **peer dependencies**. Your application must install whichever client it uses. @@ -35,7 +36,6 @@ See [Upgrading to v4](#upgrading-to-v4) for migration steps. - [Gotchas & Common Mistakes](#gotchas--common-mistakes) - [Constructor](#constructor) - [Reservoir Intervals](#reservoir-intervals) -- [`submit()`](#submit) - [`schedule()`](#schedule) - [`wrap()`](#wrap) - [Job Options](#job-options) @@ -153,16 +153,11 @@ const result = await wrapped(arg1, arg2); #### ➤ Using callbacks? -Instead of this: +Bottleneck is Promise-only. Wrap callback-style functions with [`util.promisify`](https://nodejs.org/api/util.html#utilpromisifyoriginal) and `schedule()` them: ```js -someAsyncCall(arg1, arg2, callback); -``` - -Do this: - -```js -limiter.submit(someAsyncCall, arg1, arg2, callback); +const promisified = util.promisify(someAsyncCall); +const result = await limiter.schedule(promisified, arg1, arg2); ``` ### Step 3 of 3 @@ -219,9 +214,7 @@ limiter.schedule(() => object.doSomething()); - If you plan on using `priorities`, make sure to set a `maxConcurrent` value. -- **When using `submit()`**, if a callback isn't necessary, you must pass `null` or an empty function instead. It will not work otherwise. - -- **When using `submit()`**, make sure all the jobs will eventually complete by calling their callback, or set an [`expiration`](#job-options). Even if you submitted your job with a `null` callback , it still needs to call its callback. This is particularly important if you are using a `maxConcurrent` value that isn't `null` (unlimited), otherwise those not completed jobs will be clogging up the limiter and no new jobs will be allowed to run. It's safe to call the callback more than once, subsequent calls are ignored. +- **Make sure your jobs eventually settle** (resolve or reject), or set an [`expiration`](#job-options). With a `maxConcurrent` value that isn't `null` (unlimited), jobs whose promises never settle clog the limiter and no new jobs will be allowed to run. - Using tools like `mockdate` in your tests to change time in JavaScript will likely result in undefined behavior from Bottleneck. @@ -301,21 +294,9 @@ Reservoir Intervals are an advanced feature, please take the time to read and un - **Reservoir Intervals prevent a limiter from being garbage collected.** Call `limiter.disconnect()` to clear the interval and allow the memory to be freed. However, it's not necessary to call `.disconnect()` to allow the Node.js process to exit. -### submit() - -Adds a job to the queue. This is the callback version of `schedule()`. - -```js -limiter.submit(someAsyncCall, arg1, arg2, callback); -``` - -You can pass `null` instead of an empty function if there is no callback, but `someAsyncCall` still needs to call **its** callback to let the limiter know it has completed its work. - -`submit()` can also accept [advanced options](#job-options). - ### schedule() -Adds a job to the queue. This is the Promise and async/await version of `submit()`. +Adds a job to the queue. ```js const fn = function (arg1, arg2) { @@ -360,12 +341,9 @@ wrapped() ### Job Options -`submit()`, `schedule()`, and `wrap()` all accept advanced options. +`schedule()` and `wrap()` accept advanced options. ```js -// Submit -limiter.submit({/* options */}, someAsyncCall, arg1, arg2, callback); - // Schedule limiter.schedule({/* options */}, fn, arg1, arg2); @@ -1123,6 +1101,20 @@ The same applies to `Bottleneck.Group` and to the standalone `Bottleneck.RedisCo If you previously hit "Bottleneck failed to require ioredis at runtime", that workaround paragraph is no longer needed. The implicit-require hack has been removed entirely. +### `submit()` users + +The callback API is gone. The one-line translation: + +```js +// Before +limiter.submit(someAsyncCall, arg1, arg2, callback); + +// After +limiter.schedule(util.promisify(someAsyncCall), arg1, arg2).then(result => /* ... */); +``` + +Job options move over unchanged: `limiter.schedule({ priority: 4 }, fn, ...args)`. + ### Legacy `redis` v2/v3 users The minimum supported `redis` package version is now v4. The v2/v3 callback-style client API is no longer supported. Follow node-redis's own [v3-to-v4 migration guide](https://github.com/redis/node-redis/blob/master/docs/v3-to-v4.md) to upgrade your client. v5 is also fully supported. diff --git a/bottleneck.d.ts b/bottleneck.d.ts index c819415..4a42eb2 100644 --- a/bottleneck.d.ts +++ b/bottleneck.d.ts @@ -129,7 +129,6 @@ declare module "bottleneck" { */ readonly enqueueErrorMessage?: string | null; }; - type Callback = (err: any, result: T) => void; type ClientsList = { client?: any; subscriber?: any }; type GroupLimiterPair = { key: string; limiter: Bottleneck }; type Strategy = number & { readonly __brand: "BottleneckStrategy" }; @@ -597,16 +596,6 @@ declare module "bottleneck" { withOptions: (options: Bottleneck.JobOptions, ...args: Args) => Promise; }; - submit( - fn: (...args: [...Args, Bottleneck.Callback]) => void, - ...args: [...Args, Bottleneck.Callback] - ): void; - submit( - options: Bottleneck.JobOptions, - fn: (...args: [...Args, Bottleneck.Callback]) => void, - ...args: [...Args, Bottleneck.Callback] - ): void; - schedule( fn: (...args: Args) => PromiseLike, ...args: Args diff --git a/src/Bottleneck.js b/src/Bottleneck.js index da3db5c..46b2e94 100644 --- a/src/Bottleneck.js +++ b/src/Bottleneck.js @@ -396,49 +396,6 @@ class Bottleneck { } } - submit(...sargs) { - let cb, fn, options; - if (typeof sargs[0] === "function") { - cb = sargs.pop(); - [fn, ...sargs] = sargs; - options = parser.load({}, this.jobDefaults); - } else { - cb = sargs.pop(); - [options, fn, ...sargs] = sargs; - options = parser.load(options, this.jobDefaults); - } - - const task = (...targs) => { - return new Promise((resolve, reject) => - fn(...targs, (...args) => (args[0] != null ? reject : resolve)(args)), - ); - }; - - const job = new Job( - task, - sargs, - options, - this.jobDefaults, - this.rejectOnDrop, - this.Events, - this._states, - ); - // Promise-to-callback bridge for the dual submit()/schedule() API: submit() - // is synchronous and pipes the job's eventual outcome into the Node-style - // callback. The chain form IS the bridge — an async wrapper would just add - // a floating promise around the same pipe. - job.promise - .then((args) => (typeof cb === "function" ? cb(...(args || [])) : undefined)) - .catch((args) => { - if (Array.isArray(args)) { - return typeof cb === "function" ? cb(...args) : undefined; - } else { - return typeof cb === "function" ? cb(args) : undefined; - } - }); - return this._receive(job); - } - schedule(...args) { let options, task; if (typeof args[0] === "function") { diff --git a/test.ts b/test.ts index 29d948a..de2f348 100644 --- a/test.ts +++ b/test.ts @@ -11,11 +11,6 @@ package name via the triple-slash reference above). Checked by `pnpm tsc` as part of the project tsconfig. */ -function withCb(foo: number, bar: () => void, cb: (err: any, result: string) => void) { - let s: string = `cb ${foo}`; - cb(null, s); -} - console.log(Bottleneck); let limiter = new Bottleneck({ @@ -61,17 +56,6 @@ limiter.done().then(function (x) { let i: number = x; }); -limiter.submit( - withCb, - 1, - () => {}, - (err, result) => { - let s: string = result; - console.log(s); - assert(s == "cb 1"); - }, -); - function withPromise(foo: number, bar: () => void): PromiseLike { let s: string = `promise ${foo}`; return Promise.resolve(s); @@ -185,29 +169,6 @@ group.on("created", (limiter, key) => { assert(key.length > 0); }); -group.key("foo").submit( - withCb, - 2, - () => {}, - (err, result) => { - let s: string = `${result} foo`; - console.log(s); - assert(s == "cb 2 foo"); - }, -); - -group.key("bar").submit( - { priority: 4 }, - withCb, - 3, - () => {}, - (err, result) => { - let s: string = `${result} bar`; - console.log(s); - assert(s == "cb 3 foo"); - }, -); - let f1: Promise = group.key("pizza").schedule(withPromise, 2, () => {}); f1.then(function (result: string) { let s: string = result; diff --git a/test/cluster-coordination.test.js b/test/cluster-coordination.test.js index 8df9170..f488b4a 100644 --- a/test/cluster-coordination.test.js +++ b/test/cluster-coordination.test.js @@ -1,6 +1,6 @@ import { describe, expect } from "vitest"; import sleep from "../src/sleep.js"; -import { test, waitForState, deferred } from "./helpers/test-api.js"; +import { test, waitForState, deferred, enqueued } from "./helpers/test-api.js"; const Bottleneck = require("./bottleneck"); const Scripts = require("../src/cluster/Scripts.js"); @@ -16,20 +16,6 @@ const runningOrExecuting = (limiter) => { const counts = limiter.counts(); return counts.RUNNING + counts.EXECUTING; }; -// Promisify a submit() callback: pass `cb` to limiter.submit and await -// `promise` for the job's result value. `onCall` runs at completion time -// (e.g. to timestamp it). -const submitResult = (onCall) => { - const d = deferred(); - return { - promise: d.signal, - cb: (_err, n) => { - onCall?.(); - d.release(n); - }, - }; -}; - describe("Cluster coordination", () => { if (process.env.DATASTORE !== "redis" && process.env.DATASTORE !== "ioredis") { throw new Error("DATASTORE must be redis or ioredis"); @@ -209,14 +195,31 @@ describe("Cluster coordination", () => { ); await limiter2.ready(); + // Fire jobs 1-2 on limiter1, then wait for enqueued(limiter1) so both + // registrations reach redis before limiter2's jobs — preserving the + // enqueue order the old awaited submit groups pinned. Job 2 is NOT + // dropped yet at this point: blocked mode only trips once limiter2's + // submissions push the cluster queue to highWater, so p2's rejection + // cannot be awaited before jobs 3-5 are fired. + const p1 = limiter1.schedule(h.slowPromise, 100, null, 1); + const p2 = limiter1.schedule(h.slowPromise, 100, null, 2); + await enqueued(limiter1); + // Jobs 3-5 trip blocked mode, which drops jobs 2-5 cluster-wide and + // rejects their schedule promises (default rejectOnDrop). The .rejects + // assertions join the barrier's Promise.all: they attach synchronously + // with these schedules — before the drops fire during the registration + // round-trips — so vitest never sees an unhandled rejection, and the + // await point covers all four drops, which the old test confirmed via + // the queue counts right below. + const p3 = limiter2.schedule(h.slowPromise, 100, null, 3); + const p4 = limiter2.schedule(h.slowPromise, 100, null, 4); + const p5 = limiter2.schedule(h.slowPromise, 100, null, 5); await Promise.all([ - limiter1.submit(h.slowJob, 100, null, 1, h.noErrVal(1)), - limiter1.submit(h.slowJob, 100, null, 2, (err) => expect(err).toBeTruthy()), - ]); - await Promise.all([ - limiter2.submit(h.slowJob, 100, null, 3, (err) => expect(err).toBeTruthy()), - limiter2.submit(h.slowJob, 100, null, 4, (err) => expect(err).toBeTruthy()), - limiter2.submit(h.slowJob, 100, null, 5, (err) => expect(err).toBeTruthy()), + enqueued(limiter2), + expect(p2).rejects.toThrow("This job has been dropped by Bottleneck"), + expect(p3).rejects.toThrow("This job has been dropped by Bottleneck"), + expect(p4).rejects.toThrow("This job has been dropped by Bottleneck"), + expect(p5).rejects.toThrow("This job has been dropped by Bottleneck"), ]); const queues = await runCommand(limiter1, "hvals", [client_num_queued_key]); @@ -228,11 +231,15 @@ describe("Cluster coordination", () => { ]); expect(clusterQueues).toEqual([0, 0]); + // Job 1 is the only survivor; its completion is confirmed alongside the + // drop counts the old test checked here. + await expect(p1).resolves.toEqual([1]); + // Poll for the final settled state instead of a fixed wait. Under // event-loop stress (sustained test runs), setTimeout(100) can slip // multiple seconds and break a wall-clock-based wait. We give // 5000ms (default 2000ms is not always enough): j1's 100ms - // slowJob can dispatch hundreds of ms late under load (a single + // slowPromise can dispatch hundreds of ms late under load (a single // connect-retry on the ioredis client adds ~500ms to register; // doExecute's setTimeout(0) drifts when the event loop is busy // serving other parallel test workers). @@ -690,53 +697,49 @@ describe("Cluster coordination", () => { }), ); - const r1 = submitResult(); - const r2 = submitResult(); - const r3 = submitResult(); - const r4 = submitResult(); - const r5 = submitResult(); - const r6 = submitResult(); - const r7 = submitResult(); - await limiter1.schedule({ id: "1" }, h.promise, null, "A"); await limiter2.schedule({ id: "2" }, h.promise, null, "B"); await limiter3.schedule({ id: "3" }, h.promise, null, "C"); await limiter4.schedule({ id: "4" }, h.promise, null, "D"); // Hold A open with a deferred job so cluster capacity stays at 3 while - // D/E/F/G queue, regardless of how long the submit round-trips take. + // D/E/F/G queue, regardless of how long the registration round-trips take. // We release A after the QUEUED assertions so it finishes before B, // preserving the original [1, 4, 5, 6, 7, 2, 3] completion order. + // + // Registration order is load-bearing (client_last_registered feeds the + // capacity-priority grants), so each schedule is followed by a drain of + // that limiter's enqueued() barrier — the enqueue-time guarantee the old + // sequentially-awaited submits provided. const sigA = deferred(); - await limiter1.submit({ id: "A" }, h.deferredJob, sigA.signal, null, 1, r1.cb); + const p1 = limiter1.schedule({ id: "A" }, h.deferredPromise, sigA.signal, null, 1); + await enqueued(limiter1); // B and C must finish after D/E/F/G. Use generous durations so the test // is robust to redis round-trip delays between releaseA() and G's completion. - await limiter1.submit({ id: "B" }, h.slowJob, 1000, null, 2, r2.cb); - await limiter2.submit({ id: "C" }, h.slowJob, 1050, null, 3, r3.cb); + const p2 = limiter1.schedule({ id: "B" }, h.slowPromise, 1000, null, 2); + await enqueued(limiter1); + const p3 = limiter2.schedule({ id: "C" }, h.slowPromise, 1050, null, 3); + await enqueued(limiter2); expect(runningOrExecuting(limiter1)).toEqual(2); expect(runningOrExecuting(limiter2)).toEqual(1); - await limiter3.submit({ id: "D" }, h.slowJob, 50, null, 4, r4.cb); - await limiter4.submit({ id: "E" }, h.slowJob, 50, null, 5, r5.cb); - await limiter3.submit({ id: "F" }, h.slowJob, 50, null, 6, r6.cb); - await limiter4.submit({ id: "G" }, h.slowJob, 50, null, 7, r7.cb); + const p4 = limiter3.schedule({ id: "D" }, h.slowPromise, 50, null, 4); + await enqueued(limiter3); + const p5 = limiter4.schedule({ id: "E" }, h.slowPromise, 50, null, 5); + await enqueued(limiter4); + const p6 = limiter3.schedule({ id: "F" }, h.slowPromise, 50, null, 6); + await enqueued(limiter3); + const p7 = limiter4.schedule({ id: "G" }, h.slowPromise, 50, null, 7); + await enqueued(limiter4); expect(limiter3.counts().QUEUED).toEqual(2); expect(limiter4.counts().QUEUED).toEqual(2); sigA.release(); - await Promise.all([ - r1.promise, - r2.promise, - r3.promise, - r4.promise, - r5.promise, - r6.promise, - r7.promise, - ]); + await Promise.all([p1, p2, p3, p4, p5, p6, p7]); // The CONTRACT here is "Bottleneck distributes cluster capacity to the // least-busy limiter" — i.e. D/E spread to limiter3/limiter4 (instead of @@ -806,47 +809,59 @@ describe("Cluster coordination", () => { ); let t3, t4; - const r1 = submitResult(); - const r2 = submitResult(); - const r3 = submitResult(() => { - t3 = Date.now(); - }); - const r4 = submitResult(() => { - t4 = Date.now(); - }); - const r5 = submitResult(); - await limiter1.schedule({ id: "1" }, h.promise, null, "A"); await limiter2.schedule({ id: "2" }, h.promise, null, "B"); await limiter3.schedule({ id: "3" }, h.promise, null, "C"); await limiter4.schedule({ id: "4" }, h.promise, null, "D"); - // Hold limiter1's job (weight 2) with deferredJob so the cluster's + // Hold limiter1's job (weight 2) with deferredPromise so the cluster's // shared maxConcurrent=3 stays saturated (2+1=3) across the queue - // count assertions below. With slowJob(50), under load 4 awaited - // submits can take >50ms, so limiter1's job sometimes finished + // count assertions below. With slowPromise(50), under load 4 awaited + // registrations can take >50ms, so limiter1's job sometimes finished // before the asserts ran — capacity freed, limiter3's queued job // dispatched, and `limiter3.counts().QUEUED` flipped from 1 to 0. + // + // Registration order is load-bearing here (see the capacity-priority + // comment below), so each schedule is followed by a drain of that + // limiter's enqueued() barrier — the enqueue-time guarantee the old + // sequentially-awaited submits provided. const sigFirst = deferred(); - await limiter1.submit({ id: "A", weight: 2 }, h.deferredJob, sigFirst.signal, null, 1, r1.cb); - await limiter2.submit({ id: "C" }, h.slowJob, 550, null, 2, r2.cb); + const p1 = limiter1.schedule( + { id: "A", weight: 2 }, + h.deferredPromise, + sigFirst.signal, + null, + 1, + ); + await enqueued(limiter1); + const p2 = limiter2.schedule({ id: "C" }, h.slowPromise, 550, null, 2); + await enqueued(limiter2); expect(runningOrExecuting(limiter1)).toEqual(1); expect(runningOrExecuting(limiter2)).toEqual(1); - await limiter3.submit({ id: "D" }, h.slowJob, 50, null, 3, r3.cb); - await limiter4.submit({ id: "E" }, h.slowJob, 50, null, 4, r4.cb); - await limiter4.submit({ id: "G" }, h.slowJob, 50, null, 5, r5.cb); + // The .finally callbacks timestamp each job's completion; they are + // chained at schedule time, before any await of the promises. + const p3 = limiter3.schedule({ id: "D" }, h.slowPromise, 50, null, 3).finally(() => { + t3 = Date.now(); + }); + await enqueued(limiter3); + const p4 = limiter4.schedule({ id: "E" }, h.slowPromise, 50, null, 4).finally(() => { + t4 = Date.now(); + }); + await enqueued(limiter4); + const p5 = limiter4.schedule({ id: "G" }, h.slowPromise, 50, null, 5); + await enqueued(limiter4); expect(limiter3.counts().QUEUED).toEqual(1); expect(limiter4.counts().QUEUED).toEqual(2); // Release limiter1's job; capacity opens up; queued jobs dispatch. - // Order is preserved because deferredJob's calls.push fires when the - // signal resolves (matching slowJob's timing semantics). + // Order is preserved because deferredPromise's log.record fires when the + // signal resolves (matching slowPromise's timing semantics). sigFirst.release(); - await Promise.all([r1.promise, r2.promise, r3.promise, r4.promise, r5.promise]); + await Promise.all([p1, p2, p3, p4, p5]); // Capacity-priority (process_tick.lua): among clients tied on minimum running // load with queued>0, Redis picks the one with the smallest client_last_registered @@ -902,16 +917,22 @@ describe("Cluster coordination", () => { await limiter2.schedule({ id: "2" }, h.promise, null, "B"); await limiter3.schedule({ id: "3" }, h.promise, null, "C"); - const r1 = submitResult(); - const r2 = submitResult(); // never completes: limiter2 disconnects below - const r3 = submitResult(); - - await limiter1.submit({ id: "4" }, h.slowJob, 100, null, 4, r1.cb); - await limiter2.submit({ id: "5" }, h.slowJob, 100, null, 5, r2.cb); - await limiter3.submit({ id: "6" }, h.slowJob, 100, null, 6, r3.cb); + // Registration order is load-bearing (limiter2 must be the priority + // client when it stops responding), so each schedule is followed by a + // wait on that limiter's enqueued() barrier — the enqueue-time guarantee the + // old sequentially-awaited submits provided. + const p1 = limiter1.schedule({ id: "4" }, h.slowPromise, 100, null, 4); + await enqueued(limiter1); + // Job 5's promise never settles: limiter2 disconnects below while the + // job is still queued, so it is never dispatched nor dropped — no + // assertion can be attached and it must stay un-awaited. + limiter2.schedule({ id: "5" }, h.slowPromise, 100, null, 5); + await enqueued(limiter2); + const p3 = limiter3.schedule({ id: "6" }, h.slowPromise, 100, null, 6); + await enqueued(limiter3); await limiter2.disconnect(false); - await Promise.all([r1.promise, r3.promise]); + await Promise.all([p1, p3]); expect(h.log).toHaveCallOrder([["A"], ["B"], ["C"], [4], [6]]); }); }); diff --git a/test/cluster.test.js b/test/cluster.test.js index ec4bb5f..2f1becd 100644 --- a/test/cluster.test.js +++ b/test/cluster.test.js @@ -1,5 +1,5 @@ import { describe, expect } from "vitest"; -import { test, waitForState, deferred } from "./helpers/test-api.js"; +import { test, waitForState, deferred, enqueued } from "./helpers/test-api.js"; const Bottleneck = require("./bottleneck"); const Scripts = require("../src/cluster/Scripts.js"); const assert = require("assert"); @@ -380,16 +380,13 @@ describe("Cluster-only", () => { const job0 = deferred(); const p0 = rootLimiter.schedule({ id: 0 }, h.deferredPromise, job0.signal, null, 0); - await rootLimiter._submitLock.schedule(() => Promise.resolve()); + await enqueued(rootLimiter); const p1 = rootLimiter.schedule({ id: 1 }, h.promise, null, 1); const p2 = rootLimiter.schedule({ id: 2 }, h.promise, null, 2); const p3 = limiter2.schedule({ id: 3 }, h.promise, null, 3); - await Promise.all([ - rootLimiter._submitLock.schedule(() => Promise.resolve()), - limiter2._submitLock.schedule(() => Promise.resolve()), - ]); + await Promise.all([enqueued(rootLimiter), enqueued(limiter2)]); const queuedA = await runCommand(rootLimiter, "hgetall", [client_num_queued_key]); expect(rootLimiter.counts().QUEUED).toEqual(2); @@ -434,12 +431,12 @@ describe("Cluster-only", () => { rootLimiter.schedule({ id: 2 }, h.deferredPromise, jobs.signal, null, 2); await rootLimiter.schedule({ id: 0, weight: 0 }, h.promise, null, 0); - await rootLimiter._submitLock.schedule(() => Promise.resolve()); + await enqueued(rootLimiter); expect(rootLimiter.counts().EXECUTING).toEqual(2); const p3 = limiter2.schedule({ id: 3 }, h.promise, null, 3); // Drain limiter2's lock — job 3 was submitted on limiter2, so only - // its own _submitLock guarantees the registration reached redis. - await limiter2._submitLock.schedule(() => Promise.resolve()); + // its own enqueued() barrier guarantees the registration reached redis. + await enqueued(limiter2); expect(limiter2.counts().EXECUTING).toEqual(0); jobs.release(); await p3; @@ -558,7 +555,7 @@ describe("Cluster-only", () => { .schedule({ expiration: 50, weight: 5 }, h.deferredPromise, never, null, 5) .catch(errorHandler); - await rootLimiter._submitLock.schedule(() => Promise.resolve(true)); + await enqueued(rootLimiter); await rootLimiter._drainAll(); await rootLimiter.disconnect(false); job1.release(); diff --git a/test/general.test.js b/test/general.test.js index a433798..9a1bf64 100644 --- a/test/general.test.js +++ b/test/general.test.js @@ -1,6 +1,6 @@ import { describe, expect } from "vitest"; import { useFakeClock } from "./helpers/clock.js"; -import { test, waitForState, deferred } from "./helpers/test-api.js"; +import { test, waitForState, deferred, enqueued } from "./helpers/test-api.js"; const Bottleneck = require("./bottleneck"); useFakeClock(); @@ -34,27 +34,6 @@ describe("General", () => { expect(await limiter.wrap(job.action.bind(job))(2)).toEqual(7); }); - test("Should pass multiple arguments back even on errors when using submit()", ({ - harness: h, - makeLimiter, - }) => { - expect.hasAssertions(); - const limiter = makeLimiter({ maxConcurrent: 1 }); - - return new Promise((resolve, reject) => { - limiter.submit(h.job, new Error("welp"), 1, 2, (err, x, y) => { - try { - expect(err.message).toEqual("welp"); - expect(x).toEqual(1); - expect(y).toEqual(2); - resolve(); - } catch (e) { - reject(e); - } - }); - }); - }); - test("Should expose the Events library", () => { class Hello { constructor() { @@ -92,9 +71,10 @@ describe("General", () => { const limiter = makeLimiter({ maxConcurrent: 1, minTime: 100 }); // Hold job 1 with a deferred promise so it never finishes until we - // explicitly release it. Otherwise the prior `slowJob, 50` could finish - // before all 4 submits complete (each submit adds Redis RTT) and a - // queued job dispatches, making `queued()` count race the minTime gate. + // explicitly release it. Otherwise a `slowPromise, 50` job 1 could finish + // before all 4 remaining jobs are enqueued (each enqueue barrier adds a + // Redis RTT) and a queued job dispatches, making `queued()` count race + // the minTime gate. const hold1 = deferred(); expect(await limiter.check()).toEqual(true); @@ -102,30 +82,35 @@ describe("General", () => { expect(limiter.queued()).toEqual(0); expect(await limiter.clusterQueued()).toEqual(0); - await limiter.submit({ id: 1 }, h.deferredJob, hold1.signal, null, 1, h.noErrVal(1)); + const p1 = limiter.schedule({ id: 1 }, h.deferredPromise, hold1.signal, null, 1); + await enqueued(limiter); expect(limiter.queued()).toEqual(0); // It's already running expect(await limiter.check()).toEqual(false); - await limiter.submit({ id: 2 }, h.slowJob, 50, null, 2, h.noErrVal(2)); + const p2 = limiter.schedule({ id: 2 }, h.slowPromise, 50, null, 2); + await enqueued(limiter); expect(limiter.queued()).toEqual(1); expect(await limiter.clusterQueued()).toEqual(1); expect(limiter.queued(1)).toEqual(0); expect(limiter.queued(5)).toEqual(1); - await limiter.submit({ id: 3 }, h.slowJob, 50, null, 3, h.noErrVal(3)); + const p3 = limiter.schedule({ id: 3 }, h.slowPromise, 50, null, 3); + await enqueued(limiter); expect(limiter.queued()).toEqual(2); expect(await limiter.clusterQueued()).toEqual(2); expect(limiter.queued(1)).toEqual(0); expect(limiter.queued(5)).toEqual(2); - await limiter.submit({ id: 4 }, h.slowJob, 50, null, 4, h.noErrVal(4)); + const p4 = limiter.schedule({ id: 4 }, h.slowPromise, 50, null, 4); + await enqueued(limiter); expect(limiter.queued()).toEqual(3); expect(await limiter.clusterQueued()).toEqual(3); expect(limiter.queued(1)).toEqual(0); expect(limiter.queued(5)).toEqual(3); - await limiter.submit({ priority: 1, id: 5 }, h.job, null, 5, h.noErrVal(5)); + const p5 = limiter.schedule({ priority: 1, id: 5 }, h.promise, null, 5); + await enqueued(limiter); expect(limiter.queued()).toEqual(4); expect(await limiter.clusterQueued()).toEqual(4); expect(limiter.queued(1)).toEqual(1); @@ -134,6 +119,13 @@ describe("General", () => { hold1.release(); await h.flushLimiter(limiter); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p2).resolves.toEqual([2]), + expect(p3).resolves.toEqual([3]), + expect(p4).resolves.toEqual([4]), + expect(p5).resolves.toEqual([5]), + ]); expect(limiter.queued()).toEqual(0); expect(await limiter.clusterQueued()).toEqual(0); expect(h.log).toHaveCallOrder([[1], [5], [2], [3], [4]]); @@ -155,9 +147,9 @@ describe("General", () => { expect(running0).toEqual(0); expect(done0).toEqual(0); - limiter.submit({ weight: 1, id: 1 }, h.deferredJob, hold1.signal, null, 1, h.noErrVal(1)); - limiter.submit({ weight: 3, id: 2 }, h.deferredJob, hold2.signal, null, 2, h.noErrVal(2)); - limiter.submit({ weight: 1, id: 3 }, h.deferredJob, hold3.signal, null, 3, h.noErrVal(3)); + const p1 = limiter.schedule({ weight: 1, id: 1 }, h.deferredPromise, hold1.signal, null, 1); + const p2 = limiter.schedule({ weight: 3, id: 2 }, h.deferredPromise, hold2.signal, null, 2); + const p3 = limiter.schedule({ weight: 1, id: 3 }, h.deferredPromise, hold3.signal, null, 3); await limiter.schedule({ weight: 0, id: 4 }, h.promise, null); const [running1, done1] = await Promise.all([limiter.running(), limiter.done()]); @@ -188,6 +180,11 @@ describe("General", () => { expect(done3).toEqual(5); await h.flushLimiter(limiter); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p2).resolves.toEqual([2]), + expect(p3).resolves.toEqual([3]), + ]); expect(h.log).toHaveCallOrder([[], [1], [3], [2]]); }); @@ -340,7 +337,7 @@ describe("General", () => { DONE: 0, }); - // Job 1 is held with a deferredJob so we can observe the state where + // Job 1 is held with a deferredPromise so we can observe the state where // job 1 is EXECUTING, job 2 is RUNNING (just dispatched), job 3 is // QUEUED, and DONE=0 deterministically. Using slowPromise(100) here // races with minTime=100 — the moment job 2 dispatches is the same @@ -348,7 +345,7 @@ describe("General", () => { // window may not exist depending on microtask order. const hold1 = deferred(); - limiter.submit({ weight: 1, id: 1 }, h.deferredJob, hold1.signal, null, 1, h.noErrVal(1)); + const p1 = limiter.schedule({ weight: 1, id: 1 }, h.deferredPromise, hold1.signal, null, 1); const p2 = limiter.schedule({ weight: 1, id: 2 }, h.slowPromise, 200, null, 2); const p3 = limiter.schedule({ weight: 2, id: 3 }, h.slowPromise, 100, null, 3); expect(limiter.counts()).toEqual({ @@ -406,7 +403,11 @@ describe("General", () => { expect(limiter.jobs("QUEUED")).toEqual(["3"]); await h.flushLimiter(limiter); - await Promise.all([expect(p2).resolves.toEqual([2]), expect(p3).resolves.toEqual([3])]); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p2).resolves.toEqual([2]), + expect(p3).resolves.toEqual([3]), + ]); expect(limiter.counts()).toEqual({ RECEIVED: 0, @@ -545,9 +546,13 @@ describe("General", () => { let calledEmpty = 0; let calledIdle = 0; let calledDepleted = 0; + const thirdEmpty = deferred(); limiter.on("empty", () => { calledEmpty++; + if (calledEmpty === 3) { + thirdEmpty.release(); + } }); limiter.on("idle", () => { calledIdle++; @@ -563,13 +568,20 @@ describe("General", () => { expect(limiter.schedule({ id: 2 }, h.slowPromise, 50, null, 2)).resolves.toEqual([2]), expect(limiter.schedule({ id: 3 }, h.slowPromise, 50, null, 3)).resolves.toEqual([3]), ]); - await limiter.submit({ id: 4 }, h.slowJob, 50, null, 4, null); + // Fire job 4 and wait for its enqueue to trigger the third "empty" — + // the counters below must be observed while job 4 is still pending. + // An enqueued() barrier cannot be used here: the empty() check requires + // the submit lock to be idle, so a pending barrier task would + // suppress the very event under test. + const p4 = limiter.schedule({ id: 4 }, h.slowPromise, 50, null, 4); + await thirdEmpty.signal; expect(h).toHaveFinalCallAt(250); expect(h.log).toHaveCallOrder([[1], [2], [3]]); expect(calledEmpty).toEqual(3); expect(calledIdle).toEqual(2); expect(calledDepleted).toEqual(0); await h.flushLimiter(limiter); + await expect(p4).resolves.toEqual([4]); }); test("Should fire events once", async ({ harness: h, makeLimiter }) => { diff --git a/test/group.test.js b/test/group.test.js index 8f36ce3..d478baa 100644 --- a/test/group.test.js +++ b/test/group.test.js @@ -7,7 +7,7 @@ const Bottleneck = require("./bottleneck"); useFakeClock(); describe("Group", () => { - test("Should create limiters", ({ track }) => { + test("Should create limiters", async ({ track }) => { expect.hasAssertions(); const group = track( new Bottleneck.Group({ @@ -34,28 +34,22 @@ describe("Group", () => { group.key("C").schedule(job, 7); }, 40); - return new Promise((resolve, reject) => { - group.key("A").submit((cb) => { - try { - expect(results.length).toStrictEqual(6); - - const byGroup = {}; - for (let i = 0; i < results.length; i++) { - const v = results[i][0]; - const key = v === 1 || v === 3 || v === 4 ? "A" : v === 5 ? "B" : "C"; - byGroup[key] = byGroup[key] || []; - byGroup[key].push(v); - } - expect(byGroup.A).toStrictEqual([1, 3, 4]); - expect(byGroup.B).toStrictEqual([5]); - expect(byGroup.C).toStrictEqual([6, 7]); - expect(results[0]).toStrictEqual([1, 2]); - cb(); - resolve(); - } catch (e) { - reject(e); - } - }, null); + // Scheduled last on key "A", so it runs once all other jobs are done and + // acts as the completion barrier; assertion failures reject the promise. + await group.key("A").schedule(async () => { + expect(results.length).toStrictEqual(6); + + const byGroup = {}; + for (let i = 0; i < results.length; i++) { + const v = results[i][0]; + const key = v === 1 || v === 3 || v === 4 ? "A" : v === 5 ? "B" : "C"; + byGroup[key] = byGroup[key] || []; + byGroup[key].push(v); + } + expect(byGroup.A).toStrictEqual([1, 3, 4]); + expect(byGroup.B).toStrictEqual([5]); + expect(byGroup.C).toStrictEqual([6, 7]); + expect(results[0]).toStrictEqual([1, 2]); }); }); @@ -135,7 +129,7 @@ describe("Group", () => { await limiter.ready(); }); - test("Should pass error on failure", ({ track }) => { + test("Should pass error on failure", async ({ track }) => { const failureMessage = "SOMETHING BLEW UP!!"; const group = track( new Bottleneck.Group({ @@ -169,16 +163,10 @@ describe("Group", () => { group.key("C").schedule(job, 7); }, 40); - return new Promise((resolve, reject) => { - group.key("A").submit((cb) => { - try { - expect(results).toStrictEqual([[1, 2], ["CAUGHT", failureMessage], [6], [3], [7], [4]]); - cb(); - resolve(); - } catch (e) { - reject(e); - } - }, null); + // Scheduled last on key "A", so it runs once all other jobs are done and + // acts as the completion barrier; assertion failures reject the promise. + await group.key("A").schedule(async () => { + expect(results).toStrictEqual([[1, 2], ["CAUGHT", failureMessage], [6], [3], [7], [4]]); }); }); diff --git a/test/helpers/job-tasks.js b/test/helpers/job-tasks.js index 4f6c769..b9f8a7b 100644 --- a/test/helpers/job-tasks.js +++ b/test/helpers/job-tasks.js @@ -1,7 +1,7 @@ import sleep from "../../src/sleep.js"; /** - * Manually-released signal for {@link createTaskFns}'s deferredJob/deferredPromise. + * Manually-released signal for {@link createTaskFns}'s deferredPromise. * * const d = deferred(); * limiter.schedule(h.deferredPromise, d.signal, null, 1); @@ -21,26 +21,6 @@ export function deferred() { * Expects a `log` with a `record(err, result)` method. */ export function createTaskFns(log) { - function job(err, ...result) { - const cb = result.pop(); - log.record(err, result); - cb.apply(null, [err].concat(result)); - } - - async function slowJob(duration, err, ...result) { - const cb = result.pop(); - await sleep(duration); - log.record(err, result); - cb.apply(null, [err].concat(result)); - } - - async function deferredJob(signal, err, ...result) { - const cb = result.pop(); - await signal; - log.record(err, result); - cb.apply(null, [err].concat(result)); - } - async function promise(err, ...result) { log.record(err, result); if (err === null) { @@ -68,9 +48,6 @@ export function createTaskFns(log) { } return { - job, - slowJob, - deferredJob, promise, slowPromise, deferredPromise, diff --git a/test/helpers/test-api.js b/test/helpers/test-api.js index 79d890a..6597e01 100644 --- a/test/helpers/test-api.js +++ b/test/helpers/test-api.js @@ -6,12 +6,16 @@ import makeLimiterHelper from "./limiter.js"; export { waitForState } from "./wait-for-state.js"; export { deferred } from "./job-tasks.js"; -function noErrVal(...expected) { - return (err, ...actual) => { - vitestExpect(err).toBeNull(); - vitestExpect(actual).toEqual(expected); - }; -} +/** + * Enqueue barrier: resolves once every schedule() issued so far on this + * limiter has committed to the queue (or been dropped). schedule() returns + * only the COMPLETION promise, so tests that sequence submissions wait here. + * + * This is the one place the suite couples to the private _submitLock; if the + * library ever hardens privacy (#fields), replace this body with a per-job + * "queued"/"dropped" event race. + */ +export const enqueued = (limiter) => limiter._submitLock.schedule(() => Promise.resolve()); function createJobHarness() { const start = Date.now(); @@ -45,16 +49,12 @@ function createJobHarness() { return { log: record, - job: tasks.job, - slowJob: tasks.slowJob, - deferredJob: tasks.deferredJob, promise: tasks.promise, slowPromise: tasks.slowPromise, deferredPromise: tasks.deferredPromise, getResults, results: getResults, flushLimiter, - noErrVal, callTimes, }; } diff --git a/test/priority.test.js b/test/priority.test.js index 8169919..b9fb1ca 100644 --- a/test/priority.test.js +++ b/test/priority.test.js @@ -1,6 +1,6 @@ import { describe, expect } from "vitest"; import { useFakeClock, isFakeClock } from "./helpers/clock.js"; -import { test, waitForState, deferred } from "./helpers/test-api.js"; +import { test, waitForState, deferred, enqueued } from "./helpers/test-api.js"; const Bottleneck = require("./bottleneck"); useFakeClock(); @@ -41,19 +41,31 @@ describe("Priority", () => { const first = deferred(); - const subs = [ - limiter.submit(h.deferredJob, first.signal, null, 1, h.noErrVal(1)), - limiter.submit(h.job, null, 2, h.noErrVal(2)), - limiter.submit(h.job, null, 3, h.noErrVal(3)), - limiter.submit(h.job, null, 4, h.noErrVal(4)), - limiter.submit({ priority: 2 }, h.job, null, 5, h.noErrVal(5)), - limiter.submit({ priority: 1 }, h.job, null, 6, h.noErrVal(6)), - limiter.submit({ priority: 9 }, h.job, null, 7, h.noErrVal(7)), - ]; - await Promise.all(subs); + const p1 = limiter.schedule(h.deferredPromise, first.signal, null, 1); + // Jobs 2, 3, 4 and 7 are dropped (LEAK, rejectOnDrop: false): their + // promises never settle, so wrapping them in expect().resolves would hang + // the test's auto-awaited assertions. toHaveCallOrder below proves they + // never ran. (2 and 3 are displaced by 5 and 6; 7 is dropped on arrival + // because its own priority (9) is the lowest; 4 is displaced later by the + // flush job, which is submitted while the queue is still at highWater.) + limiter.schedule(h.promise, null, 2); + limiter.schedule(h.promise, null, 3); + limiter.schedule(h.promise, null, 4); + const p5 = limiter.schedule({ priority: 2 }, h.promise, null, 5); + const p6 = limiter.schedule({ priority: 1 }, h.promise, null, 6); + limiter.schedule({ priority: 9 }, h.promise, null, 7); + // Enqueue barrier: schedule() resolves at completion, not enqueue, so run + // a no-op through the submit lock to guarantee all seven submissions + // above have been processed before releasing. + await enqueued(limiter); first.release(); - await h.flushLimiter(limiter, { weight: 0 }); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p5).resolves.toEqual([5]), + expect(p6).resolves.toEqual([6]), + h.flushLimiter(limiter, { weight: 0 }), + ]); expect(h.log).toHaveCallOrder([[1], [6], [5]]); expect(called).toEqual(true); }); @@ -76,20 +88,30 @@ describe("Priority", () => { const first = deferred(); - const subs = [ - limiter.submit(h.deferredJob, first.signal, null, 1, h.noErrVal(1)), - limiter.submit(h.job, null, 2, h.noErrVal(2)), - limiter.submit(h.job, null, 3, h.noErrVal(3)), - limiter.submit(h.job, null, 4, h.noErrVal(4)), - limiter.submit({ priority: 2 }, h.job, null, 5, h.noErrVal(5)), - limiter.submit({ priority: 1 }, h.job, null, 6, h.noErrVal(6)), - limiter.submit({ priority: 9 }, h.job, null, 7, h.noErrVal(7)), - ]; - await Promise.all(subs); + const p1 = limiter.schedule(h.deferredPromise, first.signal, null, 1); + const p2 = limiter.schedule(h.promise, null, 2); + const p3 = limiter.schedule(h.promise, null, 3); + // Jobs 4-7 are dropped on arrival (OVERFLOW, rejectOnDrop: false): their + // promises never settle, so wrapping them in expect().resolves would hang + // the test's auto-awaited assertions. toHaveCallOrder below proves they + // never ran. + limiter.schedule(h.promise, null, 4); + limiter.schedule({ priority: 2 }, h.promise, null, 5); + limiter.schedule({ priority: 1 }, h.promise, null, 6); + limiter.schedule({ priority: 9 }, h.promise, null, 7); + // Enqueue barrier: schedule() resolves at completion, not enqueue, so run + // a no-op through the submit lock to guarantee all seven submissions + // above have been processed before releasing. + await enqueued(limiter); first.release(); await limiter.updateSettings({ highWater: null }); - await h.flushLimiter(limiter); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p2).resolves.toEqual([2]), + expect(p3).resolves.toEqual([3]), + h.flushLimiter(limiter), + ]); expect(h.log).toHaveCallOrder([[1], [2], [3]]); expect(called).toEqual(true); }); @@ -112,20 +134,31 @@ describe("Priority", () => { const first = deferred(); - const subs = [ - limiter.submit(h.deferredJob, first.signal, null, 1, h.noErrVal(1)), - limiter.submit(h.job, null, 2, h.noErrVal(2)), - limiter.submit(h.job, null, 3, h.noErrVal(3)), - limiter.submit(h.job, null, 4, h.noErrVal(4)), - limiter.submit({ priority: 2 }, h.job, null, 5, h.noErrVal(5)), - limiter.submit({ priority: 2 }, h.job, null, 6, h.noErrVal(6)), - limiter.submit({ priority: 2 }, h.job, null, 7, h.noErrVal(7)), - ]; - await Promise.all(subs); + const p1 = limiter.schedule(h.deferredPromise, first.signal, null, 1); + // Jobs 2, 3, 4 and 7 are dropped (OVERFLOW_PRIORITY, rejectOnDrop: false): + // their promises never settle, so wrapping them in expect().resolves would + // hang the test's auto-awaited assertions. toHaveCallOrder below proves + // they never ran. (2 and 3 are displaced by the higher-priority 5 and 6; + // 4 and 7 are dropped on arrival with no lower-priority job to displace.) + limiter.schedule(h.promise, null, 2); + limiter.schedule(h.promise, null, 3); + limiter.schedule(h.promise, null, 4); + const p5 = limiter.schedule({ priority: 2 }, h.promise, null, 5); + const p6 = limiter.schedule({ priority: 2 }, h.promise, null, 6); + limiter.schedule({ priority: 2 }, h.promise, null, 7); + // Enqueue barrier: schedule() resolves at completion, not enqueue, so run + // a no-op through the submit lock to guarantee all seven submissions + // above have been processed before releasing. + await enqueued(limiter); first.release(); await limiter.updateSettings({ highWater: null }); - await h.flushLimiter(limiter); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p5).resolves.toEqual([5]), + expect(p6).resolves.toEqual([6]), + h.flushLimiter(limiter), + ]); expect(h.log).toHaveCallOrder([[1], [5], [6]]); expect(called).toEqual(true); }); @@ -141,7 +174,8 @@ describe("Priority", () => { }); let called = 0; - return new Promise((resolve) => { + let p1, p2, p3, p4; + const unblocked = new Promise((resolve) => { const first = deferred(); limiter.on("dropped", (dropped) => { @@ -154,7 +188,7 @@ describe("Priority", () => { // in the catch below. limiter .updateSettings({ highWater: null }) - .then(() => limiter.schedule(h.job, null, 8)) + .then(() => limiter.schedule(h.promise, null, 8)) .catch((err) => { expect(err).toBeInstanceOf(Bottleneck.BottleneckError); expect(err.message).toEqual("This job has been dropped by Bottleneck"); @@ -165,11 +199,22 @@ describe("Priority", () => { } }); - limiter.submit(h.deferredJob, first.signal, null, 1, h.noErrVal(1)); - limiter.submit(h.slowJob, 20, null, 2, (err) => expect(err).toBeTruthy()); - limiter.submit(h.slowJob, 20, null, 3, (err) => expect(err).toBeTruthy()); - limiter.submit(h.slowJob, 20, null, 4, (err) => expect(err).toBeTruthy()); + p1 = limiter.schedule(h.deferredPromise, first.signal, null, 1); + p2 = limiter.schedule(h.slowPromise, 20, null, 2); + p3 = limiter.schedule(h.slowPromise, 20, null, 3); + p4 = limiter.schedule(h.slowPromise, 20, null, 4); }); + + // Jobs 2-4 are dropped by BLOCK; with the default rejectOnDrop their + // promises reject with the drop error. Job 1 is already running when the + // strategy triggers, so it completes once first.release() fires above. + return Promise.all([ + unblocked, + expect(p1).resolves.toEqual([1]), + expect(p2).rejects.toThrow("This job has been dropped by Bottleneck"), + expect(p3).rejects.toThrow("This job has been dropped by Bottleneck"), + expect(p4).rejects.toThrow("This job has been dropped by Bottleneck"), + ]); }); test("Should have the right priority", async ({ harness: h, makeLimiter }) => { diff --git a/test/promises.test.js b/test/promises.test.js index 4c0171b..5464ba8 100644 --- a/test/promises.test.js +++ b/test/promises.test.js @@ -9,15 +9,20 @@ describe("Promises", () => { test("Should support promises", async ({ harness: h, makeLimiter }) => { const limiter = makeLimiter({ maxConcurrent: 1, minTime: 100 }); - limiter.submit(h.job, null, 1, 9, h.noErrVal(1, 9)); - limiter.submit(h.job, null, 2, h.noErrVal(2)); - limiter.submit(h.job, null, 3, h.noErrVal(3)); + const p1 = limiter.schedule(h.promise, null, 1, 9); + const p2 = limiter.schedule(h.promise, null, 2); + const p3 = limiter.schedule(h.promise, null, 3); const p4 = limiter.schedule(h.promise, null, 4, 5); await h.flushLimiter(limiter); expect(h.log).toHaveCallOrder([[1, 9], [2], [3], [4, 5]]); expect(h).toHaveFinalCallAt(300); - await expect(p4).resolves.toEqual([4, 5]); + await Promise.all([ + expect(p1).resolves.toEqual([1, 9]), + expect(p2).resolves.toEqual([2]), + expect(p3).resolves.toEqual([3]), + expect(p4).resolves.toEqual([4, 5]), + ]); }); test("Should pass error on failure", async ({ harness: h, makeLimiter }) => { @@ -90,9 +95,10 @@ describe("Promises", () => { test.override({ limiterOptions: { maxConcurrent: 1, minTime: 100 } }); test("Should wrap", async ({ harness: h, limiter }) => { - limiter.submit(h.job, null, 1, h.noErrVal(1)); - limiter.submit(h.job, null, 2, h.noErrVal(2)); - limiter.submit(h.job, null, 3, h.noErrVal(3)); + // Wrapped jobs share the same queue as directly scheduled ones. + const p1 = limiter.schedule(h.promise, null, 1); + const p2 = limiter.schedule(h.promise, null, 2); + const p3 = limiter.schedule(h.promise, null, 3); const wrapped = limiter.wrap(h.promise); const p4 = wrapped(null, 4); @@ -100,7 +106,12 @@ describe("Promises", () => { await h.flushLimiter(limiter); expect(h.log).toHaveCallOrder([[1], [2], [3], [4]]); expect(h).toHaveFinalCallAt(300); - await expect(p4).resolves.toEqual([4]); + await Promise.all([ + expect(p1).resolves.toEqual([1]), + expect(p2).resolves.toEqual([2]), + expect(p3).resolves.toEqual([3]), + expect(p4).resolves.toEqual([4]), + ]); }); test("Should automatically wrap a returned value in a resolved promise", async ({