feat(settings): storage budgets and retention in settings UI, tmdbMaxSize default 5000 (INT-3)
- Automation section gets storage budget, retention and cache TTL cards bound to settings.storage - Metadata section drops deprecated cache.tmdbTtlHours / cache.emptyResultTtlSec / identity.poolArchiveDays inputs - cache.tmdbMaxSize default 256 -> 5000; migration 0051 bumps stored values still at the old default Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0a8bfbb63e
commit
fe05138c9c
7 files changed
+119
-31
No files matched your search
@@ -5,11 +5,11 @@ import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { num } from "./resource-section.tsx";
|
||||
import { type FormValues, num } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数
|
||||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
|
||||
@@ -89,6 +89,88 @@ const RETENTION_CARD: CardSpec = {
|
||||
}),
|
||||
};
|
||||
|
||||
// CACHE-2:存储预算、流水保留期、缓存有效期(settings.storage,docs/runbook/storage.md)
|
||||
const gbField = (k: string, label: string, hint?: string) => ({
|
||||
name: `storage.budgets.${k}`,
|
||||
label,
|
||||
kind: "number" as const,
|
||||
hint,
|
||||
});
|
||||
const dayField = (group: "retentionDays" | "cacheTtl", k: string, label: string, hint?: string) => ({
|
||||
name: `storage.${group}.${k}`,
|
||||
label,
|
||||
kind: "number" as const,
|
||||
hint,
|
||||
});
|
||||
const pick = (v: FormValues, prefix: string, keys: readonly string[]) =>
|
||||
Object.fromEntries(keys.map((k) => [k, num(v[`${prefix}.${k}`])]));
|
||||
|
||||
const BUDGET_KEYS = ["pgDataGb", "pgDataWarnGb", "imagesGb", "backupsGb", "sessionsEvidenceGb"] as const;
|
||||
const STORAGE_BUDGET_CARD: CardSpec = {
|
||||
anchor: "storage",
|
||||
title: "存储预算",
|
||||
description: "超过预算时健康页告警;图片缓存超出容量按最久未访问删除。单位 GB。",
|
||||
fields: [
|
||||
gbField("pgDataGb", "数据库预算", "超过为严重"),
|
||||
gbField("pgDataWarnGb", "数据库告警线", "不高于数据库预算"),
|
||||
gbField("imagesGb", "图片缓存容量"),
|
||||
gbField("backupsGb", "备份目录预算"),
|
||||
gbField("sessionsEvidenceGb", "Agent 会话与截图证据预算"),
|
||||
],
|
||||
fromSettings: (s) => Object.fromEntries(BUDGET_KEYS.map((k) => [`storage.budgets.${k}`, n(s.storage.budgets[k])])),
|
||||
toPatch: (v) => ({ storage: { budgets: pick(v, "storage.budgets", BUDGET_KEYS) } }),
|
||||
};
|
||||
|
||||
const RETENTION_KEYS = [
|
||||
"observation",
|
||||
"sseEvent",
|
||||
"heartbeatHours",
|
||||
"taskRun",
|
||||
"agentEvent",
|
||||
"audit",
|
||||
"capabilityStats",
|
||||
"identityPoolArchive",
|
||||
] as const;
|
||||
const STORAGE_RETENTION_CARD: CardSpec = {
|
||||
anchor: "storage-retention",
|
||||
title: "流水数据保留",
|
||||
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
|
||||
fields: [
|
||||
dayField("retentionDays", "observation", "Feed 观察"),
|
||||
dayField("retentionDays", "sseEvent", "实时事件"),
|
||||
dayField("retentionDays", "heartbeatHours", "进程心跳(小时)"),
|
||||
dayField("retentionDays", "taskRun", "任务运行记录"),
|
||||
dayField("retentionDays", "agentEvent", "Agent 会话事件", "只删已结束的会话"),
|
||||
dayField("retentionDays", "audit", "审计日志", "能力调用审计除外"),
|
||||
dayField("retentionDays", "capabilityStats", "能力调用统计"),
|
||||
dayField(
|
||||
"retentionDays",
|
||||
"identityPoolArchive",
|
||||
"待识别池归档",
|
||||
"未命中的条目这么久没有新证据就归档,仍可手动指定",
|
||||
),
|
||||
],
|
||||
fromSettings: (s) =>
|
||||
Object.fromEntries(RETENTION_KEYS.map((k) => [`storage.retentionDays.${k}`, n(s.storage.retentionDays[k])])),
|
||||
toPatch: (v) => ({ storage: { retentionDays: pick(v, "storage.retentionDays", RETENTION_KEYS) } }),
|
||||
};
|
||||
|
||||
const TTL_KEYS = ["tmdbSearchDays", "tmdbDetailDays", "tmdbMissDays", "siteDetailDays", "webSearchDays"] as const;
|
||||
const STORAGE_TTL_CARD: CardSpec = {
|
||||
anchor: "storage-ttl",
|
||||
title: "缓存有效期",
|
||||
description: "单位天。追踪中的作品命中 TMDB 详情缓存时自动续期。",
|
||||
fields: [
|
||||
dayField("cacheTtl", "tmdbSearchDays", "TMDB 搜索与识别结果"),
|
||||
dayField("cacheTtl", "tmdbDetailDays", "TMDB 详情"),
|
||||
dayField("cacheTtl", "tmdbMissDays", "TMDB 空结果", "搜索无结果、未识别时的负缓存"),
|
||||
dayField("cacheTtl", "siteDetailDays", "站点详情页内容"),
|
||||
dayField("cacheTtl", "webSearchDays", "网页搜索结果"),
|
||||
],
|
||||
fromSettings: (s) => Object.fromEntries(TTL_KEYS.map((k) => [`storage.cacheTtl.${k}`, n(s.storage.cacheTtl[k])])),
|
||||
toPatch: (v) => ({ storage: { cacheTtl: pick(v, "storage.cacheTtl", TTL_KEYS) } }),
|
||||
};
|
||||
|
||||
function ToggleRow({
|
||||
id,
|
||||
icon,
|
||||
@@ -181,6 +263,9 @@ export function AutomationSection() {
|
||||
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
||||
</>
|
||||
)}
|
||||
</SettingsGate>
|
||||
|
||||
@@ -102,27 +102,15 @@ function TmdbKeyStatus({ saved }: { saved: boolean }) {
|
||||
const TMDB_CACHE_CARD: CardSpec = {
|
||||
anchor: "tmdb-cache",
|
||||
title: "TMDB 缓存",
|
||||
description: "请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目。",
|
||||
fields: [
|
||||
{ name: "cache.tmdbTtlHours", label: "有效期(小时)", kind: "number" },
|
||||
{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" },
|
||||
{
|
||||
name: "cache.emptyResultTtlSec",
|
||||
label: "空结果有效期(秒)",
|
||||
kind: "number",
|
||||
hint: "搜索无结果、未识别时的短缓存",
|
||||
},
|
||||
],
|
||||
description:
|
||||
"请求响应与识别结果存在数据库里,三个进程共用。超过条数上限时删最久未用的条目;有效期在「自动化 → 存储与保留」里设。",
|
||||
fields: [{ name: "cache.tmdbMaxSize", label: "条数上限", kind: "number", hint: "请求缓存和识别缓存各自计数" }],
|
||||
fromSettings: (s) => ({
|
||||
"cache.tmdbTtlHours": n(s.cache.tmdbTtlHours),
|
||||
"cache.tmdbMaxSize": n(s.cache.tmdbMaxSize),
|
||||
"cache.emptyResultTtlSec": n(s.cache.emptyResultTtlSec),
|
||||
}),
|
||||
toPatch: (v) => ({
|
||||
cache: {
|
||||
tmdbTtlHours: Number(v["cache.tmdbTtlHours"]),
|
||||
tmdbMaxSize: Number(v["cache.tmdbMaxSize"]),
|
||||
emptyResultTtlSec: Number(v["cache.emptyResultTtlSec"]),
|
||||
},
|
||||
}),
|
||||
extra: () => <TmdbCacheStatus />,
|
||||
@@ -224,12 +212,6 @@ const IDENTITY_CARD: CardSpec = {
|
||||
hint: "旧行为。每条资源约 1–2 次搜索 + 命中时 1 次详情(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到",
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "identity.poolArchiveDays",
|
||||
label: "待识别池归档(天)",
|
||||
kind: "number",
|
||||
hint: "未命中订阅的条目超过这么多天没有新证据就归档,仍可筛出并手动指定",
|
||||
},
|
||||
{
|
||||
name: "identity.autoEnrich",
|
||||
label: "TMDB 查不到时自动补全元数据",
|
||||
@@ -253,7 +235,6 @@ const IDENTITY_CARD: CardSpec = {
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"identity.eagerTmdbLookup": s.identity.eagerTmdbLookup,
|
||||
"identity.poolArchiveDays": n(s.identity.poolArchiveDays),
|
||||
"identity.autoEnrich": s.identity.autoEnrich,
|
||||
"identity.enrichDailyBudget": n(s.identity.enrichDailyBudget),
|
||||
"identity.enrichDomains": s.identity.enrichDomains.join("\n"),
|
||||
@@ -261,7 +242,6 @@ const IDENTITY_CARD: CardSpec = {
|
||||
toPatch: (v) => ({
|
||||
identity: {
|
||||
eagerTmdbLookup: v["identity.eagerTmdbLookup"] === true,
|
||||
poolArchiveDays: Number(v["identity.poolArchiveDays"]),
|
||||
autoEnrich: v["identity.autoEnrich"] === true,
|
||||
enrichDailyBudget: Number(v["identity.enrichDailyBudget"]),
|
||||
enrichDomains: String(v["identity.enrichDomains"] ?? "")
|
||||
|
||||
@@ -23,7 +23,7 @@ PG 读写失败时 TMDB 路径按未命中处理(内存缓存不会失败)。
|
||||
|
||||
| 上游 | 我们 |
|
||||
|---|---|
|
||||
| `maxsize = CONF.tmdb`(256;大内存 1024) | 设置 `cache.tmdbMaxSize`(缺省 256) |
|
||||
| `maxsize = CONF.tmdb`(256;大内存 1024) | 设置 `cache.tmdbMaxSize`(缺省 5000,存 PG 不受进程内存约束) |
|
||||
| `ttl = CONF.meta = META_CACHE_EXPIRE 小时`(缺省 24) | CACHE-2 起按请求类别取设置:`/search/`、`/find/` 用 `storage.cacheTtl.tmdbSearchDays`(7 天),其余用 `tmdbDetailDays`(30 天) |
|
||||
| `empty_ttl = EMPTY_RESULT_CACHE_TTL`(1800 秒),`empty_if = results 为空列表` | `storage.cacheTtl.tmdbMissDays`(1 天);`isEmptyResult` 同判定 |
|
||||
| `skip_none`(连接失败不缓存)、`skip_if = success:false`(业务失败不缓存) | 这两类在 `fetchWithRetry` 抛错,不会写缓存 |
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
TTL 设置(CACHE-2 起):`settings.storage.cacheTtl`,见 [storage.md](storage.md)。MP 的 `META_CACHE_EXPIRE` 只有一个值,
|
||||
我们按请求类别拆成搜索 / 详情两档,`EMPTY_RESULT_CACHE_TTL` 对应 `tmdbMissDays`;缓存语义不变。
|
||||
条数上限仍是 `cache.tmdbMaxSize`(缺省 256,MP `CONF.tmdb`)。旧字段 `cache.tmdbTtlHours`、`cache.emptyResultTtlSec` 不再读取。
|
||||
条数上限仍是 `cache.tmdbMaxSize`(缺省 5000;MP `CONF.tmdb` 为 256,本项目缓存在 PG 里不占进程内存)。旧字段 `cache.tmdbTtlHours`、`cache.emptyResultTtlSec` 不再读取。
|
||||
|
||||
API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 / 过期 / 大小,识别条目带 tmdbId / 标题 / 海报);
|
||||
`POST /api/v1/cache/tmdb/clear` 清空两类(仅 admin)。
|
||||
@@ -63,5 +63,5 @@ API:`GET /api/v1/cache/tmdb?kind=recognize|request&limit=` 列出条目(键 /
|
||||
- TMDB 缓存不生效:确认 `select region, count(*) from kv_cache group by 1` 有 `tmdb.request`;PG 读写失败时按未命中处理并记
|
||||
`TMDB 缓存读写失败,按未命中处理`。
|
||||
- 改了 TMDB 缓存设置:新写入的条目按新 TTL / 上限;已有条目保持原过期时间,必要时清空。
|
||||
- TTL 拉长后条目更早被条数上限挤掉:`cache.tmdbMaxSize` 缺省只有 256,追踪作品多时调大。
|
||||
- TTL 拉长后条目更早被条数上限挤掉:`cache.tmdbMaxSize` 缺省 5000,追踪作品很多时再调大。
|
||||
- 数据库重启后缓存为空:UNLOGGED 表的正常行为。
|
||||
@@ -34,7 +34,7 @@ PATCH /api/v1/settings {"storage":{"retentionDays":{"observation":60}}}
|
||||
被取代的旧字段(保留在 schema 里兼容已存 JSON,服务端不再读取,已列入 `DEPRECATED_SETTINGS_FIELDS`):
|
||||
`cache.tmdbTtlHours` → `cacheTtl.tmdbSearchDays / tmdbDetailDays`,`cache.emptyResultTtlSec` → `cacheTtl.tmdbMissDays`,
|
||||
`identity.poolArchiveDays` → `retentionDays.identityPoolArchive`。设置页「元数据」里这三项接 UI 时要换成新字段。
|
||||
`cache.tmdbMaxSize`(条数上限,缺省 256)仍然有效。TTL 拉长后条数上限会先起作用,需要时调大。
|
||||
`cache.tmdbMaxSize`(条数上限,缺省 5000)仍然有效。TTL 拉长后条数上限会先起作用,需要时调大。
|
||||
|
||||
## 缓存有效期
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const CacheShape = z.object({
|
||||
* 保留在 schema 里只为兼容已存的 JSON 与旧设置页。
|
||||
*/
|
||||
tmdbTtlHours: z.number().int().min(1).max(720),
|
||||
/** TMDB 缓存每类条数上限,超出删最旧(对照 MP CONF.tmdb,缺省 256;大内存模式 1024) */
|
||||
/** TMDB 缓存每类条数上限,超出删最旧(MP CONF.tmdb 为 256 / 大内存 1024;本项目存 PG 不占进程内存,缺省 5000) */
|
||||
tmdbMaxSize: z.number().int().min(16).max(100_000),
|
||||
/** @deprecated CACHE-2 起由 storage.cacheTtl.tmdbMissDays 取代,服务端不再读取 */
|
||||
emptyResultTtlSec: z.number().int().min(0).max(86_400),
|
||||
@@ -566,7 +566,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
checkIntervalSec: 60,
|
||||
cleanupTargetPercent: 80,
|
||||
tmdbTtlHours: 24,
|
||||
tmdbMaxSize: 256,
|
||||
tmdbMaxSize: 5000,
|
||||
emptyResultTtlSec: 1800,
|
||||
globalImageCache: false,
|
||||
globalImageCacheDays: 7,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl } from "../src/migration-helpers.ts";
|
||||
|
||||
// INT-3:cache.tmdbMaxSize 缺省 256 → 5000(CACHE-2 把 TTL 拉长到天级后,256 条先被挤掉)。
|
||||
// settings 表里整段存 cache 对象,已保存过设置的库仍是旧缺省 256:只改仍等于旧缺省的值,用户改过的不动。
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`update settings set value = jsonb_set(value, '{tmdbMaxSize}', '5000'::jsonb), updated_at = now()
|
||||
where key = 'cache' and value->'tmdbMaxSize' = '256'::jsonb;`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`update settings set value = jsonb_set(value, '{tmdbMaxSize}', '256'::jsonb), updated_at = now()
|
||||
where key = 'cache' and value->'tmdbMaxSize' = '5000'::jsonb;`,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user