Files
greptimedb/tests-integration/src/cluster.rs
T
discord9 28697c9e55 feat(datanode): execute nested MergeScan plans on the datanode (#9206)
* feat(datanode): execute nested MergeScan plans on the datanode

Give a datanode the ability to decode and execute a plan whose MergeScan
carries a nested plan (e.g. an inner MergeScan over a build table), so a
distributed sub-plan pushed to a datanode can itself fan out to other
datanodes. This is the execution/codec capability only; it is not wired
to any user-facing switch and produces no plan by itself.

Codec (src/query/src/query_engine/default_serializer.rs):
- MergeScanAwareSerializer keeps the request's SessionState and the
  engine catalog manager, and decodes a MergeScan payload's tables by
  (catalog, schema, table) name through the engine catalog rather than
  the region-aware request catalog list (which binds every table to the
  request's region and breaks on mismatched columns).

Datanode (src/datanode):
- New DatanodeRegionQueryHandler (region_query.rs) serves region queries
  for the MergeScan nodes of a received plan, resolving region leaders
  via PartitionRuleManager and dispatching to peer datanodes.
- Wire KvBackendCatalogManager + PartitionRuleManager + NodeClients into
  the datanode's catalog/cache registry (with_dist_planner stays false:
  a datanode never rewrites its own plan).

Bug fixes uncovered while wiring this up:
- cache registry: register the table/table_info/table_name/table_route/
  partition_info caches the datanode's catalog manager and partition
  rule manager require (previously missing -> panic on lookup).
- default_serializer: decode_sub_plan bridge no longer drives a future
  on a current-thread runtime from within another executor (spawn a
  dedicated thread + runtime when not on a multi-thread runtime).

Tested by query_engine::default_serializer unit tests: nested MergeScan
round-trip and engine-catalog resolution with mismatched column names.
No user-facing behavior change.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): keep GreptimeDB function bindings after rebuilding decode state

SessionStateBuilder::new_from_existing collects the function registry
(HashMap) into a Vec and build() re-registers each entry; register_udf
registers aliases before the function's own name and the Vec order is
unstable. Rebuilding the session state to install MergeScanAwareSerializer
after the GreptimeDB functions were registered could therefore rebind a
name/alias (e.g. date_format, an alias of DataFusion's to_char) back to
the built-in function. This path runs for every decoded plan, not only
nested MergeScan ones.

Extract the GreptimeDB function registration into register_greptime_functions
and re-apply it after the final build of both the top-level decode state and
the MergeScan payload state. Add regression tests asserting date_format stays
bound to the GreptimeDB implementation on both paths.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(datanode): address review on nested MergeScan capability

Three follow-ups from review of the nested MergeScan execution capability:

1. Layer the datanode caches (P2b). table_cache derives from
   table_info_cache + table_name_cache, and partition_info_cache derives
   from table_route_cache, but they were all registered in a single flat
   CacheRegistry whose invalidate() runs concurrently with no ordering.
   A derived cache could be invalidated and refilled from a base cache
   that had not been invalidated yet, leaving stale metadata. Build the
   base caches and the derived caches as separate layers of a
   LayeredCacheRegistry (reusing the existing layering mechanism) so base
   caches finish invalidating before derived ones. Both the production
   path (cmd) and the test scaffolding now build the same layered
   registry; the flat build_datanode_cache_registry is removed.

2. Don't let a nested MergeScan's internal region query compete for the
   concurrency permit (P2a). An outer query holds its permit until its
   stream is fully consumed, and a nested MergeScan makes the datanode
   issue region queries to peer datanodes as execution stages of that
   same query. If such an inner stage also took a permit, the outer query
   waiting on it would deadlock against its own held permit (or time out
   when max_concurrent_queries > 0). Mark inner DN->DN region queries as
   internal stages: QueryRequest gains an  flag carried over
   the wire in the request header's query_context extensions (reserved
   key, not injectable via hints), and the region server skips acquiring
   a concurrency permit for internal-stage requests on both acquire
   sites. Outer queries still acquire normally.

3. Add an end-to-end capability test
   (tests-integration/.../nested_merge_scan_capability_test.rs): hand-build
   Join(<local probe region>, MergeScan(build regions on both datanodes))
   with mismatched column names and duplicate build keys, ship it to a
   datanode over a real TCP Flight endpoint, consume the stream fully and
   compare the row multiset against the frontend's reference join result,
   assert a real cross-datanode DoGet via an RPC counter, and assert an
   inner region failure fails the whole query. Also migrate the
   with_real_datanode_grpc_addr + DatanodeRpcStats test scaffolding.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): adapt date_format regression test to the updated DataFusion fork

ScalarUDFImpl's trait bounds changed so its impl can no longer be
downcast via as_any(); assert the function equals the GreptimeDB
implementation instead, which to_char can never satisfy.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(datanode): cover the internal stage under the concurrency limit

Add an end-to-end regression test for the nested MergeScan capability
under max_concurrent_queries = 1: both datanodes' only permit is held by
the outer queries' streams while their inner MergeScans fetch the build
regions from the same datanodes over real Flight connections. The result
consumption is bounded by a timeout, so a broken link anywhere in the
chain (marking the inner request internal, carrying the marker in the
request header, honoring it at the receiving acquire sites) fails the
test instead of deadlocking.

Also add a minimal with_datanode_options_override hook to the cluster
builder, and fix the test module doc: the outer request is handed to
RegionServer::handle_remote_read in process; only the inner region
queries go over the wire.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(datanode): cover cold route loads under unrelated invalidations

