Skip to content

Commit 83fa301

Browse files
authored
[autorevert] Add option to simulate run at a specific timestamp (#7609)
Adds an optional param `--as-of <UTC date[time]>` that will set internal clock (NOW) in CH queries (specifically, commits and jobs), allowing to virtually go back in time, and perform restarts / reverts. When set ` --hours N` lookback window becomes relative to the `as-of` timestamp. Testing: ``` 1049 python -m pytorch_auto_revert --dry-run autorevert-checker pull --hours 12 --hud-html 1049 python -m pytorch_auto_revert --dry-run autorevert-checker pull --hours 12 --hud-html --as-of "2025-12-18 15:31" 1052 python -m pytorch_auto_revert --dry-run autorevert-checker pull --hours 12 --hud-html --as-of "2025-12-18 06:13" ```
1 parent 7528834 commit 83fa301

File tree

8 files changed

+185
-14
lines changed

8 files changed

+185
-14
lines changed

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/__main__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from .testers.autorevert_v2 import autorevert_v2
3131
from .testers.hud import render_hud_html_from_clickhouse, write_hud_html_from_cli
3232
from .testers.restart_checker import workflow_restart_checker
33-
from .utils import RestartAction, RetryWithBackoff, RevertAction
33+
from .utils import parse_datetime, RestartAction, RetryWithBackoff, RevertAction
3434

3535

3636
# Special constant to indicate --hud-html was passed as a flag (without a value)
@@ -326,6 +326,16 @@ def get_opts(default_config: DefaultConfig) -> argparse.Namespace:
326326
default=default_config.notify_issue_number,
327327
help="Issue number to notify",
328328
)
329+
workflow_parser.add_argument(
330+
"--as-of",
331+
type=str,
332+
default=None,
333+
help=(
334+
"Run as if current time is this timestamp (UTC). "
335+
"Accepts ISO 8601 or 'YYYY-MM-DD HH:MM[:SS]' format. "
336+
"Useful for testing autorevert logic at a specific point in time."
337+
),
338+
)
329339

330340
# workflow-restart-checker subcommand
331341
workflow_restart_parser = subparsers.add_parser(
@@ -422,6 +432,7 @@ def _get(attr: str, default=None):
422432
restart_action=_get("restart_action", None),
423433
revert_action=_get("revert_action", None),
424434
bisection_limit=_get("bisection_limit", None),
435+
as_of=parse_datetime(_get("as_of")) if _get("as_of") else None,
425436
# Application Settings
426437
log_level=_get("log_level", DEFAULT_LOG_LEVEL),
427438
dry_run=_get("dry_run", False),
@@ -477,6 +488,7 @@ def build_config_from_event(
477488
"restart_action": default_config.restart_action,
478489
"revert_action": default_config.revert_action,
479490
"bisection_limit": default_config.bisection_limit,
491+
"as_of": None, # Not supported in Lambda invocation
480492
# Application Settings
481493
"log_level": default_config.log_level,
482494
# Force subcommand to autorevert-checker for Lambda
@@ -674,6 +686,7 @@ def main_run(
674686
else (config.revert_action or RevertAction.LOG)
675687
),
676688
bisection_limit=config.bisection_limit,
689+
as_of=config.as_of,
677690
)
678691
write_hud_html_from_cli(config.hud_html, HUD_HTML_NO_VALUE_FLAG, state_json)
679692
elif config.subcommand == "workflow-restart-checker":

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
from dataclasses import dataclass, field
9+
from datetime import datetime
910
from typing import Optional
1011

1112
from .utils import RestartAction, RevertAction
@@ -71,6 +72,7 @@ class AutorevertConfig:
7172
restart_action: Optional[RestartAction] = None
7273
revert_action: Optional[RevertAction] = None
7374
bisection_limit: Optional[int] = None
75+
as_of: Optional[datetime] = None
7476

7577
# -------------------------------------------------------------------------
7678
# Application Settings

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/signal_extraction.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@ def __init__(
4141
workflows: Iterable[str],
4242
lookback_hours: int = 24,
4343
repo_full_name: str = "pytorch/pytorch",
44+
as_of: Optional[datetime] = None,
4445
) -> None:
4546
self.workflows = list(workflows)
4647
self.lookback_hours = lookback_hours
4748
self.repo_full_name = repo_full_name
49+
self.as_of = as_of
4850
# Datasource for DB access
4951
self._datasource = SignalExtractionDatasource()
5052

@@ -69,6 +71,7 @@ def extract(self) -> List[Signal]:
6971
commits = self._datasource.fetch_commits_in_time_range(
7072
repo_full_name=self.repo_full_name,
7173
lookback_hours=self.lookback_hours,
74+
as_of=self.as_of,
7275
)
7376

7477
# Fetch jobs for these commits
@@ -77,6 +80,7 @@ def extract(self) -> List[Signal]:
7780
workflows=self.workflows,
7881
lookback_hours=self.lookback_hours,
7982
head_shas=[sha for sha, _ in commits],
83+
as_of=self.as_of,
8084
)
8185

