npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@seekcx/expo-durable-queue

v0.1.1

Published

A durable, step-based task queue for Expo React Native applications.

Readme

@seekcx/expo-durable-queue

面向 Expo iOS/Android 应用的端侧持久化任务队列。它将任务、步骤结果和依赖关系保存在 expo-sqlite 中。当应用进程意外终止并重新启动后,未完成的工作可以从已持久化的步骤继续,而不必从头执行整个流程。

适合上传、同步、媒体处理等需要重试、断点恢复或由多个步骤组成的端侧工作流。

特性

  • SQLite WAL 持久化和自动数据库迁移
  • 类型安全的任务输入和 Standard Schema 运行时校验,可直接使用 Zod 等校验库
  • runStep 步骤结果持久化与重放
  • 步骤超时、最大尝试次数和带 full jitter 的指数退避
  • runTask 子任务依赖、依赖环检测和最大依赖深度限制
  • fastlongRunning 两个独立并发池
  • 根任务取消、失败后重试和全新工作流重启
  • enqueueKey 幂等入队
  • 任务状态、任务图和执行事件查询
  • 成功与取消工作流的自动过期清理
  • 单数据库协调器租约,避免多个队列实例同时消费同一数据库

安装

先为当前 Expo SDK 安装兼容的 expo-sqlite,再安装队列:

npx expo install expo-sqlite
npm install @seekcx/expo-durable-queue

也可以使用 Bun:

bun add @seekcx/expo-durable-queue

本库要求 expo-sqlite >= 15,面向 Expo 原生 iOS/Android 运行环境。

快速开始

1. 定义任务

任务定义需要稳定的 id 和正整数 version。输入 schema 同时提供 TypeScript 类型推导和运行时校验。

import { z } from "zod";
import { defineTask, NonRetryableError } from "@seekcx/expo-durable-queue";

export const uploadAsset = defineTask({
  id: "upload-asset",
  version: 1,
  queue: "longRunning",
  input: z.object({
    assetId: z.string(),
    uri: z.string(),
  }),
  retry: {
    maxAttempts: 5,
    initialDelayMs: 1_000,
    maxDelayMs: 60_000,
  },
  run: async (input, ctx) => {
    const uploadUrl = await ctx.runStep("create-upload", async ({ signal }) => {
      const response = await fetch("https://api.example.com/uploads", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ assetId: input.assetId }),
        signal,
      });

      if (response.status === 400) {
        throw new NonRetryableError("服务端拒绝了上传请求");
      }
      if (!response.ok) throw new Error(`创建上传失败: ${response.status}`);

      const data = (await response.json()) as { uploadUrl: string };
      return data.uploadUrl;
    });

    await ctx.runStep(
      "upload-file",
      async ({ signal, attempt }) => {
        await uploadFile(input.uri, uploadUrl, { signal, attempt });
      },
      { timeoutMs: 2 * 60_000 },
    );

    await ctx.runStep("confirm-upload", async ({ signal }) => {
      const response = await fetch(
        `https://api.example.com/assets/${input.assetId}/complete`,
        { method: "POST", signal },
      );
      if (!response.ok) throw new Error(`确认上传失败: ${response.status}`);
    });
  },
});

2. 创建并启动队列

所有可能被执行或恢复的任务定义都必须在队列启动前注册。任务注册后可以直接通过自身的 dispatch() 方法入队,无需从 feature 模块引用队列实例。

import { createQueue } from "@seekcx/expo-durable-queue";
import { uploadAsset } from "./tasks";

export const queue = await createQueue({
  databaseName: "durable-queue.db",
  concurrency: {
    fast: 4,
    longRunning: 1,
  },
});

queue.register(uploadAsset);
await queue.start();

应用每次启动时都应创建并启动队列。start() 会恢复上次进程中断时正在执行的任务。对于同一个数据库,同一时刻只能有一个已启动的队列协调器。

3. 入队并等待结果

const handle = await uploadAsset.dispatch({
  assetId: "asset-42",
  uri: "file:///data/user/0/app/cache/photo.jpg",
});

console.log(handle.id);

try {
  await handle.done();
  console.log("上传完成");
} catch (error) {
  console.error("上传失败或已取消", error);
}

任务无需调用 done() 才会执行。done() 只是等待任务进入 SUCCEEDEDFAILEDCANCELLED 终态。

持久化步骤

