create-bubbles 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +15 -0
- package/package.json +1 -2
- package/template-nextjs-vinext-eslint/Dockerfile +4 -4
- package/template-nextjs-vinext-eslint/README.md +6 -2
- package/template-nextjs-vinext-eslint/docker-compose.yaml +4 -4
- package/template-nextjs-vinext-eslint/eslint.config.js +2 -0
- package/template-nextjs-vinext-eslint/package.json +28 -26
- package/template-nextjs-vinext-eslint/pnpm-workspace.yaml +13 -1
- package/template-nextjs-vinext-eslint/src/utils/request/core/index.ts +63 -45
- package/template-nextjs-vinext-eslint/src/utils/request/core/utils.ts +12 -6
- package/template-nextjs-vinext-eslint/vite.config.ts +1 -1
- package/template-react-rsbuild-biome/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-react-rsbuild-biome/AGENTS.md +11 -0
- package/template-react-rsbuild-biome/index.html +2 -2
- package/template-react-rsbuild-biome/package.json +25 -24
- package/template-react-rsbuild-biome/pnpm-workspace.yaml +5 -0
- package/template-react-rsbuild-biome/rsbuild.config.ts +1 -1
- package/template-react-rsbuild-biome/src/assets/icon/logo.svg +1 -1
- package/template-react-rsbuild-biome/src/assets/svg/draft.svg +4 -0
- package/template-react-rsbuild-biome/src/components/Icon/svg-icon/README.md +37 -0
- package/template-react-rsbuild-biome/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-react-rsbuild-biome/src/env.d.ts +7 -0
- package/template-react-rsbuild-biome/src/utils/request/index.ts +3 -4
- package/template-react-rsbuild-biome/tsconfig.json +1 -2
- package/template-taro-react-oxc/.oxlintrc.json +0 -1
- package/template-taro-react-oxc/package.json +53 -52
- package/template-taro-react-oxc/pnpm-workspace.yaml +46 -0
- package/template-taro-vue-eslint/package.json +56 -56
- package/template-vp-monorepo-react-hono/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-monorepo-react-hono/apps/web/AGENTS.md +11 -0
- package/template-vp-monorepo-react-hono/apps/web/package.json +1 -0
- package/template-vp-monorepo-react-hono/apps/web/src/assets/svg/draft.svg +4 -0
- package/template-vp-monorepo-react-hono/apps/web/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-monorepo-react-hono/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-monorepo-react-hono/apps/web/tsconfig.json +1 -1
- package/template-vp-monorepo-react-hono/apps/web/vite.config.ts +2 -0
- package/template-vp-monorepo-react-hono/pnpm-workspace.yaml +1 -0
- package/template-vp-monorepo-react-nestjs/.agents/rules//344/273/243/347/240/201/350/256/276/350/256/241.md +4 -0
- package/template-vp-monorepo-react-nestjs/.agents/rules//345/205/261/344/272/253/344/273/243/347/240/201.md +4 -0
- package/template-vp-monorepo-react-nestjs/.spaces/01./344/274/201/344/270/232/347/272/247/351/241/271/347/233/256/347/272/247//351/234/200/346/261/202/346/226/207/346/241/243.md +177 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/04.upload.md +4504 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/05./344/270/212/344/274/240/346/224/271/351/200/240/346/226/271/346/241/210/README.md +858 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/06./345/205/254/345/205/261/351/230/237/345/210/227/346/216/245/345/205/245/346/226/271/346/241/210/README.md +1890 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/06./345/205/254/345/205/261/351/230/237/345/210/227/346/216/245/345/205/245/346/226/271/346/241/210//344/270/212/344/274/240/346/270/205/347/220/206.md +1465 -0
- package/template-vp-monorepo-react-nestjs/AGENTS.md +8 -12
- package/template-vp-monorepo-react-nestjs/apps/server/.agents/rules/error-handling.md +84 -0
- package/template-vp-monorepo-react-nestjs/apps/server/.env.development +60 -0
- package/template-vp-monorepo-react-nestjs/apps/server/.env.production +16 -0
- package/template-vp-monorepo-react-nestjs/apps/server/AGENTS.md +9 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0000_military_colonel_america.sql +11 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/0001_loud_siren.sql +27 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0000_snapshot.json +97 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/0001_snapshot.json +354 -0
- package/template-vp-monorepo-react-nestjs/apps/server/drizzle/meta/_journal.json +20 -0
- package/template-vp-monorepo-react-nestjs/apps/server/package.json +4 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/app.module.ts +14 -2
- package/template-vp-monorepo-react-nestjs/apps/server/src/common/adapters/fastify.adapter.ts +6 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/index.ts +2 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/queue.config.ts +105 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/config/storage.config.ts +33 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/database/schema.ts +57 -1
- package/template-vp-monorepo-react-nestjs/apps/server/src/main.ts +1 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/auth.service.ts +1 -3
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/auth/dto/auth-repsponse.dto.ts +2 -2
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/queue-worker.bootstrap.ts +24 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.constants.ts +24 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.contracts.ts +35 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.module.ts +56 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.processor.ts +62 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/task-queue/task-queue.service.ts +58 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/test/error-catelog.spec.ts +34 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/initiate-mutipart-upload.dto.ts +26 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/dto/upload-params.dto.ts +15 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/exact-size.transform.ts +66 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/minio-storage.adapter.ts +239 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/storage/storage.port.ts +61 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.constants.ts +23 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.controller.ts +109 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.errors.ts +80 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.module.ts +20 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.reponsitory.ts +166 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/upload.service.ts +696 -0
- package/template-vp-monorepo-react-nestjs/apps/server/src/modules/upload/utils/object-key.ts +10 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.integration.spec.ts +120 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.processor.spec.ts +63 -0
- package/template-vp-monorepo-react-nestjs/apps/server/test/task-queue.service.spec.ts +106 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//345/274/271/347/252/227/347/273/204/344/273/266.md +4 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//346/226/207/344/273/266/346/213/206/345/210/206.md +4 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//347/273/204/344/273/266/345/221/275/345/220/215.md +3 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//350/241/250/346/240/274/351/241/265/351/235/242.md +11 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.agents/rules//351/241/265/351/235/242/346/216/245/345/217/243.md +3 -0
- package/template-vp-monorepo-react-nestjs/apps/web/.env.dev +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web/AGENTS.md +16 -0
- package/template-vp-monorepo-react-nestjs/apps/web/README.MD +153 -0
- package/template-vp-monorepo-react-nestjs/apps/web/index.html +3 -2
- package/template-vp-monorepo-react-nestjs/apps/web/package.json +6 -1
- package/template-vp-monorepo-react-nestjs/apps/web/public/favicon.svg +9 -1
- package/template-vp-monorepo-react-nestjs/apps/web/src/App.module.css +5 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/App.tsx +16 -9
- package/template-vp-monorepo-react-nestjs/apps/web/src/api/auth.ts +9 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/assets/svg/draft.svg +4 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.module.css +78 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/DraftProTable.tsx +99 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/DraftProTable/README.md +34 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.module.css +54 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/FullHeightProTable/FullHeightProTable.tsx +41 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.module.css +7 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/components/Loading/PageLoading.tsx +2 -1
- package/template-vp-monorepo-react-nestjs/apps/web/src/config/theme.ts +20 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.module.css +17 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/layouts/BasicLayout.tsx +106 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.module.css +23 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/i18n/index.tsx +53 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/components/ProjectFormDialog.tsx +132 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/columns.tsx +121 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/config/index.ts +77 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/components/DraftProjectFormDialog.tsx +140 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/columns.tsx +127 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/index.ts +39 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/config/projects.ts +48 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.module.css +49 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/draft/index.tsx +163 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.module.css +53 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/examples/pro-table/index.tsx +135 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.module.css +23 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/home/index.tsx +26 -43
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/api.ts +11 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/index.tsx +183 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/pages/login/login.css +164 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/router/index.tsx +36 -12
- package/template-vp-monorepo-react-nestjs/apps/web/src/styles/index.css +11 -4
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/core/index.ts +55 -47
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/request/index.ts +10 -5
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/cookie.ts +50 -0
- package/template-vp-monorepo-react-nestjs/apps/web/src/utils/storage/session.ts +33 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/draft-projects.spec.ts +89 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/request.spec.ts +145 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/storage.spec.ts +108 -0
- package/template-vp-monorepo-react-nestjs/apps/web/test/tsconfig.json +7 -0
- package/template-vp-monorepo-react-nestjs/apps/web/tsconfig.json +1 -1
- package/template-vp-monorepo-react-nestjs/apps/web/vite.config.ts +7 -0
- package/template-vp-monorepo-react-nestjs/apps/web-vue/tsconfig.tsbuildinfo +1 -0
- package/template-vp-monorepo-react-nestjs/docker-compose.yml +19 -0
- package/template-vp-monorepo-react-nestjs/mise.toml +1 -1
- package/template-vp-monorepo-react-nestjs/package.json +1 -6
- package/template-vp-monorepo-react-nestjs/pnpm-workspace.yaml +9 -1
- package/template-vp-react/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-react/AGENTS.md +11 -0
- package/template-vp-react/commitlint.config.js +0 -0
- package/template-vp-react/package.json +21 -26
- package/template-vp-react/pnpm-workspace.yaml +6 -0
- package/template-vp-react/src/assets/svg/draft.svg +4 -0
- package/template-vp-react/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-react/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-react/src/utils/request/index.ts +18 -5
- package/template-vp-react/tsconfig.json +2 -3
- package/template-vp-react/vite.config.ts +2 -0
- package/template-vp-react-shadcn/.agents/rules/SVG/344/275/277/347/224/250.md +8 -0
- package/template-vp-react-shadcn/.vscode/settings.json +2 -2
- package/template-vp-react-shadcn/{agents.md → AGENTS.md} +12 -2
- package/template-vp-react-shadcn/README.md +41 -0
- package/template-vp-react-shadcn/commitlint.config.js +1 -1
- package/template-vp-react-shadcn/components.json +1 -1
- package/template-vp-react-shadcn/package.json +25 -32
- package/template-vp-react-shadcn/pnpm-workspace.yaml +10 -0
- package/template-vp-react-shadcn/src/App.tsx +10 -10
- package/template-vp-react-shadcn/src/assets/svg/draft.svg +4 -0
- package/template-vp-react-shadcn/src/components/Icon/svg-icon/README.md +37 -0
- package/template-vp-react-shadcn/src/components/Icon/svg-icon/SvgIcon.tsx +27 -0
- package/template-vp-react-shadcn/src/components/Loading/PageLoading.tsx +4 -4
- package/template-vp-react-shadcn/src/components/Player/VideoJS/index.tsx +94 -78
- package/template-vp-react-shadcn/src/components/Player/index.ts +1 -1
- package/template-vp-react-shadcn/src/components/ui/button.tsx +25 -32
- package/template-vp-react-shadcn/src/components/ui/spinner.tsx +8 -7
- package/template-vp-react-shadcn/src/components/ui/toast.tsx +229 -0
- package/template-vp-react-shadcn/src/lib/utils.ts +3 -3
- package/template-vp-react-shadcn/src/main.tsx +5 -4
- package/template-vp-react-shadcn/src/pages/home/index.tsx +43 -35
- package/template-vp-react-shadcn/src/router/index.tsx +15 -15
- package/template-vp-react-shadcn/src/styles/index.css +4 -4
- package/template-vp-react-shadcn/src/styles/shadcn.css +2 -2
- package/template-vp-react-shadcn/src/utils/env/index.tsx +2 -2
- package/template-vp-react-shadcn/src/utils/request/alova-core/index.ts +102 -30
- package/template-vp-react-shadcn/src/utils/request/alova-core/utils.ts +5 -2
- package/template-vp-react-shadcn/src/utils/request/index.ts +32 -19
- package/template-vp-react-shadcn/src/utils/request/readme.md +41 -38
- package/template-vp-react-shadcn/tsconfig.json +2 -3
- package/template-vp-react-shadcn/vite.config.ts +29 -15
- package/template-vp-vue-eslint-vapor/package.json +26 -33
- package/template-vp-vue-eslint-vapor/pnpm-workspace.yaml +29 -1
- package/template-vue-vp-eslint/README.md +3 -10
- package/template-vue-vp-eslint/package.json +27 -34
- package/template-vue-vp-eslint/pnpm-workspace.yaml +27 -2
- package/template-vue-vp-eslint/src/layout/default/index.vue +4 -3
- package/dist/index.mjs +0 -422
- package/template-vp-monorepo-react-nestjs/.agents/auth.config.ts +0 -0
- package/template-vp-monorepo-react-nestjs/.spaces/server/todo.md +0 -991
- package/template-vp-monorepo-react-nestjs/apps/server/README.md +0 -3
- package/template-vp-react-shadcn/.oxfmtrc.jsonc +0 -8
- package/template-vp-react-shadcn/.oxlintrc.jsonc +0 -3
- package/template-vp-react-shadcn/src/components/ui/sonner.tsx +0 -43
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
# 上传文件定时清理:给前端开发者的 NestJS + BullMQ 实现教程
|
|
2
|
+
|
|
3
|
+
> 本文是动手实现教程,代码需要你按步骤写入项目;更新本文不代表业务代码已经实现。
|
|
4
|
+
> 基线:2026-09-05 的 `apps/server`,PostgreSQL + Drizzle + MinIO,公共队列沿用 [README](./README.md)。
|
|
5
|
+
> 最终效果:后端每分钟自动扫描,找到过期且没有分片正在上传的会话,逐个释放 MinIO 中的未完成分片,并保存清理结果。
|
|
6
|
+
|
|
7
|
+
## 先理解最终效果
|
|
8
|
+
|
|
9
|
+
用户上传大文件,只上传了两片就关闭页面。数据库留下一个上传会话,MinIO 保存着这两片。即使用户不再打开页面,后端也应该在会话过期后回收它们。
|
|
10
|
+
|
|
11
|
+
这件事由后端定时完成。前端不用加定时器,也不用定期调用“清理接口”。本教程选定 **BullMQ Job Scheduler** 作为定时器;项目已经选择 BullMQ,它自带周期调度能力,不需要再安装 `@nestjs/schedule`。
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
NestJS 启动:向 Redis 注册一条固定的周期规则
|
|
15
|
+
│
|
|
16
|
+
▼ 每 60 秒产生扫描任务
|
|
17
|
+
upload.cleanup-scan.v1
|
|
18
|
+
│
|
|
19
|
+
▼ 公共 TaskQueueProcessor 分发
|
|
20
|
+
UploadCleanupScanner
|
|
21
|
+
│
|
|
22
|
+
├── 查询最多 100 个候选会话
|
|
23
|
+
└── 每个会话投递一个小任务
|
|
24
|
+
│
|
|
25
|
+
▼
|
|
26
|
+
upload.multipart-expire.v1
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
UploadCleanupHandler
|
|
30
|
+
├── 重新检查数据库
|
|
31
|
+
├── 领取清理权
|
|
32
|
+
├── 调用 MinIO Abort
|
|
33
|
+
└── 确认后写入 cleanedAt
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
扫描和清理拆开,一条记录失败时就只重试那条记录。`await enqueue()` 只表示写入 Redis 成功,不表示清理完成。队列繁忙时实际执行会延后,定时扫描不保证精确到秒。
|
|
37
|
+
|
|
38
|
+
阅读路线:第 0~3 步准备配置和数据;第 4~6 步解决“哪些记录允许清理”;第 7~10 步把任务和定时器接起来;第 11~12 步验证结果;最后两步用于排障和部署。第一次可以先读第 9、12 步了解效果,真正写代码时按顺序进行。
|
|
39
|
+
|
|
40
|
+
### 前端概念对照
|
|
41
|
+
|
|
42
|
+
| 后端概念 | 可以怎样理解 | 本文用途 |
|
|
43
|
+
| ------------------ | ------------------------------ | ------------------------------ |
|
|
44
|
+
| Scheduler | 保存在服务器的重复执行规则 | 每分钟产生一个扫描 Job |
|
|
45
|
+
| Job / Payload | 一次任务及其参数 | 清理某个 `uploadSessionId` |
|
|
46
|
+
| Queue | Redis 中的待办列表 | 沿用 `server-tasks` |
|
|
47
|
+
| Worker / Processor | 从列表取任务的执行者 | 校验参数,再调用业务方法 |
|
|
48
|
+
| Repository | 封装数据库读写的模块 | 查询、领取、保存结果 |
|
|
49
|
+
| 依赖注入 | Nest 创建并传入 Service 实例 | 构造函数参数由 Module 配置提供 |
|
|
50
|
+
| 幂等 | 同一操作重复执行仍得到正确结果 | 避免重试导致误处理 |
|
|
51
|
+
| 租约 Lease | 有到期时间的占用凭证 | 标记分片在途、Worker 正在清理 |
|
|
52
|
+
|
|
53
|
+
### 这篇教程清理哪些数据
|
|
54
|
+
|
|
55
|
+
| 状态 | 处理方式 |
|
|
56
|
+
| ---------------------------------------- | ------------------------------------ |
|
|
57
|
+
| `uploading`,未过期 | 保留 |
|
|
58
|
+
| `uploading`,已过期 | 等活动分片租约结束,再封口并回收 |
|
|
59
|
+
| `expired`,`cleanedAt` 为空 | 继续回收,不能当成已经清理成功 |
|
|
60
|
+
| `aborting` / `aborted`,`cleanedAt` 为空 | 对取消操作做幂等补偿,确认分片已释放 |
|
|
61
|
+
| `completed` | 保留正式文件 |
|
|
62
|
+
| `completing` | 不 Abort,交给完成结果恢复流程 |
|
|
63
|
+
|
|
64
|
+
统一规定:`expired` 表示“会话关闭,不能继续上传”;**`cleanedAt` 非空才表示确认 Multipart 已释放**。清理任务不会调用 `DeleteObject`,不会删除已完成的正式文件,也不删除上传会话记录。
|
|
65
|
+
|
|
66
|
+
`completing` 可能是 MinIO 已合并成功、数据库还没写回。现有 `getStatus()` 会通过 `recoverCompletedObject()` 检查最终对象并补写 `completed`。长期无法恢复的记录需要单独对账,不能因为过期就 Abort。本篇实现未完成上传的定时回收,不承诺恢复所有上传异常。
|
|
67
|
+
|
|
68
|
+
## 第 0 步:先跑通公共队列
|
|
69
|
+
|
|
70
|
+
当前仓库已声明 `bullmq` 和 `@nestjs/bullmq` 依赖,并已有公共队列文件。开始前先检查基础能力是否跑通;如果你还在较早的代码版本上,按基础篇补齐即可,不必重复创建已有文件。
|
|
71
|
+
|
|
72
|
+
对照 [公共队列 README](./README.md),确认下面这些文件存在,并看到 `system.queue-smoke.v1` 被 Worker 成功处理,再继续本篇:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
apps/server/src/config/queue.config.ts
|
|
76
|
+
apps/server/src/modules/task-queue/task-queue.constants.ts
|
|
77
|
+
apps/server/src/modules/task-queue/task-queue.contracts.ts
|
|
78
|
+
apps/server/src/modules/task-queue/task-queue.service.ts
|
|
79
|
+
apps/server/src/modules/task-queue/task-queue.processor.ts
|
|
80
|
+
apps/server/src/modules/task-queue/task-queue.module.ts
|
|
81
|
+
apps/server/src/modules/task-queue/queue-worker.bootstrap.ts
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
README 的“暂不实现定时任务”是基础篇范围。本篇就是接着基础篇实现定时清理。
|
|
85
|
+
|
|
86
|
+
本文所有 `vp`、`docker compose` 命令在模板根目录执行,即同时包含 `apps/`、`pnpm-workspace.yaml`、`docker-compose.yml` 的目录:
|
|
87
|
+
|
|
88
|
+
```powershell
|
|
89
|
+
docker compose up -d postgres redis minio
|
|
90
|
+
vp run --filter server build
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Bucket、访问账号沿用已经配置好的上传环境。上传表已迁移,初始化和上传分片接口应能正常使用。
|
|
94
|
+
|
|
95
|
+
注意:当前上传 Repository 文件真实拼写是 **`upload.reponsitory.ts`**,本文按这个路径引用。
|
|
96
|
+
|
|
97
|
+
## 第 1 步:了解文件分工
|
|
98
|
+
|
|
99
|
+
后续新增这些文件,现在先看职责,不必一次性创建空文件:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
apps/server/src/config/
|
|
103
|
+
└── upload-cleanup.config.ts # 频率、批量大小、预览开关
|
|
104
|
+
|
|
105
|
+
apps/server/src/modules/task-queue/
|
|
106
|
+
└── task-queue-worker.module.ts # 组装公共 Worker 与上传业务
|
|
107
|
+
|
|
108
|
+
apps/server/src/modules/upload/
|
|
109
|
+
├── upload-session-lock.ts # 同一会话的短事务互斥
|
|
110
|
+
├── upload-part-lease.service.ts # 登记并续租正在上传的分片
|
|
111
|
+
├── upload-cleanup.repository.ts # 候选查询、领取、写回
|
|
112
|
+
├── upload-cleanup.scanner.ts # 投递候选 ID
|
|
113
|
+
├── upload-cleanup.handler.ts # 真正调用 MinIO
|
|
114
|
+
└── upload-cleanup.scheduler.ts # 注册周期规则
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
还会修改 Schema、原上传 Service/Repository、任务契约、Processor 和 Module。涉及原上传流程,是因为后台必须知道有没有分片正在写入,不能只看过期时间。
|
|
118
|
+
|
|
119
|
+
## 第 2 步:增加配置,默认先预览
|
|
120
|
+
|
|
121
|
+
在 `apps/server/.env.development` 增加:
|
|
122
|
+
|
|
123
|
+
```dotenv
|
|
124
|
+
UPLOAD_CLEANUP_ENABLED=true
|
|
125
|
+
UPLOAD_CLEANUP_DRY_RUN=true
|
|
126
|
+
UPLOAD_CLEANUP_INTERVAL_MS=60000
|
|
127
|
+
UPLOAD_CLEANUP_BATCH_SIZE=100
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| 配置 | 含义 |
|
|
131
|
+
| ------------- | ----------------------------------------------------------- |
|
|
132
|
+
| `ENABLED` | 是否允许扫描及清理 |
|
|
133
|
+
| `DRY_RUN` | `true` 时只打印候选,不投递实际清理;已排队任务也检查此开关 |
|
|
134
|
+
| `INTERVAL_MS` | 扫描间隔,毫秒;`60000` 是一分钟 |
|
|
135
|
+
| `BATCH_SIZE` | 每轮最多扫描条数,不是 Worker 并发数 |
|
|
136
|
+
|
|
137
|
+
上传有效期仍由原来的 `storage.sessionTtlMs` 和记录中的 `expiresAt` 决定。每分钟扫描,不等于文件只能上传一分钟。
|
|
138
|
+
|
|
139
|
+
新建 `apps/server/src/config/upload-cleanup.config.ts`:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { registerAs } from '@nestjs/config'
|
|
143
|
+
import { z } from 'zod'
|
|
144
|
+
|
|
145
|
+
const BooleanText = z.enum(['true', 'false']).transform((value) => value === 'true')
|
|
146
|
+
const CleanupEnv = z.object({
|
|
147
|
+
UPLOAD_CLEANUP_ENABLED: BooleanText.default(true),
|
|
148
|
+
UPLOAD_CLEANUP_DRY_RUN: BooleanText.default(true),
|
|
149
|
+
UPLOAD_CLEANUP_INTERVAL_MS: z.coerce.number().int().min(5_000).max(86_400_000).default(60_000),
|
|
150
|
+
UPLOAD_CLEANUP_BATCH_SIZE: z.coerce.number().int().min(1).max(1_000).default(100),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
export default registerAs('uploadCleanup', () => {
|
|
154
|
+
const env = CleanupEnv.parse(process.env)
|
|
155
|
+
return {
|
|
156
|
+
enabled: env.UPLOAD_CLEANUP_ENABLED,
|
|
157
|
+
dryRun: env.UPLOAD_CLEANUP_DRY_RUN,
|
|
158
|
+
intervalMs: env.UPLOAD_CLEANUP_INTERVAL_MS,
|
|
159
|
+
batchSize: env.UPLOAD_CLEANUP_BATCH_SIZE,
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
沿用项目的 Zod 4,`.default(true)` 是 `transform` 后的布尔默认值。不能用 `Boolean('false')` 解析环境变量,那样仍得到 `true`。
|
|
165
|
+
|
|
166
|
+
在 `apps/server/src/config/index.ts` 增加:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
export { default as uploadCleanupConfig } from './upload-cleanup.config'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
在 `apps/server/src/app.module.ts` 从 `@/config` 导入 `uploadCleanupConfig`,追加到 `ConfigModule.forRoot({ load: [...] })`,保留现有配置和基础篇的 `queueConfig`。
|
|
173
|
+
|
|
174
|
+
以后 `config.getOrThrow<boolean>('uploadCleanup.enabled')` 读到的就是布尔值。
|
|
175
|
+
|
|
176
|
+
## 第 3 步:增加清理字段和分片租约表
|
|
177
|
+
|
|
178
|
+
### 3.1 上传会话字段
|
|
179
|
+
|
|
180
|
+
修改 `apps/server/src/database/schema.ts`,在 `uploadSessions` 的字段对象追加:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
cleanedAt: timestamp('cleaned_at', { withTimezone: true }),
|
|
184
|
+
cleanupClaimId: uuid('cleanup_claim_id'),
|
|
185
|
+
cleanupLeaseUntil: timestamp('cleanup_lease_until', { withTimezone: true }),
|
|
186
|
+
nextCleanupAt: timestamp('next_cleanup_at', { withTimezone: true }).defaultNow().notNull(),
|
|
187
|
+
lastCleanupError: varchar('last_cleanup_error', { length: 100 }),
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
在同一个表的索引数组追加:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
index('upload_sessions_cleanup_due_idx')
|
|
194
|
+
.on(table.nextCleanupAt, table.id)
|
|
195
|
+
.where(sql`${table.cleanedAt} is null and ${table.status} in ('uploading', 'expired', 'aborting', 'aborted')`),
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
为此在 Schema 顶部增加 `import { sql } from 'drizzle-orm'`。这是部分索引:只为仍可能需要清理的记录建立扫描入口,避免历史已完成记录越来越多时,每轮都从它们中间筛选。
|
|
199
|
+
|
|
200
|
+
| 字段 | 作用 |
|
|
201
|
+
| ------------------- | -------------------------------------------------- |
|
|
202
|
+
| `cleanedAt` | 区分“过期”和“确实清完” |
|
|
203
|
+
| `cleanupClaimId` | 本次 Worker 的随机 UUID,旧 Worker 不能覆盖新结果 |
|
|
204
|
+
| `cleanupLeaseUntil` | Worker 崩溃后,允许其他实例在到期后接手 |
|
|
205
|
+
| `nextCleanupAt` | 下一次允许扫描投递的时间,避免失败记录一直挤在队首 |
|
|
206
|
+
| `lastCleanupError` | 固定的失败分类,不保存原始 SQL、SDK 错误或密钥 |
|
|
207
|
+
|
|
208
|
+
历史 `expired/aborted` 记录也会被重新确认一次,因为新增的 `cleanedAt` 为空。已经不存在的 Multipart 可以按幂等成功处理。
|
|
209
|
+
|
|
210
|
+
### 3.2 活动分片租约表
|
|
211
|
+
|
|
212
|
+
在同一个 Schema 文件末尾追加:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
export const uploadPartLeases = pgTable(
|
|
216
|
+
'upload_part_leases',
|
|
217
|
+
{
|
|
218
|
+
id: uuid('id').primaryKey(),
|
|
219
|
+
uploadSessionId: uuid('upload_session_id')
|
|
220
|
+
.notNull()
|
|
221
|
+
.references(() => uploadSessions.id, { onDelete: 'cascade' }),
|
|
222
|
+
partNumber: integer('part_number').notNull(),
|
|
223
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
|
224
|
+
},
|
|
225
|
+
(table) => [
|
|
226
|
+
uniqueIndex('upload_part_leases_session_part_uq').on(table.uploadSessionId, table.partNumber),
|
|
227
|
+
index('upload_part_leases_session_expires_idx').on(table.uploadSessionId, table.expiresAt),
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
一片正在上传,就有一条租约。唯一索引保证同一会话同一分片不会被两次请求同时占用。不同分片仍能并行。现有 Service 已检查 `partNumber` 范围,登记租约放在参数校验之后。
|
|
233
|
+
|
|
234
|
+
### 3.3 生成并执行迁移
|
|
235
|
+
|
|
236
|
+
```powershell
|
|
237
|
+
vp run --filter server db:generate
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
打开新生成的 `apps/server/drizzle/*.sql`,确认包含新增字段、表和索引,没有意外删除旧字段。然后在本地开发数据库执行:
|
|
241
|
+
|
|
242
|
+
```powershell
|
|
243
|
+
vp run --filter server db:migrate
|
|
244
|
+
vp run --filter server db:studio
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Studio 应能看到新结构。`db:generate` 只生成 SQL,`db:migrate` 才修改数据库;只改 TypeScript 不会自动建表。
|
|
248
|
+
|
|
249
|
+
## 第 4 步:共用一把短事务锁
|
|
250
|
+
|
|
251
|
+
后台刚查到“没有分片”,另一个请求就登记了分片,这两个操作可能交错。因此“登记分片”和“封口清理”必须对同一个会话串行判断。
|
|
252
|
+
|
|
253
|
+
新建 `apps/server/src/modules/upload/upload-session-lock.ts`:
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import type { DrizzleDB } from '@/database/db.module'
|
|
257
|
+
import { uploadPartLeases } from '@/database/schema'
|
|
258
|
+
import { and, eq, gt, sql } from 'drizzle-orm'
|
|
259
|
+
|
|
260
|
+
export type UploadTransaction = Parameters<Parameters<DrizzleDB['transaction']>[0]>[0]
|
|
261
|
+
|
|
262
|
+
export async function withUploadSessionLock<T>(
|
|
263
|
+
db: DrizzleDB,
|
|
264
|
+
uploadSessionId: string,
|
|
265
|
+
operation: (tx: UploadTransaction) => Promise<T>,
|
|
266
|
+
): Promise<T> {
|
|
267
|
+
return db.transaction(async (tx) => {
|
|
268
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${uploadSessionId}, 0))`)
|
|
269
|
+
return operation(tx)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function hasActiveParts(tx: UploadTransaction, uploadSessionId: string) {
|
|
274
|
+
const rows = await tx
|
|
275
|
+
.select({ id: uploadPartLeases.id })
|
|
276
|
+
.from(uploadPartLeases)
|
|
277
|
+
.where(
|
|
278
|
+
and(
|
|
279
|
+
eq(uploadPartLeases.uploadSessionId, uploadSessionId),
|
|
280
|
+
gt(uploadPartLeases.expiresAt, sql`clock_timestamp()`),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
.limit(1)
|
|
284
|
+
return rows.length > 0
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
这是 PostgreSQL 的事务级 advisory lock:相同会话 ID 对应同一把锁,事务结束自动释放;不同会话通常互不影响。哈希碰撞最多增加排队,不会使应该互斥的操作绕过锁。
|
|
289
|
+
|
|
290
|
+
`sql` 模板的变量会作为数据库参数传入,不要自己拼接 SQL。**锁内只做数据库操作,事务提交后才调用 MinIO。** 不能拿着数据库连接等待整个文件上传。
|
|
291
|
+
|
|
292
|
+
## 第 5 步:让正在上传的分片拥有租约
|
|
293
|
+
|
|
294
|
+
### 5.1 新建租约 Service
|
|
295
|
+
|
|
296
|
+
新建 `apps/server/src/modules/upload/upload-part-lease.service.ts`:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
import { randomUUID } from 'node:crypto'
|
|
300
|
+
import { Inject, Injectable } from '@nestjs/common'
|
|
301
|
+
import { and, eq, gt, lte, sql } from 'drizzle-orm'
|
|
302
|
+
import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
|
|
303
|
+
import { uploadPartLeases, uploadSessions } from '@/database/schema'
|
|
304
|
+
import { AppException } from '@/common/exceptions/app.exception'
|
|
305
|
+
import { UPLOAD_ERRORS } from './upload.errors'
|
|
306
|
+
import { withUploadSessionLock } from './upload-session-lock'
|
|
307
|
+
|
|
308
|
+
@Injectable()
|
|
309
|
+
export class UploadPartLeaseService {
|
|
310
|
+
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
|
311
|
+
|
|
312
|
+
private async begin(ownerId: string, uploadSessionId: string, partNumber: number) {
|
|
313
|
+
return withUploadSessionLock(this.db, uploadSessionId, async (tx) => {
|
|
314
|
+
const [session] = await tx
|
|
315
|
+
.select()
|
|
316
|
+
.from(uploadSessions)
|
|
317
|
+
.where(
|
|
318
|
+
and(
|
|
319
|
+
eq(uploadSessions.id, uploadSessionId),
|
|
320
|
+
eq(uploadSessions.ownerId, ownerId),
|
|
321
|
+
eq(uploadSessions.status, 'uploading'),
|
|
322
|
+
gt(uploadSessions.expiresAt, sql`clock_timestamp()`),
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
.limit(1)
|
|
326
|
+
if (!session) throw new AppException(UPLOAD_ERRORS.INVALID_STATE)
|
|
327
|
+
|
|
328
|
+
await tx
|
|
329
|
+
.delete(uploadPartLeases)
|
|
330
|
+
.where(
|
|
331
|
+
and(
|
|
332
|
+
eq(uploadPartLeases.uploadSessionId, uploadSessionId),
|
|
333
|
+
lte(uploadPartLeases.expiresAt, sql`clock_timestamp()`),
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
const leaseId = randomUUID()
|
|
337
|
+
const inserted = await tx
|
|
338
|
+
.insert(uploadPartLeases)
|
|
339
|
+
.values({
|
|
340
|
+
id: leaseId,
|
|
341
|
+
uploadSessionId,
|
|
342
|
+
partNumber,
|
|
343
|
+
expiresAt: sql`clock_timestamp() + interval '2 minutes'`,
|
|
344
|
+
})
|
|
345
|
+
.onConflictDoNothing()
|
|
346
|
+
.returning({ id: uploadPartLeases.id })
|
|
347
|
+
if (inserted.length === 0) throw new AppException(UPLOAD_ERRORS.PARTS_STILL_ACTIVE)
|
|
348
|
+
return leaseId
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async renew(leaseId: string, uploadSessionId: string) {
|
|
353
|
+
const rows = await this.db
|
|
354
|
+
.update(uploadPartLeases)
|
|
355
|
+
.set({
|
|
356
|
+
expiresAt: sql`clock_timestamp() + interval '2 minutes'`,
|
|
357
|
+
})
|
|
358
|
+
.where(
|
|
359
|
+
and(
|
|
360
|
+
eq(uploadPartLeases.id, leaseId),
|
|
361
|
+
eq(uploadPartLeases.uploadSessionId, uploadSessionId),
|
|
362
|
+
gt(uploadPartLeases.expiresAt, sql`clock_timestamp()`),
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
.returning({ id: uploadPartLeases.id })
|
|
366
|
+
if (rows.length === 0) throw new Error('Upload part lease lost')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async run<T>(
|
|
370
|
+
input: {
|
|
371
|
+
ownerId: string
|
|
372
|
+
uploadSessionId: string
|
|
373
|
+
partNumber: number
|
|
374
|
+
abortSignal?: AbortSignal
|
|
375
|
+
},
|
|
376
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
377
|
+
): Promise<T> {
|
|
378
|
+
const leaseStartedAt = performance.now()
|
|
379
|
+
const leaseId = await this.begin(input.ownerId, input.uploadSessionId, input.partNumber)
|
|
380
|
+
const controller = new AbortController()
|
|
381
|
+
const abort = () => controller.abort()
|
|
382
|
+
input.abortSignal?.addEventListener('abort', abort, { once: true })
|
|
383
|
+
if (input.abortSignal?.aborted) controller.abort()
|
|
384
|
+
|
|
385
|
+
let stopped = false
|
|
386
|
+
let succeeded = false
|
|
387
|
+
let renewalFailed = false
|
|
388
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
389
|
+
let pending: Promise<void> = Promise.resolve()
|
|
390
|
+
// 数据库续租请求如果一直没返回,也不能让分片超过租约继续写入。
|
|
391
|
+
// 90 秒小于数据库的 2 分钟 TTL,留出网络中止的时间。
|
|
392
|
+
let watchdog = setTimeout(
|
|
393
|
+
() => {
|
|
394
|
+
renewalFailed = true
|
|
395
|
+
controller.abort()
|
|
396
|
+
},
|
|
397
|
+
Math.max(1, 90_000 - (performance.now() - leaseStartedAt)),
|
|
398
|
+
)
|
|
399
|
+
const scheduleRenewal = () => {
|
|
400
|
+
timer = setTimeout(() => {
|
|
401
|
+
pending = (async () => {
|
|
402
|
+
try {
|
|
403
|
+
const renewalStartedAt = performance.now()
|
|
404
|
+
await this.renew(leaseId, input.uploadSessionId)
|
|
405
|
+
if (!stopped && !renewalFailed) {
|
|
406
|
+
clearTimeout(watchdog)
|
|
407
|
+
watchdog = setTimeout(
|
|
408
|
+
() => {
|
|
409
|
+
renewalFailed = true
|
|
410
|
+
controller.abort()
|
|
411
|
+
},
|
|
412
|
+
Math.max(1, 90_000 - (performance.now() - renewalStartedAt)),
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
renewalFailed = true
|
|
417
|
+
controller.abort()
|
|
418
|
+
}
|
|
419
|
+
if (!stopped && !renewalFailed) scheduleRenewal()
|
|
420
|
+
})()
|
|
421
|
+
}, 30_000)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
scheduleRenewal()
|
|
425
|
+
try {
|
|
426
|
+
const result = await operation(controller.signal)
|
|
427
|
+
if (renewalFailed) throw new Error('Upload part lease lost')
|
|
428
|
+
succeeded = true
|
|
429
|
+
return result
|
|
430
|
+
} finally {
|
|
431
|
+
stopped = true
|
|
432
|
+
if (timer) clearTimeout(timer)
|
|
433
|
+
clearTimeout(watchdog)
|
|
434
|
+
await pending
|
|
435
|
+
input.abortSignal?.removeEventListener('abort', abort)
|
|
436
|
+
// 失败时保留租约到期,给已发出的存储请求留出收敛时间。
|
|
437
|
+
if (succeeded && !renewalFailed) {
|
|
438
|
+
await this.db
|
|
439
|
+
.delete(uploadPartLeases)
|
|
440
|
+
.where(
|
|
441
|
+
and(
|
|
442
|
+
eq(uploadPartLeases.id, leaseId),
|
|
443
|
+
eq(uploadPartLeases.uploadSessionId, input.uploadSessionId),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
每 30 秒续租到未来 2 分钟。续租失败就中止 MinIO 请求;进程崩溃后租约自行到期。不要只用一个永久的 `isUploading=true`,崩溃后它可能永远不恢复。
|
|
453
|
+
|
|
454
|
+
租约协调正常并发,但数据库不能撤回已经到达 MinIO 的网络请求。因此后面还会在 Abort 后检查 `ListParts`,保留重试和存储生命周期兜底。
|
|
455
|
+
|
|
456
|
+
### 5.2 接入原来的上传 Service
|
|
457
|
+
|
|
458
|
+
在 `apps/server/src/modules/upload/upload.service.ts` 导入 `UploadPartLeaseService`,构造函数追加:
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
private readonly partLeases: UploadPartLeaseService,
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
在 `uploadPart()` 中找到原 `const result = await this.callStorage(...)`,保留前面的校验、`ExactSizeTransform` 和外层流清理,只替换调用存储的那一段:
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
const result = await this.partLeases.run(input, (signal) =>
|
|
468
|
+
this.callStorage(() =>
|
|
469
|
+
this.storage.uploadPart({
|
|
470
|
+
...this.toMultipartIdentity(session),
|
|
471
|
+
partNumber: input.partNumber,
|
|
472
|
+
body: guardedBody,
|
|
473
|
+
contentLength: expectedSize,
|
|
474
|
+
abortSignal: signal,
|
|
475
|
+
}),
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
必须传这里的 `signal`,它同时处理客户端断开和租约丢失。
|
|
481
|
+
|
|
482
|
+
### 5.3 Complete 和取消也遵守同一把锁
|
|
483
|
+
|
|
484
|
+
修改 `apps/server/src/modules/upload/upload.reponsitory.ts`。在现有 `drizzle-orm` 导入中补入 `gt, isNull, lte, or, sql`,再增加:
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
import { AppException } from '@/common/exceptions/app.exception'
|
|
488
|
+
import { UPLOAD_ERRORS } from './upload.errors'
|
|
489
|
+
import { hasActiveParts, withUploadSessionLock } from './upload-session-lock'
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
在类内新增私有方法,替换原 `claimCompleting()`、`claimAborting()`:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
private async claimIdleState(
|
|
496
|
+
ownerId: string,
|
|
497
|
+
uploadSessionId: string,
|
|
498
|
+
target: 'completing' | 'aborting',
|
|
499
|
+
): Promise<UploadSession | null> {
|
|
500
|
+
return withUploadSessionLock(this.db, uploadSessionId, async (tx) => {
|
|
501
|
+
if (await hasActiveParts(tx, uploadSessionId)) {
|
|
502
|
+
throw new AppException(UPLOAD_ERRORS.PARTS_STILL_ACTIVE)
|
|
503
|
+
}
|
|
504
|
+
const [session] = await tx.update(uploadSessions).set({
|
|
505
|
+
status: target,
|
|
506
|
+
updatedAt: new Date(),
|
|
507
|
+
}).where(and(
|
|
508
|
+
eq(uploadSessions.id, uploadSessionId),
|
|
509
|
+
eq(uploadSessions.ownerId, ownerId),
|
|
510
|
+
target === 'completing'
|
|
511
|
+
? and(eq(uploadSessions.status, 'uploading'), gt(uploadSessions.expiresAt, sql`clock_timestamp()`))
|
|
512
|
+
: inArray(uploadSessions.status, ['uploading', 'aborting', 'expired']),
|
|
513
|
+
or(isNull(uploadSessions.cleanupLeaseUntil), lte(uploadSessions.cleanupLeaseUntil, sql`clock_timestamp()`)),
|
|
514
|
+
)).returning()
|
|
515
|
+
return session ?? null
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async claimCompleting(ownerId: string, uploadSessionId: string) {
|
|
520
|
+
return this.claimIdleState(ownerId, uploadSessionId, 'completing')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async claimAborting(ownerId: string, uploadSessionId: string) {
|
|
524
|
+
return this.claimIdleState(ownerId, uploadSessionId, 'aborting')
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`PARTS_STILL_ACTIVE` 已在当前错误目录定义。前端收到后先停止并等待分片结束,再重试完成或取消。失败分片保留的短租约会让操作多等一段时间,这是保护窗口。
|
|
529
|
+
|
|
530
|
+
### 5.4 移除 HTTP 请求中的过期清理
|
|
531
|
+
|
|
532
|
+
把 `upload.service.ts` 的 `ensureNotExpired()` 替换为:
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
private async ensureNotExpired(session: UploadSession): Promise<void> {
|
|
536
|
+
if (session.expiresAt.getTime() > Date.now()) return
|
|
537
|
+
await this.uploadRepository.markExpired(session.ownerId, session.id)
|
|
538
|
+
throw new AppException(UPLOAD_ERRORS.UPLOAD_EXPIRED)
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
这里仅关闭会话,释放分片交给定时任务。现有 `markExpired()` 是带 `status = uploading` 条件的 UPDATE,它不调用 MinIO,活动分片可在自身租约保护下结束。
|
|
543
|
+
|
|
544
|
+
保留 `safeAbort()` 在初始化失败时的补偿用途,不能删除整个方法。用户主动取消仍然及时 Abort,后台再确认结果。不要只给后台加锁,却保留旧的 `claimCompleting()`。
|
|
545
|
+
|
|
546
|
+
## 第 6 步:实现清理 Repository
|
|
547
|
+
|
|
548
|
+
新建 `apps/server/src/modules/upload/upload-cleanup.repository.ts`:
|
|
549
|
+
|
|
550
|
+
```ts
|
|
551
|
+
import { randomUUID } from 'node:crypto'
|
|
552
|
+
import { Inject, Injectable } from '@nestjs/common'
|
|
553
|
+
import { and, asc, eq, gt, inArray, isNull, lte, or, sql } from 'drizzle-orm'
|
|
554
|
+
import { DRIZZLE, type DrizzleDB } from '@/database/db.module'
|
|
555
|
+
import { uploadPartLeases, uploadSessions } from '@/database/schema'
|
|
556
|
+
import { hasActiveParts, withUploadSessionLock } from './upload-session-lock'
|
|
557
|
+
|
|
558
|
+
@Injectable()
|
|
559
|
+
export class UploadCleanupRepository {
|
|
560
|
+
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
|
561
|
+
|
|
562
|
+
private eligible() {
|
|
563
|
+
return and(
|
|
564
|
+
isNull(uploadSessions.cleanedAt),
|
|
565
|
+
or(
|
|
566
|
+
and(
|
|
567
|
+
eq(uploadSessions.status, 'uploading'),
|
|
568
|
+
lte(uploadSessions.expiresAt, sql`clock_timestamp()`),
|
|
569
|
+
),
|
|
570
|
+
inArray(uploadSessions.status, ['expired', 'aborting', 'aborted']),
|
|
571
|
+
),
|
|
572
|
+
or(
|
|
573
|
+
isNull(uploadSessions.cleanupLeaseUntil),
|
|
574
|
+
lte(uploadSessions.cleanupLeaseUntil, sql`clock_timestamp()`),
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async findCandidates(limit: number) {
|
|
580
|
+
return this.db
|
|
581
|
+
.select({ id: uploadSessions.id, status: uploadSessions.status })
|
|
582
|
+
.from(uploadSessions)
|
|
583
|
+
.where(and(this.eligible(), lte(uploadSessions.nextCleanupAt, sql`clock_timestamp()`)))
|
|
584
|
+
.orderBy(asc(uploadSessions.nextCleanupAt), asc(uploadSessions.id))
|
|
585
|
+
.limit(limit)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async reserveForEnqueue(uploadSessionId: string) {
|
|
589
|
+
const rows = await this.db
|
|
590
|
+
.update(uploadSessions)
|
|
591
|
+
.set({
|
|
592
|
+
nextCleanupAt: sql`clock_timestamp() + interval '5 minutes'`,
|
|
593
|
+
})
|
|
594
|
+
.where(
|
|
595
|
+
and(
|
|
596
|
+
eq(uploadSessions.id, uploadSessionId),
|
|
597
|
+
this.eligible(),
|
|
598
|
+
lte(uploadSessions.nextCleanupAt, sql`clock_timestamp()`),
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
.returning({ id: uploadSessions.id })
|
|
602
|
+
return rows.length > 0
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async claim(uploadSessionId: string) {
|
|
606
|
+
return withUploadSessionLock(this.db, uploadSessionId, async (tx) => {
|
|
607
|
+
if (await hasActiveParts(tx, uploadSessionId)) return null
|
|
608
|
+
|
|
609
|
+
const cleanupClaimId = randomUUID()
|
|
610
|
+
const [session] = await tx
|
|
611
|
+
.update(uploadSessions)
|
|
612
|
+
.set({
|
|
613
|
+
// 在短事务内封口,后来的分片与 Complete 都不能再进入。
|
|
614
|
+
status: sql`case when ${uploadSessions.status} = 'uploading'
|
|
615
|
+
then 'expired'::upload_status else ${uploadSessions.status} end`,
|
|
616
|
+
cleanupClaimId,
|
|
617
|
+
cleanupLeaseUntil: sql`clock_timestamp() + interval '2 minutes'`,
|
|
618
|
+
updatedAt: new Date(),
|
|
619
|
+
})
|
|
620
|
+
.where(and(eq(uploadSessions.id, uploadSessionId), this.eligible()))
|
|
621
|
+
.returning()
|
|
622
|
+
|
|
623
|
+
if (!session) return null
|
|
624
|
+
|
|
625
|
+
await tx
|
|
626
|
+
.delete(uploadPartLeases)
|
|
627
|
+
.where(
|
|
628
|
+
and(
|
|
629
|
+
eq(uploadPartLeases.uploadSessionId, uploadSessionId),
|
|
630
|
+
lte(uploadPartLeases.expiresAt, sql`clock_timestamp()`),
|
|
631
|
+
),
|
|
632
|
+
)
|
|
633
|
+
return { session, cleanupClaimId }
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private ownsClaim(uploadSessionId: string, cleanupClaimId: string) {
|
|
638
|
+
return and(
|
|
639
|
+
eq(uploadSessions.id, uploadSessionId),
|
|
640
|
+
eq(uploadSessions.cleanupClaimId, cleanupClaimId),
|
|
641
|
+
gt(uploadSessions.cleanupLeaseUntil, sql`clock_timestamp()`),
|
|
642
|
+
inArray(uploadSessions.status, ['expired', 'aborting', 'aborted']),
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async finish(uploadSessionId: string, cleanupClaimId: string) {
|
|
647
|
+
const rows = await this.db
|
|
648
|
+
.update(uploadSessions)
|
|
649
|
+
.set({
|
|
650
|
+
status: sql`case when ${uploadSessions.status} = 'expired'
|
|
651
|
+
then 'expired'::upload_status else 'aborted'::upload_status end`,
|
|
652
|
+
cleanedAt: sql`clock_timestamp()`,
|
|
653
|
+
cleanupClaimId: null,
|
|
654
|
+
cleanupLeaseUntil: null,
|
|
655
|
+
lastCleanupError: null,
|
|
656
|
+
updatedAt: new Date(),
|
|
657
|
+
})
|
|
658
|
+
.where(this.ownsClaim(uploadSessionId, cleanupClaimId))
|
|
659
|
+
.returning({ id: uploadSessions.id })
|
|
660
|
+
return rows.length > 0
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async recordFailure(uploadSessionId: string, cleanupClaimId: string) {
|
|
664
|
+
await this.db
|
|
665
|
+
.update(uploadSessions)
|
|
666
|
+
.set({
|
|
667
|
+
cleanupClaimId: null,
|
|
668
|
+
cleanupLeaseUntil: null,
|
|
669
|
+
nextCleanupAt: sql`clock_timestamp() + interval '1 minute'`,
|
|
670
|
+
lastCleanupError: 'STORAGE_OR_DATABASE_FAILURE',
|
|
671
|
+
updatedAt: new Date(),
|
|
672
|
+
})
|
|
673
|
+
.where(this.ownsClaim(uploadSessionId, cleanupClaimId))
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
### 按方法理解这段代码
|
|
679
|
+
|
|
680
|
+
`findCandidates()` 只是找候选,排序后最多取一批,不把全表一次读进内存。扫描结果可能马上过时,所以 Worker 必须再次检查。
|
|
681
|
+
|
|
682
|
+
`reserveForEnqueue()` 把下次扫描时间推后 5 分钟。两个扫描任务同时看见一条记录,只有一个能成功更新并投递。最早失败的记录也会暂时让开,让后面的记录有机会被处理。
|
|
683
|
+
|
|
684
|
+
数据库更新和 Redis 投递不是一个事务。如果预约成功后进程崩溃,记录不会永久丢失,5 分钟后又会被扫描。这里接受有限延迟,通过后续扫描补偿。5 分钟是防重复投递的窗口,不是任务有效期。
|
|
685
|
+
|
|
686
|
+
`claim()` 才是领取真正的执行权。它忽略扫描预约时间,但再次检查状态、过期时间、活动分片和其他 Worker 的租约;因此“预约了任务”不等于“允许删分片”。返回 `null` 表示当前不需要或暂时不能处理。
|
|
687
|
+
|
|
688
|
+
`finish()` 同时匹配会话 ID、Claim UUID、租约仍有效。即使旧 Worker 在网络恢复后继续运行,也不能把新 Worker 的结果覆盖掉。清理超过 2 分钟导致租约失效时,旧任务不写成功;后续扫描会重新确认结果。针对更慢的存储应加入清理租约续租及请求超时,不能只无限加长租约。
|
|
689
|
+
|
|
690
|
+
## 第 7 步:定义两种任务协议
|
|
691
|
+
|
|
692
|
+
修改基础篇创建的 `apps/server/src/modules/task-queue/task-queue.constants.ts`,保留队列名、并发常量,替换任务名对象:
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
export const TASK_JOB_NAMES = {
|
|
696
|
+
QUEUE_SMOKE: 'system.queue-smoke.v1',
|
|
697
|
+
UPLOAD_CLEANUP_SCAN: 'upload.cleanup-scan.v1',
|
|
698
|
+
UPLOAD_MULTIPART_EXPIRE: 'upload.multipart-expire.v1',
|
|
699
|
+
} as const
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
在 `task-queue.contracts.ts` 保留原来的 `QueueSmokePayloadSchema`、解析函数、投递选项和返回类型,新增两个 Schema,并替换两张映射表:
|
|
703
|
+
|
|
704
|
+
```ts
|
|
705
|
+
export const UploadCleanupScanPayloadSchema = z.object({}).strict()
|
|
706
|
+
export const UploadMultipartExpirePayloadSchema = z
|
|
707
|
+
.object({
|
|
708
|
+
uploadSessionId: z.string().uuid(),
|
|
709
|
+
})
|
|
710
|
+
.strict()
|
|
711
|
+
|
|
712
|
+
export interface TaskPayloadMap {
|
|
713
|
+
[TASK_JOB_NAMES.QUEUE_SMOKE]: z.infer<typeof QueueSmokePayloadSchema>
|
|
714
|
+
[TASK_JOB_NAMES.UPLOAD_CLEANUP_SCAN]: z.infer<typeof UploadCleanupScanPayloadSchema>
|
|
715
|
+
[TASK_JOB_NAMES.UPLOAD_MULTIPART_EXPIRE]: z.infer<typeof UploadMultipartExpirePayloadSchema>
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export const TASK_PAYLOAD_SCHEMAS = {
|
|
719
|
+
[TASK_JOB_NAMES.QUEUE_SMOKE]: QueueSmokePayloadSchema,
|
|
720
|
+
[TASK_JOB_NAMES.UPLOAD_CLEANUP_SCAN]: UploadCleanupScanPayloadSchema,
|
|
721
|
+
[TASK_JOB_NAMES.UPLOAD_MULTIPART_EXPIRE]: UploadMultipartExpirePayloadSchema,
|
|
722
|
+
} satisfies Record<TaskName, z.ZodType>
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
扫描任务不需要参数,使用 `{}`;单条清理只传会话 UUID。Bucket、Object Key、MinIO UploadId 从数据库读取,不从队列参数接收。
|
|
726
|
+
|
|
727
|
+
TypeScript 在开发时检查,Zod 在运行时检查 Redis 中实际拿到的数据。两者用途不同,都要保留。
|
|
728
|
+
|
|
729
|
+
## 第 8 步:实现扫描器和清理 Handler
|
|
730
|
+
|
|
731
|
+
### 8.1 扫描器:找候选并投递
|
|
732
|
+
|
|
733
|
+
新建 `apps/server/src/modules/upload/upload-cleanup.scanner.ts`:
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
import { Injectable, Logger } from '@nestjs/common'
|
|
737
|
+
import { ConfigService } from '@nestjs/config'
|
|
738
|
+
import { TASK_JOB_NAMES } from '../task-queue/task-queue.constants'
|
|
739
|
+
import { TaskQueueService } from '../task-queue/task-queue.service'
|
|
740
|
+
import { UploadCleanupRepository } from './upload-cleanup.repository'
|
|
741
|
+
|
|
742
|
+
@Injectable()
|
|
743
|
+
export class UploadCleanupScanner {
|
|
744
|
+
private readonly logger = new Logger(UploadCleanupScanner.name)
|
|
745
|
+
|
|
746
|
+
constructor(
|
|
747
|
+
private readonly config: ConfigService,
|
|
748
|
+
private readonly repository: UploadCleanupRepository,
|
|
749
|
+
private readonly taskQueue: TaskQueueService,
|
|
750
|
+
) {}
|
|
751
|
+
|
|
752
|
+
async scan() {
|
|
753
|
+
if (!this.config.getOrThrow<boolean>('uploadCleanup.enabled')) {
|
|
754
|
+
return { skipped: 'disabled' }
|
|
755
|
+
}
|
|
756
|
+
const dryRun = this.config.getOrThrow<boolean>('uploadCleanup.dryRun')
|
|
757
|
+
const limit = this.config.getOrThrow<number>('uploadCleanup.batchSize')
|
|
758
|
+
const candidates = await this.repository.findCandidates(limit)
|
|
759
|
+
let enqueued = 0
|
|
760
|
+
|
|
761
|
+
for (const candidate of candidates) {
|
|
762
|
+
if (dryRun) {
|
|
763
|
+
this.logger.log({ event: 'upload_cleanup_candidate', ...candidate })
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
if (!(await this.repository.reserveForEnqueue(candidate.id))) continue
|
|
767
|
+
|
|
768
|
+
await this.taskQueue.enqueue(TASK_JOB_NAMES.UPLOAD_MULTIPART_EXPIRE, {
|
|
769
|
+
uploadSessionId: candidate.id,
|
|
770
|
+
})
|
|
771
|
+
enqueued += 1
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const result = { dryRun, candidates: candidates.length, enqueued }
|
|
775
|
+
this.logger.log({ event: 'upload_cleanup_scan', ...result })
|
|
776
|
+
return result
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
预览不会预约、不会投递、不会 Abort。因此预览模式反复看到相同一批候选是正常的,它只展示当前最早的一批。
|
|
782
|
+
|
|
783
|
+
投递失败让异常继续抛出,扫描 Job 会重试,数据库预约窗口也会最终到期。这里不手写永久固定的 `jobId`,避免旧失败 Job 仍保留时阻止后续补偿。
|
|
784
|
+
|
|
785
|
+
### 8.2 Handler:执行真正的清理
|
|
786
|
+
|
|
787
|
+
新建 `apps/server/src/modules/upload/upload-cleanup.handler.ts`:
|
|
788
|
+
|
|
789
|
+
```ts
|
|
790
|
+
import { Inject, Injectable, Logger } from '@nestjs/common'
|
|
791
|
+
import { ConfigService } from '@nestjs/config'
|
|
792
|
+
import {
|
|
793
|
+
STORAGE_PORT,
|
|
794
|
+
StorageMultipartNotFoundError,
|
|
795
|
+
type StoragePort,
|
|
796
|
+
} from './storage/storage.port'
|
|
797
|
+
import { UploadCleanupRepository } from './upload-cleanup.repository'
|
|
798
|
+
|
|
799
|
+
@Injectable()
|
|
800
|
+
export class UploadCleanupHandler {
|
|
801
|
+
private readonly logger = new Logger(UploadCleanupHandler.name)
|
|
802
|
+
|
|
803
|
+
constructor(
|
|
804
|
+
private readonly config: ConfigService,
|
|
805
|
+
private readonly repository: UploadCleanupRepository,
|
|
806
|
+
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
|
807
|
+
) {}
|
|
808
|
+
|
|
809
|
+
async handle(input: { uploadSessionId: string }) {
|
|
810
|
+
if (
|
|
811
|
+
!this.config.getOrThrow<boolean>('uploadCleanup.enabled') ||
|
|
812
|
+
this.config.getOrThrow<boolean>('uploadCleanup.dryRun')
|
|
813
|
+
)
|
|
814
|
+
return { skipped: 'disabled-or-dry-run' }
|
|
815
|
+
|
|
816
|
+
const claim = await this.repository.claim(input.uploadSessionId)
|
|
817
|
+
if (!claim) return { skipped: 'not-eligible-or-busy' }
|
|
818
|
+
|
|
819
|
+
const { session, cleanupClaimId } = claim
|
|
820
|
+
const identity = {
|
|
821
|
+
bucket: session.bucket,
|
|
822
|
+
objectKey: session.objectKey,
|
|
823
|
+
storageUploadId: session.storageUploadId,
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
try {
|
|
827
|
+
// 异常状态下如果已有正式对象,保留现场,交给对账。
|
|
828
|
+
const object = await this.storage.headObject(identity)
|
|
829
|
+
if (object) throw new Error('Final object exists; reconciliation required')
|
|
830
|
+
|
|
831
|
+
await this.storage.abortMultipartUpload(identity)
|
|
832
|
+
|
|
833
|
+
// 一次 Abort 返回成功不等于可以忽略存储端的并发和不确定性。
|
|
834
|
+
let missing = false
|
|
835
|
+
try {
|
|
836
|
+
await this.storage.listParts(identity)
|
|
837
|
+
} catch (error) {
|
|
838
|
+
if (!(error instanceof StorageMultipartNotFoundError)) throw error
|
|
839
|
+
missing = true
|
|
840
|
+
}
|
|
841
|
+
if (!missing) throw new Error('Multipart still exists after abort')
|
|
842
|
+
|
|
843
|
+
const persisted = await this.repository.finish(session.id, cleanupClaimId)
|
|
844
|
+
if (!persisted) return { skipped: 'cleanup-lease-lost' }
|
|
845
|
+
|
|
846
|
+
this.logger.log({ event: 'upload_cleanup_completed', uploadSessionId: session.id })
|
|
847
|
+
return { cleaned: true, uploadSessionId: session.id }
|
|
848
|
+
} catch (error) {
|
|
849
|
+
await this.repository.recordFailure(session.id, cleanupClaimId)
|
|
850
|
+
throw error
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
这些存储方法已经在当前 `StoragePort` 和 `MinioStorageAdapter` 中存在。当前适配器把 Abort 的 `NoSuchUpload` 当作成功,把 ListParts 的 `NoSuchUpload` 转成 `StorageMultipartNotFoundError`,所以不需要另写一套 S3 客户端。
|
|
857
|
+
|
|
858
|
+
普通权限错误、Bucket 错误和网络超时都不能伪装成“对象不存在”。现有适配器的错误映射要保留。
|
|
859
|
+
|
|
860
|
+
为什么先调用 MinIO、后写 `cleanedAt`:先写数据库再 Abort,一旦 Abort 失败,就会把“未清完”误记成“成功”。反过来,即使 Abort 成功后数据库失败,下次重试再次 Abort,再得到 NoSuchUpload,就能补上结果。
|
|
861
|
+
|
|
862
|
+
为什么 `catch` 后还要 `throw`:BullMQ 用 Promise 是否 reject 判断失败。返回 `{ ok: false }` 会被它当成 completed,重试就失效了。这里的 catch 用于记录补偿状态;HTTP 全局异常过滤器不会处理 Worker 异常,Worker 的事件日志沿用公共 Processor。
|
|
863
|
+
|
|
864
|
+
租约记录失败时数据库也可能不可用,此时仍然让 Job 失败。Claim 到期后下一轮会重新领取。所有权丢失的旧 Worker 不写回结果。
|
|
865
|
+
|
|
866
|
+
## 第 9 步:真正注册“每分钟执行一次”
|
|
867
|
+
|
|
868
|
+
### 9.1 扩展公共队列 Service
|
|
869
|
+
|
|
870
|
+
修改 `apps/server/src/modules/task-queue/task-queue.service.ts`,在现有 `TaskQueueService` 类中新增以下方法。所用 `TaskName`、`TaskPayloadMap`、`parseTaskPayload` 已由基础篇导入:
|
|
871
|
+
|
|
872
|
+
```ts
|
|
873
|
+
async upsertSchedule<Name extends TaskName>(
|
|
874
|
+
schedulerId: string,
|
|
875
|
+
everyMs: number,
|
|
876
|
+
name: Name,
|
|
877
|
+
payload: TaskPayloadMap[Name],
|
|
878
|
+
) {
|
|
879
|
+
if (!Number.isSafeInteger(everyMs) || everyMs < 5_000) {
|
|
880
|
+
throw new Error('Schedule interval must be at least 5000 milliseconds')
|
|
881
|
+
}
|
|
882
|
+
await this.queue.upsertJobScheduler(
|
|
883
|
+
schedulerId,
|
|
884
|
+
{ every: everyMs },
|
|
885
|
+
{ name, data: parseTaskPayload(name, payload) },
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async removeSchedule(schedulerId: string) {
|
|
890
|
+
return this.queue.removeJobScheduler(schedulerId)
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
`upsert` 就是“存在则更新,不存在则创建”。它保存的是调度规则,不是立即循环执行的方法调用。调度规则 ID 必须稳定,服务重启时再次调用仍更新同一条规则。
|
|
895
|
+
|
|
896
|
+
当前安装的 BullMQ 提供 `upsertJobScheduler()`;不要复制旧文章使用 `QueueScheduler`,也不要再加一个 Nest `@Cron` 重复扫描。
|
|
897
|
+
|
|
898
|
+
### 9.2 新建启动注册器
|
|
899
|
+
|
|
900
|
+
新建 `apps/server/src/modules/upload/upload-cleanup.scheduler.ts`:
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
import { Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common'
|
|
904
|
+
import { ConfigService } from '@nestjs/config'
|
|
905
|
+
import { TASK_JOB_NAMES } from '../task-queue/task-queue.constants'
|
|
906
|
+
import { TaskQueueService } from '../task-queue/task-queue.service'
|
|
907
|
+
|
|
908
|
+
const SCHEDULER_ID = 'upload-cleanup-scan-v1'
|
|
909
|
+
|
|
910
|
+
@Injectable()
|
|
911
|
+
export class UploadCleanupScheduler implements OnApplicationBootstrap {
|
|
912
|
+
private readonly logger = new Logger(UploadCleanupScheduler.name)
|
|
913
|
+
|
|
914
|
+
constructor(
|
|
915
|
+
private readonly config: ConfigService,
|
|
916
|
+
private readonly taskQueue: TaskQueueService,
|
|
917
|
+
) {}
|
|
918
|
+
|
|
919
|
+
async onApplicationBootstrap() {
|
|
920
|
+
// 只让承担 Worker 的进程管理调度规则;纯 API 副本跳过。
|
|
921
|
+
if (!this.config.getOrThrow<boolean>('queue.workerEnabled')) return
|
|
922
|
+
|
|
923
|
+
if (!this.config.getOrThrow<boolean>('uploadCleanup.enabled')) {
|
|
924
|
+
await this.taskQueue.removeSchedule(SCHEDULER_ID)
|
|
925
|
+
this.logger.log({ event: 'upload_cleanup_schedule_removed' })
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const intervalMs = this.config.getOrThrow<number>('uploadCleanup.intervalMs')
|
|
930
|
+
await this.taskQueue.upsertSchedule(
|
|
931
|
+
SCHEDULER_ID,
|
|
932
|
+
intervalMs,
|
|
933
|
+
TASK_JOB_NAMES.UPLOAD_CLEANUP_SCAN,
|
|
934
|
+
{},
|
|
935
|
+
)
|
|
936
|
+
this.logger.log({ event: 'upload_cleanup_schedule_ready', intervalMs })
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
`OnApplicationBootstrap` 是 Nest 的启动生命周期,类似组件初始化,但它属于整个后端应用。这里只在启动时注册一次,后续重复由 BullMQ 驱动,不需要写 `setInterval()`。
|
|
942
|
+
|
|
943
|
+
### 9.3 调度行为要理解清楚
|
|
944
|
+
|
|
945
|
+
| 问题 | 实际行为 |
|
|
946
|
+
| ---------------------------- | ----------------------------------------------------------------------------------- |
|
|
947
|
+
| 服务重启会多注册一条吗 | 相同 Redis、DB、Prefix、队列和稳定 Scheduler ID 下,更新同一条规则 |
|
|
948
|
+
| 部署两个 Worker 会重复规则吗 | 同配置、同 ID 的 upsert 不创建两条规则;Job 仍可能重复执行,靠数据库幂等保护 |
|
|
949
|
+
| 第一轮一定等一分钟吗 | 当前 BullMQ 的 `every` 调度新建时可能立即产生首轮任务,不依赖“先等完整一分钟”的假设 |
|
|
950
|
+
| 服务停了还能自动清理吗 | 规则在 Redis 中保留,但必须有运行中的 Worker 才能执行 |
|
|
951
|
+
| 停机一小时会补跑 60 次吗 | 不按停机期间每个时间点补齐历史扫描;恢复后的扫描会处理仍到期的数据库记录 |
|
|
952
|
+
| 扫描积压怎么办 | Scheduler 的后续产生与消费有关,繁忙时频率会低于配置值;需要监控队列延迟 |
|
|
953
|
+
| 停用时只不调用 upsert 可以吗 | 不可以,旧规则仍在 Redis;要调用 `removeJobScheduler` |
|
|
954
|
+
|
|
955
|
+
同一环境的所有 Worker 必须使用一致的 enabled、dryRun、扫描间隔、Prefix。不要一个副本删除规则、另一个副本重新注册。
|
|
956
|
+
|
|
957
|
+
修改 `.env` 后重启 Server 才会应用新配置。关闭 `UPLOAD_CLEANUP_ENABLED` 并重启负责调度的 Worker,会删除未来规则;Scanner 和 Handler 的开关检查会让已有排队任务跳过。已经开始的 MinIO 请求不会被配置变更瞬间撤销,应等待正常停机完成。
|
|
958
|
+
|
|
959
|
+
不要在 `onModuleDestroy()` 中删除调度规则。一个副本重启,不应该撤掉其他副本还在使用的周期任务。
|
|
960
|
+
|
|
961
|
+
### 9.4 如果想每天凌晨清理
|
|
962
|
+
|
|
963
|
+
本教程主线是固定间隔。如果确定要每天北京时间 03:00 扫描,把 `upsertJobScheduler` 的第二个参数替换成:
|
|
964
|
+
|
|
965
|
+
```ts
|
|
966
|
+
{ pattern: '0 0 3 * * *', tz: 'Asia/Shanghai' }
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
这里六位依次是“秒、分、时、日、月、周”。`every` 和 `pattern` 二选一,不能同时传;改成 pattern 后应同步去掉该方法中无效的 `everyMs` 参数及校验,并调整调用者,避免留下看似生效的间隔配置。
|
|
970
|
+
|
|
971
|
+
每天仅扫 100 条,积压时最多也只处理这个量,因此大量上传通常更适合每分钟小批扫描。
|
|
972
|
+
|
|
973
|
+
## 第 10 步:组装模块,避免循环依赖
|
|
974
|
+
|
|
975
|
+
上传 Scanner 需要队列 Service,队列 Processor 又需要上传 Handler。如果互相导入整个模块,会变成循环依赖。这里把“队列基础设施”和“消费组装”拆开。
|
|
976
|
+
|
|
977
|
+
```text
|
|
978
|
+
TaskQueueModule
|
|
979
|
+
└── BullMQ 配置 + Queue + TaskQueueService
|
|
980
|
+
|
|
981
|
+
UploadModule ──导入──> TaskQueueModule
|
|
982
|
+
└── 上传 API + Scanner + Handler + Scheduler
|
|
983
|
+
|
|
984
|
+
TaskQueueWorkerModule
|
|
985
|
+
├── 导入 TaskQueueModule
|
|
986
|
+
├── 导入 UploadModule
|
|
987
|
+
└── QueueWorkerBootstrap + 唯一 TaskQueueProcessor
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### 10.1 修改基础队列模块
|
|
991
|
+
|
|
992
|
+
修改 `apps/server/src/modules/task-queue/task-queue.module.ts`:保留基础篇全部 `BullModule.forRootAsync(...)`、`registerQueue(...)` 配置,包括 `manualRegistration: true`。移除 `QueueWorkerBootstrap`、`TaskQueueProcessor` 的导入和 provider 注册。
|
|
993
|
+
|
|
994
|
+
装饰器中的这两个属性改为:
|
|
995
|
+
|
|
996
|
+
```ts
|
|
997
|
+
providers: [TaskQueueService],
|
|
998
|
+
exports: [TaskQueueService, BullModule],
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
导出 `BullModule`,让消费组装模块可以取得队列及注册能力。不要再创建第二套 `forRootAsync`。
|
|
1002
|
+
|
|
1003
|
+
### 10.2 替换 UploadModule
|
|
1004
|
+
|
|
1005
|
+
将 `apps/server/src/modules/upload/upload.module.ts` 改为:
|
|
1006
|
+
|
|
1007
|
+
```ts
|
|
1008
|
+
import { Module } from '@nestjs/common'
|
|
1009
|
+
import { TaskQueueModule } from '../task-queue/task-queue.module'
|
|
1010
|
+
import { MinioStorageAdapter } from './storage/minio-storage.adapter'
|
|
1011
|
+
import { STORAGE_PORT } from './storage/storage.port'
|
|
1012
|
+
import { UploadController } from './upload.controller'
|
|
1013
|
+
import { UploadRepository } from './upload.reponsitory'
|
|
1014
|
+
import { UploadService } from './upload.service'
|
|
1015
|
+
import { UploadPartLeaseService } from './upload-part-lease.service'
|
|
1016
|
+
import { UploadCleanupRepository } from './upload-cleanup.repository'
|
|
1017
|
+
import { UploadCleanupScanner } from './upload-cleanup.scanner'
|
|
1018
|
+
import { UploadCleanupHandler } from './upload-cleanup.handler'
|
|
1019
|
+
import { UploadCleanupScheduler } from './upload-cleanup.scheduler'
|
|
1020
|
+
|
|
1021
|
+
@Module({
|
|
1022
|
+
imports: [TaskQueueModule],
|
|
1023
|
+
controllers: [UploadController],
|
|
1024
|
+
providers: [
|
|
1025
|
+
UploadRepository,
|
|
1026
|
+
UploadService,
|
|
1027
|
+
UploadPartLeaseService,
|
|
1028
|
+
MinioStorageAdapter,
|
|
1029
|
+
{ provide: STORAGE_PORT, useExisting: MinioStorageAdapter },
|
|
1030
|
+
UploadCleanupRepository,
|
|
1031
|
+
UploadCleanupScanner,
|
|
1032
|
+
UploadCleanupHandler,
|
|
1033
|
+
UploadCleanupScheduler,
|
|
1034
|
+
],
|
|
1035
|
+
exports: [UploadCleanupScanner, UploadCleanupHandler],
|
|
1036
|
+
})
|
|
1037
|
+
export class UploadModule {}
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
`providers` 表示“由当前模块创建这些实例”;`exports` 表示“允许导入此模块的其他模块使用”。数据库和 Config 已由当前项目的全局模块提供。
|
|
1041
|
+
|
|
1042
|
+
### 10.3 新建消费组装模块
|
|
1043
|
+
|
|
1044
|
+
新建 `apps/server/src/modules/task-queue/task-queue-worker.module.ts`:
|
|
1045
|
+
|
|
1046
|
+
```ts
|
|
1047
|
+
import { Module } from '@nestjs/common'
|
|
1048
|
+
import { UploadModule } from '../upload/upload.module'
|
|
1049
|
+
import { QueueWorkerBootstrap } from './queue-worker.bootstrap'
|
|
1050
|
+
import { TaskQueueModule } from './task-queue.module'
|
|
1051
|
+
import { TaskQueueProcessor } from './task-queue.processor'
|
|
1052
|
+
|
|
1053
|
+
@Module({
|
|
1054
|
+
imports: [TaskQueueModule, UploadModule],
|
|
1055
|
+
providers: [QueueWorkerBootstrap, TaskQueueProcessor],
|
|
1056
|
+
})
|
|
1057
|
+
export class TaskQueueWorkerModule {}
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
基础篇的 `QueueWorkerBootstrap` 内容保留,它仍通过 `QUEUE_WORKER_ENABLED` 和 `BullRegistrar.register()` 控制消费者启动。
|
|
1061
|
+
|
|
1062
|
+
### 10.4 Processor 增加分发
|
|
1063
|
+
|
|
1064
|
+
修改 `apps/server/src/modules/task-queue/task-queue.processor.ts`,新增导入:
|
|
1065
|
+
|
|
1066
|
+
```ts
|
|
1067
|
+
import { UploadCleanupScanner } from '../upload/upload-cleanup.scanner'
|
|
1068
|
+
import { UploadCleanupHandler } from '../upload/upload-cleanup.handler'
|
|
1069
|
+
import {
|
|
1070
|
+
UploadCleanupScanPayloadSchema,
|
|
1071
|
+
UploadMultipartExpirePayloadSchema,
|
|
1072
|
+
} from './task-queue.contracts'
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
与原 contracts 导入合并。类中增加构造函数,`WorkerHost` 子类必须调用 `super()`:
|
|
1076
|
+
|
|
1077
|
+
```ts
|
|
1078
|
+
constructor(
|
|
1079
|
+
private readonly uploadCleanupScanner: UploadCleanupScanner,
|
|
1080
|
+
private readonly uploadCleanupHandler: UploadCleanupHandler,
|
|
1081
|
+
) {
|
|
1082
|
+
super()
|
|
1083
|
+
}
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
在 `process()` 的 `switch` 内、原 `default` 之前增加分支。保留冒烟分支和全部 Worker 事件日志:
|
|
1087
|
+
|
|
1088
|
+
```ts
|
|
1089
|
+
case TASK_JOB_NAMES.UPLOAD_CLEANUP_SCAN: {
|
|
1090
|
+
const parsed = UploadCleanupScanPayloadSchema.safeParse(job.data)
|
|
1091
|
+
if (!parsed.success) throw new UnrecoverableError('Invalid cleanup scan payload')
|
|
1092
|
+
return this.uploadCleanupScanner.scan()
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
case TASK_JOB_NAMES.UPLOAD_MULTIPART_EXPIRE: {
|
|
1096
|
+
const parsed = UploadMultipartExpirePayloadSchema.safeParse(job.data)
|
|
1097
|
+
if (!parsed.success) throw new UnrecoverableError('Invalid upload cleanup payload')
|
|
1098
|
+
return this.uploadCleanupHandler.handle(parsed.data)
|
|
1099
|
+
}
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
非法参数使用 `UnrecoverableError`,重试不会把错误 UUID 修好。普通数据库、存储故障让异常继续抛出,沿用公共队列的 3 次尝试和指数退避。
|
|
1103
|
+
|
|
1104
|
+
**同一个 `server-tasks` 不再新增 `@Processor`。** 多个只认识部分任务的 Processor 会竞争整个队列,无法按任务名自动各取所需。
|
|
1105
|
+
|
|
1106
|
+
### 10.5 AppModule 接入
|
|
1107
|
+
|
|
1108
|
+
在 `apps/server/src/app.module.ts` 增加导入并把它加入 `imports`:
|
|
1109
|
+
|
|
1110
|
+
```ts
|
|
1111
|
+
import { TaskQueueWorkerModule } from './modules/task-queue/task-queue-worker.module'
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
保留 `DatabaseModule`、`ConfigModule`、`AuthModule`、`UploadModule` 等原有模块。原 AppModule 对 `TaskQueueModule` 的直接导入可以移除,由 WorkerModule 和 UploadModule 明确导入即可。
|
|
1115
|
+
|
|
1116
|
+
确认第 2 步已经把 `uploadCleanupConfig` 加入 `load`;确认基础篇已经在 `main.ts` 调用 `app.enableShutdownHooks()`。
|
|
1117
|
+
|
|
1118
|
+
至此文件才全部组装完成,执行:
|
|
1119
|
+
|
|
1120
|
+
```powershell
|
|
1121
|
+
vp run --filter server build
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
如果出现 `Nest can't resolve dependencies`,先核对 providers、imports、exports;不要把缺少的 Service 随手在多个模块重复注册。
|
|
1125
|
+
|
|
1126
|
+
### 10.6 同步调整基础篇的队列测试
|
|
1127
|
+
|
|
1128
|
+
基础篇集成测试只导入 `TaskQueueModule`,拆分后那里已不再提供 Worker,原测试会等待超时。
|
|
1129
|
+
|
|
1130
|
+
保留公共队列冒烟测试只依赖 Redis的目标:在该测试文件中直接导入 `QueueWorkerBootstrap`、`TaskQueueProcessor`、`UploadCleanupScanner`、`UploadCleanupHandler`,并给 `Test.createTestingModule()` 增加以下 providers,原 imports 保留:
|
|
1131
|
+
|
|
1132
|
+
```ts
|
|
1133
|
+
providers: [
|
|
1134
|
+
QueueWorkerBootstrap,
|
|
1135
|
+
TaskQueueProcessor,
|
|
1136
|
+
{ provide: UploadCleanupScanner, useValue: { scan: async () => ({ skipped: 'smoke-test' }) } },
|
|
1137
|
+
{ provide: UploadCleanupHandler, useValue: { handle: async () => ({ skipped: 'smoke-test' }) } },
|
|
1138
|
+
],
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
原来的 `testingModule.init()` 也要保留。这样只验证真实 Redis 和公共 Worker,上传业务用替身,不会注册真实清理 Scheduler。
|
|
1142
|
+
|
|
1143
|
+
如果已有直接 `new TaskQueueProcessor()` 的单元测试,也要传入 Scanner 和 Handler 的测试替身。真正的上传清理需要后面单独联调,不能把此处替身的成功当作清理验收。
|
|
1144
|
+
|
|
1145
|
+
## 第 11 步:先加几个关键测试
|
|
1146
|
+
|
|
1147
|
+
新建 `apps/server/test/upload-cleanup.handler.spec.ts`。当前测试配置只匹配 `test/**/*.spec.ts`,不要放进 `src/`。
|
|
1148
|
+
|
|
1149
|
+
```ts
|
|
1150
|
+
import { ConfigService } from '@nestjs/config'
|
|
1151
|
+
import { describe, expect, it, vi } from 'vite-plus/test'
|
|
1152
|
+
import { UploadCleanupHandler } from '@/modules/upload/upload-cleanup.handler'
|
|
1153
|
+
import { UploadCleanupRepository } from '@/modules/upload/upload-cleanup.repository'
|
|
1154
|
+
import {
|
|
1155
|
+
StorageMultipartNotFoundError,
|
|
1156
|
+
type StoragePort,
|
|
1157
|
+
} from '@/modules/upload/storage/storage.port'
|
|
1158
|
+
|
|
1159
|
+
function setup(dryRun = false) {
|
|
1160
|
+
const uploadSessionId = '11111111-1111-4111-8111-111111111111'
|
|
1161
|
+
const repository = {
|
|
1162
|
+
claim: vi.fn().mockResolvedValue({
|
|
1163
|
+
cleanupClaimId: '22222222-2222-4222-8222-222222222222',
|
|
1164
|
+
session: {
|
|
1165
|
+
id: uploadSessionId,
|
|
1166
|
+
bucket: 'test-bucket',
|
|
1167
|
+
objectKey: 'test-object',
|
|
1168
|
+
storageUploadId: 'test-multipart',
|
|
1169
|
+
},
|
|
1170
|
+
}),
|
|
1171
|
+
finish: vi.fn().mockResolvedValue(true),
|
|
1172
|
+
recordFailure: vi.fn().mockResolvedValue(undefined),
|
|
1173
|
+
}
|
|
1174
|
+
const storage = {
|
|
1175
|
+
headObject: vi.fn().mockResolvedValue(null),
|
|
1176
|
+
abortMultipartUpload: vi.fn().mockResolvedValue(undefined),
|
|
1177
|
+
listParts: vi.fn().mockRejectedValue(new StorageMultipartNotFoundError(undefined)),
|
|
1178
|
+
}
|
|
1179
|
+
const config = {
|
|
1180
|
+
getOrThrow: (key: string) => (key === 'uploadCleanup.dryRun' ? dryRun : true),
|
|
1181
|
+
}
|
|
1182
|
+
const handler = new UploadCleanupHandler(
|
|
1183
|
+
config as unknown as ConfigService,
|
|
1184
|
+
repository as unknown as UploadCleanupRepository,
|
|
1185
|
+
storage as unknown as StoragePort,
|
|
1186
|
+
)
|
|
1187
|
+
return { handler, repository, storage, input: { uploadSessionId } }
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
describe('UploadCleanupHandler', () => {
|
|
1191
|
+
it('预览时不领取任务、不操作存储', async () => {
|
|
1192
|
+
const context = setup(true)
|
|
1193
|
+
await context.handler.handle(context.input)
|
|
1194
|
+
expect(context.repository.claim).not.toHaveBeenCalled()
|
|
1195
|
+
expect(context.storage.abortMultipartUpload).not.toHaveBeenCalled()
|
|
1196
|
+
})
|
|
1197
|
+
|
|
1198
|
+
it('无法领取时不操作存储', async () => {
|
|
1199
|
+
const context = setup()
|
|
1200
|
+
context.repository.claim.mockResolvedValue(null)
|
|
1201
|
+
await context.handler.handle(context.input)
|
|
1202
|
+
expect(context.storage.abortMultipartUpload).not.toHaveBeenCalled()
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
it('确认 Multipart 不存在以后才写成功', async () => {
|
|
1206
|
+
const context = setup()
|
|
1207
|
+
await expect(context.handler.handle(context.input)).resolves.toMatchObject({ cleaned: true })
|
|
1208
|
+
expect(context.storage.abortMultipartUpload).toHaveBeenCalledOnce()
|
|
1209
|
+
expect(context.storage.listParts).toHaveBeenCalledOnce()
|
|
1210
|
+
expect(context.repository.finish).toHaveBeenCalledOnce()
|
|
1211
|
+
expect(context.repository.recordFailure).not.toHaveBeenCalled()
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
it('存储暂时失败时保留失败,并抛出给 BullMQ 重试', async () => {
|
|
1215
|
+
const context = setup()
|
|
1216
|
+
const error = new Error('Temporary storage failure')
|
|
1217
|
+
context.storage.abortMultipartUpload.mockRejectedValue(error)
|
|
1218
|
+
await expect(context.handler.handle(context.input)).rejects.toThrow(error)
|
|
1219
|
+
expect(context.repository.finish).not.toHaveBeenCalled()
|
|
1220
|
+
expect(context.repository.recordFailure).toHaveBeenCalledOnce()
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
it('存在正式对象时不 Abort', async () => {
|
|
1224
|
+
const context = setup()
|
|
1225
|
+
context.storage.headObject.mockResolvedValue({ etag: 'test-etag' })
|
|
1226
|
+
await expect(context.handler.handle(context.input)).rejects.toThrow('reconciliation required')
|
|
1227
|
+
expect(context.storage.abortMultipartUpload).not.toHaveBeenCalled()
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
it('Abort 后 Multipart 仍存在时不记成功', async () => {
|
|
1231
|
+
const context = setup()
|
|
1232
|
+
context.storage.listParts.mockResolvedValue([])
|
|
1233
|
+
await expect(context.handler.handle(context.input)).rejects.toThrow('still exists')
|
|
1234
|
+
expect(context.repository.finish).not.toHaveBeenCalled()
|
|
1235
|
+
})
|
|
1236
|
+
})
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
执行:
|
|
1240
|
+
|
|
1241
|
+
```powershell
|
|
1242
|
+
vp run --filter server test
|
|
1243
|
+
vp run --filter server build
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
这些测试证明 Handler 的分支行为,但没有验证真实数据库锁和 MinIO。后续还要针对 Repository 增加真实 PostgreSQL 测试,覆盖“未过期、活动租约、两个 Worker 抢同一条记录、旧 Claim 无法写回”。不要只 Mock `claim()` 返回 `null` 就声称已经验证并发安全。
|
|
1247
|
+
|
|
1248
|
+
## 第 12 步:本地走通完整链路
|
|
1249
|
+
|
|
1250
|
+
### 12.1 启动预览模式
|
|
1251
|
+
|
|
1252
|
+
在本机开发环境保持:
|
|
1253
|
+
|
|
1254
|
+
```dotenv
|
|
1255
|
+
QUEUE_WORKER_ENABLED=true
|
|
1256
|
+
UPLOAD_CLEANUP_ENABLED=true
|
|
1257
|
+
UPLOAD_CLEANUP_DRY_RUN=true
|
|
1258
|
+
UPLOAD_CLEANUP_INTERVAL_MS=10000
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
临时改为 10 秒是为了不用等一分钟,验收后再改回 60000。确认 `.env.development.local`、`.env.local` 或外部环境变量没有覆盖它们。
|
|
1262
|
+
|
|
1263
|
+
启动后端:
|
|
1264
|
+
|
|
1265
|
+
```powershell
|
|
1266
|
+
vp run --filter server dev
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
先观察启动日志中的事件:
|
|
1270
|
+
|
|
1271
|
+
```text
|
|
1272
|
+
upload_cleanup_schedule_ready
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
随后应周期性看到类似结果:
|
|
1276
|
+
|
|
1277
|
+
```json
|
|
1278
|
+
{
|
|
1279
|
+
"event": "upload_cleanup_scan",
|
|
1280
|
+
"dryRun": true,
|
|
1281
|
+
"candidates": 0,
|
|
1282
|
+
"enqueued": 0
|
|
1283
|
+
}
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
日志实际可能由 Nest 包装成多行文本,重点看字段和值。候选数是 0 也证明扫描执行了,只是还没有到期数据。
|
|
1287
|
+
|
|
1288
|
+
### 12.2 制造一个真正的未完成测试上传
|
|
1289
|
+
|
|
1290
|
+
用现有上传页面或 Swagger 的 `POST /uploads/multipart` 创建一个测试会话,上传一片后暂停并等待所有请求结束。不要调用 Complete,也不要点“取消并释放”,否则前端已经主动清理,无法证明后台在起作用。
|
|
1291
|
+
|
|
1292
|
+
复制接口返回的 `uploadSessionId`。浏览器 Network 可以确认请求完成;租约表中这个会话正常情况下应没有活动租约。
|
|
1293
|
+
|
|
1294
|
+
另开终端执行 `vp run --filter server db:studio`。在 `upload_sessions` 中按这个 ID 找到唯一一条测试记录,仅把它的 `expiresAt` 改为几分钟前,其他字段保留。若之前已经预约过投递,再把这条记录的 `nextCleanupAt` 改到过去。
|
|
1295
|
+
|
|
1296
|
+
这是为本地验收缩短等待时间。不要批量修改所有上传记录,也不要把它作为线上管理方式。
|
|
1297
|
+
|
|
1298
|
+
### 12.3 预览应出现候选,但不释放分片
|
|
1299
|
+
|
|
1300
|
+
等待下一轮扫描,应看到:
|
|
1301
|
+
|
|
1302
|
+
```json
|
|
1303
|
+
{
|
|
1304
|
+
"event": "upload_cleanup_candidate",
|
|
1305
|
+
"id": "你刚创建的会话 UUID",
|
|
1306
|
+
"status": "uploading"
|
|
1307
|
+
}
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
此时 `enqueued` 仍是 0,`cleanedAt` 仍为空,MinIO 的未完成上传仍在。
|
|
1311
|
+
|
|
1312
|
+
普通 MinIO 对象列表通常不会展示未完成的 Multipart。需要使用已有 `mc` 环境的 `mc ls --incomplete --recursive`,不能因为普通文件列表为空就判断清完了。
|
|
1313
|
+
|
|
1314
|
+
当前模板有 `minio-init` 工具服务,可以另开终端进入临时工具容器:
|
|
1315
|
+
|
|
1316
|
+
```powershell
|
|
1317
|
+
docker compose run --rm --no-deps --entrypoint /bin/sh minio-init
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
进入容器后,在它的 Shell 中逐行执行:
|
|
1321
|
+
|
|
1322
|
+
```sh
|
|
1323
|
+
mc alias set inspect http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
|
|
1324
|
+
mc ls --incomplete --recursive "inspect/$MINIO_BUCKET"
|
|
1325
|
+
exit
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
这复用本地 Compose 配置,只列举状态,不删除 Bucket 或文件。用测试会话的 Object Key 对照这一条上传;这些内部存储标识只在本机排查,不写进前端接口或公共日志。
|
|
1329
|
+
|
|
1330
|
+
### 12.4 开启实际清理
|
|
1331
|
+
|
|
1332
|
+
确认候选只包含应回收的测试上传后,在本机配置修改:
|
|
1333
|
+
|
|
1334
|
+
```dotenv
|
|
1335
|
+
UPLOAD_CLEANUP_DRY_RUN=false
|
|
1336
|
+
```
|
|
1337
|
+
|
|
1338
|
+
重启 Server,等待扫描和单条任务处理完成。预期看到:
|
|
1339
|
+
|
|
1340
|
+
```text
|
|
1341
|
+
upload_cleanup_scan enqueued: 1
|
|
1342
|
+
upload_cleanup_completed uploadSessionId: 测试会话 UUID
|
|
1343
|
+
queue_job_completed jobName: upload.multipart-expire.v1
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
回到 Studio,检查这条记录:
|
|
1347
|
+
|
|
1348
|
+
| 字段 | 预期 |
|
|
1349
|
+
| ------------------- | --------- |
|
|
1350
|
+
| `status` | `expired` |
|
|
1351
|
+
| `cleanedAt` | 非空时间 |
|
|
1352
|
+
| `cleanupClaimId` | 空 |
|
|
1353
|
+
| `cleanupLeaseUntil` | 空 |
|
|
1354
|
+
| `lastCleanupError` | 空 |
|
|
1355
|
+
|
|
1356
|
+
再次运行 `mc ls --incomplete --recursive`,同一个未完成上传应已消失。Handler 也已通过 `ListParts → NoSuchUpload` 确认这一点。
|
|
1357
|
+
|
|
1358
|
+
继续等待两轮扫描,已清理记录不会反复成为候选,因为 `cleanedAt` 非空。
|
|
1359
|
+
|
|
1360
|
+
### 12.5 验证不能清理的情况
|
|
1361
|
+
|
|
1362
|
+
逐个创建不同的测试会话,避免一个测试的数据影响另一个:
|
|
1363
|
+
|
|
1364
|
+
| 场景 | 操作 | 必须看到的结果 |
|
|
1365
|
+
| ------------ | ---------------------------------------------- | ------------------------------------------------------------------ |
|
|
1366
|
+
| 未过期 | 正常初始化后等待扫描 | 不 Abort,`cleanedAt` 为空 |
|
|
1367
|
+
| 有活动分片 | 限速上传一片,确认租约有效,再让该测试会话到期 | 在租约有效期间不 Abort;停止请求、租约结束并到下次预约时间后才清理 |
|
|
1368
|
+
| 已完成 | 正常完成一个测试文件 | 正式文件仍能读取,清理不操作它 |
|
|
1369
|
+
| `completing` | 对照现有完成恢复流程 | 不被本教程扫描选中 |
|
|
1370
|
+
| 重复处理 | 观察失败重试或重复投递同一会话 | 已确认完成的记录幂等跳过 |
|
|
1371
|
+
| 多实例 | 在不同 HTTP 端口启动两个同配置后端 | 同一时刻只有一个有效清理 Claim;所有权校验阻止旧 Claim 写回 |
|
|
1372
|
+
|
|
1373
|
+
在途分片测试必须真实观察到有效租约;只在前端界面看见“上传中”文字不算验证。租约结束后,先前已预约过的记录可能等待最多 5 分钟再被扫描,测试时要结合 `nextCleanupAt` 判断。
|
|
1374
|
+
|
|
1375
|
+
### 12.6 验证失败重试和后续补偿
|
|
1376
|
+
|
|
1377
|
+
在本地 Server 已启动、初始化测试上传已成功之后,临时停止 MinIO:
|
|
1378
|
+
|
|
1379
|
+
```powershell
|
|
1380
|
+
docker compose stop minio
|
|
1381
|
+
```
|
|
1382
|
+
|
|
1383
|
+
把该测试会话设为到期,观察单条清理失败,`cleanedAt` 保持为空。BullMQ 默认 `attempts: 3` 是总共最多尝试 3 次,重试间隔沿用公共队列配置;不是首次之外再试 3 次。
|
|
1384
|
+
|
|
1385
|
+
恢复 MinIO:
|
|
1386
|
+
|
|
1387
|
+
```powershell
|
|
1388
|
+
docker compose start minio
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
即使旧 Job 已耗尽尝试次数,后续扫描也会为仍待处理的记录投递新 Job。数据库恢复需要等待 MinIO 请求失败返回及 `nextCleanupAt`/Claim 到期,并非一定在下一秒完成。
|
|
1392
|
+
|
|
1393
|
+
当前 MinIO Adapter 启动时会 HeadBucket,所以不要在 MinIO 停止时重启 Server,否则你验证到的是“应用启动失败”,而不是“运行中的队列重试”。
|
|
1394
|
+
|
|
1395
|
+
### 12.7 验证重启和停用
|
|
1396
|
+
|
|
1397
|
+
重启后观察同一 Scheduler 继续工作,不要求补齐停机期间每一次扫描。把 `UPLOAD_CLEANUP_ENABLED=false` 后重启承担 Worker 的实例,应该看到 `upload_cleanup_schedule_removed`,后续不再周期扫描。
|
|
1398
|
+
|
|
1399
|
+
多实例时全部 Worker 配置一致再重启。仅关闭一个实例的 Worker 开关,不会停止其他实例,也不会自动删除 Redis 里的调度规则。
|
|
1400
|
+
|
|
1401
|
+
验收结束恢复需要的频率及开关,再执行:
|
|
1402
|
+
|
|
1403
|
+
```powershell
|
|
1404
|
+
vp check
|
|
1405
|
+
vp run --filter server test
|
|
1406
|
+
vp run --filter server build
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
## 第 13 步:排查常见问题
|
|
1410
|
+
|
|
1411
|
+
| 现象 | 优先检查 |
|
|
1412
|
+
| -------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
1413
|
+
| 有注册日志,没有扫描日志 | `QUEUE_WORKER_ENABLED`、WorkerModule 是否导入、Redis 的 DB/Prefix 是否一致、队列是否暂停 |
|
|
1414
|
+
| 有扫描日志,始终 0 条 | `expiresAt`、`nextCleanupAt`、`cleanedAt`、候选状态是否符合 |
|
|
1415
|
+
| 看到候选,没有实际清理 | 是否仍在 `DRY_RUN=true`,本机 local 环境文件是否覆盖 |
|
|
1416
|
+
| Job completed,但没有 cleanedAt | 可能返回了 skipped;检查活动租约、状态、旧 Claim 和开关,Job 完成不等于业务清理成功 |
|
|
1417
|
+
| 长时间保持 expired | `lastCleanupError`、Worker failed 日志、MinIO 是否可用、有效分片租约是否一直续期 |
|
|
1418
|
+
| NoSuchUpload 后仍失败 | 是否沿用当前 Adapter 的错误映射;其他 403/超时不能按 NoSuchUpload 处理 |
|
|
1419
|
+
| 报 reconciliation required | 异常状态下发现正式对象;保留对象,核对会话元数据和完成流程 |
|
|
1420
|
+
| 完成/取消返回 PARTS_STILL_ACTIVE | 先等分片结束;失败请求可能保留最多 2 分钟的短租约 |
|
|
1421
|
+
| 基础 Redis 冒烟测试超时 | 拆分模块后是否按第 10.6 节提供 Worker 和业务替身 |
|
|
1422
|
+
| 关闭开关仍有扫描 | 是否重启、是否删除旧 Scheduler、其他副本是否又注册了规则 |
|
|
1423
|
+
| 单次清理超过 2 分钟 | 检查存储请求时长,补超时和续租;租约失效后本次不能写成功 |
|
|
1424
|
+
| 大量过期数据积压 | 看每轮批量、扫描耗时、Worker 并发和最老 nextCleanupAt,再调整吞吐 |
|
|
1425
|
+
|
|
1426
|
+
按扫描间隔 60 秒、批量 100 粗算,顺利情况下扫描投递能力约为每分钟 100 条;实际完成速度还受 Worker 和 MinIO 限制。不要一开始就把批量和并发同时放大。
|
|
1427
|
+
|
|
1428
|
+
## 第 14 步:部署时保留存储兜底和监控
|
|
1429
|
+
|
|
1430
|
+
应用扫描依赖数据库保留的 UploadId。初始化 MinIO 成功但数据库插入失败、服务长期停机等情况,可能留下数据库里找不到的孤儿 Multipart。因此还要由 MinIO 生命周期兜底。
|
|
1431
|
+
|
|
1432
|
+
在 Bucket 已有生命周期规则中**合并**下面这一条,例如 7 天后终止未完成上传:
|
|
1433
|
+
|
|
1434
|
+
```json
|
|
1435
|
+
{
|
|
1436
|
+
"ID": "abort-incomplete-multipart-after-7-days",
|
|
1437
|
+
"Status": "Enabled",
|
|
1438
|
+
"Filter": { "Prefix": "" },
|
|
1439
|
+
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
|
|
1440
|
+
}
|
|
1441
|
+
```
|
|
1442
|
+
|
|
1443
|
+
上面是一个 Rule,不是完整配置文件。完整配置使用 `{"Rules": [...]}`,先读取 Bucket 当前规则,把此 Rule 加入或按 ID 更新,再保存,避免覆盖其他保留策略。兜底时间要大于业务允许的上传时长,并留出排障时间;生命周期不会帮数据库写 `cleanedAt`。
|
|
1444
|
+
|
|
1445
|
+
至少持续观察扫描次数、候选数、清理成功数、最终失败 Job、最老未处理记录和长期 `completing` 记录。原始 SDK 错误可能包含连接和存储信息,公共日志只记录安全元数据;Redis 内的失败 Job 也应限权访问并按基础篇设置保留上限。
|
|
1446
|
+
|
|
1447
|
+
上线已有服务时,先迁移数据库,保持清理预览模式,部署完所有支持分片租约的新 API/Worker,排空旧实例及旧在途上传,再启用实际清理。新旧版本混跑时,旧 API 不登记租约,后台无法知道它正在上传,不能提前打开实际清理。
|
|
1448
|
+
|
|
1449
|
+
本教程使用租约、封口、结果确认和重复补偿处理常见并发与故障。生产还需验证数据库断连、进程暂停超过租约、存储慢请求及实例强杀等情况,并配置请求超时和告警,不能仅凭一次本地成功宣称所有故障已覆盖。
|
|
1450
|
+
|
|
1451
|
+
## 完成检查
|
|
1452
|
+
|
|
1453
|
+
- [ ] 公共队列冒烟任务已经跑通。
|
|
1454
|
+
- [ ] Scheduler 使用固定 ID,按配置周期自动扫描。
|
|
1455
|
+
- [ ] 前端关闭后仍然自动处理过期会话。
|
|
1456
|
+
- [ ] 预览模式只看候选,不操作存储。
|
|
1457
|
+
- [ ] 分片上传、Complete、取消和清理遵守同一套租约与封口规则。
|
|
1458
|
+
- [ ] 活动分片、未过期会话和正式文件不会被误清理。
|
|
1459
|
+
- [ ] MinIO 确认处理后才写 cleanedAt。
|
|
1460
|
+
- [ ] 单个 Job 失败能重试,次数耗尽后还有后续扫描补偿。
|
|
1461
|
+
- [ ] 多实例仍由数据库 Claim 控制清理权。
|
|
1462
|
+
- [ ] 重启不会累积重复规则,停用能删除已有规则。
|
|
1463
|
+
- [ ] 完成了测试及 PostgreSQL、Redis、MinIO 实际联调。
|
|
1464
|
+
|
|
1465
|
+
参考:[公共队列基础篇](./README.md)、[上传教程的过期清理说明](../04.upload.md#第-18-步过期清理和生产安全)、[BullMQ Job Schedulers](https://docs.bullmq.io/guide/job-schedulers)。如果以后实施了 [files 表上传改造](../05.上传改造方案/README.md),还需同步该新模型的文件状态;本文代码以当前只有 upload_sessions 的实现为准。
|