Skip to content

Queued fetch cannot stream bytes from an out-of-process worker #806

Description

@sroussey

The fetch-streaming design (spec §4) names three byte sources. Two shipped in #790. The third has no mechanism at either end.

source condition status
inline config.queue === false works — response.body.getReader()
queued, in-process worker handle.onStream is a function works — job stream channel
queued, out-of-process worker otherwise no mechanism

Why the third one has nothing

The job stream channel is in-memory. publishStreamChunk / subscribeToStream are implemented only by InMemoryQueueStorage, which is same-process by definition — the other files matching those names are the interfaces, the telemetry passthrough, the wrapper, and tests. So on SQLite or Postgres, handle.onStream is undefined and FetchUrlTask settles the job and yields whatever the output carries. For response_type: "stream" that is { metadata } with no bytes at all.

An out-of-process worker therefore cannot return a large body without materializing it into the job output — which is precisely what this work exists to avoid.

Why it hasn't bitten

Nothing runs a genuinely out-of-process worker today. sec — the only real consumer — calls client.attach(server), so its client holds a server reference, handle.onStream is defined, and it takes the in-process path.

What was designed and not built

  • Worker side. The worker was to sink bytes to a shared backing and return a CacheRef on the port. Nothing calls saveOutputStreamPort or makeCacheRef outside task-graph's own cache layer and its tests.
  • Client side. JobQueueClientOptions.outputStreamResolver exists (JobQueueClient.ts:102, stored at :117/:178, read at :883) and is injected nowhere.
  • Consumer half. Stream-based response handling for FetchUrlTask #790 built the code that reads such a ref back and re-emits it as deltas, with loud failures for a missing cache or an unresolvable entry. It is correct against the contract it states and genuinely tested — but only against refs the tests construct by hand, and it is unreachable in production. It is being removed in Stream-based response handling for FetchUrlTask #790 rather than maintained as a speculative half whose design choices (e.g. preferring the deterministic cache slot over private) cannot be validated without a real worker. The loud-failure posture was right and worth keeping if this is rebuilt.

What building it would need

  1. A shared backing both processes can reach. This is the hard part, and it is a scoping decision before it is a plumbing one: RunPrivateCacheRepo rejects foreign-run refs by design, so a cross-process ref has to live in the deterministic tier or carry an explicit shared scope. Getting this wrong reopens the cross-run leak that tier exists to prevent.
  2. A worker-side sink in the job execution path that writes the port's bytes and returns the ref.
  3. Injecting outputStreamResolver at the composition root.
  4. Failure modes decided up front: no shared cache configured, entry evicted, entry written by a run this cache does not serve. Each wants a distinct message — they have opposite operator actions.
  5. Backpressure. The in-process path paces the producer through awaited dispatch. A ref-based path has no natural gate: the worker writes to the backing at full speed regardless of whether anything is reading. That may be fine — the backing is the buffer — but it should be a decision, not an accident.

Alternative worth weighing first

Implementing a durable stream channel (publishStreamChunk / subscribeToStream on the SQLite and Postgres queue storages) would make the existing in-process path work across processes unchanged, and reuse the backpressure and ordering that already exist. That is plausibly less new surface than the ref path, at the cost of putting body chunks through the queue storage. Worth comparing before building the ref design, rather than treating the ref approach as settled because it was written down first.

When this matters

The first deployment that runs workers in a separate process or on a separate machine against a durable queue. Until then the in-process path covers every real caller.

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