@ayepi/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/client/index.cjs +5 -0
- package/dist/client/index.d.cts +2 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/doer.cjs +110 -0
- package/dist/doer.d.cts +75 -0
- package/dist/doer.d.ts +75 -0
- package/dist/doer.js +106 -0
- package/dist/errors.d.cts +1729 -0
- package/dist/errors.d.ts +1729 -0
- package/dist/index.cjs +2004 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1968 -0
- package/dist/retry.cjs +135 -0
- package/dist/retry.d.cts +90 -0
- package/dist/retry.d.ts +90 -0
- package/dist/retry.js +129 -0
- package/dist/stats.cjs +0 -0
- package/dist/stats.d.cts +150 -0
- package/dist/stats.d.ts +150 -0
- package/dist/stats.js +0 -0
- package/dist/types.d.cts +54 -0
- package/dist/types.d.ts +54 -0
- package/dist/ws-transport.cjs +1472 -0
- package/dist/ws-transport.js +1383 -0
- package/package.json +110 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# @ayepi/core
|
|
2
|
+
|
|
3
|
+
zod-first, painfully-typed HTTP + WebSocket API library with OpenAPI 3.1 +
|
|
4
|
+
AsyncAPI 3.0 generation. Define endpoints and events once with zod schemas and
|
|
5
|
+
get a typed server, a typed client, wire docs, and a zod-free runtime manifest.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @ayepi/core zod
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { spec, endpoint, server, client } from '@ayepi/core'
|
|
13
|
+
import { client } from '@ayepi/core/client' // zod-free browser entry
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`zod` is a **peer dependency** (`^4`). The `@ayepi/core/client` entry contains
|
|
17
|
+
zero zod runtime code.
|
|
18
|
+
|
|
19
|
+
Runtime adapters: [`@ayepi/node`](https://www.npmjs.com/package/@ayepi/node),
|
|
20
|
+
[`@ayepi/bun`](https://www.npmjs.com/package/@ayepi/bun),
|
|
21
|
+
[`@ayepi/deno`](https://www.npmjs.com/package/@ayepi/deno). The core is
|
|
22
|
+
fetch-native, so it also runs directly on Cloudflare Workers and edge runtimes by
|
|
23
|
+
passing `app.fetch`.
|
|
24
|
+
|
|
25
|
+
See the [full documentation and feature tour](https://github.com/pdiffenderfer/ayepi#readme).
|
|
26
|
+
|
|
27
|
+
## `@ayepi/core/doer`
|
|
28
|
+
|
|
29
|
+
A small, runtime-agnostic **concurrency + scheduling** primitive (`available()` /
|
|
30
|
+
`do(task, opts)` / `done()`) with bundled policies — `unlimitedDoer`, `balancedDoer`,
|
|
31
|
+
`priorityDoer`, `ageDoer`. It has no dependency on the rest of core; [`@ayepi/work`](https://www.npmjs.com/package/@ayepi/work)
|
|
32
|
+
drives one to govern job execution, and [`@ayepi/rate`](https://www.npmjs.com/package/@ayepi/rate)
|
|
33
|
+
adds a `rateLimitedDoer`.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { priorityDoer } from '@ayepi/core/doer'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## `@ayepi/core/retry`
|
|
40
|
+
|
|
41
|
+
A general retry helper — run an operation with exponential backoff + jitter, hooks
|
|
42
|
+
(`onSuccess`/`onRetry`/`onError`), an `errorResult` escape hatch, and a live
|
|
43
|
+
`RetryState`. Set fleet-wide defaults with `setDefaultRetryOptions`. `@ayepi/work` uses
|
|
44
|
+
its `RetryOptions` for every work type's retry policy.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { retry, setDefaultRetryOptions } from '@ayepi/core'
|
|
48
|
+
|
|
49
|
+
setDefaultRetryOptions({ attempts: 5 })
|
|
50
|
+
const data = await retry((state) => fetchJson(url), { base: 200 })
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## `@ayepi/core/stats`
|
|
54
|
+
|
|
55
|
+
A tiny, dependency-free metrics primitive — typed, **labelled** measurements you hand to
|
|
56
|
+
whatever you already run (a periodic log, StatsD, Prometheus). Three kinds: **counter**
|
|
57
|
+
(`inc`), **gauge** (`set`/`add`/`max`), and **summary** (`observe` → count/total/min/max/avg,
|
|
58
|
+
plus histogram buckets + approximate quantiles when configured). `createMetrics()` is the
|
|
59
|
+
registry: `list()`/`get()` snapshots, a **coalesced** `subscribe()` for change notifications,
|
|
60
|
+
and `formatPrometheus()` to render the text exposition. `@ayepi/work` records its per-type job
|
|
61
|
+
stats into one of these.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { createMetrics, formatPrometheus } from '@ayepi/core'
|
|
65
|
+
|
|
66
|
+
const m = createMetrics({ quantiles: [0.5, 0.95, 0.99] })
|
|
67
|
+
m.counter('jobs_done', { type: 'email' }).inc()
|
|
68
|
+
m.summary('job_ms', { type: 'email' }, { unit: 'ms' }).observe(42)
|
|
69
|
+
|
|
70
|
+
setInterval(() => console.log(formatPrometheus(m.list())), 15_000) // scrape/log loop
|
|
71
|
+
m.subscribe((changed) => pushToStatsd(changed)) // or push on change (batched)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## For AI coding agents
|
|
75
|
+
|
|
76
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
77
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
78
|
+
|
|
79
|
+
- [`ayepi-core-client.md`](./ayepi-core-client.md)
|
|
80
|
+
- [`ayepi-core-endpoints.md`](./ayepi-core-endpoints.md)
|
|
81
|
+
- [`ayepi-core-middleware.md`](./ayepi-core-middleware.md)
|
|
82
|
+
- [`ayepi-core-types.md`](./ayepi-core-types.md)
|
|
83
|
+
- [`ayepi-core.md`](./ayepi-core.md)
|
|
84
|
+
|
|
85
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/core) and are **not** shipped in the npm tarball.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT © Philip Diffenderfer
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_ws_transport = require("../ws-transport.cjs");
|
|
3
|
+
exports.ApiError = require_ws_transport.ApiError;
|
|
4
|
+
exports.client = require_ws_transport.client;
|
|
5
|
+
exports.wsTransport = require_ws_transport.wsTransport;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { F as AnySpec, Ft as CallArgs, It as CallOpts, Rt as CallReturn, a as WebSocketCtor, an as HttpMethod, c as WsState, cn as ManifestEvent, d as wsTransport, f as ApiClient, g as client, h as GetUrlKeys, i as HeartbeatOptions, l as WsTransport, m as ClientWs, o as WebSocketLike, on as Manifest, p as ClientOptions, r as BackoffOptions, s as WsMessageEvent, sn as ManifestEndpoint, t as ApiError, u as WsTransportOptions, zt as ClientData } from "../errors.cjs";
|
|
2
|
+
export { type AnySpec, type ApiClient, ApiError, type BackoffOptions, type CallArgs, type CallOpts, type CallReturn, type ClientData, type ClientOptions, type ClientWs, type GetUrlKeys, type HeartbeatOptions, type HttpMethod, type Manifest, type ManifestEndpoint, type ManifestEvent, type WebSocketCtor, type WebSocketLike, type WsMessageEvent, type WsState, type WsTransport, type WsTransportOptions, client, wsTransport };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { F as AnySpec, Ft as CallArgs, It as CallOpts, Rt as CallReturn, a as WebSocketCtor, an as HttpMethod, c as WsState, cn as ManifestEvent, d as wsTransport, f as ApiClient, g as client, h as GetUrlKeys, i as HeartbeatOptions, l as WsTransport, m as ClientWs, o as WebSocketLike, on as Manifest, p as ClientOptions, r as BackoffOptions, s as WsMessageEvent, sn as ManifestEndpoint, t as ApiError, u as WsTransportOptions, zt as ClientData } from "../errors.js";
|
|
2
|
+
export { type AnySpec, type ApiClient, ApiError, type BackoffOptions, type CallArgs, type CallOpts, type CallReturn, type ClientData, type ClientOptions, type ClientWs, type GetUrlKeys, type HeartbeatOptions, type HttpMethod, type Manifest, type ManifestEndpoint, type ManifestEvent, type WebSocketCtor, type WebSocketLike, type WsMessageEvent, type WsState, type WsTransport, type WsTransportOptions, client, wsTransport };
|
package/dist/doer.cjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/doer.ts
|
|
3
|
+
/** Default pull batch for {@link unlimitedDoer} — bounds how much one tick admits. */
|
|
4
|
+
const DEFAULT_UNLIMITED_AVAILABLE = 256;
|
|
5
|
+
/**
|
|
6
|
+
* Run every task immediately with **no concurrency cap**. `available()` reports a fixed
|
|
7
|
+
* batch so one tick doesn't admit an unbounded burst at once.
|
|
8
|
+
*/
|
|
9
|
+
function unlimitedDoer(opts = {}) {
|
|
10
|
+
const available = opts.available ?? DEFAULT_UNLIMITED_AVAILABLE;
|
|
11
|
+
let running = 0;
|
|
12
|
+
const idle = [];
|
|
13
|
+
const settle = () => {
|
|
14
|
+
if (running === 0) for (const r of idle.splice(0)) r();
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
available: () => available,
|
|
18
|
+
do(task) {
|
|
19
|
+
running++;
|
|
20
|
+
Promise.resolve().then(task).catch(() => {}).finally(() => {
|
|
21
|
+
running--;
|
|
22
|
+
settle();
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
done: () => running === 0 ? Promise.resolve() : new Promise((r) => idle.push(r))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function boundedDoer(pick, opts) {
|
|
29
|
+
const max = opts.max;
|
|
30
|
+
const buffer = opts.buffer ?? opts.max;
|
|
31
|
+
const now = opts.now ?? Date.now;
|
|
32
|
+
const pending = [];
|
|
33
|
+
const runningByGroup = /* @__PURE__ */ new Map();
|
|
34
|
+
const idle = [];
|
|
35
|
+
let running = 0;
|
|
36
|
+
let seq = 0;
|
|
37
|
+
const held = () => running + pending.length;
|
|
38
|
+
const bump = (group, by) => void runningByGroup.set(group, (runningByGroup.get(group) ?? 0) + by);
|
|
39
|
+
const drain = () => {
|
|
40
|
+
while (running < max && pending.length > 0) {
|
|
41
|
+
const idx = pick(pending, runningByGroup);
|
|
42
|
+
const [task] = pending.splice(idx, 1);
|
|
43
|
+
running++;
|
|
44
|
+
bump(task.group, 1);
|
|
45
|
+
Promise.resolve().then(task.run).catch(() => {}).finally(() => {
|
|
46
|
+
running--;
|
|
47
|
+
bump(task.group, -1);
|
|
48
|
+
drain();
|
|
49
|
+
if (held() === 0) for (const r of idle.splice(0)) r();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
available: () => Math.max(0, max + buffer - held()),
|
|
55
|
+
do(task, o) {
|
|
56
|
+
pending.push({
|
|
57
|
+
run: task,
|
|
58
|
+
group: o?.group ?? "",
|
|
59
|
+
priority: o?.priority ?? 0,
|
|
60
|
+
createdAt: o?.createdAt ?? now(),
|
|
61
|
+
seq: seq++
|
|
62
|
+
});
|
|
63
|
+
drain();
|
|
64
|
+
},
|
|
65
|
+
done: () => held() === 0 ? Promise.resolve() : new Promise((r) => idle.push(r))
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Pick the lowest pending task per a comparator (`better(a, b)` ⇒ `a` runs before `b`). */
|
|
69
|
+
const argmin = (pending, better) => {
|
|
70
|
+
let best = 0;
|
|
71
|
+
for (let i = 1; i < pending.length; i++) if (better(pending[i], pending[best])) best = i;
|
|
72
|
+
return best;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Cap `max`; when a slot frees, give it to the group with the fewest currently-running
|
|
76
|
+
* tasks (fair share), breaking ties by higher priority, then older `createdAt`.
|
|
77
|
+
*/
|
|
78
|
+
function balancedDoer(opts) {
|
|
79
|
+
const pick = (pending, rbg) => argmin(pending, (a, b) => {
|
|
80
|
+
const ga = rbg.get(a.group) ?? 0;
|
|
81
|
+
const gb = rbg.get(b.group) ?? 0;
|
|
82
|
+
if (ga !== gb) return ga < gb;
|
|
83
|
+
if (a.priority !== b.priority) return a.priority > b.priority;
|
|
84
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt;
|
|
85
|
+
return a.seq < b.seq;
|
|
86
|
+
});
|
|
87
|
+
return boundedDoer(pick, opts);
|
|
88
|
+
}
|
|
89
|
+
/** Cap `max`; run the highest-priority pending task next, breaking ties by older `createdAt`. */
|
|
90
|
+
function priorityDoer(opts) {
|
|
91
|
+
const pick = (pending) => argmin(pending, (a, b) => {
|
|
92
|
+
if (a.priority !== b.priority) return a.priority > b.priority;
|
|
93
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt;
|
|
94
|
+
return a.seq < b.seq;
|
|
95
|
+
});
|
|
96
|
+
return boundedDoer(pick, opts);
|
|
97
|
+
}
|
|
98
|
+
/** Cap `max`; run the oldest pending task next (by `createdAt`). */
|
|
99
|
+
function ageDoer(opts) {
|
|
100
|
+
const pick = (pending) => argmin(pending, (a, b) => {
|
|
101
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt;
|
|
102
|
+
return a.seq < b.seq;
|
|
103
|
+
});
|
|
104
|
+
return boundedDoer(pick, opts);
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
exports.ageDoer = ageDoer;
|
|
108
|
+
exports.balancedDoer = balancedDoer;
|
|
109
|
+
exports.priorityDoer = priorityDoer;
|
|
110
|
+
exports.unlimitedDoer = unlimitedDoer;
|
package/dist/doer.d.cts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/doer.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # Doers — concurrency + scheduling policy
|
|
4
|
+
*
|
|
5
|
+
* A **doer** decides *how many* tasks to admit and *which* to run next. A caller asks
|
|
6
|
+
* {@link Doer.available} how many tasks it may submit right now, then hands each to
|
|
7
|
+
* {@link Doer.do}; the doer runs up to its policy's cap and, when a slot frees, picks
|
|
8
|
+
* the next pending task. {@link Doer.done} resolves when everything it holds has
|
|
9
|
+
* settled. This is a runtime-agnostic primitive — `@ayepi/work` drives one to govern
|
|
10
|
+
* job execution, but it has no dependency on work and can throttle anything.
|
|
11
|
+
*
|
|
12
|
+
* Bundled policies:
|
|
13
|
+
* - {@link unlimitedDoer} — run everything immediately, no cap.
|
|
14
|
+
* - {@link balancedDoer} — cap N; share slots fairly across groups, then priority, then age.
|
|
15
|
+
* - {@link priorityDoer} — cap N; highest priority first, then age.
|
|
16
|
+
* - {@link ageDoer} — cap N; oldest (by `createdAt`) first.
|
|
17
|
+
*
|
|
18
|
+
* A rate-limiting doer (start-rate cap), `rateLimitedDoer`, lives in `@ayepi/rate`,
|
|
19
|
+
* built on this interface and that package's limiter primitives.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
/** Per-task hints used by a {@link Doer} to order pending work. */
|
|
24
|
+
interface DoerTaskOptions {
|
|
25
|
+
/** Fairness group — `balancedDoer` spreads slots evenly across distinct groups. */
|
|
26
|
+
readonly group?: string;
|
|
27
|
+
/** Higher runs first (default 0). */
|
|
28
|
+
readonly priority?: number;
|
|
29
|
+
/** Creation time (epoch ms) — older runs first on ties (default: now). */
|
|
30
|
+
readonly createdAt?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs tasks under a concurrency + ordering policy.
|
|
34
|
+
*
|
|
35
|
+
* A driver loop uses it like: `available()` → how many to submit now; `do(task, opts)`
|
|
36
|
+
* → accept a task; `done()` → resolve when all accepted tasks have settled.
|
|
37
|
+
*/
|
|
38
|
+
interface Doer {
|
|
39
|
+
/** How many more tasks this doer will accept right now (a driver submits up to this many). */
|
|
40
|
+
available(): number;
|
|
41
|
+
/** Accept a task to run (now or when a slot frees, per policy). Never rejects. */
|
|
42
|
+
do(task: () => Promise<void>, opts?: DoerTaskOptions): void;
|
|
43
|
+
/** Resolve once every accepted task (running + pending) has settled. */
|
|
44
|
+
done(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/** Options for {@link unlimitedDoer}. */
|
|
47
|
+
interface UnlimitedDoerOptions {
|
|
48
|
+
/** Max tasks to admit per tick (default 256). Concurrency itself is unbounded. */
|
|
49
|
+
readonly available?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Run every task immediately with **no concurrency cap**. `available()` reports a fixed
|
|
53
|
+
* batch so one tick doesn't admit an unbounded burst at once.
|
|
54
|
+
*/
|
|
55
|
+
declare function unlimitedDoer(opts?: UnlimitedDoerOptions): Doer;
|
|
56
|
+
/** Options shared by the bounded doers. */
|
|
57
|
+
interface BoundedDoerOptions {
|
|
58
|
+
/** Max concurrently running. */
|
|
59
|
+
readonly max: number;
|
|
60
|
+
/** Extra pending tasks to buffer for selection (default `max`). Total held ≤ `max + buffer`. */
|
|
61
|
+
readonly buffer?: number;
|
|
62
|
+
/** Clock for the default `createdAt` (default `Date.now`). */
|
|
63
|
+
readonly now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Cap `max`; when a slot frees, give it to the group with the fewest currently-running
|
|
67
|
+
* tasks (fair share), breaking ties by higher priority, then older `createdAt`.
|
|
68
|
+
*/
|
|
69
|
+
declare function balancedDoer(opts: BoundedDoerOptions): Doer;
|
|
70
|
+
/** Cap `max`; run the highest-priority pending task next, breaking ties by older `createdAt`. */
|
|
71
|
+
declare function priorityDoer(opts: BoundedDoerOptions): Doer;
|
|
72
|
+
/** Cap `max`; run the oldest pending task next (by `createdAt`). */
|
|
73
|
+
declare function ageDoer(opts: BoundedDoerOptions): Doer;
|
|
74
|
+
//#endregion
|
|
75
|
+
export { BoundedDoerOptions, Doer, DoerTaskOptions, UnlimitedDoerOptions, ageDoer, balancedDoer, priorityDoer, unlimitedDoer };
|
package/dist/doer.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/doer.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # Doers — concurrency + scheduling policy
|
|
4
|
+
*
|
|
5
|
+
* A **doer** decides *how many* tasks to admit and *which* to run next. A caller asks
|
|
6
|
+
* {@link Doer.available} how many tasks it may submit right now, then hands each to
|
|
7
|
+
* {@link Doer.do}; the doer runs up to its policy's cap and, when a slot frees, picks
|
|
8
|
+
* the next pending task. {@link Doer.done} resolves when everything it holds has
|
|
9
|
+
* settled. This is a runtime-agnostic primitive — `@ayepi/work` drives one to govern
|
|
10
|
+
* job execution, but it has no dependency on work and can throttle anything.
|
|
11
|
+
*
|
|
12
|
+
* Bundled policies:
|
|
13
|
+
* - {@link unlimitedDoer} — run everything immediately, no cap.
|
|
14
|
+
* - {@link balancedDoer} — cap N; share slots fairly across groups, then priority, then age.
|
|
15
|
+
* - {@link priorityDoer} — cap N; highest priority first, then age.
|
|
16
|
+
* - {@link ageDoer} — cap N; oldest (by `createdAt`) first.
|
|
17
|
+
*
|
|
18
|
+
* A rate-limiting doer (start-rate cap), `rateLimitedDoer`, lives in `@ayepi/rate`,
|
|
19
|
+
* built on this interface and that package's limiter primitives.
|
|
20
|
+
*
|
|
21
|
+
* @module
|
|
22
|
+
*/
|
|
23
|
+
/** Per-task hints used by a {@link Doer} to order pending work. */
|
|
24
|
+
interface DoerTaskOptions {
|
|
25
|
+
/** Fairness group — `balancedDoer` spreads slots evenly across distinct groups. */
|
|
26
|
+
readonly group?: string;
|
|
27
|
+
/** Higher runs first (default 0). */
|
|
28
|
+
readonly priority?: number;
|
|
29
|
+
/** Creation time (epoch ms) — older runs first on ties (default: now). */
|
|
30
|
+
readonly createdAt?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs tasks under a concurrency + ordering policy.
|
|
34
|
+
*
|
|
35
|
+
* A driver loop uses it like: `available()` → how many to submit now; `do(task, opts)`
|
|
36
|
+
* → accept a task; `done()` → resolve when all accepted tasks have settled.
|
|
37
|
+
*/
|
|
38
|
+
interface Doer {
|
|
39
|
+
/** How many more tasks this doer will accept right now (a driver submits up to this many). */
|
|
40
|
+
available(): number;
|
|
41
|
+
/** Accept a task to run (now or when a slot frees, per policy). Never rejects. */
|
|
42
|
+
do(task: () => Promise<void>, opts?: DoerTaskOptions): void;
|
|
43
|
+
/** Resolve once every accepted task (running + pending) has settled. */
|
|
44
|
+
done(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/** Options for {@link unlimitedDoer}. */
|
|
47
|
+
interface UnlimitedDoerOptions {
|
|
48
|
+
/** Max tasks to admit per tick (default 256). Concurrency itself is unbounded. */
|
|
49
|
+
readonly available?: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Run every task immediately with **no concurrency cap**. `available()` reports a fixed
|
|
53
|
+
* batch so one tick doesn't admit an unbounded burst at once.
|
|
54
|
+
*/
|
|
55
|
+
declare function unlimitedDoer(opts?: UnlimitedDoerOptions): Doer;
|
|
56
|
+
/** Options shared by the bounded doers. */
|
|
57
|
+
interface BoundedDoerOptions {
|
|
58
|
+
/** Max concurrently running. */
|
|
59
|
+
readonly max: number;
|
|
60
|
+
/** Extra pending tasks to buffer for selection (default `max`). Total held ≤ `max + buffer`. */
|
|
61
|
+
readonly buffer?: number;
|
|
62
|
+
/** Clock for the default `createdAt` (default `Date.now`). */
|
|
63
|
+
readonly now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Cap `max`; when a slot frees, give it to the group with the fewest currently-running
|
|
67
|
+
* tasks (fair share), breaking ties by higher priority, then older `createdAt`.
|
|
68
|
+
*/
|
|
69
|
+
declare function balancedDoer(opts: BoundedDoerOptions): Doer;
|
|
70
|
+
/** Cap `max`; run the highest-priority pending task next, breaking ties by older `createdAt`. */
|
|
71
|
+
declare function priorityDoer(opts: BoundedDoerOptions): Doer;
|
|
72
|
+
/** Cap `max`; run the oldest pending task next (by `createdAt`). */
|
|
73
|
+
declare function ageDoer(opts: BoundedDoerOptions): Doer;
|
|
74
|
+
//#endregion
|
|
75
|
+
export { BoundedDoerOptions, Doer, DoerTaskOptions, UnlimitedDoerOptions, ageDoer, balancedDoer, priorityDoer, unlimitedDoer };
|
package/dist/doer.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
//#region src/doer.ts
|
|
2
|
+
/** Default pull batch for {@link unlimitedDoer} — bounds how much one tick admits. */
|
|
3
|
+
const DEFAULT_UNLIMITED_AVAILABLE = 256;
|
|
4
|
+
/**
|
|
5
|
+
* Run every task immediately with **no concurrency cap**. `available()` reports a fixed
|
|
6
|
+
* batch so one tick doesn't admit an unbounded burst at once.
|
|
7
|
+
*/
|
|
8
|
+
function unlimitedDoer(opts = {}) {
|
|
9
|
+
const available = opts.available ?? DEFAULT_UNLIMITED_AVAILABLE;
|
|
10
|
+
let running = 0;
|
|
11
|
+
const idle = [];
|
|
12
|
+
const settle = () => {
|
|
13
|
+
if (running === 0) for (const r of idle.splice(0)) r();
|
|
14
|
+
};
|
|
15
|
+
return {
|
|
16
|
+
available: () => available,
|
|
17
|
+
do(task) {
|
|
18
|
+
running++;
|
|
19
|
+
Promise.resolve().then(task).catch(() => {}).finally(() => {
|
|
20
|
+
running--;
|
|
21
|
+
settle();
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
done: () => running === 0 ? Promise.resolve() : new Promise((r) => idle.push(r))
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function boundedDoer(pick, opts) {
|
|
28
|
+
const max = opts.max;
|
|
29
|
+
const buffer = opts.buffer ?? opts.max;
|
|
30
|
+
const now = opts.now ?? Date.now;
|
|
31
|
+
const pending = [];
|
|
32
|
+
const runningByGroup = /* @__PURE__ */ new Map();
|
|
33
|
+
const idle = [];
|
|
34
|
+
let running = 0;
|
|
35
|
+
let seq = 0;
|
|
36
|
+
const held = () => running + pending.length;
|
|
37
|
+
const bump = (group, by) => void runningByGroup.set(group, (runningByGroup.get(group) ?? 0) + by);
|
|
38
|
+
const drain = () => {
|
|
39
|
+
while (running < max && pending.length > 0) {
|
|
40
|
+
const idx = pick(pending, runningByGroup);
|
|
41
|
+
const [task] = pending.splice(idx, 1);
|
|
42
|
+
running++;
|
|
43
|
+
bump(task.group, 1);
|
|
44
|
+
Promise.resolve().then(task.run).catch(() => {}).finally(() => {
|
|
45
|
+
running--;
|
|
46
|
+
bump(task.group, -1);
|
|
47
|
+
drain();
|
|
48
|
+
if (held() === 0) for (const r of idle.splice(0)) r();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
available: () => Math.max(0, max + buffer - held()),
|
|
54
|
+
do(task, o) {
|
|
55
|
+
pending.push({
|
|
56
|
+
run: task,
|
|
57
|
+
group: o?.group ?? "",
|
|
58
|
+
priority: o?.priority ?? 0,
|
|
59
|
+
createdAt: o?.createdAt ?? now(),
|
|
60
|
+
seq: seq++
|
|
61
|
+
});
|
|
62
|
+
drain();
|
|
63
|
+
},
|
|
64
|
+
done: () => held() === 0 ? Promise.resolve() : new Promise((r) => idle.push(r))
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Pick the lowest pending task per a comparator (`better(a, b)` ⇒ `a` runs before `b`). */
|
|
68
|
+
const argmin = (pending, better) => {
|
|
69
|
+
let best = 0;
|
|
70
|
+
for (let i = 1; i < pending.length; i++) if (better(pending[i], pending[best])) best = i;
|
|
71
|
+
return best;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Cap `max`; when a slot frees, give it to the group with the fewest currently-running
|
|
75
|
+
* tasks (fair share), breaking ties by higher priority, then older `createdAt`.
|
|
76
|
+
*/
|
|
77
|
+
function balancedDoer(opts) {
|
|
78
|
+
const pick = (pending, rbg) => argmin(pending, (a, b) => {
|
|
79
|
+
const ga = rbg.get(a.group) ?? 0;
|
|
80
|
+
const gb = rbg.get(b.group) ?? 0;
|
|
81
|
+
if (ga !== gb) return ga < gb;
|
|
82
|
+
if (a.priority !== b.priority) return a.priority > b.priority;
|
|
83
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt;
|
|
84
|
+
return a.seq < b.seq;
|
|
85
|
+
});
|
|
86
|
+
return boundedDoer(pick, opts);
|
|
87
|
+
}
|
|
88
|
+
/** Cap `max`; run the highest-priority pending task next, breaking ties by older `createdAt`. */
|
|
89
|
+
function priorityDoer(opts) {
|
|
90
|
+
const pick = (pending) => argmin(pending, (a, b) => {
|
|
91
|
+
if (a.priority !== b.priority) return a.priority > b.priority;
|
|
92
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt;
|
|
93
|
+
return a.seq < b.seq;
|
|
94
|
+
});
|
|
95
|
+
return boundedDoer(pick, opts);
|
|
96
|
+
}
|
|
97
|
+
/** Cap `max`; run the oldest pending task next (by `createdAt`). */
|
|
98
|
+
function ageDoer(opts) {
|
|
99
|
+
const pick = (pending) => argmin(pending, (a, b) => {
|
|
100
|
+
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt;
|
|
101
|
+
return a.seq < b.seq;
|
|
102
|
+
});
|
|
103
|
+
return boundedDoer(pick, opts);
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
export { ageDoer, balancedDoer, priorityDoer, unlimitedDoer };
|