Skip to content

Commit d309e83

Browse files
committed
[autorevert] finish signal implementation
1 parent 294d382 commit d309e83

2 files changed

Lines changed: 228 additions & 139 deletions

File tree

Lines changed: 191 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from dataclasses import dataclass
22
from datetime import datetime
33
from enum import Enum
4-
from typing import List, Optional, Set, Union
4+
from typing import List, Optional, Set, Tuple, Union
55

66

77
class SignalStatus(Enum):
@@ -78,23 +78,22 @@ def __init__(self, head_sha: str, events: List[SignalEvent]):
7878
self.head_sha = head_sha
7979
# enforce events ordered by time, oldest first
8080
self.events = sorted(events, key=lambda e: e.started_at) if events else []
81-
self.statuses = {event.status for event in self.events}
82-
83-
def has_status(self, status: SignalStatus) -> bool:
84-
"""Check if any event has the specified status."""
85-
return status in self.statuses
81+
# counts by status
82+
self.statuses = {}
83+
for e in self.events:
84+
self.statuses[e.status] = self.statuses.get(e.status, 0) + 1
8685

8786
@property
8887
def has_pending(self) -> bool:
89-
return self.has_status(SignalStatus.PENDING)
88+
return SignalStatus.PENDING in self.statuses
9089

9190
@property
9291
def has_success(self) -> bool:
93-
return self.has_status(SignalStatus.SUCCESS)
92+
return SignalStatus.SUCCESS in self.statuses
9493

9594
@property
9695
def has_failure(self) -> bool:
97-
return self.has_status(SignalStatus.FAILURE)
96+
return SignalStatus.FAILURE in self.statuses
9897

9998
def events_by_status(self, status: SignalStatus) -> List[SignalEvent]:
10099
"""Get all events with the specified status."""
@@ -105,6 +104,38 @@ def __iter__(self):
105104
return iter(self.events)
106105

107106

