@chamvi/channel-cron

中文文档
0.8.0发布于 2026/11/09Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 3465

The chamvi cron channel: fires cron subscriptions once per tick across replicas, on hubwire.

原始使用文档
文档目录10 节
  1. Installation
  2. Usage
  3. How a tick works
  4. Overlap policies
  5. Timezones
  6. Shutdown
  7. One-shot jobs
  8. Options
  9. API
  10. License

@chamvi/channel-cron

@chamvi/channel-cron

The chamvi cron channel: fires cron subscriptions once per tick across replicas, on hubwire.
Every replica registers the same channel. A lock in the store elects one per tick,
and the same package fires a subscription one-shot for a Kubernetes CronJob.

npm version CI License


Table of Contents

Installation

npm install @chamvi/channel-cron @chamvi/core --save

ESM only, Node.js >=26.0.0. @chamvi/core is a peer dependency; hubwire (croner inside) and cron-parser are installed with the package.

Usage

cron.on() subscribes an agent to the cron channel; cronChannel() is the channel the engine starts:

import type { LanguageModel } from 'ai';
import {
    Engine,
    createMemoryStore,
    defineModelAgent,
} from '@chamvi/core';
import { cron, cronChannel } from '@chamvi/channel-cron';

export async function startScheduling(model: LanguageModel) {
    const sweep = defineModelAgent({
        name: 'sweep',
        model,
        instructions: 'Close stale issues older than 30 days.',
        // every 30 minutes, Berlin time; skipped while the previous run is active
        channels: [cron.on('*/30 * * * *', { timezone: 'Europe/Berlin' })],
    });

    // createMemoryStore is per process; with several replicas, pass one shared store
    // (e.g. createDatabaseStore) so its lock fires each tick once across them
    const engine = new Engine({
        store: createMemoryStore(),
        agents: [sweep],
        // every replica registers the same channel; it reads agents and store from the engine
        channels: [cronChannel()],
    });

    // starts the cron channel after recovering runs
    await engine.start();

    // shutdown: drain() stops the channel first, then lets the runs in flight finish
    // await engine.drain();
    return { engine };
}

cron.on(expression, options?) validates the expression (cron-parser) and the timezone and throws InvalidAgentDefinitionError for one it refuses. engine.start() starts the channel after recovering runs: the subscription of an agent passed in code that the scheduler cannot use (an expression croner refuses) fails engine.start() with InvalidAgentDefinitionError naming the agent and subscription, an agent folder's goes to onError and leaves that agent unscheduled. Subscriptions of other channels are ignored. The channel follows agents registered, replaced and removed at runtime.

A second cron channel (e.g. one per region) needs its own name; its on() makes subscriptions only it fires:

const eu = cronChannel({ name: 'cron-eu' });
defineModelAgent({ name: 'digest', channels: [eu.on('0 8 * * *', { timezone: 'Europe/Berlin' })] /* ... */ });
new Engine({ channels: [cronChannel(), eu] /* ... */ });

