Skip to content

Commit a15267a

Browse files
committed
Update
[ghstack-poisoned]
1 parent bb0f11a commit a15267a

3 files changed

Lines changed: 174 additions & 16 deletions

File tree

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

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

34-
## What it does not do
34+
## Classification
3535

36-
It does not ping the log classifier. An S3 `ObjectCreated` notification on the
37-
`log/` prefix invokes `call-log-classifier`, which invokes `log_classifier`. That
38-
split is deliberate: the old lambda called the classifier with an untimed
36+
After a log is stored, `log_classifier` is invoked with `InvocationType: "Event"`
37+
and the result is not awaited. `github-status-test` called it with an untimed
3938
`urlopen` and blocked until classification finished, which is what produced its
40-
274s/344s/400s/900s duration tails.
39+
274s/344s/400s/900s duration tails — the fix is the invocation type, not a
40+
separate function.
41+
42+
It is reached through `lambda:InvokeFunction` rather than its public function URL
43+
(`AuthType: NONE`), so the path from here to classification never crosses a
44+
public endpoint. `log_classifier` builds on `lambda_http` with only the
45+
`apigw_http` feature, so `classifier_payload()` reproduces an API Gateway HTTP API
46+
v2.0 request. Verified against the deployed function: a payload with no `job_id`
47+
returns its 400 "no job id provided" branch, and a non-numeric one fails inside
48+
its `parse::<usize>()`, which together show both the envelope and the query
49+
string are read from a direct invoke.
50+
51+
A failed handoff is logged and reported as `classified: false`, not raised.
52+
Raising would make Lambda retry the whole function, re-downloading a
53+
multi-megabyte log from GitHub to retry something that takes milliseconds; the
54+
log itself is already safe in S3.
55+
56+
## What it does not do
4157

4258
It does not archive raw webhook payloads. Nothing read them —
4359
`clickhouse-replicator-s3` has no `SUPPORTED_PATHS` entry for `workflow_job/`,
@@ -88,8 +104,10 @@ Not done by CI. Needed before the deploy workflow can run.
88104
1. Create the function: python3.12, x86_64, handler `lambda_function.lambda_handler`.
89105
512 MB and a 60s timeout are plenty — the old function averaged 200ms and its
90106
long tail was the classifier ping this one does not make.
91-
2. Give its execution role `s3:PutObject` on `arn:aws:s3:::ossci-raw-job-status/log/*`
92-
plus the usual CloudWatch Logs permissions.
107+
2. Give its execution role `s3:PutObject` on `arn:aws:s3:::ossci-raw-job-status/log/*`,
108+
`lambda:InvokeFunction` on
109+
`arn:aws:lambda:us-east-1:308535385114:function:log_classifier`, plus the
110+
usual CloudWatch Logs permissions.
93111
3. Set the env vars above. Prefer fresh credentials over copying
94112
`github-status-test`'s, whose PATs sit in plaintext env vars and are due for
95113
rotation.
@@ -105,7 +123,9 @@ Not done by CI. Needed before the deploy workflow can run.
105123
`OUR_AWS_ACCESS_KEY_ID` before granting.
106124
6. Create the `gha_workflow_gha-log-uploader-lambda` IAM role the deploy workflow
107125
assumes, mirroring `gha_workflow_github-status-test-lambda`.
108-
7. Wire the classifier notification — see `../call-log-classifier/README.md`.
126+
7. Nothing to wire for classification: this function invokes `log_classifier`
127+
directly, so there is no S3 notification to add. `keep-going-call-log-classifier`
128+
still covers the separate `temp_logs/` prefix on `gha-artifacts`.
109129

110130
## Deployment
111131

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

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
# Copyright (c) 2019-present, Facebook, Inc.
22

