Files
UHD_Media_Tool/docs/runbook/storage.md
T

272 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 存储预算与保留(CACHE-2)
按 4C / 8G / 100G 的机器分配:PG ≤ 25 GB(20 GB 告警)、图片缓存 20 GB、备份 20 GB(14 份)、会话与证据 10 GB。
流水数据按保留期定时分批删除,缓存有效期延长,容量不够时按 LRU 淘汰。本轮只有后端与配置,设置页 UI 后续接。
## 设置 `settings.storage`
三组,在「设置 → 系统」(存储预算、数据保留)与「设置 → 缓存」(缓存有效期)。PATCH 可以只给子字段:
```sh
PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}}
```
| 字段 | 缺省 | 说明 |
|---|---|---|
| `budgets.pgDataGb` / `pgDataWarnGb` | 25 / 20 | PG 库体积(`pg_database_size`,不含 WAL)。≥ 告警线 warn,≥ 预算 critical |
| `budgets.imagesGb` | 20 | 图片代理缓存容量。超出由 `cache.images_lru` 按最久未访问删到 90% |
| `budgets.backupsGb` | 20 | `DATA_DIR/backups`。≥ 80% warn,≥ 预算 critical。份数仍由 `BACKUP_KEEP` 控制 |
| `budgets.sessionsEvidenceGb` | 10 | Agent 会话目录 + `DATA_DIR/tmp/evidence`。≥ 80% warn,≥ 预算 critical |
| `retentionDays.observation` | 90 | Feed 观察记录 |
| `retentionDays.sseEvent` | 7 | SSE 补读事件(发布方另按条数裁剪 10000 条) |
| `retentionDays.heartbeatHours` | 24 | 进程心跳采样(小时)。历史曲线最多只能看这么久 |
| `retentionDays.taskRun` | 60 | 已结束且没有被其它表引用的业务任务 `task`;成功的操作账本 |
| `retentionDays.agentEvent` | 90 | 已结束会话的事件流与已消费消息。`agent_run` 本身保留 |
| `retentionDays.audit` | 180 | 审计事件(能力调用审计除外) |
| `retentionDays.capabilityStats` | 90 | 能力调用审计(`target_type=capability`)与已结束的 `capability_invocation` |
| `retentionDays.identityPoolArchive` | 30 | 待识别池 unmatched 条目无新证据多少天后归档(不删) |
| `retentionDays.engineRun` | 14 | 已结束的引擎 run(步骤级联)与运行元数据 / 事实 / 信号;仍被保留中的业务任务引用的跟随任务 |
| `retentionDays.outboxEvent` | 30 | 已投递的 outbox 事件(只删无幂等键或一次性幂等键的,见「保留规则」) |
| `retentionDays.resolutionAttempt` | 30 | 识别尝试记录;每条资源至少保留最近 3 次 |
| `retentionDays.identityResolved` | 60 | 已识别(资源仍绑定作品)的待识别池条目;忽略、归档的不删 |
| `retentionDays.siteSnapshot` | 30 | 站点快照;每个站点最近一次成功的列表快照保留 |
| `cacheTtl.tmdbSearchDays` | 7 | TMDB `/search/*`、`/find/*` 响应与识别缓存 |
| `cacheTtl.tmdbDetailDays` | 30 | TMDB 详情 / 季 / 剧集组。关注中的作品命中时续期 |
| `cacheTtl.tmdbMissDays` | 1 | TMDB 空结果与未识别负缓存 |
| `cacheTtl.tmdbListHours` | 6 | TMDB 榜单 / 热门 / 趋势 / 按条件筛选(小时) |
| `cacheTtl.tmdbListEmptyHours` | 1 | TMDB 列表空结果(小时) |
| `cacheTtl.imageDays` | 90 | TMDB 人物 / 图片列表(`/person/*`、`…/images`) |
| `cacheTtl.metaSearchDays` | 7 | 豆瓣 / bgm 搜索请求(2026-10-06 起缺省 7 天,以前 1 天) |
| `cacheTtl.metaDetailDays` | 30 | 豆瓣 / bgm 条目、发现页条目 → TMDB 匹配 |
| `cacheTtl.metaMissDays` | 1 | 豆瓣 / bgm 搜索空结果、条目不存在 |
| `cacheTtl.metaListHours` | 6 | 豆瓣 / bgm 榜单、热门、每日放送等列表(小时) |
| `cacheTtl.metaEmptyHours` | 1 | 豆瓣 / bgm 列表空结果(小时) |
| `cacheTtl.siteDetailDays` | 7 | 站点详情页扩展内容(`kv_cache` `site.detail`) |
| `cacheTtl.webSearchDays` | 1 | 网页搜索结果 |
被取代的旧字段(保留在 schema 里兼容已存 JSON,服务端不再读取,已列入 `DEPRECATED_SETTINGS_FIELDS`):
`cache.tmdbTtlHours` → `cacheTtl.tmdbSearchDays / tmdbDetailDays`,`cache.emptyResultTtlSec` → `cacheTtl.tmdbMissDays`,
`identity.poolArchiveDays` → `retentionDays.identityPoolArchive`。设置页「元数据」里这三项接 UI 时要换成新字段。
`cache.tmdbMaxSize`(条数上限,缺省 5000)仍然有效。TTL 拉长后条数上限会先起作用,需要时调大。
## 缓存有效期
只改 TTL 的取值来源,缓存语义还是对照 MP(见 [porting/moviepilot-tmdb-cache.md](../porting/moviepilot-tmdb-cache.md))。
2026-10-06 起 TMDB / 豆瓣 / Bangumi 按「来源 × 资源类型」统一取值、过期先用旧值(宽限期 TTL 的 50%,最多 7 天),
完整策略表、两级缓存与关注作品的 pinned / 续期见 [cache.md](cache.md)「统一元数据缓存」。
- TMDB 请求缓存:键以 `/search/` 或 `/find/` 开头的用搜索 TTL;榜单类用列表 TTL;`/person/*`、`…/images` 用图片 TTL;其余用详情 TTL。`results` 为空的用未命中 TTL。
- TMDB 识别缓存:正常条目用搜索 TTL(识别结果就是名称到条目的映射),负缓存 `{id:0}` 用未命中 TTL。
- 关注中的作品续期:口径同 `isEntityWatched`,它的详情 / 季 / 剧集组 / 图片键命中时把过期时间推到「现在 + 本类 TTL」,
只延长不缩短,并标 pinned(条数上限淘汰时跳过)。关注集合每进程缓存 5 分钟。
- 站点详情:`kv_cache` TTL 取 `max(站点详情缓存期, siteDetailDays)`。是否重抓仍按站点详情缓存期
(`sites.defaultDetailCacheSec`,站点定义可更长)判断,下载前复核促销和 H&R 走的是这条。
- 改了 TTL:新写入的条目按新值,已有条目保持原过期时间。
## 图片缓存容量 LRU
- 读盘命中时把文件 atime 显式写成当前时间(`utimes`,不依赖挂载的 relatime),mtime 不动。
- `cache.images_lru`(维护组,30 分钟):总量超过 `imagesGb` 时按 atime 从旧到新删到预算的 90%;没超不动。
- `cache.images_clean`(每天,MP `clear_temp`)照旧按 mtime 删超过 `cache.globalImageCacheDays` 天的文件,作兜底。
- 存储面板里图片超过预算 10% 才告警(说明 LRU 没跟上),不给 critical。
## 保留清理任务
都在维护组,开关和周期在定时任务看板改。首次登记在一个周期后才第一次运行。
| key | 目标 | 默认周期 | 删除条件 |
|---|---|---|---|
| `storage.retention.observation` | `observation` | 6 小时 | `created_at` 早于保留期 |
| `storage.retention.sse_event` | `sse_event` | 1 小时 | `at` 早于保留期 |
| `system.heartbeat_cleanup` | 心跳 | 1 小时 | 进程行 24 小时无心跳;采样早于 `heartbeatHours` |
| `storage.retention.task_run` | `task`、引擎 `workflow_runs`(`step_attempts` 级联)、`tasks_run_meta` / `tasks_fact` / `workflow_signals` / `tasks_op_ledger` | 6 小时 | 见下「保留规则」 |
| `storage.retention.agent_event` | `agent_run_event`、`agent_run_message` | 6 小时 | 早于保留期且会话已结束;消息只删已消费的 |
| `storage.retention.audit` | `audit_event` | 1 天 | `at` 早于保留期,`target_type` 不是 capability |
| `storage.retention.capability_stats` | `audit_event`(capability)、`capability_invocation` | 1 天 | 早于保留期;调用记录不删 running |
| `storage.retention.pipeline` | `outbox_event`、`resolution_attempt`、`identity_candidate`(已识别)、`site_snapshot` | 每天 04:40(cron) | 见下「保留规则」 |
| `storage.retention.identity_pool` | `identity_candidate` | 6 小时 | pending + unmatched,`coalesce(last_attempt_at, created_at)` 早于保留期 → 改为 archived |
| `storage.analyze` | 全库 | 7 天 | `ANALYZE` |
| `cache.images_lru` | 图片缓存 | 30 分钟 | 见上 |
| `cache.sweep` | `kv_cache` | 10 分钟 | 已过硬过期(宽限期内的旧值保留) |
### 保留规则(2026-10-06 数据库清理)
规则写在 `modules/system/retention.ts`(每个任务的 `rules`),定时任务、`POST /system/storage/cleanup/{target}` 与
`scripts/db-retention.ts` 共用。原则:只删已结束、过期、没有被引用的行;拿不准的不删。
| 表 | 删除条件(都要同时满足) | 缺省保留 |
|---|---|---|
| `task` | 已结束(succeeded / failed / cancelled / dead)、`created_at` 早于保留期、没有被 `task.parent_task_id` / `acquisition_plan` / `asset_operation` / `agent_run` / `artifact` / `decision` / `reconcile_run` / `organization_record` 引用(这些外键是 set null,删了会断开引用) | `taskRun` 60 天 |
| 引擎 `workflow_runs` | 终态(completed / succeeded / failed / canceled);`created_at` 与结束时间都早于保留期;没有被「保留中的」`task.engine_run_id` 引用;有父 run 的,父 run 也已结束 | `engineRun` 14 天 |
| 引擎 `step_attempts` | 随 run 级联(外键 on delete cascade) | 同上 |
| `tasks_run_meta` / `tasks_fact` / 引擎 `workflow_signals` | 早于引擎保留期,且对应 run 已不存在或本次可删 | 同上 |
| `tasks_op_ledger` | `succeeded` 且 `started_at` 早于保留期(不变) | `taskRun` |
| `outbox_event` | `status = dispatched`、`at` 早于保留期,且幂等键为空或属于 `release.observed:` / `feed.due:` / `decision.created:` / `download.submitted:`(只在创建时发一次或带时间槽)。其它幂等键(生命周期事件 `media.watched/…`、`decision.updated:<id>` 等)靠「全表唯一」做永久去重,删了会重发,保留;pending / failed / dead 不删 | `outboxEvent` 30 天 |
| `resolution_attempt` | `created_at` 早于保留期,且同一资源有至少 3 次更新的尝试(每条资源留最近 3 次) | `resolutionAttempt` 30 天 |
| `identity_candidate` | `status = resolved`、`updated_at` 早于保留期、资源仍绑定作品、没有 `metadata_enrichment` 引用、`decision_id` 不指向未结事项。忽略(dismissed,是「不再自动识别」的标记)与归档(archived,仍可手动指定)不删 | `identityResolved` 60 天 |
| `site_snapshot` | `started_at` 早于保留期、不是 running、不是该站点最近一次成功(ok / partial)的列表快照(periodic / manual / backfill;站点刷新间隔按它判断) | `siteSnapshot` 30 天 |
| `scheduled_job_run` | 不按天数:每个任务只留最近 50 次(`trimRuns`,原有) | — |
| `cache_stat` | 不按天数:写入统计时顺带删 30 天前的行(原有) | — |
引擎语义核对(openworkflow 0.10.1):run 幂等键只在 24 小时内去重(`DEFAULT_RUN_IDEMPOTENCY_PERIOD_MS`),
认领 / 恢复只看 pending / running / sleeping,父 run 在 step 里等子 run 时读子 run 状态。
所以删 14 天前的终态 run 不影响去重与恢复;父 run 未结束的子 run 保留。业务侧去重看 `task` 表(`kind + idempotency_key`)
与 `enqueueSweep` 的时间槽,不依赖引擎旧 run。
删得多(≥ 1000 行)的表,任务结束时顺带 `ANALYZE`。
### 脚本 `scripts/db-retention.ts`
和定时任务同一套规则,用来对真实库先看清单再删(lessons 第 22 条):
```sh
# 只读:每张表将删多少行(缺省)
DATABASE_URL=postgres://umt:***@<host>:5432/umt deno run -A scripts/db-retention.ts
# 只看部分目标
DATABASE_URL=… deno run -A scripts/db-retention.ts --only task_run,pipeline
# 执行(分批、每批单独事务;本次不限 5 分钟,最多 1 小时;删完 ANALYZE)
DATABASE_URL=… deno run -A scripts/db-retention.ts --apply
```
保留天数取库里的 `settings.storage`(缺字段按缺省)。在宿主机源码目录里跑(app 镜像是编译产物,不带脚本);
部署库的 postgres 没有映射端口,需要临时映射或在 compose 网络里起一个带 deno 的容器去连。
引擎 schema 不是 `openworkflow` 时设 `ENGINE_SCHEMA`。不含心跳、图片、kv_cache(各自任务管)。
测试库按部署规模造数(2.35 万 run / 1.1 万 outbox / 1.7 万识别尝试)实测:删 3.7 万行用时约 1.4 秒。
分批删除(`modules/system/retention.ts` 的 `deleteInBatches`):
- 每批 5000 行:`delete … where ctid = any(array(select ctid … where <条件> limit 5000))`,单独事务。
- 每批 `statement_timeout` 15 秒、`lock_timeout` 3 秒。超时就结束本次运行,不算失败,摘要写「未删完,下次继续」。
- 每次运行最多 5 分钟,批间停 50 ms。第一次上线积压多时会分几次删完。
- 删除后空间由 autovacuum 回收复用,不会立即还给文件系统。确实要缩文件用 `pg_repack` 或停机 `VACUUM FULL`。
迁移 `0050_storage_retention`:给 `observation(created_at)`、`sse_event(at)`、`audit_event(at)`、`capability_invocation(created_at)`、
`agent_run_message(created_at)`、`tasks_fact(created_at)`、`tasks_run_meta(created_at)`、`tasks_op_ledger(started_at)` 补索引;
按表设 autovacuum:
- 追加加批量删除的大表(observation、sse_event、audit_event、agent_run_event、process_heartbeat_sample、capability_invocation、task、tasks_fact、tasks_run_meta):
`vacuum_scale_factor=0.05`、`analyze_scale_factor=0.02`、`vacuum_insert_scale_factor=0.1`。
- 频繁原地更新的小表(process_heartbeat、rate_limit_bucket、scheduled_job、kv_cache、outbox_event):
`vacuum_scale_factor=0.02`、`vacuum_threshold=100`、`analyze_scale_factor=0.05`。
查看:`select relname, reloptions from pg_class where reloptions is not null;`
## API
### GET /system/storage(admin)
| 字段 | 含义 |
|---|---|
| `level` | 各项最差档:ok / warn / critical,取不到的项 unknown 不参与 |
| `db` | 库体积对照 `pgDataGb` / `pgDataWarnGb`;`tables` 为体积前 15 的表(总 / 表 / 索引字节、估算行数、死元组、最近 autovacuum / analyze);`statements` 为语句统计(见「数据库性能」) |
| `kvCache[]` | 每个 region 的条数、其中已过期未清理的条数、字节 |
| `images` | 图片缓存用量、文件数、预算 |
| `backups` | 备份目录用量、份数(不含 `.partial`)、预算 |
| `sessionsEvidence` | 会话目录 + `tmp/evidence` 合计、预算 |
| `dataDir[]` | DATA_DIR 一级目录用量,`cache/`、`tmp/` 再细分一层;会话目录在 DATA_DIR 外时单列 |
| `dirsScannedAt` | 目录遍历时间。遍历结果缓存 5 分钟 |
| `cleanup[]` | 每个清理目标:任务 key、保留期说明、开关、最近运行时间 / 状态 / 摘要、下次运行 |
### POST /system/storage/cleanup/{target}(admin)
`target`:`observation`、`sse_event`、`heartbeat`、`task_run`、`agent_event`、`audit`、`capability_stats`、`pipeline`、`identity_pool`、`analyze`、`images`、`kv_cache`。
和定时任务「立即执行」一样,只设置 `run_requested_at`,由调度器执行。已在排队或运行中返回 `queued:false`。
任务还没登记(调度器没同步过)返回 404。触发成功写审计 `storage.cleanup`。
### GET /system/health 的 `storage` 段
`{ level, alerts[] }`,level 计入总档位。库体积实时取;目录用量只用缓存值,过期时后台刷新,本次不等
(进程刚启动时目录项是 unknown,不参与汇总)。
## 运行状态页「存储」节(FIX-5)
控制台 → 运行状态最下方。`GET /system/health` 的 `storage.level / alerts` 显示为档位与告警条(横幅也会提示),
`GET /system/storage` 显示:数据库体积 / 图片缓存 / 备份 / 会话与证据对照预算的用量条(竖线是告警线)、体积前 10 的表、
`kv_cache` 各类缓存的条数 / 已过期 / 体积、DATA_DIR 各子目录占用、各清理任务的保留期与上次结果。每行「立即清理」调
`POST /system/storage/cleanup/{target}`(已在排队或运行中则合并),结果看计划页的运行记录。明细每分钟刷新,目录用量服务端缓存 5 分钟。
代码:`apps/web/app/components/status/storage-section.tsx`。
## PG 参数
`docker-compose.yml` 的 postgres 服务按 4C / 8G 给 PG 3G 内存:
| 参数 | 缺省 | 环境变量 | 经验值 |
|---|---|---|---|
| 容器内存上限 | 3g | `PG_MEM_LIMIT` | 机器内存的 35–40% |
| `shared_buffers` | 2GB | `PG_SHARED_BUFFERS` | 容器上限的 60–70%(不超过机器内存的 25–30%) |
| `effective_cache_size` | 5GB | `PG_EFFECTIVE_CACHE_SIZE` | 机器内存的 60–70%(只影响规划,不占内存) |
| `work_mem` | 32MB | `PG_WORK_MEM` | 每个排序 / 哈希一份,连接多时调小 |
| `maintenance_work_mem` | 256MB | `PG_MAINTENANCE_WORK_MEM` | VACUUM / 建索引用 |
| `max_wal_size` | 2GB | `PG_MAX_WAL_SIZE` | 批量删除时减少检查点 |
| `random_page_cost` | 1.1 | `PG_RANDOM_PAGE_COST` | 库整个在内存里时 1.1;缺省 4 是机械盘的估值 |
另外 `shm_size: 512mb`(并行查询的动态共享内存走 /dev/shm),`shared_preload_libraries=pg_stat_statements`
与 `pg_stat_statements.track=top`(语句统计,见下)。
2026-10-06 评估(部署库 172 MB):
- `shared_buffers` 2GB 远大于库,但只是上限,页用到才占内存;容器上限 3g 也没紧张,不改。
- `random_page_cost` 4 → 1.1:库全部命中缓存(`shared_buffers` + 页缓存),随机读的代价被高估,
规划器在中等表上会偏向整表扫描。这是唯一调整的参数。
- `work_mem` / `maintenance_work_mem` / `max_wal_size` 按 4C/8G 给的值,没有看到排序落盘或检查点过频,不改。
- autovacuum 开着,按表参数见上(迁移 0050);部署库死元组都在几十行以内,不改。
按机器调整,例如 2C/4G:`PG_MEM_LIMIT=1536m PG_SHARED_BUFFERS=768MB PG_EFFECTIVE_CACHE_SIZE=2GB PG_WORK_MEM=16MB PG_MAINTENANCE_WORK_MEM=128MB`;
8C/16G:`PG_MEM_LIMIT=6g PG_SHARED_BUFFERS=4GB PG_EFFECTIVE_CACHE_SIZE=10GB PG_WORK_MEM=64MB PG_MAINTENANCE_WORK_MEM=512MB`。
写进 `.env` 后 `docker compose up -d postgres` 重建容器生效(数据在卷里,不丢)。确认:`docker compose exec postgres psql -U umt -c 'show shared_buffers'`。
`shared_buffers` 大于容器上限会在启动时 OOM,两者一起改。
## 数据库性能(2026-10-06)
### 语句统计 pg_stat_statements
- 迁移 `0089_db_perf` 在镜像带这个扩展时 `create extension if not exists pg_stat_statements`(建在 public);
扩展不存在或权限不够(非超级用户)时跳过,迁移照常成功。
- 采集要求 postgres 以 `shared_preload_libraries=pg_stat_statements` 启动(`docker-compose.yml` 已加,`track=top`
只记顶层语句)。**改 compose 后要重建 postgres 容器才生效**:
```sh
docker compose --env-file .env.deploy up -d postgres # 重建 postgres(数据在卷里不丢);app 会短暂断开数据库后自动重连
docker compose --env-file .env.deploy exec postgres psql -U umt -c 'show shared_preload_libraries'
```
- 查看:运行状态页「存储 → 数据库语句统计」(平均最慢的 5 条,只看调用 ≥ 10 次的;调用最多的 5 条),
对应 `GET /system/storage` 的 `db.statements`;没装扩展 / 没预加载时 `enabled=false` 并在 `note` 里说明。
命令行:
```sql
select calls, round(mean_exec_time::numeric, 3) as mean_ms, round(total_exec_time::numeric) as total_ms, query
from pg_stat_statements order by total_exec_time desc limit 20;
select pg_stat_statements_reset(); -- 清零重新统计
```
- 同一节的「体积最大的表」加了死元组列(`pg_stat_all_tables.n_dead_tup`)。
### 高频全表扫描的结论
部署库的 `pg_stat_user_tables` 里 seq_scan 高的表大多只有几十到一百多行(`scheduled_job` 33、`task` 81、`admin_session` 139、
`title_alias` 177、`external_identity` 74、`site` 13、`download_task` 4):这种表 1~5 页,整表扫描比走索引便宜,
规划器选 seq scan 是对的,对应的唯一索引 / 主键「从未使用」也是这个原因(比如会话校验本来就是按 `session_hash`
等值查,按 10 倍数据量造数后就走 `admin_session_hash_key`)。真正改了的:
- 巡检回填(调度器每 15 秒)原来每次整扫 `scheduled_job_run`:加部分索引 `scheduled_job_run_harvest_idx`。
- 标题模糊匹配原来写 `similarity(x, q) >= t`,函数调用用不上 trigram 索引:改为 `x % q and similarity(x, q) >= t`
(`%` 的缺省阈值 0.3 不高于各处阈值,结果相同)。`source_release` 标题搜索 8440 行从 75 ms 降到 0.7 ms;
别名匹配(`title_alias`)现在表太小,规划器仍选整表扫描,表长大后会改走索引。
- 调度器每秒查一次到期任务(33 行,约 0.15 ms)保留不改:它决定「立即执行」的响应延迟,成本可以忽略。
- 删除从未使用且没有查询会用到的 `content_unit_air_date_idx`。其余从未使用的索引有用途,保留:
`source_release_title_trgm_idx`(上面的标题搜索)、`resolution_attempt_release_idx`(保留规则「每条资源留最近 3 次」)、
`source_release_infohash_v1_idx`(按 hash 接管下载 / 浏览器核验)、`audit_event_action_idx`(能力调用统计)、
主键与唯一约束(保证唯一性)。
## 常见问题
- 面板上 PG 超预算:先看 `tables` 前几名。流水表大就临时调短对应保留期,再 `POST /system/storage/cleanup/<target>`;
死元组多说明 autovacuum 跟不上,可手动 `VACUUM (VERBOSE) <表>`。
- 清理摘要一直是「未删完」:积压太多,每次 5 分钟删不完。可以把任务周期临时调短,或在低峰期多触发几次。
- 图片缓存一直 warn:LRU 任务是否启用、最近一次是否失败;磁盘上是否有大量 `.tmp` 残留(不计入用量,可手动删)。
- 历史曲线只能看 24 小时:这是 `heartbeatHours` 的缺省,要看更久就调大。