Skip to content

Commit 22bd369

Browse files
authored
[CRCR] Use {run_id}:{run_attempt} instead of {check_run_id} in state machine key composition (#8170)
Use `f"{_STATE_PREFIX}{delivery_id}:{downstream_repo}:{run_id}:{run_attempt}"` instead of `f"{_STATE_PREFIX}{delivery_id}:{downstream_repo}:{check_run_id}"` to extend support of enabling downstream setting multiple jobs within one workflow. Because `run_id` is workflow-level and `check_run_id` is job-level and we don't want to see too many entries (one for each job within a single workflow) displayed on HUD and PR CI panel. cc @fffrog @can-gaa-hou @subinz1 @jewelkm89
1 parent bda9f5f commit 22bd369

8 files changed

Lines changed: 164 additions & 155 deletions

File tree

aws/lambda/cross_repo_ci_relay/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ The callback endpoint validates incoming callbacks and forwards them to HUD for
4444
- **Identity**: the `Authorization: Bearer <oidc-token>` header is verified against GitHub's JWKS. The OIDC `repository` claim is a trusted identity for the caller and is used for the L2+ allowlist check. Relay forwards this trusted value to HUD as a top-level `verified_repo` field; HUD should prefer it over anything self-reported in `callback_payload`.
4545
- **Repo level**: Relay determines the downstream repository's allowlist level (L1–L4) and forwards it to HUD as `downstream_repo_level`. This authoritative level information is determined once by the relay, ensuring HUD doesn't need to recompute it and avoiding synchronization/timing issues if tiering information becomes dynamic.
4646
- **Schema validation**: Relay validates that required fields (`delivery_id` and `workflow.status`) are present in the callback body. Missing fields result in a `400` error to signal contract violations to the caller. HUD receives validated data and does not need to perform schema checks.
47-
- **State machine**: Relay maintains a **unified state machine** in Redis to validate callback lifecycles, compute timing metrics, and support per-job tracking:
48-
- **Unified structure**: Single enum `CallbackState` with states `DISPATCHED` (webhook side, keyed by sentinel `check_run_id="dispatched"`), `IN_PROGRESS`, and `COMPLETED` (callback side, per-job). State records stored as JSON: `{"state": "...", "timestamp": 1234.56, "job_name": "...", "run_id": "..."}`.
47+
- **State machine**: Relay maintains a **unified state machine** in Redis to validate callback lifecycles, compute timing metrics, and support per-workflow tracking:
48+
- **Unified structure**: Single enum `CallbackState` with states `DISPATCHED` (webhook side, keyed by sentinel `run_id=0, run_attempt=0`), `IN_PROGRESS`, and `COMPLETED` (callback side, per-workflow). State records stored as JSON: `{"state": "...", "timestamp": 1234.56}`.
4949
- **Dispatch validation**: `DISPATCHED` state proves valid webhook origin. Callbacks without this state are rejected (no prior dispatch).
50-
- **Job-level tracking**: Each job has independent state and timestamps keyed by `check_run_id` (`oot:state:{delivery_id}:{repo}:{check_run_id}`). Supports multiple jobs per webhook.
50+
- **Workflow-level tracking**: Each workflow has independent state and timestamps keyed by `{run_id}:{run_attempt}` (`oot:state:{delivery_id}:{repo}:{run_id}:{run_attempt}`). Supports multiple workflows per webhook.
5151
- **Timing metrics**: `queue_time = dispatch_timestamp → in_progress_timestamp`, `execution_time = in_progress_timestamp → completed_timestamp`. Timestamps extracted from state records.
52-
- **State transitions**: Rejects invalid flows (`COMPLETED` without prior `IN_PROGRESS`, duplicate `IN_PROGRESS` for the same `check_run_id`, duplicate `COMPLETED`, callbacks without a prior `DISPATCHED` record).
53-
Note that the direction graph below is for a single check run, reruns have different `check_run_id` and are treated as separate jobs, so they won't violate the state machine since they won't have a prior `IN_PROGRESS` or `COMPLETED` record.
52+
- **State transitions**: Rejects invalid flows (`COMPLETED` without prior `IN_PROGRESS`, duplicate `IN_PROGRESS` for the same `{run_id}:{run_attempt}`, duplicate `COMPLETED`, callbacks without a prior `DISPATCHED` record).
53+
Note that the direction graph below is for a single check run, reruns have different `run_attempt` and are treated as separate workflows, so they won't violate the state machine since they won't have a prior `IN_PROGRESS` or `COMPLETED` record.
5454
```mermaid
5555
stateDiagram-v2
5656
direction LR

aws/lambda/cross_repo_ci_relay/callback/callback_handler.py

Lines changed: 32 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
from utils.misc import (
1212
CallbackState,
1313
CallbackStateRecord,
14-
DISPATCH_CHECK_RUN_ID,
14+
DISPATCH_RUN_ATTEMPT,
15+
DISPATCH_RUN_ID,
1516
HTTPException,
1617
)
1718
from utils.redis_helper import check_rate_limit
@@ -59,41 +60,41 @@ def _verify_access(
5960
return allowlist, repo_level
6061

6162

62-
def _parse_callback_body(body: dict) -> tuple[str, str, str, str, str]:
63-
"""Return (delivery_id, status, check_run_id, job_name, run_id) from ``body``.
63+
def _parse_callback_body(body: dict) -> tuple[str, str, int, int, str]:
64+
"""Return (delivery_id, status, run_id, run_attempt, workflow_name) from ``body``.
6465
65-
check_run_id is set by GitHub Actions (job.check_run_id context) and
66-
cannot be tampered with, ensuring replay-attack detection integrity.
66+
run_id and run_attempt uniquely identify a workflow run execution.
67+
run_attempt defaults to 1 when not present in the callback body.
6768
6869
Raises HTTPException(400) on any missing or mis-typed field.
6970
"""
7071
try:
7172
delivery_id = body["delivery_id"]
7273
workflow_dict = body["workflow"]
7374
status = workflow_dict["status"]
74-
check_run_id = workflow_dict["check_run_id"] # Required
75-
job_name = workflow_dict["job_name"] # Required for HUD grouping
7675
run_id = workflow_dict["run_id"] # Required for HUD grouping
76+
run_attempt = workflow_dict.get("run_attempt", 1) # Default to 1 if not present
77+
workflow_name = workflow_dict["name"] # Required for HUD grouping
7778
except (KeyError, TypeError) as exc:
7879
logger.warning(f"missing required field in callback body: {exc}")
7980
raise HTTPException(
8081
400, f"callback body missing required field: {exc}"
8182
) from exc
82-
return delivery_id, status, check_run_id, job_name, run_id
83+
return delivery_id, status, run_id, run_attempt, workflow_name
8384

8485

8586
def _update_state_and_compute_metrics(
8687
config: RelayConfig,
8788
delivery_id: str,
8889
verified_repo: str,
89-
check_run_id: str,
90-
job_name: str,
91-
run_id: str,
90+
run_id: int,
91+
run_attempt: int,
92+
workflow_name: str,
9293
status: str,
9394
dispatch_record: CallbackStateRecord,
94-
job_record: CallbackStateRecord | None,
95+
workflow_record: CallbackStateRecord | None,
9596
) -> dict:
96-
"""Persist the new job state to Redis and return CI timing metrics.
97+
"""Persist the new workflow state to Redis and return CI timing metrics.
9798
9899
Writes IN_PROGRESS or COMPLETED state (with the current timestamp), then
99100
reads back the stored record to compute:
@@ -119,11 +120,11 @@ def _update_state_and_compute_metrics(
119120
config,
120121
delivery_id,
121122
verified_repo,
122-
check_run_id,
123+
run_id,
124+
run_attempt,
123125
state,
124126
current_timestamp,
125-
job_name,
126-
run_id,
127+
workflow_name,
127128
)
128129
except RedisError:
129130
raise HTTPException(
@@ -138,22 +139,24 @@ def _update_state_and_compute_metrics(
138139
except Exception:
139140
raise
140141

141-
updated_job_record = redis_helper.get_callback_state(
142-
config, delivery_id, verified_repo, check_run_id
142+
updated_workflow_record = redis_helper.get_callback_state(
143+
config, delivery_id, verified_repo, run_id, run_attempt
143144
)
144-
if updated_job_record is None:
145+
if updated_workflow_record is None:
145146
return ci_metrics
146147

147148
if state == CallbackState.IN_PROGRESS:
148149
ci_metrics["queue_time"] = _safe_delta(
149150
dispatch_record.timestamp,
150-
updated_job_record.timestamp,
151+
updated_workflow_record.timestamp,
151152
"queue_time",
152153
)
153154
else:
154-
if job_record is not None:
155+
if workflow_record is not None:
155156
ci_metrics["execution_time"] = _safe_delta(
156-
job_record.timestamp, updated_job_record.timestamp, "execution_time"
157+
workflow_record.timestamp,
158+
updated_workflow_record.timestamp,
159+
"execution_time",
157160
)
158161

159162
return ci_metrics
@@ -181,10 +184,10 @@ def handle(config: RelayConfig, body: dict, verified_repo: str) -> dict:
181184
return {"ok": True, "status": "ignored"}
182185
_, repo_level = result
183186

184-
delivery_id, status, check_run_id, job_name, run_id = _parse_callback_body(body)
187+
delivery_id, status, run_id, run_attempt, workflow_name = _parse_callback_body(body)
185188

186189
dispatch_record = redis_helper.get_callback_state(
187-
config, delivery_id, verified_repo, DISPATCH_CHECK_RUN_ID
190+
config, delivery_id, verified_repo, DISPATCH_RUN_ID, DISPATCH_RUN_ATTEMPT
188191
)
189192
if not dispatch_record:
190193
logger.warning(
@@ -194,20 +197,20 @@ def handle(config: RelayConfig, body: dict, verified_repo: str) -> dict:
194197
)
195198
raise HTTPException(400, "callback rejected: no matching dispatch record")
196199

197-
job_record = redis_helper.get_callback_state(
198-
config, delivery_id, verified_repo, check_run_id
200+
workflow_record = redis_helper.get_callback_state(
201+
config, delivery_id, verified_repo, run_id, run_attempt
199202
)
200203

201204
ci_metrics = _update_state_and_compute_metrics(
202205
config,
203206
delivery_id,
204207
verified_repo,
205-
check_run_id,
206-
job_name,
207208
run_id,
209+
run_attempt,
210+
workflow_name,
208211
status,
209212
dispatch_record,
210-
job_record,
213+
workflow_record,
211214
)
212215

213216
trusted = {

aws/lambda/cross_repo_ci_relay/tests/test_callback_handler.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from callback.callback_handler import handle
66
from utils.allowlist import AllowlistLevel
7-
from utils.misc import CallbackState, DISPATCH_CHECK_RUN_ID, HTTPException
7+
from utils.misc import CallbackState, DISPATCH_RUN_ID, HTTPException
88
from utils.redis_helper import CallbackStateRecord
99

1010

@@ -19,7 +19,7 @@ def _cfg():
1919
return cfg
2020

2121

22-
def _body(status="completed", job_name="default", check_run_id="12345", run_id="99999"):
22+
def _body(status="completed", workflow_name="default", run_id=99999, run_attempt=1):
2323
return {
2424
"event_type": "pull_request",
2525
"delivery_id": "del-123",
@@ -32,9 +32,9 @@ def _body(status="completed", job_name="default", check_run_id="12345", run_id="
3232
"conclusion": "success" if status == "completed" else None,
3333
"name": "CI",
3434
"url": "http://ci.example.com/run/1",
35-
"job_name": job_name,
36-
"check_run_id": check_run_id,
35+
"workflow_name": workflow_name,
3736
"run_id": run_id,
37+
"run_attempt": run_attempt,
3838
},
3939
}
4040

@@ -52,15 +52,19 @@ def setUp(self):
5252
self.mock_redis = self.patcher_redis.start()
5353
self.mock_redis.create_client.return_value = MagicMock()
5454

55-
# Setup default: dispatch exists, job state is None (in_progress not yet reported)
56-
def default_get_state(cfg, delivery_id, repo, check_run_id_arg, client=None):
57-
if check_run_id_arg == DISPATCH_CHECK_RUN_ID:
55+
# Setup default: dispatch exists, workflow state is None (in_progress not yet reported)
56+
def default_get_state(
57+
cfg, delivery_id, repo, run_id_arg, run_attempt_arg, client=None
58+
):
59+
if run_id_arg == DISPATCH_RUN_ID:
5860
return CallbackStateRecord(
59-
CallbackState.DISPATCHED, time.time() - 30, "dispatch-job", 11111
61+
CallbackState.DISPATCHED,
62+
time.time() - 30,
6063
)
61-
elif check_run_id_arg == "12345": # default check_run_id in _body()
64+
elif run_id_arg == 99999: # default run_id in _body()
6265
return CallbackStateRecord(
63-
CallbackState.IN_PROGRESS, time.time() - 20, "default", 99999
66+
CallbackState.IN_PROGRESS,
67+
time.time() - 20,
6468
)
6569
return None
6670

@@ -110,15 +114,17 @@ def test_body_is_passed_to_hud_unchanged(self):
110114
def test_queue_time_calculated_from_state_records(self):
111115
"""queue_time is the dispatch-to-in_progress delta."""
112116
dispatch_record = CallbackStateRecord(
113-
CallbackState.DISPATCHED, 1000.0, "dispatch-job", 11111
117+
CallbackState.DISPATCHED,
118+
1000.0,
114119
)
115-
job_record = CallbackStateRecord(
116-
CallbackState.IN_PROGRESS, 1030.0, "default", 99999
120+
workflow_record = CallbackStateRecord(
121+
CallbackState.IN_PROGRESS,
122+
1030.0,
117123
)
118124
self.mock_redis.get_callback_state.side_effect = [
119125
dispatch_record, # dispatch lookup
120-
None, # job state: not yet set
121-
job_record, # re-read after set_callback_state
126+
None, # workflow state: not yet set
127+
workflow_record, # re-read after set_callback_state
122128
]
123129

124130
handle(_cfg(), _body(status="in_progress"), verified_repo="org/repo")
@@ -131,17 +137,20 @@ def test_queue_time_calculated_from_state_records(self):
131137
def test_execution_time_calculated_from_state_records(self):
132138
"""execution_time is the in_progress-to-completed delta."""
133139
dispatch_record = CallbackStateRecord(
134-
CallbackState.DISPATCHED, 1000.0, "dispatch-job", 11111
140+
CallbackState.DISPATCHED,
141+
1000.0,
135142
)
136-
job_record = CallbackStateRecord(
137-
CallbackState.IN_PROGRESS, 1030.0, "default", 99999
143+
workflow_record = CallbackStateRecord(
144+
CallbackState.IN_PROGRESS,
145+
1030.0,
138146
)
139147
completed_record = CallbackStateRecord(
140-
CallbackState.COMPLETED, 1060.0, "default", 99999
148+
CallbackState.COMPLETED,
149+
1060.0,
141150
)
142151
self.mock_redis.get_callback_state.side_effect = [
143152
dispatch_record, # dispatch lookup
144-
job_record, # job state: in_progress
153+
workflow_record, # workflow state: in_progress
145154
completed_record, # re-read after set_callback_state
146155
]
147156

@@ -199,16 +208,17 @@ def test_redis_error_fetching_dispatch_record_rejected(self):
199208
handle(_cfg(), _body(status="completed"), verified_repo="org/repo")
200209
self.assertEqual(ctx.exception.status_code, 400)
201210

202-
def test_redis_error_fetching_job_record_proceeds(self):
203-
"""Redis error on job record lookup returns None; callback proceeds."""
211+
def test_redis_error_fetching_workflow_record_proceeds(self):
212+
"""Redis error on workflow record lookup returns None; callback proceeds."""
204213
dispatch_record = CallbackStateRecord(
205-
CallbackState.DISPATCHED, 1000.0, "dispatch-job", 11111
214+
CallbackState.DISPATCHED,
215+
1000.0,
206216
)
207-
# Three calls: dispatch lookup, job record lookup, re-read after set.
217+
# Three calls: dispatch lookup, workflow record lookup, re-read after set.
208218
self.mock_redis.get_callback_state.side_effect = [
209219
dispatch_record,
210-
None, # job record lookup returns None (get_callback_state catches RedisError)
211-
None, # updated_job_record re-read → early return with empty metrics
220+
None, # workflow record lookup returns None (get_callback_state catches RedisError)
221+
None, # updated_workflow_record re-read → early return with empty metrics
212222
]
213223

214224
handle(_cfg(), _body(status="completed"), verified_repo="org/repo")

0 commit comments

Comments
 (0)