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,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()