From 6c728aaacd815764403602000cdead846294c484 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Thu, 15 Oct 2026 19:18:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=89=96=E6=9E=90=E6=8C=89?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E6=84=8F=E8=A7=81=E4=BF=AE=E4=B8=89=E5=A4=84?= =?UTF-8?q?=EF=BC=9A=E6=B2=A1=E6=9D=A5=E5=BE=97=E5=8F=8A=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E7=9A=84=E8=A1=A8=E7=85=A7=E5=AE=9E=E8=AF=B4=E3=80=81=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E4=B8=AD=E7=9A=84=E8=AF=B4=E6=B3=95=E3=80=81=E5=B1=95?= =?UTF-8?q?=E5=BC=80=E7=9A=84=E8=BF=9B=E8=A1=8C=E4=B8=AD=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=E4=B8=8D=E8=B7=9F=E7=9D=80=E6=9F=A5=E8=AF=A2=E6=95=B0=E9=87=8D?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 服务重启的说明不再一律说「做完的项已写进数据目录」(R1 前端这一侧)。目录只在一张表结束时和它的结果一起写:停止、关停 服务时正在剖析的那张来得及收尾,做完的项照常写进去(服务端记部分完成,原因写「已做完的 N 项照常写进了数据目录」);进程 被杀、下次启动时才标中断的那张没来得及收尾,这次的结论一项都没写进去,服务端把它记成已停止、查完的项改记跳过。界面按逐表 的状态认出这样的表(已停止却有开始时刻:正常收尾的表不会是已停止,没开始的表没有开始时刻),服务重启、停止的说明里点名它 「剖析到一半服务就停了,没来得及收尾,这次的结论没有写进目录,保留原来的结论」,有别的表收了尾才说「其余做完的项已写进数据 目录」;逐表一行用服务端给这张表的原话(查完的几项没写进目录),一项都还没查完、原因就是任务那一句时写「没来得及收尾」。 不说「做完的项没写进去」:被杀时它可能一项都还没查完。 - 「正在停止」不再说等正在执行的那条查询结束(R4)。停止时服务端不等这条查询(它在发过的查询里记失败),库那边按单条时限截止 它,收尾写完停止就答;单条时限设得长时,旧说法会让人以为要等满时限。改成「正在执行的那条查询不再等待,由数据库按单条时限 截止;已做完的项照常写进目录…」,和服务端的说法一致;接口、停止逻辑的注释同步改。 - 展开进行中的那一行,只在一项检查开始、结束时重取这张表的完整结果(R5)。服务端只在这两个时刻写检查清单、正在做哪一项和发过 的查询,其间每发一条查询只加查询数和用时;以前重取的依据带着查询数,进行中几乎每次轮询(1.5 秒)都多取一份没变的结果, 一张 40 条查询的表约多 40 次。依据改成状态、结束的项数和正在做哪一项。 检查脚本 check-catalog-profile:伪造的停止照服务端分「来得及收尾」(正在剖析的那张记部分完成、原因写做完的已写进目录)和 「没来得及收尾」(记已停止、查完的项改记跳过、说法照服务端)两种;停止那一节跟着改(正在剖析的那张是部分完成,汇总 「完成 1,部分完成 1,已停止 3」),「正在停止」的说法改查新句子;服务重启一节加关停时收了尾、进程被杀(查完一项、一项都 没查完)三种;表详情一节加剖析到一半进程被杀;进行中一节加一条:展开进行中的一行后只多发查询时不再请求 tasks/{seq},一项 结束、换下一项时重取一次。先在改动前的代码上跑过,这几条照复核说的那样不通过(展开后多取了 2~3 次、停止中还是旧说法、 服务重启一律说已写进),改完通过。 --- frontend/src/api/client.ts | 4 +- frontend/src/lib/terms.ts | 27 +++- frontend/src/pages/catalog/ProfileDialog.tsx | 25 +++- frontend/src/pages/catalog/profileJob.ts | 3 +- scripts/check-catalog-profile.mjs | 150 ++++++++++++++++--- 5 files changed, 177 insertions(+), 32 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f6f12789..832089de 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -492,8 +492,8 @@ export const api = { get<{ task: CatalogProfileTaskDetail }>( `${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/tasks/${encodeURIComponent(String(seq))}`, opts), /** - * 停止:排队的直接停;进行中的那条查询由数据库按单条时限截止,这张表已做完的项照常写进目录、没做的保留旧结论, - * 后面的表记已停止。回停止之后的状态;已经结束的回 409(catalog_profile_finished) + * 停止:排队的直接停;进行中的不再等正在执行的那条查询(数据库按单条时限截止它),这张表已做完的项照常写进目录、 + * 没做的保留旧结论,后面的表记已停止。收尾写完才回停止之后的状态;已经结束的回 409(catalog_profile_finished) */ cancel: (sourceId: string, jobId: string) => post<{ job: CatalogProfileJob }>(`${catalogBase(sourceId)}/profile-jobs/${encodeURIComponent(jobId)}/cancel`), diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index e15408e5..340dd609 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -2769,6 +2769,8 @@ export const SCHEMA_PARTIAL_TEXT = { // - 「数据剖析」:对业务库发只读查询,核实推断的关联关系(覆盖率、基数),取状态类列的码值候选,提议业务日期。 // 动作写「剖析」,不写扫描、探测。默认关闭,按数据源开启。 // - 剖析是后台任务:逐张表进行,每张表剖析完就写进目录;查询一条一条地发,可以随时停止。关掉对话框照常进行。 +// 目录只在整张表结束时写一次:停止、关停服务时正在做的那张来得及收尾,做完的项照常写进去;进程被杀时那张表没来得及收尾, +// 这次做完的项一项都没写进去。说停下以后写进了什么,按逐表的状态照实说(cancelledNote / interruptedNote),不一律说「已写进」。 // - 预算按表算:「每张表最多查询数」「每张表时长上限」用完只停这张表,下一张表重新计数;另有单条查询时限、抽样键值数、 // 整表统计行数上限。标签和服务端校验报错里的叫法一致(catalog_profile._NUMBER_FIELDS),保存被拒时按机读码认出是哪一项。 // - 没有整次总量上限:任务自己在连续失败、多数超时时停下(止损),写明原因。 @@ -2801,6 +2803,9 @@ export const PROFILE_FIELD_HINT: Record = { max_scan_rows: '不超过此行数的表才做去重计数、最小值和最大值等整表统计', } +/** 表名点名:「visits」、「refunds」 */ +const quoteNames = (names: string[]) => names.map((n) => `「${n}」`).join('、') + export const PROFILE_TEXT = { // ---- 设置 section: '数据剖析', @@ -2888,8 +2893,9 @@ export const PROFILE_TEXT = { interrupted: '服务重启,剖析中断', } as Record, waitingDraft: '排在起草任务后面,起草结束后自动开始', - // 停止时正在执行的那条查询由数据库按单条时限截止,不是立刻停 - stopping: '正在停止:等正在执行的那条查询结束(不超过单条查询时限),已做完的项照常写进目录…', + // 停止时服务端不等正在执行的那条查询(它的结果记「失败」),库那边按单条时限截止它;收尾写完(至多十来秒)停止就答。 + // 别写成「等这条查询结束」:单条时限设得长时,界面会让人以为要等满时限 + stopping: '正在停止:正在执行的那条查询不再等待,由数据库按单条时限截止;已做完的项照常写进目录…', progress: (done: number, total: number) => `${formatNumber(done)} / ${formatNumber(total)} 张表`, /** 结束以后:做完了几张(停止、失败、跳过的不算) */ progressDone: (ok: number, total: number) => `完成 ${formatNumber(ok)} / ${formatNumber(total)} 张表`, @@ -2910,8 +2916,21 @@ export const PROFILE_TEXT = { failed: '后面的表没有剖析。请先在数据源卡片上测试连接、确认账号有只读权限,再重试没做完的表。', slow: '后面的表没有剖析。可以在业务库不忙的时候重试没做完的表,或在剖析设置中调低单条查询时限、每张表最多查询数。', } as Record, - cancelledNote: '做完的项已写进数据目录,没做的项保留原来的结论;后面的表没有剖析,可以重试没做完的表。', - interruptedNote: '服务重启时这次剖析还没有完成:做完的项已写进数据目录,没做的项保留原来的结论。不会自动续上,可以重试没做完的表。', + /** + * 停止、服务重启以后的说明。停下时正在剖析的那张表通常来得及收尾,做完的项照常写进目录(服务端记部分完成);lost 是没来得 + * 及收尾的表(进程被杀、下次启动时才标中断,或停止时服务端没在跑它):目录只在整张表结束时和它的结果一起写,所以它这次 + * 一项结论都没写进去,服务端把它记成已停止、查完的项也改记跳过。有这样的表时点名说,不能一律说「做完的项已写进数据目录」。 + * 它这次可能一项都还没查完,所以说「这次的结论没有写进目录」,不说「做完的项没写进去」。 + * written:有别的表收了尾(完成、部分完成),才说「其余做完的项已写进」 + */ + cancelledNote: (lost: string[], written: boolean) => (lost.length + ? `${quoteNames(lost)}剖析到一半停下,没来得及收尾,这次的结论没有写进目录,保留原来的结论${written ? ';其余做完的项已写进数据目录' : ''}。可以重试没做完的表。` + : '做完的项已写进数据目录,没做的项保留原来的结论;后面的表没有剖析,可以重试没做完的表。'), + interruptedNote: (lost: string[], written: boolean) => (lost.length + ? `服务重启时这次剖析还没有完成:${quoteNames(lost)}剖析到一半服务就停了,没来得及收尾,这次的结论没有写进目录,保留原来的结论${written ? ';其余做完的项已写进数据目录' : ''}。不会自动续上,可以重试没做完的表。` + : '服务重启时这次剖析还没有完成:做完的项已写进数据目录,没做的项保留原来的结论。不会自动续上,可以重试没做完的表。'), + /** 逐表一行:没来得及收尾的表(见 cancelledNote)。服务端给了这张表自己的原因(查完的几项没写进目录)时用原话 */ + notSaved: '剖析到一半中断,没来得及收尾,这次的结论没有写进目录,保留原来的结论', /** 结束后的汇总:「40 张表:完成 33,部分完成 4,跳过 2,失败 1」 */ counts: (total: number, c: { ok: number; partial: number; skipped: number; failed: number; cancelled: number }) => `${formatNumber(total)} 张表:${[ diff --git a/frontend/src/pages/catalog/ProfileDialog.tsx b/frontend/src/pages/catalog/ProfileDialog.tsx index 1b7ee42e..321ad9c3 100644 --- a/frontend/src/pages/catalog/ProfileDialog.tsx +++ b/frontend/src/pages/catalog/ProfileDialog.tsx @@ -455,6 +455,9 @@ function JobView({ sourceId, job, running, stopping, busy, filled, returnTo, onS const notice = running ? job.notice?.trim() ?? '' : '' const changed = job.added + job.updated + job.removed const findings = PT.findings(job.summary) + // 停下以后写进了什么按逐表照实说:没来得及收尾的表点名;收了尾的表(完成、部分完成)做完的项写进了目录 + const unsaved = job.tasks.filter(isUnsaved).map((t) => t.table_name) + const written = job.tasks.some((t) => t.status === 'ok' || t.status === 'partial') const [filter, setFilter] = useState('all') const radio = useRadioGroup(FILTERS, filter, setFilter) const tasks = running ? job.tasks : job.tasks.filter(FILTER_TEST[filter]) @@ -519,8 +522,8 @@ function JobView({ sourceId, job, running, stopping, busy, filled, returnTo, onS {!job.stop_reason && job.status === 'failed' && job.error && ( {job.error} )} - {!running && job.status === 'cancelled' &&

{PT.cancelledNote}

} - {job.status === 'interrupted' && {PT.interruptedNote}} + {!running && job.status === 'cancelled' &&

{PT.cancelledNote(unsaved, written)}

} + {job.status === 'interrupted' && {PT.interruptedNote(unsaved, written)}}
{PT.tasks} @@ -571,14 +574,24 @@ export function checksText(t: Pick): st .filter(Boolean).join(' · ') } +/** + * 没来得及收尾的表:开始剖析了(有开始时刻),却以「已停止」结束。正常收尾的表状态按逐表算(完成、部分完成、失败、跳过), + * 不会是已停止;没开始就停下的表没有开始时刻。只有进程被杀(下次启动时才标中断)或停止时服务端没在跑它,正在做的那张才以 + * 已停止结束——目录只在整张表结束时和它的结果一起写,它这次一项结论都没写进去(服务端把查完的项改记跳过,这张表的原因 + * 写明查完的几项没写进目录;一项都还没查完时原因就是任务的那一句) + */ +export const isUnsaved = (t: Pick): boolean => t.status === 'cancelled' && !!t.started_at + /** * 一张表的结论:进行中写正在做哪一项;结束了写发现(核实了几条关系、取到几列码值……),没查成的写几项,整张表的原因照服务端 - * 的原话。jobError:任务整体的原因(止损、服务重启时服务端把后面的表也记上同一句),和它一样的不再逐表重复 + * 的原话。jobError:任务整体的原因(止损、服务重启时服务端把后面的表也记上同一句),和它一样的不再逐表重复。 + * 没来得及收尾的表写明这次的结论没写进目录(服务端给了自己的原因时用原话) */ export function taskNote(t: CatalogProfileTaskRow, jobError: string | null): string { if (t.status === 'pending') return '' if (t.status === 'running') return t.current ?? '' const own = t.error && t.error !== jobError ? t.error : '' + if (isUnsaved(t)) return own || PT.notSaved if (t.status === 'skipped' || t.status === 'failed') return own const parts = [PT.findings(t.summary, true), t.unfinished ? PT.unfinished(t.unfinished) : '', own] if (t.status === 'ok' && !parts.some(Boolean)) return PT.noFindings @@ -648,7 +661,9 @@ function TaskItem({ sourceId, job, task: t, open, onToggle, filled, focusAfter, } /** - * 展开的一张表:按 seq 取完整结果(检查清单、发现、发过的查询)。还在剖析的表,每多结束一项、多发一条查询就重取一次 + * 展开的一张表:按 seq 取完整结果(检查清单、发现、发过的查询)。还在剖析的表只在一项检查开始、结束时重取:服务端这时才 + * 写检查清单、正在做哪一项和发过的查询(开始时 current 换成这一项,结束时结束的项数加一、current 清空);其间每发一条 + * 查询只加查询数和用时,这两样逐表一行上就有,跟着它重取只会每次轮询都多取一份没变的结果 */ function TaskDetailLoader({ sourceId, jobId, task: t, filled, focusAfter, onOpenTable, onFillCodes }: { sourceId: string @@ -662,7 +677,7 @@ function TaskDetailLoader({ sourceId, jobId, task: t, filled, focusAfter, onOpen const [detail, setDetail] = useState(null) const [error, setError] = useState(null) const [again, setAgain] = useState(0) - const sig = `${t.status}:${t.checks_done}:${t.queries}` + const sig = `${t.status}:${t.checks_done}:${t.current ?? ''}` useEffect(() => { let alive = true api.dataCatalog.profileJobs.task(sourceId, jobId, t.seq).then(({ task }) => { diff --git a/frontend/src/pages/catalog/profileJob.ts b/frontend/src/pages/catalog/profileJob.ts index 23dd2ca7..839b8516 100644 --- a/frontend/src/pages/catalog/profileJob.ts +++ b/frontend/src/pages/catalog/profileJob.ts @@ -133,7 +133,8 @@ export function useProfileJob(sourceId: string, active: CatalogActiveProfileJob }, [accept]) /** - * 停止。服务端停下正在执行的那条查询(由数据库按单条时限截止)之前任务还是进行中:这期间 stopping 为这个任务。 + * 停止。停止接口等服务端收尾写完才答(不等正在执行的那条查询,它由数据库按单条时限截止),这期间任务还是进行中: + * stopping 为这个任务。 * 已经结束了(409)就取一次最新的 */ const cancel = useCallback(async () => { diff --git a/scripts/check-catalog-profile.mjs b/scripts/check-catalog-profile.mjs index 3dadac4f..1ba26afa 100644 --- a/scripts/check-catalog-profile.mjs +++ b/scripts/check-catalog-profile.mjs @@ -4,8 +4,9 @@ // 「开始前的范围和个数是不是按清单算的」「预算是不是按表写的、估了这次最多几条查询」「明说只读、一条一条发、可以停」 // 「同源有起草任务时是不是写排队」「进行中每张表到哪了(各类检查几项、发了几条查询、正在做哪一项)」「停得下来、停下来 // 写清做完的保留了什么」「展开一张表看得到检查清单和发过的查询(字面量换成了 ?)」「结束后能按有发现 / 有跳过 / 失败筛、 -// 只重试没做完的表」「止损、服务重启的原因照实写」「各种 409 各有下一步」「表清单的剖析一列和需要剖析的筛选」「表详情的一节 -// 和剖析这张表」「设置的两项新字段、被拒时报错落到那一项」「起草排在剖析后面时怎么说」。 +// 只重试没做完的表」「止损、服务重启的原因照实写(进程被杀时没来得及收尾的那张表点名,不说做完的已写进目录)」 +// 「各种 409 各有下一步」「表清单的剖析一列和需要剖析的筛选」「表详情的一节和剖析这张表」「设置的两项新字段、被拒时报错 +// 落到那一项」「起草排在剖析后面时怎么说」「展开进行中的表时只在一项检查开始、结束时重取,不跟着查询数每次轮询都取」。 // // 写法同 check-catalog:数据源列表、目录清单、单表目录、剖析任务、起草任务都用 page.route 伪造(虚构的网店业务库,表名 // orders、customers、visits……都是通用的假名字),任务怎么往前走由检查自己推(settle、finish、stopJob),页面每 1.5 秒 @@ -360,8 +361,16 @@ function finish(job, status) { job.started_at ??= job.finished_at delete job.stopAsked } -/** 停下(使用者停止、止损、服务重启):正在做的表写入已做完的项、其余项记原因;没开始的表记已停止,原因同一句 */ -function stopJob(job, { status = 'cancelled', reason = 'cancelled', current = null, rest = null, error = null } = {}) { +/** + * 停下(使用者停止、止损、服务重启)。正在做的那张表照服务端分两种: + * - 来得及收尾(saved,缺省:停止、止损、关停服务):写入已做完的项,其余项记原因,状态按逐表算(有项没查是部分完成), + * error 是 rest(服务端的「已做完的 N 项照常写进了数据目录,剩下 M 项没查」); + * - 没来得及收尾(saved: false:进程被杀,下次启动时才标中断):结论一项都没写进目录,表记已停止,查完的项也改记跳过, + * 说法照服务端(catalog_profile_jobs._close_tasks):查完的项「{原因},查完了但结论没有写进数据目录」,表的原因 + * 「{原因}:已查完的 N 项没有写进数据目录,剩下 M 项没查」;一项都还没查完时表的原因就是 error。 + * 没开始的表记已停止,原因同一句 + */ +function stopJob(job, { status = 'cancelled', reason = 'cancelled', current = null, rest = null, error = null, saved = true } = {}) { for (const t of job.tasks) { if (t.status === 'running') { const left = t.checks.filter((c) => c.state === 'pending' || c.state === 'running') @@ -369,7 +378,17 @@ function stopJob(job, { status = 'cancelled', reason = 'cancelled', current = nu Object.assign(c, { state: 'skipped', reason, detail: current ?? '已停止,这一项没有查' }) t.skipped.push({ kind: c.kind, target: c.target, path: c.path, reason, detail: c.detail }) } - Object.assign(t, { status: 'cancelled', current: null, finished_at: isoNow(), error: rest ?? error }) + if (saved) { + Object.assign(t, { status: left.length ? 'partial' : 'ok', current: null, finished_at: isoNow(), error: rest ?? error }) + continue + } + const done = t.checks.filter((x) => x.state === 'done') + for (const c of done) { + Object.assign(c, { state: 'skipped', reason, detail: `${error},查完了但结论没有写进数据目录` }) + t.skipped.push({ kind: c.kind, target: c.target, path: c.path, reason, detail: c.detail }) + } + const own = done.length ? `${error}:已查完的 ${done.length} 项没有写进数据目录${left.length ? `,剩下 ${left.length} 项没查` : ''}` : error + Object.assign(t, { status: 'cancelled', current: null, finished_at: isoNow(), error: own }) } else if (t.status === 'pending') { Object.assign(t, { status: 'cancelled', finished_at: isoNow(), error: error ?? '已停止,这张表没有剖析' }) } @@ -830,6 +849,30 @@ await section('剖析 · 进行中:逐表进度、后台继续与停止', asyn && (await inBox(page, '[data-profile-queries]')).includes('已发 23 条查询') && (await inBox(page, '[data-profile-elapsed]')).startsWith('· 用时'), await textOf(box(page).locator('[role="status"]'))) check('……标题写「正在剖析…」', (await inBox(page, '[data-profile-title]')) === '正在剖析…') + // 展开进行中的那一行:服务端只在一项检查开始、结束时写检查清单和发过的查询,其间每发一条查询只加查询数。所以只多了 + // 查询数时不重取这张表的完整结果,一项结束了、换了一项在做才重取(开发模式下 StrictMode 挂载时会多取一次,按展开后的 + // 次数为准) + const detailGets = () => count(sent, new RegExp(`^GET .*profile-jobs/${job.id}/tasks/1$`)) + const jobPolls = () => count(sent, new RegExp(`^GET .*profile-jobs/${job.id}$`)) + const ordersTask = job.tasks.find((t) => t.table_name === 'orders') + await taskLine(page, 'orders').locator('[data-profile-task-toggle]').click() + const live = box(page).locator('[data-profile-task-detail="orders"]') + check('进行中展开一行:取这张表的完整结果,正在做的那一项写「进行中」', await until(async () => (await live.locator('[data-profile-checks]').count()) === 1) + && detailGets() >= 1 && (await live.locator('[data-profile-check][data-state="running"]').count()) === 1, `${detailGets()} 次`) + await sleep(300) + const gets0 = detailGets() + for (const n of [25, 27]) { + const p0 = jobPolls() + Object.assign(ordersTask, { queries: n, elapsed_ms: ordersTask.elapsed_ms + 1500 }) + await until(async () => jobPolls() >= p0 + 2, 5000) + } + check('……这一项没结束、只多发了几条查询:逐表一行跟着走,不重取这张表的完整结果', (await inBox(page, '[data-profile-task="orders"] [data-profile-task-queries]')) === '27 条查询' + && detailGets() === gets0, `展开后 ${gets0} 次 → ${detailGets()} 次`) + settle(state, job, 'orders', { status: 'running', done: { relation: 4, codes: 5, date: 1 }, queries: 29, current: '正在取 orders.source 的码值候选' }) + check('……这一项结束、换了下一项:重取一次,检查清单跟上', await until(async () => detailGets() === gets0 + 1 + && (await live.locator('[data-profile-check="relation"][data-state="done"]').count()) === 4), `展开后 ${gets0} 次 → ${detailGets()} 次`) + await taskLine(page, 'orders').locator('[data-profile-task-toggle]').click() + await until(async () => (await live.count()) === 0) settle(state, job, 'orders', { queries: 31, elapsed_ms: 26000, added: 3, updated: 2, row_estimate: { rows: 250000, method: 'stats', at_least: null }, findings: [ @@ -883,21 +926,25 @@ await section('剖析 · 进行中:逐表进度、后台继续与停止', asyn check('点页头:回到对话框,直接是进行中的任务,提醒写在进度条下面', await until(async () => (await phase(page)) === 'running') && (await inBox(page, '[data-profile-notice]')) === NOTICE && await box(page).locator('[data-profile-scope]').count() === 0) - // 停止:调停止接口;正在执行的那条查询由数据库按单条时限截止,这期间写「正在停止」 + // 停止:调停止接口。服务端不再等正在执行的那条查询(数据库按单条时限截止它),收尾写完就答,这期间写「正在停止」 await page.locator('[data-profile-stop]').click() - check('点「停止」:调停止接口,写「正在停止:等正在执行的那条查询结束…」,停止按钮不能再点', await until(async () => - (await inBox(page, '[data-profile-title]')).startsWith('正在停止:等正在执行的那条查询结束')) + check('点「停止」:调停止接口,写「正在停止:正在执行的那条查询不再等待,由数据库按单条时限截止…」,停止按钮不能再点', await until(async () => + (await inBox(page, '[data-profile-title]')).startsWith('正在停止:正在执行的那条查询不再等待,由数据库按单条时限截止')) + && !(await inBox(page, '[data-profile-title]')).includes('等正在执行的那条查询结束') && count(sent, new RegExp(`^POST .*profile-jobs/${job.id}/cancel$`)) === 1 && await page.locator('[data-profile-stop]').isDisabled(), await inBox(page, '[data-profile-title]')) - stopJob(job, { rest: '已停止:做完的 1 项已写进目录,其余 3 项保留原来的结论' }) + // 服务端:正在剖析的访问记录来得及收尾,做完的 1 项写进目录、剩下 3 项没查(部分完成),后面的表已停止 + stopJob(job, { rest: '剖析已停止:已做完的 1 项照常写进了数据目录,剩下 3 项没查' }) check('……停下来:标题写「已停止」,写明做完的已写进目录、没做的保留原来的结论', await until(async () => (await phase(page)) === 'done') - && (await inBox(page, '[data-profile-title]')) === '已停止' && (await inBox(page, '[data-profile-cancelled]')).includes('没做的项保留原来的结论'), + && (await inBox(page, '[data-profile-title]')) === '已停止' && (await inBox(page, '[data-profile-cancelled]')).includes('没做的项保留原来的结论') + && !(await inBox(page, '[data-profile-cancelled]')).includes('没来得及'), `${await inBox(page, '[data-profile-title]')} · ${await inBox(page, '[data-profile-cancelled]')}`) - check('……正在剖析的那张写已停止和服务端的原因;后面的表都记已停止', (await inBox(page, '[data-profile-task="visits"] [data-profile-task-status]')) === '已停止' - && (await inBox(page, '[data-profile-task="visits"] [data-profile-task-note]')).includes('其余 3 项保留原来的结论') + check('……正在剖析的那张写部分完成和服务端的原因;后面的表都记已停止', (await inBox(page, '[data-profile-task="visits"] [data-profile-task-status]')) === '部分完成' + && (await inBox(page, '[data-profile-task="visits"] [data-profile-task-note]')).includes('已做完的 1 项照常写进了数据目录') && (await inBox(page, '[data-profile-task="tmp_log"] [data-profile-task-status]')) === '已停止' - && await taskLine(page, 'tmp_log').locator('[data-profile-task-checks]').count() === 0) - check('……汇总照实写:5 张表,完成 1,已停止 4', (await inBox(page, '[data-profile-counts]')) === '5 张表:完成 1,已停止 4', await inBox(page, '[data-profile-counts]')) + && await taskLine(page, 'tmp_log').locator('[data-profile-task-checks]').count() === 0, await inBox(page, '[data-profile-task="visits"]')) + check('……汇总照实写:5 张表,完成 1,部分完成 1,已停止 3', (await inBox(page, '[data-profile-counts]')) === '5 张表:完成 1,部分完成 1,已停止 3', + await inBox(page, '[data-profile-counts]')) check('……可以重试没做完的 4 张', (await textOf(page.locator('[data-profile-retry]'))).includes('重试没做完的表(4 张)')) check('……页头回到「数据剖析」', await until(async () => (await head(page).getAttribute('data-catalog-profile')) === '')) const polls = () => count(sent, new RegExp(`^GET .*profile-jobs/${job.id}$`)) @@ -1109,16 +1156,67 @@ await section('剖析 · 止损与服务重启', async () => { && (await inBox(page, '[data-profile-stop-error]')) === FAIL && (await inBox(page, '[data-profile-stopped="failed"]')).includes('测试连接')) await page.locator('[data-profile-close]').click() - // 服务重启:任务标中断,不自动续跑 + // 服务重启:任务标中断,不自动续跑。关停服务时正在剖析的那张表来得及收尾(做完的项写进目录),后面的表已停止 + const RESTART = '服务重启,剖析中断' await openDialog(page) await page.locator('[data-profile-start]').click() await until(async () => (await phase(page)) === 'running') const job3 = state.pjobs.at(-1) - settle(state, job3, job3.tasks[0].table_name, { queries: 5 }) - stopJob(job3, { status: 'interrupted', error: '服务重启,剖析中断' }) - check('服务重启:标题写「服务重启,剖析中断」,写明不会自动续上、可以重试没做完的表', await until(async () => (await phase(page)) === 'done') - && (await inBox(page, '[data-profile-title]')) === '服务重启,剖析中断' && (await inBox(page, '[data-profile-interrupted]')).includes('不会自动续上')) - check('……和任务相同的原因不逐表重复', await box(page).locator('[data-profile-task-note]').filter({ hasText: '服务重启' }).count() === 0) + const [first3, second3] = job3.tasks.map((t) => t.table_name) + settle(state, job3, first3, { queries: 5 }) + settle(state, job3, second3, { status: 'running', done: { relation: 1 }, queries: 4 }) + const left3 = job3.tasks[1].checks.filter((c) => c.state !== 'done').length + stopJob(job3, { status: 'interrupted', error: RESTART, rest: `${RESTART}:已做完的 1 项照常写进了数据目录,剩下 ${left3} 项没查` }) + check('服务重启(关停时收了尾):标题写「服务重启,剖析中断」,写明做完的项已写进目录、不会自动续上、可以重试', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, '[data-profile-title]')) === RESTART && (await inBox(page, '[data-profile-interrupted]')).includes('不会自动续上') + && (await inBox(page, '[data-profile-interrupted]')).includes('做完的项已写进数据目录') + && !(await inBox(page, '[data-profile-interrupted]')).includes('没来得及'), await inBox(page, '[data-profile-interrupted]')) + check('……正在剖析的那张:部分完成,写服务端的原因(做完的写进了目录)', (await inBox(page, `[data-profile-task="${second3}"] [data-profile-task-status]`)) === '部分完成' + && (await inBox(page, `[data-profile-task="${second3}"] [data-profile-task-note]`)).includes('已做完的 1 项照常写进了数据目录'), + await inBox(page, `[data-profile-task="${second3}"]`)) + check('……和任务相同的原因不逐表重复(只有收了尾的那张写它自己的一句)', await box(page).locator('[data-profile-task-note]').filter({ hasText: RESTART }).count() === 1) + await page.locator('[data-profile-close]').click() + + // 进程被杀:下次启动时才标中断,正在剖析的那张表没来得及收尾,这次的结论一项都没写进目录(服务端把查完的项改记跳过)。 + // 不能说「做完的项已写进数据目录」:点名这张表,写明它这次的结论没写进去 + await openDialog(page) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job4 = state.pjobs.at(-1) + const [first4, lost4] = job4.tasks.map((t) => t.table_name) + settle(state, job4, first4, { queries: 5 }) + settle(state, job4, lost4, { status: 'running', done: { relation: 1 }, queries: 4 }) + stopJob(job4, { status: 'interrupted', error: RESTART, current: `${RESTART},未检查`, saved: false }) + const note = () => inBox(page, '[data-profile-interrupted]') + check('服务重启(进程被杀、没来得及收尾):点名剖析到一半的那张表,写明它这次的结论没写进目录、保留原来的结论', await until(async () => (await phase(page)) === 'done') + && (await note()).includes(`「${lost4}」剖析到一半服务就停了`) && (await note()).includes('这次的结论没有写进目录') && (await note()).includes('保留原来的结论') + && (await note()).includes('其余做完的项已写进数据目录') && (await note()).includes('不会自动续上'), await note()) + check('……不再一律说「做完的项已写进数据目录」', !(await note()).startsWith('服务重启时这次剖析还没有完成:做完的项已写进数据目录'), await note()) + check('……那张表记已停止,这一行照服务端写明查完的项没写进目录;前面剖析完的表照常', (await inBox(page, `[data-profile-task="${lost4}"] [data-profile-task-status]`)) === '已停止' + && (await inBox(page, `[data-profile-task="${lost4}"] [data-profile-task-note]`)) === `${RESTART}:已查完的 1 项没有写进数据目录,剩下 1 项没查` + && (await inBox(page, `[data-profile-task="${first4}"] [data-profile-task-status]`)) === '完成', await inBox(page, `[data-profile-task="${lost4}"]`)) + await taskLine(page, lost4).locator('[data-profile-task-toggle]').click() + const lostBox = box(page).locator(`[data-profile-task-detail="${lost4}"]`) + check('……展开:检查清单没有标「完成」的项,查完的那项写明结论没写进目录', await until(async () => (await lostBox.locator('[data-profile-checks]').count()) === 1) + && await lostBox.locator('[data-profile-check][data-state="done"]').count() === 0 + && (await textOf(lostBox.locator('[data-profile-check="relation"]'))).includes('查完了但结论没有写进数据目录'), await textOf(lostBox.locator('[data-profile-checks]'))) + check('……没开始的表不写这一句', await box(page).locator('[data-profile-task-note]').filter({ hasText: '写进' }).count() === 1) + await shot(page, 'profile-interrupted') + await page.locator('[data-profile-close]').click() + + // 进程被杀时那张表一项都还没查完(还在估行数):服务端给它的原因就是任务的那一句,这一行改写「没来得及收尾」; + // 也没有别的表收了尾,不说「其余做完的项已写进」 + await openDialog(page) + await page.locator('[data-profile-start]').click() + await until(async () => (await phase(page)) === 'running') + const job5 = state.pjobs.at(-1) + const lost5 = job5.tasks[0].table_name + settle(state, job5, lost5, { status: 'running', queries: 1, current: `正在估算 ${lost5} 的行数` }) + stopJob(job5, { status: 'interrupted', error: RESTART, current: `${RESTART},未检查`, saved: false }) + check('……一项都还没查完就被杀:这一行写「没来得及收尾,这次的结论没有写进目录」,说明里不提「其余做完的项」', await until(async () => (await phase(page)) === 'done') + && (await inBox(page, `[data-profile-task="${lost5}"] [data-profile-task-note]`)) === '剖析到一半中断,没来得及收尾,这次的结论没有写进目录,保留原来的结论' + && (await note()).includes(`「${lost5}」剖析到一半服务就停了`) && !(await note()).includes('其余做完的项'), + `${await inBox(page, `[data-profile-task="${lost5}"]`)} · ${await note()}`) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() }) @@ -1378,6 +1476,18 @@ await section('剖析 · 表详情的「数据剖析」一节', async () => { await page.locator('[data-catalog-row="customers"] [data-catalog-open-row]').click() check('剖析完:这一节换成这次的结果(行数更新),发起人是本机', await until(async () => (await textOf(sec.locator('[data-profile-rows]'))).includes('约 12,500 行')) && (await textOf(sec.locator('[data-catalog-profile-last]'))).includes(`${ACTOR} 发起`)) + // 再剖析一次,剖析到一半进程被杀(下次启动时才标中断):这张表没来得及收尾,这一节照服务端写明查完的项没写进目录, + // 跳过的项里那一项写明结论没写进去 + await sec.locator('[data-catalog-profile-table]').click() + await until(async () => state.pjobs.at(-1) !== job) + const killed = state.pjobs.at(-1) + settle(state, killed, 'customers', { status: 'running', done: { codes: 1 }, queries: 2 }) + await until(async () => (await sec.locator('[data-catalog-profile-live="running"]').count()) === 1) + stopJob(killed, { status: 'interrupted', error: '服务重启,剖析中断', current: '服务重启,剖析中断,未检查', saved: false }) + check('剖析到一半进程被杀:这一节写已停止和服务端的原因(查完的项没写进目录),跳过的项写明结论没写进去', await until(async () => + (await textOf(sec.locator('[data-profile-table-error]'))) === '服务重启,剖析中断:已查完的 1 项没有写进数据目录,剩下 1 项没查') + && (await textOf(sec.locator('[data-catalog-profile-last]'))).includes('已停止') + && (await textOf(sec)).includes('查完了但结论没有写进数据目录') && !(await textOf(sec)).includes('照常写进'), await textOf(sec)) // 没剖析过、视图、没有可剖析项 await page.locator('[data-catalog-row="orders"] [data-catalog-open-row]').click() check('从没剖析过的表:写「还没有剖析过」,给「剖析这张表」', await until(async () => (await textOf(sec.locator('[data-catalog-profile-never]'))) === '这张表还没有剖析过。')