Repository navigation
feat: add pipelined exchange operators - #2670
Conversation
Add PipelinedShuffleWriterExec, PipelinedShuffleReaderExec and the per-executor PipelinedRegistry that connects them, with their protobuf messages, serde, the FetchPipelinedPartition Flight action and the ballista.execution.mode and ballista.pipelined.* settings. Nothing plans these operators yet. The scheduler rejects jobs with ballista.execution.mode = pipelined, and executors answer pipelined fetches with unimplemented, until later changes wire them in.
…pers - Lease pooled connections for the life of a remote pipelined stream, and build unpooled clients with new_ballista_client, as the shuffle reader does. Encode Flight tickets with encode_protobuf. - Compute the writer's routing once, drop unused builder and getter methods, and let JoinSet's drop abort the remaining inputs. - Track unclaimed queues with a counter instead of rescanning every partition on each open, share one bounded set for closed jobs and stages, and stop cloning per batch in the queue stream. - Keep a broadcast reader whole when restricting partitions, forget a consumer's waiting slot when it times out, and document the minimums of the pipelined settings.
15e27d3 to
e6272fa
Compare
Each remote stream checked out its own connection, so a wide pipelined job opened one per producer and consumer task pair and overflowed the executors' listen queues. A reader partition now multiplexes its streams to an executor onto one connection, capped at 64 streams per connection.
tonic's `serve` binds through the standard library, which listens with a backlog of 128. A pipelined job connects from every reader task on every executor when it starts, so an executor can get thousands of connections within a second and overflow both its accept and SYN queues. Overflowed connections wait for a SYN retransmit, and once the kernel falls back to SYN cookies, a client whose first segments were dropped is reset as soon as it sends more. The reader then sees `ConnectionReset` on a stream it never opened, with nothing logged on either side. Bind the Flight server through `create_grpc_server_incoming`, which the executor's gRPC server already uses, and have that helper listen with `GrpcServerConfig::listen_backlog`, 4096 by default. The kernel caps the value at `net.core.somaxconn`, which is 4096 on Linux 5.4 and later.
… server answered A wide pipelined job opens its remote streams in one burst, and a connection the producer's executor could not accept in time can be reset by the kernel as the reader starts sending. The reader saw that as a `ConnectionReset` on a stream it had not opened, and since pipelined jobs do not retry tasks, one such reset failed the job. `fetch_pipelined_partition` now reports a failure of the connection itself, before the server answered, as `GrpcConnectionError`, and keeps `GrpcActionError` for anything the server answered. The reader retries the former on a new connection, `io_retries_times` times with `io_retry_wait_time_ms` between attempts, the settings the blocking shuffle reader already uses. A failed connection is dropped from its slot, so the other streams of the partition that share it connect again rather than fail on it. An error the server answered, including the producer's own failure, is still final: the producer may already have handed the partition to the failed stream. Tests cover a stream open that is reset on its first connection and served on its second, and a server-side failure that is not retried.
A pipelined reader opened a connection per reader partition to each producer executor, so a job opened executors x tasks x stage boundaries connections to every executor at once, enough to overflow its listen queue however large the queue is. The executor's client pool now keeps one shared connection per (host, port, config), which the streams of every task on the executor multiplex over, so an executor holds one connection to each other executor. A connection that fails before the server answers is discarded from the pool, and the open is retried on a new one. Sharing is safe because of how the windows are set. A stream nobody is reading holds up to its stream window of data on the connection, and if stalled streams could use up the connection window they would starve the streams something is waiting on. The shared connection therefore advertises the largest connection window HTTP/2 allows and a small stream window, `ballista.pipelined.stream_window_bytes` (512 KiB by default), so stalled streams hold at most one window each and a connection carries over 4000 streams before any could starve the rest. The executor's server is explicitly left without a limit on concurrent streams, since a stream the client had to queue behind a limit would leave its producer blocked on a full queue. `BallistaClientPool` gains a `shared` method with a default implementation, so existing pools keep compiling.
|
Thanks @andygrove, splitting this out of #2657 makes it much easier to review. Notes below, most important first. Items marked (unverified) are expectations I did not test. Correctness and design
Reuse and simplification
Efficiency
Tests
|
…hange Address review feedback on the pipelined exchange operators. Fixes: - `PipelinedRegistry::remove_job` and `remove_stage` now close the queues no consumer has claimed. The writer keeps its `ProducerOutputs` alive for the life of the task, so dropping the registry's reference alone left a producer blocked on a full queue until the claim timeout. The test now holds the outputs the way the writer does. - A producer stays registered until its stage or job is removed, instead of being forgotten once every queue is claimed. A second open of a queue now fails at once with "already being read" rather than waiting out the registration timeout for a producer that will never register. - A pipelined stream open is retried only when the connection failed before the server answered. Once the response headers have arrived the executor may have handed the partition to that stream, so a failure on the first message is reported as a server failure and not retried. - `is_transport_failure` relies on the status's source chain alone. A reset arrives as `Cancelled` and a refused connection as `Unavailable`, both with the transport error as source; the string match on "transport error" and the bare `Unavailable` check were redundant. Simplifications: - `ProducerKey` is replaced by the existing `TaskKey`, which gains a `Display` impl. - The writer threads one `Outputs` struct through its input tasks in place of an `Arc<HashMap>` and an `Arc<AtomicUsize>`; outputs are dense slots, so hash routing indexes them directly, and the hash coalescers live beside them rather than as an `Option` on every output. `Routing::Hash` holds the `Partitioning` instead of rebuilding it per input. `finish_outputs` becomes `Outputs::finish`. - `open_remote` reuses the blocking reader's `with_retry`. - `BallistaClient::do_get_stream` holds the `do_get` and schema-read sequence that `execute_do_get` and `fetch_pipelined_partition` both repeated. - `serialize_hash_partitioning` serves both the sort shuffle writer and the pipelined writer. - The producer's failure is stored once, on `ProducerOutputs`; a broadcast partition records only whether it finished or failed. - `try_new_broadcast` becomes `new_broadcast`, since it cannot fail. `ProducerOutputs::schema` and `producer_count` are no longer public. - Reader metrics are `local_partitions` and `remote_partitions`, and the writer's send time is `write_time`, matching the blocking operators. - `DefaultBallistaClientPool::shared` returns an open connection without taking the connecting lock. - The `io-util` tokio feature moves to dev-dependencies. - Registry tests share a `queue` helper, and the remote-read tests keep their listeners open instead of reusing a port a dropped listener had. Docs note where the claim timeout is measured from and the plan shapes that relies on, that `max_concurrent_streams(None)` is tonic's default, and that the stream window also bounds a stream's throughput per round trip.
|
Addressed in 0d53dfc. Fixed: unclaimed queues are closed on cleanup (the writer keeps the outputs alive, as you said), a second open of a claimed queue fails at once, only a failure before the server answers is retried, and Filed as follow-ups rather than grow this PR: memory accounting #2672, shared slot bounding #2673, writer send path and the |
|
Thanks for the quick turnaround, the new head reads much tighter. A second pass on Prototyped (untested)
Net for the prototype is about 190 added and 250 removed lines across 6 files. Proposed, not prototyped
|
…ections - Mark a shared client discarded with a flag; the pool skips discarded connections and evicts their slots, so no discard closure or generation counter is needed - Hold a producer's registry slot and broadcast replay state in tokio::sync::watch channels instead of waiter lists and Notify - Store the producer failure in a OnceLock - Remember closed jobs and stages in one bounded map keyed by job - Keep each output's coalescer inside the output and derive whether all consumers are gone instead of counting - Take the registry from the session config only, dropping with_registry from the pipelined writer and reader
|
Addressed in 353e907, thanks for the prototype notes. All six prototyped items are in: |
comphead
left a comment
There was a problem hiding this comment.
Thanks @andygrove lets give it a try
|
Thanks @comphead ! |
Which issue does this PR close?
Part of #2652. This is the second of a series of smaller PRs split out of #2657 (pipelined execution). It is independent of #2669.
Rationale for this change
In pipelined execution, every task of a job runs at once and stages stream record batches to each other instead of exchanging shuffle files. This PR adds the exchange itself: the operators at either end of a pipelined stage boundary, the per-executor registry that connects them, and the connection handling a wide job needs. Nothing plans these operators yet, so this PR can't change how existing jobs run.
#2657 describes the whole feature, its design notes and SF1000 results.
What changes are included in this PR?
execution_plans/pipelined/PipelinedRegistryholds a bounded queue for each output partition of each running producer task, keyed by job, stage and task.PipelinedShuffleWriterExecis a stage root. It drives every input partition and routes batches into the queues: hash-partitioned, passthrough or broadcast.ballista.pipelined.registration_timeout_seconds.PipelinedShuffleReaderExecis a stage leaf. It drains all of its producers concurrently: directly from the registry when the producer is on the same executor, and over Flight otherwise. Reading concurrently is what keeps bounded queues free of deadlock for tree-shaped plans.BallistaClientPool::shared), and the streams of every task on the executor multiplex over it, so an executor holds one connection to each other executor however wide the job. The first version opened a connection per reader partition and producer executor, which at TPC-H SF1000 on 32 executors asked each executor to accept thousands of connections within a second and overflowed its listen queue.ballista.pipelined.stream_window_bytes(512 KiB by default), so stalled streams hold at most one window each and a connection carries over 4000 streams before any could starve the rest.ballista.client.io_retries_timestimes withballista.client.io_retry_wait_time_msbetween attempts.BallistaClient::fetch_pipelined_partitionreports those asGrpcConnectionError, and anything the server answered asGrpcActionError, which is never retried: the producer may already have handed the partition to the failed stream.tests.rsruns writers against readers in-process.create_grpc_server_incominglistens withGrpcServerConfig::listen_backlog, 4096 by default, instead of the 128 the standard library gives tonic'sserve, and the Flight server now binds through it like the gRPC server already did. The kernel caps the value atnet.core.somaxconn.create_grpc_serverexplicitly sets no limit on streams per connection, since a stream the client had to queue behind a limit would leave its producer blocked on a full queue.PipelinedShuffleWriterExecNode,PipelinedShuffleReaderExecNode,PipelinedPartitionLocation, and theFetchPipelinedPartitionaction, plusBallistaClient::fetch_pipelined_partition. The Flight proxy inserving.rsforwards the new action.ballista.execution.mode(blockingorpipelined),ballista.pipelined.queue_capacity,ballista.pipelined.registration_timeout_secondsandballista.pipelined.stream_window_bytes. They are documented in a new section of the config reference.ballista.execution.mode = pipelined("not supported yet").FetchPipelinedPartitionwithunimplemented.How are these changes tested?
Are there any user-facing changes?
There are four new session settings, documented in the config reference. Setting
ballista.execution.mode = pipelinedcurrently makes the job fail with a "not supported yet" error. The default behavior is unchanged.Two public types change:
GrpcServerConfiggains alisten_backlogfield, which affects code that builds it by struct literal rather thanDefault, andBallistaClientPoolgains asharedmethod with a default implementation, so existing implementations keep compiling. The executor's gRPC and Flight listeners get a 4096 backlog in every mode.