Summary
GraphServer builds and pickles snapshot payloads while holding its single global _command_lock. That lock also serializes process registration and pub/sub edge setup, so a client that snapshots a graph while it is starting up stalls the graph's own units — far enough to exhaust a publisher's buffer ring and emit under subscriber backpressure!.
Where
ezmsg/core/graphserver.py (v3.9.0):
async def _handle_session_snapshot_request(self, writer):
async with self._command_lock:
snapshot = self._snapshot()
snapshot_bytes = pickle.dumps(snapshot) # serialization inside the lock
...
async def _handle_session_settings_snapshot_request(self, writer):
async with self._command_lock:
snapshot = {addr: self._settings_state[addr].value for addr in sorted(self._settings_state)}
snapshot_bytes = pickle.dumps(snapshot) # ditto
...
_handle_session_settings_events_request has the same shape. The same _command_lock is taken by, among others, _handle_process_register_request and _handle_session_edge_request — i.e. exactly what every process and every pub/sub edge in a starting graph needs.
Observed behaviour
An external client (our experiment-control UI) polls GraphContext.snapshot() to discover when a launched pipeline has registered. While a 2-hub acquisition pipeline was coming up:
- the pipeline's own hub boundary logged
.../HUB1/__relay_out_OUTPUT_SIGNAL/OUTPUT under subscriber backpressure! within the first ~2 seconds
- the client's own
snapshot() / settings_snapshot() round-trip exceeded a 5 s deadline and was cancelled, leaving revert: Could not clear GraphContext session state: GraphContext session task is not running
Stopping the polling — the only change — removed both, on the heaviest configuration we have. The publisher's ring is 16 buffers at ~100 msg/s in that configuration, so the subscriber stall was ≥160 ms; it is a discrete block, not a throughput deficit. Reducing our poll rate from 100 ms to a 0.25→1.0 s backoff (≈21 snapshots down to ≈4 in the same window) is what we shipped as a mitigation, but any client snapshotting a busy graph can still cause this.
Suggested fix
Copy the state under the lock and serialize outside it:
async def _handle_session_snapshot_request(self, writer):
async with self._command_lock:
snapshot = self._snapshot() # cheap, consistent
snapshot_bytes = pickle.dumps(snapshot) # expensive, no lock held
writer.write(uint64_to_bytes(len(snapshot_bytes)))
writer.write(snapshot_bytes)
writer.write(Command.COMPLETE.value)
_snapshot() already returns a GraphSnapshot built from server state, so if it (and the _settings_state comprehension) produce values that are not mutated afterwards, the pickle needs no lock at all. If those payloads can alias mutable server state, the copy under the lock should be deepened rather than the pickle moved back in.
A cheaper, complementary option: cache the pickled snapshot and invalidate it on topology change, so repeated polls from a discovery client cost one serialization rather than N.
Environment
- ezmsg 3.9.0, Python 3.12, macOS (Apple silicon)
- Graph service in its own process; pipeline processes connect to it over
graph_address
- Reproduces on the graph's startup window specifically, when registration/edge traffic contends with snapshot requests
Happy to open a PR for the copy-under-lock change if the approach looks right.
Summary
GraphServerbuilds and pickles snapshot payloads while holding its single global_command_lock. That lock also serializes process registration and pub/sub edge setup, so a client that snapshots a graph while it is starting up stalls the graph's own units — far enough to exhaust a publisher's buffer ring and emitunder subscriber backpressure!.Where
ezmsg/core/graphserver.py(v3.9.0):_handle_session_settings_events_requesthas the same shape. The same_command_lockis taken by, among others,_handle_process_register_requestand_handle_session_edge_request— i.e. exactly what every process and every pub/sub edge in a starting graph needs.Observed behaviour
An external client (our experiment-control UI) polls
GraphContext.snapshot()to discover when a launched pipeline has registered. While a 2-hub acquisition pipeline was coming up:.../HUB1/__relay_out_OUTPUT_SIGNAL/OUTPUT under subscriber backpressure!within the first ~2 secondssnapshot()/settings_snapshot()round-trip exceeded a 5 s deadline and was cancelled, leavingrevert: Could not clear GraphContext session state: GraphContext session task is not runningStopping the polling — the only change — removed both, on the heaviest configuration we have. The publisher's ring is 16 buffers at ~100 msg/s in that configuration, so the subscriber stall was ≥160 ms; it is a discrete block, not a throughput deficit. Reducing our poll rate from 100 ms to a 0.25→1.0 s backoff (≈21 snapshots down to ≈4 in the same window) is what we shipped as a mitigation, but any client snapshotting a busy graph can still cause this.
Suggested fix
Copy the state under the lock and serialize outside it:
_snapshot()already returns aGraphSnapshotbuilt from server state, so if it (and the_settings_statecomprehension) produce values that are not mutated afterwards, the pickle needs no lock at all. If those payloads can alias mutable server state, the copy under the lock should be deepened rather than the pickle moved back in.A cheaper, complementary option: cache the pickled snapshot and invalidate it on topology change, so repeated polls from a discovery client cost one serialization rather than N.
Environment
graph_addressHappy to open a PR for the copy-under-lock change if the approach looks right.