Skip to content

Commit d416a9c

Browse files
ci: fit weekly test-durations refresh inside the 6h limit and alert on failure (#13586)
* ci: fit weekly test-durations refresh inside the 6h limit and alert on failure The serial full-suite run no longer fits the 6h job limit; every weekly run since mid-2025 was cancelled at exactly 6h, so .test_durations silently froze and the 5 CI test groups drifted out of balance (the recurring "Group 3 times out at 99%" nightly failures). - Measure durations as a 5-group matrix (same pytest-split groups, same xdist and test selection as make unit_tests), ~45-60 min per group instead of >6h serial. Each group stores only its own tests' durations (clean-durations); a merge job unions the disjoint group files and sanity-checks coverage before committing anything. - Add job-level timeouts everywhere and a Slack alert job (LANGFLOW_ENG_SLACK_WEBHOOK_URL, same channel as nightly_build) so a silent freeze cannot recur unnoticed. - Label the auto-PR skip-nightly-check: the required CI Success check previously failed on these PRs whenever the nightly was red, which is why none of them (#6225..#8669) ever merged. - Dispatch ci.yml on the PR branch after creation: PRs created with the default GITHUB_TOKEN never trigger pull_request workflows, so the required checks otherwise never run. workflow_dispatch is exempt from that restriction and from the nightly gate. Optionally supports a DURATIONS_PR_TOKEN PAT for fully native triggering. - Request review/assign so the PR gets attention instead of rotting. - Commit only .test_durations (add-paths) and drop the unused ASTRA/OPENAI api_key_required test env (CI never runs those tests). * ci: paginate the stale-durations-PR close step pulls.list returns one page (30) and the repo has hundreds of open PRs, so months-old stale durations PRs never appeared in the results — that is how #6225..#8669 accumulated unclosed even while the workflow still ran. Paginate and additionally match on the update-test-durations head branch prefix. * chore: update test durations (#13587) Co-authored-by: erichare <700235+erichare@users.noreply.github.com> * ci: fail the Slack alert step on webhook HTTP errors Without --fail-with-body, curl exits 0 on a Slack 4xx/5xx (e.g. a rotated webhook), leaving the alert job green while no alert was sent. * ci: address durations workflow review questions --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: erichare <700235+erichare@users.noreply.github.com>
1 parent 3a8f48b commit d416a9c

2 files changed

Lines changed: 10326 additions & 10844 deletions

File tree

.github/workflows/store_pytest_durations.yml

Lines changed: 197 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,32 @@ on:
66
# Run job at 6:30 UTC every Monday (10.30pm PST/11.30pm PDT Sunday night)
77
- cron: "30 6 * * 1"
88

9-
env:
10-
PYTEST_RUN_PATH: "src/backend/tests"
9+
concurrency:
10+
group: ${{ github.workflow }}
11+
cancel-in-progress: true
1112

1213
jobs:
13-
build:
14-
name: Run pytest and store durations
14+
# The full unit suite no longer fits in the 6h job limit when run serially
15+
# in a single process (the runs were cancelled at exactly 6h for months,
16+
# silently freezing .test_durations). Instead we measure in the same shape
17+
# CI consumes the file: 5 pytest-split groups, each with `-n auto`, running
18+
# the same selection as `make unit_tests` (no api_key_required, no template
19+
# dir). Each group stores ONLY its own tests' durations (--clean-durations)
20+
# and the merge job unions the 5 disjoint group files into a complete,
21+
# freshly-measured durations file.
22+
measure:
23+
name: Measure durations (group ${{ matrix.group }})
1524
runs-on: ubuntu-latest
16-
permissions:
17-
contents: write
18-
pull-requests: write
25+
# The 5 matrix groups run in parallel; this keeps failures visible before
26+
# the old single-job 6h cancellation cliff.
27+
timeout-minutes: 150
28+
strategy:
29+
fail-fast: false
30+
matrix:
31+
group: [1, 2, 3, 4, 5]
1932
env:
2033
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
21-
ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }}
22-
ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }}
34+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
2335
steps:
2436
- uses: actions/checkout@v6
2537
- name: "Setup Environment"
@@ -31,25 +43,124 @@ jobs:
3143
prune-cache: false
3244
- name: Install the project
3345
run: uv sync
34-
- name: Run unit tests
35-
id: run_tests
36-
continue-on-error: true
37-
run: uv run pytest src/backend/tests/unit --timeout=150 --durations-path src/backend/tests/.test_durations --splitting-algorithm least_duration --store-durations
46+
- name: Run unit tests and store this group's durations
47+
run: |
48+
# The durations file is both read (to compute the same 5-way split
49+
# CI uses) and written (--store-durations), so run against a copy.
50+
# --clean-durations makes the output contain ONLY the tests that ran
51+
# in this group, which lets the merge job take a plain union.
52+
# No --reruns here: pytest-split sums every TestReport per test, so
53+
# rerun-on-failure would inflate the stored durations.
54+
cp src/backend/tests/.test_durations /tmp/group_durations.json
55+
set +e
56+
uv run pytest src/backend/tests/unit \
57+
--ignore=src/backend/tests/integration \
58+
--ignore=src/backend/tests/unit/template \
59+
-n auto -q -ra -m 'not api_key_required' \
60+
--durations-path /tmp/group_durations.json \
61+
--splitting-algorithm least_duration \
62+
--splits 5 --group ${{ matrix.group }} \
63+
--store-durations --clean-durations
64+
code=$?
65+
# Exit code 1 means some tests failed; their durations are still
66+
# measured and valid. Anything else (collection error, internal
67+
# error, usage error) means the measurement is incomplete: fail.
68+
if [ "$code" -ne 0 ] && [ "$code" -ne 1 ]; then
69+
echo "pytest exited with fatal code $code"
70+
exit "$code"
71+
fi
72+
- name: Upload group durations
73+
uses: actions/upload-artifact@v7
74+
with:
75+
name: durations-group-${{ matrix.group }}
76+
path: /tmp/group_durations.json
77+
retention-days: 7
3878

