From 54ab06e1e5a0e2491a10612f9a70ecdf270dd5eb Mon Sep 17 00:00:00 2001 From: Michael Ficarra Date: Thu, 24 Oct 2024 19:09:47 -0600 Subject: [PATCH] add governor combinators --- README.md | 9 ++++-- src/governor.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 01ff6a2..61e79d8 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The Governor name is taken from [the speed-limiting device in motor vehicles](ht
-There is also a Governor constructor with helpers on its prototype. +There is also a Governor constructor with helpers. The constructor unconditionally throws when it is the `new.target`. To make the helpers available, a concrete Governor can be implemented as follows: @@ -70,11 +70,13 @@ Governor.prototype.wrap = fn => { ``` Similarly, `wrapIterator(it: Iterator | AsyncIterator): AsyncIterator` takes an Iterator or AsyncIterator and returns an AsyncIterator that yields the same values but limited in concurrency by this Governor. + +There are also static helpers for composing Governors: `Governor.any` and `Governor.all`. `any` takes 0 or more Governors and produces a Governor that, when acquired, attempts to acquire all of the passed Governors, returns the first GovernorToken it receives, and releases any other tokens it acquires. `all` takes 0 or more Governors and produces a Governor that attempts to acquire all of the passed Governors and only returns a GovernorToken once it has received a token for each of them.
#### Open Questions -- should the protocol be Symbol-based? +- should the acquire protocol be Symbol-based? - maybe a sync/throwing acquire? - `tryAcquire(): GovernorToken` - or maybe not throwing? `tryAcquire(): GovernorToken | null` @@ -83,6 +85,9 @@ Similarly, `wrapIterator(it: Iterator | AsyncIterator): AsyncIterator` - also takes a `tryAcquire` function? - easy enough to live without it - alternative name: Regulator? +- it's kind of annoying to implement both "release" and Symbol.dispose +- should any/all take an iterable instead of varargs to match Promise.any/all? +- should "any" be named "race" since it better matches Promise.race? ### CountingGovernor diff --git a/src/governor.ts b/src/governor.ts index 2021919..113d564 100644 --- a/src/governor.ts +++ b/src/governor.ts @@ -31,6 +31,80 @@ export abstract class Governor { // wrapIterable(iter: Iterable | AsyncIterable): AsyncIterable { // } + + static all(...governors: Governor[]): Governor { + return new ComposedGovernorAll(governors); + } + + static any(...governors: Governor[]): Governor { + return new ComposedGovernorAny(governors); + } +} + +class ComposedGovernorAll extends Governor { + #governors; + + constructor(governors: Governor[]) { + super(); + this.#governors = governors + } + + async acquire(): Promise { + let tokens = await Promise.all(this.#governors.map(g => g.acquire())); + function dispose() { + let deferred = null; + for (let t of tokens) { + try { + t.release(); + } catch (e) { + deferred ??= e; + } + }; + if (deferred) { + throw deferred; + } + } + return { + release: dispose, + [Symbol.dispose]: dispose, + } as GovernorToken; + } +} + +class ComposedGovernorAny extends Governor { + #governors; + + constructor(governors: Governor[]) { + super(); + this.#governors = governors + } + + acquire(): Promise { + // Governor.any() + if (this.#governors.length === 0) { + return Promise.resolve({ + release: () => {}, + [Symbol.dispose]: () => {}, + }); + } + let settled = false; + let { promise, resolve, reject } = Promise.withResolvers(); + let tokenPromises = this.#governors.map(g => g.acquire()); + for (let p of tokenPromises) { + p.then(token => { + if (!settled) { + settled = true; + resolve(token); + } else { + token.release(); + } + }); + }; + Promise.any(tokenPromises).catch(e => { + reject(e); + }); + return promise; + } } export interface GovernorToken {