17.13-Hono作为任务调度入口

要点

  • Hono 同时处理 HTTP 请求、Queue 消息、Cron 定时任务——三种入口统一管理
  • HTTP 接口负责接收请求、校验、入队
  • Queue 消费者负责后台处理、重试、状态更新
  • Cron 负责定时扫描、清理、汇总
  • 所有入口共享同一套业务逻辑和环境绑定
  • 这篇是整个第 17 章的收束,把前面讲的能力拼在一起

1. 三种入口的统一

Workers 有三种触发方式:

触发方式入口方法用途
HTTP 请求fetch()用户直接调用
Queue 消息queue()异步任务处理
Cron 定时scheduled()周期性任务

Hono 本身只处理 fetch。要接入 Queue 和 Cron,需要在默认导出里包装:

import { Hono } from 'hono'
import { taskConsumer } from './consumers/task-consumer'
import { cronJobs } from './cron/jobs'
 
const app = new Hono<{ Bindings: Env }>()
 
// HTTP 路由
app.post('/api/tasks', async (c) => { /* ... */ })
app.get('/api/tasks/:id', async (c) => { /* ... */ })
 
// 默认导出包装三种入口
export default {
  fetch: app.fetch,
 
  async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
    switch (batch.queue) {
      case 'tasks':
        await taskConsumer(batch as MessageBatch<TaskMessage>, env)
        break
      case 'notifications':
        await notificationConsumer(batch as MessageBatch<NotifyMessage>, env)
        break
    }
  },
 
  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
    switch (controller.cron) {
      case '*/5 * * * *':
        ctx.waitUntil(cronJobs.cleanupStaleTasks(env))
        break
      case '0 * * * *':
        ctx.waitUntil(cronJobs.hourlyRollup(env))
        break
      case '30 3 * * *':
        ctx.waitUntil(cronJobs.dailyCleanup(env))
        break
    }
  },
}

2. 项目结构

src/
├── index.ts              # 入口:Hono app + 默认导出
├── types.ts              # 类型定义
├── routes/               # HTTP 路由
│   ├── tasks.ts          # 任务提交、查询
│   ├── workflows.ts      # 工作流接口
│   └── batch.ts          # 批量任务接口
├── consumers/            # Queue 消费者
│   ├── task-consumer.ts  # 单任务处理
│   ├── workflow-consumer.ts
│   └── batch-consumer.ts
├── cron/                 # Cron 定时任务
│   ├── jobs.ts           # 定时任务实现
│   └── cleanup.ts        # 清理任务
├── services/             # 业务逻辑
│   ├── file-parser.ts
│   ├── vectorizer.ts
│   └── llm-client.ts
└── lib/                  # 工具函数
    ├── queue.ts
    ├── progress.ts
    └── retry.ts

路由层只做参数校验和入队,业务逻辑放在 services/。消费者和 Cron 都调用 services/ 里的函数。

3. HTTP 路由:任务提交

// src/routes/tasks.ts
import { Hono } from 'hono'
import type { Env, TaskMessage } from '../types'
 
export const tasksApp = new Hono<{ Bindings: Env }>()
 
tasksApp.post('/tasks', async (c) => {
  const formData = await c.req.formData()
  const file = formData.get('file') as File
  const type = formData.get('type') as string
 
  if (!file) return c.json({ error: 'No file' }, 400)
 
  const taskId = crypto.randomUUID()
  const now = new Date().toISOString()
 
  // 1. 文件存 R2
  const fileKey = `uploads/${taskId}/${file.name}`
  await c.env.BUCKET.put(fileKey, file.stream(), {
    httpMetadata: { contentType: file.type },
  })
 
  // 2. 写 D1
  await c.env.DB.prepare(`
    INSERT INTO tasks (id, user_id, type, file_key, status, progress, created_at, updated_at)
    VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)
  `).bind(taskId, c.get('user').id, type, fileKey, now, now).run()
 
  // 3. 入队
  await c.env.TASK_QUEUE.send({
    taskId,
    userId: c.get('user').id,
    fileKey,
    fileType: file.type,
    type,
  })
 
  // 4. 返回 202
  return c.json({ taskId, status: 'pending' }, 202)
})
 
tasksApp.get('/tasks/:taskId', async (c) => {
  const task = await c.env.DB.prepare(`
    SELECT id, type, status, stage, progress, error_message, created_at, updated_at
    FROM tasks WHERE id = ? AND user_id = ?
  `).bind(c.req.param('taskId'), c.get('user').id).first()
 
  if (!task) return c.json({ error: 'Not found' }, 404)
  return c.json(task)
})
 