8286
# Select jobs to participate in test-track details fetch

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/signal_extraction_datasource.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ class SignalExtractionDatasource:
2525
"""
2626

2727
def fetch_commits_in_time_range(
28-
self, *, repo_full_name: str, lookback_hours: int
28+
self,
29+
*,
30+
repo_full_name: str,
31+
lookback_hours: int,
32+
as_of: Optional[datetime] = None,
2933
) -> List[tuple[Sha, datetime]]:
3034
"""
3135
Fetch all commits pushed to main within the lookback window.
@@ -35,34 +39,49 @@ def fetch_commits_in_time_range(
3539
which can contain multiple commits in a single push event.
3640
For commits with identical timestamps (from the same ghstack push),
3741
orders by array index descending to match HUD/torchci ordering.
42+
43+
Args:
44+
as_of: If set, use this as the reference time instead of now.
3845
"""
39-
lookback_time = datetime.now() - timedelta(hours=lookback_hours)
46+
reference_time = as_of if as_of else datetime.now()
47+
lookback_time = reference_time - timedelta(hours=lookback_hours)
4048

41-
query = """
49+
# Add upper bound filter only when as_of is specified
50+
upper_bound_clause = ""
51+
if as_of:
52+
upper_bound_clause = "AND head_commit.timestamp <= {as_of:DateTime}"
53+
54+
query = f"""
4255
SELECT
4356
commit.id AS sha,
4457
max(commit.timestamp) AS ts,
4558
arrayMax(groupArray(arrayFirstIndex(c -> c.'id' = commit.id, commits))) as array_index
4659
FROM default.push
4760
ARRAY JOIN commits as commit, commits as c
48-
WHERE head_commit.timestamp >= {lookback_time:DateTime}
61+
WHERE head_commit.timestamp >= {{lookback_time:DateTime}}
62+
{upper_bound_clause}
4963
AND ref = 'refs/heads/main'
50-
AND dynamoKey like {repo:String}
64+
AND dynamoKey like {{repo:String}}
5165
GROUP BY sha
5266
ORDER BY ts DESC, array_index DESC
5367
"""
5468

55-
params = {
69+
params: Dict[str, Any] = {
5670
"lookback_time": lookback_time,
5771
"repo": f"{repo_full_name}%",
5872
}
73+
if as_of:
74+
params["as_of"] = as_of
5975

6076
log = logging.getLogger(__name__)
6177
log.info(
62-
"[extract] Fetching commits in time range: repo=%s lookback=%sh",
78+
"[extract] Fetching commits in time range: repo=%s lookback=%sh as_of=%s",
6379
repo_full_name,
6480
lookback_hours,
81+
as_of.isoformat() if as_of else "none",
6582
)
83+
log.debug("[extract] Query params: %s", params)
84+
log.debug("[extract] Query: %s", query)
6685
t0 = time.perf_counter()
6786
for attempt in RetryWithBackoff():
6887
with attempt:
@@ -79,20 +98,32 @@ def fetch_jobs_for_workflows(
7998
workflows: Iterable[str],
8099
lookback_hours: int,
81100
head_shas: List[Sha],
101+
as_of: Optional[datetime] = None,
82102
) -> List[JobRow]:
83103
"""
84104
Fetch workflow job rows for the given head_shas and workflows.
85105
86106
Returns rows ordered by head_sha (following the order of head_shas), then by started_at ASC.
107+
108+
Args:
109+
as_of: If set, use this as the reference time instead of now.
87110
"""
88-
lookback_time = datetime.now() - timedelta(hours=lookback_hours)
111+
reference_time = as_of if as_of else datetime.now()
112+
lookback_time = reference_time - timedelta(hours=lookback_hours)
113+
114+
# Add upper bound filter only when as_of is specified
115+
upper_bound_clause = ""
116+
if as_of:
117+
upper_bound_clause = "AND wf.created_at <= {as_of:DateTime}"
89118

90119
workflow_filter = ""
91120
params: Dict[str, Any] = {
92121
"lookback_time": lookback_time,
93122
"repo": repo_full_name,
94123
"head_shas": [str(s) for s in head_shas],
95124
}
125+
if as_of:
126+
params["as_of"] = as_of
96127
workflow_list = list(workflows)
97128
if workflow_list:
98129
workflow_filter = "AND wf.workflow_name IN {workflows:Array(String)}"
@@ -126,6 +157,7 @@ def fetch_jobs_for_workflows(
126157
WHERE wf.repository_full_name = {{repo:String}}
127158
AND wf.head_sha IN {{head_shas:Array(String)}}
128159
AND wf.created_at >= {{lookback_time:DateTime}}
160+
{upper_bound_clause}
129161
AND (
130162
wf.name NOT LIKE '%mem_leak_check%'
131163
AND wf.name NOT LIKE '%rerun_disabled_tests%'

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/testers/autorevert_v2.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ def autorevert_v2(
1919
restart_action: RestartAction = RestartAction.RUN,
2020
revert_action: RevertAction = RevertAction.LOG,
2121
bisection_limit: Optional[int] = None,
22+
as_of: Optional[datetime] = None,
2223
) -> Tuple[List[Signal], List[Tuple[Signal, SignalProcOutcome]], str]:
2324
"""Run the Signals-based autorevert flow end-to-end.
2425
@@ -35,19 +36,23 @@ def autorevert_v2(
3536

3637
logging.info(
3738
"[v2] Start: workflows=%s hours=%s repo=%s restart_action=%s"
38-
" revert_action=%s notify_issue_number=%s bisection=%s",
39+
" revert_action=%s notify_issue_number=%s bisection=%s as_of=%s",
3940
",".join(workflows),
4041
hours,
4142
repo_full_name,
4243
restart_action,
4344
revert_action,
4445
notify_issue_number,
4546
("unlimited" if bisection_limit is None else f"limit={bisection_limit}"),
47+
(as_of.isoformat() if as_of else "now"),
4648
)
4749
logging.info("[v2] Run timestamp (CH log ts) = %s", ts.isoformat())
4850

4951
extractor = SignalExtractor(
50-
workflows=workflows, lookback_hours=hours, repo_full_name=repo_full_name
52+
workflows=workflows,
53+
lookback_hours=hours,
54+
repo_full_name=repo_full_name,
55+
as_of=as_of,
5156
)
5257
signals = extractor.extract()
5358
logging.info("[v2] Extracted %d signals", len(signals))
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import sys
2+
import unittest
3+
from datetime import datetime, timezone
4+
5+
6+
# Ensure package import when running from repo root
7+
sys.path.insert(0, "aws/lambda/pytorch-auto-revert")
8+
9+
from pytorch_auto_revert.utils import parse_datetime # noqa: E402
10+
11+
12+
class TestParseDatetime(unittest.TestCase):
13+
def test_iso8601_with_seconds(self):
14+
result = parse_datetime("2025-12-18T15:31:45")
15+
expected = datetime(2025, 12, 18, 15, 31, 45, tzinfo=timezone.utc)
16+
self.assertEqual(result, expected)
17+
18+
def test_iso8601_without_seconds(self):
19+
result = parse_datetime("2025-12-18T15:31")
20+
expected = datetime(2025, 12, 18, 15, 31, 0, tzinfo=timezone.utc)
21+
self.assertEqual(result, expected)
22+
23+
def test_space_separated_with_seconds(self):
24+
result = parse_datetime("2025-12-18 15:31:45")
25+
expected = datetime(2025, 12, 18, 15, 31, 45, tzinfo=timezone.utc)
26+
self.assertEqual(result, expected)
27+
28+
def test_space_separated_without_seconds(self):
29+
result = parse_datetime("2025-12-18 15:31")
30+
expected = datetime(2025, 12, 18, 15, 31, 0, tzinfo=timezone.utc)
31+
self.assertEqual(result, expected)
32+
33+
def test_date_only(self):
34+
result = parse_datetime("2025-12-18")
35+
expected = datetime(2025, 12, 18, 0, 0, 0, tzinfo=timezone.utc)
36+
self.assertEqual(result, expected)
37+
38+
def test_result_is_utc_aware(self):
39+
result = parse_datetime("2025-12-18T15:31")
40+
self.assertIsNotNone(result.tzinfo)
41+
self.assertEqual(result.tzinfo, timezone.utc)
42+
43+
def test_invalid_format_raises_valueerror(self):
44+
with self.assertRaises(ValueError) as ctx:
45+
parse_datetime("not-a-date")
46+
self.assertIn("Cannot parse datetime", str(ctx.exception))
47+
48+
def test_partial_date_raises_valueerror(self):
49+
with self.assertRaises(ValueError):
50+
parse_datetime("2025-12")
51+
52+
def test_empty_string_raises_valueerror(self):
53+
with self.assertRaises(ValueError):
54+
parse_datetime("")
55+
56+
def test_midnight_boundary(self):
57+
result = parse_datetime("2025-01-01T00:00:00")
58+
expected = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
59+
self.assertEqual(result, expected)
60+
61+
def test_end_of_day(self):
62+
result = parse_datetime("2025-12-31T23:59:59")
63+
expected = datetime(2025, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
64+
self.assertEqual(result, expected)
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/tests/test_signal_extraction.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import unittest
22
from datetime import datetime, timedelta
3-
from typing import Iterable, List
3+
from typing import Iterable, List, Optional
44

55
from pytorch_auto_revert.signal import SignalStatus
66
from pytorch_auto_revert.signal_extraction import SignalExtractor
@@ -30,7 +30,11 @@ def __init__(self, jobs: List[JobRow], tests: List[TestRow]):
3030
self._tests = tests
3131

3232
def fetch_commits_in_time_range(
33-
self, *, repo_full_name: str, lookback_hours: int
33+
self,
34+
*,
35+
repo_full_name: str,
36+
lookback_hours: int,
37+
as_of: Optional[datetime] = None,
3438
) -> List[tuple[Sha, datetime]]:
3539
# Extract unique commits from jobs in the order they appear
3640
seen = set()
@@ -48,6 +52,7 @@ def fetch_jobs_for_workflows(
4852
lookback_hours: int,
4953
repo_full_name: str,
5054
head_shas: List[Sha],
55+
as_of: Optional[datetime] = None,
5156
) -> List[JobRow]:
5257
return list(self._jobs)
5358

@@ -394,7 +399,11 @@ def test_commits_without_jobs_are_included(self):
394399

395400
class FakeDatasourceWithExtraCommit(FakeDatasource):
396401
def fetch_commits_in_time_range(
397-
self, *, repo_full_name: str, lookback_hours: int
402+
self,
403+
*,
404+
repo_full_name: str,
405+
lookback_hours: int,
406+
as_of: Optional[datetime] = None,
398407
):
399408
# Return commits C2, C3 (no jobs), C1 in newest->older order
400409
return [

aws/lambda/pytorch-auto-revert/pytorch_auto_revert/utils.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import random
22
import time
33
import urllib.parse
4+
from datetime import datetime, timezone
45
from enum import Enum
56

67
import github
@@ -172,3 +173,40 @@ def proper_workflow_create_dispatch(
172173
if status != 204:
173174
raise ValueError(f"Error dispatching workflow: {status}, {headers}, {body}")
174175
return True
176+
177+
178+
def parse_datetime(s: str) -> datetime:
179+
"""Parse datetime string in various formats (ISO 8601 or common formats).
180+
181+
The input is interpreted as UTC time and returns a timezone-aware datetime.
182+
183+
Supported formats:
184+
- 2025-12-18T15:31:00 (ISO 8601 with seconds)
185+
- 2025-12-18T15:31 (ISO 8601 without seconds)
186+
- 2025-12-18 15:31:00 (space-separated with seconds)
187+
- 2025-12-18 15:31 (space-separated without seconds)
188+
- 2025-12-18 (date only, time defaults to 00:00:00)
189+
190+
Args:
191+
s: Datetime string to parse.
192+
193+
Returns:
194+
Timezone-aware datetime in UTC.
195+
196+
Raises:
197+
ValueError: If the string cannot be parsed in any supported format.
198+
"""
199+
for fmt in (
200+
"%Y-%m-%dT%H:%M:%S",
201+
"%Y-%m-%dT%H:%M",
202+
"%Y-%m-%d %H:%M:%S",
203+
"%Y-%m-%d %H:%M",
204+
"%Y-%m-%d",
205+
):
206+
try:
207+
naive = datetime.strptime(s, fmt)
208+
# Attach UTC timezone since input is expected to be UTC
209+
return naive.replace(tzinfo=timezone.utc)
210+
except ValueError:
211+
continue
212+
raise ValueError(f"Cannot parse datetime: {s!r}")

0 commit comments

Comments
 (0)