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.
- coda_node/__init__.py +18 -0
- coda_node/errors.py +44 -0
- coda_node/py.typed +0 -0
- coda_node/server/__init__.py +9 -0
- coda_node/server/app.py +191 -0
- coda_node/server/auth.py +192 -0
- coda_node/server/cli.py +360 -0
- coda_node/server/config.py +300 -0
- coda_node/server/consumer.py +625 -0
- coda_node/server/daemon.py +261 -0
- coda_node/server/executor.py +200 -0
- coda_node/server/heartbeat.py +113 -0
- coda_node/server/ir.py +182 -0
- coda_node/server/qasm.py +377 -0
- coda_node/server/webhook.py +161 -0
- coda_node/vpn/__init__.py +49 -0
- coda_node/vpn/guard.py +424 -0
- coda_node/vpn/service.py +593 -0
- coda_node-0.1.0.dist-info/METADATA +348 -0
- coda_node-0.1.0.dist-info/RECORD +23 -0
- coda_node-0.1.0.dist-info/WHEEL +4 -0
- coda_node-0.1.0.dist-info/entry_points.txt +3 -0
- coda_node-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|