Skip to content

Commit c8ea853

Browse files
ci: merge queue 吞吐——Test Core/Dogfood 各 3 分片 + 队列失败自动分诊评论 (#4863)
* ci: merge queue 吞吐——Test Core/Dogfood 各 3 分片 + 队列失败自动分诊评论 (#4859) 2026-08-03 上午实测:队列构建关键路径 ~11 min(Test Core 慢分片 9m50s), 且 20 个队列构建 7 个失败,失败被盲目重排、每次连坐重建后面所有构建—— 失败放大才是队列慢的主因,构建时长是次因。 - Test Core 2→3 分片:慢分片 ~10min → 预期 ~5-6min。required 上下文由 test-gate 聚合层承载("Test Core"),分片数可变正是它存在的目的(#3622)。 - Dogfood 2→3 分片:Test Core 砍掉后的新长杆(7m38s → 预期 ~5min)。顺带 给其 turbo restore 补 job 级 catch-all 恢复键(Test Core 已有同款), 新分片号在 main 播种前不至于全冷。 - 新增 merge-queue-triage.yml:merge_group 的 CI 失败 → 自动在 PR 上评论 失败 job/测试行 + 本 PR 24h 重排失败史 + 队列失败面板 + 分诊清单, 给人和 PM agent「先诊断再重排」的机器可读信号。cancelled(连坐撤销) 不触发。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNH5jsbfeRTZPwBmBbkwMH * chore: 空 changeset —— 本 PR 仅改 workflow,不发布任何包 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNH5jsbfeRTZPwBmBbkwMH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent beefe89 commit c8ea853

3 files changed

Lines changed: 194 additions & 20 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
CI-only (#4859): Test Core / Dogfood 各 2→3 分片 + merge-queue 失败自动分诊评论 workflow。仅 `.github/workflows/**`,不发布任何包。

.github/workflows/ci.yml

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,13 @@ jobs:
7575
- '.github/workflows/ci.yml'
7676
7777
test:
78-
# Sharded 2-way BY PACKAGE: a core-touching PR ran the affected suite
79-
# ~11½ min on one 4-vCPU runner — the longest pole in the whole workflow.
80-
# scripts/partition-test-shards.mjs splits the package list into two
81-
# deterministic, test-file-count-balanced halves (573/572 at the time of
82-
# writing) and each shard runs its half through turbo. NOT the dogfood
78+
# Sharded 3-way BY PACKAGE: a core-touching PR ran the affected suite
79+
# ~11½ min on one 4-vCPU runner — the longest pole in the whole workflow —
80+
# and at 2 shards the slower shard still ran ~10 min, keeping merge-queue
81+
# builds at ~11 min end-to-end (#4859).
82+
# scripts/partition-test-shards.mjs splits the package list into three
83+
# deterministic, test-file-count-balanced thirds and each shard runs its
84+
# slice through turbo. NOT the dogfood
8385
# job's vitest --shard passthrough, deliberately: that works for dogfood
8486
# because it is ONE package with ~60 files, but applied workspace-wide,
8587
# vitest 4 hard-fails every package with fewer test files than the shard
@@ -90,13 +92,13 @@ jobs:
9092
# Branch protection requires the bare "Test Core" context, which a matrix
9193
# can never publish again — the test-gate job below carries that name
9294
# (the #3622 lesson; see dogfood-gate).
93-
name: Test Core (${{ matrix.shard }}/2)
95+
name: Test Core (${{ matrix.shard }}/3)
9496
needs: filter
9597
if: needs.filter.outputs.core == 'true'
9698
runs-on: ubuntu-latest
9799
# Backstop only — the stall guard on the test steps is the primary
98100
# detector for a #4250-style hang and fires well before this. 30 min is
99-
# ~4× a normal sharded run (~6-7 min), with margin for a cold Turbo cache;
101+
# ~5× a normal sharded run (~4-6 min), with margin for a cold Turbo cache;
100102
# the old 45 left a hung job "running" for half an hour past any plausible
101103
# healthy finish.
102104
timeout-minutes: 30
@@ -105,7 +107,7 @@ jobs:
105107
strategy:
106108
fail-fast: false
107109
matrix:
108-
shard: [1, 2]
110+
shard: [1, 2, 3]
109111

110112
steps:
111113
- name: Checkout repository
@@ -190,7 +192,7 @@ jobs:
190192
pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json"
191193
fi
192194
node scripts/partition-test-shards.mjs "$RUNNER_TEMP/turbo-ls.json" \
193-
--shard ${{ matrix.shard }}/2 --exclude @objectstack/dogfood \
195+
--shard ${{ matrix.shard }}/3 --exclude @objectstack/dogfood \
194196
> "$RUNNER_TEMP/shard-packages.txt"
195197
echo "Packages on this shard:"
196198
cat "$RUNNER_TEMP/shard-packages.txt"
@@ -499,18 +501,19 @@ jobs:
499501
test
500502
501503
dogfood:
502-
# Sharded 2-way: the suite is ~60 independent test files, each booting its
503-
# own in-process app, and a single 4-vCPU runner needed ~7½ minutes for the
504-
# lot — the longest pole in the whole workflow. vitest partitions the file
505-
# list deterministically across shards; both shards must pass. If branch
506-
# protection lists "Dogfood Regression Gate" as a required check, it must be
507-
# updated to the two sharded check names.
508-
name: Dogfood Regression Gate (${{ matrix.shard }}/2)
504+
# Sharded 3-way: the suite is ~60 independent test files, each booting its
505+
# own in-process app; a single 4-vCPU runner needed ~7½ minutes for the
506+
# lot, and at 2 shards each half still ran ~7 min — the longest pole left
507+
# once Test Core went 3-way (#4859). vitest partitions the file list
508+
# deterministically across shards; all shards must pass. Branch protection
509+
# requires only the bare "Dogfood Regression Gate" context, carried by the
510+
# dogfood-gate job below — the shard count can change without touching it.
511+
name: Dogfood Regression Gate (${{ matrix.shard }}/3)
509512
needs: filter
510513
if: needs.filter.outputs.core == 'true'
511514
runs-on: ubuntu-latest
512515
# Backstop only — the stall guard on the test step is the primary detector
513-
# for a #4250-style hang (see Test Core). 30 min is ~4× a shard (~7 min;
516+
# for a #4250-style hang (see Test Core). 30 min is ~6× a shard (~5 min;
514517
# the verify-CLI pass that used to ride shard 1 is its own parallel job
515518
# now — dogfood-verify below).
516519
timeout-minutes: 30
@@ -519,7 +522,7 @@ jobs:
519522
strategy:
520523
fail-fast: false
521524
matrix:
522-
shard: [1, 2]
525+
shard: [1, 2, 3]
523526

524527
steps:
525528
- name: Checkout repository
@@ -552,7 +555,10 @@ jobs:
552555
# Shard-scoped key: the turbo test hash differs per shard (pass-through
553556
# args are part of the task hash). Restore-only on PRs — see the Restore
554557
# Turbo cache comment in the test job; the save step at the end of this
555-
# job seeds from main only.
558+
# job seeds from main only. The job-level catch-all (same as Test Core's)
559+
# is what keeps a NEW shard number warm before main has ever saved it:
560+
# turbo's cache is content-addressed per task, so another shard's entries
561+
# replay the shared build closure even when the test slice differs.
556562
- name: Restore Turbo cache
557563
uses: actions/cache/restore@v6
558564
with:
@@ -561,6 +567,7 @@ jobs:
561567
restore-keys: |
562568
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-
563569
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-
570+
${{ runner.os }}-turbo-${{ github.job }}-
564571
565572
- name: Install dependencies
566573
run: pnpm install --frozen-lockfile
@@ -585,7 +592,7 @@ jobs:
585592
mkdir -p "$RUNNER_TEMP/stall-reports"
586593
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/dogfood.log" --stall-minutes 10 \
587594
--report-dir "$RUNNER_TEMP/stall-reports" -- \
588-
pnpm turbo run test --filter=@objectstack/dogfood -- --shard=${{ matrix.shard }}/2
595+
pnpm turbo run test --filter=@objectstack/dogfood -- --shard=${{ matrix.shard }}/3
589596
590597
# Dogfood boots real apps in-process, so a native/OOM abort is likelier
591598
# here than in the unit suites — and a shard that dies silently looks like
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
name: Merge Queue Triage
2+
3+
# Why this exists (#4859): red queue builds were being blind-requeued. On
4+
# 2026-08-03 one PR failed the queue four times and landed unchanged on the
5+
# fifth attempt (09:19 → 10:22), and every failure evicted and rebuilt every
6+
# entry queued behind it — the queue's perceived slowness that morning was
7+
# mostly this amplification, not build duration.
8+
#
9+
# A queue failure is a different animal from a PR failure: the PR's own CI ran
10+
# affected-only, while the queue runs the FULL suite on the speculative merge
11+
# result. The failing test is therefore often in a package the PR never
12+
# touched — a flake, or a semantic conflict with another queued PR — and
13+
# neither of those is fixed by re-queueing; re-queueing just burns another
14+
# ~10-minute build for every entry behind it.
15+
#
16+
# So: every red merge_group CI run gets a triage comment on its PR — the
17+
# failed jobs/steps, the failing test lines pulled from the logs (best
18+
# effort), how many times THIS PR has already failed in the queue in the last
19+
# 24 h, and the queue-wide failure count. The checklist tells the author
20+
# (human or agent) to diagnose before re-queueing. The comment is the
21+
# machine-readable signal the PM dispatch loop can key on, and the cross-PR
22+
# flake evidence lives in these comments: the same test name appearing in two
23+
# unrelated PRs' triage comments is a confirmed flake.
24+
#
25+
# Fires on conclusion == failure ONLY. 'cancelled' is the queue evicting an
26+
# entry because something AHEAD of it failed (or a manual cancel) — it says
27+
# nothing about this PR, so it gets no comment (the same run-lifecycle
28+
# reasoning as dogfood-gate's cancelled handling in ci.yml, from the other
29+
# side).
30+
#
31+
# workflow_run executes in the DEFAULT branch's context: this file must be on
32+
# main before it fires, it never checks out or runs PR code, and it holds the
33+
# minimum permissions (actions: read for logs, pull-requests: write for the
34+
# comment).
35+
36+
on:
37+
workflow_run:
38+
workflows: [CI]
39+
types: [completed]
40+
41+
permissions: {}
42+
43+
jobs:
44+
triage:
45+
name: Comment queue-failure triage on the PR
46+
if: >-
47+
github.event.workflow_run.event == 'merge_group' &&
48+
github.event.workflow_run.conclusion == 'failure'
49+
runs-on: ubuntu-latest
50+
timeout-minutes: 5
51+
permissions:
52+
actions: read
53+
pull-requests: write
54+
steps:
55+
- name: Post the triage comment
56+
uses: actions/github-script@v9
57+
with:
58+
script: |
59+
const run = context.payload.workflow_run;
60+
const { owner, repo } = context.repo;
61+
62+
// Queue branches are named gh-readonly-queue/<base>/pr-<N>-<sha>.
63+
const m = /^gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]{40}$/.exec(run.head_branch ?? '');
64+
if (!m) {
65+
core.info(`head_branch '${run.head_branch}' is not a merge-queue branch — nothing to do.`);
66+
return;
67+
}
68+
const prNumber = Number(m[1]);
69+
const marker = `<!-- merge-queue-triage:${run.id} -->`;
70+
71+
// Idempotency: workflow_run deliveries can repeat; one comment per run.
72+
const existing = await github.rest.issues.listComments({
73+
owner, repo, issue_number: prNumber, per_page: 100,
74+
});
75+
if (existing.data.some((c) => (c.body ?? '').includes(marker))) {
76+
core.info('triage comment for this run already exists — skipping.');
77+
return;
78+
}
79+
80+
// Failed jobs and their failed steps.
81+
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
82+
owner, repo, run_id: run.id, per_page: 100,
83+
});
84+
const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
85+
86+
// Best-effort log harvest: the lines a human would grep for first.
87+
// Aggregate gate jobs (Test Core / Dogfood Regression Gate) fail
88+
// with no information of their own, so prefer real jobs when both
89+
// are present. A 4xx on the logs endpoint degrades to names only.
90+
const FAIL_LINE = /(?:^|[\s|])(?:✗|×|✕|FAIL\s|AssertionError|STALL)/;
91+
const informative = failedJobs.filter((j) => (j.steps ?? []).some(
92+
(s) => s.conclusion === 'failure' && !/^Verify .* results$/.test(s.name)));
93+
const details = [];
94+
for (const job of (informative.length ? informative : failedJobs).slice(0, 4)) {
95+
const steps = (job.steps ?? [])
96+
.filter((s) => s.conclusion === 'failure')
97+
.map((s) => s.name);
98+
let lines = [];
99+
try {
100+
const res = await github.request(
101+
'GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs',
102+
{ owner, repo, job_id: job.id });
103+
const text = typeof res.data === 'string'
104+
? res.data
105+
: Buffer.from(res.data).toString('utf8');
106+
lines = text.split('\n')
107+
.map((l) => l.replace(/^[^ ]*Z /, ''))
108+
.filter((l) => FAIL_LINE.test(l))
109+
.map((l) => l.trim().slice(0, 200))
110+
.slice(0, 12);
111+
} catch (e) {
112+
core.info(`logs unavailable for job ${job.id}: ${e.message}`);
113+
}
114+
details.push({ name: job.name, url: job.html_url, steps, lines });
115+
}
116+
117+
// History: this PR's earlier queue failures + queue-wide count, 24 h.
118+
const since = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
119+
const recent = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
120+
owner, repo, event: 'merge_group', created: `>=${since}`, per_page: 100,
121+
});
122+
const ciRuns = recent.filter((r) => r.workflow_id === run.workflow_id && r.id !== run.id);
123+
const priorFailuresThisPr = ciRuns.filter((r) =>
124+
r.conclusion === 'failure' && (r.head_branch ?? '').includes(`/pr-${prNumber}-`)).length;
125+
const queueFailures24h = ciRuns.filter((r) => r.conclusion === 'failure').length;
126+
127+
const jobSections = details.map((d) => {
128+
const head = `- **[${d.name}](${d.url})** — 失败步骤: ${d.steps.join('、') || '(无步骤级结论)'}`;
129+
return d.lines.length
130+
? `${head}\n\n \`\`\`\n ${d.lines.join('\n ')}\n \`\`\``
131+
: `${head}(日志不可读,点进 job 看)`;
132+
}).join('\n');
133+
134+
const flakeHint = priorFailuresThisPr > 0
135+
? `⚠️ **本 PR 过去 24h 已在队列失败 ${priorFailuresThisPr} 次(不含本次)。** 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。`
136+
: '本 PR 过去 24h 无队列失败记录(首次)。';
137+
138+
const body = [
139+
'### ⛔ merge queue 构建失败 — 先分诊,再决定要不要重排',
140+
'',
141+
`队列构建 [${run.id}](${run.html_url}) 红了。队列跑的是**全量**套件(PR 侧 CI 只跑 affected 子集),`,
142+
'所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。',
143+
'',
144+
'**失败的 job(日志抽取,best effort):**',
145+
'',
146+
jobSections || '- (没拿到 job 级信息,点上面的 run 链接看)',
147+
'',
148+
'**历史信号:**',
149+
`- ${flakeHint}`,
150+
`- 过去 24h 队列共有 ${queueFailures24h} 个失败构建(不含本次)。`,
151+
'',
152+
'**分诊清单:**',
153+
'1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。',
154+
'2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。',
155+
'3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。',
156+
'',
157+
marker,
158+
'---',
159+
'_Generated by [Claude Code](https://claude.ai/code) · merge-queue-triage workflow (#4859)_',
160+
].join('\n');
161+
162+
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
163+
core.info(`triage comment posted on #${prNumber}.`);

0 commit comments

Comments
 (0)