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
311from __future__ import annotations
412
513import argparse
14+ import json
615import subprocess
16+ import sys
17+ from dataclasses import dataclass
18+ from datetime import datetime , timedelta , timezone
719from 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
1123LOOKBACK_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
2079def 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+
36129def 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