Skip to content

Commit b395914

Browse files
authored
Improve SHA tracking (#152)
1 parent a1a98d8 commit b395914

4 files changed

Lines changed: 457 additions & 74 deletions

File tree

.github/workflows/run_asvs_2026_01_04.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ jobs:
3030
shell: bash -e {0}
3131
permissions:
3232
contents: write
33+
# claim reports dead runs (expired leases) on a tracking issue.
34+
issues: write
3335
steps:
3436
# In order to run pandas' actions, we have to checkout into the root directory.
3537
- name: Checkout pandas
@@ -54,6 +56,8 @@ jobs:
5456

5557
- name: Claim next SHA
5658
id: claim
59+
env:
60+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5761
run: |
5862
PYTHONPATH=asv-runner-code python -m asv_runner claim \
5963
--storage-dir=asv-runner \

asv_runner/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""CLI entry point for asv_runner.
22
33
Subcommands invoked from .github/workflows/*.yaml:
4-
claim - pick next pandas SHA, append to shas.txt, push storage branch
4+
claim - lease next pandas SHA (new or stale retry), update shas.txt, push
55
benchmark - run asv machine + asv run for a target SHA
66
push - stage asv outputs (zstd-compress per-sha files) and push to storage
77
process - decompress, asv publish, build parquet, raise issues, push

asv_runner/claim.py

Lines changed: 160 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,79 @@
1-
"""Claim the next pandas SHA, append to shas.txt, push storage branch."""
1+
"""Lease the next pandas SHA to benchmark, update shas.txt, push storage branch.
2+
3+
Each line of shas.txt is a lease: ``<sha> <claimed_at> <attempts> [abandoned]``.
4+
A SHA is complete when its results file exists on the storage branch; a lease
5+
with no results whose age exceeds CLAIM_TTL belonged to a run that died
6+
(timeout, build failure, runner loss) and is retried up to MAX_ATTEMPTS times.
7+
Legacy bare-sha lines parse as expired leases with no attempts on record.
8+
Detected failures are reported as comments on a single tracking issue.
9+
"""
210

311
from __future__ import annotations
412

513
import argparse
14+
import json
615
import subprocess
16+
import sys
17+
from dataclasses import dataclass
18+
from datetime import datetime, timedelta, timezone
719
from pathlib import Path
820

9-
from asv_runner.util import orphan_push_with_retry, write_github_output
21+
from asv_runner.util import execute, orphan_push_with_retry, write_github_output
1022

1123
LOOKBACK_COMMITS = 40
24+
# GitHub kills a job after 6 hours, so a lease older than this with no
25+
# results is guaranteed to belong to a dead run.
26+
CLAIM_TTL = timedelta(hours=24)
27+
MAX_ATTEMPTS = 3
28+
FAILURE_ISSUE_TITLE = "Benchmark run failures"
29+
EPOCH = datetime.fromtimestamp(0, tz=timezone.utc)
30+
31+
32+
@dataclass
33+
class Claim:
34+
sha: str
35+
claimed_at: datetime
36+
attempts: int
37+
abandoned: bool = False
38+
39+
@classmethod
40+
def from_line(cls, line: str) -> Claim:
41+
parts = line.split()
42+
if len(parts) == 1:
43+
# Legacy bare-sha line: an expired lease with no attempts on
44+
# record. If its results exist it is complete; otherwise it is
45+
# eligible for retry.
46+
return cls(sha=parts[0], claimed_at=EPOCH, attempts=0)
47+
return cls(
48+
sha=parts[0],
49+
claimed_at=datetime.fromisoformat(parts[1]),
50+
attempts=int(parts[2]),
51+
abandoned=len(parts) > 3 and parts[3] == "abandoned",
52+
)
53+
54+
def to_line(self) -> str:
55+
line = f"{self.sha} {self.claimed_at.isoformat()} {self.attempts}"
56+
if self.abandoned:
57+
line += " abandoned"
58+
return line
1259

1360

14-
def read_existing_shas(shas_path: Path) -> set[str]:
61+
def read_claims(shas_path: Path) -> list[Claim]:
1562
if not shas_path.exists():
16-
return set()
17-
return {line.strip() for line in shas_path.read_text().splitlines()}
63+
return []
64+
return [
65+
Claim.from_line(line)
66+
for line in shas_path.read_text().splitlines()
67+
if line.strip()
68+
]
69+
70+
71+
def write_claims(shas_path: Path, claims: list[Claim]) -> None:
72+
shas_path.write_text("".join(f"{claim.to_line()}\n" for claim in claims))
73+
74+
75+
def has_results(storage: Path, sha: str) -> bool:
76+
return (storage / "data" / "results" / "asvrunner" / f"{sha}.json.zst").exists()
1877

1978

2079
def pick_next_sha(repo: Path, existing_shas: set[str]) -> str | None:
@@ -33,34 +92,113 @@ def pick_next_sha(repo: Path, existing_shas: set[str]) -> str | None:
3392
return None
3493

3594

95+
def find_failure_issue() -> str | None:
96+
result = execute(
97+
"gh issue list"
98+
" --repo pandas-dev/asv-runner"
99+
" --state open"
100+
" --limit 1000"
101+
" --json number,title"
102+
)
103+
for issue in json.loads(result):
104+
if issue["title"] == FAILURE_ISSUE_TITLE:
105+
return str(issue["number"])
106+
return None
107+
108+
109+
def notify_failures(events: list[str]) -> None:
110+
"""Report detected run failures on a single tracking issue."""
111+
body = "\n".join(f"- {event}" for event in events)
112+
issue_number = find_failure_issue()
113+
if issue_number is None:
114+
cmd = (
115+
"gh issue create"
116+
" --repo pandas-dev/asv-runner"
117+
f' --title "{FAILURE_ISSUE_TITLE}"'
118+
" --body-file -"
119+
)
120+
else:
121+
cmd = (
122+
f"gh issue comment {issue_number}"
123+
" --repo pandas-dev/asv-runner"
124+
" --body-file -"
125+
)
126+
execute(cmd, input=body)
127+
128+
36129
def run(args: argparse.Namespace) -> None:
37130
storage = Path(args.storage_dir)
38131
repo = Path(args.repo_dir)
39132
shas_path = storage / "data" / "shas.txt"
40133

41-
last_picked: list[str | None] = [None]
42-
43-
# Append the next unclaimed SHA to shas.txt; the mutable cell smuggles
44-
# the picked SHA back out so the caller can report it once the push lands.
45-
def modify_tree(_: Path) -> bool:
46-
existing = read_existing_shas(shas_path)
47-
sha = pick_next_sha(repo, existing_shas=existing)
48-
if sha is None:
49-
last_picked[0] = None
50-
return False
51-
with shas_path.open("a") as f:
52-
f.write(f"{sha}\n")
53-
last_picked[0] = sha
54-
return True
134+
# Mutable cells smuggle results out of modify_tree so the caller can
135+
# report them once the push lands. Rebuilt on every call because each
136+
# push attempt refetches the branch.
137+
picked: list[str | None] = [None]
138+
events: list[str] = []
139+
140+
def modify_tree(tree: Path) -> bool:
141+
picked[0] = None
142+
events.clear()
143+
claims = read_claims(shas_path)
144+
now = datetime.now(timezone.utc)
145+
changed = False
146+
147+
sha = pick_next_sha(repo, existing_shas={claim.sha for claim in claims})
148+
if sha is not None:
149+
claims.append(Claim(sha=sha, claimed_at=now, attempts=1))
150+
picked[0] = sha
151+
changed = True
152+
else:
153+
# No fresh commit: spend the idle slot retrying the newest stale
154+
# lease, marking exhausted ones abandoned along the way.
155+
for claim in reversed(claims):
156+
if claim.abandoned or has_results(tree, claim.sha):
157+
continue
158+
if now - claim.claimed_at < CLAIM_TTL:
159+
# A runner may still be working on it.
160+
continue
161+
if claim.attempts >= MAX_ATTEMPTS:
162+
claim.abandoned = True
163+
events.append(
164+
f"Giving up on `{claim.sha}`:"
165+
f" no results after {claim.attempts} attempts."
166+
)
167+
changed = True
168+
continue
169+
claim.attempts += 1
170+
claim.claimed_at = now
171+
if claim.attempts > 1:
172+
# attempts == 1 is a legacy bare line being backfilled,
173+
# not a newly detected death.
174+
ttl_hours = int(CLAIM_TTL.total_seconds() // 3600)
175+
events.append(
176+
f"Run for `{claim.sha}` produced no results within"
177+
f" {ttl_hours} hours; retrying"
178+
f" (attempt {claim.attempts}/{MAX_ATTEMPTS})."
179+
)
180+
picked[0] = claim.sha
181+
changed = True
182+
break
183+
184+
if changed:
185+
write_claims(shas_path, claims)
186+
return changed
55187

56188
pushed = orphan_push_with_retry(
57189
storage,
58190
branch=args.branch,
59191
message="Update shas.txt",
60192
modify_tree=modify_tree,
61193
)
62-
if pushed:
63-
assert last_picked[0] is not None
64-
write_github_output(sha=last_picked[0], new_commit="yes")
194+
if pushed and picked[0] is not None:
195+
write_github_output(sha=picked[0], new_commit="yes")
65196
else:
66197
write_github_output(sha="NONE", new_commit="no")
198+
if pushed and events:
199+
try:
200+
notify_failures(events)
201+
except Exception as err:
202+
# Notification is best-effort; never fail the job (and thereby
203+
# waste the lease we just pushed) over it.
204+
print(f"Failed to post failure notification: {err}", file=sys.stderr)

0 commit comments

Comments
 (0)