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,261 @@
|
|
|
1
|
+
"""Daemon management for the Coda node server.
|
|
2
|
+
|
|
3
|
+
Provides functionality to run the server as a background daemon process
|
|
4
|
+
with PID file tracking, log file redirection, and signal-based shutdown.
|
|
5
|
+
|
|
6
|
+
The daemon process spawns uvicorn in the background, writes its PID to
|
|
7
|
+
``/tmp/coda-node.pid``, and redirects output to
|
|
8
|
+
``/tmp/coda-node.log``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DAEMON_LOG_PATH",
|
|
23
|
+
"DAEMON_PID_PATH",
|
|
24
|
+
"daemon_status",
|
|
25
|
+
"is_daemon_running",
|
|
26
|
+
"read_daemon_pid",
|
|
27
|
+
"start_daemon",
|
|
28
|
+
"stop_daemon",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_RUNTIME_DIR = Path(tempfile.gettempdir())
|
|
32
|
+
DAEMON_PID_PATH = _RUNTIME_DIR / "coda-node.pid"
|
|
33
|
+
DAEMON_LOG_PATH = _RUNTIME_DIR / "coda-node.log"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_daemon_pid() -> int | None:
|
|
37
|
+
"""Read the daemon PID from the PID file.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The PID as an integer, or ``None`` if the file does not exist
|
|
41
|
+
or contains invalid content.
|
|
42
|
+
"""
|
|
43
|
+
if not DAEMON_PID_PATH.exists():
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(DAEMON_PID_PATH.read_text().strip())
|
|
47
|
+
except (ValueError, OSError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _process_exists(pid: int) -> bool:
|
|
52
|
+
"""Check if a process with the given PID exists."""
|
|
53
|
+
try:
|
|
54
|
+
os.kill(pid, 0)
|
|
55
|
+
return True
|
|
56
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
57
|
+
# OSError covers Windows-specific errors like WinError 87
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_daemon_running() -> bool:
|
|
62
|
+
"""Check if the daemon process is currently running.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
``True`` if the PID file exists and the process is alive,
|
|
66
|
+
``False`` otherwise.
|
|
67
|
+
"""
|
|
68
|
+
pid = read_daemon_pid()
|
|
69
|
+
if pid is None:
|
|
70
|
+
return False
|
|
71
|
+
return _process_exists(pid)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def daemon_status() -> dict[str, object]:
|
|
75
|
+
"""Get detailed status information about the daemon.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A dictionary containing:
|
|
79
|
+
- ``running``: Whether the daemon is running.
|
|
80
|
+
- ``pid``: The daemon PID (or ``None``).
|
|
81
|
+
- ``pid_file``: Path to the PID file.
|
|
82
|
+
- ``log_file``: Path to the log file.
|
|
83
|
+
- ``log_exists``: Whether the log file exists.
|
|
84
|
+
"""
|
|
85
|
+
pid = read_daemon_pid()
|
|
86
|
+
running = pid is not None and _process_exists(pid)
|
|
87
|
+
return {
|
|
88
|
+
"running": running,
|
|
89
|
+
"pid": pid if running else None,
|
|
90
|
+
"pid_file": str(DAEMON_PID_PATH),
|
|
91
|
+
"log_file": str(DAEMON_LOG_PATH),
|
|
92
|
+
"log_exists": DAEMON_LOG_PATH.exists(),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def start_daemon(
|
|
97
|
+
host: str = "0.0.0.0",
|
|
98
|
+
port: int = 8080,
|
|
99
|
+
token: str | None = None,
|
|
100
|
+
) -> int:
|
|
101
|
+
"""Start the server as a background daemon process.
|
|
102
|
+
|
|
103
|
+
Spawns a new Python process running uvicorn with the FastAPI app,
|
|
104
|
+
detached from the current terminal. The process PID is written to
|
|
105
|
+
:data:`DAEMON_PID_PATH` and output is redirected to
|
|
106
|
+
:data:`DAEMON_LOG_PATH`.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
host: Bind address for the server.
|
|
110
|
+
port: Bind port for the server.
|
|
111
|
+
token: Optional node token for first-run provisioning.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The PID of the spawned daemon process.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
RuntimeError: If the daemon is already running.
|
|
118
|
+
"""
|
|
119
|
+
if is_daemon_running():
|
|
120
|
+
pid = read_daemon_pid()
|
|
121
|
+
raise RuntimeError(f"Daemon already running (PID {pid})")
|
|
122
|
+
|
|
123
|
+
# Clean up stale PID file if process is gone
|
|
124
|
+
if DAEMON_PID_PATH.exists():
|
|
125
|
+
DAEMON_PID_PATH.unlink()
|
|
126
|
+
|
|
127
|
+
# Build environment with optional token
|
|
128
|
+
env = os.environ.copy()
|
|
129
|
+
env["CODA_HOST"] = host
|
|
130
|
+
env["CODA_PORT"] = str(port)
|
|
131
|
+
if token:
|
|
132
|
+
env["CODA_NODE_TOKEN"] = token
|
|
133
|
+
|
|
134
|
+
# Build the command to run uvicorn directly
|
|
135
|
+
cmd = [
|
|
136
|
+
sys.executable,
|
|
137
|
+
"-m",
|
|
138
|
+
"uvicorn",
|
|
139
|
+
"coda_node.server.app:app",
|
|
140
|
+
"--host",
|
|
141
|
+
host,
|
|
142
|
+
"--port",
|
|
143
|
+
str(port),
|
|
144
|
+
"--log-level",
|
|
145
|
+
"warning",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
# Open log file for output redirection
|
|
149
|
+
log_handle = DAEMON_LOG_PATH.open("a", encoding="utf-8")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
if os.name == "nt":
|
|
153
|
+
# Windows: use creation flags to detach
|
|
154
|
+
creationflags = 0
|
|
155
|
+
creationflags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
156
|
+
creationflags |= getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
157
|
+
creationflags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
158
|
+
process = subprocess.Popen(
|
|
159
|
+
cmd,
|
|
160
|
+
stdout=log_handle,
|
|
161
|
+
stderr=subprocess.STDOUT,
|
|
162
|
+
stdin=subprocess.DEVNULL,
|
|
163
|
+
env=env,
|
|
164
|
+
creationflags=creationflags,
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
# POSIX: use start_new_session to detach from terminal
|
|
168
|
+
process = subprocess.Popen(
|
|
169
|
+
cmd,
|
|
170
|
+
stdout=log_handle,
|
|
171
|
+
stderr=subprocess.STDOUT,
|
|
172
|
+
stdin=subprocess.DEVNULL,
|
|
173
|
+
env=env,
|
|
174
|
+
start_new_session=True,
|
|
175
|
+
)
|
|
176
|
+
finally:
|
|
177
|
+
log_handle.close()
|
|
178
|
+
|
|
179
|
+
# Write PID file
|
|
180
|
+
DAEMON_PID_PATH.write_text(f"{process.pid}\n")
|
|
181
|
+
if os.name != "nt":
|
|
182
|
+
DAEMON_PID_PATH.chmod(0o644)
|
|
183
|
+
|
|
184
|
+
return process.pid
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def stop_daemon(timeout: float = 10.0) -> bool:
|
|
188
|
+
"""Stop the running daemon process.
|
|
189
|
+
|
|
190
|
+
Sends ``SIGTERM`` (or uses ``taskkill`` on Windows) to the daemon
|
|
191
|
+
and waits for it to exit. If the process does not exit within
|
|
192
|
+
*timeout* seconds, sends ``SIGKILL``.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
timeout: Seconds to wait for graceful shutdown before force-killing.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
``True`` if a daemon was found and stopped, ``False`` if no
|
|
199
|
+
daemon was running.
|
|
200
|
+
"""
|
|
201
|
+
pid = read_daemon_pid()
|
|
202
|
+
if pid is None:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
if not _process_exists(pid):
|
|
206
|
+
# Stale PID file, clean up
|
|
207
|
+
DAEMON_PID_PATH.unlink(missing_ok=True)
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
if os.name == "nt":
|
|
212
|
+
# Windows: use taskkill
|
|
213
|
+
subprocess.run(
|
|
214
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
timeout=timeout,
|
|
218
|
+
check=False,
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
# POSIX: send SIGTERM first
|
|
222
|
+
os.kill(pid, signal.SIGTERM)
|
|
223
|
+
|
|
224
|
+
# Wait for process to exit
|
|
225
|
+
deadline = time.monotonic() + timeout
|
|
226
|
+
while time.monotonic() < deadline:
|
|
227
|
+
if not _process_exists(pid):
|
|
228
|
+
break
|
|
229
|
+
time.sleep(0.1)
|
|
230
|
+
else:
|
|
231
|
+
# Process didn't exit, force kill
|
|
232
|
+
# Use getattr for SIGKILL since it doesn't exist on Windows
|
|
233
|
+
sigkill = getattr(signal, "SIGKILL", signal.SIGTERM)
|
|
234
|
+
os.kill(pid, sigkill)
|
|
235
|
+
time.sleep(0.5)
|
|
236
|
+
|
|
237
|
+
DAEMON_PID_PATH.unlink(missing_ok=True)
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
except (PermissionError, ProcessLookupError, subprocess.TimeoutExpired):
|
|
241
|
+
DAEMON_PID_PATH.unlink(missing_ok=True)
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def tail_daemon_log(lines: int = 50) -> str:
|
|
246
|
+
"""Return the last *lines* of the daemon log file.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
lines: Maximum number of lines to return.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The log tail as a string, or an empty string if the log
|
|
253
|
+
file does not exist.
|
|
254
|
+
"""
|
|
255
|
+
if not DAEMON_LOG_PATH.exists():
|
|
256
|
+
return ""
|
|
257
|
+
try:
|
|
258
|
+
all_lines = DAEMON_LOG_PATH.read_text().splitlines()
|
|
259
|
+
return "\n".join(all_lines[-lines:])
|
|
260
|
+
except OSError:
|
|
261
|
+
return ""
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Pluggable execution backend for quantum job processing.
|
|
2
|
+
|
|
3
|
+
The :class:`JobExecutor` protocol defines the single ``run`` method that
|
|
4
|
+
backends must implement. A backend can be anything from a hardware QPU
|
|
5
|
+
driver to a simulator -- the consumer doesn't care.
|
|
6
|
+
|
|
7
|
+
Executor resolution order (in :func:`load_executor`):
|
|
8
|
+
|
|
9
|
+
1. If ``CODA_EXECUTOR_FACTORY`` is set, import the dotted path and use
|
|
10
|
+
it as either a pre-built executor instance (has ``.run``) or a
|
|
11
|
+
factory callable.
|
|
12
|
+
2. If ``settings.device_config`` provides an ``executor_factory`` value,
|
|
13
|
+
use that.
|
|
14
|
+
3. Scan installed packages for the convention
|
|
15
|
+
``<pkg>.executor_factory:create_executor``. If exactly one match is
|
|
16
|
+
found, use it automatically. If multiple match, warn and fall back.
|
|
17
|
+
4. Fall back to :class:`NoopExecutor`, which returns a deterministic
|
|
18
|
+
all-zeros bitstring for every job.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import importlib
|
|
24
|
+
import importlib.util
|
|
25
|
+
import inspect
|
|
26
|
+
import logging
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from coda_node.errors import ExecutorError
|
|
31
|
+
from coda_node.server.ir import NativeGateIR
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from coda_node.server.config import Settings
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
__all__ = ["ExecutionResult", "JobExecutor", "NoopExecutor", "load_executor"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ExecutionResult:
|
|
43
|
+
"""Measurement outcome returned by an executor.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
counts: Mapping from bitstring (e.g. ``"010"``) to the number of
|
|
47
|
+
times that outcome was observed.
|
|
48
|
+
execution_time_ms: Wall-clock execution time in milliseconds.
|
|
49
|
+
shots_completed: Total shots actually executed (may differ from
|
|
50
|
+
the requested count if the backend applies shot budgeting).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
counts: dict[str, int]
|
|
54
|
+
execution_time_ms: float
|
|
55
|
+
shots_completed: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@runtime_checkable
|
|
59
|
+
class JobExecutor(Protocol):
|
|
60
|
+
"""Protocol that all execution backends must satisfy.
|
|
61
|
+
|
|
62
|
+
Implement a single async ``run`` method that accepts a validated IR
|
|
63
|
+
program and returns an :class:`ExecutionResult`.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
async def run(self, ir: NativeGateIR, shots: int) -> ExecutionResult:
|
|
67
|
+
"""Execute a quantum circuit and return measurement counts.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
ir: A validated native-gate intermediate representation.
|
|
71
|
+
shots: Number of measurement shots to perform.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
An :class:`ExecutionResult` with bitstring counts.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NoopExecutor:
|
|
79
|
+
"""Deterministic executor used for scaffolding and integration smoke tests."""
|
|
80
|
+
|
|
81
|
+
async def run(self, ir: NativeGateIR, shots: int) -> ExecutionResult:
|
|
82
|
+
"""Return an all-zeros result for every circuit."""
|
|
83
|
+
bitstring = "0" * len(ir.measurements)
|
|
84
|
+
return ExecutionResult(
|
|
85
|
+
counts={bitstring: shots},
|
|
86
|
+
execution_time_ms=0.0,
|
|
87
|
+
shots_completed=shots,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_attr(import_path: str) -> Any:
|
|
92
|
+
"""Import and return the attribute at *import_path* (``module:attr`` format)."""
|
|
93
|
+
module_name, sep, attr_name = import_path.partition(":")
|
|
94
|
+
if not sep or not module_name or not attr_name:
|
|
95
|
+
raise ExecutorError(
|
|
96
|
+
"CODA_EXECUTOR_FACTORY must look like 'package.module:factory_name'"
|
|
97
|
+
)
|
|
98
|
+
module = importlib.import_module(module_name)
|
|
99
|
+
return getattr(module, attr_name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _instantiate_factory(import_path: str, settings: Settings) -> JobExecutor:
|
|
103
|
+
"""Import and instantiate the executor at *import_path*."""
|
|
104
|
+
target = _load_attr(import_path)
|
|
105
|
+
if hasattr(target, "run"):
|
|
106
|
+
return cast(JobExecutor, target)
|
|
107
|
+
|
|
108
|
+
if not callable(target):
|
|
109
|
+
raise ExecutorError(f"Executor target {import_path!r} is not callable")
|
|
110
|
+
|
|
111
|
+
parameters = inspect.signature(target).parameters
|
|
112
|
+
executor = target(settings) if parameters else target()
|
|
113
|
+
if not hasattr(executor, "run"):
|
|
114
|
+
raise ExecutorError(f"Executor factory {import_path!r} did not return a runner")
|
|
115
|
+
return cast(JobExecutor, executor)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _discover_executor_factories() -> list[str]:
|
|
119
|
+
"""Scan installed packages for the ``create_executor`` convention.
|
|
120
|
+
|
|
121
|
+
Iterates over importable top-level packages and checks whether
|
|
122
|
+
``<pkg>.executor_factory`` exists with a callable ``create_executor``.
|
|
123
|
+
Returns a list of ``module:attr`` import paths.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
from importlib.metadata import packages_distributions
|
|
127
|
+
except ImportError:
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
candidates: list[str] = []
|
|
131
|
+
for pkg in packages_distributions():
|
|
132
|
+
if "." in pkg or pkg.startswith("_") or pkg == "coda_node":
|
|
133
|
+
continue
|
|
134
|
+
module_name = f"{pkg}.executor_factory"
|
|
135
|
+
try:
|
|
136
|
+
spec = importlib.util.find_spec(module_name)
|
|
137
|
+
except (ModuleNotFoundError, ValueError):
|
|
138
|
+
continue
|
|
139
|
+
if spec is None:
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
mod = importlib.import_module(module_name)
|
|
143
|
+
except Exception:
|
|
144
|
+
logger.warning(
|
|
145
|
+
"Failed to import executor_factory module %r, skipping",
|
|
146
|
+
module_name,
|
|
147
|
+
exc_info=True,
|
|
148
|
+
)
|
|
149
|
+
continue
|
|
150
|
+
factory = getattr(mod, "create_executor", None)
|
|
151
|
+
if factory is not None and callable(factory):
|
|
152
|
+
candidates.append(f"{module_name}:create_executor")
|
|
153
|
+
|
|
154
|
+
return candidates
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load_executor(settings: Settings) -> JobExecutor:
|
|
158
|
+
"""Resolve and instantiate the configured execution backend.
|
|
159
|
+
|
|
160
|
+
Resolution order:
|
|
161
|
+
|
|
162
|
+
1. If ``settings.executor_factory`` is set, import and use it
|
|
163
|
+
(pre-built executor or factory callable).
|
|
164
|
+
2. If ``settings.device_config`` provides an ``executor_factory``
|
|
165
|
+
value, import and use it.
|
|
166
|
+
3. Scan installed packages for a conventional
|
|
167
|
+
``<pkg>.executor_factory:create_executor`` factory. Use it if
|
|
168
|
+
exactly one match is found; warn and skip if multiple match.
|
|
169
|
+
4. Fall back to :class:`NoopExecutor` with a warning.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
settings: Runtime settings.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
An object satisfying the :class:`JobExecutor` protocol.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ExecutorError: If the configured executor cannot be loaded.
|
|
179
|
+
"""
|
|
180
|
+
if settings.executor_factory:
|
|
181
|
+
return _instantiate_factory(settings.executor_factory, settings)
|
|
182
|
+
|
|
183
|
+
discovered = _discover_executor_factories()
|
|
184
|
+
|
|
185
|
+
if len(discovered) == 1:
|
|
186
|
+
logger.info("Auto-discovered executor factory: %s", discovered[0])
|
|
187
|
+
return _instantiate_factory(discovered[0], settings)
|
|
188
|
+
|
|
189
|
+
if len(discovered) > 1:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"Multiple executor factories discovered (%s); "
|
|
192
|
+
"set CODA_EXECUTOR_FACTORY to choose one. Using NoopExecutor.",
|
|
193
|
+
", ".join(discovered),
|
|
194
|
+
)
|
|
195
|
+
return NoopExecutor()
|
|
196
|
+
|
|
197
|
+
logger.warning(
|
|
198
|
+
"No executor configured (set CODA_EXECUTOR_FACTORY); using NoopExecutor"
|
|
199
|
+
)
|
|
200
|
+
return NoopExecutor()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Periodic heartbeat reporter that keeps the QPU status "online".
|
|
2
|
+
|
|
3
|
+
The :class:`HeartbeatClient` runs a background loop that POSTs
|
|
4
|
+
authenticated status updates to the Coda cloud heartbeat endpoint
|
|
5
|
+
every *interval* seconds. If the QPU misses several consecutive
|
|
6
|
+
heartbeats the cloud marks it offline, so this loop is essential for
|
|
7
|
+
maintaining QPU visibility.
|
|
8
|
+
|
|
9
|
+
The heartbeat payload mirrors the node's readiness state: current job,
|
|
10
|
+
last job timestamp, and Redis health.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from coda_node.server.auth import sign_token
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from coda_node.server.consumer import RedisConsumer
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
__all__ = ["HeartbeatClient"]
|
|
29
|
+
|
|
30
|
+
_DEFAULT_INTERVAL = 30
|
|
31
|
+
_HTTP_TIMEOUT = 10.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HeartbeatClient:
|
|
35
|
+
"""Send periodic authenticated heartbeats to the Coda cloud.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
heartbeat_url: Full URL of the heartbeat endpoint.
|
|
39
|
+
qpu_id: QPU identifier used as the JWT ``sub`` claim.
|
|
40
|
+
jwt_private_key: PEM-encoded RSA private key for signing.
|
|
41
|
+
jwt_key_id: ``kid`` header value for the JWT.
|
|
42
|
+
consumer: The Redis consumer whose readiness state is reported.
|
|
43
|
+
interval: Seconds between heartbeat POSTs.
|
|
44
|
+
connectivity: Qubit-topology edge list (e.g. ``[[1, 0], [2, 1]]``).
|
|
45
|
+
For directed gate sets (CNOT) the order is ``[ctrl, tgt]``; for
|
|
46
|
+
symmetric gate sets (CZ, iSWAP, pCZ) the order is ignored by the
|
|
47
|
+
cloud compiler. Sent with every heartbeat so the cloud
|
|
48
|
+
compiler can perform topology-aware routing.
|
|
49
|
+
extra_headers: Additional HTTP headers merged into every
|
|
50
|
+
heartbeat request (e.g. deployment-protection bypass).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
heartbeat_url: str,
|
|
56
|
+
qpu_id: str,
|
|
57
|
+
jwt_private_key: str,
|
|
58
|
+
jwt_key_id: str,
|
|
59
|
+
consumer: RedisConsumer,
|
|
60
|
+
interval: int = _DEFAULT_INTERVAL,
|
|
61
|
+
connectivity: list[list[int]] | None = None,
|
|
62
|
+
extra_headers: dict[str, str] | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Initialise the heartbeat client with endpoint, credentials, and consumer."""
|
|
65
|
+
self._url = heartbeat_url
|
|
66
|
+
self._qpu_id = qpu_id
|
|
67
|
+
self._jwt_private_key = jwt_private_key
|
|
68
|
+
self._jwt_key_id = jwt_key_id
|
|
69
|
+
self._consumer = consumer
|
|
70
|
+
self._interval = interval
|
|
71
|
+
self._connectivity = connectivity
|
|
72
|
+
self._extra_headers = extra_headers or {}
|
|
73
|
+
self._running = False
|
|
74
|
+
self._client = httpx.AsyncClient(timeout=_HTTP_TIMEOUT)
|
|
75
|
+
|
|
76
|
+
async def _send(self) -> None:
|
|
77
|
+
"""POST a single heartbeat with current node status."""
|
|
78
|
+
token = sign_token(self._qpu_id, self._jwt_private_key, key_id=self._jwt_key_id)
|
|
79
|
+
body: dict[str, object] = {
|
|
80
|
+
"current_job": self._consumer.current_job_id,
|
|
81
|
+
"last_job_at": self._consumer.last_job_at,
|
|
82
|
+
"redis_healthy": self._consumer.redis_healthy,
|
|
83
|
+
"connectivity": self._connectivity,
|
|
84
|
+
}
|
|
85
|
+
response = await self._client.post(
|
|
86
|
+
self._url,
|
|
87
|
+
json=body,
|
|
88
|
+
headers={"Authorization": f"Bearer {token}", **self._extra_headers},
|
|
89
|
+
)
|
|
90
|
+
response.raise_for_status()
|
|
91
|
+
|
|
92
|
+
async def run(self) -> None:
|
|
93
|
+
"""Run the heartbeat loop until :meth:`stop` is called."""
|
|
94
|
+
self._running = True
|
|
95
|
+
logger.info(
|
|
96
|
+
"Heartbeat loop started (every %ds → %s)", self._interval, self._url
|
|
97
|
+
)
|
|
98
|
+
while self._running:
|
|
99
|
+
try:
|
|
100
|
+
await self._send()
|
|
101
|
+
logger.debug("Heartbeat sent to %s", self._url)
|
|
102
|
+
except Exception:
|
|
103
|
+
logger.warning("Heartbeat to %s failed", self._url, exc_info=True)
|
|
104
|
+
await asyncio.sleep(self._interval)
|
|
105
|
+
|
|
106
|
+
def stop(self) -> None:
|
|
107
|
+
"""Signal the heartbeat loop to exit after the current iteration."""
|
|
108
|
+
self._running = False
|
|
109
|
+
|
|
110
|
+
async def close(self) -> None:
|
|
111
|
+
"""Shut down the underlying HTTP connection pool."""
|
|
112
|
+
self.stop()
|
|
113
|
+
await self._client.aclose()
|