ctx.runStep(stepKey, handler, options?) 是构建可恢复工作流的核心:

  • 步骤成功后,返回值会写入 SQLite。
  • 任务因后续步骤失败、应用重启或手动重试而再次运行时,成功步骤不会重新执行,而是直接返回已保存的结果。
  • 返回值必须是 JSON 可序列化值或 undefined。不要返回 DateMap、类实例、函数或循环引用对象。
  • stepKey 必须稳定、非空,并且在同一次任务执行中只能使用一次。
  • signal 会在超时、取消、队列停止或协调器租约丢失时触发,应传递给 fetch 等可取消操作。
  • attempt1 开始,可用于日志和服务端幂等键。

步骤必须顺序等待:

const profile = await ctx.runStep("fetch-profile", fetchProfile);
await ctx.runStep("save-profile", () => saveProfile(profile));

不要并发调用上下文操作,也不要遗漏 await

// 不支持:同一个任务中的上下文操作不能并发执行。
await Promise.all([
  ctx.runStep("one", doOne),
  ctx.runStep("two", doTwo),
]);

重试策略

普通步骤异常会按照重试策略重新尝试。任务级 retry 是所有步骤的默认值,单个步骤可以覆盖其中任意字段:

await ctx.runStep("request", makeRequest, {
  timeoutMs: 15_000,
  retry: {
    maxAttempts: 8,
    initialDelayMs: 500,
    maxDelayMs: 30_000,
  },
});

重试延迟采用指数增长上限和 full jitter。NonRetryableError、无法持久化的步骤结果以及任务取消不会重试。retry 配置作用于步骤;在 runStep 之外直接抛出的异常会立即使任务失败。

子任务与工作流

使用 ctx.runTask 将较大的流程拆成可独立调度和恢复的子任务:

const generateThumbnail = defineTask({
  id: "generate-thumbnail",
  version: 1,
  queue: "longRunning",
  input: z.object({ assetId: z.string() }),
  run: async ({ assetId }, ctx) => {
    await ctx.runStep("generate", ({ signal }) => generate(assetId, signal));
  },
});

const syncAsset = defineTask({
  id: "sync-asset",
  version: 1,
  input: z.object({ assetId: z.string() }),
  run: async ({ assetId }, ctx) => {
    await ctx.runTask("thumbnail", generateThumbnail, {
      input: { assetId },
    });
    await ctx.runTask("upload", uploadAsset, {
      input: { assetId, uri: `file:///assets/${assetId}.jpg` },
    });
  },
});

父任务会持久化等待子任务。恢复时,相同 callKey 会复用已经创建的子任务。callKey 必须稳定且在一次任务执行中唯一;同一个 key 不能在重放时改为另一项任务定义。队列会拒绝任务定义环和超过 maxDependencyDepth 的依赖链。

入队去重

为重复提交的业务操作指定 enqueueKey

const handle = await uploadAsset.dispatch(
  { assetId: "asset-42", uri: "file:///photo.jpg" },
  { enqueueKey: "upload:asset-42" },
);

同一任务定义的同一版本和同一 enqueueKey 只会创建一个根任务,并返回已有任务的 handle。这个去重记录会一直存在到对应工作流被清理;重复入队不会替换已有任务的输入。

任务控制

enqueue() 返回 TaskHandle

const snapshot = await handle.status();

await handle.cancel();  // 取消根任务以及仍未结束的子任务和步骤
await handle.retry();   // 仅适用于 FAILED 根任务,保留成功步骤并重试失败部分

const freshHandle = await handle.restart(); // 使用原输入创建全新的工作流
  • status() 获取当前任务快照。
  • done() 等待任务完成;失败或取消时抛出 DurableQueueError
  • cancel() 只允许用于根任务。已经提交到外部系统的副作用无法由队列撤销。
  • retry() 只允许用于失败的根任务,会保留已成功的步骤和子任务。
  • restart() 使用原始输入创建新的根任务,所有步骤从头执行。

查询与事件

const task = await queue.getTaskSnapshot(handle.id);

const graph = await queue.getTaskGraph(handle.id);
// graph.tasks: 根任务和全部子任务
// graph.dependencies: 父子任务依赖边

const failedUploads = await queue.listRootTasks({
  state: "FAILED",
  definitionId: "upload-asset",
  limit: 50,
});

const unsubscribe = queue.subscribe((event) => {
  console.log(event.type, event.taskId);
});

unsubscribe();

事件监听器适合刷新 UI 和记录日志,但事件本身不会持久化。需要可靠状态时,应使用任务快照或任务图查询。

常见任务状态:

| 状态 | 含义 | | --- | --- | | QUEUED | 等待 worker 执行 | | RUNNING | 正在执行任务代码 | | WAITING_RETRY | 等待步骤到达下次重试时间 | | WAITING_DEPENDENCY | 等待子任务完成 | | BLOCKED_DEFINITION | 数据库中的任务定义未在当前版本应用中注册 | | CANCEL_REQUESTED | 已请求取消,等待执行停止 | | SUCCEEDED | 执行成功 | | FAILED | 执行失败 | | CANCELLED | 已取消 |

