跳到正文
曾亮懿 ZengLiangYi Blog

p-queue 任务队列:限速、重试与取消各自保证什么

对照 ChatCrystal 的队列与状态跟踪,区分任务限速和请求限速,并用离线练习验证清空队列不会中断在途任务。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

队列中的一个任务可能调用摘要模型、多个 Embedding 请求和关系模型。限制每秒启动一个任务,不等于限制每秒只有一个模型请求。本文沿着 ChatCrystal 的队列、重试和状态跟踪阅读,再用一个离线练习验证取消的实际边界。

限制的是队列任务的启动

固定版本的 queue/index.ts使用 p-queue,锁文件解析为 9.3.3,配置核心如下。这个片段依赖现有导入,不是独立程序。

new PQueue({
  concurrency: 1,
  intervalCap: 1,
  interval: 1000,
  carryoverIntervalCount: true,
});

concurrency 限制同时执行的队列任务;intervalCap 与 interval 限制时间窗口内启动的任务。carryoverIntervalCount 把跨窗口尚未结束的任务计入下一个窗口,不是累积或清除“未用完的额度”。当前配置也没有启用滚动窗口的 strict 模式。

任务函数内部可以发起多个请求,AI SDK 和应用层还各有重试。因此这份配置不能证明某个供应商的全局配额一定满足,也不会限制另一个进程、未入队入口或共用 API key 的客户端。调整并发前应先列出实际请求路径;多进程写入和存储竞争也不能从 concurrency=1 推导为不存在。

外层重试在槽位内部等待

enqueueWithRetry 默认 maxRetries 为 3,循环包含首次尝试,最多执行四次任务函数。仅当错误消息包含 '429' 且还有重试机会时,等待 1000、2000、4000 毫秒后继续。等待发生在任务内部,该任务仍占用执行槽位。

这是错误文本匹配,不是结构化 HTTP 状态检查:其他错误文字也可能包含 429,真实限流错误也可能被包装成不含这个数字的消息。代码没有读取 Retry-After,也没有加入随机抖动。应把这些作为当前策略的限制,而不是称其为最可靠的错误识别方式。

外层最多四次任务执行还不等于最多四个 HTTP 请求。任务内部的 AI SDK 调用可能自行重试;多块 Embedding 会产生多次请求。格式、业务和保存错误是否重试,需要按相应入口逐项检查,参见结构化 Prompt 的失败类型。

清空等待队列不会中断在途任务

在新临时目录安装 p-queue@9.3.3,保存以下 lesson.mjs,用 Node 24+ 运行。它是教学实例,只验证执行槽位和 clear,不使用应用的全局队列、数据库或模型,也没有测试实际每秒限速。

npm init -y
npm install --ignore-scripts --no-audit --no-fund p-queue@9.3.3
node lesson.mjs
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import PQueue from 'p-queue';

const queue = new PQueue({ concurrency: 1, autoStart: false });
let release;
let entered;
let executions = 0;
const gate = new Promise((resolve) => {
  release = resolve;
});
const started = new Promise((resolve) => {
  entered = resolve;
});
const active = queue.add(async () => {
  executions++;
  entered();
  await gate;
  return 'finished';
});
const waiting = queue.add(async () => {
  executions++;
  return 'waiting';
});
queue.start();
await started;
assert.equal(queue.pending, 1);
assert.equal(queue.size, 1);
queue.clear();
assert.equal(queue.size, 0);
assert.equal(queue.pending, 1);
release();
assert.equal(await active, 'finished');
await queue.onIdle();
assert.equal(executions, 1);
const waitingState = await Promise.race([
  waiting.then(
    () => 'resolved',
    () => 'rejected'
  ),
  delay(20).then(() => 'unsettled'),
]);
assert.equal(waitingState, 'unsettled');
console.log('clear removes waiting work; active finishes; removed promise stays pending');

把 executions 的预期从 1 改成 2,程序会失败;恢复后通过。pending 是正在执行的数量,size 是等待数量。onIdle 可以在已清空队列且在途任务结束后完成,但被移除任务的 add Promise 仍不会因此完成。20 毫秒只用于观察这次构造实例,不是性能指标;长期不完成的行为依据该版本 clear 的实现。

若需要取消时结算返回的 Promise,可另外设计 AbortSignal 路径,并让运行中的工作响应信号;这不是当前 cancelQueue 已实现的能力。不能直接 await 被 clear 移除的 Promise 来等待“全部取消完成”。

状态记录与执行队列是两份数据

tracker.ts记录 queued、processing、completed、failed,并保存开始和结束时间。cancelQueue 先清空等待队列,再把跟踪器中 queued 的记录标为 failed、错误为“已取消”,processing 继续运行。

跟踪器的 Map 按 ID 忽略重复 add,但这不自动阻止 p-queue 添加重复任务;入口处是否检查 active ID 要单独确认。没有活动任务时,它安排五秒后清空记录,因此这个快照不是持久化任务日志,重启后也不能恢复等待任务。队列和记录一致性需要同时检查。

SSE 传递快照,不能代替持久化

notes.ts的 /api/queue/stream 每秒发送 status 事件,包含汇总、任务和已完成任务的 duration。total 大于零且没有 queued/processing 时,发送 done 并结束连接;请求断开也清理计时器。

这条路径没有用于恢复历史事件的 event ID 或持久化重放。客户端断开、任务记录五秒后清空、服务重启,都需要与正常结束分开验收。完成快照可帮助界面展示,却不能证明每次外部请求都被记录。

纯内存队列减少了当前单进程调度所需的基础设施,同时需要明确丢失、取消和退出边界。相关用法与 API 说明可参考 p-queue 官方仓库;本次练习只建立 clear 的行为证据,限速与真实模型链路仍需各自验证。

分享:

相关文章

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。