coda-node 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,625 @@
1
+ """Redis Streams consumer that reads jobs and dispatches them to an executor.
2
+
3
+ The consumer uses a Redis consumer group so that multiple workers can
4
+ share a single stream. On startup it performs crash recovery for
5
+ messages that were claimed but never acknowledged (e.g. after an
6
+ unclean shutdown).
7
+
8
+ Message lifecycle:
9
+
10
+ 1. ``XREADGROUP`` blocks for new messages on ``qpu:<qpu_id>:jobs``.
11
+ 2. Each message is deserialized into a :class:`NativeGateIR` and handed
12
+ to the :class:`~coda_node.server.executor.JobExecutor`.
13
+ 3. The result (or error) is posted back via
14
+ :class:`~coda_node.server.webhook.WebhookClient`.
15
+ 4. The message is ``XACK``-ed regardless of success or failure so it
16
+ does not re-enter the pending list.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import inspect
23
+ import logging
24
+ import time
25
+ from collections.abc import Awaitable, Mapping
26
+ from datetime import UTC, datetime
27
+ from typing import TYPE_CHECKING, TypeVar, cast
28
+
29
+ import redis.asyncio as aioredis
30
+
31
+ from coda_node.server.ir import NativeGateIR
32
+ from coda_node.server.webhook import WebhookPayload
33
+
34
+ if TYPE_CHECKING:
35
+ from coda_node.server.executor import ExecutionResult, JobExecutor
36
+ from coda_node.server.webhook import WebhookClient
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ __all__ = ["RedisConsumer"]
41
+
42
+ T = TypeVar("T")
43
+
44
+ _BACKOFF_BASE = 1.0
45
+ _BACKOFF_FACTOR = 2.0
46
+ _BACKOFF_MAX = 60.0
47
+ _PENDING_RECHECK_SECS = 60.0
48
+ _CANCEL_POLL_INTERVAL_SECS = 0.25
49
+ _BATCH_READ_COUNT = 100
50
+
51
+
52
+ class _JobCancelledWhileExecuting(Exception):
53
+ """Raised when the cloud cancels a job after execution has started."""
54
+
55
+
56
+ async def _await_if_needed(value: T | Awaitable[T]) -> T:
57
+ """Await *value* if it is awaitable, otherwise return it directly."""
58
+ if inspect.isawaitable(value):
59
+ return await cast(Awaitable[T], value)
60
+ return value
61
+
62
+
63
+ class RedisConsumer:
64
+ """Consume jobs from a Coda Redis stream and dispatch to an executor.
65
+
66
+ When the executor supports ``batch_run``, the consumer automatically
67
+ reads up to ``_BATCH_READ_COUNT`` messages per iteration and
68
+ dispatches them as a single batch for compilation and execution.
69
+
70
+ Args:
71
+ redis: An async Redis client instance.
72
+ runner: The execution backend that processes each job.
73
+ webhook: Client for posting results back to the Coda cloud.
74
+ qpu_id: QPU identifier used to derive stream and group names.
75
+ consumer_name: Name of this consumer within the group (for
76
+ distinguishing workers in a multi-process deployment).
77
+ crash_recovery_threshold_ms: Minimum idle time (in ms) before a
78
+ pending message is considered abandoned and eligible for
79
+ reprocessing.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ redis: aioredis.Redis,
85
+ runner: JobExecutor,
86
+ webhook: WebhookClient,
87
+ qpu_id: str,
88
+ consumer_name: str = "worker-0",
89
+ crash_recovery_threshold_ms: int = 60_000,
90
+ ) -> None:
91
+ """Initialise the consumer with its Redis client, executor, and webhook."""
92
+ self._redis = redis
93
+ self._runner = runner
94
+ self._webhook = webhook
95
+ self._qpu_id = qpu_id
96
+ self._consumer_name = consumer_name
97
+ self._crash_recovery_threshold_ms = crash_recovery_threshold_ms
98
+ batch_run = getattr(runner, "batch_run", None)
99
+ self._can_batch = callable(batch_run)
100
+ self._stream = f"qpu:{qpu_id}:jobs"
101
+ self._group = f"qpu:{qpu_id}:workers"
102
+ self._running = False
103
+ self._idle_event = asyncio.Event()
104
+ self._idle_event.set()
105
+ self._pending_batch_delivery: asyncio.Task[None] | None = None
106
+ self.current_job_id: str | None = None
107
+ self.last_job_at: str | None = None
108
+ self.redis_healthy = True
109
+
110
+ @staticmethod
111
+ def _decode_fields(fields: Mapping[object, object]) -> dict[str, str]:
112
+ """Normalise byte keys/values returned when *decode_responses* is off."""
113
+ return {
114
+ (k.decode() if isinstance(k, bytes) else str(k)): (
115
+ v.decode() if isinstance(v, bytes) else str(v)
116
+ )
117
+ for k, v in fields.items()
118
+ }
119
+
120
+ async def setup(self) -> None:
121
+ """Create the consumer group if it does not already exist.
122
+
123
+ The stream is created automatically (``mkstream=True``) so that
124
+ the consumer can start before any jobs have been enqueued.
125
+ """
126
+ try:
127
+ await self._redis.xgroup_create(
128
+ name=self._stream,
129
+ groupname=self._group,
130
+ id="0",
131
+ mkstream=True,
132
+ )
133
+ except aioredis.ResponseError as exc:
134
+ if "BUSYGROUP" not in str(exc):
135
+ raise
136
+
137
+ async def recover_pending(self) -> int:
138
+ """Re-process messages left pending after an unclean shutdown.
139
+
140
+ Because the query is scoped to ``consumername=self._consumer_name``,
141
+ these are exclusively *our own* unacknowledged messages. There is
142
+ no risk of stealing work from a peer, so every pending message is
143
+ recovered regardless of idle time.
144
+
145
+ Returns:
146
+ The number of messages that were reprocessed.
147
+ """
148
+ recovered = 0
149
+ pending = await self._redis.xpending_range(
150
+ name=self._stream,
151
+ groupname=self._group,
152
+ consumername=self._consumer_name,
153
+ min="-",
154
+ max="+",
155
+ count=100,
156
+ )
157
+
158
+ for entry in pending:
159
+ message_id = entry["message_id"]
160
+ messages = await self._redis.xrange(
161
+ self._stream, min=message_id, max=message_id
162
+ )
163
+ if messages:
164
+ _, fields = messages[0]
165
+ await self._process_message(message_id, fields)
166
+ recovered += 1
167
+
168
+ return recovered
169
+
170
+ async def consume_loop(self) -> None:
171
+ """Run the main consume loop until :meth:`stop` is called.
172
+
173
+ On each iteration the loop blocks (up to 5 s) for new messages
174
+ via ``XREADGROUP``. When the executor supports ``batch_run``,
175
+ reads up to ``_BATCH_READ_COUNT`` messages and dispatches them
176
+ together.
177
+
178
+ Connection errors trigger exponential backoff (1 s -> 60 s max);
179
+ unexpected errors trigger a 1 s backoff. The loop sets
180
+ :attr:`redis_healthy` to reflect the current connection state.
181
+ """
182
+ self._running = True
183
+ await self.setup()
184
+ await self.recover_pending()
185
+
186
+ read_count = _BATCH_READ_COUNT if self._can_batch else 1
187
+ consecutive_failures = 0
188
+ last_pending_check = time.monotonic()
189
+ while self._running:
190
+ try:
191
+ messages = await self._redis.xreadgroup(
192
+ groupname=self._group,
193
+ consumername=self._consumer_name,
194
+ streams={self._stream: ">"},
195
+ count=read_count,
196
+ block=5000,
197
+ )
198
+ self.redis_healthy = True
199
+ consecutive_failures = 0
200
+
201
+ if self._pending_batch_delivery is not None:
202
+ await self._pending_batch_delivery
203
+ self._pending_batch_delivery = None
204
+
205
+ if not messages:
206
+ now = time.monotonic()
207
+ if now - last_pending_check >= _PENDING_RECHECK_SECS:
208
+ await self.recover_pending()
209
+ last_pending_check = now
210
+ continue
211
+
212
+ for _stream_name, stream_messages in messages:
213
+ if self._can_batch and len(stream_messages) > 1:
214
+ delivery = await self._process_batch(stream_messages)
215
+ if delivery is not None:
216
+ self._pending_batch_delivery = delivery
217
+ else:
218
+ for message_id, fields in stream_messages:
219
+ await self._process_message(message_id, fields)
220
+ except (ConnectionError, OSError, aioredis.ConnectionError):
221
+ self.redis_healthy = False
222
+ consecutive_failures += 1
223
+ delay = min(
224
+ _BACKOFF_BASE * (_BACKOFF_FACTOR ** (consecutive_failures - 1)),
225
+ _BACKOFF_MAX,
226
+ )
227
+ logger.warning(
228
+ "Redis connection lost (attempt %d), retrying in %.1fs",
229
+ consecutive_failures,
230
+ delay,
231
+ )
232
+ await asyncio.sleep(delay)
233
+ except Exception:
234
+ logger.exception("Consumer loop error")
235
+ await asyncio.sleep(1)
236
+
237
+ def stop(self) -> None:
238
+ """Signal the consume loop to exit after the current iteration."""
239
+ self._running = False
240
+
241
+ async def drain(self, timeout: float = 30.0) -> bool:
242
+ """Wait for any in-flight job to finish, then stop.
243
+
244
+ Args:
245
+ timeout: Maximum seconds to wait for the current job.
246
+
247
+ Returns:
248
+ ``True`` if drained cleanly, ``False`` if the timeout
249
+ expired with a job still running.
250
+ """
251
+ self.stop()
252
+ try:
253
+ await asyncio.wait_for(self._idle_event.wait(), timeout=timeout)
254
+ if self._pending_batch_delivery is not None:
255
+ await asyncio.wait_for(self._pending_batch_delivery, timeout=timeout)
256
+ self._pending_batch_delivery = None
257
+ return True
258
+ except TimeoutError:
259
+ return False
260
+
261
+ async def _safe_hset(self, key: str, mapping: dict[str, str]) -> bool:
262
+ """Attempt HSET, return False on connection error."""
263
+ try:
264
+ await _await_if_needed(self._redis.hset(key, mapping=mapping))
265
+ return True
266
+ except (ConnectionError, OSError, aioredis.ConnectionError):
267
+ logger.warning("Redis unavailable, skipping status update for %s", key)
268
+ return False
269
+
270
+ async def _safe_xack(self, message_id: str) -> bool:
271
+ """Attempt XACK, return False on connection error."""
272
+ try:
273
+ await self._redis.xack(self._stream, self._group, message_id)
274
+ return True
275
+ except (ConnectionError, OSError, aioredis.ConnectionError):
276
+ logger.warning(
277
+ "Redis unavailable, could not XACK %s (will be retried via crash recovery)",
278
+ message_id,
279
+ )
280
+ return False
281
+
282
+ async def _has_cancel_signal(self, job_id: str) -> bool:
283
+ """Check whether the cloud has set a cancellation flag for *job_id*."""
284
+ cancel_raw = await _await_if_needed(
285
+ self._redis.get(f"qpu:job:cancelled:{job_id}")
286
+ )
287
+ return cancel_raw is not None
288
+
289
+ async def _get_job_status(self, job_id: str) -> str | None:
290
+ """Return the current state string for *job_id*, or ``None``."""
291
+ status_raw = await _await_if_needed(
292
+ self._redis.hget(f"qpu:job:{job_id}:status", "state")
293
+ )
294
+ return (
295
+ status_raw.decode()
296
+ if isinstance(status_raw, bytes)
297
+ else str(status_raw)
298
+ if status_raw is not None
299
+ else None
300
+ )
301
+
302
+ async def _mark_job_cancelled(self, job_id: str, message_id: str) -> None:
303
+ """Write a ``cancelled`` status record for *job_id*."""
304
+ await self._safe_hset(
305
+ f"qpu:job:{job_id}:status",
306
+ {
307
+ "state": "cancelled",
308
+ "cancelled_at": datetime.now(UTC).isoformat(),
309
+ "message_id": message_id,
310
+ "qpu_id": self._qpu_id,
311
+ },
312
+ )
313
+
314
+ async def _request_runner_cancel(self, job_id: str) -> None:
315
+ """Invoke the executor's optional ``cancel_current_job`` hook."""
316
+ cancel_current_job = getattr(self._runner, "cancel_current_job", None)
317
+ if not callable(cancel_current_job):
318
+ return
319
+
320
+ try:
321
+ await _await_if_needed(cancel_current_job())
322
+ except Exception:
323
+ logger.warning(
324
+ "Executor cancel hook failed for job %s", job_id, exc_info=True
325
+ )
326
+
327
+ async def _run_job_with_cancellation(
328
+ self, job_id: str, ir: NativeGateIR, shots: int
329
+ ) -> ExecutionResult:
330
+ """Execute a job while polling for cancellation signals.
331
+
332
+ Runs the executor in a task and periodically checks Redis for a
333
+ cancel flag. If cancellation is detected mid-flight the executor's
334
+ ``cancel_current_job`` hook is called (when available), the task is
335
+ cancelled, and :class:`_JobCancelledWhileExecuting` is raised.
336
+ """
337
+ run_task = asyncio.create_task(self._runner.run(ir, shots))
338
+ try:
339
+ while True:
340
+ done, _pending = await asyncio.wait(
341
+ {run_task},
342
+ timeout=_CANCEL_POLL_INTERVAL_SECS,
343
+ )
344
+ if run_task in done:
345
+ result = await run_task
346
+ if await self._has_cancel_signal(job_id):
347
+ raise _JobCancelledWhileExecuting
348
+ return result
349
+
350
+ if await self._has_cancel_signal(job_id):
351
+ logger.info("Cancellation detected for executing job %s", job_id)
352
+ await self._request_runner_cancel(job_id)
353
+ run_task.cancel()
354
+ try:
355
+ await run_task
356
+ except asyncio.CancelledError:
357
+ pass
358
+ except Exception:
359
+ logger.info(
360
+ "Executor raised after cancellation for job %s",
361
+ job_id,
362
+ exc_info=True,
363
+ )
364
+ raise _JobCancelledWhileExecuting
365
+ finally:
366
+ if not run_task.done():
367
+ run_task.cancel()
368
+
369
+ async def _process_batch(
370
+ self, stream_messages: list[tuple[str, Mapping[object, object]]]
371
+ ) -> asyncio.Task[None] | None:
372
+ """Translate, execute, and report a batch of jobs.
373
+
374
+ On success, returns a background task that delivers webhooks and
375
+ ``XACK``s (parallelized). The caller should await that task before
376
+ starting the next hardware batch.
377
+
378
+ On any batch-level failure, falls back to processing each
379
+ message individually and returns ``None``.
380
+ """
381
+ parsed: list[tuple[str, str, str, NativeGateIR, int]] = []
382
+ for message_id, fields in stream_messages:
383
+ decoded = self._decode_fields(fields)
384
+ try:
385
+ job_id = decoded["job_id"]
386
+ callback_url = decoded["callback_url"]
387
+ except KeyError as exc:
388
+ logger.error(
389
+ "Malformed stream message %s (missing %s), skipping. Keys: %s",
390
+ message_id,
391
+ exc,
392
+ sorted(decoded.keys()),
393
+ )
394
+ await self._safe_xack(message_id)
395
+ continue
396
+
397
+ status = await self._get_job_status(job_id)
398
+ if status in {"completed", "cancelled"}:
399
+ await self._safe_xack(message_id)
400
+ continue
401
+
402
+ if await self._has_cancel_signal(job_id):
403
+ await self._mark_job_cancelled(job_id, message_id)
404
+ await self._safe_xack(message_id)
405
+ continue
406
+
407
+ try:
408
+ ir = NativeGateIR.from_json(decoded["ir_json"])
409
+ shots = int(decoded["shots"])
410
+ except Exception as exc:
411
+ logger.warning(
412
+ "Skipping malformed message %s in batch: %s", message_id, exc
413
+ )
414
+ await self._safe_xack(message_id)
415
+ continue
416
+ parsed.append((message_id, job_id, callback_url, ir, shots))
417
+
418
+ if not parsed:
419
+ return None
420
+
421
+ self._idle_event.clear()
422
+ n = len(parsed)
423
+ logger.info("Executing batch of %d jobs", n)
424
+
425
+ jobs = [(ir, shots) for _, _, _, ir, shots in parsed]
426
+ for _, job_id, _, _, _ in parsed:
427
+ await self._safe_hset(
428
+ f"qpu:job:{job_id}:status",
429
+ {
430
+ "state": "executing",
431
+ "started_at": datetime.now(UTC).isoformat(),
432
+ "qpu_id": self._qpu_id,
433
+ },
434
+ )
435
+
436
+ batch_run = getattr(self._runner, "batch_run", None)
437
+ if not callable(batch_run):
438
+ logger.error(
439
+ "Executor does not support batch_run at dispatch time; "
440
+ "falling back to single-job processing"
441
+ )
442
+ for message_id, fields in stream_messages:
443
+ await self._process_message(message_id, fields)
444
+ return None
445
+
446
+ try:
447
+ results = await batch_run(jobs)
448
+ except Exception as exc:
449
+ logger.error(
450
+ "Batch execution failed (%d jobs): %s — falling back to "
451
+ "single-job processing",
452
+ n,
453
+ exc,
454
+ exc_info=True,
455
+ )
456
+ for message_id, fields in stream_messages:
457
+ await self._process_message(message_id, fields)
458
+ return None
459
+
460
+ async def _deliver_single_batch_result(
461
+ message_id: str,
462
+ job_id: str,
463
+ callback_url: str,
464
+ result: ExecutionResult,
465
+ completed_at: str,
466
+ ) -> None:
467
+ """Deliver one result from the batch, honouring late cancellation."""
468
+ if await self._has_cancel_signal(job_id):
469
+ await self._mark_job_cancelled(job_id, message_id)
470
+ await self._safe_xack(message_id)
471
+ return
472
+
473
+ await self._safe_hset(
474
+ f"qpu:job:{job_id}:status",
475
+ {"state": "completed", "completed_at": completed_at},
476
+ )
477
+
478
+ if await self._has_cancel_signal(job_id):
479
+ await self._mark_job_cancelled(job_id, message_id)
480
+ await self._safe_xack(message_id)
481
+ return
482
+
483
+ payload = WebhookPayload(
484
+ job_id=job_id,
485
+ status="completed",
486
+ counts=result.counts,
487
+ execution_time_ms=result.execution_time_ms,
488
+ shots_completed=result.shots_completed,
489
+ )
490
+ try:
491
+ await self._webhook.send_result(callback_url, payload)
492
+ except Exception:
493
+ logger.error(
494
+ "Failed to send webhook for job %s in batch",
495
+ job_id,
496
+ exc_info=True,
497
+ )
498
+ await self._safe_xack(message_id)
499
+
500
+ async def _deliver_batch_results() -> None:
501
+ """Fan out webhook delivery for all results in parallel."""
502
+ now_iso = datetime.now(UTC).isoformat()
503
+ try:
504
+ await asyncio.gather(
505
+ *(
506
+ _deliver_single_batch_result(
507
+ message_id,
508
+ job_id,
509
+ callback_url,
510
+ result,
511
+ now_iso,
512
+ )
513
+ for (message_id, job_id, callback_url, _, _), result in zip(
514
+ parsed, results, strict=True
515
+ )
516
+ ),
517
+ )
518
+ self.last_job_at = now_iso
519
+ logger.info("Batch of %d jobs completed", n)
520
+ finally:
521
+ self._idle_event.set()
522
+
523
+ return asyncio.create_task(_deliver_batch_results())
524
+
525
+ async def _process_message(
526
+ self, message_id: str, fields: Mapping[object, object]
527
+ ) -> None:
528
+ """Deserialize, execute, and report a single job from the stream.
529
+
530
+ Skips jobs already marked as completed or cancelled. On success,
531
+ sends a result webhook; on failure, sends an error webhook. The
532
+ message is always acknowledged so it does not re-enter the
533
+ pending list. Redis connection errors during status updates are
534
+ logged but do not prevent webhook delivery.
535
+ """
536
+ decoded_fields = self._decode_fields(fields)
537
+ try:
538
+ job_id = decoded_fields["job_id"]
539
+ callback_url = decoded_fields["callback_url"]
540
+ except KeyError as exc:
541
+ logger.error(
542
+ "Malformed stream message %s (missing %s), skipping. Keys: %s",
543
+ message_id,
544
+ exc,
545
+ sorted(decoded_fields.keys()),
546
+ )
547
+ await self._safe_xack(message_id)
548
+ return
549
+
550
+ status = await self._get_job_status(job_id)
551
+ if status in {"completed", "cancelled"}:
552
+ await self._safe_xack(message_id)
553
+ return
554
+
555
+ if await self._has_cancel_signal(job_id):
556
+ await self._mark_job_cancelled(job_id, message_id)
557
+ await self._safe_xack(message_id)
558
+ return
559
+
560
+ self._idle_event.clear()
561
+ self.current_job_id = job_id
562
+ await self._safe_hset(
563
+ f"qpu:job:{job_id}:status",
564
+ {
565
+ "state": "executing",
566
+ "started_at": datetime.now(UTC).isoformat(),
567
+ "message_id": message_id,
568
+ "qpu_id": self._qpu_id,
569
+ },
570
+ )
571
+
572
+ try:
573
+ ir = NativeGateIR.from_json(decoded_fields["ir_json"])
574
+ shots = int(decoded_fields["shots"])
575
+ result = await self._run_job_with_cancellation(job_id, ir, shots)
576
+ if await self._has_cancel_signal(job_id):
577
+ raise _JobCancelledWhileExecuting
578
+
579
+ await self._safe_hset(
580
+ f"qpu:job:{job_id}:status",
581
+ {
582
+ "state": "completed",
583
+ "completed_at": datetime.now(UTC).isoformat(),
584
+ },
585
+ )
586
+ if await self._has_cancel_signal(job_id):
587
+ raise _JobCancelledWhileExecuting
588
+
589
+ payload = WebhookPayload(
590
+ job_id=job_id,
591
+ status="completed",
592
+ counts=result.counts,
593
+ execution_time_ms=result.execution_time_ms,
594
+ shots_completed=result.shots_completed,
595
+ )
596
+ await self._webhook.send_result(callback_url, payload)
597
+ self.last_job_at = datetime.now(UTC).isoformat()
598
+ except _JobCancelledWhileExecuting:
599
+ await self._mark_job_cancelled(job_id, message_id)
600
+ logger.info("Cancelled executing job %s before webhook delivery", job_id)
601
+ return
602
+ except Exception as exc:
603
+ if await self._has_cancel_signal(job_id):
604
+ await self._mark_job_cancelled(job_id, message_id)
605
+ logger.info(
606
+ "Cancelled executing job %s while handling terminal state", job_id
607
+ )
608
+ return
609
+ logger.error("Job %s failed: %s", job_id, exc, exc_info=True)
610
+ await self._safe_hset(
611
+ f"qpu:job:{job_id}:status",
612
+ {
613
+ "state": "failed",
614
+ "error": str(exc)[:500],
615
+ "failed_at": datetime.now(UTC).isoformat(),
616
+ },
617
+ )
618
+ try:
619
+ await self._webhook.send_error(callback_url, job_id, str(exc)[:500])
620
+ except Exception:
621
+ logger.exception("Failed to send error webhook for job %s", job_id)
622
+ finally:
623
+ await self._safe_xack(message_id)
624
+ self.current_job_id = None
625
+ self._idle_event.set()