3-
"""Download a completed GitHub Actions job log and archive it to S3.
3+
"""Download a completed GitHub Actions job log, archive it to S3, and classify it.
44
55
Invoked asynchronously (``InvocationType: "Event"``) by the PyTorch bot's
66
``workflow_job`` handler in torchci, and by torchci's backfill route. There is no
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 *not* triggered from here. An S3 ObjectCreated notification on
11-
the ``log/`` prefix drives it, so any path that lands a log gets classified and
12-
this function never blocks on the classifier finishing.
10+
The classifier is invoked the same way, asynchronously, so this function never
11+
blocks on classification finishing. ``github-status-test`` called it with an
12+
untimed ``urlopen`` and waited, which is what produced its 274s/344s/400s/900s
13+
duration tails -- the fix is the invocation type, not a separate function.
1314
"""
1415

1516
import base64
1617
import contextlib
1718
import gzip
19+
import json
1820
import os
1921
import random
2022
import time
@@ -26,11 +28,13 @@
2628

2729

2830
s3 = boto3.resource("s3")
31+
lambda_client = boto3.client("lambda")
2932
GITHUB_TOKENS = os.environ.get("GITHUB_TOKENS")
3033
GITHUB_APP_ID = os.environ.get("GITHUB_APP_ID")
3134
# Base64-encoded PEM, the same encoding torchci uses for its app key
3235
GITHUB_APP_PRIVATE_KEY = os.environ.get("GITHUB_APP_PRIVATE_KEY")
3336
BUCKET_NAME = "ossci-raw-job-status"
37+
LOG_CLASSIFIER_FUNCTION = "log_classifier"
3438

3539
GITHUB_API_URL = "https://api.github.com"
3640
# Installation tokens last an hour. Refresh early so a warm invocation never
@@ -131,6 +135,66 @@ def log_object_path(full_name, job_id):
131135
return f"log/{full_name}/{job_id}"
132136

133137

138+
def classifier_payload(full_name, job_id):
139+
"""An API Gateway HTTP API v2.0 request, which is what lambda_http parses.
140+
141+
log_classifier is built on lambda_http with only the `apigw_http` feature, so
142+
it expects this envelope even on a direct invoke. Verified against the
143+
deployed function: a payload with no `job_id` returns its 400 "no job id
144+
provided" branch, and a non-numeric one fails in its `parse::<usize>()`,
145+
which together show both the envelope and the query string are read.
146+
"""
147+
return {
148+
"version": "2.0",
149+
"routeKey": "$default",
150+
"rawPath": "/",
151+
"rawQueryString": f"job_id={job_id}&repo={full_name}",
152+
"headers": {},
153+
"queryStringParameters": {"job_id": str(job_id), "repo": full_name},
154+
"requestContext": {
155+
"accountId": "308535385114",
156+
"apiId": "gha-log-uploader",
157+
"domainName": "lambda-invoke",
158+
"domainPrefix": "lambda-invoke",
159+
"http": {
160+
"method": "GET",
161+
"path": "/",
162+
"protocol": "HTTP/1.1",
163+
"sourceIp": "127.0.0.1",
164+
"userAgent": "gha-log-uploader",
165+
},
166+
"requestId": f"gha-log-uploader-{job_id}",
167+
"routeKey": "$default",
168+
"stage": "$default",
169+
"time": "01/Jan/1970:00:00:00 +0000",
170+
"timeEpoch": 0,
171+
},
172+
"isBase64Encoded": False,
173+
}
174+
175+
176+
def classify_log(full_name, job_id):
177+
"""Kick off classification for a log we just stored. Returns True on handoff.
178+
179+
Asynchronous, and reached through `lambda:InvokeFunction` rather than
180+
log_classifier's public function URL, so the path from here to classification
181+
never crosses a public endpoint.
182+
"""
183+
try:
184+
lambda_client.invoke(
185+
FunctionName=LOG_CLASSIFIER_FUNCTION,
186+
InvocationType="Event",
187+
Payload=json.dumps(classifier_payload(full_name, job_id)).encode(),
188+
)
189+
return True
190+
except Exception as err:
191+
# Best effort, deliberately. Raising would make Lambda retry the whole
192+
# function, re-downloading a multi-megabyte log from GitHub to retry a
193+
# handoff that takes milliseconds. The log itself is already safe in S3.
194+
print(f"ERROR invoking the classifier for {full_name} job {job_id}: {err}")
195+
return False
196+
197+
134198
def download_log(full_name, conclusion, job_id):
135199
"""Fetch a job log from GitHub and archive it. Returns True when stored."""
136200
response = None
@@ -204,4 +268,11 @@ def lambda_handler(event, context):
204268
print(f"ERROR downloading log for {full_name} job {job_id}: {err}")
205269
raise
206270

207-
return {"repo": full_name, "job_id": job_id, "stored": stored}
271+
classified = classify_log(full_name, job_id) if stored else False
272+
273+
return {
274+
"repo": full_name,
275+
"job_id": job_id,
276+
"stored": stored,
277+
"classified": classified,
278+
}

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

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import json
12
import unittest
23
from unittest.mock import MagicMock, patch
34

45
import lambda_function
5-
from lambda_function import download_log, installation_token, parse_event
6+
from lambda_function import (
7+
classifier_payload,
8+
classify_log,
9+
download_log,
10+
installation_token,
11+
parse_event,
12+
)
613

714

815
def make_response(status_code, content=b"log data", headers=None):
@@ -49,6 +56,46 @@ def test_rejects_a_non_object_payload(self):
4956
parse_event(["pytorch/pytorch", 123])
5057

5158

59+
class TestClassifierPayload(unittest.TestCase):
60+
def test_is_a_v2_request_the_classifier_can_parse(self):
61+
payload = classifier_payload("pytorch/executorch", 999)
62+
# log_classifier builds on lambda_http with only the apigw_http feature,
63+
# so version 2.0 and requestContext.http are what make it deserialize.
64+
self.assertEqual(payload["version"], "2.0")
65+
self.assertIn("http", payload["requestContext"])
66+
self.assertEqual(
67+
payload["queryStringParameters"],
68+
{"job_id": "999", "repo": "pytorch/executorch"},
69+
)
70+
self.assertEqual(payload["rawQueryString"], "job_id=999&repo=pytorch/executorch")
71+
72+
def test_is_json_serializable(self):
73+
json.dumps(classifier_payload("pytorch/pytorch", 1))
74+
75+
76+
class TestClassifyLog(unittest.TestCase):
77+
def test_invokes_the_classifier_asynchronously(self):
78+
with patch.object(lambda_function, "lambda_client") as client:
79+
self.assertTrue(classify_log("pytorch/pytorch", 123))
80+
81+
kwargs = client.invoke.call_args.kwargs
82+
self.assertEqual(kwargs["FunctionName"], "log_classifier")
83+
# Event, not RequestResponse: waiting on classification is exactly the
84+
# mistake that gave github-status-test its multi-hundred-second tails.
85+
self.assertEqual(kwargs["InvocationType"], "Event")
86+
self.assertEqual(
87+
json.loads(kwargs["Payload"])["queryStringParameters"],
88+
{"job_id": "123", "repo": "pytorch/pytorch"},
89+
)
90+
91+
def test_a_failed_invoke_is_reported_not_raised(self):
92+
# Raising would make Lambda retry the whole function, re-downloading a
93+
# multi-megabyte log to retry a handoff that takes milliseconds.
94+
with patch.object(lambda_function, "lambda_client") as client:
95+
client.invoke.side_effect = RuntimeError("throttled")
96+
self.assertFalse(classify_log("pytorch/pytorch", 123))
97+
98+
5299
class TestInstallationToken(unittest.TestCase):
53100
def setUp(self):
54101
lambda_function._token_cache.clear()
@@ -240,15 +287,35 @@ def test_a_null_conclusion_is_stored_as_empty(self, s3):
240287
@patch.object(lambda_function, "s3")
241288
class TestLambdaHandler(unittest.TestCase):
242289
def test_returns_a_summary(self, s3):
243-
with patch.object(lambda_function, "download_log", return_value=True):
290+
with patch.object(
291+
lambda_function, "download_log", return_value=True
292+
), patch.object(lambda_function, "classify_log", return_value=True):
244293
self.assertEqual(
245294
lambda_function.lambda_handler(
246295
{"repo": "pytorch/pytorch", "job_id": 5, "conclusion": "success"},
247296
None,
248297
),
249-
{"repo": "pytorch/pytorch", "job_id": 5, "stored": True},
298+
{
299+
"repo": "pytorch/pytorch",
300+
"job_id": 5,
301+
"stored": True,
302+
"classified": True,
303+
},
250304
)
251305

306+
def test_does_not_classify_a_log_that_was_never_stored(self, s3):
307+
# A 404 from GitHub means there is nothing in S3 for the classifier to
308+
# read, so asking it to try would only produce a confusing failure.
309+
with patch.object(
310+
lambda_function, "download_log", return_value=False
311+
), patch.object(lambda_function, "classify_log") as classify:
312+
result = lambda_function.lambda_handler(
313+
{"repo": "pytorch/pytorch", "job_id": 5}, None
314+
)
315+
316+
classify.assert_not_called()
317+
self.assertFalse(result["classified"])
318+
252319
def test_a_github_blip_propagates_so_lambda_retries(self, s3):
253320
with patch.object(
254321
lambda_function,

0 commit comments

Comments
 (0)