mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
refactor: split flight's common module by responsibility
common.rs held the wire messages, what a node sends and when, and what a remote scan's caller tracks. Each now has its own module: - message: CustomMessage and Metrics, the protocol between nodes. - node_messages: NodeMessages, NodeRole and MetricsInfo, the sending side. - remote_scan: RemoteScanMetrics, RemoteScanState and NodeProfiles, the receiving side. FlightMessage, the decoder's output, moves into decoder. Code moves without changes; callers import from the new modules.
This commit is contained in:
@@ -28,7 +28,7 @@ use datafusion::{
|
||||
common::{DataFusionError, Result},
|
||||
physical_plan::execute_stream_partitioned,
|
||||
};
|
||||
use flight::common::{MetricsInfo, NodeMessages};
|
||||
use flight::node_messages::{MetricsInfo, NodeMessages};
|
||||
use futures::{StreamExt, stream::BoxStream};
|
||||
use futures_util::pin_mut;
|
||||
use prost::Message;
|
||||
|
||||
@@ -19,7 +19,7 @@ use arrow::ipc::writer::IpcWriteOptions;
|
||||
use arrow_flight::{FlightData, error::FlightError};
|
||||
use config::metrics;
|
||||
use datafusion::execution::SendableRecordBatchStream;
|
||||
use flight::{common::NodeMessages, encoder::FlightDataEncoder};
|
||||
use flight::{encoder::FlightDataEncoder, node_messages::NodeMessages};
|
||||
use futures::{Stream, StreamExt};
|
||||
use search_service::work_group::DeferredLock;
|
||||
use tokio::{sync::mpsc, task::JoinSet};
|
||||
@@ -392,7 +392,7 @@ mod tests {
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use config::meta::search::ScanStats;
|
||||
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
|
||||
use flight::common::{CustomMessage, NodeRole};
|
||||
use flight::{message::CustomMessage, node_messages::NodeRole};
|
||||
use futures::TryStreamExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -19,7 +19,7 @@ use datafusion::{
|
||||
common::tree_node::{TreeNode, TreeNodeRecursion},
|
||||
physical_plan::ExecutionPlan,
|
||||
};
|
||||
use flight::common::NodeRole;
|
||||
use flight::node_messages::NodeRole;
|
||||
use search::datafusion::distributed_plan::remote_scan_exec::RemoteScanExec;
|
||||
pub use search::datafusion::plan_metrics::get_peak_memory_from_ctx;
|
||||
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use arrow::array::RecordBatch;
|
||||
use arrow_schema::SchemaRef;
|
||||
use config::{
|
||||
cluster::LOCAL_NODE,
|
||||
meta::{cluster::NodeInfo, search::ScanStats},
|
||||
utils::record_batch_ext::RecordBatchExt,
|
||||
};
|
||||
use datafusion::{
|
||||
self,
|
||||
physical_plan::{
|
||||
ExecutionPlan,
|
||||
metrics::{self, ExecutionPlanMetricsSet, MetricBuilder, MetricValue},
|
||||
},
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::profile::{ProfileNode, RemoteNodeProfile};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteScanMetrics {
|
||||
/// Time in nanos to execute child operator and fetch batches
|
||||
pub fetch_time: metrics::Time,
|
||||
/// Time in nanos to perform decoding
|
||||
pub decode_time: metrics::Time,
|
||||
/// output rows
|
||||
pub output_rows: metrics::Count,
|
||||
/// number of RecordBatches received from the remote node
|
||||
pub batches_received: metrics::Count,
|
||||
/// total in-memory bytes of RecordBatches received; registered as
|
||||
/// `MetricValue::OutputBytes` so DataFusion formats it with `human_readable_size`
|
||||
/// (e.g. "1.5 GB") rather than `human_readable_count` ("1.57 B" = billion)
|
||||
pub bytes_received: metrics::Count,
|
||||
}
|
||||
|
||||
impl RemoteScanMetrics {
|
||||
pub fn new(input_partition: usize, metrics: &ExecutionPlanMetricsSet) -> Self {
|
||||
let fetch_time = MetricBuilder::new(metrics).subset_time("fetch_time", input_partition);
|
||||
let decode_time = MetricBuilder::new(metrics).subset_time("decode_time", input_partition);
|
||||
let output_rows = MetricBuilder::new(metrics).output_rows(input_partition);
|
||||
let batches_received =
|
||||
MetricBuilder::new(metrics).counter("batches_received", input_partition);
|
||||
|
||||
// Register as OutputBytes so the display uses human_readable_size (KB/MB/GB)
|
||||
// instead of human_readable_count (K/M/B where B means billion).
|
||||
let bytes_received = metrics::Count::new();
|
||||
MetricBuilder::new(metrics)
|
||||
.with_partition(input_partition)
|
||||
.build(MetricValue::OutputBytes(bytes_received.clone()));
|
||||
|
||||
Self {
|
||||
fetch_time,
|
||||
decode_time,
|
||||
output_rows,
|
||||
batches_received,
|
||||
bytes_received,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_output(&self, batch: &RecordBatch) {
|
||||
self.output_rows.add(batch.num_rows());
|
||||
self.batches_received.add(1);
|
||||
self.bytes_received.add(batch.size());
|
||||
}
|
||||
|
||||
/// Average fetch time per batch in milliseconds, or 0 if no batches received.
|
||||
pub fn avg_batch_time_ms(&self) -> u64 {
|
||||
let batches = self.batches_received.value();
|
||||
if batches == 0 {
|
||||
return 0;
|
||||
}
|
||||
// fetch_time.value() is total nanoseconds (usize)
|
||||
let total_ns = self.fetch_time.value() as u64;
|
||||
total_ns / batches as u64 / 1_000_000
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FlightMessage {
|
||||
Schema(SchemaRef),
|
||||
RecordBatch(RecordBatch),
|
||||
CustomMessage(CustomMessage),
|
||||
}
|
||||
|
||||
/// Custom message for ser/deserialize
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum CustomMessage {
|
||||
ScanStats(ScanStats),
|
||||
Metrics(Vec<Metrics>),
|
||||
PeakMemory(usize),
|
||||
PartialErr(String),
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Metrics {
|
||||
/// Unused; kept, like `metrics`, so that older leaders can still parse the message.
|
||||
pub stage: i32,
|
||||
pub node_address: String,
|
||||
pub node_name: String,
|
||||
/// Left empty: the leader renders the text plan from `profile`.
|
||||
pub metrics: String,
|
||||
/// The plan this node ran; older nodes do not send it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<ProfileNode>,
|
||||
}
|
||||
|
||||
/// What a node sends besides its record batches; the shared references are read when sent.
|
||||
pub struct NodeMessages {
|
||||
/// This node's own scan stats, known before execution.
|
||||
pub scan_stats: ScanStats,
|
||||
/// The peak of this node's memory pool.
|
||||
pub peak_memory: Arc<AtomicUsize>,
|
||||
/// The plan to send back as a profile; set only for EXPLAIN ANALYZE.
|
||||
pub profile: Option<MetricsInfo>,
|
||||
pub role: NodeRole,
|
||||
}
|
||||
|
||||
impl NodeMessages {
|
||||
/// Sent before the first batch, so the caller sees scan stats and fast failures early.
|
||||
pub fn first(&self) -> Vec<CustomMessage> {
|
||||
let mut messages = vec![CustomMessage::ScanStats(self.scan_stats)];
|
||||
if let NodeRole::ClusterLeader(remote) = &self.role {
|
||||
messages.push(CustomMessage::ScanStats(*remote.scan_stats.lock()));
|
||||
messages.extend(remote.partial_err());
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Sent after the last batch.
|
||||
pub fn last(&self) -> Vec<CustomMessage> {
|
||||
let mut messages = vec![CustomMessage::PeakMemory(
|
||||
self.peak_memory.load(Ordering::Relaxed),
|
||||
)];
|
||||
if let NodeRole::ClusterLeader(remote) = &self.role {
|
||||
messages.push(CustomMessage::PeakMemory(
|
||||
remote.peak_memory.load(Ordering::Relaxed),
|
||||
));
|
||||
messages.extend(remote.partial_err());
|
||||
}
|
||||
if let Some(info) = &self.profile {
|
||||
messages.push(CustomMessage::Metrics(collect_metrics(info)));
|
||||
}
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
pub enum NodeRole {
|
||||
/// Scans the data itself.
|
||||
Follower,
|
||||
/// A super cluster's cluster leader, which passes the query on to its own followers.
|
||||
ClusterLeader(RemoteScanState),
|
||||
}
|
||||
|
||||
/// Per-partition profiles of one remote scan, keyed by partition.
|
||||
pub type NodeProfiles = Arc<Mutex<BTreeMap<usize, RemoteNodeProfile>>>;
|
||||
|
||||
/// What a remote scan collects from the nodes it calls, shared by every copy of the scan.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RemoteScanState {
|
||||
pub scan_stats: Arc<Mutex<ScanStats>>,
|
||||
pub peak_memory: Arc<AtomicUsize>,
|
||||
pub partial_err: Arc<Mutex<String>>,
|
||||
/// Filled only for EXPLAIN ANALYZE.
|
||||
pub node_profiles: NodeProfiles,
|
||||
}
|
||||
|
||||
impl RemoteScanState {
|
||||
fn partial_err(&self) -> Option<CustomMessage> {
|
||||
let err = self.partial_err.lock().clone();
|
||||
(!err.is_empty()).then_some(CustomMessage::PartialErr(err))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MetricsInfo {
|
||||
pub plan: Arc<dyn ExecutionPlan>,
|
||||
/// Passed in because it lives in the search crate, which depends on this one.
|
||||
pub build_profile: fn(&dyn ExecutionPlan) -> ProfileNode,
|
||||
}
|
||||
|
||||
fn collect_metrics(metrics_info: &MetricsInfo) -> Vec<Metrics> {
|
||||
vec![Metrics {
|
||||
node_address: LOCAL_NODE.get_grpc_addr().to_string(),
|
||||
node_name: LOCAL_NODE.get_name().to_string(),
|
||||
profile: Some((metrics_info.build_profile)(metrics_info.plan.as_ref())),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use config::meta::search::ScanStats;
|
||||
use datafusion::physical_plan::{ExecutionPlan, empty::EmptyExec};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_test_scan_stats() -> ScanStats {
|
||||
ScanStats {
|
||||
files: 10,
|
||||
records: 1000,
|
||||
original_size: 2048,
|
||||
compressed_size: 1024,
|
||||
querier_files: 5,
|
||||
querier_memory_cached_files: 2,
|
||||
querier_disk_cached_files: 3,
|
||||
idx_scan_size: 512,
|
||||
idx_took: 100,
|
||||
file_list_took: 50,
|
||||
aggs_cache_ratio: 80,
|
||||
peak_memory_usage: 1024000,
|
||||
wait_in_queue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_profile(plan: &dyn ExecutionPlan) -> ProfileNode {
|
||||
ProfileNode {
|
||||
name: plan.name().to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_execution_plan() -> Arc<dyn ExecutionPlan> {
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
|
||||
Arc::new(EmptyExec::new(schema))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_default() {
|
||||
let metrics = Metrics::default();
|
||||
assert_eq!(metrics.stage, 0);
|
||||
assert_eq!(metrics.node_address, "");
|
||||
assert_eq!(metrics.node_name, "");
|
||||
assert_eq!(metrics.metrics, "");
|
||||
}
|
||||
|
||||
fn describe(messages: Vec<CustomMessage>) -> Vec<String> {
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
CustomMessage::ScanStats(stats) => format!("ScanStats({})", stats.files),
|
||||
CustomMessage::PeakMemory(peak) => format!("PeakMemory({peak})"),
|
||||
CustomMessage::PartialErr(err) => format!("PartialErr({err})"),
|
||||
CustomMessage::Metrics(metrics) => {
|
||||
format!("Metrics({})", metrics[0].profile.as_ref().unwrap().name)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follower_sends_only_its_own_values() {
|
||||
let messages = NodeMessages {
|
||||
scan_stats: create_test_scan_stats(),
|
||||
peak_memory: Arc::new(AtomicUsize::new(5)),
|
||||
profile: None,
|
||||
role: NodeRole::Follower,
|
||||
};
|
||||
assert_eq!(describe(messages.first()), ["ScanStats(10)"]);
|
||||
assert_eq!(describe(messages.last()), ["PeakMemory(5)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_leader_also_sends_what_its_remote_scan_collected() {
|
||||
let remote = RemoteScanState {
|
||||
scan_stats: Arc::new(Mutex::new(ScanStats {
|
||||
files: 3,
|
||||
..Default::default()
|
||||
})),
|
||||
peak_memory: Arc::new(AtomicUsize::new(7)),
|
||||
..Default::default()
|
||||
};
|
||||
let partial_err = remote.partial_err.clone();
|
||||
let messages = NodeMessages {
|
||||
scan_stats: create_test_scan_stats(),
|
||||
peak_memory: Arc::new(AtomicUsize::new(5)),
|
||||
profile: Some(MetricsInfo {
|
||||
plan: create_test_execution_plan(),
|
||||
build_profile: test_profile,
|
||||
}),
|
||||
role: NodeRole::ClusterLeader(remote),
|
||||
};
|
||||
assert_eq!(
|
||||
describe(messages.first()),
|
||||
["ScanStats(10)", "ScanStats(3)"]
|
||||
);
|
||||
|
||||
*partial_err.lock() = "node down".to_string();
|
||||
assert_eq!(
|
||||
describe(messages.last()),
|
||||
[
|
||||
"PeakMemory(5)",
|
||||
"PeakMemory(7)",
|
||||
"PartialErr(node down)",
|
||||
"Metrics(EmptyExec)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_carries_this_node() {
|
||||
let metrics = collect_metrics(&MetricsInfo {
|
||||
plan: create_test_execution_plan(),
|
||||
build_profile: test_profile,
|
||||
});
|
||||
assert_eq!(metrics.len(), 1);
|
||||
assert!(!metrics[0].node_address.is_empty());
|
||||
assert!(!metrics[0].node_name.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_without_profile_deserializes() {
|
||||
let json = r#"{"stage":1,"node_address":"a","node_name":"b","metrics":"m"}"#;
|
||||
let metrics: Metrics = serde_json::from_str(json).unwrap();
|
||||
assert!(metrics.profile.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_keeps_fields_older_leaders_require() {
|
||||
let metrics = Metrics {
|
||||
profile: Some(ProfileNode::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(&metrics).unwrap();
|
||||
for key in ["stage", "node_address", "node_name", "metrics"] {
|
||||
assert!(json.get(key).is_some(), "missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_message_serialization() {
|
||||
let scan_stats = create_test_scan_stats();
|
||||
let custom_msg = CustomMessage::ScanStats(scan_stats);
|
||||
|
||||
let serialized = serde_json::to_string(&custom_msg).unwrap();
|
||||
let deserialized: CustomMessage = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
match deserialized {
|
||||
CustomMessage::ScanStats(stats) => {
|
||||
assert_eq!(stats.files, 10);
|
||||
assert_eq!(stats.records, 1000);
|
||||
}
|
||||
_ => panic!("Expected ScanStats variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_serialization() {
|
||||
let metrics = vec![Metrics {
|
||||
stage: 1,
|
||||
node_address: "test_node".to_string(),
|
||||
node_name: "Test Node".to_string(),
|
||||
metrics: "test_metrics".to_string(),
|
||||
profile: None,
|
||||
}];
|
||||
let custom_msg = CustomMessage::Metrics(metrics);
|
||||
|
||||
let serialized = serde_json::to_string(&custom_msg).unwrap();
|
||||
let deserialized: CustomMessage = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
match deserialized {
|
||||
CustomMessage::Metrics(metrics) => {
|
||||
assert_eq!(metrics.len(), 1);
|
||||
assert_eq!(metrics[0].stage, 1);
|
||||
assert_eq!(metrics[0].node_address, "test_node");
|
||||
assert_eq!(metrics[0].node_name, "Test Node");
|
||||
assert_eq!(metrics[0].metrics, "test_metrics");
|
||||
}
|
||||
_ => panic!("Expected Metrics variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_message_peak_memory_serialization() {
|
||||
let peak_memory = 1024 * 1024 * 100; // 100 MB
|
||||
let custom_msg = CustomMessage::PeakMemory(peak_memory);
|
||||
|
||||
let serialized = serde_json::to_string(&custom_msg).unwrap();
|
||||
let deserialized: CustomMessage = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
match deserialized {
|
||||
CustomMessage::PeakMemory(mem) => {
|
||||
assert_eq!(mem, peak_memory);
|
||||
}
|
||||
_ => panic!("Expected PeakMemory variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@
|
||||
|
||||
use std::{collections::HashMap, fmt::Debug, pin::Pin, sync::Arc, task::Poll};
|
||||
|
||||
use arrow::{array::ArrayRef, buffer::Buffer, ipc::MessageHeader};
|
||||
use arrow::{
|
||||
array::{ArrayRef, RecordBatch},
|
||||
buffer::Buffer,
|
||||
ipc::MessageHeader,
|
||||
};
|
||||
use arrow_flight::{
|
||||
FlightData,
|
||||
error::{FlightError, Result},
|
||||
@@ -26,7 +30,14 @@ use datafusion::parquet::data_type::AsBytes;
|
||||
use futures::{Stream, StreamExt, ready};
|
||||
use tonic::Streaming;
|
||||
|
||||
use crate::common::{CustomMessage, FlightMessage, RemoteScanMetrics};
|
||||
use crate::{message::CustomMessage, remote_scan::RemoteScanMetrics};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FlightMessage {
|
||||
Schema(SchemaRef),
|
||||
RecordBatch(RecordBatch),
|
||||
CustomMessage(CustomMessage),
|
||||
}
|
||||
|
||||
pub struct FlightDataDecoder {
|
||||
response: Streaming<FlightData>,
|
||||
@@ -198,7 +209,7 @@ mod tests {
|
||||
use flatbuffers::FlatBufferBuilder;
|
||||
|
||||
use super::*;
|
||||
use crate::common::CustomMessage;
|
||||
use crate::message::CustomMessage;
|
||||
|
||||
fn create_test_schema() -> Arc<Schema> {
|
||||
Arc::new(Schema::new(vec![
|
||||
|
||||
@@ -26,7 +26,7 @@ use arrow_flight::{FlightData, error::Result};
|
||||
use bytes::Bytes;
|
||||
use flatbuffers::FlatBufferBuilder;
|
||||
|
||||
use crate::common::CustomMessage;
|
||||
use crate::message::CustomMessage;
|
||||
|
||||
/// Encodes CustomMessages and RecordBatch chunks into FlightData (coalescing is separate — see
|
||||
/// [`ChunkCoalescer`]). Owns the zstd compressor, so it's not `Clone`: use one per thread.
|
||||
@@ -232,7 +232,7 @@ mod tests {
|
||||
use config::meta::search::ScanStats;
|
||||
|
||||
use super::*;
|
||||
use crate::common::CustomMessage;
|
||||
use crate::message::CustomMessage;
|
||||
|
||||
fn create_test_schema() -> Schema {
|
||||
Schema::new(vec![
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
pub mod common;
|
||||
pub mod decoder;
|
||||
pub mod encoder;
|
||||
pub mod message;
|
||||
pub mod node_messages;
|
||||
pub mod profile;
|
||||
pub mod remote_scan;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! What nodes send each other over Flight besides record batches; changing it changes the protocol.
|
||||
|
||||
use config::meta::search::ScanStats;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::profile::ProfileNode;
|
||||
|
||||
/// Custom message for ser/deserialize
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum CustomMessage {
|
||||
ScanStats(ScanStats),
|
||||
Metrics(Vec<Metrics>),
|
||||
PeakMemory(usize),
|
||||
PartialErr(String),
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Metrics {
|
||||
/// Unused; kept, like `metrics`, so that older leaders can still parse the message.
|
||||
pub stage: i32,
|
||||
pub node_address: String,
|
||||
pub node_name: String,
|
||||
/// Left empty: the leader renders the text plan from `profile`.
|
||||
pub metrics: String,
|
||||
/// The plan this node ran; older nodes do not send it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<ProfileNode>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_scan_stats() -> ScanStats {
|
||||
ScanStats {
|
||||
files: 10,
|
||||
records: 1000,
|
||||
original_size: 2048,
|
||||
compressed_size: 1024,
|
||||
querier_files: 5,
|
||||
querier_memory_cached_files: 2,
|
||||
querier_disk_cached_files: 3,
|
||||
idx_scan_size: 512,
|
||||
idx_took: 100,
|
||||
file_list_took: 50,
|
||||
aggs_cache_ratio: 80,
|
||||
peak_memory_usage: 1024000,
|
||||
wait_in_queue: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_default() {
|
||||
let metrics = Metrics::default();
|
||||
assert_eq!(metrics.stage, 0);
|
||||
assert_eq!(metrics.node_address, "");
|
||||
assert_eq!(metrics.node_name, "");
|
||||
assert_eq!(metrics.metrics, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_without_profile_deserializes() {
|
||||
let json = r#"{"stage":1,"node_address":"a","node_name":"b","metrics":"m"}"#;
|
||||
let metrics: Metrics = serde_json::from_str(json).unwrap();
|
||||
assert!(metrics.profile.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_keeps_fields_older_leaders_require() {
|
||||
let metrics = Metrics {
|
||||
profile: Some(ProfileNode::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(&metrics).unwrap();
|
||||
for key in ["stage", "node_address", "node_name", "metrics"] {
|
||||
assert!(json.get(key).is_some(), "missing {key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_message_serialization() {
|
||||
let scan_stats = create_test_scan_stats();
|
||||
let custom_msg = CustomMessage::ScanStats(scan_stats);
|
||||
|
||||
let serialized = serde_json::to_string(&custom_msg).unwrap();
|
||||
let deserialized: CustomMessage = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
match deserialized {
|
||||
CustomMessage::ScanStats(stats) => {
|
||||
assert_eq!(stats.files, 10);
|
||||
assert_eq!(stats.records, 1000);
|
||||
}
|
||||
_ => panic!("Expected ScanStats variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metrics_serialization() {
|
||||
let metrics = vec![Metrics {
|
||||
stage: 1,
|
||||
node_address: "test_node".to_string(),
|
||||
node_name: "Test Node".to_string(),
|
||||
metrics: "test_metrics".to_string(),
|
||||
profile: None,
|
||||
}];
|
||||
let custom_msg = CustomMessage::Metrics(metrics);
|
||||
|
||||
let serialized = serde_json::to_string(&custom_msg).unwrap();
|
||||
let deserialized: CustomMessage = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
match deserialized {
|
||||
CustomMessage::Metrics(metrics) => {
|
||||
assert_eq!(metrics.len(), 1);
|
||||
assert_eq!(metrics[0].stage, 1);
|
||||
assert_eq!(metrics[0].node_address, "test_node");
|
||||
assert_eq!(metrics[0].node_name, "Test Node");
|
||||
assert_eq!(metrics[0].metrics, "test_metrics");
|
||||
}
|
||||
_ => panic!("Expected Metrics variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_message_peak_memory_serialization() {
|
||||
let peak_memory = 1024 * 1024 * 100; // 100 MB
|
||||
let custom_msg = CustomMessage::PeakMemory(peak_memory);
|
||||
|
||||
let serialized = serde_json::to_string(&custom_msg).unwrap();
|
||||
let deserialized: CustomMessage = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
match deserialized {
|
||||
CustomMessage::PeakMemory(mem) => {
|
||||
assert_eq!(mem, peak_memory);
|
||||
}
|
||||
_ => panic!("Expected PeakMemory variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! What a node sends back to its caller besides record batches, and when.
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use config::{
|
||||
cluster::LOCAL_NODE,
|
||||
meta::{cluster::NodeInfo, search::ScanStats},
|
||||
};
|
||||
use datafusion::physical_plan::ExecutionPlan;
|
||||
|
||||
use crate::{
|
||||
message::{CustomMessage, Metrics},
|
||||
profile::ProfileNode,
|
||||
remote_scan::RemoteScanState,
|
||||
};
|
||||
|
||||
/// What a node sends besides its record batches; the shared references are read when sent.
|
||||
pub struct NodeMessages {
|
||||
/// This node's own scan stats, known before execution.
|
||||
pub scan_stats: ScanStats,
|
||||
/// The peak of this node's memory pool.
|
||||
pub peak_memory: Arc<AtomicUsize>,
|
||||
/// The plan to send back as a profile; set only for EXPLAIN ANALYZE.
|
||||
pub profile: Option<MetricsInfo>,
|
||||
pub role: NodeRole,
|
||||
}
|
||||
|
||||
impl NodeMessages {
|
||||
/// Sent before the first batch, so the caller sees scan stats and fast failures early.
|
||||
pub fn first(&self) -> Vec<CustomMessage> {
|
||||
let mut messages = vec![CustomMessage::ScanStats(self.scan_stats)];
|
||||
if let NodeRole::ClusterLeader(remote) = &self.role {
|
||||
messages.push(CustomMessage::ScanStats(*remote.scan_stats.lock()));
|
||||
messages.extend(partial_err(remote));
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Sent after the last batch.
|
||||
pub fn last(&self) -> Vec<CustomMessage> {
|
||||
let mut messages = vec![CustomMessage::PeakMemory(
|
||||
self.peak_memory.load(Ordering::Relaxed),
|
||||
)];
|
||||
if let NodeRole::ClusterLeader(remote) = &self.role {
|
||||
messages.push(CustomMessage::PeakMemory(
|
||||
remote.peak_memory.load(Ordering::Relaxed),
|
||||
));
|
||||
messages.extend(partial_err(remote));
|
||||
}
|
||||
if let Some(info) = &self.profile {
|
||||
messages.push(CustomMessage::Metrics(collect_metrics(info)));
|
||||
}
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
pub enum NodeRole {
|
||||
/// Scans the data itself.
|
||||
Follower,
|
||||
/// A super cluster's cluster leader, which passes the query on to its own followers.
|
||||
ClusterLeader(RemoteScanState),
|
||||
}
|
||||
|
||||
pub struct MetricsInfo {
|
||||
pub plan: Arc<dyn ExecutionPlan>,
|
||||
/// Passed in because it lives in the search crate, which depends on this one.
|
||||
pub build_profile: fn(&dyn ExecutionPlan) -> ProfileNode,
|
||||
}
|
||||
|
||||
fn partial_err(remote: &RemoteScanState) -> Option<CustomMessage> {
|
||||
let err = remote.partial_err.lock().clone();
|
||||
(!err.is_empty()).then_some(CustomMessage::PartialErr(err))
|
||||
}
|
||||
|
||||
fn collect_metrics(metrics_info: &MetricsInfo) -> Vec<Metrics> {
|
||||
vec![Metrics {
|
||||
node_address: LOCAL_NODE.get_grpc_addr().to_string(),
|
||||
node_name: LOCAL_NODE.get_name().to_string(),
|
||||
profile: Some((metrics_info.build_profile)(metrics_info.plan.as_ref())),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use datafusion::physical_plan::empty::EmptyExec;
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_test_scan_stats() -> ScanStats {
|
||||
ScanStats {
|
||||
files: 10,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_profile(plan: &dyn ExecutionPlan) -> ProfileNode {
|
||||
ProfileNode {
|
||||
name: plan.name().to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_execution_plan() -> Arc<dyn ExecutionPlan> {
|
||||
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
|
||||
Arc::new(EmptyExec::new(schema))
|
||||
}
|
||||
|
||||
fn describe(messages: Vec<CustomMessage>) -> Vec<String> {
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| match message {
|
||||
CustomMessage::ScanStats(stats) => format!("ScanStats({})", stats.files),
|
||||
CustomMessage::PeakMemory(peak) => format!("PeakMemory({peak})"),
|
||||
CustomMessage::PartialErr(err) => format!("PartialErr({err})"),
|
||||
CustomMessage::Metrics(metrics) => {
|
||||
format!("Metrics({})", metrics[0].profile.as_ref().unwrap().name)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follower_sends_only_its_own_values() {
|
||||
let messages = NodeMessages {
|
||||
scan_stats: create_test_scan_stats(),
|
||||
peak_memory: Arc::new(AtomicUsize::new(5)),
|
||||
profile: None,
|
||||
role: NodeRole::Follower,
|
||||
};
|
||||
assert_eq!(describe(messages.first()), ["ScanStats(10)"]);
|
||||
assert_eq!(describe(messages.last()), ["PeakMemory(5)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_leader_also_sends_what_its_remote_scan_collected() {
|
||||
let remote = RemoteScanState {
|
||||
scan_stats: Arc::new(Mutex::new(ScanStats {
|
||||
files: 3,
|
||||
..Default::default()
|
||||
})),
|
||||
peak_memory: Arc::new(AtomicUsize::new(7)),
|
||||
..Default::default()
|
||||
};
|
||||
let partial_err = remote.partial_err.clone();
|
||||
let messages = NodeMessages {
|
||||
scan_stats: create_test_scan_stats(),
|
||||
peak_memory: Arc::new(AtomicUsize::new(5)),
|
||||
profile: Some(MetricsInfo {
|
||||
plan: create_test_execution_plan(),
|
||||
build_profile: test_profile,
|
||||
}),
|
||||
role: NodeRole::ClusterLeader(remote),
|
||||
};
|
||||
assert_eq!(
|
||||
describe(messages.first()),
|
||||
["ScanStats(10)", "ScanStats(3)"]
|
||||
);
|
||||
|
||||
*partial_err.lock() = "node down".to_string();
|
||||
assert_eq!(
|
||||
describe(messages.last()),
|
||||
[
|
||||
"PeakMemory(5)",
|
||||
"PeakMemory(7)",
|
||||
"PartialErr(node down)",
|
||||
"Metrics(EmptyExec)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_carries_this_node() {
|
||||
let metrics = collect_metrics(&MetricsInfo {
|
||||
plan: create_test_execution_plan(),
|
||||
build_profile: test_profile,
|
||||
});
|
||||
assert_eq!(metrics.len(), 1);
|
||||
assert!(!metrics[0].node_address.is_empty());
|
||||
assert!(!metrics[0].node_name.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! What the caller of a remote scan tracks about the nodes it calls.
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, atomic::AtomicUsize},
|
||||
};
|
||||
|
||||
use arrow::array::RecordBatch;
|
||||
use config::{meta::search::ScanStats, utils::record_batch_ext::RecordBatchExt};
|
||||
use datafusion::physical_plan::metrics::{
|
||||
self, ExecutionPlanMetricsSet, MetricBuilder, MetricValue,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::profile::RemoteNodeProfile;
|
||||
|
||||
/// Per-partition profiles of one remote scan, keyed by partition.
|
||||
pub type NodeProfiles = Arc<Mutex<BTreeMap<usize, RemoteNodeProfile>>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteScanMetrics {
|
||||
/// Time in nanos to execute child operator and fetch batches
|
||||
pub fetch_time: metrics::Time,
|
||||
/// Time in nanos to perform decoding
|
||||
pub decode_time: metrics::Time,
|
||||
/// output rows
|
||||
pub output_rows: metrics::Count,
|
||||
/// number of RecordBatches received from the remote node
|
||||
pub batches_received: metrics::Count,
|
||||
/// total in-memory bytes of RecordBatches received; registered as
|
||||
/// `MetricValue::OutputBytes` so DataFusion formats it with `human_readable_size`
|
||||
/// (e.g. "1.5 GB") rather than `human_readable_count` ("1.57 B" = billion)
|
||||
pub bytes_received: metrics::Count,
|
||||
}
|
||||
|
||||
impl RemoteScanMetrics {
|
||||
pub fn new(input_partition: usize, metrics: &ExecutionPlanMetricsSet) -> Self {
|
||||
let fetch_time = MetricBuilder::new(metrics).subset_time("fetch_time", input_partition);
|
||||
let decode_time = MetricBuilder::new(metrics).subset_time("decode_time", input_partition);
|
||||
let output_rows = MetricBuilder::new(metrics).output_rows(input_partition);
|
||||
let batches_received =
|
||||
MetricBuilder::new(metrics).counter("batches_received", input_partition);
|
||||
|
||||
// Register as OutputBytes so the display uses human_readable_size (KB/MB/GB)
|
||||
// instead of human_readable_count (K/M/B where B means billion).
|
||||
let bytes_received = metrics::Count::new();
|
||||
MetricBuilder::new(metrics)
|
||||
.with_partition(input_partition)
|
||||
.build(MetricValue::OutputBytes(bytes_received.clone()));
|
||||
|
||||
Self {
|
||||
fetch_time,
|
||||
decode_time,
|
||||
output_rows,
|
||||
batches_received,
|
||||
bytes_received,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_output(&self, batch: &RecordBatch) {
|
||||
self.output_rows.add(batch.num_rows());
|
||||
self.batches_received.add(1);
|
||||
self.bytes_received.add(batch.size());
|
||||
}
|
||||
|
||||
/// Average fetch time per batch in milliseconds, or 0 if no batches received.
|
||||
pub fn avg_batch_time_ms(&self) -> u64 {
|
||||
let batches = self.batches_received.value();
|
||||
if batches == 0 {
|
||||
return 0;
|
||||
}
|
||||
// fetch_time.value() is total nanoseconds (usize)
|
||||
let total_ns = self.fetch_time.value() as u64;
|
||||
total_ns / batches as u64 / 1_000_000
|
||||
}
|
||||
}
|
||||
|
||||
/// What a remote scan collects from the nodes it calls, shared by every copy of the scan.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RemoteScanState {
|
||||
pub scan_stats: Arc<Mutex<ScanStats>>,
|
||||
pub peak_memory: Arc<AtomicUsize>,
|
||||
pub partial_err: Arc<Mutex<String>>,
|
||||
/// Filled only for EXPLAIN ANALYZE.
|
||||
pub node_profiles: NodeProfiles,
|
||||
}
|
||||
@@ -23,8 +23,9 @@ use config::{
|
||||
utils::{record_batch_ext::RecordBatchExt, size::bytes_to_human_readable},
|
||||
};
|
||||
use flight::{
|
||||
common::{CustomMessage, FlightMessage, RemoteScanMetrics},
|
||||
decoder::FlightDataDecoder,
|
||||
decoder::{FlightDataDecoder, FlightMessage},
|
||||
message::CustomMessage,
|
||||
remote_scan::RemoteScanMetrics,
|
||||
};
|
||||
use futures::{Stream, StreamExt, ready};
|
||||
use tonic::Streaming;
|
||||
|
||||
@@ -24,8 +24,9 @@ use datafusion::physical_plan::{
|
||||
metrics::{MetricValue, MetricsSet},
|
||||
};
|
||||
use flight::{
|
||||
common::{Metrics, NodeProfiles, RemoteScanMetrics},
|
||||
message::Metrics,
|
||||
profile::{MetricKind, ProfileInputs, ProfileMetric, ProfileNode, RemoteNodeProfile},
|
||||
remote_scan::{NodeProfiles, RemoteScanMetrics},
|
||||
};
|
||||
|
||||
use super::remote_scan_exec::RemoteScanExec;
|
||||
|
||||
@@ -32,7 +32,7 @@ use datafusion::{
|
||||
},
|
||||
};
|
||||
use datafusion_proto::bytes::physical_plan_to_bytes_with_extension_codec;
|
||||
use flight::common::{RemoteScanMetrics, RemoteScanState};
|
||||
use flight::remote_scan::{RemoteScanMetrics, RemoteScanState};
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use futures_util::pin_mut;
|
||||
use opentelemetry::context::FutureExt as _;
|
||||
|
||||
Reference in New Issue
Block a user