Client for the long-lived WARM synapse READ worker.
Manages a single persistent python -m zettelkasten.synapse.worker subprocess
and serializes queries to it over the worker's line protocol. Keeping the worker
warm across queries is the repeat-query win (see :mod:zettelkasten.synapse.worker):
only the first query pays the cold model load + index build; the rest reuse the
warm state.
Crash isolation is preserved: a native crash kills only the worker process, which
this client detects (the stdout pipe reaches EOF and poll() reports a signal)
and transparently respawns on the next query. Callers distinguish outcomes by
exception type:
- :class:
WorkerCrash — the worker died on a signal (native fault). The query
that triggered it should NOT be retried in-process; surface a clean error.
- :class:
WorkerTimeout — the query exceeded its budget (cold rebuild / hang);
the worker was killed.
- :class:
WorkerError — spawn/protocol failure. The caller should FALL BACK to
the one-shot subprocess path (the proven, always-cold route).
Queries are serialized through a single lock: the worker is single-threaded and
kglite is not thread-safe, so concurrent synapse reads queue rather than race.
Cross-store reads are heavy and infrequent, so this is the correct trade.
WorkerError
Bases: RuntimeError
A recoverable worker failure — the caller should fall back to one-shot.
Source code in zettelkasten/synapse/worker_client.py
| class WorkerError(RuntimeError):
"""A recoverable worker failure — the caller should fall back to one-shot."""
|
WorkerCrash
Bases: WorkerError
The worker died on a signal (almost certainly a native kglite/model2vec fault).
Source code in zettelkasten/synapse/worker_client.py
| class WorkerCrash(WorkerError):
"""The worker died on a signal (almost certainly a native kglite/model2vec fault)."""
def __init__(self, signal: int, trace: str = "") -> None:
super().__init__(f"synapse worker crashed with signal {signal} (native fault)")
self.signal = signal
self.trace = trace
|
WorkerTimeout
Bases: WorkerError
A query exceeded its wall-clock budget; the worker was killed.
Source code in zettelkasten/synapse/worker_client.py
| class WorkerTimeout(WorkerError):
"""A query exceeded its wall-clock budget; the worker was killed."""
def __init__(self, timeout: float) -> None:
super().__init__(f"synapse worker query exceeded {timeout:.0f}s")
self.timeout = timeout
|
SynapseWorker
Owns one persistent worker subprocess and serializes queries to it.
Source code in zettelkasten/synapse/worker_client.py
| class SynapseWorker:
"""Owns one persistent worker subprocess and serializes queries to it."""
def __init__(self, cmd: "list[str] | None" = None, *, spawn_timeout: float = 60.0) -> None:
# ``cmd`` is overridable so tests can point at a stub script; production
# uses the real worker module.
self._cmd = cmd or [sys.executable, "-m", "zettelkasten.synapse.worker"]
self._spawn_timeout = spawn_timeout
self._lock = threading.Lock()
self._proc: "subprocess.Popen[str] | None" = None
self._stdout_q: "queue.Queue[object]" = queue.Queue()
self._stderr_buf: "deque[str]" = deque(maxlen=100)
# ── lifecycle ────────────────────────────────────────────────────────────
def _alive(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def _spawn(self) -> None:
workspace = os.environ.get("ANGELO_WORKSPACE") or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
try:
proc = subprocess.Popen(
self._cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # line-buffered
cwd=workspace,
env=os.environ.copy(),
)
except Exception as exc: # noqa: BLE001 — spawn failure => fall back to one-shot
raise WorkerError(f"could not launch synapse worker: {exc}") from exc
self._proc = proc
self._stdout_q = queue.Queue()
self._stderr_buf = deque(maxlen=100)
threading.Thread(
target=self._drain_stdout, args=(proc.stdout, self._stdout_q), daemon=True
).start()
threading.Thread(
target=self._drain_stderr, args=(proc.stderr, self._stderr_buf), daemon=True
).start()
# Wait for the readiness line so a worker that fails to import surfaces as
# a clean spawn error (=> fall back) rather than hanging the first query.
deadline = time.monotonic() + self._spawn_timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._kill()
raise WorkerError("synapse worker did not become ready in time")
try:
line = self._stdout_q.get(timeout=remaining)
except queue.Empty:
self._kill()
raise WorkerError("synapse worker did not become ready in time")
if line is _EOF:
trace = "\n".join(self._stderr_buf)
self._kill()
raise WorkerError(f"synapse worker exited before ready:\n{trace}")
if isinstance(line, str) and line.strip() == READY_LINE:
return
# Ignore any other startup chatter on stdout.
@staticmethod
def _drain_stdout(pipe: Any, q: "queue.Queue[object]") -> None:
try:
for line in pipe:
q.put(line.rstrip("\n"))
except Exception: # noqa: BLE001 — pipe torn down on kill; nothing to do
pass
finally:
q.put(_EOF)
@staticmethod
def _drain_stderr(pipe: Any, buf: "deque[str]") -> None:
try:
for line in pipe:
buf.append(line.rstrip("\n"))
except Exception: # noqa: BLE001
pass
def _kill(self) -> None:
proc = self._proc
self._proc = None
if proc is None:
return
try:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=5)
except Exception: # noqa: BLE001 — best-effort teardown
pass
for stream in (proc.stdin, proc.stdout, proc.stderr):
try:
if stream is not None:
stream.close()
except Exception: # noqa: BLE001
pass
def shutdown(self) -> None:
"""Terminate the worker (e.g. on server exit). Idempotent."""
with self._lock:
self._kill()
# ── query ────────────────────────────────────────────────────────────────
def query(self, payload: dict, timeout: float) -> dict:
"""Run one search/frame ``payload`` on the warm worker; return its result dict.
Spawns the worker on first use (or after a crash). Raises
:class:`WorkerCrash` / :class:`WorkerTimeout` / :class:`WorkerError` on the
respective failure modes (see the module docstring); the worker is killed
before raising so the next call starts fresh.
"""
with self._lock:
if not self._alive():
self._spawn()
assert self._proc is not None and self._proc.stdin is not None
try:
self._proc.stdin.write(json.dumps(payload) + "\n")
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as exc:
self._kill()
raise WorkerError(f"synapse worker stdin closed: {exc}") from exc
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._kill()
raise WorkerTimeout(timeout)
try:
line = self._stdout_q.get(timeout=remaining)
except queue.Empty:
self._kill()
raise WorkerTimeout(timeout)
if line is _EOF:
rc = self._proc.poll() if self._proc else None
trace = "\n".join(self._stderr_buf)
self._kill()
if rc is not None and rc < 0:
raise WorkerCrash(-rc, trace)
raise WorkerError(f"synapse worker exited (rc={rc}):\n{trace}")
if isinstance(line, str) and line.startswith(RESULT_PREFIX):
try:
return json.loads(line[len(RESULT_PREFIX):])
except json.JSONDecodeError as exc:
self._kill()
raise WorkerError(f"synapse worker sent malformed result: {exc}") from exc
|
shutdown
Terminate the worker (e.g. on server exit). Idempotent.
Source code in zettelkasten/synapse/worker_client.py
| def shutdown(self) -> None:
"""Terminate the worker (e.g. on server exit). Idempotent."""
with self._lock:
self._kill()
|
query
query(payload: dict, timeout: float) -> dict
Run one search/frame payload on the warm worker; return its result dict.
Spawns the worker on first use (or after a crash). Raises
:class:WorkerCrash / :class:WorkerTimeout / :class:WorkerError on the
respective failure modes (see the module docstring); the worker is killed
before raising so the next call starts fresh.
Source code in zettelkasten/synapse/worker_client.py
| def query(self, payload: dict, timeout: float) -> dict:
"""Run one search/frame ``payload`` on the warm worker; return its result dict.
Spawns the worker on first use (or after a crash). Raises
:class:`WorkerCrash` / :class:`WorkerTimeout` / :class:`WorkerError` on the
respective failure modes (see the module docstring); the worker is killed
before raising so the next call starts fresh.
"""
with self._lock:
if not self._alive():
self._spawn()
assert self._proc is not None and self._proc.stdin is not None
try:
self._proc.stdin.write(json.dumps(payload) + "\n")
self._proc.stdin.flush()
except (BrokenPipeError, OSError) as exc:
self._kill()
raise WorkerError(f"synapse worker stdin closed: {exc}") from exc
deadline = time.monotonic() + timeout
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._kill()
raise WorkerTimeout(timeout)
try:
line = self._stdout_q.get(timeout=remaining)
except queue.Empty:
self._kill()
raise WorkerTimeout(timeout)
if line is _EOF:
rc = self._proc.poll() if self._proc else None
trace = "\n".join(self._stderr_buf)
self._kill()
if rc is not None and rc < 0:
raise WorkerCrash(-rc, trace)
raise WorkerError(f"synapse worker exited (rc={rc}):\n{trace}")
if isinstance(line, str) and line.startswith(RESULT_PREFIX):
try:
return json.loads(line[len(RESULT_PREFIX):])
except json.JSONDecodeError as exc:
self._kill()
raise WorkerError(f"synapse worker sent malformed result: {exc}") from exc
|