@yaebal/runner 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 neverlane
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,36 @@
1
+ # @yaebal/runner
2
+
3
+ concurrent update processing for yaebal bots. updates that share a chat id still run strictly in order; unrelated chats run in parallel up to `concurrency`.
4
+
5
+ ## install
6
+
7
+ ```sh
8
+ pnpm add @yaebal/runner
9
+ ```
10
+
11
+ ## usage
12
+
13
+ ```ts
14
+ import { run } from "@yaebal/runner";
15
+
16
+ const handle = run(bot, {
17
+ concurrency: 50, // max parallel updates (default 50)
18
+ limit: 100, // getUpdates batch size (default 100)
19
+ timeout: 30, // long-poll timeout in seconds (default 30)
20
+ onError: (err, update) => console.error(update?.update_id, err),
21
+ });
22
+
23
+ // graceful shutdown
24
+ process.once("SIGINT", () => handle.stop());
25
+ ```
26
+
27
+ use `createScheduler` directly if you need the bounded-concurrency queue for other purposes:
28
+
29
+ ```ts
30
+ import { createScheduler } from "@yaebal/runner";
31
+
32
+ const scheduler = createScheduler(10);
33
+ scheduler.submit(chatId, async () => { /* ... */ });
34
+
35
+ await scheduler.idle();
36
+ ```
package/lib/index.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Update } from "@yaebal/types";
2
+ /**
3
+ * @yaebal/runner — drives a bot with CONCURRENT update processing instead of the
4
+ * built-in sequential long-poll. updates that share a key (the chat id, by
5
+ * default) still run strictly in order, so per-chat state (sessions) stays safe;
6
+ * unrelated chats run in parallel up to `concurrency`.
7
+ */
8
+ export interface Scheduler {
9
+ /** queue a task. tasks with the same non-null `key` run in submit order, never overlapping. */
10
+ submit(key: PropertyKey | undefined, task: () => Promise<void>): void;
11
+ /** resolves once nothing is queued or running. */
12
+ idle(): Promise<void>;
13
+ /** resolves once fewer than `n` tasks are queued or running (backpressure gate). */
14
+ whenBelow(n: number): Promise<void>;
15
+ /** tasks currently queued or running. */
16
+ size(): number;
17
+ }
18
+ /** a bounded-concurrency scheduler with optional per-key sequentialization. */
19
+ export declare function createScheduler(concurrency: number): Scheduler;
20
+ /** extract the default sequentialization key (chat id, falling back to the actor's user id). */
21
+ export declare function chatKey(update: Update): number | undefined;
22
+ export interface RunnerBot {
23
+ api: {
24
+ getUpdates(params?: Record<string, unknown>): Promise<Update[]>;
25
+ };
26
+ handleUpdate(update: Update): Promise<void>;
27
+ }
28
+ export interface RunnerOptions {
29
+ /** max updates processed at once. defaults to 50. */
30
+ concurrency?: number;
31
+ /** map an update to a key whose updates must stay ordered. defaults to chat id. `undefined` = no ordering. */
32
+ sequentializeBy?: (update: Update) => PropertyKey | undefined;
33
+ /** getUpdates batch size. defaults to 100. */
34
+ limit?: number;
35
+ /** long-poll timeout (seconds). defaults to 30. */
36
+ timeout?: number;
37
+ /** restrict update types (telegram `allowed_updates`). */
38
+ allowedUpdates?: string[];
39
+ /** called on a handler or polling error. */
40
+ onError?: (error: unknown, update?: Update) => void;
41
+ }
42
+ export interface RunnerHandle {
43
+ /** stop polling and wait for in-flight updates to drain. */
44
+ stop(): Promise<void>;
45
+ }
46
+ /** start driving `bot` concurrently. returns a handle whose `stop()` drains in-flight work. */
47
+ export declare function run(bot: RunnerBot, options?: RunnerOptions): RunnerHandle;
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAE5C;;;;;GAKG;AAEH,MAAM,WAAW,SAAS;IACzB,+FAA+F;IAC/F,MAAM,CAAC,GAAG,EAAE,WAAW,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACtE,kDAAkD;IAClD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,oFAAoF;IACpF,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,yCAAyC;IACzC,IAAI,IAAI,MAAM,CAAC;CACf;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAyF9D;AAED,gGAAgG;AAChG,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoB1D;AAED,MAAM,WAAW,SAAS;IACzB,GAAG,EAAE;QAAE,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;KAAE,CAAC;IACzE,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,aAAa;IAC7B,qDAAqD;IACrD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8GAA8G;IAC9G,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,WAAW,GAAG,SAAS,CAAC;IAC9D,8CAA8C;IAC9C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0DAA0D;IAC1D,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,4CAA4C;IAC5C,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,YAAY;IAC5B,4DAA4D;IAC5D,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACtB;AAID,+FAA+F;AAC/F,wBAAgB,GAAG,CAAC,GAAG,EAAE,SAAS,EAAE,OAAO,GAAE,aAAkB,GAAG,YAAY,CAsD7E"}
package/lib/index.js ADDED
@@ -0,0 +1,152 @@
1
+ /** a bounded-concurrency scheduler with optional per-key sequentialization. */
2
+ export function createScheduler(concurrency) {
3
+ let active = 0;
4
+ let pending = 0;
5
+ const slotWaiters = [];
6
+ const idleWaiters = [];
7
+ const belowWaiters = [];
8
+ const tails = new Map();
9
+ const acquire = () => new Promise((resolve) => {
10
+ if (active < concurrency) {
11
+ active++;
12
+ resolve();
13
+ }
14
+ else {
15
+ slotWaiters.push(() => {
16
+ active++;
17
+ resolve();
18
+ });
19
+ }
20
+ });
21
+ const release = () => {
22
+ active--;
23
+ slotWaiters.shift()?.();
24
+ };
25
+ const settle = () => {
26
+ pending--;
27
+ if (pending === 0)
28
+ for (const f of idleWaiters.splice(0))
29
+ f();
30
+ for (let i = belowWaiters.length - 1; i >= 0; i--) {
31
+ const w = belowWaiters[i];
32
+ if (w && pending < w.n) {
33
+ belowWaiters.splice(i, 1);
34
+ w.resolve();
35
+ }
36
+ }
37
+ };
38
+ const runTask = async (task) => {
39
+ await acquire();
40
+ try {
41
+ await task();
42
+ }
43
+ catch {
44
+ // tasks are expected to handle their own errors (the runner wraps handleUpdate)
45
+ }
46
+ finally {
47
+ release();
48
+ settle();
49
+ }
50
+ };
51
+ return {
52
+ submit(key, task) {
53
+ pending++;
54
+ if (key == null) {
55
+ void runTask(task);
56
+ return;
57
+ }
58
+ const prev = tails.get(key) ?? Promise.resolve();
59
+ const next = prev.then(() => runTask(task), () => runTask(task));
60
+ tails.set(key, next);
61
+ void next.finally(() => {
62
+ if (tails.get(key) === next)
63
+ tails.delete(key);
64
+ });
65
+ },
66
+ idle() {
67
+ return pending === 0
68
+ ? Promise.resolve()
69
+ : new Promise((resolve) => idleWaiters.push(resolve));
70
+ },
71
+ whenBelow(n) {
72
+ return pending < n
73
+ ? Promise.resolve()
74
+ : new Promise((resolve) => belowWaiters.push({ n, resolve }));
75
+ },
76
+ size() {
77
+ return pending;
78
+ },
79
+ };
80
+ }
81
+ /** extract the default sequentialization key (chat id, falling back to the actor's user id). */
82
+ export function chatKey(update) {
83
+ const msg = update.message ??
84
+ update.edited_message ??
85
+ update.channel_post ??
86
+ update.edited_channel_post ??
87
+ update.callback_query?.message ??
88
+ update.my_chat_member ??
89
+ update.chat_member ??
90
+ update.chat_join_request;
91
+ const chatId = msg?.chat?.id;
92
+ if (chatId != null)
93
+ return chatId;
94
+ return (update.callback_query?.from?.id ??
95
+ update.inline_query?.from?.id ??
96
+ update.poll_answer?.user?.id ??
97
+ undefined);
98
+ }
99
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
100
+ /** start driving `bot` concurrently. returns a handle whose `stop()` drains in-flight work. */
101
+ export function run(bot, options = {}) {
102
+ const concurrency = options.concurrency ?? 50;
103
+ const seqBy = options.sequentializeBy ?? chatKey;
104
+ const scheduler = createScheduler(concurrency);
105
+ let stopped = false;
106
+ let offset = 0;
107
+ const safe = async (update) => {
108
+ try {
109
+ await bot.handleUpdate(update);
110
+ }
111
+ catch (error) {
112
+ options.onError?.(error, update);
113
+ }
114
+ };
115
+ const loop = async () => {
116
+ while (!stopped) {
117
+ if (scheduler.size() >= concurrency)
118
+ await scheduler.whenBelow(concurrency);
119
+ if (stopped)
120
+ break;
121
+ let updates;
122
+ try {
123
+ updates = await bot.api.getUpdates({
124
+ offset,
125
+ limit: options.limit ?? 100,
126
+ timeout: options.timeout ?? 30,
127
+ ...(options.allowedUpdates ? { allowed_updates: options.allowedUpdates } : {}),
128
+ });
129
+ }
130
+ catch (error) {
131
+ if (stopped)
132
+ break;
133
+ options.onError?.(error);
134
+ await delay(3000);
135
+ continue;
136
+ }
137
+ for (const update of updates) {
138
+ offset = update.update_id + 1;
139
+ scheduler.submit(seqBy(update), () => safe(update));
140
+ }
141
+ }
142
+ };
143
+ const loopDone = loop();
144
+ return {
145
+ async stop() {
146
+ stopped = true;
147
+ await loopDone;
148
+ await scheduler.idle();
149
+ },
150
+ };
151
+ }
152
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,111 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { chatKey, createScheduler, run } from "./index.js";
4
+ const tick = () => new Promise((r) => setTimeout(r, 0));
5
+ function deferred() {
6
+ let resolve;
7
+ const promise = new Promise((r) => {
8
+ resolve = r;
9
+ });
10
+ return { promise, resolve };
11
+ }
12
+ test("scheduler: same key runs in order, never overlapping", async () => {
13
+ const s = createScheduler(5);
14
+ const log = [];
15
+ const a1 = deferred();
16
+ const a2 = deferred();
17
+ s.submit("A", async () => {
18
+ log.push("a1-start");
19
+ await a1.promise;
20
+ log.push("a1-end");
21
+ });
22
+ s.submit("A", async () => {
23
+ log.push("a2-start");
24
+ await a2.promise;
25
+ log.push("a2-end");
26
+ });
27
+ await tick();
28
+ assert.deepEqual(log, ["a1-start"]); // a2 waits for a1 (same key)
29
+ a1.resolve();
30
+ await tick();
31
+ assert.deepEqual(log, ["a1-start", "a1-end", "a2-start"]);
32
+ a2.resolve();
33
+ await s.idle();
34
+ assert.deepEqual(log, ["a1-start", "a1-end", "a2-start", "a2-end"]);
35
+ });
36
+ test("scheduler: different keys run concurrently up to the limit", async () => {
37
+ const s = createScheduler(2);
38
+ const started = [];
39
+ const gates = [deferred(), deferred(), deferred()];
40
+ for (let i = 0; i < 3; i++) {
41
+ s.submit(`k${i}`, async () => {
42
+ started.push(`k${i}`);
43
+ await gates[i]?.promise;
44
+ });
45
+ }
46
+ await tick();
47
+ assert.deepEqual(started, ["k0", "k1"]); // 3rd is blocked by the concurrency cap
48
+ gates[0]?.resolve();
49
+ await tick();
50
+ assert.deepEqual(started, ["k0", "k1", "k2"]); // freed slot lets k2 start
51
+ gates[1]?.resolve();
52
+ gates[2]?.resolve();
53
+ await s.idle();
54
+ });
55
+ test("scheduler: whenBelow gates backpressure", async () => {
56
+ const s = createScheduler(10);
57
+ const g = deferred();
58
+ for (let i = 0; i < 3; i++)
59
+ s.submit(undefined, async () => await g.promise);
60
+ assert.equal(s.size(), 3);
61
+ let below = false;
62
+ void s.whenBelow(2).then(() => {
63
+ below = true;
64
+ });
65
+ await tick();
66
+ assert.equal(below, false);
67
+ g.resolve();
68
+ await s.idle();
69
+ await tick();
70
+ assert.equal(below, true);
71
+ });
72
+ test("chatKey: pulls chat id from common update shapes", () => {
73
+ assert.equal(chatKey({ update_id: 1, message: { chat: { id: 7 } } }), 7);
74
+ assert.equal(chatKey({ update_id: 2, callback_query: { message: { chat: { id: 9 } } } }), 9);
75
+ assert.equal(chatKey({ update_id: 3, callback_query: { from: { id: 5 } } }), 5);
76
+ assert.equal(chatKey({ update_id: 4 }), undefined);
77
+ });
78
+ test("run: processes a batch, keeps per-chat order, advances offset, drains on stop", async () => {
79
+ const order = [];
80
+ const offsets = [];
81
+ let delivered = false;
82
+ const bot = {
83
+ api: {
84
+ async getUpdates(params) {
85
+ offsets.push(Number(params?.offset ?? 0));
86
+ if (!delivered) {
87
+ delivered = true;
88
+ return [
89
+ { update_id: 1, message: { chat: { id: 7 } } },
90
+ { update_id: 2, message: { chat: { id: 7 } } },
91
+ { update_id: 3, message: { chat: { id: 8 } } },
92
+ ];
93
+ }
94
+ await tick();
95
+ return [];
96
+ },
97
+ // biome-ignore lint/suspicious/noExplicitAny: test mock
98
+ },
99
+ async handleUpdate(u) {
100
+ order.push(u.update_id);
101
+ },
102
+ };
103
+ const handle = run(bot, { concurrency: 5, timeout: 0 });
104
+ await tick();
105
+ await tick();
106
+ await handle.stop();
107
+ assert.ok(order.includes(1) && order.includes(2) && order.includes(3));
108
+ assert.ok(order.indexOf(1) < order.indexOf(2), "same chat stays ordered");
109
+ assert.ok(offsets.includes(4), "offset advanced past the batch");
110
+ });
111
+ //# sourceMappingURL=index.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.js","sourceRoot":"","sources":["../src/index.test.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@yaebal/runner",
3
+ "version": "0.0.1",
4
+ "description": "yaebal runner — concurrent update processing with per-chat sequentialization.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "@yaebal/types": "0.0.1"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "latest"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "keywords": [
28
+ "telegram",
29
+ "telegram-bot",
30
+ "yaebal",
31
+ "runner",
32
+ "concurrency"
33
+ ],
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/neverlane/yaebal",
38
+ "directory": "packages/runner"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.json",
45
+ "typecheck": "tsc -p tsconfig.json --noEmit",
46
+ "test": "node --test lib"
47
+ }
48
+ }
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { chatKey, createScheduler, run } from "./index.js";
4
+
5
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0));
6
+
7
+ function deferred() {
8
+ let resolve!: () => void;
9
+
10
+ const promise = new Promise<void>((r) => {
11
+ resolve = r;
12
+ });
13
+
14
+ return { promise, resolve };
15
+ }
16
+
17
+ test("scheduler: same key runs in order, never overlapping", async () => {
18
+ const s = createScheduler(5);
19
+ const log: string[] = [];
20
+ const a1 = deferred();
21
+ const a2 = deferred();
22
+
23
+ s.submit("A", async () => {
24
+ log.push("a1-start");
25
+ await a1.promise;
26
+
27
+ log.push("a1-end");
28
+ });
29
+
30
+ s.submit("A", async () => {
31
+ log.push("a2-start");
32
+ await a2.promise;
33
+
34
+ log.push("a2-end");
35
+ });
36
+
37
+ await tick();
38
+ assert.deepEqual(log, ["a1-start"]); // a2 waits for a1 (same key)
39
+
40
+ a1.resolve();
41
+ await tick();
42
+ assert.deepEqual(log, ["a1-start", "a1-end", "a2-start"]);
43
+
44
+ a2.resolve();
45
+ await s.idle();
46
+ assert.deepEqual(log, ["a1-start", "a1-end", "a2-start", "a2-end"]);
47
+ });
48
+
49
+ test("scheduler: different keys run concurrently up to the limit", async () => {
50
+ const s = createScheduler(2);
51
+ const started: string[] = [];
52
+ const gates = [deferred(), deferred(), deferred()];
53
+
54
+ for (let i = 0; i < 3; i++) {
55
+ s.submit(`k${i}`, async () => {
56
+ started.push(`k${i}`);
57
+ await gates[i]?.promise;
58
+ });
59
+ }
60
+
61
+ await tick();
62
+ assert.deepEqual(started, ["k0", "k1"]); // 3rd is blocked by the concurrency cap
63
+
64
+ gates[0]?.resolve();
65
+ await tick();
66
+ assert.deepEqual(started, ["k0", "k1", "k2"]); // freed slot lets k2 start
67
+
68
+ gates[1]?.resolve();
69
+ gates[2]?.resolve();
70
+ await s.idle();
71
+ });
72
+
73
+ test("scheduler: whenBelow gates backpressure", async () => {
74
+ const s = createScheduler(10);
75
+ const g = deferred();
76
+
77
+ for (let i = 0; i < 3; i++) s.submit(undefined, async () => await g.promise);
78
+ assert.equal(s.size(), 3);
79
+
80
+ let below = false;
81
+ void s.whenBelow(2).then(() => {
82
+ below = true;
83
+ });
84
+
85
+ await tick();
86
+ assert.equal(below, false);
87
+
88
+ g.resolve();
89
+
90
+ await s.idle();
91
+ await tick();
92
+
93
+ assert.equal(below, true);
94
+ });
95
+
96
+ test("chatKey: pulls chat id from common update shapes", () => {
97
+ assert.equal(chatKey({ update_id: 1, message: { chat: { id: 7 } } } as never), 7);
98
+
99
+ assert.equal(
100
+ chatKey({ update_id: 2, callback_query: { message: { chat: { id: 9 } } } } as never),
101
+ 9,
102
+ );
103
+
104
+ assert.equal(chatKey({ update_id: 3, callback_query: { from: { id: 5 } } } as never), 5);
105
+ assert.equal(chatKey({ update_id: 4 } as never), undefined);
106
+ });
107
+
108
+ test("run: processes a batch, keeps per-chat order, advances offset, drains on stop", async () => {
109
+ const order: number[] = [];
110
+ const offsets: number[] = [];
111
+ let delivered = false;
112
+
113
+ const bot = {
114
+ api: {
115
+ async getUpdates(params?: Record<string, unknown>) {
116
+ offsets.push(Number(params?.offset ?? 0));
117
+
118
+ if (!delivered) {
119
+ delivered = true;
120
+
121
+ return [
122
+ { update_id: 1, message: { chat: { id: 7 } } },
123
+ { update_id: 2, message: { chat: { id: 7 } } },
124
+ { update_id: 3, message: { chat: { id: 8 } } },
125
+ ] as never;
126
+ }
127
+
128
+ await tick();
129
+ return [] as never;
130
+ },
131
+ // biome-ignore lint/suspicious/noExplicitAny: test mock
132
+ } as any,
133
+ async handleUpdate(u: { update_id: number }) {
134
+ order.push(u.update_id);
135
+ },
136
+ };
137
+
138
+ const handle = run(bot, { concurrency: 5, timeout: 0 });
139
+
140
+ await tick();
141
+ await tick();
142
+
143
+ await handle.stop();
144
+
145
+ assert.ok(order.includes(1) && order.includes(2) && order.includes(3));
146
+ assert.ok(order.indexOf(1) < order.indexOf(2), "same chat stays ordered");
147
+ assert.ok(offsets.includes(4), "offset advanced past the batch");
148
+ });
package/src/index.ts ADDED
@@ -0,0 +1,218 @@
1
+ import type { Update } from "@yaebal/types";
2
+
3
+ /**
4
+ * @yaebal/runner — drives a bot with CONCURRENT update processing instead of the
5
+ * built-in sequential long-poll. updates that share a key (the chat id, by
6
+ * default) still run strictly in order, so per-chat state (sessions) stays safe;
7
+ * unrelated chats run in parallel up to `concurrency`.
8
+ */
9
+
10
+ export interface Scheduler {
11
+ /** queue a task. tasks with the same non-null `key` run in submit order, never overlapping. */
12
+ submit(key: PropertyKey | undefined, task: () => Promise<void>): void;
13
+ /** resolves once nothing is queued or running. */
14
+ idle(): Promise<void>;
15
+ /** resolves once fewer than `n` tasks are queued or running (backpressure gate). */
16
+ whenBelow(n: number): Promise<void>;
17
+ /** tasks currently queued or running. */
18
+ size(): number;
19
+ }
20
+
21
+ /** a bounded-concurrency scheduler with optional per-key sequentialization. */
22
+ export function createScheduler(concurrency: number): Scheduler {
23
+ let active = 0;
24
+ let pending = 0;
25
+
26
+ const slotWaiters: (() => void)[] = [];
27
+ const idleWaiters: (() => void)[] = [];
28
+ const belowWaiters: { n: number; resolve: () => void }[] = [];
29
+ const tails = new Map<PropertyKey, Promise<void>>();
30
+
31
+ const acquire = () =>
32
+ new Promise<void>((resolve) => {
33
+ if (active < concurrency) {
34
+ active++;
35
+ resolve();
36
+ } else {
37
+ slotWaiters.push(() => {
38
+ active++;
39
+ resolve();
40
+ });
41
+ }
42
+ });
43
+
44
+ const release = () => {
45
+ active--;
46
+ slotWaiters.shift()?.();
47
+ };
48
+
49
+ const settle = () => {
50
+ pending--;
51
+
52
+ if (pending === 0) for (const f of idleWaiters.splice(0)) f();
53
+
54
+ for (let i = belowWaiters.length - 1; i >= 0; i--) {
55
+ const w = belowWaiters[i];
56
+
57
+ if (w && pending < w.n) {
58
+ belowWaiters.splice(i, 1);
59
+ w.resolve();
60
+ }
61
+ }
62
+ };
63
+
64
+ const runTask = async (task: () => Promise<void>) => {
65
+ await acquire();
66
+
67
+ try {
68
+ await task();
69
+ } catch {
70
+ // tasks are expected to handle their own errors (the runner wraps handleUpdate)
71
+ } finally {
72
+ release();
73
+ settle();
74
+ }
75
+ };
76
+
77
+ return {
78
+ submit(key, task) {
79
+ pending++;
80
+
81
+ if (key == null) {
82
+ void runTask(task);
83
+ return;
84
+ }
85
+
86
+ const prev = tails.get(key) ?? Promise.resolve();
87
+ const next = prev.then(
88
+ () => runTask(task),
89
+ () => runTask(task),
90
+ );
91
+
92
+ tails.set(key, next);
93
+ void next.finally(() => {
94
+ if (tails.get(key) === next) tails.delete(key);
95
+ });
96
+ },
97
+ idle() {
98
+ return pending === 0
99
+ ? Promise.resolve()
100
+ : new Promise((resolve) => idleWaiters.push(resolve));
101
+ },
102
+ whenBelow(n) {
103
+ return pending < n
104
+ ? Promise.resolve()
105
+ : new Promise((resolve) => belowWaiters.push({ n, resolve }));
106
+ },
107
+ size() {
108
+ return pending;
109
+ },
110
+ };
111
+ }
112
+
113
+ /** extract the default sequentialization key (chat id, falling back to the actor's user id). */
114
+ export function chatKey(update: Update): number | undefined {
115
+ const msg =
116
+ update.message ??
117
+ update.edited_message ??
118
+ update.channel_post ??
119
+ update.edited_channel_post ??
120
+ update.callback_query?.message ??
121
+ update.my_chat_member ??
122
+ update.chat_member ??
123
+ update.chat_join_request;
124
+
125
+ const chatId = msg?.chat?.id;
126
+ if (chatId != null) return chatId;
127
+
128
+ return (
129
+ update.callback_query?.from?.id ??
130
+ update.inline_query?.from?.id ??
131
+ update.poll_answer?.user?.id ??
132
+ undefined
133
+ );
134
+ }
135
+
136
+ export interface RunnerBot {
137
+ api: { getUpdates(params?: Record<string, unknown>): Promise<Update[]> };
138
+ handleUpdate(update: Update): Promise<void>;
139
+ }
140
+
141
+ export interface RunnerOptions {
142
+ /** max updates processed at once. defaults to 50. */
143
+ concurrency?: number;
144
+ /** map an update to a key whose updates must stay ordered. defaults to chat id. `undefined` = no ordering. */
145
+ sequentializeBy?: (update: Update) => PropertyKey | undefined;
146
+ /** getUpdates batch size. defaults to 100. */
147
+ limit?: number;
148
+ /** long-poll timeout (seconds). defaults to 30. */
149
+ timeout?: number;
150
+ /** restrict update types (telegram `allowed_updates`). */
151
+ allowedUpdates?: string[];
152
+ /** called on a handler or polling error. */
153
+ onError?: (error: unknown, update?: Update) => void;
154
+ }
155
+
156
+ export interface RunnerHandle {
157
+ /** stop polling and wait for in-flight updates to drain. */
158
+ stop(): Promise<void>;
159
+ }
160
+
161
+ const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
162
+
163
+ /** start driving `bot` concurrently. returns a handle whose `stop()` drains in-flight work. */
164
+ export function run(bot: RunnerBot, options: RunnerOptions = {}): RunnerHandle {
165
+ const concurrency = options.concurrency ?? 50;
166
+ const seqBy = options.sequentializeBy ?? chatKey;
167
+ const scheduler = createScheduler(concurrency);
168
+
169
+ let stopped = false;
170
+ let offset = 0;
171
+
172
+ const safe = async (update: Update) => {
173
+ try {
174
+ await bot.handleUpdate(update);
175
+ } catch (error) {
176
+ options.onError?.(error, update);
177
+ }
178
+ };
179
+
180
+ const loop = async () => {
181
+ while (!stopped) {
182
+ if (scheduler.size() >= concurrency) await scheduler.whenBelow(concurrency);
183
+ if (stopped) break;
184
+
185
+ let updates: Update[];
186
+ try {
187
+ updates = await bot.api.getUpdates({
188
+ offset,
189
+ limit: options.limit ?? 100,
190
+ timeout: options.timeout ?? 30,
191
+ ...(options.allowedUpdates ? { allowed_updates: options.allowedUpdates } : {}),
192
+ });
193
+ } catch (error) {
194
+ if (stopped) break;
195
+
196
+ options.onError?.(error);
197
+ await delay(3000);
198
+
199
+ continue;
200
+ }
201
+
202
+ for (const update of updates) {
203
+ offset = update.update_id + 1;
204
+ scheduler.submit(seqBy(update), () => safe(update));
205
+ }
206
+ }
207
+ };
208
+
209
+ const loopDone = loop();
210
+
211
+ return {
212
+ async stop() {
213
+ stopped = true;
214
+ await loopDone;
215
+ await scheduler.idle();
216
+ },
217
+ };
218
+ }