How a tick works

  • One component per subscription. Each becomes a hubwire scheduled component named cron:<agent>:<subscriptionId>.
  • One replica per tick. Before a tick runs, the replica acquires the store lock cron:<agent>:<subscriptionId>:<scheduledAt ISO> (through ILockRepository) for lockTtlMs (default 10 minutes). The key is never released: a replica that fires the same tick late cannot win it again.
  • Deduplicated dispatch. The winner dispatches through the channel (envelope channel = the channel's name, trigger = the subscription id) with payload options.payload ?? { scheduledAt: '<ISO>' } and the idempotency key <subscriptionId>@<scheduledAt ISO>, so even a second firing of the same tick returns the existing run. A subscription's own idempotencyKey() is ignored for cron.
  • Never on start. Starting or restarting a replica fires nothing; the first dispatch is at the next scheduled tick.
  • No catch-up. A tick that falls into a restart, or whose timer fires lockTtlMs or more late (a suspended process), is not fired later.
  • Failures are reported, not thrown. A failing lock store, overlap check or dispatch reaches onError(error, { agent, subscription }); the next tick runs normally.

Overlap policies

cron.on(expression, { overlap }) sets the policy; the channel enforces it before dispatching. "Active" means a run of the same subscription in queued, running, waiting or interrupted.

PolicyTick is skipped whenDefault concurrencyKey
skip (default)any run of the subscription is activethe subscription id
queue-onea run of the subscription is still queuedthe subscription id
allownevernone

A concurrencyKey() on the subscription wins over the default. The default key is what makes queue-one wait: the engine starts one run per agent and key at a time, so the queued run starts when the active one settles. It also makes a race harmless: a tick that checks while the previous tick's dispatch is still in flight may dispatch a second run, which then waits instead of running alongside.

Timezones

cron.on(expression, { timezone: 'Europe/Berlin' }) takes an IANA zone; without one, the process's local zone applies. At DST changes:

  • A local time that occurs twice (fall-back) fires once, at its first occurrence.
  • A local time the spring-forward change skips fires once, an hour later in local time (30 2 * * * fires at 03:30).

Shutdown

engine.drain() (and engine.stop()) stop the channel first (no new ticks; ticks in flight finish), then drain the runs:

await engine.drain(); // stops the cron channel, waits for running runs, then interrupts the rest

One-shot jobs

runJob() fires one cron subscription once and waits for its run, for a Kubernetes CronJob instead of a long-lived replica:

const { exitCode } = await runJob({ engine, agent: sweep });
process.exitCode = exitCode;

It dispatches the tick at the current time (the overlap policy applies), starts the engine with start({ channels: false }) (so a job never starts cron schedules or other channels) and runs one approval tick (also when the tick is skipped), waits until the run is completed, failed, cancelled or waiting, or queued behind a run of the same subscription that waits for an approval, then calls engine.drain(). The engine must hold the subscription's cron channel (it refuses the agent otherwise).

OutcomestatusexitCode
The run completedcompleted0
The run waits for an approvalwaiting0
The run waits behind a run paused for an approval (queue-one)queued0
The overlap policy skipped the tickskipped0
The run failed or was cancelledfailed, cancelled1

A run that pauses for an approval does not hold the pod: the job returns, the approval is answered (or times out), and the answered run is executed by a long-lived replica or by the next job pod, which starts the engine even when its own tick is skipped. That pod also recovers a run whose pod was killed and denies approvals past their timeout. Caveats:

  • engine.start() recovers and executes every queued run of every agent in the shared store, not only the cron run.
  • drain() interrupts whatever still runs after drainTimeoutMs. runJob waits only for the run it dispatched, so a leftover run is finished by a long-lived replica or by a later job pod, within that pod's drainTimeoutMs.
  • Size drainTimeoutMs to the job's runs, and prefer a long-lived deployment with cronChannel() when runs are long.

Options

cronChannel(options):

OptionTypeDefaultDescription
namestring'cron'The channel name; on() subscribes to it
ownerstring`${hostname}-${pid}`Lock owner
lockTtlMsnumber600_000How long a tick's lock key is held; a tick firing this late or later is skipped
onError(error, { agent, subscription }) => voidconsole.errorFailing ticks and an agent folder's unschedulable subscription

cron.on(expression, options):

OptionTypeDefaultDescription
timezonestringlocal zoneIANA zone
overlap'skip' | 'queue-one' | 'allow''skip'See Overlap policies
payloadunknown{ scheduledAt }The run's payload
idstring`cron:${expression}`Subscription id, unique per agent

runJob(options):

OptionTypeDefaultDescription
engineIEnginerequiredNot started yet; started without channels and drained by the job, also when the tick is skipped. Its clock gives scheduledAt and drives the poll
agentAgentrequiredThe agent to fire
subscriptionstringthe only cron subscriptionCron subscription id; required when the agent has several
pollMsnumber1_000Poll of engine.getRun, which also sees a run finished by another replica
drainTimeoutMsnumber30_000Passed to engine.drain()
signalAbortSignalnoneStops the job (see the type's docs)

API

ExportKindPurpose
cronChannelfunctionThe cron channel; engine.start() starts it, drain() stops it; .on() subscribes to it
cronobjectcron.on(expression, options?): a subscription of the default cron channel
runJobfunctionFires one cron subscription once and waits for its run
fireCronSubscriptionfunctionOne tick: overlap check and dispatch, for a given scheduledAt
readCronOptionsfunctionReads and validates the options cron.on() stores on a subscription
assertSchedulablefunctionThrows for a subscription of a cron channel (default cron) the scheduler cannot use
createRepositoryScheduleLockfunctionA hubwire IScheduleLock over core's ILockRepository
CRON_CHANNEL_NAMEconstant'cron'
SCHEDULE_LOCK_TTL_MSconstantThe default lock TTL, 600_000

Types: CronChannel, CronChannelOptions, CronChannelErrorContext, CronSubscriber, CronSubscriptionOptions, OverlapPolicy, RunJobOptions, RunJobResult, CronOptions, CronTickContext, CronTickResult, CronTickDispatched, CronTickSkipped.

License

Published under Apache License 2.0.

版本与发布记录

@chamvi/channel-cron 共发布 15 个版本,首次发布于 2026/10/25;过去 12 个月发布了 15 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.8.0latest2026/11/09今天
0.7.32026/11/063 天前
0.7.22026/11/053 天前
0.7.12026/11/054 天前
0.7.02026/11/045 天前
0.6.12026/11/045 天前

关于 @chamvi/channel-cron 的常见问题

@chamvi/channel-cron 是什么?

The chamvi cron channel: fires cron subscriptions once per tick across replicas, on hubwire.

如何安装 @chamvi/channel-cron?

在项目目录运行 npm install @chamvi/channel-cron,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@chamvi/core(^0.8.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@chamvi/channel-cron 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@chamvi/channel-cron 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@chamvi/channel-cron 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=26.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@chamvi/channel-cron 可以商用吗?

@chamvi/channel-cron 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@chamvi/channel-cron 的下载量有多少?

根据 npm 官方下载统计,@chamvi/channel-cron 在 2026/10/10 – 2026/11/08 这 30 天内被下载 3,465 次,日均约 116 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@chamvi/channel-cron 还在维护吗?

最新版本 0.8.0 发布于 2026/11/09(今天);至今共发布 15 个版本,其中过去 12 个月发布了 15 个;首次发布于 2026/10/25。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。