107+
@dataclass
108+
class PartitionedCommits:
109+
"""
110+
Represents the result of partitioning commits based on an autorevert pattern.
111+
"""
112+
113+
def __init__(
114+
self,
115+
failed: List[SignalCommit],
116+
unknown: List[SignalCommit],
117+
successful: List[SignalCommit],
118+
):
119+
self.failed = failed
120+
self.unknown = unknown
121+
self.successful = successful
122+
123+
def failure_events_count(self) -> int:
124+
return sum(c.statuses.get(SignalStatus.FAILURE, 0) for c in self.failed)
125+
126+
def success_events_count(self) -> int:
127+
return sum(c.statuses.get(SignalStatus.SUCCESS, 0) for c in self.successful)
128+
129+
130+
class InfraCheckResult(Enum):
131+
"""Outcome of infra check based on partitioned commits."""
132+
133+
CONFIRMED = "confirmed" # failure bracketed by two successes (not infra)
134+
PENDING = "pending" # pending events could still form the sandwich
135+
RESTART_SUCCESS = "restart_success" # no success after any failure
136+
RESTART_FAILURE = "restart_failure" # no failure after any success
137+
138+
108139
class Signal:
109140
"""A refined, column-like view of raw CI data for pattern detection.
110141
@@ -141,127 +172,129 @@ def detect_flaky(self) -> bool:
141172
for commit in self.commits
142173
)
143174

144-
def confirm_not_an_infra_issue(self) -> Optional[bool]:
145-
"""
146-
Considers pairs of commits: an older one with two successful jobs,
147-
and a newer one (not necessarily an immediate successor) with a
148-
failure.
149-
Checks if there is a "sandwich" pattern where:
150-
- The failure of the newer commit is between two successes of the older commit (time-wise).
151-
152-
The goal of this it to eliminate the possibility of transient infra issue.
153-
154-
Note: in the real world this relies on the previously checked invariants:
155-
* no flakiness - older commit will not have failures if it has successful job
156-
* not recovered - there is a newer commit with failure that is
157-
followed by an older commit with at least one success
158-
159-
Returns:
160-
True if such a pattern exists, meaning the failure is likely
161-
not an infra issue (previously successful signal stays stable),
162-
False means no bracketing successes were observed; we can’t
163-
rule out infra, so prefer restarts (i.e. "not enough data",
164-
given "no flakiness" invariant is true).
165-
None is "Maybe", meaning the result depends on the resolution
166-
of the existing pending job.
167-
"""
168-
if len(self.commits) < 2:
169-
return False
170-
171-
maybe = False
172-
173-
# Iterate through commits, looking for a newer commit with failure
174-
for i in range(0, len(self.commits) - 1):
175-
nc = self.commits[i]
176-
177-
# Check all older commits before this one
178-
for j in range(i + 1, len(self.commits)):
179-
oc = self.commits[j]
180-
# Check if this older commit has two successful jobs
181-
oc_successes = oc.events_by_status(SignalStatus.SUCCESS)
182-
oc_pending = oc.events_by_status(SignalStatus.PENDING)
183-
184-
if len(oc_successes) >= 2:
185-
# Check if the failure of the newer commit is between the two successes of the older commit
186-
# Events are ordered by time within each commit, oldest events first
187-
for e in nc.events:
188-
if not ( # between failures
189-
oc_successes[0].started_at
190-
< e.started_at
191-
< oc_successes[-1].started_at
192-
):
193-
continue
194-
195-
if e.is_failure:
196-
# We have a sandwich pattern
197-
return True
198-
elif e.is_pending:
199-
# We have a pending job, possible pattern, cannot confirm yet
200-
maybe = True
201-
202-
elif (
203-
len(oc_successes) == 1
204-
and len(oc_pending) > 1
205-
and oc_successes[0].started_at < oc_pending[-1].started_at
206-
# If there is only one success and multiple pending jobs, we cannot confirm the sandwich
207-
and any(
208-
oc_successes[0].started_at
209-
< e.started_at
210-
< oc_pending[-1].started_at
211-
and (e.is_failure or e.is_pending)
212-
for e in nc.events
213-
)
214-
):
215-
maybe = True
216-
217-
return None if maybe else False
218-
219175
def has_successes(self) -> bool:
220176
"""
221177
Checks if there is at least one successful event in the signal.
222178
"""
223179
return any(commit.has_success for commit in self.commits)
224180

225-
def detect_autorevert_pattern(self) -> Optional[AutorevertPattern]:
181+
def partition_by_autorevert_pattern(self) -> Optional[PartitionedCommits]:
182+
"""
183+
Partition the most recent commit history into three lists:
184+
- Failed commits before the first potential breakage
185+
- Pending / missing signal commits in the middle (if any)
186+
- Successful commits after the breakage (up to the next breakage, if any)
187+
188+
Preserves the original order of commits (newest to oldest).
189+
190+
The useful invariant this establishes:
191+
- pending commits in the "failed" list are expected to resolve to failure
192+
- pending commits in the "successful" list are expected to resolve to success
193+
- pending commits in the "unknown" list could resolve either way
194+
- commits with the missing signal (that we need to trigger) would fall into the "unknown" list
226195
"""
227-
Detect first autorevert pattern in the Signal.
196+
if len(self.commits) < 2:
197+
return None
228198

229-
Pattern: 3 consecutive commits where:
230-
- 2 newer commits have failure
231-
- 1 older commit doesn't have this failure
199+
failed = []
200+
successful = []
232201

233-
Note:
234-
in real world relies on the previously checked invariants, such as:
235-
no flakiness, no infra issues, etc.
202+
picking_failed = True # simple state machine
236203

237-
Returns:
238-
First detected autorevert pattern if exists, None otherwise.
239-
"""
240-
# Commits are ordered newest -> older
241-
if len(self.commits) < 3:
204+
# first broadly partition into failed and successful
205+
for c in self.commits:
206+
if c.has_success:
207+
picking_failed = False
208+
elif c.has_failure and not picking_failed:
209+
# encountered a failure after the streak of successes
210+
# this indicates another older pattern which we don't care about
211+
break
212+
213+
if picking_failed:
214+
failed.append(c)
215+
else:
216+
successful.append(c)
217+
218+
# further partition failed into failed and unknown (pending/missing)
219+
unknown = []
220+
while failed and not failed[-1].has_failure:
221+
unknown.append(failed.pop())
222+
223+
unknown.reverse()
224+
225+
if not failed or not successful:
242226
return None
243227

