Skip to content

ADR-0029: Offload Graph Transfer Spool I/O off the Event Loop

Status

Accepted — supersedes the "Worker-thread starvation under high concurrency" risk of ADR-0025 § Risks (that bullet only; the rest of ADR-0025 stays in effect).

Context

ADR-0025 introduced AsyncBackendSyncAdapter and, in its § Risks, flagged that all sync callers share one event loop and that a CPU-bound backend could stall sibling calls. It named a concrete example — "_graph_transfer's chunk hashing" — and deferred any mitigation "to backend authors via asyncio.to_thread for hot CPU paths."

That example is wrong on inspection: the Graph transfer path computes no hash. There is no hashlib use in aio/backends/_graph/transfer.py; the only "hashes" the backend touches are the server-provided file.hashes metadata in items.py, which it copies, never computes.

The real loop-stall source in the Graph backend is blocking spool file I/O, not hashing. Two paths drain or replay a SpooledTemporaryFile that rolls over from memory to disk once it exceeds its threshold:

  • the read path's SharePoint range-fallback (_spooled_window) re-reads the full entity into a spool and serves the requested window from it (write / seek / read); and
  • the write path's unknown-length upload (spool_content + _upload_chunks) drains the iterator into a spool and replays it in chunks (write / tell / seek, then seek + read of up to one upload_chunk_size — 10 MiB by default).

Once spilled to disk, each of those calls is synchronous file I/O. Run directly on the event-loop thread, a large disk-spilled transfer head-of-line-blocks every sibling coroutine for the duration of the disk op — the exact "sibling-call stall" ADR-0025 anticipated, reached through I/O rather than CPU.

Decision

Dispatch the blocking spool I/O in transfer.py through asyncio.to_thread: the range-fallback _spooled_window write/seek/read, the spool_content write/tell/seek, and the _upload_chunks per-chunk seek+read (bundled into one _seek_read hop). The spool objects are accessed sequentially under await, so single-threaded offload is safe; nothing else holds the reader concurrently.

This realises ADR-0025's own prescription ("asyncio.to_thread for hot paths") in-backend rather than leaving it deferred, and corrects the mischaracterised example.

Consequences

  • No event-loop stall on disk-spilled Graph transfers. A large fallback read or unknown-length upload no longer blocks sibling coroutines while the spool spills to or replays from disk.
  • One thread hop per spool op. Small, in-memory spools (below the rollover threshold) pay a cheap hop they did not before; the trade is deliberate — the cost is bounded and the win is loop liveness under the disk-spill case the offload exists for.
  • ADR-0025's chunk-hashing example is retired. The risk it described is real but I/O-bound, and is now mitigated here rather than deferred.
  • Connection-pool capacity is the orthogonal half of BK-290 and is addressed by documentation (the client_options={"limits": httpx.Limits(...)} knob), not this ADR.

References

  • Supersedes: ADR-0025 § Risks, "Worker-thread starvation under high concurrency" bullet only
  • Backlog: BK-290
  • Specs: GR-015 (range-fallback spool), GR-019 (upload-session spool)