79+
merge-and-pr:
80+
name: Merge durations and open PR
81+
needs: measure
82+
runs-on: ubuntu-latest
83+
timeout-minutes: 20
84+
permissions:
85+
contents: write
86+
pull-requests: write
87+
actions: write
88+
env:
89+
# Optional fine-grained PAT (contents: write, pull-requests: write).
90+
# PRs created with the default GITHUB_TOKEN do not trigger pull_request
91+
# or pull_request_target workflows, so the required "CI Success" and
92+
# "Validate PR" checks never run on them. With a PAT both fire normally.
93+
HAS_PR_TOKEN: ${{ secrets.DURATIONS_PR_TOKEN != '' }}
94+
steps:
95+
- uses: actions/checkout@v6
96+
- name: Download group durations
97+
uses: actions/download-artifact@v8
98+
with:
99+
pattern: durations-group-*
100+
path: /tmp/durations
101+
- name: Merge group durations
102+
run: |
103+
python3 - <<'EOF'
104+
import json
105+
import pathlib
106+
import sys
107+
108+
baseline_path = pathlib.Path("src/backend/tests/.test_durations")
109+
baseline = json.loads(baseline_path.read_text())
110+
111+
merged = {}
112+
for group in range(1, 6):
113+
path = pathlib.Path(f"/tmp/durations/durations-group-{group}/group_durations.json")
114+
data = json.loads(path.read_text())
115+
if len(data) < 100:
116+
sys.exit(f"Group {group} stored only {len(data)} durations; "
117+
"the run was incomplete. Refusing to update.")
118+
overlap = merged.keys() & data.keys()
119+
if overlap:
120+
sys.exit(f"Group {group} overlaps previous groups on "
121+
f"{len(overlap)} tests; split was inconsistent.")
122+
print(f"group {group}: {len(data)} tests")
123+
merged.update(data)
124+
125+
if len(merged) < 2000 or len(merged) < 0.8 * len(baseline):
126+
sys.exit(f"Merged durations cover only {len(merged)} tests "
127+
f"(baseline has {len(baseline)}). Refusing to update.")
128+
129+
added = merged.keys() - baseline.keys()
130+
removed = baseline.keys() - merged.keys()
131+
print(f"merged: {len(merged)} tests "
132+
f"(+{len(added)} new, -{len(removed)} deleted vs baseline)")
133+
134+
# Same format pytest-split itself writes.
135+
baseline_path.write_text(json.dumps(merged, sort_keys=True, indent=4))
136+
137+
summary = (f"| Tests measured | {len(merged)} |\n"
138+
f"| New tests | {len(added)} |\n"
139+
f"| Removed tests | {len(removed)} |\n")
140+
pathlib.Path("/tmp/durations_summary.md").write_text(
141+
"| Metric | Value |\n| --- | --- |\n" + summary)
142+
EOF
143+
cat /tmp/durations_summary.md >> "$GITHUB_STEP_SUMMARY"
39144
40145
- name: Close existing PRs
41146
uses: actions/github-script@v8
42147
with:
43148
github-token: ${{ secrets.GITHUB_TOKEN }}
44149
script: |
45-
const { data: pulls } = await github.rest.pulls.list({
150+
// Paginate: the repo has far more open PRs than one page, and a
151+
// months-old stale durations PR never appears in the first 30
152+
// results (which is how #6225..#8669 piled up unclosed).
153+
const pulls = await github.paginate(github.rest.pulls.list, {
46154
owner: context.repo.owner,
47155
repo: context.repo.repo,
48-
state: 'open'
156+
state: 'open',
157+
per_page: 100
49158
});
50159
51160
for (const pull of pulls) {
52-
if (pull.title === "chore: update test durations") {
161+
if (pull.title === "chore: update test durations" &&
162+
pull.head.ref.startsWith("update-test-durations")) {
163+
console.log(`Closing stale durations PR #${pull.number}`);
53164
await github.rest.pulls.update({
54165
owner: context.repo.owner,
55166
repo: context.repo.repo,
@@ -60,17 +171,83 @@ jobs:
60171
}
61172
62173
- name: Create Pull Request
174+
id: cpr
63175
uses: peter-evans/create-pull-request@v8
64176
with:
65-
token: ${{ secrets.GITHUB_TOKEN }}
66-
branch-token: ${{ secrets.GITHUB_TOKEN }}
177+
token: ${{ secrets.DURATIONS_PR_TOKEN || secrets.GITHUB_TOKEN }}
178+
branch-token: ${{ secrets.DURATIONS_PR_TOKEN || secrets.GITHUB_TOKEN }}
179+
add-paths: src/backend/tests/.test_durations
67180
commit-message: "chore: update test durations"
68181
title: "chore: update test durations"
69182
body: |
70-
Automated PR to update test durations file.
183+
Automated weekly refresh of `src/backend/tests/.test_durations`,
184+
used by pytest-split to balance the 5 backend CI test groups.
185+
A stale file causes unbalanced groups and group timeouts
186+
("fails at 99%"), so this PR should be merged promptly.
71187
72-
This PR was automatically created by the store_pytest_durations workflow.
188+
This PR was automatically created by the store_pytest_durations
189+
workflow. Durations were measured per CI group with the same test
190+
selection CI uses (`-n auto`, no `api_key_required`, no
191+
`unit/template`).
192+
193+
If the **Validate PR** check is missing, edit the PR title (e.g.
194+
re-save it unchanged) or push to the branch to trigger it — PRs
195+
created with the default `GITHUB_TOKEN` don't fire
196+
`pull_request_target` workflows.
73197
branch: update-test-durations
74198
branch-suffix: timestamp
75199
delete-branch: true
76200
maintainer-can-modify: true
201+
labels: |
202+
skip-nightly-check
203+
reviewers: |
204+
erichare
205+
assignees: |
206+
erichare
207+
Adam-Aghili
208+
209+
- name: Trigger CI on the PR branch
210+
# PRs created with the default GITHUB_TOKEN don't trigger pull_request
211+
# workflows, so the required "CI Success" check would never appear.
212+
# workflow_dispatch is exempt from that restriction (and from the
213+
# nightly-status gate), and its check runs land on the same head SHA,
214+
# which satisfies the required check. Skip when a PAT created the PR,
215+
# because then the pull_request event already fired.
216+
if: steps.cpr.outputs.pull-request-number != '' && env.HAS_PR_TOKEN != 'true'
217+
env:
218+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
219+
run: |
220+
branch=$(gh pr view "${{ steps.cpr.outputs.pull-request-number }}" \
221+
--repo "${{ github.repository }}" --json headRefName -q .headRefName)
222+
gh workflow run ci.yml --repo "${{ github.repository }}" --ref "$branch"
223+
echo "Dispatched ci.yml on $branch"
224+
225+
alert-on-failure:
226+
name: Alert on failure
227+
needs: [measure, merge-and-pr]
228+
if: always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
229+
runs-on: ubuntu-latest
230+
timeout-minutes: 5
231+
steps:
232+
- name: Send failure notification to Slack
233+
env:
234+
WEBHOOK_URL: ${{ secrets.LANGFLOW_ENG_SLACK_WEBHOOK_URL }}
235+
run: |
236+
if [ -z "$WEBHOOK_URL" ]; then
237+
echo "LANGFLOW_ENG_SLACK_WEBHOOK_URL not set; skipping Slack alert"
238+
exit 0
239+
fi
240+
# --fail-with-body: a Slack 4xx (e.g. rotated/revoked webhook) must
241+
# fail this job, or a broken alert path stays green unnoticed.
242+
curl --fail-with-body -X POST -H 'Content-type: application/json' \
243+
--data "{
244+
\"blocks\": [
245+
{
246+
\"type\": \"section\",
247+
\"text\": {
248+
\"type\": \"mrkdwn\",
249+
\"text\": \":warning: *Store pytest durations failed.* The test-duration file used to balance backend CI groups was NOT refreshed this week. Stale durations cause unbalanced CI groups and group timeouts.\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>\"
250+
}
251+
}
252+
]
253+
}" "$WEBHOOK_URL"

0 commit comments

Comments
 (0)