Skip to content

GraphServer pickles snapshots under the global command lock, stalling a starting graph #257

Description

@cboulay

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions