Skip to content

Commit a448d8e

Browse files
committed
Update (base update)
[ghstack-poisoned]
1 parent 215d21a commit a448d8e

3 files changed

Lines changed: 168 additions & 32 deletions

File tree

aws/lambda/gha-log-uploader/README.md

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,51 @@ Payload:
3131
`conclusion` is optional. A malformed payload raises, which means Lambda retries
3232
twice and then DLQs it.
3333

34+
## What fails how
35+
36+
Callers invoke asynchronously, so *raising* is what reaches Lambda's retries and
37+
then the dead-letter queue; *returning* records the invocation as a success no
38+
matter what the return value says. Which failures do which:
39+
40+
| Failure | Behaviour |
41+
| --- | --- |
42+
| Network error reaching GitHub | raises — retried, then DLQ |
43+
| GitHub 5xx or 429 | raises — retried, then DLQ |
44+
| No usable credential | raises — retried, then DLQ |
45+
| Malformed payload | raises — retried, then DLQ |
46+
| GitHub 404 (log has aged out), 401, 403 | returns `stored: false`, no retry |
47+
| Classifier call fails or times out | returns `classified: false`, no retry |
48+
49+
The bottom two are deliberate. A log GitHub has already dropped does not come
50+
back on the third attempt, and re-running a whole download to retry a classifier
51+
handoff would re-fetch megabytes to redo something that takes milliseconds.
52+
`stored: false` is therefore not visible on the DLQ; if you want to alarm on it,
53+
match the terminal `ERROR <status> downloading log` line specifically. Do **not**
54+
alarm on `ERROR` generally: `installation_token` logs one every time it falls
55+
back to the PAT pool, which is a path that then usually succeeds.
56+
3457
## Classification
3558

3659
After a log is stored, `log_classifier` is called through its function URL —
3760
byte for byte the call `github-status-test` makes today.
3861

39-
That call is synchronous. Function URLs only support the `RequestResponse`
40-
invocation type, so this function's duration includes the classification, and
41-
`github-status-test`'s 274s/344s/400s/900s duration maxima come from exactly
42-
this. **Keep the timeout at 900s**: on a slow classification a shorter one would
43-
kill the invocation mid-wait, and since callers invoke asynchronously, Lambda
44-
would then retry the whole thing and re-download the log.
62+
Function URLs only support the `RequestResponse` invocation type, so there is no
63+
way to ask for fire-and-forget. `CLASSIFIER_TIMEOUT` gets close enough: after 30s
64+
this stops waiting for the reply. Disconnecting does not cancel the classifier —
65+
it runs to completion regardless — so nothing is lost by hanging up, and
66+
`github-status-test`'s 274s/344s/400s/900s duration maxima do not carry over.
67+
68+
That bound is load-bearing, not tidiness. `urlopen` with no `timeout` has none at
69+
all, so a connection that is accepted and never answered raises nothing and burns
70+
the entire function timeout. Since callers invoke asynchronously, Lambda counts
71+
that as a failure and replays the whole invocation twice more, re-downloading the
72+
same log each time and eventually DLQ-ing a job whose log was archived fine on
73+
the first attempt. `github-status-test` does hit its 900s ceiling, so this is an
74+
observed tail, not a theoretical one.
4575

46-
Unlike in `github-status-test` the tail is no longer harmful. There it ran behind
47-
API Gateway on the webhook's critical path, so a slow classification risked a
48-
GitHub webhook timeout. Here the caller has already returned, and a long
49-
invocation costs GB-seconds and a concurrency slot, nothing more.
76+
With the wait bounded, every step has an explicit ceiling — two 30s log fetches
77+
at most, then a 30s classifier call — so a **300s function timeout** is
78+
comfortable, rather than the 900s `github-status-test` needs.
5079

5180
The way out is `lambda:InvokeFunction` with `InvocationType: "Event"`, which
5281
needs `log_classifier` to accept a plain `{"job_id", "repo"}` payload — it
@@ -82,7 +111,7 @@ on the repo.
82111

83112
| Env var | Required | Purpose |
84113
| --- | --- | --- |
85-
| `GITHUB_APP_ID` | no | App id used to mint installation tokens (e.g. `4550824`, `pytorch-bot-preview`) |
114+
| `GITHUB_APP_ID` | no | Numeric app id used to mint installation tokens, e.g. `4550824` — the id of the `pytorch-bot-preview` app. Must be the number: it goes through `int()`, so an app slug fails |
86115
| `GITHUB_APP_PRIVATE_KEY` | no | The app's private key, base64-encoded PEM (same encoding torchci uses) |
87116
| `GITHUB_TOKENS` | yes | Comma-separated PAT pool, used as the fallback and when no app is configured |
88117

@@ -109,16 +138,17 @@ Notes on the app path:
109138
Not done by CI. Needed before the deploy workflow can run.
110139

111140
1. Create the function: python3.12, x86_64, handler `lambda_function.lambda_handler`,
112-
512 MB, **900s timeout**matching `github-status-test`, because the
113-
synchronous classifier call means a slow classification is a slow invocation.
141+
512 MB, **300s timeout**every step is individually bounded, so this does
142+
not need `github-status-test`'s 900s. See Classification above.
114143
2. Give its execution role `s3:PutObject` on `arn:aws:s3:::ossci-raw-job-status/log/*`
115144
plus the usual CloudWatch Logs permissions. No `lambda:InvokeFunction` is
116145
needed while the classifier is reached over its function URL.
117146
3. Set the env vars above. Prefer fresh credentials over copying
118147
`github-status-test`'s, whose PATs sit in plaintext env vars and are due for
119148
rotation.
120-
4. Configure an on-failure destination or DLQ, and alarm on it. That queue is the
121-
only signal that a trunk-only job lost its log.
149+
4. Configure an on-failure destination or DLQ, and alarm on it. For a trunk-only
150+
job that is the only signal its log went missing — Dr.CI's self-heal only
151+
covers PR jobs. See "What fails how" for what does and does not land there.
122152
5. Add the invoke grant for torchci, and nothing else:
123153
```
124154
aws lambda add-permission --function-name gha-log-uploader \

aws/lambda/gha-log-uploader/lambda_function.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
API Gateway integration and no Lambda function URL: the only way in is
88
``lambda:InvokeFunction``, which is IAM-authenticated.
99
10-
Classification is kicked off exactly as ``github-status-test`` does it, through
11-
log_classifier's function URL. That call is synchronous, so this function's
12-
duration includes the classification -- which is why its timeout has to stay at
13-
900s. Switching to an async ``lambda:InvokeFunction`` needs log_classifier to
14-
accept a plain payload first; see the README.
10+
Classification is kicked off through log_classifier's function URL, as
11+
``github-status-test`` does it, but with a bounded wait for the reply so a hung
12+
classifier cannot burn this function's whole timeout. Switching to an async
13+
``lambda:InvokeFunction`` needs log_classifier to accept a plain payload first;
14+
see the README.
1515
"""
1616

1717
import base64
@@ -37,6 +37,10 @@
3737
LOG_CLASSIFIER_URL = (
3838
"https://vwg52br27lx5oymv4ouejwf4re0akoeg.lambda-url.us-east-1.on.aws"
3939
)
40+
# How long to wait for the classifier's reply before giving up on it. Classifying
41+
# a big log can take minutes, and waiting that out is pointless: nothing here
42+
# reads the result, and disconnecting does not cancel the classifier.
43+
CLASSIFIER_TIMEOUT = 30
4044

4145
GITHUB_API_URL = "https://api.github.com"
4246
# Installation tokens last an hour. Refresh early so a warm invocation never
@@ -59,6 +63,15 @@
5963
_token_cache = {}
6064

6165

66+
class RetryableDownloadError(Exception):
67+
"""A download failure worth another attempt.
68+
69+
Raised rather than returned so the async invocation fails: Lambda retries it
70+
twice and then hands it to the dead-letter queue, which is the only place a
71+
permanently lost log gets reported.
72+
"""
73+
74+
6275
def app_private_key():
6376
key = GITHUB_APP_PRIVATE_KEY
6477
if "PRIVATE KEY" not in key:
@@ -138,14 +151,30 @@ def log_object_path(full_name, job_id):
138151

139152

140153
def classify_log(full_name, job_id):
141-
"""Kick off classification for a log we just stored. Returns True on success.
142-
143-
Same call github-status-test makes. The function URL only supports the
144-
RequestResponse invocation type, so this blocks until classification
145-
finishes; the function's 900s timeout has to cover that.
154+
"""Kick off classification for a log we just stored.
155+
156+
Same call github-status-test makes, except that it waits a bounded time for
157+
the reply. The function URL only supports the RequestResponse invocation
158+
type, so there is no way to ask for fire-and-forget -- but giving up on the
159+
reply is close enough, because a client disconnect does not cancel the
160+
classifier. It runs to completion either way; we just stop waiting.
161+
162+
That bound matters. `urlopen` with no `timeout` has none at all, so a
163+
connection that is accepted and never answered raises nothing and instead
164+
burns the whole function timeout. Callers invoke this asynchronously, which
165+
means Lambda would count that as a failure and replay the entire invocation
166+
twice more, re-downloading the same multi-megabyte log each time -- and
167+
github-status-test really does hit its 900s ceiling, so this is an observed
168+
tail rather than a theoretical one.
169+
170+
Returns True when the classifier answered. False means we stopped waiting or
171+
the call failed, and only the second of those actually skips classification.
146172
"""
147173
try:
148-
urlopen(f"{LOG_CLASSIFIER_URL}/?job_id={job_id}&repo={full_name}")
174+
urlopen(
175+
f"{LOG_CLASSIFIER_URL}/?job_id={job_id}&repo={full_name}",
176+
timeout=CLASSIFIER_TIMEOUT,
177+
)
149178
return True
150179
except Exception as err:
151180
# Best effort, deliberately. Raising would make Lambda retry the whole
@@ -155,8 +184,25 @@ def classify_log(full_name, job_id):
155184
return False
156185

157186

187+
def is_retryable(status_code):
188+
"""Whether GitHub answering with this is worth another attempt.
189+
190+
Server errors and rate limits go away on their own; 404 (the log has aged
191+
out, which GitHub does after a couple of months) and the auth failures do
192+
not, and replaying those only re-runs a download that will fail identically.
193+
"""
194+
return status_code >= 500 or status_code == 429
195+
196+
158197
def download_log(full_name, conclusion, job_id):
159-
"""Fetch a job log from GitHub and archive it. Returns True when stored."""
198+
"""Fetch a job log from GitHub and archive it. Returns True when stored.
199+
200+
Returns False for failures that a retry cannot fix, and raises
201+
RetryableDownloadError for the ones it can. Because callers invoke this
202+
function asynchronously, raising is what puts a job in front of Lambda's
203+
retries and, if they all fail, on the dead-letter queue -- returning False
204+
records the invocation as a success and the lost log goes unreported.
205+
"""
160206
response = None
161207

162208
app_token = installation_token(full_name)
@@ -174,12 +220,19 @@ def download_log(full_name, conclusion, job_id):
174220

175221
if response is None:
176222
if not GITHUB_TOKENS:
177-
print(f"ERROR no usable credential for {full_name} job {job_id}")
178-
return False
223+
# A misconfiguration, so retrying will not fix it -- but it costs
224+
# every repo every log, and the DLQ is where that has to show up.
225+
raise RetryableDownloadError(
226+
f"no usable credential for {full_name} job {job_id}"
227+
)
179228
response = fetch_log(full_name, job_id, random.choice(GITHUB_TOKENS.split(",")))
180229

181230
if not response.ok:
182231
# Bail out rather than archive the API error body as if it were the log
232+
if is_retryable(response.status_code):
233+
raise RetryableDownloadError(
234+
f"{response.status_code} downloading log for {full_name} job {job_id}"
235+
)
183236
print(
184237
f"ERROR {response.status_code} downloading log for {full_name} job {job_id}"
185238
)

aws/lambda/gha-log-uploader/test_lambda_function.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,43 @@ def test_calls_the_classifier(self):
5555
self.assertTrue(classify_log("pytorch/pytorch", 123))
5656

5757
urlopen.assert_called_once_with(
58-
f"{lambda_function.LOG_CLASSIFIER_URL}/?job_id=123&repo=pytorch/pytorch"
58+
f"{lambda_function.LOG_CLASSIFIER_URL}/?job_id=123&repo=pytorch/pytorch",
59+
timeout=lambda_function.CLASSIFIER_TIMEOUT,
5960
)
6061

62+
def test_waits_a_bounded_time(self):
63+
# Without a timeout there is none at all, and a hung classifier burns the
64+
# whole function timeout -- which Lambda then treats as a failed async
65+
# invocation and replays, re-downloading the log every time.
66+
self.assertIsNotNone(lambda_function.CLASSIFIER_TIMEOUT)
67+
self.assertLess(lambda_function.CLASSIFIER_TIMEOUT, 300)
68+
6169
def test_a_failed_call_is_reported_not_raised(self):
6270
# Raising would make Lambda retry the whole function and re-download a
6371
# multi-megabyte log, when the log is already safe in S3.
6472
with patch.object(lambda_function, "urlopen") as urlopen:
6573
urlopen.side_effect = RuntimeError("connection reset")
6674
self.assertFalse(classify_log("pytorch/pytorch", 123))
6775

76+
def test_a_hang_is_reported_not_raised(self):
77+
# The case the timeout exists for: urlopen raises TimeoutError once it
78+
# fires, which must come back down the same branch as any other failure.
79+
with patch.object(lambda_function, "urlopen") as urlopen:
80+
urlopen.side_effect = TimeoutError("timed out")
81+
self.assertFalse(classify_log("pytorch/pytorch", 123))
82+
83+
def test_a_hang_still_leaves_the_log_stored(self):
84+
# Giving up on the reply must not undo the upload or fail the invocation.
85+
with patch.object(lambda_function, "s3"), patch.object(
86+
lambda_function, "download_log", return_value=True
87+
), patch.object(lambda_function, "urlopen", side_effect=TimeoutError):
88+
result = lambda_function.lambda_handler(
89+
{"repo": "pytorch/pytorch", "job_id": 5}, None
90+
)
91+
92+
self.assertTrue(result["stored"])
93+
self.assertFalse(result["classified"])
94+
6895

6996
class TestInstallationToken(unittest.TestCase):
7097
def setUp(self):
@@ -221,6 +248,29 @@ def test_error_response_is_not_archived(self, s3):
221248

222249
s3.Object.assert_not_called()
223250

251+
def test_a_terminal_status_does_not_retry(self, s3):
252+
# GitHub drops logs after a couple of months, and no number of retries
253+
# brings one back -- nor does replaying an auth failure fix it.
254+
for status in (401, 403, 404, 410, 422):
255+
with self.subTest(status=status), patch.object(
256+
lambda_function, "installation_token", return_value=None
257+
), patch.object(
258+
lambda_function, "fetch_log", return_value=make_response(status)
259+
):
260+
self.assertFalse(download_log("pytorch/pytorch", "failure", 123))
261+
262+
def test_a_transient_status_raises_so_lambda_retries(self, s3):
263+
for status in (429, 500, 502, 503, 504):
264+
with self.subTest(status=status), patch.object(
265+
lambda_function, "installation_token", return_value=None
266+
), patch.object(
267+
lambda_function, "fetch_log", return_value=make_response(status)
268+
):
269+
with self.assertRaises(lambda_function.RetryableDownloadError):
270+
download_log("pytorch/pytorch", "failure", 123)
271+
272+
s3.Object.assert_not_called()
273+
224274
def test_non_pytorch_repo_is_prefixed(self, s3):
225275
with patch.object(
226276
lambda_function, "installation_token", return_value=None
@@ -231,13 +281,16 @@ def test_non_pytorch_repo_is_prefixed(self, s3):
231281
"ossci-raw-job-status", "log/pytorch/executorch/999"
232282
)
233283

234-
def test_no_credentials_at_all_is_a_noop(self, s3):
284+
def test_no_credentials_at_all_reaches_the_dlq(self, s3):
285+
# Retries will not conjure a credential, but this costs every repo every
286+
# log, so it has to fail loudly rather than report a successful no-op.
235287
with patch.object(
236288
lambda_function, "installation_token", return_value=None
237289
), patch.object(lambda_function, "GITHUB_TOKENS", None), patch.object(
238290
lambda_function, "fetch_log"
239291
) as fetch_log:
240-
self.assertFalse(download_log("pytorch/pytorch", "failure", 123))
292+
with self.assertRaises(lambda_function.RetryableDownloadError):
293+
download_log("pytorch/pytorch", "failure", 123)
241294

242295
fetch_log.assert_not_called()
243296
s3.Object.assert_not_called()

0 commit comments

Comments
 (0)