tasksApp.get('/tasks/:taskId/progress', async (c) => {
  const taskId = c.req.param('taskId')
 
  // 先查 KV(实时进度)
  const kvData = await c.env.KV.get(`progress:${taskId}`, 'json')
  if (kvData) return c.json(kvData)
 
  // 再查 D1
  const task = await c.env.DB.prepare(`
    SELECT status, stage, progress FROM tasks WHERE id = ?
  `).bind(taskId).first()
 
  return c.json(task)
})

4. Queue 消费者:任务处理

// src/consumers/task-consumer.ts
import type { MessageBatch } from '@cloudflare/workers-types'
import type { Env, TaskMessage } from '../types'
import { parseDocument } from '../services/file-parser'
import { vectorize } from '../services/vectorizer'
import { updateProgress } from '../lib/progress'
 
export async function taskConsumer(
  batch: MessageBatch<TaskMessage>,
  env: Env
): Promise<void> {
  for (const msg of batch.messages) {
    const { taskId, fileKey, fileType, type } = msg.body
 
    try {
      // 标记处理中
      await env.DB.prepare(
        "UPDATE tasks SET status = 'processing', started_at = ?, updated_at = ? WHERE id = ?"
      ).bind(new Date().toISOString(), new Date().toISOString(), taskId).run()
 
      // 读文件
      const object = await env.BUCKET.get(fileKey)
      if (!object) throw new Error('File not found')
      const buffer = await object.arrayBuffer()
 
      // 根据类型分发
      if (type === 'vectorize') {
        await processVectorize(taskId, buffer, fileType, env)
      } else if (type === 'ocr') {
        await processOCR(taskId, buffer, env)
      } else if (type === 'transcribe') {
        await processTranscribe(taskId, buffer, env)
      } else {
        throw new Error(`Unknown type: ${type}`)
      }
 
      // 标记完成
      await env.DB.prepare(
        "UPDATE tasks SET status = 'completed', progress = 100, completed_at = ?, updated_at = ? WHERE id = ?"
      ).bind(new Date().toISOString(), new Date().toISOString(), taskId).run()
 
      msg.ack()
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err)
      console.error(`Task ${taskId} failed:`, errMsg)
 
      const retryCount = msg.attempts - 1
      if (retryCount < 3 && isRetryable(err)) {
        await env.DB.prepare(
          "UPDATE tasks SET status = 'retrying', retry_count = retry_count + 1, error_message = ?, updated_at = ? WHERE id = ?"
        ).bind(errMsg, new Date().toISOString(), taskId).run()
 
        msg.retry({ delaySeconds: 30 * (retryCount + 1) })
      } else {
        await env.DB.prepare(
          "UPDATE tasks SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?"
        ).bind(errMsg, new Date().toISOString(), taskId).run()
 
        msg.ack()
      }
    }
  }
}
 
async function processVectorize(taskId: string, buffer: ArrayBuffer, mimeType: string, env: Env) {
  await updateProgress(env, taskId, 0, 'parse')
  const text = await parseDocument(buffer, mimeType)
 
  await updateProgress(env, taskId, 30, 'chunk')
  const chunks = chunkText(text)
 
  await updateProgress(env, taskId, 50, 'embed')
  const vectors = await generateEmbeddings(chunks, env)
 
  await updateProgress(env, taskId, 80, 'index')
  await indexVectors(chunks, vectors, env)
 
  await updateProgress(env, taskId, 100, 'completed')
}

5. Cron 定时任务

// src/cron/jobs.ts
import type { Env } from '../types'
 
export const cronJobs = {
  // 每 5 分钟:清理超时任务
  async cleanupStaleTasks(env: Env): Promise<void> {
    const cutoff = new Date(Date.now() - 30 * 60 * 1000).toISOString()
 
    const stale = await env.DB.prepare(`
      SELECT id FROM tasks WHERE status = 'processing' AND started_at < ?
    `).bind(cutoff).all()
 
    for (const task of stale.results) {
      // 重新入队
      await env.TASK_QUEUE.send({ taskId: task.id })
      await env.DB.prepare(
        "UPDATE tasks SET status = 'pending', updated_at = ? WHERE id = ?"
      ).bind(new Date().toISOString(), task.id).run()
    }
  },
 
  // 每小时:汇总统计
  async hourlyRollup(env: Env): Promise<void> {
    const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
 
    const stats = await env.DB.prepare(`
      SELECT
        status,
        COUNT(*) as count,
        AVG(JULIANDAY(completed_at) - JULIANDAY(started_at)) * 86400 as avg_duration
      FROM tasks
      WHERE created_at > ?
      GROUP BY status
    `).bind(hourAgo).all()
 
    // 存入统计表
    for (const row of stats.results) {
      await env.DB.prepare(`
        INSERT INTO task_stats (hour, status, count, avg_duration, created_at)
        VALUES (?, ?, ?, ?, ?)
      `).bind(
        new Date().toISOString().slice(0, 13),
        row.status,
        row.count,
        row.avg_duration,
        new Date().toISOString(),
      ).run()
    }
  },
 
  // 每天凌晨 3:30:清理过期数据
  async dailyCleanup(env: Env): Promise<void> {
    const cutoff = new Date()
    cutoff.setDate(cutoff.getDate() - 30)
 
    // 删除 30 天前的已完成任务
    await env.DB.prepare(`
      DELETE FROM tasks WHERE status = 'completed' AND completed_at < ?
    `).bind(cutoff.toISOString()).run()
 
    // 清理死信
    await env.DB.prepare(`
      DELETE FROM dead_letters WHERE created_at < ?
    `).bind(cutoff.toISOString()).run()
  },
}