244-
for i in range(1, len(self.commits) - 1):
245-
suspected_commit1 = self.commits[i]
246-
newer_commit = self.commits[i - 1]
247-
successful_base_commit = self.commits[i + 1]
248-
249-
if (
250-
newer_commit.has_failure
251-
and suspected_commit1.has_failure
252-
and successful_base_commit.has_success
253-
):
254-
return AutorevertPattern(
255-
pattern_detected=True,
256-
workflow_name=self.workflow_name,
257-
newer_commits=[
258-
newer_commit.head_sha,
259-
suspected_commit1.head_sha,
260-
],
261-
older_commit=successful_base_commit.head_sha,
262-
)
263-
264-
return None
228+
return PartitionedCommits(failed=failed, unknown=unknown, successful=successful)
229+
230+
def confirm_not_an_infra_issue(
231+
self, partition: PartitionedCommits
232+
) -> InfraCheckResult:
233+
"""
234+
Checks if there is a "sandwich" pattern where:
235+
- The failure of the newer commit is between (time-wise) two successes from the older commits.
236+
237+
The goal of this it to rule out the transient infra / outside issue
238+
(i.e. issue not caused by the change in the newer commit).
239+
240+
Invariants established:
241+
- CONFIRMED: at least one failure (newer commit) timestamp lies strictly between two
242+
actual success timestamps (older commits).
243+
- PENDING: success-like and failure-like time ranges overlap, but no
244+
confirmed sandwich yet; pending events could complete the sandwich.
245+
- RESTART_SUCCESS: no success-like event occurs after any failure-like
246+
event (ranges do not overlap in that direction).
247+
- RESTART_FAILURE: no failure-like event occurs after any success-like
248+
event (ranges do not overlap in that direction).
249+
250+
Notes:
251+
- success-like = SUCCESS or PENDING; failure-like = FAILURE or PENDING.
252+
- Only commits from the failed/successful partitions are considered;
253+
unknown is ignored here.
254+
- Flakiness is assumed to be ruled out upstream.
255+
"""
256+
257+
def bounds(
258+
commits: List[SignalCommit], keep
259+
) -> Tuple[Optional[datetime], Optional[datetime]]:
260+
lo: Optional[datetime] = None
261+
hi: Optional[datetime] = None
262+
for c in commits:
263+
for e in c.events:
264+
if keep(e):
265+
t = e.started_at
266+
if lo is None or t < lo:
267+
lo = t
268+
if hi is None or t > hi:
269+
hi = t
270+
return lo, hi
271+
272+
# success-like includes pending; actual-success excludes pending
273+
min_succ_like, max_succ_like = bounds(
274+
partition.successful, lambda e: e.is_success or e.is_pending
275+
)
276+
min_succ, max_succ = bounds(partition.successful, lambda e: e.is_success)
277+
# failure-like includes pending
278+
min_fail_like, max_fail_like = bounds(
279+
partition.failed, lambda e: e.is_failure or e.is_pending
280+
)
281+
282+
# Strict ordering without overlap → restart
283+
if min_succ_like is None or max_succ_like <= min_fail_like:
284+
return InfraCheckResult.RESTART_SUCCESS
285+
if min_fail_like is None or max_fail_like <= min_succ_like:
286+
return InfraCheckResult.RESTART_FAILURE
287+
288+
# Confirmed: any actual failure of the newer commit falls strictly
289+
# between two actual successes of the older commit
290+
if min_succ is not None and max_succ is not None and min_succ < max_succ:
291+
for c in partition.failed:
292+
for e in c.events:
293+
if e.is_failure and (min_succ < e.started_at < max_succ):
294+
return InfraCheckResult.CONFIRMED
295+
296+
# Overlap exists, but not confirmed yet → pending
297+
return InfraCheckResult.PENDING
265298

266299
def process_valid_autorevert_pattern(
267300
self,
@@ -278,16 +311,47 @@ def process_valid_autorevert_pattern(
278311
if self.detect_flaky() or self.detect_fixed() or not self.has_successes():
279312
return None
280313

281-
infra_failure = self.confirm_not_an_infra_issue()
282-
if infra_failure is None:
283-
# If we have pending jobs, we cannot confirm the pattern yet
314+
partition = self.partition_by_autorevert_pattern()
315+
if partition is None:
284316
return None
285317

286-
if not infra_failure:
287-
# not enough data to confirm the pattern, need to issue restarts
288-
# TODO find a commit to restart
289-
pass
318+
restart_commits = set()
319+
320+
# close gaps in the signal (greedily for now)
321+
for c in partition.unknown:
322+
if not c.events:
323+
restart_commits.add(c.head_sha)
324+
325+
infra_check_result = self.confirm_not_an_infra_issue(partition)
326+
# note re: event_count < 2:
327+
# this is a confidence heuristic to detect flakiness, can adjust as needed
328+
if (
329+
infra_check_result == InfraCheckResult.RESTART_FAILURE
330+
or partition.failure_events_count() < 2
331+
):
332+
# restarting oldest failed
333+
restart_commits.add(partition.failed[-1].head_sha)
334+
elif (
335+
infra_check_result == InfraCheckResult.RESTART_SUCCESS
336+
or partition.success_events_count() < 2
337+
):
338+
# restarting newest successful
339+
restart_commits.add(partition.successful[0].head_sha)
340+
341+
if restart_commits:
342+
return RestartCommits(commit_shas=restart_commits)
343+
344+
if infra_check_result != InfraCheckResult.CONFIRMED:
345+
return None
290346

291-
# TODO close the gaps in the signal (find commits to restart)
347+
if partition.unknown:
348+
# there are still pending/missing commits in the unknown partition
349+
return None
292350

293-
return self.detect_autorevert_pattern()
351+
# all invariants validated, confirmed not infra, pattern exists
352+
return AutorevertPattern(
353+
pattern_detected=True,
354+
workflow_name=self.workflow_name,
355+
newer_commits=[c.head_sha for c in partition.failed[-2:]],
356+
older_commit=partition.successful[0].head_sha,
357+
)

0 commit comments

Comments
 (0)