fix(server,test): W6-A stabilize CI — sync lost update, atomic recovery summary, serial PG tests
- downloads.ts observe: re-read download_task FOR UPDATE inside the tx and merge from the locked row; qB sync could overwrite imported back to importing when verifyAndImport committed between the sync read and write (E8 flake). - agent finalize: accept summaryPrefix so the recovery verdict is written with the terminal status in one update (E6 read a budget_exceeded run before the "未恢复:…" annotation landed); recover-summary step kept, now idempotent no-op. - organize.ts: drop local slotComplete and the once-subscription completion write; completion/history are decided only by applySlotEvent(verify_passed) (W5-C follow-up, removes double write). Manual slot organize uses the shared subscriptions.slotComplete. - vitest: server split into unit (parallel) and pg (file-serial, groupOrder 1) projects; packages/tasks file-serial; explicit worker concurrency in e2e. - docs: W6 stabilization record in 04-v0.1-acceptance.md. Test expectations unchanged. Full `deno task ci` x3 green: 369s / 341s / 337s. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
36aa99b8aa
commit
887843f777
10 files changed
+198
-143
No files matched your search
@@ -659,7 +659,29 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
|
||||
const sizeOf = (t: QbTorrent) => (t.totalSize ?? t.size) || null;
|
||||
const downloadedOf = (t: QbTorrent) => Math.max(0, Math.round((sizeOf(t) ?? 0) - t.amountLeft));
|
||||
|
||||
async function observe(d: AoDeps, row: DownloadTaskRow, t: QbTorrent, now: Date, stats: SyncStats): Promise<boolean> {
|
||||
async function observe(d: AoDeps, seen: DownloadTaskRow, t: QbTorrent, now: Date, stats: SyncStats): Promise<boolean> {
|
||||
// 行锁内按最新行合并:同步读到行之后入库事务可能已把 importing 推进到 imported,
|
||||
// 用同步开始时的快照整行覆写会把状态写回 importing(丢失更新)
|
||||
return d.ctx.db.transaction().execute(async (trx) => {
|
||||
const row = await trx
|
||||
.selectFrom("download_task")
|
||||
.selectAll()
|
||||
.where("id", "=", seen.id)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!row || row.removed_at || row.status === "removed") return false;
|
||||
return observeLocked(d, trx, row, t, now, stats);
|
||||
});
|
||||
}
|
||||
|
||||
async function observeLocked(
|
||||
d: AoDeps,
|
||||
trx: Exec,
|
||||
row: DownloadTaskRow,
|
||||
t: QbTorrent,
|
||||
now: Date,
|
||||
stats: SyncStats,
|
||||
): Promise<boolean> {
|
||||
const cur = { status: row.status, pauseReason: row.pause_reason };
|
||||
const obs = mapQbState(
|
||||
{ state: t.rawState, progress: t.progress, amountLeft: t.amountLeft },
|
||||
@@ -691,80 +713,71 @@ async function observe(d: AoDeps, row: DownloadTaskRow, t: QbTorrent, now: Date,
|
||||
}
|
||||
const becameComplete = merged.status === "completed" && !["completed", "importing", "imported"].includes(row.status);
|
||||
const changed = merged.status !== row.status || merged.pauseReason !== row.pause_reason;
|
||||
await d.ctx.db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({
|
||||
status: merged.status,
|
||||
pause_reason: merged.status === "paused" ? merged.pauseReason : null,
|
||||
raw_state: t.rawState,
|
||||
progress: Math.min(1, Math.max(0, t.progress)),
|
||||
size_bytes: sizeOf(t),
|
||||
downloaded_bytes: downloaded,
|
||||
remaining_bytes: Math.max(0, Math.round(t.amountLeft)),
|
||||
ratio: t.ratio,
|
||||
seeding_time_sec: t.seedingTimeSec,
|
||||
save_path: t.savePath,
|
||||
content_path: t.contentPath,
|
||||
name: t.name,
|
||||
tags: t.tags,
|
||||
last_progress_at: lastProgressAt,
|
||||
no_progress_since: noProgressSince,
|
||||
last_synced_at: now,
|
||||
...(becameComplete ? { completed_at: now } : {}),
|
||||
})
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
if (changed)
|
||||
await emitStatus(
|
||||
d,
|
||||
trx,
|
||||
row.id,
|
||||
row.status,
|
||||
merged.status,
|
||||
merged.status === "paused" ? merged.pauseReason : null,
|
||||
);
|
||||
if (becameComplete) {
|
||||
stats.completed.push(row.id);
|
||||
const plan = row.acquisition_plan_id
|
||||
? await trx
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["id", "slot_id"])
|
||||
.where("id", "=", row.acquisition_plan_id)
|
||||
.executeTakeFirst()
|
||||
: null;
|
||||
if (plan) await applySlotEvent(d, trx, plan.slot_id, { type: "download_completed" }, { planId: plan.id });
|
||||
}
|
||||
if (stalledNow) {
|
||||
// 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2)
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "download_stalled",
|
||||
title: `下载无进展:${t.name}`,
|
||||
summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`,
|
||||
subjectType: "download_task",
|
||||
subjectId: row.id,
|
||||
dedupeKey: `download_stalled:${row.id}`,
|
||||
evidence: {
|
||||
rawState: t.rawState,
|
||||
progress: t.progress,
|
||||
downloadedBytes: downloaded,
|
||||
noProgressSince: noProgressSince?.toISOString(),
|
||||
action: "record_evidence",
|
||||
},
|
||||
});
|
||||
}
|
||||
if ((merged.status === "failed" || merged.status === "missing") && changed) {
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "download_failed",
|
||||
title: `下载异常:${t.name}`,
|
||||
summary: `qB 状态 ${t.rawState}`,
|
||||
subjectType: "download_task",
|
||||
subjectId: row.id,
|
||||
dedupeKey: `download_failed:${row.id}`,
|
||||
evidence: { rawState: t.rawState, progress: t.progress },
|
||||
});
|
||||
}
|
||||
});
|
||||
await trx
|
||||
.updateTable("download_task")
|
||||
.set({
|
||||
status: merged.status,
|
||||
pause_reason: merged.status === "paused" ? merged.pauseReason : null,
|
||||
raw_state: t.rawState,
|
||||
progress: Math.min(1, Math.max(0, t.progress)),
|
||||
size_bytes: sizeOf(t),
|
||||
downloaded_bytes: downloaded,
|
||||
remaining_bytes: Math.max(0, Math.round(t.amountLeft)),
|
||||
ratio: t.ratio,
|
||||
seeding_time_sec: t.seedingTimeSec,
|
||||
save_path: t.savePath,
|
||||
content_path: t.contentPath,
|
||||
name: t.name,
|
||||
tags: t.tags,
|
||||
last_progress_at: lastProgressAt,
|
||||
no_progress_since: noProgressSince,
|
||||
last_synced_at: now,
|
||||
...(becameComplete ? { completed_at: now } : {}),
|
||||
})
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
if (changed)
|
||||
await emitStatus(d, trx, row.id, row.status, merged.status, merged.status === "paused" ? merged.pauseReason : null);
|
||||
if (becameComplete) {
|
||||
stats.completed.push(row.id);
|
||||
const plan = row.acquisition_plan_id
|
||||
? await trx
|
||||
.selectFrom("acquisition_plan")
|
||||
.select(["id", "slot_id"])
|
||||
.where("id", "=", row.acquisition_plan_id)
|
||||
.executeTakeFirst()
|
||||
: null;
|
||||
if (plan) await applySlotEvent(d, trx, plan.slot_id, { type: "download_completed" }, { planId: plan.id });
|
||||
}
|
||||
if (stalledNow) {
|
||||
// 无进展:只记证据 + 待处理,保留原任务,不换源(REQ §8.4.2)
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "download_stalled",
|
||||
title: `下载无进展:${t.name}`,
|
||||
summary: `已 ${Math.round((now.getTime() - (noProgressSince?.getTime() ?? now.getTime())) / 60000)} 分钟没有新增字节`,
|
||||
subjectType: "download_task",
|
||||
subjectId: row.id,
|
||||
dedupeKey: `download_stalled:${row.id}`,
|
||||
evidence: {
|
||||
rawState: t.rawState,
|
||||
progress: t.progress,
|
||||
downloadedBytes: downloaded,
|
||||
noProgressSince: noProgressSince?.toISOString(),
|
||||
action: "record_evidence",
|
||||
},
|
||||
});
|
||||
}
|
||||
if ((merged.status === "failed" || merged.status === "missing") && changed) {
|
||||
await upsertDecision(d, trx, {
|
||||
kind: "download_failed",
|
||||
title: `下载异常:${t.name}`,
|
||||
summary: `qB 状态 ${t.rawState}`,
|
||||
subjectType: "download_task",
|
||||
subjectId: row.id,
|
||||
dedupeKey: `download_failed:${row.id}`,
|
||||
evidence: { rawState: t.rawState, progress: t.progress },
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type ExistingPolicy,
|
||||
type ExistingTarget,
|
||||
findOtherVersions,
|
||||
movieCompletion,
|
||||
type NewBinding,
|
||||
type OrganizePlan,
|
||||
overrideFor,
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
type QualityFacts,
|
||||
resolveTargetDirectory,
|
||||
type SourceFile,
|
||||
unitCompletion,
|
||||
} from "@umt/domain";
|
||||
import { organizationStatusFromFiles, organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping";
|
||||
import { withAttemptToken } from "@umt/tasks";
|
||||
@@ -45,6 +43,7 @@ import { applySlotEvent } from "./downloads.ts";
|
||||
import { PathGuardError } from "./organize-guard.ts";
|
||||
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
|
||||
import { hnrSatisfied } from "./retention.ts";
|
||||
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts";
|
||||
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||
@@ -1455,14 +1454,14 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.where("id", "=", plan.slot_id)
|
||||
.executeTakeFirst();
|
||||
if (slot && okAny) {
|
||||
const complete = await slotComplete(trx, slot, frozen, verifiedAssets);
|
||||
const old = slot.current_asset_id;
|
||||
// 完成判定与订阅完成(含历史)统一由 applySlotEvent(verify_passed) 按总集数账本重算,这里不再本地判定
|
||||
await applySlotEvent(
|
||||
d,
|
||||
trx,
|
||||
slot.id,
|
||||
{ type: "verify_passed", complete },
|
||||
{ currentAssetId: verifiedAssets[0]!, reason: complete ? "verified" : "verified_partial" },
|
||||
{ type: "verify_passed", complete: false },
|
||||
{ currentAssetId: verifiedAssets[0]!, reason: "verified" },
|
||||
);
|
||||
if (old && old !== verifiedAssets[0] && plan.purpose === "upgrade") {
|
||||
// 升级:旧版本保留,处置只记待处理,不删
|
||||
@@ -1482,14 +1481,6 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
},
|
||||
});
|
||||
}
|
||||
if (complete && slot.subscription_id) {
|
||||
await trx
|
||||
.updateTable("subscription")
|
||||
.set({ status: "completed", status_reason: "slot_satisfied" })
|
||||
.where("id", "=", slot.subscription_id)
|
||||
.where("mode", "=", "once")
|
||||
.execute();
|
||||
}
|
||||
} else if (slot && !okAny) {
|
||||
await applySlotEvent(d, trx, slot.id, { type: "verify_failed" }, { reason: `organize:${status}` });
|
||||
}
|
||||
@@ -1505,7 +1496,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (slot && !["reserved", "downloading", "verifying"].includes(slot.status)) {
|
||||
const complete = await slotComplete(trx, slot, frozen, verifiedAssets);
|
||||
const { complete } = await sharedSlotComplete(trx, slot, frozen.mediaType, verifiedAssets);
|
||||
await trx
|
||||
.updateTable("acquisition_slot")
|
||||
.set({
|
||||
@@ -1545,56 +1536,6 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
return { recordId: result.recordId, status: result.status, succeeded: result.succeeded, failed: result.failed };
|
||||
}
|
||||
|
||||
async function slotComplete(
|
||||
exec: Exec,
|
||||
slot: { id: string; media_entity_id: string; content_unit_id: string | null; completion: unknown },
|
||||
frozen: FrozenPlan,
|
||||
newAssets: string[],
|
||||
): Promise<boolean> {
|
||||
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
|
||||
if (frozen.mediaType === "movie") {
|
||||
const assets = await exec
|
||||
.selectFrom("media_asset")
|
||||
.select(["id", "content_edition"])
|
||||
.where("id", "in", newAssets)
|
||||
.execute();
|
||||
return movieCompletion(
|
||||
completion.cuts as never,
|
||||
assets.map((a) => ({
|
||||
assetId: a.id,
|
||||
verified: true,
|
||||
...(a.content_edition ? { cut: a.content_edition as never } : {}),
|
||||
})),
|
||||
).complete;
|
||||
}
|
||||
// 剧集/综艺:按内容单元覆盖;单元未建档(总集数未知)时继续追踪,不声称完整
|
||||
let q = exec
|
||||
.selectFrom("content_unit")
|
||||
.select(["id", "status"])
|
||||
.where("media_entity_id", "=", slot.media_entity_id)
|
||||
.where("kind", "=", "episode")
|
||||
.where("content_type", "=", "main");
|
||||
if (slot.content_unit_id) q = q.where("parent_unit_id", "=", slot.content_unit_id);
|
||||
const units = await q.where("status", "!=", "retired").execute();
|
||||
const required = units.map((u) => u.id);
|
||||
if (required.length === 0) return false;
|
||||
// 总集数只有在单元都已确认(元数据/人工)时才算已知;由发布标题或文件名临时建出的单元(provisional)
|
||||
// 只说明"见过这些集",不说明"只有这些集":总集数未知时不判完整,订阅继续追踪(REQ §7.1.1)
|
||||
const totalKnown = units.every((u) => u.status === "confirmed");
|
||||
if (!totalKnown) return false;
|
||||
const cov = await sql<{ asset_id: string; unit_id: string }>`
|
||||
select c.media_asset_id as asset_id, c.content_unit_id as unit_id from asset_unit_coverage c
|
||||
join media_asset a on a.id = c.media_asset_id
|
||||
where a.media_entity_id = ${slot.media_entity_id} and a.status = 'active'`.execute(exec);
|
||||
const byAsset = new Map<string, string[]>();
|
||||
for (const r of cov.rows) byAsset.set(r.asset_id, [...(byAsset.get(r.asset_id) ?? []), r.unit_id]);
|
||||
return unitCompletion(
|
||||
required,
|
||||
[...byAsset].map(([assetId, fileUnits]) => ({ assetId, verified: true, fileUnits })),
|
||||
{ totalKnown },
|
||||
).complete;
|
||||
}
|
||||
|
||||
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
|
||||
async function ensureEpisodeUnits(
|
||||
exec: Exec,
|
||||
|
||||
@@ -189,6 +189,7 @@ export async function finalize(
|
||||
runId: string,
|
||||
res: DriveResult,
|
||||
settled: AgentGoalReport | null,
|
||||
opts: { summaryPrefix?: string } = {},
|
||||
): Promise<{ status: AgentRunStatus; summary: string }> {
|
||||
const { ctx } = env;
|
||||
const run = await ctx.db.selectFrom("agent_run").selectAll().where("id", "=", runId).executeTakeFirstOrThrow();
|
||||
@@ -213,6 +214,8 @@ export async function finalize(
|
||||
}
|
||||
// 模型文本只作参考:附在业务核查结论之后,明确标注未经核查
|
||||
if (res.finalText && res.status !== "failed") summary += `\n模型汇报(未经核查):${res.finalText}`;
|
||||
// 系统核实结论(授权内恢复)与终态同一次写入:观察者看到终态时汇总已是最终文本
|
||||
if (opts.summaryPrefix) summary = `${opts.summaryPrefix}\n${summary}`;
|
||||
await ctx.db
|
||||
.insertInto("artifact")
|
||||
.values({
|
||||
|
||||
@@ -90,8 +90,11 @@ export function registerAgentTasks(registry: TaskRegistry, env: () => AgentEnv)
|
||||
maxRounds: rounds,
|
||||
});
|
||||
}
|
||||
const out = await t.step.run("finalize", () => finalize(e, runId, res, settled));
|
||||
const out = await t.step.run("finalize", () =>
|
||||
finalize(e, runId, res, settled, recovery ? { summaryPrefix: recovery.summary } : {}),
|
||||
);
|
||||
if (recovery) {
|
||||
// 已在 finalize 内带上核实结论;保留该步骤兼容旧 run 的步骤历史(已带前缀时幂等跳过)
|
||||
const rec = recovery;
|
||||
return t.step.run("recover-summary", () => annotateRunSummary(e.ctx, runId, rec));
|
||||
}
|
||||
|
||||
@@ -34,7 +34,14 @@ const worker = await startWorker({
|
||||
logger,
|
||||
heartbeatMs: 60_000,
|
||||
bus,
|
||||
runtime: { ctx: booted.ctx, modules, setRelay: booted.outbox.setRelay, outboxIntervalMs: 100, concurrency: 4 },
|
||||
runtime: {
|
||||
ctx: booted.ctx,
|
||||
modules,
|
||||
setRelay: booted.outbox.setRelay,
|
||||
outboxIntervalMs: 100,
|
||||
concurrency: 4,
|
||||
agentConcurrency: 2,
|
||||
},
|
||||
});
|
||||
console.log(`worker ready pid=${Deno.pid}`);
|
||||
Deno.addSignalListener("SIGTERM", async () => {
|
||||
|
||||
@@ -189,7 +189,9 @@ export async function createE2E(name: string, opts: E2EOptions = {}): Promise<E2
|
||||
modules,
|
||||
setRelay: booted.outbox.setRelay,
|
||||
outboxIntervalMs: 100,
|
||||
// 并发显式固定,不随 WORKER_CONCURRENCY 等环境变量漂移
|
||||
concurrency: 4,
|
||||
agentConcurrency: 2,
|
||||
},
|
||||
});
|
||||
e.worker = worker;
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// 真实 PG 的集成/e2e 测试共用开发库并行跑:建/删 schema 串行在一把咨询锁上(withSchemaLock),
|
||||
// 负载高时 beforeAll/afterAll 排队会超过缺省 10s,这里放宽钩子超时。
|
||||
// 两组测试:
|
||||
// unit —— 纯内存、无 PG,文件并行;
|
||||
// pg —— 真实 PG 的集成/e2e(每文件独立业务 schema + 引擎 schema,真实 worker/后台循环),文件级串行,
|
||||
// 并排在 unit 之后(groupOrder)。并发全量跑时多个 e2e 的 worker/轮询/迁移互相抢 CPU 与连接,
|
||||
// 会把"入库→状态推进"这类时序拉长到超时,也会放大竞态;串行让每个文件拿到稳定的时序预算。
|
||||
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
|
||||
const UNIT = [
|
||||
"test/api-registry.test.ts",
|
||||
"test/auth.test.ts",
|
||||
"test/health.test.ts",
|
||||
"test/invoke-limits.test.ts",
|
||||
"test/logger.test.ts",
|
||||
"test/mapping-compat.test.ts",
|
||||
"test/modules.test.ts",
|
||||
"test/sse.test.ts",
|
||||
"test/static.test.ts",
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// 建/删 schema 在一把咨询锁上排队(withSchemaLock),迁移 + 引擎 schema 在负载下可能超过缺省 10s
|
||||
hookTimeout: 120_000,
|
||||
teardownTimeout: 60_000,
|
||||
projects: [
|
||||
{ extends: true, test: { name: "unit", include: UNIT } },
|
||||
{
|
||||
extends: true,
|
||||
test: {
|
||||
name: "pg",
|
||||
include: ["test/**/*.test.ts", "src/**/*.test.ts"],
|
||||
exclude: UNIT,
|
||||
fileParallelism: false,
|
||||
sequence: { groupOrder: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/internal@^1.0.14": "1.0.14",
|
||||
"jsr:@std/path@1": "1.1.6",
|
||||
"npm:@biomejs/biome@*": "2.5.14",
|
||||
"npm:@biomejs/[email protected]": "2.5.14",
|
||||
"npm:@earendil-works/[email protected]": "[email protected]",
|
||||
@@ -60,6 +62,17 @@
|
||||
"npm:[email protected]": "4.6.5",
|
||||
"npm:zod@^4.3.6": "4.6.5"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/[email protected]": {
|
||||
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
|
||||
},
|
||||
"@std/[email protected]": {
|
||||
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@antfu/[email protected]": {
|
||||
"integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==",
|
||||
|
||||
@@ -214,3 +214,36 @@
|
||||
3. **按 03 清单把 IO 与 Agent 分成两个 worker 组**,给 verifyAndImport、runAgent 分配独立并发度,并加事件循环延迟探针。在性能基线(REQ §16.2)里记录文件数、挂载类型和 ci 耗时,防止 ci 从约 135 秒继续膨胀(可以考虑按包并行跑 `-j`)。
|
||||
4. **e2e 矩阵扩到剧集和部分失败**:剧集追更(S5)、整季包部分已有(AT54)、部分文件入库后不清理(AT29)、多下载器同一 hash(AT73)。这些是 M2 综艺分段建模的前置样本。
|
||||
5. **把恢复和版本升级纳入 CI**:用 docker compose 跑"备份 → 清库 → 恢复 → 只读对账 → 映射比对"的夜间任务,再加一条 openworkflow 版本升级的回归测试,避免只靠人工演练记录。
|
||||
|
||||
## W6 治稳记录(2026-09-28)
|
||||
|
||||
起点:W4/W5 全部合入后两次全量 `deno task ci` 各挂不同用例(run1 E8 两例:`download_task` 停在 `importing`、C 没被清理;run2 E1 等 qB 收种超时、E6 预算超限用例汇总文案不符)。
|
||||
|
||||
**区分方法**:10 个 e2e 文件逐个串行各跑 3 次,30/30 全绿;失败只在 44 个 server 测试文件并发全量时出现。结论是没有"稳定必现"的回归,但其中两处是并发下真实存在的产品竞态,另一处是负载导致的超时。
|
||||
|
||||
### 真实缺陷(并发暴露)与修法
|
||||
|
||||
| 现象 | 根因 | 修法 |
|
||||
|---|---|---|
|
||||
| E8:入库记录已 succeeded,`download_task` 仍是 `importing`;随后缓存清理不认它(期望 removed 实得 importing) | qB 同步 `observe` 先在事务外读 `download_task` 快照,再整行覆写 `status`;入库事务在两者之间把 `importing→imported` 提交后,同步按旧快照合并出 `importing` 写回(丢失更新)。负载高时窗口变大 | `services/downloads.ts observe`:事务内 `select … for update` 重读该行,按锁住的最新状态合并后写回(`mergeObservedDownloadState` 对 imported 保持不变) |
|
||||
| E6 预算超限:run 已是 `budget_exceeded`,summary 还是 finalize 的原文,之后才被改写成"未恢复:调查超出预算…" | 授权内恢复的核实结论由 finalize 之后单独一步 `annotateRunSummary` 追加,终态与最终汇总分两次写,观察者(UI/SSE/测试)能读到中间态 | `finalize` 新增 `summaryPrefix`,runAgent 把恢复结论随终态同一次写入并推送;`recover-summary` 步骤保留(已带前缀时幂等跳过),兼容旧 run 的步骤历史 |
|
||||
| (W5-C 遗留双写) | `organize.ts verifyAndImport` 仍用本地 `slotComplete` 判定并直接把 once 订阅置 completed,与 `applySlotEvent(verify_passed)` 按总集数账本的判定并存;总集数未知/订阅 pending 时两者会给出相反结论 | 删除 organize 本地 `slotComplete` 与订阅完成更新,完成与历史只由 `applySlotEvent` 钩子负责;手动整理指定 Slot 的分支改调 `services/subscriptions.ts slotComplete` |
|
||||
|
||||
测试期望未改。
|
||||
|
||||
### 不稳定项与治法
|
||||
|
||||
- E1 等 qB 收种 60s 超时:全量并发时 44 个文件各起真实 worker/outbox/后台循环抢 CPU 与连接,编排链被拉长。治法:`apps/server/vitest.config.ts` 分两个 project,`unit`(明确列出的纯内存文件)并行,`pg`(其余全部,含 e2e 与 PG 集成,新文件缺省归此)`fileParallelism:false` 且 `groupOrder:1` 排在 unit 之后;`packages/tasks` 的 PG 引擎测试同样文件级串行。
|
||||
- worker 并发在 e2e 夹具与崩溃恢复子进程里显式配置(io 4 / agent 2),不受环境变量影响;钩子超时 120s、teardown 60s。
|
||||
- 已有保障复核:每文件独立业务 schema + 引擎 schema(pid+随机后缀),建/删在咨询锁上串行;mock qB/TMDB/RSS/站点端口均为 0(随机);等待均走 `waitFor` 轮询 + 超时。E8/E9 里剩下的 1.5s 固定等待是"一段时间内不应发生"的反向断言,保留。
|
||||
- 未引入任何重试。
|
||||
|
||||
### 3 次全量 ci
|
||||
|
||||
| 次 | 结果 | 耗时 | 其中 server vitest |
|
||||
|---|---|---|---|
|
||||
| 1 | 全绿(server 44 文件 / 259 用例) | 369s | 227s |
|
||||
| 2 | 全绿 | 341s | 232s |
|
||||
| 3 | 全绿 | 337s | 228s |
|
||||
|
||||
代价:server 测试从并发约 90s 变为串行约 230s,tasks 约 52s;换来的是时序预算可预期。后续如需提速,可按 e2e 与集成拆成两个串行组分进程跑。
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// 引擎测试连真实 PG(每文件独立 schema)并起真实 worker 轮询/租约:文件级串行,避免并发抢占拉长租约与心跳时序。
|
||||
export default defineConfig({
|
||||
test: {
|
||||
fileParallelism: false,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user