6. 配置汇总

// wrangler.jsonc
{
  "name": "task-worker",
  "main": "src/index.ts",
 
  "queues": {
    "producers": [
      { "queue": "tasks", "binding": "TASK_QUEUE" },
      { "queue": "notifications", "binding": "NOTIFY_QUEUE" }
    ],
    "consumers": [
      {
        "queue": "tasks",
        "max_batch_size": 5,
        "max_retries": 3,
        "dead_letter_queue": "tasks-dlq"
      },
      {
        "queue": "notifications",
        "max_batch_size": 10,
        "max_retries": 3
      },
      {
        "queue": "tasks-dlq",
        "max_batch_size": 10,
        "max_retries": 0
      }
    ]
  },
 
  "triggers": {
    "crons": [
      "*/5 * * * *",    // 每 5 分钟
      "0 * * * *",      // 每小时
      "30 3 * * *"      // 每天凌晨 3:30
    ]
  },
 
  "d1_databases": [
    { "binding": "DB", "database_name": "tasks-db", "database_id": "xxx" }
  ],
 
  "r2_buckets": [
    { "binding": "BUCKET", "bucket_name": "task-files" }
  ],
 
  "kv_namespaces": [
    { "binding": "KV", "namespace_id": "xxx" }
  ]
}

7. 完整示例:从提交到完成

用户提交一个文档向量化任务:

1. POST /api/tasks
   - 文件上传到 R2
   - D1 创建任务记录
   - Queue 发送消息
   - 返回 202 + taskId

2. Queue 消费者接收
   - 读 R2 文件
   - 解析 → 切 chunk → embedding → 写入向量库
   - 每步更新 KV 进度
   - 完成后更新 D1 状态

3. 前端轮询 GET /api/tasks/:taskId/progress
   - 先查 KV(实时)
   - 返回进度和阶段

4. Cron 每 5 分钟
   - 扫描超时任务
   - 重新入队

5. 任务完成
   - 前端查询 GET /api/tasks/:taskId
   - 返回结果或下载链接

8. 监控和可观测性

// 任务统计接口
app.get('/api/stats', async (c) => {
  const [total, byStatus, avgDuration] = await Promise.all([
    c.env.DB.prepare('SELECT COUNT(*) as count FROM tasks').first(),
    c.env.DB.prepare(`
      SELECT status, COUNT(*) as count FROM tasks GROUP BY status
    `).all(),
    c.env.DB.prepare(`
      SELECT AVG(JULIANDAY(completed_at) - JULIANDAY(started_at)) * 86400 as avg
      FROM tasks WHERE status = 'completed' AND completed_at > datetime('now', '-1 hour')
    `).first(),
  ])
 
  const statusMap: Record<string, number> = {}
  for (const row of byStatus.results) {
    statusMap[row.status] = row.count
  }
 
  return c.json({
    total: total?.count ?? 0,
    byStatus: statusMap,
    avgDurationSeconds: avgDuration?.avg ?? 0,
  })
})

总结

回顾这一节的要点:

  • Hono 同时处理 HTTP、Queue、Cron 三种入口——统一管理
  • HTTP 接口负责接收、校验、入队——返回 202
  • Queue 消费者负责后台处理——重试、状态更新、进度上报
  • Cron 负责定时扫描——清理超时任务、汇总统计、清理过期数据
  • 所有入口共享 services/ 层的业务逻辑
  • 配置集中在 wrangler.jsonc——队列、Cron、D1、R2、KV 绑定
  • 监控接口提供任务统计——成功率、平均耗时、队列深度

第 17 章从「为什么需要异步」开始,讲了任务队列模型、选型、状态设计、重试、死信、进度查询、具体任务类型(向量化、批量生成)、工作流编排,最后以 Hono 作为统一入口收束。这些能力组合起来,可以构建完整的异步任务系统。