Skip to content

Commit 8f7bb99

Browse files
committed
Implement workflow-level queue_time tracking and update Redis helper for job dependencies
1 parent 3e85a5d commit 8f7bb99

5 files changed

Lines changed: 195 additions & 4 deletions

File tree

aws/lambda/cross_repo_ci_relay/callback/callback_handler.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,15 @@ def _update_state_and_compute_metrics(
121121
122122
Writes IN_PROGRESS or COMPLETED state (with the current timestamp), then
123123
reads back the stored record to compute:
124-
- ``queue_time``: dispatch → in_progress (set on "in_progress" callbacks)
125-
- ``execution_time``: in_progress → completed (set on "completed" callbacks)
124+
- ``queue_time``: dispatch → first job's in_progress in the run (set on
125+
"in_progress" callbacks). Workflow-level, not
126+
per-job: a run's jobs commonly wait on one another
127+
(e.g. tests waiting on a build job), but they all
128+
share one dispatch timestamp, so every job in the run
129+
is given the same queue_time, anchored to whichever
130+
job started first. See ``record_workflow_started``.
131+
- ``execution_time``: in_progress → completed (set on "completed"
132+
callbacks). Stays per-job: each job's own duration.
126133
127134
Both metrics default to None when the required prior state is unavailable
128135
(e.g. Redis cache miss or rerun without matching prior record).
@@ -175,9 +182,21 @@ def _update_state_and_compute_metrics(
175182
return ci_metrics
176183

177184
if state == CallbackState.IN_PROGRESS:
185+
# Anchor queue_time to the first job in the run to start, not to this
186+
# job's own in_progress time -- otherwise a job that waits on an
187+
# earlier one (e.g. tests waiting on a build) would have that wait
188+
# folded into its queue_time.
189+
workflow_start_ts = redis_helper.record_workflow_started(
190+
config,
191+
delivery_id,
192+
verified_repo,
193+
run_id,
194+
run_attempt,
195+
updated_workflow_record.timestamp,
196+
)
178197
ci_metrics["queue_time"] = _safe_delta(
179198
dispatch_record.timestamp,
180-
updated_workflow_record.timestamp,
199+
workflow_start_ts,
181200
"queue_time",
182201
)
183202
else:

aws/lambda/cross_repo_ci_relay/tests/test_callback_handler.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ def setUp(self):
6868
self.mock_redis = self.patcher_redis.start()
6969
self.mock_redis.create_client.return_value = MagicMock()
7070

71+
# Default: workflow-level queue_time passthrough, i.e. this job is
72+
# treated as the first (and only) job in its run. Tests exercising
73+
# multiple jobs in one run override this explicitly.
74+
self.mock_redis.record_workflow_started.side_effect = lambda *a, **kw: a[5]
75+
7176
# Setup default: dispatch exists, workflow state is None (in_progress not yet reported)
7277
def default_get_state(
7378
cfg,
@@ -188,6 +193,56 @@ def test_execution_time_calculated_from_state_records(self):
188193
_, trusted_arg, _ = self.mock_hud.call_args[0]
189194
self.assertEqual(trusted_arg["ci_metrics"]["execution_time"], 30.0)
190195

196+
# --- queue_time is workflow-level (anchored to the first job to start) ---
197+
198+
def test_queue_time_uses_first_jobs_start_for_a_later_job(self):
199+
"""A later job (e.g. "test", waiting on a "build" job) gets queue_time
200+
anchored to the first job's start, not its own (later) in_progress
201+
time -- otherwise the wait on "build" would inflate its queue_time."""
202+
dispatch_record = CallbackStateRecord(CallbackState.DISPATCHED, 1000.0, {})
203+
# This job reports in_progress at 1200 (long after dispatch), but the
204+
# "build" job already claimed the workflow-start marker at 1030.
205+
self.mock_redis.get_callback_state.side_effect = [
206+
dispatch_record, # dispatch lookup
207+
None, # workflow state: not yet set
208+
CallbackStateRecord(CallbackState.IN_PROGRESS, 1200.0, {}), # re-read
209+
]
210+
self.mock_redis.record_workflow_started.side_effect = None
211+
self.mock_redis.record_workflow_started.return_value = 1030.0
212+
213+
handle(
214+
_cfg(),
215+
_body(status="in_progress", job_name="test"),
216+
verified_repo="org/repo",
217+
)
218+
219+
_, trusted_arg, _ = self.mock_hud.call_args[0]
220+
# dispatch (1000) -> first job's start (1030), NOT this job's own 1200.
221+
self.assertEqual(trusted_arg["ci_metrics"]["queue_time"], 30.0)
222+
223+
def test_execution_time_unaffected_by_workflow_level_queue_time(self):
224+
"""execution_time stays purely job-level (in_progress -> completed for
225+
this same job) and completed callbacks never touch the workflow-start
226+
marker -- that's only relevant to in_progress/queue_time."""
227+
dispatch_record = CallbackStateRecord(CallbackState.DISPATCHED, 1000.0, {})
228+
workflow_record = CallbackStateRecord(CallbackState.IN_PROGRESS, 1200.0, {})
229+
completed_record = CallbackStateRecord(CallbackState.COMPLETED, 1260.0, {})
230+
self.mock_redis.get_callback_state.side_effect = [
231+
dispatch_record,
232+
workflow_record,
233+
completed_record,
234+
]
235+
236+
handle(
237+
_cfg(),
238+
_body(status="completed", job_name="test"),
239+
verified_repo="org/repo",
240+
)
241+
242+
_, trusted_arg, _ = self.mock_hud.call_args[0]
243+
self.assertEqual(trusted_arg["ci_metrics"]["execution_time"], 60.0)
244+
self.mock_redis.record_workflow_started.assert_not_called()
245+
191246
# --- HUD 4xx propagates (5xx is swallowed inside forward_to_hud) ---
192247

193248
def test_hud_4xx_propagates(self):
@@ -293,6 +348,7 @@ def setUp(self):
293348

294349
self.patcher_redis = patch("callback.callback_handler.redis_helper")
295350
self.mock_redis = self.patcher_redis.start()
351+
self.mock_redis.record_workflow_started.side_effect = lambda *a, **kw: a[5]
296352

297353
def _get_state(
298354
cfg,
@@ -521,6 +577,7 @@ def test_nightly_callback_skips_redis(self):
521577
self.mock_redis.set_callback_state.assert_not_called()
522578
self.mock_redis.add_in_progress_tracker.assert_not_called()
523579
self.mock_redis.remove_in_progress_tracker.assert_not_called()
580+
self.mock_redis.record_workflow_started.assert_not_called()
524581

525582
def test_nightly_callback_has_no_timing_metrics(self):
526583
handle(_cfg(), self._nightly_body(), verified_repo="org/repo")

aws/lambda/cross_repo_ci_relay/tests/test_redis_helper.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
create_client,
1313
get_cached_yaml,
1414
get_callback_state,
15+
record_workflow_started,
1516
set_cached_yaml,
1617
set_callback_state,
1718
)
@@ -267,6 +268,62 @@ def get_side_effect(
267268
)
268269

269270

271+
class TestWorkflowStart(unittest.TestCase):
272+
"""queue_time is workflow-level: the first job in a run to report
273+
in_progress claims the shared start marker; later jobs in the same run
274+
read that same value back instead of recording their own (later) time."""
275+
276+
def setUp(self):
277+
redis_helper._cached_client = None
278+
redis_helper._cached_client_url = None
279+
280+
def test_first_job_claims_and_returns_its_own_timestamp(self):
281+
"""SET NX succeeds when no other job has claimed the marker yet."""
282+
client = MagicMock()
283+
client.set.return_value = True
284+
285+
result = record_workflow_started(
286+
_cfg(), "del-123", "org/repo", 99999, 1, 1030.0, client=client
287+
)
288+
289+
self.assertEqual(result, 1030.0)
290+
client.set.assert_called_once_with(
291+
"crcr:workflow_start:del-123:org/repo:99999:1",
292+
1030.0,
293+
nx=True,
294+
ex=3600,
295+
)
296+
client.get.assert_not_called()
297+
298+
def test_later_job_reads_back_first_jobs_timestamp(self):
299+
"""SET NX fails once another job already claimed the marker; the
300+
later job's own (later) timestamp is discarded for the stored one."""
301+
client = MagicMock()
302+
client.set.return_value = None
303+
client.get.return_value = "1030.0"
304+
305+
result = record_workflow_started(
306+
_cfg(), "del-123", "org/repo", 99999, 1, 1200.0, client=client
307+
)
308+
309+
self.assertEqual(result, 1030.0)
310+
client.get.assert_called_once_with(
311+
"crcr:workflow_start:del-123:org/repo:99999:1"
312+
)
313+
314+
def test_redis_error_on_set_falls_back_to_own_timestamp(self):
315+
"""A Redis outage degrades to per-job queue_time rather than blocking
316+
the callback: the caller's own timestamp is returned unchanged."""
317+
client = MagicMock()
318+
client.set.side_effect = redis_lib.exceptions.RedisError("boom")
319+
320+
result = record_workflow_started(
321+
_cfg(), "del-123", "org/repo", 99999, 1, 1200.0, client=client
322+
)
323+
324+
self.assertEqual(result, 1200.0)
325+
326+
270327
class TestRateLimit(unittest.TestCase):
271328
def setUp(self):
272329
redis_helper._cached_client = None

aws/lambda/cross_repo_ci_relay/utils/redis_helper.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
_IN_PROGRESS_ZSET = "crcr:in_progress"
2323
_DISPATCH_JOB_PREFIX = "crcr:dispatch_job:"
2424
_CHECK_RUN_WANTED_PREFIX = "crcr:check_run_wanted:"
25+
_WORKFLOW_START_PREFIX = "crcr:workflow_start:"
2526
_cached_client: redis_lib.Redis | None = None
2627
_cached_client_url: str | None = None
2728

@@ -466,6 +467,62 @@ def set_callback_state(
466467
raise
467468

468469

470+
def _workflow_start_key(
471+
delivery_id: str, downstream_repo: str, run_id: int, run_attempt: int
472+
) -> str:
473+
"""Redis key for the workflow-level "first job started" marker.
474+
475+
Keyed by delivery_id + repo + run_id + run_attempt, with no job_name:
476+
every job within one run shares this single timestamp.
477+
"""
478+
return (
479+
f"{_WORKFLOW_START_PREFIX}{delivery_id}:{downstream_repo}:"
480+
f"{run_id}:{run_attempt}"
481+
)
482+
483+
484+
def record_workflow_started(
485+
config: RelayConfig,
486+
delivery_id: str,
487+
downstream_repo: str,
488+
run_id: int,
489+
run_attempt: int,
490+
timestamp: float,
491+
client: redis_lib.Redis | None = None,
492+
) -> float:
493+
"""Claim or read the workflow-level "first job started" timestamp.
494+
495+
A run's jobs commonly form a dependency chain (e.g. a build job that later
496+
test jobs wait on), but every job shares the same dispatch timestamp. If
497+
queue_time were computed per job against that shared dispatch timestamp,
498+
later jobs would have the earlier jobs' build/wait time folded into their
499+
queue_time. Instead, queue_time is measured once per workflow run, from
500+
dispatch to the first job's in_progress: this uses SET NX so the first
501+
caller's timestamp "wins" and is stored; every later job in the same run
502+
reads that same stored value back instead of recording its own (later)
503+
in_progress time.
504+
505+
Falls back to returning ``timestamp`` unchanged on Redis errors or a
506+
malformed stored value, so a transient outage degrades to (rare) per-job
507+
queue_time rather than blocking the callback.
508+
"""
509+
key = _workflow_start_key(delivery_id, downstream_repo, run_id, run_attempt)
510+
try:
511+
if client is None:
512+
client = create_client(config)
513+
won = client.set(key, timestamp, nx=True, ex=config.crcr_status_ttl)
514+
if won:
515+
return timestamp
516+
existing = client.get(key)
517+
return float(existing) if existing is not None else timestamp
518+
except RedisError:
519+
logger.exception("record_workflow_started: redis error key=%s", key)
520+
return timestamp
521+
except (TypeError, ValueError):
522+
logger.exception("record_workflow_started: malformed stored value key=%s", key)
523+
return timestamp
524+
525+
469526
def _in_progress_member(
470527
delivery_id: str,
471528
downstream_repo: str,

aws/lambda/cross_repo_ci_relay/webhook/event_handler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ def _dispatch_one(
4545
# Set dispatch state with timestamp to prove valid webhook occurred.
4646
# Keyed by delivery_id + repo + run_id + run_attempt.
4747
# Uses DISPATCH_RUN_ID/DISPATCH_RUN_ATTEMPT sentinels for repo-level dispatch.
48-
# Timestamp is used for queue_time calculation (dispatch → in_progress).
48+
# Timestamp is used for queue_time calculation (dispatch → first job's
49+
# in_progress in the run; see redis_helper.record_workflow_started).
4950
redis_helper.set_callback_state(
5051
config,
5152
client_payload["delivery_id"],

0 commit comments

Comments
 (0)