The table route cache uses InitStrategy::VersionChecked, whose version
counter belongs to the whole CacheContainer rather than to a single
table: any TableId invalidation bumps the shared version, so a cold
load of one table's route retries when an unrelated table is
invalidated concurrently. Document this as a known limitation on the
region query handler, and pin the behavior with an integration test:
run the nested plans while a background task invalidates an unrelated
table id on every datanode, assert the results still match the expected
multiset (retries only add work, they never return a stale route), and
report the observed load amplification via the cache miss counter
(2 loads without invalidations, 8 over three runs with 24 unrelated
invalidations in this run). Also expose the datanode cache registries
on the test cluster so a test can drive the local invalidation path
that the heartbeat handler drives after a metasrv broadcast.

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* docs(query): trim nested plan decoder commentary

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor: remove redundant nested scan support plumbing

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): decode nested MergeScan payloads asynchronously

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(query): avoid unnecessary extension name allocation

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(query): retain extension decoding for manual session states

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(datanode): keep nested scan query admission unchanged

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(datanode): honor query handler factories and trim nested scan fixtures

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(datanode): retain required cache error import

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(query): reuse registered state for nested scan decoding

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(query): verify payload information schema table functions

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(query): retain nested scan runtime and cold-route guards

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* refactor(query): address nested scan review feedback

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* fix(session): preserve read preference across region query hops

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(session): verify strict context rejection at Flow ingress

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

* test(query): remove redundant serializer error case

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-10-10 06:49:38 +00:00

1203 lines
42 KiB
Rust

// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::{BTreeSet, HashMap};
use std::env;
use std::net::TcpListener;
use std::ops::RangeInclusive;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll};
use std::time::Duration;
use api::v1::health_check_server::HealthCheckServer;
use api::v1::region::region_server::RegionServer;
use arrow_flight::flight_service_server::FlightServiceServer;
use cache::{
build_datanode_layered_cache_registry, build_fundamental_cache_registry,
with_default_composite_cache_registry,
};
use catalog::information_extension::DistributedInformationExtension;
use catalog::kvbackend::{
CachedKvBackendBuilder, KvBackendCatalogManagerBuilder, new_read_only_meta_kv_backend,
};
use catalog::process_manager::ProcessManager;
use client::Client;
use client::client_manager::NodeClients;
use cmd::frontend::create_heartbeat_task;
use common_base::Plugins;
use common_datasource::object_store::LocalFileAccess;
use common_event_recorder::EventRecorderOptions;
use common_grpc::channel_manager::{ChannelConfig, ChannelManager};
use common_meta::DatanodeId;
use common_meta::cache::{CacheRegistryBuilder, LayeredCacheRegistryBuilder};
use common_meta::kv_backend::KvBackendRef;
use common_meta::kv_backend::chroot::ChrootKvBackend;
use common_meta::kv_backend::etcd::EtcdStore;
use common_meta::kv_backend::memory::MemoryKvBackend;
use common_meta::peer::Peer;
use common_runtime::Builder as RuntimeBuilder;
use common_runtime::runtime::BuilderBuild;
use common_test_util::temp_dir::create_temp_dir;
use common_time::util::DefaultSystemTimer;
use common_wal::config::{DatanodeWalConfig, MetasrvWalConfig};
use datanode::config::DatanodeOptions;
use datanode::datanode::{Datanode, DatanodeBuilder, ProcedureConfig};
use frontend::frontend::{Frontend, FrontendOptions};
use frontend::instance::Instance as FeInstance;
use frontend::instance::builder::FrontendBuilder;
use frontend::server::Services;
use futures::TryStreamExt;
use hyper_util::rt::TokioIo;
use meta_client::client::MetaClientBuilder;
use meta_srv::cluster::MetaPeerClientRef;
use meta_srv::discovery;
use meta_srv::gc::GcSchedulerOptions;
use meta_srv::metasrv::{Metasrv, MetasrvOptions, SelectorRef};
use meta_srv::mocks::MockInfo;
use mito2::gc::GcConfig;
use mito2::region::MitoRegionRef;
use object_store::config::ObjectStoreConfig;
use rand::Rng;
use servers::grpc::flight::FlightCraftWrapper;
use servers::grpc::region_server::RegionServerRequestHandler;
use servers::grpc::{GrpcOptions, HealthCheckHandler};
use servers::server::ServerHandlers;
use store_api::storage::RegionId;
use tempfile::TempDir;
use tokio::io::{AsyncRead, AsyncWrite};
use tonic::codec::CompressionEncoding;
use tonic::transport::Server;
use tonic::transport::server::Connected;
use tower::{Layer, Service, service_fn};
use uuid::Uuid;
use crate::test_util::{
self, FileDirGuard, StorageType, TestGuard, create_datanode_opts,
create_tmp_dir_and_datanode_opts,
};
/// Flight `DoGet` requests another node sent to a datanode over its network address (see
/// [`GreptimeDbClusterBuilder::with_real_datanode_grpc_addr`]).
///
/// The frontend and datanodes inside the test process use in-process clients, so counted requests
/// come from remote nodes querying regions owned by this datanode.
#[derive(Debug, Default)]
pub struct DatanodeRpcStats {
do_get_requests: AtomicUsize,
}
impl DatanodeRpcStats {
/// The number of Flight `DoGet` requests another node sent to the datanode.
pub fn requests(&self) -> usize {
self.do_get_requests.load(Ordering::Relaxed)
}
}
pub struct GreptimeDbCluster {
pub guards: Vec<TestGuard>,
pub datanode_options: Vec<DatanodeOptions>,
pub datanode_instances: HashMap<DatanodeId, Datanode>,
/// The stats of the network address of every datanode, empty unless the cluster serves the
/// datanodes at their own addresses.
pub datanode_rpc_stats: HashMap<DatanodeId, Arc<DatanodeRpcStats>>,
pub kv_backend: KvBackendRef,
pub metasrv: Arc<Metasrv>,
pub frontend: Arc<Frontend>,
}
impl GreptimeDbCluster {
pub fn fe_instance(&self) -> &Arc<FeInstance> {
&self.frontend.instance
}
/// The stats of the network address of the datanode `datanode_id`, if the cluster serves the
/// datanodes at their own addresses (see
/// [`GreptimeDbClusterBuilder::with_real_datanode_grpc_addr`]).
pub fn datanode_rpc_stats(&self, datanode_id: DatanodeId) -> Option<&Arc<DatanodeRpcStats>> {
self.datanode_rpc_stats.get(&datanode_id)
}
/// List all SST files from all datanodes.
pub async fn list_sst_files_from_all_datanodes(&self) -> BTreeSet<String> {
let mut sst_files = BTreeSet::new();
for datanode in self.datanode_instances.values() {
let region_server = datanode.region_server();
let mito = region_server.mito_engine().unwrap();
let all_files = mito
.all_ssts_from_storage()
.try_collect::<Vec<_>>()
.await
.unwrap()
.into_iter()
.map(|e| e.file_path)
.collect::<Vec<_>>();
sst_files.extend(all_files);
}
sst_files
}
/// List all SST files from the manifests of all datanodes.
pub async fn list_sst_files_from_manifests(&self) -> BTreeSet<String> {
let mut sst_files = BTreeSet::new();
for datanode in self.datanode_instances.values() {
let region_server = datanode.region_server();
let mito = region_server.mito_engine().unwrap();
let all_files = mito
.all_ssts_from_manifest()
.await
.into_iter()
.flat_map(|e| {
if let Some(index_file_path) = e.index_file_path {
vec![e.file_path, index_file_path]
} else {
vec![e.file_path]
}
})
.collect::<BTreeSet<_>>();
sst_files.extend(all_files);
}
sst_files
}
pub async fn list_all_regions(&self) -> HashMap<RegionId, MitoRegionRef> {
let mut regions = HashMap::new();
for datanode in self.datanode_instances.values() {
let region_server = datanode.region_server();
let mito = region_server.mito_engine().unwrap();
for region in mito.regions() {
regions.insert(region.region_id(), region);
}
}
regions
}
}
pub struct GreptimeDbClusterBuilder {
cluster_name: String,
kv_backend: KvBackendRef,
store_config: Option<ObjectStoreConfig>,
store_providers: Option<Vec<StorageType>>,
datanodes: Option<u32>,
datanode_wal_config: DatanodeWalConfig,
metasrv_wal_config: MetasrvWalConfig,
datanode_gc_config: GcConfig,
metasrv_gc_config: GcSchedulerOptions,
frontend_auto_create_table: bool,
shared_home_dir: Option<Arc<TempDir>>,
meta_selector: Option<SelectorRef>,
plugins: Plugins,
local_file_access: LocalFileAccess,
event_recorder_options: EventRecorderOptions,
real_datanode_grpc_addr: bool,
}
impl GreptimeDbClusterBuilder {
pub async fn new(cluster_name: &str) -> Self {
let endpoints = env::var("GT_ETCD_ENDPOINTS").unwrap_or_default();
let kv_backend: KvBackendRef = if endpoints.is_empty() {
Arc::new(MemoryKvBackend::new())
} else {
let endpoints = endpoints
.split(',')
.map(|s| s.to_string())
.collect::<Vec<String>>();
let backend = EtcdStore::with_endpoints(endpoints, 128)
.await
.expect("malformed endpoints");
// Each retry requires a new isolation namespace.
let chroot = format!("{}{}", cluster_name, Uuid::new_v4());
Arc::new(ChrootKvBackend::new(chroot.into(), backend))
};
Self {
cluster_name: cluster_name.to_string(),
kv_backend,
store_config: None,
store_providers: None,
datanodes: None,
datanode_wal_config: DatanodeWalConfig::default(),
metasrv_wal_config: MetasrvWalConfig::default(),
datanode_gc_config: GcConfig::default(),
metasrv_gc_config: GcSchedulerOptions::default(),
frontend_auto_create_table: true,
shared_home_dir: None,
meta_selector: None,
plugins: Plugins::default(),
local_file_access: LocalFileAccess::default(),
event_recorder_options: EventRecorderOptions::default(),
real_datanode_grpc_addr: false,
}
}
#[must_use]
pub fn with_store_config(mut self, store_config: ObjectStoreConfig) -> Self {
self.store_config = Some(store_config);
self
}
#[must_use]
pub fn with_store_providers(mut self, store_providers: Vec<StorageType>) -> Self {
self.store_providers = Some(store_providers);
self
}
#[must_use]
pub fn with_datanodes(mut self, datanodes: u32) -> Self {
self.datanodes = Some(datanodes);
self
}
#[must_use]
pub fn with_datanode_wal_config(mut self, datanode_wal_config: DatanodeWalConfig) -> Self {
self.datanode_wal_config = datanode_wal_config;
self
}
#[must_use]
pub fn with_metasrv_wal_config(mut self, metasrv_wal_config: MetasrvWalConfig) -> Self {
self.metasrv_wal_config = metasrv_wal_config;
self
}
#[must_use]
pub fn with_datanode_gc_config(mut self, datanode_gc_config: GcConfig) -> Self {
self.datanode_gc_config = datanode_gc_config;
self
}
pub fn with_metasrv_gc_config(mut self, metasrv_gc_config: GcSchedulerOptions) -> Self {
self.metasrv_gc_config = metasrv_gc_config;
self
}
#[must_use]
pub fn with_event_recorder_options(
mut self,
event_recorder_options: EventRecorderOptions,
) -> Self {
self.event_recorder_options = event_recorder_options;
self
}
/// Sets whether the test frontend automatically creates tables on write.
#[must_use]
pub fn with_frontend_auto_create_table(mut self, auto_create_table: bool) -> Self {
self.frontend_auto_create_table = auto_create_table;
self
}
#[must_use]
pub fn with_shared_home_dir(mut self, shared_home_dir: Arc<TempDir>) -> Self {
self.shared_home_dir = Some(shared_home_dir);
self
}
#[must_use]
pub fn with_meta_selector(mut self, selector: SelectorRef) -> Self {
self.meta_selector = Some(selector);
self
}
/// Sets the [Plugins] used by the metasrv.
#[must_use]
pub fn with_plugins(mut self, plugins: Plugins) -> Self {
self.plugins = plugins;
self
}
/// Configure the frontend COPY sandbox for filesystem integration tests.
pub fn with_local_file_access(mut self, access: LocalFileAccess) -> Self {
self.local_file_access = access;
self
}
/// Serves every datanode on its own real localhost TCP port and registers that
/// address in metasrv, instead of the default in-process duplex placeholder address
/// that all datanodes share.
///
/// Datanodes serve the region queries of the plans they execute by querying the
/// leader of every region of the plan (see `DatanodeRegionQueryHandler`), so a test
/// that exercises a datanode-to-datanode region query needs each datanode to be
/// reachable at the address recorded in the table route, i.e. the address the
/// datanode registers with its heartbeat.
#[must_use]
pub fn with_real_datanode_grpc_addr(mut self, real_datanode_grpc_addr: bool) -> Self {
self.real_datanode_grpc_addr = real_datanode_grpc_addr;
self
}
pub async fn build_with(
&self,
datanode_options: Vec<DatanodeOptions>,
start_frontend_servers: bool,
guards: Vec<TestGuard>,
) -> GreptimeDbCluster {
let datanodes = datanode_options.len();
let channel_config = ChannelConfig::new().timeout(Some(Duration::from_secs(20)));
let datanode_clients = Arc::new(NodeClients::new(channel_config));
let opt = MetasrvOptions {
procedure: ProcedureConfig {
// Due to large network delay during cross data-center.
// We only make max_retry_times and retry_delay large than the default in tests.
max_retry_times: 5,
retry_delay: Duration::from_secs(1),
max_metadata_value_size: None,
max_running_procedures: 128,
},
wal: self.metasrv_wal_config.clone(),
grpc: GrpcOptions {
server_addr: "127.0.0.1:3002".to_string(),
..Default::default()
},
gc: self.metasrv_gc_config.clone(),
event_recorder: self.event_recorder_options.clone(),
..Default::default()
};
test_util::prepare_another_catalog_and_schema_with_kv_backend(self.kv_backend.clone())
.await;
let metasrv = meta_srv::mocks::mock_with_plugins(
opt,
self.kv_backend.clone(),
self.meta_selector.clone(),
Some(datanode_clients.clone()),
None,
self.plugins.clone(),
)
.await;
let datanode_instances = self
.build_datanodes_with_options(&metasrv, &datanode_options)
.await;
let datanode_rpc_stats = if self.real_datanode_grpc_addr {
datanode_options
.iter()
.map(|opts| (opts.node_id.unwrap(), Arc::new(DatanodeRpcStats::default())))
.collect::<HashMap<_, _>>()
} else {
HashMap::new()
};
build_datanode_clients(
datanode_clients.clone(),
&datanode_instances,
&datanode_options,
&datanode_rpc_stats,
)
.await;
self.wait_datanodes_alive(metasrv.metasrv.meta_peer_client(), datanodes)
.await;
let mut frontend = self
.build_frontend(
metasrv.clone(),
datanode_clients.clone(),
start_frontend_servers,
)
.await;
frontend.start().await.unwrap();
GreptimeDbCluster {
datanode_options,
guards,
datanode_instances,
datanode_rpc_stats,
kv_backend: self.kv_backend.clone(),
metasrv: metasrv.metasrv,
frontend: Arc::new(frontend),
}
}
pub async fn build(&self, start_frontend_servers: bool) -> GreptimeDbCluster {
let datanodes = self.datanodes.unwrap_or(4);
let (datanode_options, guards) = self.build_datanode_options_and_guards(datanodes).await;
self.build_with(datanode_options, start_frontend_servers, guards)
.await
}
async fn build_datanode_options_and_guards(
&self,
datanodes: u32,
) -> (Vec<DatanodeOptions>, Vec<TestGuard>) {
let mut options = Vec::with_capacity(datanodes as usize);
let mut guards = Vec::with_capacity(datanodes as usize);
for i in 0..datanodes {
let datanode_id = i as u64 + 1;
let mut opts = if let Some(store_config) = &self.store_config {
let home_dir = if let Some(home_dir) = &self.shared_home_dir {
home_dir.path().to_str().unwrap().to_string()
} else {
let home_tmp_dir = create_temp_dir(&format!("gt_home_{}", self.cluster_name));
let home_dir = home_tmp_dir.path().to_str().unwrap().to_string();
guards.push(TestGuard {
home_guard: FileDirGuard::new(home_tmp_dir),
storage_guards: Vec::new(),
});
home_dir
};
create_datanode_opts(
store_config.clone(),
vec![],
home_dir,
self.datanode_wal_config.clone(),
self.datanode_gc_config.clone(),
)
} else {
let (opts, guard) = create_tmp_dir_and_datanode_opts(
StorageType::File,
self.store_providers.clone().unwrap_or_default(),
&format!("{}-dn-{}", self.cluster_name, datanode_id),
self.datanode_wal_config.clone(),
self.datanode_gc_config.clone(),
);
guards.push(guard);
opts
};
opts.node_id = Some(datanode_id);
if self.real_datanode_grpc_addr {
// Give every datanode its own address, so that the other datanodes can
// reach it (see `with_real_datanode_grpc_addr`).
let addr = self.choose_datanode_grpc_addr();
opts.grpc.bind_addr = addr.clone();
opts.grpc.server_addr = addr;
}
options.push(opts);
}
(options, guards)
}
/// Chooses a free localhost port for a datanode.
///
/// Uses a port range disjoint from the one of the frontend servers
/// (see [`Self::build_frontend_options`]).
fn choose_datanode_grpc_addr(&self) -> String {
let localhost = "127.0.0.1";
let port = self.choose_random_unused_port(24000..=34000, 20, localhost);
format!("{}:{}", localhost, port)
}
async fn build_datanodes_with_options(
&self,
metasrv: &MockInfo,
options: &[DatanodeOptions],
) -> HashMap<DatanodeId, Datanode> {
let mut instances = HashMap::with_capacity(options.len());
for opts in options {
let datanode = self.create_datanode(opts.clone(), metasrv.clone()).await;
let datanode_id = opts.node_id.unwrap();
instances.insert(datanode_id, datanode);
}
instances
}
async fn wait_datanodes_alive(
&self,
meta_peer_client: &MetaPeerClientRef,
expected_datanodes: usize,
) {
for _ in 0..100 {
let alive_datanodes = discovery::utils::alive_datanode_infos(
&DefaultSystemTimer,
meta_peer_client.as_ref(),
Duration::from_secs(u64::MAX),
None,
)
.await
.unwrap()
.len();
if alive_datanodes == expected_datanodes {
return;
}
tokio::time::sleep(Duration::from_micros(100)).await
}
panic!("Some Datanodes are not alive in 10 seconds!")
}
async fn create_datanode(&self, opts: DatanodeOptions, metasrv: MockInfo) -> Datanode {
let mut meta_client = MetaClientBuilder::datanode_default_options(opts.node_id.unwrap())
.channel_manager(metasrv.channel_manager)
.build();
meta_client.start(&[&metasrv.server_addr]).await.unwrap();
let meta_client = Arc::new(meta_client);
let meta_backend = new_read_only_meta_kv_backend(meta_client.clone());
let layered_cache_registry =
Arc::new(build_datanode_layered_cache_registry(meta_backend.clone()));
let mut builder = DatanodeBuilder::new(opts.clone(), Plugins::default(), meta_backend);
builder
.with_cache_registry(layered_cache_registry.clone())
.with_meta_client(meta_client);
let mut datanode = builder.build().await.unwrap();
datanode.start_heartbeat().await.unwrap();
datanode
}
async fn build_frontend(
&self,
metasrv: MockInfo,
datanode_clients: Arc<NodeClients>,
start_frontend_servers: bool,
) -> Frontend {
let mut meta_client = MetaClientBuilder::frontend_default_options()
.channel_manager(metasrv.channel_manager)
.enable_access_cluster_info()
.build();
meta_client.start(&[&metasrv.server_addr]).await.unwrap();
let meta_client = Arc::new(meta_client);
let readonly_meta_backend = new_read_only_meta_kv_backend(meta_client.clone());
let cached_meta_backend =
Arc::new(CachedKvBackendBuilder::new(readonly_meta_backend.clone()).build());
let layered_cache_builder = LayeredCacheRegistryBuilder::default().add_cache_registry(
CacheRegistryBuilder::default()
.add_cache(cached_meta_backend.clone())
.build(),
);
let fundamental_cache_registry =
build_fundamental_cache_registry(readonly_meta_backend.clone());
let cache_registry = Arc::new(
with_default_composite_cache_registry(
layered_cache_builder.add_cache_registry(fundamental_cache_registry),
)
.unwrap()
.build(),
);
let information_extension = Arc::new(DistributedInformationExtension::new(
meta_client.clone(),
datanode_clients.clone(),
));
let catalog_manager = KvBackendCatalogManagerBuilder::new(
information_extension,
cached_meta_backend.clone(),
cache_registry.clone(),
)
.build();
let fe_opts = self.build_frontend_options();
let instance = FrontendBuilder::new(
fe_opts.clone(),
cached_meta_backend.clone(),
cache_registry.clone(),
catalog_manager,
datanode_clients,
meta_client.clone(),
Arc::new(ProcessManager::new(fe_opts.grpc.server_addr.clone(), None)),
)
.with_local_file_access(self.local_file_access.clone())
.with_local_cache_invalidator(cache_registry)
.try_build()
.await
.unwrap();
let heartbeat_task = create_heartbeat_task(&fe_opts, meta_client, &instance);
let instance = Arc::new(instance);
// Build the servers for the frontend.
let servers = if start_frontend_servers {
Services::new(fe_opts, instance.clone(), Plugins::default())
.build()
.unwrap()
} else {
ServerHandlers::default()
};
Frontend {
instance,
servers,
heartbeat_task: Some(heartbeat_task),
}
}
fn build_frontend_options(&self) -> FrontendOptions {
let mut fe_opts = FrontendOptions {
auto_create_table: self.frontend_auto_create_table,
event_recorder: self.event_recorder_options.clone(),
..Default::default()
};
// Choose a random unused port between [14000, 24000] for local test to avoid conflicts.
let port_range = 14000..=24000;
let max_attempts = 10;
let localhost = "127.0.0.1";
let construct_addr = |port: u16| format!("{}:{}", localhost, port);
fe_opts.http.addr = construct_addr(self.choose_random_unused_port(
port_range.clone(),
max_attempts,
localhost,
));
let grpc_port = self.choose_random_unused_port(port_range.clone(), max_attempts, localhost);
fe_opts.grpc.bind_addr = construct_addr(grpc_port);
fe_opts.grpc.server_addr = construct_addr(grpc_port);
fe_opts.mysql.addr = construct_addr(self.choose_random_unused_port(
port_range.clone(),
max_attempts,
localhost,
));
fe_opts.postgres.addr =
construct_addr(self.choose_random_unused_port(port_range, max_attempts, localhost));
fe_opts
}
// Choose a random unused port between [start, end].
fn choose_random_unused_port(
&self,
port_range: RangeInclusive<u16>,
max_attempts: u16,
addr: &str,
) -> u16 {
let mut rng = rand::rng();
let mut attempts = 0;
while attempts < max_attempts {
let port = rng.random_range(port_range.clone());
if TcpListener::bind(format!("{}:{}", addr, port)).is_ok() {
return port;
}
attempts += 1;
}
panic!("No unused port found");
}
}
async fn build_datanode_clients(
clients: Arc<NodeClients>,
instances: &HashMap<DatanodeId, Datanode>,
options: &[DatanodeOptions],
rpc_stats: &HashMap<DatanodeId, Arc<DatanodeRpcStats>>,
) {
for opts in options {
let datanode_id = opts.node_id.unwrap();
let instance = &instances[&datanode_id];
let (addr, client) = create_datanode_client(
instance,
&opts.grpc.server_addr,
rpc_stats.get(&datanode_id).cloned(),
)
.await;
clients
.insert_client(Peer::new(datanode_id, addr), client)
.await;
}
}
/// Runs a gRPC server that serves the region server of `datanode` over `incoming` until the
/// cluster is dropped.
fn serve_datanode_region_server<I, IO>(
datanode: &Datanode,
incoming: I,
rpc_stats: Option<Arc<DatanodeRpcStats>>,
) where
I: futures::Stream<Item = Result<IO, std::io::Error>> + Send + 'static,
IO: AsyncRead + AsyncWrite + Connected + Unpin + Send + 'static,
{
let runtime = RuntimeBuilder::default()
.worker_threads(2)
.thread_name("grpc-handlers")
.build()
.unwrap();
let flight_handler = FlightCraftWrapper(datanode.region_server());
let region_server_handler =
RegionServerRequestHandler::new(Arc::new(datanode.region_server()), runtime);
tokio::spawn(async move {
Server::builder()
.layer(CountRequestsLayer { rpc_stats })
.add_service(
FlightServiceServer::new(flight_handler)
.accept_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Gzip)
.send_compressed(CompressionEncoding::Zstd),
)
.add_service(
RegionServer::new(region_server_handler)
.accept_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Gzip)
.send_compressed(CompressionEncoding::Zstd),
)
.add_service(HealthCheckServer::new(HealthCheckHandler))
.serve_with_incoming(incoming)
.await
});
}
/// Serves the region server of `datanode` and returns the address and the client of the address.
///
/// The returned client is always an in-process client: the frontend and the datanodes of the test
/// process reach the region server through a duplex stream, no matter which address they use. This
/// keeps the in-process traffic out of `rpc_stats`.
///
/// [`test_util::PEER_PLACEHOLDER_ADDR`] is not a real address, so it cannot be served over the
/// network at all. Any other address is a real one: the region server is additionally served on
/// its local TCP port (`rpc_stats` counts the requests of the remote nodes that reach it, e.g. the
/// region query that a datanode sends for the regions of another datanode).
async fn create_datanode_client(
datanode: &Datanode,
addr: &str,
rpc_stats: Option<Arc<DatanodeRpcStats>>,
) -> (String, Client) {
let (client, server) = tokio::io::duplex(1024);
serve_datanode_region_server(
datanode,
futures::stream::iter(vec![Ok::<_, std::io::Error>(server)]),
None,
);
if addr != test_util::PEER_PLACEHOLDER_ADDR {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
serve_datanode_region_server(datanode, incoming, rpc_stats);
}
let mut client = Some(client);
let channel_manager = ChannelManager::new();
channel_manager
.reset_with_connector(
addr,
service_fn(move |_| {
let client = client.take();
async move {
client
.map(TokioIo::new)
.ok_or_else(|| std::io::Error::other("Client already taken"))
}
}),
)
.unwrap();
(
addr.to_string(),
// The mock connector pool lives on this single manager, so both lanes
// intentionally share it (same semantics as the legacy constructor).
Client::with_query_and_control_managers(channel_manager.clone(), channel_manager, [addr]),
)
}
/// Counts Flight `DoGet` requests received by the wrapped server.
#[derive(Clone)]
struct CountRequests<S> {
inner: S,
rpc_stats: Option<Arc<DatanodeRpcStats>>,
}
impl<S, ReqBody> Service<http::Request<ReqBody>> for CountRequests<S>
where
S: Service<http::Request<ReqBody>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: http::Request<ReqBody>) -> Self::Future {
if request.uri().path() == "/arrow.flight.protocol.FlightService/DoGet"
&& let Some(rpc_stats) = &self.rpc_stats
{
rpc_stats.do_get_requests.fetch_add(1, Ordering::Relaxed);
}
self.inner.call(request)
}
}
#[derive(Clone)]
struct CountRequestsLayer {
rpc_stats: Option<Arc<DatanodeRpcStats>>,
}
impl<S> Layer<S> for CountRequestsLayer {
type Service = CountRequests<S>;
fn layer(&self, inner: S) -> Self::Service {
CountRequests {
inner,
rpc_stats: self.rpc_stats.clone(),
}
}
}
// Mock connectors are registered on a shared channel manager.
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use api::v1::flow::FlowRequest;
use api::v1::region::{
ListMetadataRequest, RegionRequest, RegionRequestHeader, region_request,
};
use catalog::memory::new_memory_catalog_manager;
use client::Client;
use common_error::ext::ErrorExt;
use common_error::status_code::StatusCode;
use common_meta::ddl_manager::{DdlManager, DdlManagerConfigurator, DdlManagerConfiguratorRef};
use common_meta::key::TableMetadataManager;
use common_meta::key::flow::FlowMetadataManager;
use common_meta::kv_backend::memory::MemoryKvBackend;
use common_meta::node_manager::{DatanodeManager, FlownodeManager};
use common_meta::peer::Peer;
use flow::{FlownodeBuilder, FlownodeOptions, FlownodeServiceBuilder, FrontendClient};
use meta_srv::metasrv::builder::DdlManagerConfigureContext;
use super::*;
const HEALTH_REFRESH_INTERVAL: Duration = Duration::from_millis(10);
const HEALTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
async fn wait_for_active_route(client: &Client, active_addr: &str) {
client.find_channel().unwrap();
tokio::time::timeout(HEALTH_REFRESH_TIMEOUT, async {
let mut interval = tokio::time::interval(HEALTH_REFRESH_INTERVAL);
loop {
interval.tick().await;
let (active, inactive) = client.peer_addresses_by_state();
if active.len() == 1 && active[0] == active_addr && inactive.len() == 1 {
return;
}
}
})
.await
.expect("background health routing did not select the active peer");
}
async fn create_region_requester_health_client(datanode: &Datanode) -> (Peer, Peer, Client) {
const ACTIVE_ADDR: &str = "127.0.0.1:3001";
const INACTIVE_ADDR: &str = "127.0.0.1:3002";
let (active_client, server) = tokio::io::duplex(1024);
let runtime = RuntimeBuilder::default()
.worker_threads(2)
.thread_name("grpc-handlers")
.build()
.unwrap();
let region_server_handler =
RegionServerRequestHandler::new(Arc::new(datanode.region_server()), runtime);
tokio::spawn(async move {
Server::builder()
.add_service(RegionServer::new(region_server_handler))
.add_service(HealthCheckServer::new(HealthCheckHandler))
.serve_with_incoming(futures::stream::iter(vec![Ok::<_, std::io::Error>(server)]))
.await
});
let channel_manager = ChannelManager::new();
let mut active_client = Some(active_client);
channel_manager
.reset_with_connector(
ACTIVE_ADDR,
service_fn(move |_| {
let client = active_client.take();
async move {
client
.map(TokioIo::new)
.ok_or_else(|| std::io::Error::other("Active client already taken"))
}
}),
)
.unwrap();
channel_manager
.reset_with_connector(
INACTIVE_ADDR,
service_fn(|_| async {
Err::<TokioIo<tokio::io::DuplexStream>, _>(std::io::Error::other(
"Inactive synthetic peer",
))
}),
)
.unwrap();
let client = Client::with_manager_and_urls_and_options(
channel_manager,
[INACTIVE_ADDR, ACTIVE_ADDR],
client::ClientOptions {
health_check_interval: HEALTH_REFRESH_INTERVAL,
..Default::default()
},
);
(
Peer::new(1, ACTIVE_ADDR),
Peer::new(2, INACTIVE_ADDR),
client,
)
}
async fn create_flow_requester_health_client(
flownode: &flow::FlownodeInstance,
) -> (Peer, Peer, Client) {
const ACTIVE_ADDR: &str = "127.0.0.1:3003";
const INACTIVE_ADDR: &str = "127.0.0.1:3004";
let (active_client, server) = tokio::io::duplex(1024);
let flow_service = flownode.flownode_server().create_flow_service();
tokio::spawn(async move {
Server::builder()
.add_service(flow_service)
.add_service(HealthCheckServer::new(HealthCheckHandler))
.serve_with_incoming(futures::stream::iter(vec![Ok::<_, std::io::Error>(server)]))
.await
});
let channel_manager = ChannelManager::new();
let mut active_client = Some(active_client);
channel_manager
.reset_with_connector(
ACTIVE_ADDR,
service_fn(move |_| {
let client = active_client.take();
async move {
client
.map(TokioIo::new)
.ok_or_else(|| std::io::Error::other("Active client already taken"))
}
}),
)
.unwrap();
channel_manager
.reset_with_connector(
INACTIVE_ADDR,
service_fn(|_| async {
Err::<TokioIo<tokio::io::DuplexStream>, _>(std::io::Error::other(
"Inactive synthetic peer",
))
}),
)
.unwrap();
let client = Client::with_manager_and_urls_and_options(
channel_manager,
[INACTIVE_ADDR, ACTIVE_ADDR],
client::ClientOptions {
health_check_interval: HEALTH_REFRESH_INTERVAL,
..Default::default()
},
);
(
Peer::new(1, ACTIVE_ADDR),
Peer::new(2, INACTIVE_ADDR),
client,
)
}
async fn start_flownode(addr: String) -> flow::FlownodeInstance {
let kv_backend = Arc::new(MemoryKvBackend::new());
let table_meta = Arc::new(TableMetadataManager::new(kv_backend.clone()));
table_meta.init().await.unwrap();
let flow_meta = Arc::new(FlowMetadataManager::new(kv_backend));
let catalog_manager = new_memory_catalog_manager().unwrap();
let (frontend_client, _handler) =
FrontendClient::from_empty_grpc_handler(Default::default());
let mut opts = FlownodeOptions::default();
opts.grpc.bind_addr = addr.clone();
opts.grpc.server_addr = addr;
let mut flownode = FlownodeBuilder::new(
opts.clone(),
Plugins::default(),
table_meta,
catalog_manager,
flow_meta,
Arc::new(frontend_client),
)
.build()
.await
.unwrap();
let services = FlownodeServiceBuilder::new(&opts)
.with_default_grpc_server(flownode.flownode_server())
.build()
.unwrap();
flownode.setup_services(services);
flownode.start().await.unwrap();
flownode
}
struct TestDdlManagerConfigurator(Arc<AtomicBool>);
#[async_trait::async_trait]
impl DdlManagerConfigurator<DdlManagerConfigureContext> for TestDdlManagerConfigurator {
async fn configure(
&self,
ddl_manager: DdlManager,
_ctx: DdlManagerConfigureContext,
) -> std::result::Result<DdlManager, common_error::ext::BoxedError> {
self.0.store(true, Ordering::SeqCst);
Ok(ddl_manager)
}
}
#[tokio::test(flavor = "multi_thread")]
async fn test_build_cluster_with_plugins() {
let configured = Arc::new(AtomicBool::new(false));
let plugins = Plugins::default();
plugins.insert::<DdlManagerConfiguratorRef<DdlManagerConfigureContext>>(Arc::new(
TestDdlManagerConfigurator(configured.clone()),
));
let _cluster = GreptimeDbClusterBuilder::new("test_cluster_with_plugins")
.await
.with_datanodes(1)
.with_plugins(plugins)
.build(false)
.await;
assert!(configured.load(Ordering::SeqCst));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_region_requester_health_check_and_list_metadata() {
// Arrange: configure active and inactive synthetic peers over the duplex harness.
let cluster = GreptimeDbClusterBuilder::new("region_requester_health_check")
.await
.with_datanodes(1)
.build(false)
.await;
let (active_peer, inactive_peer, client) =
create_region_requester_health_client(cluster.datanode_instances.get(&1).unwrap())
.await;
let clients = NodeClients::default();
clients
.insert_client(active_peer.clone(), client.clone())
.await;
clients
.insert_client(inactive_peer.clone(), client.clone())
.await;
// Act: wait for background health routing, then send a real Region RPC.
wait_for_active_route(&client, &active_peer.addr).await;
let requester = clients.datanode(&active_peer).await;
let response = requester
.handle(RegionRequest {
header: Some(RegionRequestHeader::default()),
body: Some(region_request::Body::ListMetadata(ListMetadataRequest {
region_ids: vec![],
})),
})
.await
.unwrap();
// Assert: the inactive peer was rejected and the RegionRequester reached the active service.
assert_eq!(
(vec![active_peer.addr], vec![inactive_peer.addr]),
client.peer_addresses_by_state()
);
assert_eq!(b"[]", response.metadata.as_slice());
}
#[tokio::test(flavor = "multi_thread")]
async fn test_flow_requester_health_check_and_handle_request() {
// Arrange: start a flownode and expose its production flow service through a duplex harness.
let mut flownode = start_flownode("127.0.0.1:0".to_string()).await;
let (active_peer, inactive_peer, client) =
create_flow_requester_health_client(&flownode).await;
let clients = NodeClients::default();
clients
.insert_client(active_peer.clone(), client.clone())
.await;
clients
.insert_client(inactive_peer.clone(), client.clone())
.await;
// Act: wait for background health routing, then send a real Flow RPC.
wait_for_active_route(&client, &active_peer.addr).await;
let response = clients
.flownode(&active_peer)
.await
.handle(FlowRequest::default())
.await;
// Assert: the inactive peer was rejected and the production Flow service handled the RPC.
assert_eq!(
(vec![active_peer.addr.clone()], vec![inactive_peer.addr]),
client.peer_addresses_by_state()
);
assert_eq!(
StatusCode::InvalidArguments,
response.unwrap_err().status_code()
);
for value in ["", "unknown", "follower"] {
let mut query_context =
api::v1::QueryContext::from(session::context::QueryContext::arc().as_ref());
query_context.extensions.insert(
session::hints::READ_PREFERENCE_EXTENSION_KEY.to_string(),
value.to_string(),
);
let error = clients
.flownode(&active_peer)
.await
.handle(FlowRequest {
header: Some(api::v1::flow::FlowRequestHeader {
tracing_context: Default::default(),
query_context: Some(query_context),
}),
body: None,
})
.await
.unwrap_err();
assert_eq!(StatusCode::InvalidArguments, error.status_code());
let message = error.output_msg();
assert!(message.contains("read_preference"), "{message}");
if !value.is_empty() {
assert!(message.contains(value), "{message}");
}
}
flownode.shutdown().await.unwrap();
}
}