@yaebal/runner

concurrent update processing — per-chat order preserved, unrelated chats in parallel

install

terminal
pnpm add @yaebal/runner

usage

run(bot) replaces bot.start(). it polls getUpdates in batches and dispatches to a bounded pool, so one slow handler no longer blocks the whole queue. it returns a RunnerHandle whose stop() halts polling and waits for in-flight updates to drain.

bot.ts
import { run } from "@yaebal/runner";

// instead of bot.start(): drive the bot concurrently
const handle = run(bot, { concurrency: 50 });

// later, drain in-flight work and stop:
process.once("SIGINT", () => handle.stop());

per-chat ordering

updates that share a key run strictly in submit order and never overlap, so per-chat state (sessions) stays race-free; unrelated chats run in parallel up to concurrency. the default key comes from chatKey, which resolves the chat id from any update type and falls back to the actor's user id (callback queries, inline queries, poll answers). pass your own sequentializeBy to change it, or return undefined to disable ordering.

options.ts
import { run, chatKey } from "@yaebal/runner";

run(bot, {
  concurrency: 100,
  sequentializeBy: chatKey,          // default — chat id, falling back to actor's user id
  limit: 100,                        // getUpdates batch size
  timeout: 30,                       // long-poll seconds
  allowedUpdates: ["message"],       // telegram allowed_updates
  onError: (err, update) => log.error(update?.update_id, err),
});

// undefined disables ordering entirely (everything parallel):
run(bot, { sequentializeBy: () => undefined });

the scheduler

the core is a reusable bounded-concurrency scheduler with per-key sequentialization, exported as createScheduler(concurrency) in case you want it directly — for outbound jobs, migrations, anything that needs ordered-by-key parallelism with backpressure.

scheduler.ts
import { createScheduler } from "@yaebal/runner";

const s = createScheduler(8);                 // bound concurrency to 8
s.submit("chat-42", () => doWork());          // same key → strict order; different keys → parallel
s.submit(undefined, () => fireAndForget());   // null/undefined key → unordered

await s.whenBelow(4);                          // backpressure: wait until < 4 in flight
console.log(s.size());                         // queued + running
await s.idle();                               // resolves when everything drains

api

exportsignaturedescription
run(bot: RunnerBot, options?: RunnerOptions) => RunnerHandledrive the bot with concurrent polling
createScheduler(concurrency: number) => Schedulerbounded-concurrency, per-key-ordered queue
chatKey(update: Update) => number | undefineddefault sequentialization key (chat id → user id)
RunnerOptionsinterfacesee below
RunnerHandle{ stop(): Promise<void> }stop polling, drain in-flight
RunnerBotinterfacethe bot surface run needs (api.getUpdates, handleUpdate)
Schedulerinterfacesubmit / idle / whenBelow / size

RunnerOptions

fieldtypedefaultdescription
concurrencynumber50max updates processed at once
sequentializeBy(update) => PropertyKey | undefinedchatKeykey whose updates stay ordered; undefined result = no ordering
limitnumber100getUpdates batch size
timeoutnumber30long-poll timeout (seconds)
allowedUpdatesstring[]restrict update types
onError(error, update?) => voidhandler / polling error callback

Scheduler

methodsignaturedescription
submit(key: PropertyKey | undefined, task: () => Promise<void>) => voidqueue a task; tasks sharing a non-null key run in submit order
idle() => Promise<void>resolves once nothing is queued or running
whenBelow(n: number) => Promise<void>resolves once fewer than n tasks are queued/running (backpressure)
size() => numbertasks currently queued or running
i/o-bound by default. the runner already saturates the event loop for i/o-bound handlers, which is most bots. for genuinely CPU-heavy work, offload it with @yaebal/workers.