配置

const queue = await createQueue({
  databaseName: "durable-queue.db",
  concurrency: { fast: 4, longRunning: 1 },
  maxDependencyDepth: 32,
  retention: {
    succeededMs: 7 * 24 * 60 * 60 * 1_000,
    cancelledMs: 7 * 24 * 60 * 60 * 1_000,
  },
  scanIntervalMs: 250,
  leaseMs: 15_000,
});

queue.register(syncAsset, uploadAsset, generateThumbnail);
await queue.start();

| 选项 | 默认值 | 说明 | | --- | --- | --- | | databaseName | 必填 | expo-sqlite 数据库文件名 | | concurrency.fast | 4 | 普通任务 worker 数量 | | concurrency.longRunning | 1 | 耗时任务 worker 数量 | | maxDependencyDepth | 32 | 最大子任务依赖深度 | | retention.succeededMs | 7 天 | 成功根任务及其整棵任务树的保留时间 | | retention.cancelledMs | 7 天 | 取消根任务及其整棵任务树的保留时间 | | scanIntervalMs | 250 | 无任务时的扫描间隔 | | leaseMs | 15000 | 单协调器租约时长 |

cleanup() 可立即执行一次过期数据清理并返回删除的根任务数量。队列启动后也会定期自动清理。

生命周期

queue.register(syncAsset, uploadAsset);
await queue.start();

// 暂停 worker,并等待当前执行退出;之后可以再次 start()。
await queue.stop();

// 停止队列并关闭数据库。close() 后不要再使用该实例。
await queue.close();

register() 必须在第一次 start() 前调用。同一个任务定义对象同一时刻只能注册到一个 queue;close() 会解除绑定。start()stop() 都是幂等的,停止后再次启动不需要重新注册。库目前不会自动绑定 React 组件或 Expo AppState 生命周期,应用需要自行决定何时启动和停止队列。

该队列不是系统级后台任务调度器:只有在应用 JS 进程仍在运行且队列已启动时,worker 才能执行。进程被系统终止后,数据不会丢失,但任务要等到应用下次启动队列才会恢复。如果需要系统唤醒能力,应与 Expo BackgroundTask 等平台机制组合使用,并遵守平台后台执行限制。

可靠性与幂等性

步骤成功结果可以避免恢复后重复执行已经提交的步骤,但无法为外部副作用提供 exactly-once 保证。例如网络请求已被服务端接受后,应用可能在 SQLite 提交结果前终止,此时恢复后会再次请求。

对上传、扣款、创建资源等外部操作,应使用稳定的幂等键:

await ctx.runStep("charge", async ({ signal }) => {
  await chargeCustomer({
    orderId: input.orderId,
    idempotencyKey: `${ctx.taskId}:charge`,
    signal,
  });
});

其他建议:

  • 保持任务 idversionstepKeycallKey 稳定。
  • 修改会影响重放语义的任务代码时提升 version,并继续注册仍可能存在于数据库中的旧版本定义。
  • 在 handler 内检查或透传 AbortSignal,尽快响应取消与超时。
  • 不要依赖任务函数中的内存变量跨进程存在;需要恢复的数据应作为步骤结果持久化。
  • 任务定义应放在模块顶层,避免每次渲染创建不同对象。dispatch() 使用注册时的定义对象;enqueue() 也要求传入已注册的同一个对象。

错误处理

import {
  DurableQueueError,
  NonRetryableError,
} from "@seekcx/expo-durable-queue";

try {
  await handle.done();
} catch (error) {
  if (error instanceof DurableQueueError) {
    console.error(error.code, error.message, error.details);
  }
}

在步骤中抛出普通异常会触发重试;对于确定不应重试的业务错误,抛出 NonRetryableError

API 概览

  • defineTask(options):定义类型安全的可持久化任务。
  • createQueue(options):打开数据库、执行迁移并创建队列实例。
  • queue.register(...definitions):在启动前注册当前应用能够执行的任务定义。
  • queue.start() / stop() / close():管理 worker 与数据库生命周期。
  • definition.dispatch(input, options?):通过注册该任务的 queue 创建或复用根任务。
  • queue.enqueue(definition, input, options?):创建或复用根任务。
  • queue.getTaskSnapshot(taskId):查询单个任务。
  • queue.getTaskGraph(rootTaskId):查询完整工作流。
  • queue.listRootTasks(options?):按状态或定义查询根任务。
  • queue.subscribe(listener):监听当前进程中的任务和步骤事件。
  • queue.cleanup():清理超过保留期的成功或取消工作流。