diff --git a/Cargo.lock b/Cargo.lock
index d84446150d6..e97dd203ee3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10136,6 +10136,7 @@ dependencies = [
"meta-client",
"meter-core",
"meter-macros",
+ "mito-codec",
"moka",
"object-store",
"partition",
diff --git a/config/config.md b/config/config.md
index 5d76c8f7445..381abde24c4 100644
--- a/config/config.md
+++ b/config/config.md
@@ -391,6 +391,11 @@
| `query.experimental_spill_path` | String | Unset | Spill directory path. Ignored unless mode is "custom". |
| `query.experimental_spill_max_temp_directory_size` | String | `1GiB` | Maximum total size of spill directory (default: "1GiB").
Ignored unless mode is "custom". |
| `query.experimental_spill_compression` | String | `uncompressed` | Compression for spilled data files: "uncompressed" (default), "lz4_frame", "zstd".
Ignored unless mode is "custom". |
+| `write_retry` | -- | -- | Retry retryable distributed write failures.
Backoff grows exponentially, with jitter to spread concurrent retries. |
+| `write_retry.max_retries` | Integer | `3` | Maximum retries after the initial write attempt. Set to 0 to disable retries. |
+| `write_retry.initial_backoff` | String | `1500ms` | Initial backoff before jitter is applied. Must be positive. |
+| `write_retry.max_backoff` | String | `10s` | Maximum backoff before jitter is applied. Must be at least initial_backoff. |
+| `write_retry.backoff_multiplier` | Float | `2.0` | Backoff multiplier. Must be finite and at least 1. |
| `datanode` | -- | -- | Datanode options. |
| `datanode.client` | -- | -- | Datanode client options. |
| `datanode.client.connect_timeout` | String | 1s | Connect server timeout. Defaults to `1s`; this example sets it to `10s`. |
diff --git a/config/frontend.example.toml b/config/frontend.example.toml
index 9a1c3db63fa..c42d254671c 100644
--- a/config/frontend.example.toml
+++ b/config/frontend.example.toml
@@ -398,6 +398,18 @@ memory_pool_size = "50%"
#+ experimental_spill_compression = "uncompressed"
# --- End spill-to-disk ---
+## Retry retryable distributed write failures.
+## Backoff grows exponentially, with jitter to spread concurrent retries.
+[write_retry]
+## Maximum retries after the initial write attempt. Set to 0 to disable retries.
+max_retries = 3
+## Initial backoff before jitter is applied. Must be positive.
+initial_backoff = "1500ms"
+## Maximum backoff before jitter is applied. Must be at least initial_backoff.
+max_backoff = "10s"
+## Backoff multiplier. Must be finite and at least 1.
+backoff_multiplier = 2.0
+
## Datanode options.
[datanode]
## Datanode client options.
diff --git a/src/client/src/client_manager.rs b/src/client/src/client_manager.rs
index ef26c107cd5..4d723361e80 100644
--- a/src/client/src/client_manager.rs
+++ b/src/client/src/client_manager.rs
@@ -52,7 +52,18 @@ impl Debug for NodeClients {
#[async_trait::async_trait]
impl DatanodeManager for NodeClients {
async fn datanode(&self, datanode: &Peer) -> DatanodeRef {
- Arc::new(self.make_region_requester(datanode).await)
+ let client = self.get_client(datanode).await;
+
+ let ChannelConfig {
+ send_compression,
+ accept_compression,
+ ..
+ } = self.control_channel_manager.config();
+ Arc::new(RegionRequester::new(
+ client,
+ *send_compression,
+ *accept_compression,
+ ))
}
}
@@ -78,18 +89,6 @@ impl NodeClients {
}
}
- /// Creates a [`RegionRequester`] with the cached client and control channel compression settings.
- pub async fn make_region_requester(&self, datanode: &Peer) -> RegionRequester {
- let client = self.get_client(datanode).await;
-
- let ChannelConfig {
- send_compression,
- accept_compression,
- ..
- } = self.control_channel_manager.config();
- RegionRequester::new(client, *send_compression, *accept_compression)
- }
-
pub async fn get_client(&self, datanode: &Peer) -> Client {
self.clients
.get_with_by_ref(datanode, async move {
diff --git a/src/client/src/error.rs b/src/client/src/error.rs
index 5c70e5fbe91..fdf0df9582f 100644
--- a/src/client/src/error.rs
+++ b/src/client/src/error.rs
@@ -191,9 +191,22 @@ impl ErrorExt for Error {
Error::Tonic { retry_hint, .. } => *retry_hint,
Error::FlightGet { source, .. }
| Error::FlightStream { source, .. }
- | Error::RegionServer { source, .. }
| Error::FlowServer { source, .. }
| Error::External { source, .. } => source.retry_hint(),
+ Error::RegionServer { code, source, .. } => {
+ let hint = source.retry_hint();
+ // Preserve the transport retry policy previously applied by should_retry().
+ if !hint.is_retryable()
+ && matches!(
+ code,
+ Code::Unavailable | Code::Cancelled | Code::DeadlineExceeded
+ )
+ {
+ RetryHint::Retryable
+ } else {
+ hint
+ }
+ }
Error::ConvertFlightData { source, .. }
| Error::CreateChannel { source, .. }
| Error::CreateTlsChannel { source, .. } => source.retry_hint(),
@@ -207,13 +220,9 @@ define_from_tonic_status!(Error, Tonic);
impl From for common_meta::error::Error {
fn from(error: Error) -> Self {
- if error.should_retry() {
- Self::retry_later(error)
- } else {
- Self::External {
- source: BoxedError::new(error),
- location: location!(),
- }
+ Self::External {
+ source: BoxedError::new(error),
+ location: location!(),
}
}
}
diff --git a/src/client/src/region.rs b/src/client/src/region.rs
index 47c477b06b0..f9ebbb0d0bf 100644
--- a/src/client/src/region.rs
+++ b/src/client/src/region.rs
@@ -67,7 +67,7 @@ pub struct RegionRequester {
#[async_trait]
impl Datanode for RegionRequester {
- async fn handle(&self, request: RegionRequest) -> MetaResult {
+ async fn handle_request(&self, request: Arc) -> MetaResult {
self.handle_inner(request).await.map_err(Into::into)
}
@@ -220,16 +220,7 @@ impl RegionRequester {
.await
}
.await
- .map_err(|e| {
- let code = e.code();
- // Uses `Error::RegionServer` instead of `Error::Server`
- error::Error::RegionServer {
- addr,
- code,
- source: BoxedError::new(error::Error::from(e)),
- location: location!(),
- }
- })?
+ .map_err(|status| map_region_error(status, addr))?
.into_inner();
check_response_header(&response.header)?;
@@ -238,6 +229,15 @@ impl RegionRequester {
}
}
+fn map_region_error(status: tonic::Status, addr: String) -> error::Error {
+ error::Error::RegionServer {
+ addr,
+ code: status.code(),
+ source: BoxedError::new(error::Error::from(status)),
+ location: location!(),
+ }
+}
+
async fn recordbatches_from_flight_message_stream(
addr: String,
flight_message_stream: S,
@@ -761,21 +761,104 @@ mod tests {
assert_eq!(error.retry_hint(), baseline.retry_hint());
assert_eq!(error.tonic_code(), baseline.tonic_code());
}
- let error = Datanode::handle(&requester, build_insert_request())
- .await
- .unwrap_err();
+ let datanode: &dyn Datanode = &requester;
+ let error = datanode.handle(build_insert_request()).await.unwrap_err();
assert_eq!(error.status_code(), StatusCode::RegionNotFound);
assert_eq!(error.retry_hint(), hint);
- match hint {
- RetryHint::Retryable | RetryHint::RetryAfterRouteRefresh => assert!(matches!(
- error,
- meta_error::Error::RetryLater {
- clean_poisons: false,
+ assert!(matches!(error, meta_error::Error::External { .. }));
+ }
+ }
+
+ #[tokio::test]
+ async fn test_datanode_transport_retry_hints() {
+ let server = RegionTestServer::start().await;
+ let requester = server.create_requester(ChannelConfig::default(), false, false);
+ let datanode: &dyn Datanode = &requester;
+ let statuses = [
+ tonic::Code::Unavailable,
+ tonic::Code::Cancelled,
+ tonic::Code::DeadlineExceeded,
+ tonic::Code::FailedPrecondition,
+ ]
+ .into_iter()
+ .flat_map(|code| {
+ [
+ None,
+ Some(RetryHint::NonRetryable),
+ Some(RetryHint::Retryable),
+ Some(RetryHint::RetryAfterRouteRefresh),
+ ]
+ .into_iter()
+ .map(move |hint| (code, hint))
+ });
+ for (code, hint) in statuses {
+ for body in [
+ region_request::Body::Inserts(Default::default()),
+ region_request::Body::Deletes(Default::default()),
+ region_request::Body::BulkInsert(Default::default()),
+ region_request::Body::Create(Default::default()),
+ region_request::Body::RemoteDynFilter(Default::default()),
+ ] {
+ let request_type = body.as_ref().to_string();
+ let mut status = Status::new(code, "request failed");
+ // Metadata status is deliberately different from the transport status.
+ status.metadata_mut().insert(
+ common_error::GREPTIME_DB_HEADER_ERROR_CODE,
+ (StatusCode::RegionNotReady as u32)
+ .to_string()
+ .parse()
+ .unwrap(),
+ );
+ if let Some(hint) = hint {
+ status.metadata_mut().insert(
+ common_error::GREPTIME_DB_HEADER_ERROR_RETRY_HINT,
+ hint.as_str().parse().unwrap(),
+ );
+ }
+ let original = Error::from(status.clone());
+ let expected = if original.should_retry() && !original.retry_hint().is_retryable() {
+ RetryHint::Retryable
+ } else {
+ original.retry_hint()
+ };
+ server
+ .service
+ .responses
+ .lock()
+ .unwrap()
+ .extend([Err(status.clone()), Err(status)]);
+ let request = RegionRequest {
+ header: None,
+ body: Some(body),
+ };
+ let shared = Arc::new(request);
+ let shared_error = datanode.handle(Arc::clone(&shared)).await.unwrap_err();
+ assert_eq!(Arc::strong_count(&shared), 1);
+ let request = Arc::try_unwrap(shared).unwrap();
+ let owned_error = datanode.handle(request).await.unwrap_err();
+ for error in [owned_error, shared_error] {
+ assert_eq!(
+ error.retry_hint(),
+ expected,
+ "{code:?}, {hint:?}, {request_type}"
+ );
+ assert_eq!(error.retry_hint().is_retryable(), original.should_retry());
+ assert_eq!(error.status_code(), StatusCode::RegionNotReady);
+ let meta_error::Error::External { source, .. } = error else {
+ panic!("expected an external error");
+ };
+ assert_eq!(source.retry_hint(), expected);
+ let Error::RegionServer {
+ source,
+ code: actual_code,
..
- }
- )),
- RetryHint::NonRetryable => {
- assert!(matches!(error, meta_error::Error::External { .. }))
+ } = source.as_any().downcast_ref::().unwrap()
+ else {
+ panic!("expected a region server error");
+ };
+ assert_eq!(*actual_code, code);
+ assert_eq!(source.retry_hint(), original.retry_hint());
+ assert_eq!(source.to_string(), original.to_string());
}
}
}
@@ -846,8 +929,11 @@ mod tests {
baseline.status_code(),
"shared: {error:?}; generated: {baseline:?}"
);
+ let Error::RegionServer { source, .. } = &error else {
+ panic!("expected a region server error: {error:?}");
+ };
assert_eq!(
- error.retry_hint(),
+ source.retry_hint(),
baseline.retry_hint(),
"shared: {error:?}; generated: {baseline:?}"
);
diff --git a/src/common/meta/src/ddl/drop_table.rs b/src/common/meta/src/ddl/drop_table.rs
index b73bd95e1bc..622a27d804b 100644
--- a/src/common/meta/src/ddl/drop_table.rs
+++ b/src/common/meta/src/ddl/drop_table.rs
@@ -19,6 +19,8 @@ use std::collections::HashMap;
use async_trait::async_trait;
use common_error::ext::BoxedError;
+#[cfg(feature = "enterprise")]
+use common_error::ext::ErrorExt;
use common_event_recorder::Event;
use common_procedure::error::{ExternalSnafu, FromJsonSnafu, ToJsonSnafu};
use common_procedure::{
@@ -53,7 +55,7 @@ use crate::rpc::router::{RegionRoute, operating_leader_region_roles};
#[cfg(feature = "enterprise")]
fn ensure_retry_later(err: error::Error) -> error::Error {
- if err.is_retry_later() {
+ if err.retry_hint().is_retryable() {
err
} else {
error::Error::retry_later(err)
diff --git a/src/common/meta/src/ddl/undrop_table.rs b/src/common/meta/src/ddl/undrop_table.rs
index 73a361ffd0e..efb1d4824d7 100644
--- a/src/common/meta/src/ddl/undrop_table.rs
+++ b/src/common/meta/src/ddl/undrop_table.rs
@@ -17,8 +17,8 @@ use api::v1::region::{
use async_trait::async_trait;
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
use common_procedure::{
- Context as ProcedureContext, EventContext, EventTrigger, LockKey, Procedure,
- Result as ProcedureResult, Status,
+ Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
+ Procedure, Result as ProcedureResult, Status,
};
use common_telemetry::tracing_context::TracingContext;
use common_telemetry::warn;
@@ -156,6 +156,7 @@ impl UndropTableProcedure {
}
.await;
if let Err(err) = restore_result {
+ // Keep this classification aligned with map_restore_error().
let should_cleanup_opened_regions = !err.is_retry_later();
let err = self.map_restore_metadata_error(err);
if should_cleanup_opened_regions
@@ -274,6 +275,19 @@ impl UndropTableProcedure {
Ok(())
}
+ fn map_restore_error(err: error::Error) -> ProcedureError {
+ // Restore retries keep opened regions, while other failures close them. Preserve
+ // this policy even when the underlying metadata error carries a retry hint:
+ // retrying after cleanup would restore metadata without reopening the regions.
+ if err.is_retry_later() {
+ map_to_procedure_error(err)
+ } else if err.need_clean_poisons() {
+ ProcedureError::external_and_clean_poisons(err)
+ } else {
+ ProcedureError::external(err)
+ }
+ }
+
fn map_restore_metadata_error(&self, err: error::Error) -> error::Error {
match err {
error::Error::TombstoneTargetAlreadyExists { .. } => {
@@ -413,7 +427,12 @@ impl Procedure for UndropTableProcedure {
async fn execute(&mut self, _: &ProcedureContext) -> ProcedureResult {
match self.data.state {
UndropTableState::Prepare => self.on_prepare().await,
- UndropTableState::RestoreMetadata => self.on_restore_metadata().await,
+ UndropTableState::RestoreMetadata => {
+ return self
+ .on_restore_metadata()
+ .await
+ .map_err(Self::map_restore_error);
+ }
UndropTableState::OpenRegions => self.on_open_regions().await,
UndropTableState::InvalidateTableCache => self.on_broadcast().await,
}
@@ -606,7 +625,8 @@ mod tests {
use api::v1::region::region_request;
use async_trait::async_trait;
use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME};
- use common_error::ext::ErrorExt;
+ use common_error::ext::{BoxedError, ErrorExt};
+ use common_error::mock::MockError;
use common_error::status_code::StatusCode;
use store_api::storage::RegionId;
use table::table_name::TableName;
@@ -635,6 +655,38 @@ mod tests {
}
}
+ #[test]
+ fn test_restore_metadata_retry_policy() {
+ let source = error::Error::retry_later(MockError::new(StatusCode::Internal));
+ let err = Err::<(), _>(BoxedError::new(source))
+ .context(error::ExternalSnafu)
+ .unwrap_err();
+ assert!(err.retry_hint().is_retryable());
+ assert!(!err.is_retry_later());
+ let err = UndropTableProcedure::map_restore_error(err);
+ assert!(!err.is_retry_later());
+ assert!(!err.need_clean_poisons());
+
+ for retryable in [false, true] {
+ for clean_poisons in [false, true] {
+ let source = BoxedError::new(MockError::new(StatusCode::Internal));
+ let err = if retryable {
+ error::Error::RetryLater {
+ source,
+ clean_poisons,
+ }
+ } else {
+ Err::<(), _>(source)
+ .context(error::AbortProcedureSnafu { clean_poisons })
+ .unwrap_err()
+ };
+ let err = UndropTableProcedure::map_restore_error(err);
+ assert_eq!(err.is_retry_later(), retryable);
+ assert_eq!(err.need_clean_poisons(), clean_poisons);
+ }
+ }
+ }
+
#[test]
fn test_map_restore_metadata_error_without_table_name() {
let context = new_ddl_context(Arc::new(MockDatanodeManager::new(())));
diff --git a/src/common/meta/src/ddl/utils.rs b/src/common/meta/src/ddl/utils.rs
index ebfbd6fd522..a8058d89b94 100644
--- a/src/common/meta/src/ddl/utils.rs
+++ b/src/common/meta/src/ddl/utils.rs
@@ -28,7 +28,7 @@ use api::v1::region::{
region_request,
};
use common_catalog::consts::{METRIC_ENGINE, MITO_ENGINE};
-use common_error::ext::BoxedError;
+use common_error::ext::{BoxedError, ErrorExt};
use common_procedure::error::Error as ProcedureError;
use common_telemetry::tracing_context::TracingContext;
use common_telemetry::{error, info, warn};
@@ -61,7 +61,7 @@ use crate::wal_provider::RegionWalOptions;
pub fn add_peer_context_if_needed(datanode: Peer) -> impl FnOnce(Error) -> Error {
move |err| {
error!(err; "Failed to operate datanode, peer: {}", datanode);
- if !err.is_retry_later() {
+ if !err.retry_hint().is_retryable() {
return Err::<(), BoxedError>(BoxedError::new(err))
.context(OperateDatanodeSnafu { peer: datanode })
.unwrap_err();
@@ -84,7 +84,7 @@ pub(crate) fn is_metric_engine_logical_table(
/// This function determines whether the error should be retried and if poison cleanup is needed,
/// then maps it to the appropriate procedure error variant.
pub fn map_to_procedure_error(e: Error) -> ProcedureError {
- match (e.is_retry_later(), e.need_clean_poisons()) {
+ match (e.retry_hint().is_retryable(), e.need_clean_poisons()) {
(true, true) => ProcedureError::retry_later_and_clean_poisons(e),
(true, false) => ProcedureError::retry_later(e),
(false, true) => ProcedureError::external_and_clean_poisons(e),
@@ -256,7 +256,7 @@ pub fn handle_multiple_results(results: Vec>) -> MultipleRes
match result {
Ok(value) => ok_results.push(value),
Err(err) => {
- if err.is_retry_later() {
+ if err.retry_hint().is_retryable() {
retryable_results.push(err);
} else {
non_retryable_results.push(err);
@@ -475,8 +475,74 @@ pub fn extract_column_metadatas(
#[cfg(test)]
mod tests {
+ use common_error::mock::MockError;
+ use common_error::status_code::StatusCode;
+
use super::*;
+ fn external_error(retryable: bool) -> Error {
+ let source = MockError::new(StatusCode::Internal);
+ let source = if retryable {
+ BoxedError::new(Error::retry_later(source))
+ } else {
+ BoxedError::new(source)
+ };
+ Err::<(), _>(source)
+ .context(error::ExternalSnafu)
+ .unwrap_err()
+ }
+
+ #[test]
+ fn test_map_to_procedure_error() {
+ for retryable in [false, true] {
+ for clean_poisons in [false, true] {
+ let err = if retryable {
+ Error::RetryLater {
+ source: BoxedError::new(external_error(false)),
+ clean_poisons,
+ }
+ } else {
+ Err::<(), _>(BoxedError::new(external_error(true)))
+ .context(error::AbortProcedureSnafu { clean_poisons })
+ .unwrap_err()
+ };
+ let err = map_to_procedure_error(err);
+ assert_eq!(err.is_retry_later(), retryable);
+ assert_eq!(err.need_clean_poisons(), clean_poisons);
+ }
+ let err = map_to_procedure_error(external_error(retryable));
+ assert_eq!(err.is_retry_later(), retryable);
+ assert!(!err.need_clean_poisons());
+ }
+ }
+
+ #[test]
+ fn test_handle_multiple_results_retry_hint() {
+ assert!(matches!(
+ handle_multiple_results(vec![Ok(()), Err(external_error(true))]),
+ MultipleResults::PartialRetryable(_)
+ ));
+ assert!(matches!(
+ handle_multiple_results::<()>(vec![
+ Err(external_error(true)),
+ Err(external_error(true))
+ ]),
+ MultipleResults::AllRetryable(_)
+ ));
+ assert!(matches!(
+ handle_multiple_results(vec![
+ Ok(()),
+ Err(external_error(true)),
+ Err(external_error(false))
+ ]),
+ MultipleResults::PartialNonRetryable(_)
+ ));
+ assert!(matches!(
+ handle_multiple_results::<()>(vec![Err(external_error(false))]),
+ MultipleResults::AllNonRetryable(_)
+ ));
+ }
+
#[test]
fn test_get_catalog_and_schema() {
let test_catalog = "my_catalog";
diff --git a/src/common/meta/src/error.rs b/src/common/meta/src/error.rs
index 297af792799..adfa158adf0 100644
--- a/src/common/meta/src/error.rs
+++ b/src/common/meta/src/error.rs
@@ -1373,15 +1373,15 @@ impl ErrorExt for Error {
| StartProcedureManager { source, .. }
| StopProcedureManager { source, .. }
| RegisterProcedureLoader { source, .. }
- | PutPoison { source, .. }
- | ProcedureStateReceiver { source, .. } => source.retry_hint(),
+ | PutPoison { source, .. } => source.retry_hint(),
External { source, .. }
| ResponseExceededSizeLimit { source, .. }
| OperateDatanode { source, .. }
- | AbortProcedure { source, .. }
| RegisterRepartitionProcedureLoader { source, .. }
| CreateRepartitionProcedure { source, .. }
| PersistRepartitionGcRequirement { source, .. } => source.retry_hint(),
+ // These errors end an operation; their sources cannot make it retryable.
+ AbortProcedure { .. } | ProcedureStateReceiver { .. } => RetryHint::NonRetryable,
Table { source, .. } => source.retry_hint(),
ConvertAlterTableRequest { source, .. } => source.retry_hint(),
ConvertColumnDef { source, .. } => source.retry_hint(),
@@ -1466,6 +1466,7 @@ mod retry_hint_tests {
use std::sync::Arc;
use common_error::mock::MockError;
+ use snafu::ResultExt;
use super::*;
@@ -1483,6 +1484,42 @@ mod retry_hint_tests {
assert_eq!(err.retry_hint(), RetryHint::Retryable);
}
+ #[test]
+ fn test_leader_retry_hints() {
+ for (error, expected) in [
+ (
+ NoLeaderSnafu { table_id: 1_u32 }.build(),
+ RetryHint::Retryable,
+ ),
+ (ElectionNoLeaderSnafu.build(), RetryHint::Retryable),
+ (
+ ElectionLeaderLeaseExpiredSnafu.build(),
+ RetryHint::Retryable,
+ ),
+ (
+ ElectionLeaderLeaseChangedSnafu.build(),
+ RetryHint::Retryable,
+ ),
+ ] {
+ assert_eq!(error.retry_hint(), expected, "{error:?}");
+ }
+ }
+
+ #[test]
+ fn test_procedure_error_retry_hints() {
+ let source = common_procedure::Error::retry_later(MockError::new(StatusCode::Internal));
+ let err = Err::<(), _>(source).context(PutPoisonSnafu).unwrap_err();
+ assert_eq!(err.retry_hint(), RetryHint::Retryable);
+
+ let source = common_procedure::Error::retry_later(MockError::new(StatusCode::Internal));
+ let err = Err::<(), _>(source)
+ .context(ProcedureStateReceiverSnafu {
+ procedure_id: ProcedureId::random(),
+ })
+ .unwrap_err();
+ assert_eq!(err.retry_hint(), RetryHint::NonRetryable);
+ }
+
#[test]
fn test_get_cache_forwards_retry_hint() {
let source = Arc::new(Error::retry_later(MockError::new(StatusCode::Internal)));
diff --git a/src/common/meta/src/node_manager.rs b/src/common/meta/src/node_manager.rs
index 6cc6c3e1539..d18e2cc8471 100644
--- a/src/common/meta/src/node_manager.rs
+++ b/src/common/meta/src/node_manager.rs
@@ -28,12 +28,22 @@ use crate::peer::Peer;
#[async_trait::async_trait]
pub trait Datanode: Send + Sync {
/// Handles DML, and DDL requests.
- async fn handle(&self, request: RegionRequest) -> Result;
+ async fn handle_request(&self, request: Arc) -> Result;
/// Handles query requests
async fn handle_query(&self, request: QueryRequest) -> Result;
}
+impl dyn Datanode + '_ {
+ /// Handles an owned or shared request without cloning its payload.
+ pub async fn handle(&self, request: R) -> Result
+ where
+ R: Into> + Send,
+ {
+ self.handle_request(request.into()).await
+ }
+}
+
pub type DatanodeRef = Arc;
/// The trait for handling requests to flownode
diff --git a/src/common/meta/src/reconciliation/reconcile_catalog.rs b/src/common/meta/src/reconciliation/reconcile_catalog.rs
index e28e81d323d..f015a483b6e 100644
--- a/src/common/meta/src/reconciliation/reconcile_catalog.rs
+++ b/src/common/meta/src/reconciliation/reconcile_catalog.rs
@@ -16,6 +16,7 @@ use std::any::Any;
use std::fmt::Debug;
use std::time::Instant;
+use common_error::ext::ErrorExt;
use common_procedure::error::FromJsonSnafu;
use common_procedure::{
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
@@ -191,7 +192,7 @@ impl Procedure for ReconcileCatalogProcedure {
Ok(status)
}
Err(e) => {
- if e.is_retry_later() {
+ if e.retry_hint().is_retryable() {
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
.inc();
diff --git a/src/common/meta/src/reconciliation/reconcile_database.rs b/src/common/meta/src/reconciliation/reconcile_database.rs
index f5e2fe9f9a0..49443aca9db 100644
--- a/src/common/meta/src/reconciliation/reconcile_database.rs
+++ b/src/common/meta/src/reconciliation/reconcile_database.rs
@@ -23,6 +23,7 @@ use std::fmt::Debug;
use std::time::Instant;
use async_trait::async_trait;
+use common_error::ext::ErrorExt;
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
use common_procedure::{
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
@@ -231,7 +232,7 @@ impl Procedure for ReconcileDatabaseProcedure {
Ok(status)
}
Err(e) => {
- if e.is_retry_later() {
+ if e.retry_hint().is_retryable() {
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
.inc();
diff --git a/src/common/meta/src/reconciliation/reconcile_logical_tables.rs b/src/common/meta/src/reconciliation/reconcile_logical_tables.rs
index 8c16e5a450f..5c5c654f6df 100644
--- a/src/common/meta/src/reconciliation/reconcile_logical_tables.rs
+++ b/src/common/meta/src/reconciliation/reconcile_logical_tables.rs
@@ -22,6 +22,7 @@ use std::any::Any;
use std::fmt::Debug;
use async_trait::async_trait;
+use common_error::ext::ErrorExt;
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
use common_procedure::{
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
@@ -207,7 +208,7 @@ impl Procedure for ReconcileLogicalTablesProcedure {
Ok(status)
}
Err(e) => {
- if e.is_retry_later() {
+ if e.retry_hint().is_retryable() {
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
.inc();
diff --git a/src/common/meta/src/reconciliation/reconcile_table.rs b/src/common/meta/src/reconciliation/reconcile_table.rs
index d48999b895f..a14fe13a3db 100644
--- a/src/common/meta/src/reconciliation/reconcile_table.rs
+++ b/src/common/meta/src/reconciliation/reconcile_table.rs
@@ -20,6 +20,7 @@ pub(crate) mod update_table_info;
use std::fmt::Debug;
+use common_error::ext::ErrorExt;
use common_procedure::error::{FromJsonSnafu, ToJsonSnafu};
use common_procedure::{
Context as ProcedureContext, Error as ProcedureError, EventContext, EventTrigger, LockKey,
@@ -318,7 +319,7 @@ impl Procedure for ReconcileTableProcedure {
Ok(status)
}
Err(e) => {
- if e.is_retry_later() {
+ if e.retry_hint().is_retryable() {
metrics::METRIC_META_RECONCILIATION_PROCEDURE_ERROR
.with_label_values(&[procedure_name, step, metrics::ERROR_TYPE_RETRYABLE])
.inc();
diff --git a/src/common/meta/src/test_util.rs b/src/common/meta/src/test_util.rs
index d78dc815d09..618603c13e5 100644
--- a/src/common/meta/src/test_util.rs
+++ b/src/common/meta/src/test_util.rs
@@ -111,7 +111,8 @@ struct MockNode {
#[async_trait::async_trait]
impl Datanode for MockNode {
- async fn handle(&self, request: RegionRequest) -> Result {
+ async fn handle_request(&self, request: Arc) -> Result {
+ let request = Arc::try_unwrap(request).expect("mock request must be exclusively owned");
self.handler.handle(&self.peer, request).await
}
diff --git a/src/common/procedure/src/error.rs b/src/common/procedure/src/error.rs
index 67fab54b156..b3f4a83d5f4 100644
--- a/src/common/procedure/src/error.rs
+++ b/src/common/procedure/src/error.rs
@@ -306,7 +306,8 @@ impl ErrorExt for Error {
| Error::DeletePoison { source, .. }
| Error::GetPoison { source, .. }
| Error::CheckStatus { source, .. } => source.retry_hint(),
- Error::ProcedureExec { source, .. } => source.retry_hint(),
+ // The watcher reports this only after the child has failed or been poisoned.
+ Error::ProcedureExec { .. } => RetryHint::NonRetryable,
Error::StartRemoveOutdatedMetaTask { source, .. }
| Error::StopRemoveOutdatedMetaTask { source, .. } => source.retry_hint(),
Error::DeleteState { error, .. } => retry_hint_from_opendal_error(error),
@@ -379,8 +380,11 @@ mod tests {
use std::sync::Arc;
use common_error::mock::MockError;
+ use tokio::sync::watch;
use super::*;
+ use crate::procedure::ProcedureState;
+ use crate::watcher;
#[test]
fn test_retry_later_hint_is_retryable() {
@@ -397,6 +401,21 @@ mod tests {
assert_eq!(err.retry_hint(), RetryHint::Retryable);
}
+ #[tokio::test]
+ async fn test_finished_procedure_hint_is_non_retryable() {
+ let source = Arc::new(Error::retry_later(MockError::new(StatusCode::Internal)));
+ for state in [
+ ProcedureState::failed(source.clone()),
+ ProcedureState::poisoned(Default::default(), source),
+ ] {
+ let (sender, mut receiver) = watch::channel(ProcedureState::Running);
+ sender.send(state).unwrap();
+ let err = watcher::wait(&mut receiver).await.unwrap_err();
+
+ assert_eq!(err.retry_hint(), RetryHint::NonRetryable);
+ }
+ }
+
#[test]
fn test_retry_exceeded_hint_is_non_retryable() {
let source = Arc::new(Error::retry_later(MockError::new(StatusCode::Internal)));
diff --git a/src/frontend/AGENTS.md b/src/frontend/AGENTS.md
index fdaa5dbcfe4..8d858242e8c 100644
--- a/src/frontend/AGENTS.md
+++ b/src/frontend/AGENTS.md
@@ -48,7 +48,9 @@ including caches owned by pipeline and protocol handlers.
- **Insert** (`instance/grpc.rs`): `handle_inserts` / `handle_row_inserts` →
`check_permission` → `operator`'s `Inserter` (schema validation, optional
auto-create, partition routing, meter admission) → local `RegionServer`
- (standalone) or RPC to datanodes (distributed). Arrow bulk inserts pass the
+ (standalone) or RPC to datanodes (distributed). `FrontendBuilder` configures
+ the operator write dispatcher with frontend retry options for distributed
+ inserts, deletes, and bulk writes. Arrow bulk inserts pass the
request channel to `Inserter` and check meter admission for each nonempty batch.
- Finite ingestion requests split internally admit their total rows per database
before dispatch (`operator::insert::admit_write` / `admit_row_insert_batches`).
@@ -113,7 +115,8 @@ cargo nextest run -p frontend
`servers`; permissions, planning, and routing live here.
- Standalone vs distributed diverge in datanode access (the `standalone` crate's
local `RegionServer` adapter vs `NodeClients` RPC), MetaClient usage, and
- whether heartbeat matters. In standalone, the cache invalidator is a no-op.
+ whether heartbeat matters. The builder defaults local invalidation to its
+ layered cache registry; callers may supply a custom invalidator.
## Maintenance contract
diff --git a/src/frontend/src/error.rs b/src/frontend/src/error.rs
index 9fd2312e736..451040c884a 100644
--- a/src/frontend/src/error.rs
+++ b/src/frontend/src/error.rs
@@ -323,6 +323,13 @@ pub enum Error {
name: String,
},
+ #[snafu(display("Invalid write retry config: {}", reason))]
+ InvalidWriteRetryConfig {
+ reason: &'static str,
+ #[snafu(implicit)]
+ location: Location,
+ },
+
#[snafu(display("Invalid tls config"))]
InvalidTlsConfig {
#[snafu(source)]
@@ -391,7 +398,8 @@ impl ErrorExt for Error {
| Error::IllegalAuthConfig { .. }
| Error::ColumnNoneDefaultValue { .. }
| Error::IncompleteGrpcRequest { .. }
- | Error::InvalidTlsConfig { .. } => StatusCode::InvalidArguments,
+ | Error::InvalidTlsConfig { .. }
+ | Error::InvalidWriteRetryConfig { .. } => StatusCode::InvalidArguments,
Error::NotSupported { .. } => StatusCode::Unsupported,
diff --git a/src/frontend/src/frontend.rs b/src/frontend/src/frontend.rs
index e594ba9f7b1..2ccbc3915e5 100644
--- a/src/frontend/src/frontend.rs
+++ b/src/frontend/src/frontend.rs
@@ -36,9 +36,10 @@ use crate::heartbeat::HeartbeatTask;
use crate::instance::Instance;
use crate::service_config::{
InfluxdbOptions, JaegerOptions, MysqlOptions, OpentsdbOptions, OtlpOptions,
- PendingRowsBatcherOptions, PostgresOptions, PromStoreOptions,
+ PendingRowsBatcherOptions, PostgresOptions, PromStoreOptions, WriteRetryOptions,
};
+/// Configures frontend protocols, query execution, and distributed write retries.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct FrontendOptions {
@@ -74,6 +75,8 @@ pub struct FrontendOptions {
pub meta_client: Option,
pub logging: LoggingOptions,
pub datanode: DatanodeClientOptions,
+ /// Retry policy for retryable distributed write failures.
+ pub write_retry: WriteRetryOptions,
pub user_provider: Option,
pub tracing: TracingOptions,
pub query: QueryOptions,
@@ -110,6 +113,7 @@ impl Default for FrontendOptions {
meta_client: None,
logging: LoggingOptions::default(),
datanode: DatanodeClientOptions::default(),
+ write_retry: WriteRetryOptions::default(),
user_provider: None,
tracing: TracingOptions::default(),
query: QueryOptions::default(),
@@ -225,6 +229,72 @@ mod tests {
type GrpcStream =
Pin> + Send + Sync + 'static>>;
+ #[test]
+ fn test_write_retry_config() {
+ let defaults: FrontendOptions = toml::from_str("").unwrap();
+ assert_eq!(defaults.write_retry, WriteRetryOptions::default());
+ let options: FrontendOptions = toml::from_str(
+ r#"
+[write_retry]
+max_retries = 5
+initial_backoff = "250ms"
+max_backoff = "5s"
+backoff_multiplier = 3.0
+"#,
+ )
+ .unwrap();
+ assert_eq!(
+ options.write_retry,
+ WriteRetryOptions {
+ max_retries: 5,
+ initial_backoff: Duration::from_millis(250),
+ max_backoff: Duration::from_secs(5),
+ backoff_multiplier: 3.0,
+ }
+ );
+ options.write_retry.validate().unwrap();
+ let serialized = toml::to_string(&options).unwrap();
+ let parsed: FrontendOptions = toml::from_str(&serialized).unwrap();
+ assert_eq!(options.write_retry, parsed.write_retry);
+ let disabled: FrontendOptions = toml::from_str("[write_retry]\nmax_retries = 0").unwrap();
+ assert_eq!(disabled.write_retry.max_retries, 0);
+ assert_eq!(
+ disabled.write_retry.initial_backoff,
+ defaults.write_retry.initial_backoff
+ );
+ }
+
+ #[test]
+ fn test_write_retry_config_from_env() {
+ temp_env::with_vars(
+ [
+ ("FRONTEND_RETRY_TEST__WRITE_RETRY__MAX_RETRIES", Some("4")),
+ (
+ "FRONTEND_RETRY_TEST__WRITE_RETRY__INITIAL_BACKOFF",
+ Some("500ms"),
+ ),
+ ("FRONTEND_RETRY_TEST__WRITE_RETRY__MAX_BACKOFF", Some("8s")),
+ (
+ "FRONTEND_RETRY_TEST__WRITE_RETRY__BACKOFF_MULTIPLIER",
+ Some("1.5"),
+ ),
+ ],
+ || {
+ let options =
+ FrontendOptions::load_layered_options(None, "FRONTEND_RETRY_TEST").unwrap();
+ assert_eq!(
+ options.write_retry,
+ WriteRetryOptions {
+ max_retries: 4,
+ initial_backoff: Duration::from_millis(500),
+ max_backoff: Duration::from_secs(8),
+ backoff_multiplier: 1.5,
+ }
+ );
+ },
+ );
+ }
+
#[test]
fn test_logical_batcher_protocols_from_env() {
temp_env::with_vars(
diff --git a/src/frontend/src/instance/builder.rs b/src/frontend/src/instance/builder.rs
index 9db7b70c221..f93113d3426 100644
--- a/src/frontend/src/instance/builder.rs
+++ b/src/frontend/src/instance/builder.rs
@@ -25,8 +25,10 @@ use common_base::Plugins;
use common_batcher::request_limiter::RequestLimiter;
use common_datasource::object_store::LocalFileAccess;
use common_event_recorder::{EventRecorderImpl, EventRecorderRef};
-use common_meta::cache::{LayeredCacheRegistryRef, TableFlownodeSetCacheRef, TableRouteCacheRef};
-use common_meta::cache_invalidator::{CacheInvalidatorRef, DummyCacheInvalidator};
+use common_meta::cache::{
+ LayeredCacheRegistryRef, TableFlownodeSetCacheRef, TableInfoCacheRef, TableRouteCacheRef,
+};
+use common_meta::cache_invalidator::CacheInvalidatorRef;
use common_meta::key::TableMetadataManager;
use common_meta::key::flow::FlowMetadataManager;
use common_meta::kv_backend::KvBackendRef;
@@ -46,6 +48,7 @@ use operator::statement::{
StatementExecutor, StatementExecutorConfiguratorRef, StatementExecutorRef,
};
use operator::table::TableMutationOperator;
+use operator::write_dispatcher::WriteDispatcher;
use partition::cache::PartitionInfoCacheRef;
use partition::manager::PartitionRuleManager;
use pipeline::pipeline_operator::PipelineOperator;
@@ -70,7 +73,7 @@ pub struct FrontendBuilder {
options: FrontendOptions,
kv_backend: KvBackendRef,
layered_cache_registry: LayeredCacheRegistryRef,
- local_cache_invalidator: Option,
+ local_cache_invalidator: CacheInvalidatorRef,
catalog_manager: CatalogManagerRef,
node_manager: NodeManagerRef,
plugins: Option,
@@ -80,6 +83,9 @@ pub struct FrontendBuilder {
}
impl FrontendBuilder {
+ /// Creates a frontend builder from its configuration and shared services.
+ ///
+ /// Uses the layered cache registry for local invalidation unless overridden.
#[allow(clippy::too_many_arguments)]
pub fn new(
options: FrontendOptions,
@@ -93,8 +99,8 @@ impl FrontendBuilder {
Self {
options,
kv_backend,
+ local_cache_invalidator: layered_cache_registry.clone(),
layered_cache_registry,
- local_cache_invalidator: None,
catalog_manager,
node_manager,
plugins: None,
@@ -140,9 +146,10 @@ impl FrontendBuilder {
)
}
+ /// Overrides the default local cache invalidator.
pub fn with_local_cache_invalidator(self, cache_invalidator: CacheInvalidatorRef) -> Self {
Self {
- local_cache_invalidator: Some(cache_invalidator),
+ local_cache_invalidator: cache_invalidator,
..self
}
}
@@ -187,7 +194,11 @@ impl FrontendBuilder {
}
}
+ /// Validates configuration and assembles the frontend [`Instance`].
pub async fn try_build(self) -> Result {
+ if let Err(reason) = self.options.write_retry.validate() {
+ return error::InvalidWriteRetryConfigSnafu { reason }.fail();
+ }
let kv_backend = self.kv_backend;
let node_manager = self.node_manager;
let plugins = self.plugins.unwrap_or_default();
@@ -210,9 +221,24 @@ impl FrontendBuilder {
partition_info_cache.clone(),
));
- let local_cache_invalidator = self
- .local_cache_invalidator
- .unwrap_or_else(|| Arc::new(DummyCacheInvalidator));
+ let local_cache_invalidator = self.local_cache_invalidator;
+
+ let mut write_dispatcher =
+ WriteDispatcher::new(node_manager.clone(), partition_manager.clone());
+ if self.options.write_retry.max_retries > 0 {
+ let table_info_cache: TableInfoCacheRef =
+ self.layered_cache_registry
+ .get()
+ .context(error::CacheRequiredSnafu {
+ name: cache::TABLE_INFO_CACHE_NAME,
+ })?;
+ write_dispatcher = write_dispatcher.with_retry(
+ self.options.write_retry.clone(),
+ table_info_cache,
+ local_cache_invalidator.clone(),
+ );
+ }
+ let write_dispatcher = Arc::new(write_dispatcher);
let region_query_handler =
if let Some(factory) = plugins.get::() {
@@ -236,6 +262,7 @@ impl FrontendBuilder {
table_flownode_cache.clone(),
self.options.auto_create_table,
)
+ .with_write_dispatcher(write_dispatcher.clone())
};
// The execution-only inserter owns no batchers, avoiding an Arc cycle.
let bulk_inserter = Arc::new(create_inserter());
@@ -264,11 +291,14 @@ impl FrontendBuilder {
create_inserter()
.with_pending_rows_batcher(build_batcher(self.options.table_batcher_options())),
);
- let deleter = Arc::new(Deleter::new(
- self.catalog_manager.clone(),
- partition_manager.clone(),
- node_manager.clone(),
- ));
+ let deleter = Arc::new(
+ Deleter::new(
+ self.catalog_manager.clone(),
+ partition_manager.clone(),
+ node_manager.clone(),
+ )
+ .with_write_dispatcher(write_dispatcher),
+ );
let requester = Arc::new(Requester::new(
self.catalog_manager.clone(),
partition_manager.clone(),
@@ -421,3 +451,76 @@ impl FrontendBuilder {
})
}
}
+
+#[cfg(test)]
+mod tests {
+ use api::v1::meta::Role;
+ use common_meta::cache_invalidator::{Context, DummyCacheInvalidator};
+ use common_meta::instruction::CacheIdent;
+ use common_meta::key::table_route::{TableRouteKey, TableRouteValue};
+ use common_meta::key::{MetadataKey, MetadataValue};
+ use common_meta::rpc::router::{Region, RegionRoute};
+ use common_meta::rpc::store::PutRequest;
+ use meta_client::client::MetaClientBuilder;
+ use store_api::storage::RegionId;
+
+ use super::*;
+
+ #[tokio::test]
+ async fn test_local_cache_invalidator() {
+ for custom_invalidator in [false, true] {
+ let mut builder = FrontendBuilder::new_test(
+ &FrontendOptions::default(),
+ Arc::new(MetaClientBuilder::new(0, Role::Frontend).build()),
+ );
+ if custom_invalidator {
+ builder = builder.with_local_cache_invalidator(Arc::new(DummyCacheInvalidator));
+ }
+ let backend = builder.kv_backend.clone();
+ let routes: TableRouteCacheRef = builder.layered_cache_registry.get().unwrap();
+ let partitions: PartitionInfoCacheRef = builder.layered_cache_registry.get().unwrap();
+ let instance = builder.try_build().await.unwrap();
+ let table_id = 1024;
+ for region_number in [1, 2] {
+ let route = TableRouteValue::physical(vec![RegionRoute {
+ region: Region {
+ id: RegionId::new(table_id, region_number),
+ ..Default::default()
+ },
+ ..Default::default()
+ }]);
+ backend
+ .put(
+ PutRequest::new()
+ .with_key(TableRouteKey::new(table_id).to_bytes())
+ .with_value(route.try_as_raw_value().unwrap()),
+ )
+ .await
+ .unwrap();
+ if region_number == 1 {
+ routes.get(table_id).await.unwrap().unwrap();
+ partitions.get(table_id).await.unwrap().unwrap();
+ }
+ }
+ assert!(routes.contains_key(&table_id));
+ assert!(partitions.contains_key(&table_id));
+
+ instance
+ .cache_invalidator()
+ .invalidate(&Context::default(), &[CacheIdent::TableId(table_id)])
+ .await
+ .unwrap();
+
+ assert_eq!(custom_invalidator, routes.contains_key(&table_id));
+ assert_eq!(custom_invalidator, partitions.contains_key(&table_id));
+ let route = routes.get(table_id).await.unwrap().unwrap();
+ let region = route.as_physical_table_route_ref().unwrap().region_routes[0]
+ .region
+ .id;
+ assert_eq!(
+ if custom_invalidator { 1 } else { 2 },
+ region.region_number()
+ );
+ }
+ }
+}
diff --git a/src/frontend/src/instance/logical_batcher.rs b/src/frontend/src/instance/logical_batcher.rs
index 255766ac14d..423f2996cae 100644
--- a/src/frontend/src/instance/logical_batcher.rs
+++ b/src/frontend/src/instance/logical_batcher.rs
@@ -48,6 +48,7 @@ impl Instance {
LogicalTablePendingRowsBatcher::try_new(
self.partition_manager().clone(),
self.node_manager().clone(),
+ self.inserter.write_dispatcher().clone(),
self.catalog_manager().clone(),
self.table_flownode_set_cache().clone(),
true,
diff --git a/src/frontend/src/service_config.rs b/src/frontend/src/service_config.rs
index 2c761c4242a..535ae9b655b 100644
--- a/src/frontend/src/service_config.rs
+++ b/src/frontend/src/service_config.rs
@@ -25,6 +25,7 @@ pub use influxdb::{InfluxdbMergeMode, InfluxdbOptions};
pub use jaeger::JaegerOptions;
pub use mysql::MysqlOptions;
pub use opentsdb::OpentsdbOptions;
+pub use operator::write_dispatcher::WriteRetryOptions;
pub use otlp::OtlpOptions;
pub use pending_rows_batcher::{BatcherOptions, PendingRowsBatcherOptions};
pub use postgres::PostgresOptions;
diff --git a/src/metric-engine/src/engine/bulk_insert.rs b/src/metric-engine/src/engine/bulk_insert.rs
index dd20f34adf8..b7ad7646e3c 100644
--- a/src/metric-engine/src/engine/bulk_insert.rs
+++ b/src/metric-engine/src/engine/bulk_insert.rs
@@ -354,17 +354,32 @@ mod tests {
#[tokio::test]
async fn test_bulk_insert_physical_region_passthrough() {
- check_bulk_insert_physical_region_passthrough(false, false).await;
- check_bulk_insert_physical_region_passthrough(true, false).await;
+ check_bulk_insert_physical_region_passthrough(false, false, false).await;
+ check_bulk_insert_physical_region_passthrough(true, false, false).await;
}
#[tokio::test]
async fn test_bulk_insert_physical_region_close_recovery() {
- check_bulk_insert_physical_region_passthrough(false, true).await;
- check_bulk_insert_physical_region_passthrough(true, true).await;
+ check_bulk_insert_physical_region_passthrough(false, true, false).await;
+ check_bulk_insert_physical_region_passthrough(true, true, false).await;
}
- async fn check_bulk_insert_physical_region_passthrough(skip_wal: bool, close: bool) {
+ #[tokio::test]
+ async fn test_physical_bulk_partition_columns_preserve_stored_rows() {
+ for skip_wal in [false, true] {
+ let stripped =
+ check_bulk_insert_physical_region_passthrough(skip_wal, true, false).await;
+ let retained =
+ check_bulk_insert_physical_region_passthrough(skip_wal, true, true).await;
+ assert_eq!(retained, stripped, "skip_wal={skip_wal}");
+ }
+ }
+
+ async fn check_bulk_insert_physical_region_passthrough(
+ skip_wal: bool,
+ close: bool,
+ retain_partition_columns: bool,
+ ) -> String {
// Use flat format so that BulkMemtable is used (supports write_bulk).
let mito_config = MitoConfig {
default_flat_format: true,
@@ -394,7 +409,11 @@ mod tests {
index: 2,
column_id: 2, // column_id for "job" in the physical table
}];
- let non_tag_indices = vec![0, 1]; // timestamp, value
+ let non_tag_indices = if retain_partition_columns {
+ vec![0, 1, 2] // timestamp, value, and the job partition column
+ } else {
+ vec![0, 1] // timestamp, value
+ };
let second_batch = build_logical_batch(3, 3);
let physical_batch = modify_batch_sparse(
second_batch,
@@ -403,6 +422,10 @@ mod tests {
&non_tag_indices,
)
.unwrap();
+ assert_eq!(
+ physical_batch.column_by_name("job").is_some(),
+ retain_partition_columns
+ );
let request = build_bulk_request(physical_region_id, physical_batch, skip_wal);
let response = env
.metric()
@@ -469,6 +492,7 @@ mod tests {
batches.iter().map(|b| b.num_rows()).sum::(),
if skip_wal && !close { 0 } else { 6 },
);
+ batches.pretty_print().unwrap()
}
#[tokio::test]
diff --git a/src/operator/Cargo.toml b/src/operator/Cargo.toml
index 7f4233e633e..b89bf4b13d3 100644
--- a/src/operator/Cargo.toml
+++ b/src/operator/Cargo.toml
@@ -62,6 +62,7 @@ lazy_static.workspace = true
meta-client.workspace = true
meter-core.workspace = true
meter-macros.workspace = true
+mito-codec.workspace = true
moka = { workspace = true, features = ["future"] }
object-store.workspace = true
partition.workspace = true
diff --git a/src/operator/src/bulk_insert.rs b/src/operator/src/bulk_insert.rs
index 29537959df5..d4a1ed0497c 100644
--- a/src/operator/src/bulk_insert.rs
+++ b/src/operator/src/bulk_insert.rs
@@ -35,6 +35,7 @@ use snafu::{OptionExt, ResultExt, ensure};
use store_api::storage::RegionId;
use table::TableRef;
use table::metadata::TableInfoRef;
+use tokio_util::sync::CancellationToken;
use crate::error::Result;
use crate::insert::Inserter;
@@ -72,7 +73,7 @@ impl Inserter {
let writes = self.write_dispatcher.prepare(writes).await?;
Ok(self
.write_dispatcher
- .execute(writes, None)
+ .execute(writes, None, None)
.await
.context(error::RequestInsertsSnafu)?
.affected_rows)
@@ -191,6 +192,7 @@ impl Inserter {
Some(
metrics::HANDLE_BULK_INSERT_ELAPSED.with_label_values(&["datanode_handle"]),
),
+ None,
)
.await
.context(error::RequestRegionSnafu)
@@ -217,6 +219,9 @@ impl Inserter {
.with_label_values(&["wait_all_datanode"])
.start_timer();
+ // Dropping the caller stops retries without abandoning in-flight writes.
+ let cancellation = CancellationToken::new();
+ let _guard = cancellation.clone().drop_guard();
let mut handles = Vec::with_capacity(region_masks.len());
for (region_number, mask) in region_masks {
@@ -328,6 +333,7 @@ impl Inserter {
&metrics::HANDLE_BULK_INSERT_ELAPSED
.with_label_values(&["datanode_handle"]),
),
+ Some(&cancellation),
)
.await
.context(error::RequestRegionSnafu)
diff --git a/src/operator/src/delete.rs b/src/operator/src/delete.rs
index c389374b7ac..9bf14471696 100644
--- a/src/operator/src/delete.rs
+++ b/src/operator/src/delete.rs
@@ -27,6 +27,7 @@ use session::context::QueryContextRef;
use snafu::{OptionExt, ResultExt, ensure};
use table::TableRef;
use table::requests::DeleteRequest as TableDeleteRequest;
+use tokio_util::sync::CancellationToken;
use crate::error::{
CatalogSnafu, InvalidDeleteRequestSnafu, JoinTaskSnafu, MissingTimeIndexColumnSnafu,
@@ -141,12 +142,16 @@ impl Deleter {
let dispatcher = self.write_dispatcher.clone();
let request = request_factory.build_delete(requests);
let writes = dispatcher.prepare(vec![request]).await?;
- let affected_rows =
- common_runtime::spawn_global(async move { dispatcher.execute(writes, None).await })
- .await
- .context(JoinTaskSnafu)?
- .context(RequestDeletesSnafu)?
- .affected_rows;
+ // Dropping the caller stops retries without abandoning in-flight writes.
+ let cancellation = CancellationToken::new();
+ let _guard = cancellation.clone().drop_guard();
+ let affected_rows = common_runtime::spawn_global(async move {
+ dispatcher.execute(writes, None, Some(&cancellation)).await
+ })
+ .await
+ .context(JoinTaskSnafu)?
+ .context(RequestDeletesSnafu)?
+ .affected_rows;
crate::metrics::DIST_DELETE_ROW_COUNT
.with_label_values(&[ctx.get_db_string().as_str()])
.inc_by(affected_rows as u64);
diff --git a/src/operator/src/insert.rs b/src/operator/src/insert.rs
index 2b382dbdb5f..3f67d4fc576 100644
--- a/src/operator/src/insert.rs
+++ b/src/operator/src/insert.rs
@@ -80,6 +80,7 @@ use table::requests::{
validate_semantic_option,
};
use table::table_reference::TableReference;
+use tokio_util::sync::CancellationToken;
use crate::batcher::PendingRowsBatcher;
use crate::error::{
@@ -841,12 +842,16 @@ impl Inserter {
let dispatcher = self.write_dispatcher.clone();
let request = request_factory.build_insert(normal_requests);
let writes = dispatcher.prepare(vec![request]).await?;
- let affected_rows =
- common_runtime::spawn_global(async move { dispatcher.execute(writes, None).await })
- .await
- .context(JoinTaskSnafu)?
- .context(RequestInsertsSnafu)?
- .affected_rows;
+ // Dropping the caller stops retries without abandoning in-flight writes.
+ let cancellation = CancellationToken::new();
+ let _guard = cancellation.clone().drop_guard();
+ let affected_rows = common_runtime::spawn_global(async move {
+ dispatcher.execute(writes, None, Some(&cancellation)).await
+ })
+ .await
+ .context(JoinTaskSnafu)?
+ .context(RequestInsertsSnafu)?
+ .affected_rows;
crate::metrics::DIST_INGEST_ROW_COUNT
.with_label_values(&[ctx.get_db_string().as_str()])
.inc_by(affected_rows as u64);
@@ -3599,9 +3604,9 @@ mod tests {
#[async_trait::async_trait]
impl common_meta::node_manager::Datanode for RecordingNode {
- async fn handle(
+ async fn handle_request(
&self,
- _request: api::v1::region::RegionRequest,
+ _request: Arc,
) -> common_meta::error::Result {
let rows = match _request.body.as_ref() {
Some(api::v1::region::region_request::Body::Inserts(requests)) => requests
diff --git a/src/operator/src/req_convert/common/partitioner.rs b/src/operator/src/req_convert/common/partitioner.rs
index f1c3eff4fe3..6df73d7d1b9 100644
--- a/src/operator/src/req_convert/common/partitioner.rs
+++ b/src/operator/src/req_convert/common/partitioner.rs
@@ -13,6 +13,7 @@
// limitations under the License.
use std::collections::HashMap;
+use std::sync::Arc;
use api::v1::region::{
BulkInsertRequest, DeleteRequest, DeleteRequests, InsertRequest, InsertRequests, RegionRequest,
@@ -21,12 +22,17 @@ use api::v1::region::{
use api::v1::{ArrowIpc, PartitionExprVersion, Rows};
use common_error::ext::BoxedError;
use common_grpc::flight::{self, FlightDecoder};
+use datatypes::arrow::array::{Array, BinaryArray, StringBuilder};
use datatypes::arrow::compute;
+use datatypes::arrow::datatypes::{DataType, Field, Schema};
use datatypes::arrow::record_batch::RecordBatch;
+use mito_codec::row_converter::sparse::{SparseOffsetsCache, SparsePrimaryKeyView};
use partition::PartitionRuleRef;
use partition::manager::PartitionRuleManager;
+use partition::partition::RegionMask;
use partition::splitter::RowSplitter;
-use snafu::{OptionExt, ResultExt};
+use snafu::{OptionExt, ResultExt, ensure};
+use store_api::storage::consts;
use store_api::storage::{RegionId, RegionNumber, TableId};
use table::metadata::TableInfo;
@@ -101,9 +107,11 @@ impl TablePartitioner {
///
/// Takes ownership so row payloads can move into their new regions. Requests whose
/// region and partition version still match retain their original payloads.
+ /// The table metadata comes from the same refresh that created this partitioner.
pub(crate) fn rebuild_write_requests(
&self,
request: RegionRequest,
+ table_info: &TableInfo,
) -> Result> {
let RegionRequest { header, body } = request;
let bodies = match body.context(error::UnexpectedSnafu {
@@ -111,7 +119,7 @@ impl TablePartitioner {
})? {
Body::Inserts(inserts) => self.rebuild_insert_requests(inserts)?,
Body::Deletes(deletes) => self.rebuild_delete_requests(deletes)?,
- Body::BulkInsert(bulk) => self.rebuild_bulk_insert_request(bulk)?,
+ Body::BulkInsert(bulk) => self.rebuild_bulk_insert_request(bulk, table_info)?,
_ => {
return error::UnexpectedSnafu {
violated: "Only writes can be rebuilt",
@@ -165,13 +173,23 @@ impl TablePartitioner {
pub fn partition_bulk_insert_requests(
&self,
batch: RecordBatch,
- mut original_ipc: Option,
+ original_ipc: Option,
skip_wal: bool,
) -> Result> {
let masks = self
.rule
.split_record_batch(&batch)
.context(SplitInsertSnafu)?;
+ self.build_bulk_insert_requests(batch, masks, original_ipc, skip_wal)
+ }
+
+ fn build_bulk_insert_requests(
+ &self,
+ batch: RecordBatch,
+ masks: HashMap,
+ mut original_ipc: Option,
+ skip_wal: bool,
+ ) -> Result> {
let mut requests = Vec::with_capacity(masks.len());
for (region, mask) in masks {
if mask.select_none() {
@@ -256,7 +274,11 @@ impl TablePartitioner {
Ok(bodies)
}
- fn rebuild_bulk_insert_request(&self, bulk: BulkInsertRequest) -> Result> {
+ fn rebuild_bulk_insert_request(
+ &self,
+ bulk: BulkInsertRequest,
+ table_info: &TableInfo,
+ ) -> Result> {
let region_id = RegionId::from_u64(bulk.region_id);
if self.is_partition_expr_unchanged(region_id, bulk.partition_expr_version) {
return Ok(vec![Body::BulkInsert(bulk)]);
@@ -269,13 +291,92 @@ impl TablePartitioner {
.and_then(|mut decoder| decoder.try_decode_record_batch(&ipc.data_header, &ipc.payload))
.map_err(BoxedError::new)
.context(error::ExternalSnafu)?;
+ let routing_batch = self.restore_partition_tags(&batch, table_info)?;
+ let masks = self
+ .rule
+ .split_record_batch(routing_batch.as_ref().unwrap_or(&batch))
+ .context(SplitInsertSnafu)?;
Ok(self
- .partition_bulk_insert_requests(batch, Some(ipc), bulk.skip_wal)?
+ .build_bulk_insert_requests(batch, masks, Some(ipc), bulk.skip_wal)?
.into_iter()
.map(Body::BulkInsert)
.collect())
}
+ /// Restores omitted physical-table tags for routing without changing the write payload.
+ fn restore_partition_tags(
+ &self,
+ batch: &RecordBatch,
+ table_info: &TableInfo,
+ ) -> Result