Skip to main content

datafusion_distributed/protocol/grpc/
worker_service.rs

1use super::errors::{datafusion_error_to_tonic_status, map_status_to_datafusion_error};
2use super::generated::worker as pb;
3use super::metrics_proto::df_metrics_set_to_proto;
4use super::spawn_select_all::spawn_select_all;
5
6use crate::common::{deserialize_uuid, now_ns};
7use crate::protocol::grpc::{ObservabilityServiceImpl, ObservabilityServiceServer};
8use crate::{
9    CoordinatorToWorkerMsg, DistributedConfig, ExecuteTaskRequest, LoadInfo, MaybeEncoded,
10    ProducerHead, SetPlanRequest, TaskKey, TaskMetrics, WorkUnitBatch, WorkUnitFeedDeclaration,
11    WorkUnitMsg, Worker, WorkerResolver, WorkerToCoordinatorMsg,
12};
13
14use arrow_flight::FlightData;
15use arrow_flight::encode::{DictionaryHandling, FlightDataEncoder, FlightDataEncoderBuilder};
16use arrow_flight::error::FlightError;
17use arrow_select::dictionary::garbage_collect_any_dictionary;
18use async_trait::async_trait;
19use datafusion::arrow::array::{Array, AsArray, RecordBatch, RecordBatchOptions};
20use datafusion::arrow::ipc::CompressionType;
21use datafusion::arrow::ipc::writer::IpcWriteOptions;
22use datafusion::common::DataFusionError;
23use datafusion::execution::SendableRecordBatchStream;
24use futures::stream::BoxStream;
25use futures::{StreamExt, TryStreamExt};
26use prost::Message;
27use std::sync::Arc;
28use tonic::{Request, Response, Status, Streaming};
29use url::Url;
30
31const RECORD_BATCH_BUFFER_SIZE: usize = 2;
32
33impl Worker {
34    /// Converts this [Worker] into a [`WorkerServiceServer`] with high default message size limits.
35    ///
36    /// This is a convenience method that wraps the endpoint in a [`WorkerServiceServer`] and
37    /// configures it with `max_decoding_message_size(usize::MAX)` and
38    /// `max_encoding_message_size(usize::MAX)` to avoid message size limitations for internal
39    /// communication.
40    ///
41    /// You can further customize the returned server by chaining additional tonic methods.
42    ///
43    /// # Example
44    ///
45    /// ```
46    /// # use datafusion_distributed::Worker;
47    /// # use tonic::transport::Server;
48    /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
49    /// # async fn f() {
50    ///
51    /// let worker = Worker::default();
52    /// let server = worker.into_worker_server();
53    ///
54    /// Server::builder()
55    ///     .add_service(Worker::default().into_worker_server())
56    ///     .serve(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080))
57    ///     .await;
58    ///
59    /// # }
60    /// ```
61    pub fn into_worker_server(self) -> pb::worker_service_server::WorkerServiceServer<Self> {
62        pb::worker_service_server::WorkerServiceServer::new(self)
63            .max_decoding_message_size(usize::MAX)
64            .max_encoding_message_size(usize::MAX)
65    }
66
67    /// Creates an [`ObservabilityServiceServer`] that exposes task progress and cluster
68    /// worker discovery via the provided [`WorkerResolver`].
69    ///
70    /// The returned server is meant to be added to the same [`tonic::transport::Server`] as the
71    /// Flight service — gRPC multiplexes both services on a single port.
72    pub fn with_observability_service(
73        &self,
74        worker_resolver: Arc<dyn WorkerResolver + Send + Sync>,
75    ) -> ObservabilityServiceServer<ObservabilityServiceImpl> {
76        ObservabilityServiceServer::new(ObservabilityServiceImpl::new(
77            self.task_data_entries.clone(),
78            worker_resolver,
79        ))
80    }
81}
82
83/// Implementation of the `worker.proto` specification based on the generated Rust stubs.
84///
85/// The methods are delegated to plan `impl Worker` implementations so that they can be implemented
86/// in different files.
87#[async_trait]
88impl pb::worker_service_server::WorkerService for Worker {
89    type CoordinatorChannelStream = BoxStream<'static, Result<pb::WorkerToCoordinatorMsg, Status>>;
90
91    async fn coordinator_channel(
92        &self,
93        request: Request<Streaming<pb::CoordinatorToWorkerMsg>>,
94    ) -> Result<Response<Self::CoordinatorChannelStream>, Status> {
95        let (metadata, _ext, mut body) = request.into_parts();
96
97        let msg = body
98            .message()
99            .await?
100            .ok_or_else(empty("Coordinator stream"))?
101            .inner
102            .ok_or_else(missing("CoordinatorToWorkerMsg.inner"))?;
103        let pb::coordinator_to_worker_msg::Inner::SetPlanRequest(set_plan_request) = msg else {
104            return Err(Status::invalid_argument(
105                "First Coordinator to Worker message must be SetPlanRequest",
106            ));
107        };
108
109        let set_plan_request = decode_set_plan_request(set_plan_request)?;
110
111        let input_stream = body
112            .map_err(map_status_to_datafusion_error)
113            .map(move |msg| {
114                decode_coordinator_to_worker_msg(msg?).map_err(map_status_to_datafusion_error)
115            })
116            .boxed();
117
118        let output_stream = self
119            .coordinator_channel(metadata.into_headers(), set_plan_request, input_stream)
120            .await
121            .map_err(datafusion_error_to_tonic_status)?
122            .map(|msg| match msg {
123                Ok(msg) => encode_worker_to_coordinator_msg(msg),
124                Err(err) => Err(datafusion_error_to_tonic_status(err)),
125            })
126            .boxed();
127
128        Ok(Response::new(output_stream))
129    }
130
131    type ExecuteTaskStream = BoxStream<'static, Result<FlightData, Status>>;
132
133    async fn execute_task(
134        &self,
135        request: Request<pb::ExecuteTaskRequest>,
136    ) -> Result<Response<Self::ExecuteTaskStream>, Status> {
137        let body = request.into_inner();
138        let request = decode_execute_task_request(body).await?;
139        let partition_range = request.target_partition_start..request.target_partition_end;
140
141        let (arrow_streams, task_ctx) = self
142            .execute_task(request)
143            .await
144            .map_err(datafusion_error_to_tonic_status)?;
145
146        let d_cfg = DistributedConfig::from_config_options(task_ctx.session_config().options())
147            .map_err(datafusion_error_to_tonic_status)?;
148
149        let compression = match d_cfg.compression.as_str() {
150            "lz4" => Some(CompressionType::LZ4_FRAME),
151            "zstd" => Some(CompressionType::ZSTD),
152            "none" => None,
153            v => Err(Status::invalid_argument(format!(
154                "Unknown compression type {v}"
155            )))?,
156        };
157        let mut flight_streams = Vec::with_capacity(arrow_streams.len());
158        for (partition, arrow_stream) in partition_range.zip(arrow_streams) {
159            let flight_stream =
160                build_flight_data_stream(arrow_stream, compression)?.map(move |msg| {
161                    // For each FlightData produced by this stream, mark it with the appropriate
162                    // partition. This stream will be merged with several others from other partitions,
163                    // so marking it with the original partition allows it to be deconstructed into
164                    // the original per-partition streams in later steps.
165                    let flight_data = pb::FlightAppMetadata {
166                        partition: partition as u64,
167                        created_timestamp_unix_nanos: now_ns::<u64>(),
168                    };
169                    msg.map(|v| v.with_app_metadata(flight_data.encode_to_vec()))
170                });
171
172            flight_streams.push(flight_stream);
173        }
174
175        // Merge all the per-partition streams into one. Each message in the stream is marked with
176        // the original partition, so they can be reconstructed at the other side of the boundary.
177        let memory_pool = Arc::clone(&task_ctx.runtime_env().memory_pool);
178        let stream = spawn_select_all(flight_streams, memory_pool, RECORD_BATCH_BUFFER_SIZE);
179
180        Ok(Response::new(Box::pin(stream.map_err(|err| match err {
181            FlightError::Tonic(status) => *status,
182            _ => Status::internal(format!("Error during flight stream: {err}")),
183        }))))
184    }
185
186    async fn get_worker_info(
187        &self,
188        _request: Request<pb::GetWorkerInfoRequest>,
189    ) -> Result<Response<pb::GetWorkerInfoResponse>, Status> {
190        Ok(Response::new(pb::GetWorkerInfoResponse {
191            version: self.version().to_string(),
192        }))
193    }
194}
195
196fn decode_coordinator_to_worker_msg(
197    msg: pb::CoordinatorToWorkerMsg,
198) -> Result<CoordinatorToWorkerMsg, Status> {
199    Ok(
200        match msg
201            .inner
202            .ok_or_else(missing("CoordinatorToWorkerMsg.inner"))?
203        {
204            pb::coordinator_to_worker_msg::Inner::SetPlanRequest(_) => {
205                return Err(Status::invalid_argument(
206                    "SetPlanRequest must be the first coordinator message",
207                ));
208            }
209            pb::coordinator_to_worker_msg::Inner::WorkUnitBatch(batch) => {
210                CoordinatorToWorkerMsg::WorkUnitBatch(decode_work_unit_batch(batch)?)
211            }
212            pb::coordinator_to_worker_msg::Inner::WorkUnitEos(_) => {
213                CoordinatorToWorkerMsg::WorkUnitEos
214            }
215        },
216    )
217}
218
219fn decode_set_plan_request(request: pb::SetPlanRequest) -> Result<SetPlanRequest, Status> {
220    Ok(SetPlanRequest {
221        task_key: decode_task_key(request.task_key.ok_or_else(missing("task_key"))?)?,
222        task_count: request.task_count as usize,
223        plan: MaybeEncoded::Encoded(request.plan_proto),
224        work_unit_feed_declarations: request
225            .work_unit_feed_declarations
226            .into_iter()
227            .map(decode_work_unit_feed_declaration)
228            .collect::<Result<_, _>>()?,
229        target_worker_url: parse_url(&request.target_worker_url, "target_worker_url")?,
230        query_start_time_ns: request.query_start_time_ns as usize,
231    })
232}
233
234async fn decode_execute_task_request(
235    request: pb::ExecuteTaskRequest,
236) -> Result<ExecuteTaskRequest, Status> {
237    Ok(ExecuteTaskRequest {
238        task_key: decode_task_key(request.task_key.ok_or_else(missing("task_key"))?)?,
239        target_partition_start: request.target_partition_start as usize,
240        target_partition_end: request.target_partition_end as usize,
241        producer_head: decode_producer_head(
242            request.producer_head.ok_or_else(missing("producer_head"))?,
243        ),
244    })
245}
246
247pub(super) fn decode_producer_head(proto: pb::execute_task_request::ProducerHead) -> ProducerHead {
248    match proto {
249        pb::execute_task_request::ProducerHead::None(_) => ProducerHead::None,
250        pb::execute_task_request::ProducerHead::Broadcast(v) => ProducerHead::BroadcastExec {
251            output_partitions: v.output_partitions as usize,
252        },
253        pb::execute_task_request::ProducerHead::Repartition(v) => ProducerHead::RepartitionExec {
254            partitioning: MaybeEncoded::Encoded(v.partitioning),
255        },
256    }
257}
258
259fn encode_worker_to_coordinator_msg(
260    msg: WorkerToCoordinatorMsg,
261) -> Result<pb::WorkerToCoordinatorMsg, Status> {
262    Ok(pb::WorkerToCoordinatorMsg {
263        inner: Some(match msg {
264            WorkerToCoordinatorMsg::TaskMetrics(task_metrics) => {
265                pb::worker_to_coordinator_msg::Inner::TaskMetrics(encode_task_metrics(
266                    task_metrics,
267                )?)
268            }
269            WorkerToCoordinatorMsg::LoadInfo(load_info) => {
270                pb::worker_to_coordinator_msg::Inner::LoadInfo(encode_load_info(load_info))
271            }
272            WorkerToCoordinatorMsg::LoadInfoEos => {
273                pb::worker_to_coordinator_msg::Inner::LoadInfoEos(true)
274            }
275        }),
276    })
277}
278
279fn encode_task_metrics(task_metrics: TaskMetrics) -> Result<pb::TaskMetrics, Status> {
280    Ok(pb::TaskMetrics {
281        pre_order_plan_metrics: task_metrics
282            .pre_order_plan_metrics
283            .into_iter()
284            .map(|metrics_set| {
285                df_metrics_set_to_proto(&metrics_set).map_err(datafusion_error_to_tonic_status)
286            })
287            .collect::<Result<_, _>>()?,
288        task_metrics: Some(
289            df_metrics_set_to_proto(&task_metrics.task_metrics)
290                .map_err(datafusion_error_to_tonic_status)?,
291        ),
292    })
293}
294
295fn encode_load_info(load_info: LoadInfo) -> pb::LoadInfo {
296    pb::LoadInfo {
297        partition: load_info.partition as u64,
298        rows_ready: load_info.rows_ready as u64,
299        per_column_bytes_ready: load_info
300            .per_column_bytes_ready
301            .into_iter()
302            .map(|bytes| bytes as u64)
303            .collect(),
304        per_column_ndv_percentage: load_info.per_column_ndv_percentage,
305        per_column_null_percentage: load_info.per_column_null_percentage,
306        rows_pulled_from_leaf: load_info.rows_pulled_from_leaf as u64,
307        reached_eos: load_info.reached_eos,
308    }
309}
310
311fn decode_work_unit_batch(batch: pb::WorkUnitBatch) -> Result<WorkUnitBatch, Status> {
312    Ok(WorkUnitBatch {
313        batch: batch
314            .batch
315            .into_iter()
316            .map(decode_work_unit)
317            .collect::<Result<_, _>>()?,
318    })
319}
320
321fn decode_work_unit(work_unit: pb::WorkUnit) -> Result<WorkUnitMsg, Status> {
322    Ok(WorkUnitMsg {
323        id: deserialize_uuid(&work_unit.id).map_err(datafusion_error_to_tonic_status)?,
324        partition: work_unit.partition as usize,
325        body: MaybeEncoded::Encoded(work_unit.body),
326        created_timestamp_unix_nanos: work_unit.created_timestamp_unix_nanos as usize,
327        sent_timestamp_unix_nanos: work_unit.sent_timestamp_unix_nanos as usize,
328        received_timestamp_unix_nanos: work_unit.received_timestamp_unix_nanos as usize,
329        processed_timestamp_unix_nanos: work_unit.processed_timestamp_unix_nanos as usize,
330    })
331}
332
333fn decode_work_unit_feed_declaration(
334    declaration: pb::set_plan_request::WorkUnitFeedDeclaration,
335) -> Result<WorkUnitFeedDeclaration, Status> {
336    Ok(WorkUnitFeedDeclaration {
337        id: deserialize_uuid(&declaration.id).map_err(datafusion_error_to_tonic_status)?,
338        partitions: declaration.partitions as usize,
339    })
340}
341
342fn decode_task_key(task_key: pb::TaskKey) -> Result<TaskKey, Status> {
343    Ok(TaskKey {
344        query_id: deserialize_uuid(&task_key.query_id).map_err(datafusion_error_to_tonic_status)?,
345        stage_id: task_key.stage_id as usize,
346        task_number: task_key.task_number as usize,
347    })
348}
349
350fn parse_url(value: &str, field: &'static str) -> Result<Url, Status> {
351    Url::parse(value)
352        .map_err(|err| Status::invalid_argument(format!("Invalid field '{field}': {err}")))
353}
354
355fn empty(stream_name: &'static str) -> impl FnOnce() -> Status {
356    move || Status::invalid_argument(format!("Empty {stream_name}"))
357}
358
359fn missing(field: &'static str) -> impl FnOnce() -> Status {
360    move || Status::invalid_argument(format!("Missing field '{field}'"))
361}
362
363fn build_flight_data_stream(
364    stream: SendableRecordBatchStream,
365    compression_type: Option<CompressionType>,
366) -> datafusion::common::Result<FlightDataEncoder, Status> {
367    let stream = FlightDataEncoderBuilder::new()
368        .with_options(
369            IpcWriteOptions::default()
370                .try_with_compression(compression_type)
371                .map_err(|err| Status::internal(err.to_string()))?,
372        )
373        .with_schema(stream.schema())
374        // This tells the encoder to send dictionaries across the wire as-is.
375        // The alternative (`DictionaryHandling::Hydrate`) would expand the dictionaries
376        // into their value types, which can potentially blow up the size of the data transfer.
377        // The main reason to use `DictionaryHandling::Hydrate` is for compatibility with clients
378        // that do not support dictionaries, but since we are using the same server/client on both
379        // sides, we can safely use `DictionaryHandling::Resend`.
380        // Note that we do garbage collection of unused dictionary values above, so we are not sending
381        // unused dictionary values over the wire.
382        .with_dictionary_handling(DictionaryHandling::Resend)
383        // Set max flight data size to unlimited.
384        // This requires servers and clients to also be configured to handle unlimited sizes.
385        // Using unlimited sizes avoids splitting RecordBatches into multiple FlightData messages,
386        // which could add significant overhead for large RecordBatches.
387        // The only reason to split them really is if the client/server are configured with a message size limit,
388        // which mainly makes sense in a public network scenario where you want to avoid DoS attacks.
389        // Since all of our Arrow Flight communication happens within trusted data plane networks,
390        // we can safely use unlimited sizes here.
391        .with_max_flight_data_size(usize::MAX)
392        .build(
393            stream
394                // Apply garbage collection of dictionary and view arrays before sending over the network
395                .and_then(|rb| std::future::ready(garbage_collect_arrays(rb)))
396                .map_err(|err| FlightError::Tonic(Box::new(datafusion_error_to_tonic_status(err)))),
397        );
398    Ok(stream)
399}
400
401/// Garbage collects values sub-arrays.
402///
403/// We apply this before sending RecordBatches over the network to avoid sending
404/// values that are not referenced by any dictionary keys or buffers that are not used.
405///
406/// Unused values can arise from operations such as filtering, where
407/// some keys may no longer be referenced in the filtered result.
408fn garbage_collect_arrays(
409    batch: RecordBatch,
410) -> datafusion::common::Result<RecordBatch, DataFusionError> {
411    let (schema, arrays, row_count) = batch.into_parts();
412
413    let arrays = arrays
414        .into_iter()
415        .map(|array| {
416            if let Some(array) = array.as_any_dictionary_opt() {
417                garbage_collect_any_dictionary(array)
418            } else if let Some(array) = array.as_string_view_opt() {
419                Ok(Arc::new(array.gc()) as Arc<dyn Array>)
420            } else if let Some(array) = array.as_binary_view_opt() {
421                Ok(Arc::new(array.gc()) as Arc<dyn Array>)
422            } else {
423                Ok(array)
424            }
425        })
426        .collect::<datafusion::common::Result<Vec<_>, _>>()?;
427
428    Ok(RecordBatch::try_new_with_options(
429        schema,
430        arrays,
431        &RecordBatchOptions::new().with_row_count(Some(row_count)),
432    )?)
433}