merge: fix/logs-perf
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
60ca662530
7 files changed
+855
-144
No files matched your search
@@ -5,7 +5,8 @@ import { redact } from "../../logger.ts";
|
||||
import { BUSINESS_EXPR } from "../auth-settings/engine-business.ts";
|
||||
|
||||
// 日志聚合查询(界面第二轮 V 线):四个来源各自出同一组列,按 (at, id) 倒序各取 limit+1 条,union 后再取前 limit 条。
|
||||
// 每个来源单独 order + limit,PG 能沿各自的时间索引倒着扫,不需要把全部来源物化再排序。
|
||||
// 每个来源单独 order + limit,PG 能沿各自的时间索引倒着扫,不需要把全部来源物化再排序;
|
||||
// 筛选只用原始列,标题等昂贵列只对取出的行算(见 SourceBuilder)。
|
||||
//
|
||||
// 来源与口径(docs/runbook/logs.md):
|
||||
// - engine_run → task:引擎一次运行;时间 = 创建时间;标题 = 业务对象名(没有时为工作流名);对象 = 业务对象
|
||||
@@ -54,11 +55,73 @@ const SOURCE_TYPES: Record<LogSource, LogType[]> = {
|
||||
|
||||
const UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
|
||||
|
||||
function engineSource(schema: string): RawBuilder<unknown> {
|
||||
/** 关键词命中的业务对象每类最多取这么多个(只影响「按业务标题搜任务」,标题极泛的词会漏掉更早的对象) */
|
||||
export const KEYWORD_OBJECT_CAP = 2000;
|
||||
|
||||
/** 各来源共用的已解析筛选 */
|
||||
interface Filter {
|
||||
types: Set<LogType> | null;
|
||||
levels: LogLevel[] | null;
|
||||
from: Date | null;
|
||||
to: Date | null;
|
||||
/** ilike 模式(已转义,带 %) */
|
||||
like: string | null;
|
||||
/** 关键词命中的业务对象 "kind:id"(只给引擎来源用) */
|
||||
matched: string[];
|
||||
objectType: string | null;
|
||||
objectId: string | null;
|
||||
hideRoutine: boolean;
|
||||
cursor: Cursor | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个来源分两段:
|
||||
* 1. 只用原始列(及一次性算好的业务引用)筛选,`order by 时间 desc, 日志 id desc limit n`,PG 沿时间索引倒着扫、凑够 n 条就停;
|
||||
* 2. 只对这 n 行算标题、对象标签、extra 等昂贵列。
|
||||
* 以前是先构造带派生列(标题子查询、search 拼接)的子查询再在外层筛选,带筛选时要对全部引擎运行算一遍派生列。
|
||||
*/
|
||||
type SourceBuilder = (f: Filter, n: number) => RawBuilder<unknown>;
|
||||
|
||||
/** 时间范围与游标条件;at、logId 是该来源的时间列与日志 id 表达式 */
|
||||
function commonConds(f: Filter, at: RawBuilder<unknown>, logId: RawBuilder<unknown>): RawBuilder<unknown>[] {
|
||||
const out: RawBuilder<unknown>[] = [];
|
||||
if (f.from) out.push(sql`${at} >= ${f.from}`);
|
||||
if (f.to) out.push(sql`${at} < ${f.to}`);
|
||||
if (f.cursor) {
|
||||
const ts = sql`(timestamptz 'epoch' + ${f.cursor.us} * interval '1 microsecond')`;
|
||||
// 拆成 at <= ts(可走索引)+ 同一时刻再比日志 id,与 (at, id) < (ts, id) 等价
|
||||
out.push(sql`${at} <= ${ts} and (${at} < ${ts} or ${logId} < ${f.cursor.id})`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const andAll = (conds: RawBuilder<unknown>[]) => (conds.length ? sql.join(conds, sql` and `) : sql`true`);
|
||||
const levelIn = (expr: RawBuilder<unknown>, f: Filter) => (f.levels ? [sql`${expr} = any(${f.levels}::text[])`] : []);
|
||||
|
||||
function engineSource(schema: string): SourceBuilder {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) throw new Error(`非法 schema 名 ${schema}`);
|
||||
const t = sql.raw(`"${schema}".workflow_runs`);
|
||||
const kind = sql`(biz.b->>'kind')`;
|
||||
const ref = sql`(biz.b->>'refId')`;
|
||||
const st = sql.raw("workflow_runs.status");
|
||||
const logId = sql`('engine_run:' || workflow_runs.id)`;
|
||||
// 第一段:业务引用 BUSINESS_EXPR 每行只算一次(offset 0 阻止子查询上拉,否则每处引用都会把整个表达式展开重算)。
|
||||
// 智能体结果(级别、例行要用)的 lateral 也加 offset 0:保持逐行嵌套循环,凑够 n 条就停;
|
||||
// 不加时规划器会改成与 agent_run 的哈希连接,要先扫完全部运行。
|
||||
const kind1 = sql`(biz.b->>'kind')`;
|
||||
const ref1 = sql`(biz.b->>'refId')`;
|
||||
const uuid1 = sql`(case when ${ref1} ~* ${UUID_RE} then ${ref1}::uuid end)`;
|
||||
const level = sql`(case
|
||||
when ${st} = 'failed' then 'error'
|
||||
when ${st} = 'canceled' then 'warn'
|
||||
when ${st} in ('completed', 'succeeded') then (case ag.status
|
||||
when 'failed' then 'error' when 'budget_exceeded' then 'warn' when 'cancelled' then 'warn' else 'success' end)
|
||||
else 'running' end)`;
|
||||
const routine = sql`(biz.b is null
|
||||
or coalesce(${kind1} = 'schedule' and ${st} in ('completed', 'succeeded'), false)
|
||||
or coalesce(ag.trigger in ('event', 'schedule'), false))`;
|
||||
|
||||
// 第二段:p.kind / p.ref 是第一段带出来的列
|
||||
const kind = sql.raw("p.kind");
|
||||
const ref = sql.raw("p.ref");
|
||||
const uuidRef = sql`(case when ${ref} ~* ${UUID_RE} then ${ref}::uuid end)`;
|
||||
const objectType = sql`(case ${kind} ${sql.join(
|
||||
Object.entries(BUSINESS_OBJECT_TYPE).map(([k, v]) => sql`when ${sql.lit(k)} then ${sql.lit(v)}`),
|
||||
@@ -77,128 +140,211 @@ function engineSource(schema: string): RawBuilder<unknown> {
|
||||
when 'decision' then (select d.title from decision d where d.id = ${uuidRef})
|
||||
when 'media' then (select e.title from media_entity e where e.id = ${uuidRef})
|
||||
end)`;
|
||||
const agent = sql`(case when ${kind} = 'agent' then
|
||||
(select jsonb_build_object('status', a.status, 'trigger', a.trigger) from agent_run a where a.id = ${uuidRef}) end)`;
|
||||
const st = sql.raw("workflow_runs.status");
|
||||
return sql`select
|
||||
'engine_run:' || workflow_runs.id as id,
|
||||
'task'::text as type,
|
||||
'engine_run'::text as source,
|
||||
workflow_runs.id::text as ref_id,
|
||||
workflow_runs.created_at as at,
|
||||
workflow_runs.finished_at as finished_at,
|
||||
(case when workflow_runs.finished_at is not null and workflow_runs.started_at is not null
|
||||
then extract(epoch from workflow_runs.finished_at - workflow_runs.started_at) * 1000 end)::float8 as duration_ms,
|
||||
(case
|
||||
when ${st} = 'failed' then 'error'
|
||||
when ${st} = 'canceled' then 'warn'
|
||||
when ${st} in ('completed', 'succeeded') then (case ag.a->>'status'
|
||||
when 'failed' then 'error' when 'budget_exceeded' then 'warn' when 'cancelled' then 'warn' else 'success' end)
|
||||
else 'running' end) as level,
|
||||
${st}::text as status,
|
||||
coalesce(bt.title, workflow_runs.workflow_name) as title,
|
||||
(case when ${st} = 'failed' then left(coalesce(workflow_runs.error->>'message', workflow_runs.error::text), 500) end)
|
||||
as message,
|
||||
${objectType} as object_type,
|
||||
${ref} as object_id,
|
||||
bt.title as object_label,
|
||||
null::text as actor_type,
|
||||
null::text as actor_id,
|
||||
jsonb_build_object('workflowName', workflow_runs.workflow_name, 'businessKind', ${kind},
|
||||
'businessRefId', ${ref}, 'businessTitle', bt.title, 'outcome', ag.a->>'status',
|
||||
'agentTrigger', ag.a->>'trigger', 'attempts', workflow_runs.attempts) as extra,
|
||||
(biz.b is null
|
||||
or coalesce(${kind} = 'schedule' and ${st} in ('completed', 'succeeded'), false)
|
||||
or coalesce((ag.a->>'trigger') in ('event', 'schedule'), false)) as routine,
|
||||
concat_ws(' ', workflow_runs.workflow_name, workflow_runs.id, bt.title, ${ref},
|
||||
left(workflow_runs.error::text, 500)) as search
|
||||
from ${t}
|
||||
cross join lateral (select ${BUSINESS_EXPR} as b) biz
|
||||
cross join lateral (select ${title} as title) bt
|
||||
cross join lateral (select ${agent} as a) ag
|
||||
where workflow_runs.namespace_id = 'default'`;
|
||||
|
||||
return (f, n) => {
|
||||
const conds: RawBuilder<unknown>[] = [
|
||||
sql`workflow_runs.namespace_id = 'default'`,
|
||||
...commonConds(f, sql`workflow_runs.created_at`, logId),
|
||||
];
|
||||
conds.push(...levelIn(level, f));
|
||||
if (f.hideRoutine) conds.push(sql`not ${routine}`);
|
||||
if (f.like) {
|
||||
// 标题命中 → 业务引用在命中集合里;其余按原始列(工作流名、运行 id、业务对象 id、错误)
|
||||
conds.push(sql`(${f.matched.length ? sql`(${kind1} || ':' || ${ref1}) = any(${f.matched}::text[]) or ` : sql``}
|
||||
workflow_runs.workflow_name ilike ${f.like} or workflow_runs.id ilike ${f.like} or ${ref1} ilike ${f.like}
|
||||
or left(workflow_runs.error::text, 500) ilike ${f.like})`);
|
||||
}
|
||||
if (f.objectType) {
|
||||
const kinds = Object.entries(BUSINESS_OBJECT_TYPE)
|
||||
.filter(([, v]) => v === f.objectType)
|
||||
.map(([k]) => k);
|
||||
conds.push(kinds.length ? sql`${kind1} = any(${kinds}::text[])` : sql`false`);
|
||||
}
|
||||
if (f.objectId) conds.push(sql`${ref1} = ${f.objectId}`);
|
||||
return sql`select
|
||||
'engine_run:' || p.id as id,
|
||||
'task'::text as type,
|
||||
'engine_run'::text as source,
|
||||
p.id::text as ref_id,
|
||||
p.created_at as at,
|
||||
p.finished_at as finished_at,
|
||||
(case when p.finished_at is not null and p.started_at is not null
|
||||
then extract(epoch from p.finished_at - p.started_at) * 1000 end)::float8 as duration_ms,
|
||||
p.level,
|
||||
p.status::text as status,
|
||||
coalesce(bt.title, p.workflow_name) as title,
|
||||
(case when p.status = 'failed' then left(coalesce(p.error->>'message', p.error::text), 500) end) as message,
|
||||
${objectType} as object_type,
|
||||
${ref} as object_id,
|
||||
bt.title as object_label,
|
||||
null::text as actor_type,
|
||||
null::text as actor_id,
|
||||
jsonb_build_object('workflowName', p.workflow_name, 'businessKind', ${kind},
|
||||
'businessRefId', ${ref}, 'businessTitle', bt.title, 'outcome', p.agent_status,
|
||||
'agentTrigger', p.agent_trigger, 'attempts', p.attempts) as extra
|
||||
from (
|
||||
select workflow_runs.id, workflow_runs.created_at, workflow_runs.started_at, workflow_runs.finished_at,
|
||||
workflow_runs.status, workflow_runs.workflow_name, workflow_runs.error, workflow_runs.attempts,
|
||||
${kind1} as kind, ${ref1} as ref, ag.status as agent_status, ag.trigger as agent_trigger, ${level} as level
|
||||
from ${t}
|
||||
cross join lateral (select ${BUSINESS_EXPR} as b offset 0) biz
|
||||
left join lateral (select a.status::text as status, a.trigger::text as trigger from agent_run a
|
||||
where ${kind1} = 'agent' and a.id = ${uuid1} offset 0) ag on true
|
||||
where ${andAll(conds)}
|
||||
order by workflow_runs.created_at desc, ${logId} desc
|
||||
limit ${n}
|
||||
) p
|
||||
cross join lateral (select ${title} as title offset 0) bt`;
|
||||
};
|
||||
}
|
||||
|
||||
const SCHEDULE_SOURCE = sql`select
|
||||
'schedule_run:' || r.id::text as id,
|
||||
'auto'::text as type,
|
||||
'schedule_run'::text as source,
|
||||
r.id::text as ref_id,
|
||||
r.started_at as at,
|
||||
r.finished_at as finished_at,
|
||||
r.duration_ms::float8 as duration_ms,
|
||||
(case
|
||||
// 定时任务运行、智能体后台调查、审计:行数少、列都在本行(定时任务只多一个小表 join),两段合成一条查询:
|
||||
// 级别 / 类型 / 例行 / 关键词都是本行列上的表达式,直接放进 where,再按时间倒序取 n 条。
|
||||
|
||||
const scheduleSource: SourceBuilder = (f, n) => {
|
||||
const logId = sql`('schedule_run:' || r.id::text)`;
|
||||
const level = sql`(case
|
||||
when r.status = 'failed' or r.agent_status = 'failed' then 'error'
|
||||
when r.agent_status in ('budget_exceeded', 'cancelled') then 'warn'
|
||||
when r.status = 'ok' then 'success'
|
||||
when r.status = 'skipped' then 'info'
|
||||
else 'running' end) as level,
|
||||
r.status::text as status,
|
||||
coalesce(j.name, r.job_id) as title,
|
||||
coalesce(r.error, r.summary) as message,
|
||||
'scheduled_job'::text as object_type,
|
||||
r.job_id as object_id,
|
||||
j.name as object_label,
|
||||
r.trigger::text as actor_type,
|
||||
null::text as actor_id,
|
||||
jsonb_build_object('jobId', r.job_id, 'jobKind', j.kind, 'trigger', r.trigger, 'agentRunId', r.agent_run_id,
|
||||
'agentStatus', r.agent_status, 'summary', r.summary, 'error', r.error) as extra,
|
||||
(r.status in ('ok', 'skipped') and coalesce(j.kind::text, 'task') <> 'agent_check'
|
||||
and r.agent_status is distinct from 'failed') as routine,
|
||||
concat_ws(' ', r.job_id, j.name, r.summary, r.error, r.agent_run_id::text) as search
|
||||
from scheduled_job_run r left join scheduled_job j on j.id = r.job_id`;
|
||||
else 'running' end)`;
|
||||
const conds = [...commonConds(f, sql`r.started_at`, logId), ...levelIn(level, f)];
|
||||
if (f.hideRoutine) {
|
||||
conds.push(sql`not (r.status in ('ok', 'skipped') and coalesce(j.kind::text, 'task') <> 'agent_check'
|
||||
and r.agent_status is distinct from 'failed')`);
|
||||
}
|
||||
if (f.like) {
|
||||
conds.push(sql`concat_ws(' ', r.job_id, j.name, r.summary, r.error, r.agent_run_id::text) ilike ${f.like}`);
|
||||
}
|
||||
if (f.objectType && f.objectType !== "scheduled_job") conds.push(sql`false`);
|
||||
if (f.objectId) conds.push(sql`r.job_id = ${f.objectId}`);
|
||||
return sql`select
|
||||
${logId} as id,
|
||||
'auto'::text as type,
|
||||
'schedule_run'::text as source,
|
||||
r.id::text as ref_id,
|
||||
r.started_at as at,
|
||||
r.finished_at as finished_at,
|
||||
r.duration_ms::float8 as duration_ms,
|
||||
${level} as level,
|
||||
r.status::text as status,
|
||||
coalesce(j.name, r.job_id) as title,
|
||||
coalesce(r.error, r.summary) as message,
|
||||
'scheduled_job'::text as object_type,
|
||||
r.job_id as object_id,
|
||||
j.name as object_label,
|
||||
r.trigger::text as actor_type,
|
||||
null::text as actor_id,
|
||||
jsonb_build_object('jobId', r.job_id, 'jobKind', j.kind, 'trigger', r.trigger, 'agentRunId', r.agent_run_id,
|
||||
'agentStatus', r.agent_status, 'summary', r.summary, 'error', r.error) as extra
|
||||
from scheduled_job_run r left join scheduled_job j on j.id = r.job_id
|
||||
where ${andAll(conds)}
|
||||
order by r.started_at desc, ${logId} desc
|
||||
limit ${n}`;
|
||||
};
|
||||
|
||||
const AGENT_SOURCE = sql`select
|
||||
'agent_run:' || a.id::text as id,
|
||||
'auto'::text as type,
|
||||
'agent_run'::text as source,
|
||||
a.id::text as ref_id,
|
||||
a.created_at as at,
|
||||
a.finished_at as finished_at,
|
||||
(case when a.finished_at is not null and a.started_at is not null
|
||||
then extract(epoch from a.finished_at - a.started_at) * 1000 end)::float8 as duration_ms,
|
||||
(case a.status
|
||||
const agentSource: SourceBuilder = (f, n) => {
|
||||
const logId = sql`('agent_run:' || a.id::text)`;
|
||||
const level = sql`(case a.status
|
||||
when 'succeeded' then 'success' when 'failed' then 'error'
|
||||
when 'cancelled' then 'warn' when 'budget_exceeded' then 'warn' else 'running' end) as level,
|
||||
a.status::text as status,
|
||||
a.goal as title,
|
||||
coalesce(a.summary, a.error->>'message', a.spawn_reason) as message,
|
||||
'agent_run'::text as object_type,
|
||||
a.id::text as object_id,
|
||||
null::text as object_label,
|
||||
'event'::text as actor_type,
|
||||
a.created_by as actor_id,
|
||||
jsonb_build_object('trigger', a.trigger, 'mode', a.mode, 'toolCalls', a.tool_call_count,
|
||||
'spawnReason', a.spawn_reason, 'model', a.model) as extra,
|
||||
false as routine,
|
||||
concat_ws(' ', a.goal, a.summary, a.id::text, left(a.error::text, 500)) as search
|
||||
from agent_run a where a.trigger = 'event' and a.parent_run_id is null`;
|
||||
when 'cancelled' then 'warn' when 'budget_exceeded' then 'warn' else 'running' end)`;
|
||||
const conds = [
|
||||
sql`a.trigger = 'event' and a.parent_run_id is null`,
|
||||
...commonConds(f, sql`a.created_at`, logId),
|
||||
...levelIn(level, f),
|
||||
];
|
||||
if (f.like) {
|
||||
conds.push(sql`concat_ws(' ', a.goal, a.summary, a.id::text, left(a.error::text, 500)) ilike ${f.like}`);
|
||||
}
|
||||
if (f.objectType && f.objectType !== "agent_run") conds.push(sql`false`);
|
||||
if (f.objectId) conds.push(sql`a.id::text = ${f.objectId}`);
|
||||
return sql`select
|
||||
${logId} as id,
|
||||
'auto'::text as type,
|
||||
'agent_run'::text as source,
|
||||
a.id::text as ref_id,
|
||||
a.created_at as at,
|
||||
a.finished_at as finished_at,
|
||||
(case when a.finished_at is not null and a.started_at is not null
|
||||
then extract(epoch from a.finished_at - a.started_at) * 1000 end)::float8 as duration_ms,
|
||||
${level} as level,
|
||||
a.status::text as status,
|
||||
a.goal as title,
|
||||
coalesce(a.summary, a.error->>'message', a.spawn_reason) as message,
|
||||
'agent_run'::text as object_type,
|
||||
a.id::text as object_id,
|
||||
null::text as object_label,
|
||||
'event'::text as actor_type,
|
||||
a.created_by as actor_id,
|
||||
jsonb_build_object('trigger', a.trigger, 'mode', a.mode, 'toolCalls', a.tool_call_count,
|
||||
'spawnReason', a.spawn_reason, 'model', a.model) as extra
|
||||
from agent_run a
|
||||
where ${andAll(conds)}
|
||||
order by a.created_at desc, ${logId} desc
|
||||
limit ${n}`;
|
||||
};
|
||||
|
||||
const AUDIT_SOURCE = sql`select
|
||||
'audit_event:' || e.id::text as id,
|
||||
(case when e.action like 'admin.%' or e.action like 'api_key.%' then 'auth' else 'audit' end) as type,
|
||||
'audit_event'::text as source,
|
||||
e.id::text as ref_id,
|
||||
e.at as at,
|
||||
null::timestamptz as finished_at,
|
||||
null::float8 as duration_ms,
|
||||
(case
|
||||
const auditSource: SourceBuilder = (f, n) => {
|
||||
const logId = sql`('audit_event:' || e.id::text)`;
|
||||
const type = sql`(case when e.action like 'admin.%' or e.action like 'api_key.%' then 'auth' else 'audit' end)`;
|
||||
const level = sql`(case
|
||||
when e.action in ('admin.login_failed', 'capability.denied') then 'warn'
|
||||
when e.action in ('admin.login_success', 'admin.passkey_login', 'admin.setup') then 'success'
|
||||
else 'info' end) as level,
|
||||
e.action as status,
|
||||
e.action as title,
|
||||
(case when e.action like 'admin.%' then concat_ws(' · ', e.payload->>'username', e.payload->>'ip')
|
||||
else nullif(concat_ws(' · ', e.payload->>'reason', e.payload->>'error'), '') end) as message,
|
||||
e.target_type as object_type,
|
||||
e.target_id as object_id,
|
||||
null::text as object_label,
|
||||
e.actor_type as actor_type,
|
||||
e.actor_id as actor_id,
|
||||
jsonb_build_object('action', e.action, 'traceId', e.trace_id, 'payload', e.payload) as extra,
|
||||
false as routine,
|
||||
concat_ws(' ', e.action, e.target_type, e.target_id, e.actor_id, left(e.payload::text, 2000)) as search
|
||||
from audit_event e`;
|
||||
else 'info' end)`;
|
||||
const conds = [...commonConds(f, sql`e.at`, logId), ...levelIn(level, f)];
|
||||
if (f.types) conds.push(sql`${type} = any(${[...f.types]}::text[])`);
|
||||
if (f.like) {
|
||||
conds.push(sql`concat_ws(' ', e.action, e.target_type, e.target_id, e.actor_id, left(e.payload::text, 2000))
|
||||
ilike ${f.like}`);
|
||||
}
|
||||
if (f.objectType) conds.push(sql`e.target_type = ${f.objectType}`);
|
||||
if (f.objectId) conds.push(sql`e.target_id = ${f.objectId}`);
|
||||
return sql`select
|
||||
${logId} as id,
|
||||
${type} as type,
|
||||
'audit_event'::text as source,
|
||||
e.id::text as ref_id,
|
||||
e.at as at,
|
||||
null::timestamptz as finished_at,
|
||||
null::float8 as duration_ms,
|
||||
${level} as level,
|
||||
e.action as status,
|
||||
e.action as title,
|
||||
(case when e.action like 'admin.%' then concat_ws(' · ', e.payload->>'username', e.payload->>'ip')
|
||||
else nullif(concat_ws(' · ', e.payload->>'reason', e.payload->>'error'), '') end) as message,
|
||||
e.target_type as object_type,
|
||||
e.target_id as object_id,
|
||||
null::text as object_label,
|
||||
e.actor_type as actor_type,
|
||||
e.actor_id as actor_id,
|
||||
jsonb_build_object('action', e.action, 'traceId', e.trace_id, 'payload', e.payload) as extra
|
||||
from audit_event e
|
||||
where ${andAll(conds)}
|
||||
order by e.at desc, ${logId} desc
|
||||
limit ${n}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 关键词命中的业务对象("kind:id"),口径与引擎运行的标题一致(标题 = 业务对象名);每类最多 KEYWORD_OBJECT_CAP 个。
|
||||
* 这些表都不大,先查出 id 集合,引擎来源再按业务引用 ∈ 集合筛选,不用对每条运行算标题。
|
||||
*/
|
||||
async function keywordObjects(db: Kysely<DB>, like: string): Promise<string[]> {
|
||||
const cap = KEYWORD_OBJECT_CAP;
|
||||
const r = await sql<{ k: string }>`
|
||||
(select 'schedule:' || j.id as k from scheduled_job j where j.name ilike ${like} limit ${cap})
|
||||
union all (select 'agent:' || a.id from agent_run a where a.goal ilike ${like} limit ${cap})
|
||||
union all (select 'download:' || d.id from download_task d where d.name ilike ${like} limit ${cap})
|
||||
union all (select 'organization:' || r.id from organization_record r left join media_entity e on e.id = r.media_entity_id
|
||||
where coalesce(e.title, r.target_path, r.source_path) ilike ${like} limit ${cap})
|
||||
union all (select 'subscription:' || s.id from subscription s left join media_entity e on e.id = s.media_entity_id
|
||||
where coalesce(e.title, s.title_query) ilike ${like} limit ${cap})
|
||||
union all (select 'feed:' || f.id from feed_source f where f.name ilike ${like} limit ${cap})
|
||||
union all (select 'site:' || s.id from site s where s.name ilike ${like} limit ${cap})
|
||||
union all (select 'decision:' || d.id from decision d where d.title ilike ${like} limit ${cap})
|
||||
union all (select 'media:' || e.id from media_entity e where e.title ilike ${like} limit ${cap})`.execute(db);
|
||||
return r.rows.map((x) => x.k);
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -234,6 +380,7 @@ export interface LogQueries {
|
||||
|
||||
export function logQueries(db: Kysely<DB>, opts: { engineSchema?: string } = {}): LogQueries {
|
||||
const engineSchema = opts.engineSchema ?? "openworkflow";
|
||||
const engine = engineSource(engineSchema);
|
||||
let engineReady = false;
|
||||
const engineExists = async () => {
|
||||
if (engineReady) return true;
|
||||
@@ -247,35 +394,39 @@ export function logQueries(db: Kysely<DB>, opts: { engineSchema?: string } = {})
|
||||
return {
|
||||
async list(q) {
|
||||
const types = q.type?.length ? new Set(q.type) : null;
|
||||
const sources: [LogSource, RawBuilder<unknown>][] = [];
|
||||
const want = (s: LogSource) => !types || SOURCE_TYPES[s].some((t) => types.has(t));
|
||||
if (want("engine_run") && (await engineExists())) sources.push(["engine_run", engineSource(engineSchema)]);
|
||||
if (want("schedule_run")) sources.push(["schedule_run", SCHEDULE_SOURCE]);
|
||||
if (want("agent_run")) sources.push(["agent_run", AGENT_SOURCE]);
|
||||
if (want("audit_event")) sources.push(["audit_event", AUDIT_SOURCE]);
|
||||
if (!sources.length) return { items: [], nextCursor: null };
|
||||
|
||||
const conds: RawBuilder<unknown>[] = [];
|
||||
if (types) conds.push(sql`s.type = any(${[...types]}::text[])`);
|
||||
if (q.level?.length) conds.push(sql`s.level = any(${q.level}::text[])`);
|
||||
if (q.from) conds.push(sql`s.at >= ${new Date(q.from)}`);
|
||||
if (q.to) conds.push(sql`s.at < ${new Date(q.to)}`);
|
||||
if (q.q) conds.push(sql`s.search ilike ${`%${escapeLike(q.q)}%`}`);
|
||||
if (q.objectType) conds.push(sql`s.object_type = ${q.objectType}`);
|
||||
if (q.objectId) conds.push(sql`s.object_id = ${q.objectId}`);
|
||||
if (q.system !== "1" && !q.objectId) conds.push(sql`not s.routine`);
|
||||
const cur = decodeCursor(q.cursor);
|
||||
if (cur) {
|
||||
conds.push(sql`(s.at, s.id) < (timestamptz 'epoch' + ${cur.us} * interval '1 microsecond', ${cur.id})`);
|
||||
}
|
||||
const where = conds.length ? sql`where ${sql.join(conds, sql` and `)}` : sql``;
|
||||
const withEngine = want("engine_run") && (await engineExists());
|
||||
const like = q.q ? `%${escapeLike(q.q)}%` : null;
|
||||
const f: Filter = {
|
||||
types,
|
||||
levels: q.level?.length ? q.level : null,
|
||||
from: q.from ? new Date(q.from) : null,
|
||||
to: q.to ? new Date(q.to) : null,
|
||||
like,
|
||||
matched: like && withEngine ? await keywordObjects(db, like) : [],
|
||||
objectType: q.objectType || null,
|
||||
objectId: q.objectId || null,
|
||||
hideRoutine: q.system !== "1" && !q.objectId,
|
||||
cursor: decodeCursor(q.cursor),
|
||||
};
|
||||
const n = q.limit + 1;
|
||||
const parts = sources.map(
|
||||
([, src]) => sql`(select s.* from (${src}) s ${where} order by s.at desc, s.id desc limit ${n})`,
|
||||
);
|
||||
const rows = await sql<Row>`select u.*, (extract(epoch from u.at) * 1000000)::bigint as cursor_us
|
||||
from (${sql.join(parts, sql` union all `)}) u
|
||||
order by u.at desc, u.id desc limit ${n}`.execute(db);
|
||||
const parts: RawBuilder<unknown>[] = [];
|
||||
if (withEngine) parts.push(engine(f, n));
|
||||
if (want("schedule_run")) parts.push(scheduleSource(f, n));
|
||||
if (want("agent_run")) parts.push(agentSource(f, n));
|
||||
if (want("audit_event")) parts.push(auditSource(f, n));
|
||||
if (!parts.length) return { items: [], nextCursor: null };
|
||||
|
||||
// 表达式很长,估算代价会触发 JIT,编译一次就要上秒;这条查询只取几百行,关掉 JIT
|
||||
const rows = await db.transaction().execute(async (trx) => {
|
||||
await sql`set local jit = off`.execute(trx);
|
||||
return sql<Row>`select u.*, (extract(epoch from u.at) * 1000000)::bigint as cursor_us
|
||||
from (${sql.join(
|
||||
parts.map((p) => sql`(${p})`),
|
||||
sql` union all `,
|
||||
)}) u
|
||||
order by u.at desc, u.id desc limit ${n}`.execute(trx);
|
||||
});
|
||||
const items = rows.rows.slice(0, q.limit);
|
||||
const last = items[items.length - 1];
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user