diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts index 1f968178..c80e25db 100644 --- a/apps/server/src/modules/file-manager/identify.ts +++ b/apps/server/src/modules/file-manager/identify.ts @@ -240,7 +240,8 @@ export async function identifyCore( const rows = await sql<{ media_entity_id: string }>` select distinct a.media_entity_id from title_alias a join media_entity e on e.id = a.media_entity_id where e.status not in ('merged', 'archived') - and (a.normalized = ${q} or (length(${q}) >= 3 and similarity(a.normalized, ${q}) >= ${TRGM})) + and (a.normalized = ${q} + ${[...q].length >= 3 ? sql`or (a.normalized % ${q} and similarity(a.normalized, ${q}) >= ${TRGM})` : sql``}) limit 20`.execute(db); for (const r of rows.rows) ids.add(r.media_entity_id); } diff --git a/apps/server/src/modules/schedules/scheduler.ts b/apps/server/src/modules/schedules/scheduler.ts index 710735c7..c2f69cf8 100644 --- a/apps/server/src/modules/schedules/scheduler.ts +++ b/apps/server/src/modules/schedules/scheduler.ts @@ -289,7 +289,10 @@ export class Scheduler { if (manual || status !== job.last_status || status === "failed") this.publish(job.id, status); } - /** 巡检入队的 agent_run 结束后:回填终态与摘要;配置了 reportAsDecision 的写一条 info 待处理 */ + /** + * 巡检入队的 agent_run 结束后:回填终态与摘要;配置了 reportAsDecision 的写一条 info 待处理。 + * 条件与部分索引 scheduled_job_run_harvest_idx(迁移 0089)一致,每 15 秒一次不再全表扫运行历史。 + */ async harvest(): Promise { const rows = await sql<{ id: string; @@ -304,7 +307,7 @@ export class Scheduler { from scheduled_job_run r join scheduled_job j on j.id = r.job_id join agent_run a on a.id = r.agent_run_id - where r.agent_status is null and a.status = any(${TERMINAL_AGENT}) + where r.agent_status is null and r.agent_run_id is not null and a.status = any(${TERMINAL_AGENT}) limit 50`.execute(this.db); for (const r of rows.rows) { const summary = (r.summary ?? "").trim() || `智能体运行结束:${r.status}`; diff --git a/apps/server/src/modules/search/media.ts b/apps/server/src/modules/search/media.ts index 04f32fdc..5ad6eaca 100644 --- a/apps/server/src/modules/search/media.ts +++ b/apps/server/src/modules/search/media.ts @@ -47,7 +47,8 @@ export async function searchLocalMedia( a.alias as matched from title_alias a join media_entity e on e.id = a.media_entity_id where e.status <> 'merged' - and (a.normalized = ${n} or a.normalized like ${`${n}%`} or similarity(a.normalized, ${n}) >= 0.3) + and (a.normalized = ${n} or a.normalized like ${`${n}%`} + or (a.normalized % ${n} and similarity(a.normalized, ${n}) >= 0.3)) order by e.id, score desc limit 200`.execute(ctx.db); rows.push(...r.rows); diff --git a/apps/server/src/modules/sources-identity/capabilities.ts b/apps/server/src/modules/sources-identity/capabilities.ts index 45fbc642..7a663e1b 100644 --- a/apps/server/src/modules/sources-identity/capabilities.ts +++ b/apps/server/src/modules/sources-identity/capabilities.ts @@ -39,7 +39,8 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: () select distinct on (e.id) e.*, greatest(similarity(a.normalized, ${n}), case when a.normalized = ${n} then 1 else 0 end) as score, a.alias as matched from title_alias a join media_entity e on e.id = a.media_entity_id - where e.status <> 'merged' and (a.normalized = ${n} or similarity(a.normalized, ${n}) >= 0.3) + where e.status <> 'merged' + and (a.normalized = ${n} or (a.normalized % ${n} and similarity(a.normalized, ${n}) >= 0.3)) ${input.kind ? sql`and e.kind = ${input.kind}` : sql``} ${input.year ? sql`and (e.year is null or abs(e.year - ${input.year}) <= 1)` : sql``} order by e.id, score desc`.execute(db); diff --git a/apps/server/src/modules/sources-identity/routes/identity.ts b/apps/server/src/modules/sources-identity/routes/identity.ts index 9e8de47c..0e7fad32 100644 --- a/apps/server/src/modules/sources-identity/routes/identity.ts +++ b/apps/server/src/modules/sources-identity/routes/identity.ts @@ -389,7 +389,7 @@ export function identityRoutes(app: ModuleApp, rt: () => SourcesRuntime) { if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId); if (query.q) { const n = normalizeTitle(query.q); - q = q.where(sql`(normalized = ${n} or similarity(normalized, ${n}) >= 0.3)`); + q = q.where(sql`(normalized = ${n} or (normalized % ${n} and similarity(normalized, ${n}) >= 0.3))`); } const rows = await q .orderBy("created_at") diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index 98ee8aad..963de3ac 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -852,7 +852,11 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { if (query.resolutionStatus) q = q.where("resolution_status", "=", query.resolutionStatus); if (query.q) q = q.where((eb) => - eb.or([eb("title", "ilike", `%${query.q}%`), sql`similarity(title, ${query.q}) >= 0.3`]), + eb.or([ + eb("title", "ilike", `%${query.q}%`), + // `%` 让 source_release_title_trgm_idx 可用(两个分支都走索引,BitmapOr) + sql`(title % ${query.q} and similarity(title, ${query.q}) >= 0.3)`, + ]), ); const rows = await q .orderBy("first_seen_at", "desc") diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index ea776c5b..9b5874a5 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -164,9 +164,11 @@ async function localCandidates( for (const r of row) if (r.media_entity_id) ids.add(r.media_entity_id); } for (const q of queryTitles(parsed)) { + // `%` 让 title_alias_normalized_trgm_idx 可用(阈值 0.3 ≤ TRGM_THRESHOLD,结果与只写 similarity 相同) const rows = await sql<{ media_entity_id: string }>` select distinct media_entity_id from title_alias - where normalized = ${q} or (length(${q}) >= 3 and similarity(normalized, ${q}) >= ${TRGM_THRESHOLD}) + where normalized = ${q} + ${[...q].length >= 3 ? sql`or (normalized % ${q} and similarity(normalized, ${q}) >= ${TRGM_THRESHOLD})` : sql``} limit 20`.execute(exec); for (const r of rows.rows) ids.add(r.media_entity_id); } diff --git a/packages/db/migrations/0089_db_perf.ts b/packages/db/migrations/0089_db_perf.ts new file mode 100644 index 00000000..28ed1765 --- /dev/null +++ b/packages/db/migrations/0089_db_perf.ts @@ -0,0 +1,45 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// 数据库性能与可观测(docs/runbook/storage.md「数据库性能」): +// - scheduled_job_run_harvest_idx:调度器每 15 秒回填巡检结果(scheduler.harvest),原来每次全表扫运行历史; +// 部分索引只含「有 agent_run 且尚未回填」的行,通常为 0~几行; +// - 删除 content_unit_air_date_idx:没有任何查询按 air_date 过滤或排序(订阅日历在内存里按日期筛), +// 按作品取单元走 content_unit_entity_numbering_idx; +// - pg_stat_statements:镜像自带时才建(库级共享对象,放 public;down 不卸载)。要真正采集还需要 +// postgres 以 shared_preload_libraries=pg_stat_statements 启动(docker-compose.yml),没有预加载时只是查询会报错, +// 状态页按「未启用」处理。没有该扩展或权限不够时跳过,不让迁移失败。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + ` +create index if not exists scheduled_job_run_harvest_idx on scheduled_job_run (agent_run_id) + where agent_status is null and agent_run_id is not null; +drop index if exists content_unit_air_date_idx; +do $$ +begin + if exists (select 1 from pg_available_extensions where name = 'pg_stat_statements') then + begin + create extension if not exists pg_stat_statements with schema public; + exception when others then + raise notice 'pg_stat_statements 未安装:%', sqlerrm; + end; + end if; +end $$; +`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + ` +drop index if exists scheduled_job_run_harvest_idx; +create index if not exists content_unit_air_date_idx on content_unit (media_entity_id, air_date) where air_date is not null; +-- 扩展为库级共享对象,down 不卸载 +`, + ); +} diff --git a/packages/db/scripts/gen-types-lib.ts b/packages/db/scripts/gen-types-lib.ts index 6d2e1001..6c8646cb 100644 --- a/packages/db/scripts/gen-types-lib.ts +++ b/packages/db/scripts/gen-types-lib.ts @@ -51,6 +51,10 @@ export async function generateTypesSource(db: Kysely): Promise { select table_name, column_name, data_type, udt_name, is_nullable, column_default from information_schema.columns where table_schema = current_schema() and table_name not like 'kysely_%' + -- 扩展自带的视图(如 public.pg_stat_statements,迁移 0089)不属于应用表 + and not exists (select 1 from pg_depend d join pg_class r on r.oid = d.objid + where d.classid = 'pg_class'::regclass and d.deptype = 'e' + and r.relname = table_name and r.relnamespace = current_schema()::regnamespace) order by table_name, ordinal_position`.execute(db); const checks = await sql<{ table_name: string; def: string }>` select c.relname as table_name, pg_get_constraintdef(k.oid) as def