diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index e2958776e93..b2d3c648df1 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -6,9 +6,13 @@ on: - opened - reopened - edited + - synchronize + - ready_for_review + merge_group: + types: [checks_requested] concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: @@ -18,6 +22,9 @@ permissions: jobs: check: + name: Semantic PR title + # Validate before queue admission; report a skipped check for merge groups. + if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest timeout-minutes: 10 steps: diff --git a/Cargo.lock b/Cargo.lock index fd9eeaefdd7..61005e91848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2317,6 +2317,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "common-batcher" +version = "1.3.0-alpha.1" +dependencies = [ + "dashmap", + "futures", + "tokio", +] + [[package]] name = "common-catalog" version = "1.3.0-alpha.1" @@ -5669,6 +5678,7 @@ dependencies = [ "catalog", "client", "common-base", + "common-batcher", "common-catalog", "common-config", "common-datasource", @@ -13596,6 +13606,7 @@ dependencies = [ "chrono", "client", "common-base", + "common-batcher", "common-catalog", "common-decimal", "common-error", diff --git a/Cargo.toml b/Cargo.toml index a54a0081806..730901925a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "src/client", "src/cmd", "src/common/base", + "src/common/batcher", "src/common/catalog", "src/common/config", "src/common/datasource", @@ -287,6 +288,7 @@ cli = { path = "src/cli" } client = { path = "src/client" } cmd = { path = "src/cmd", default-features = false } common-base = { path = "src/common/base" } +common-batcher = { path = "src/common/batcher" } common-catalog = { path = "src/common/catalog" } common-config = { path = "src/common/config" } common-datasource = { path = "src/common/datasource" } diff --git a/src/common/batcher/Cargo.toml b/src/common/batcher/Cargo.toml new file mode 100644 index 00000000000..5d6ace4b2d3 --- /dev/null +++ b/src/common/batcher/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "common-batcher" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +dashmap.workspace = true +futures.workspace = true +tokio.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } diff --git a/src/common/batcher/src/flush_limiter.rs b/src/common/batcher/src/flush_limiter.rs new file mode 100644 index 00000000000..98b6b2a2f01 --- /dev/null +++ b/src/common/batcher/src/flush_limiter.rs @@ -0,0 +1,73 @@ +// 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::sync::Arc; + +use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore}; + +/// Shared execution budget. One permit represents one complete flush, not one RPC. +#[derive(Clone)] +pub struct FlushLimiter { + semaphore: Arc, +} + +impl FlushLimiter { + /// Returns `None` if the limit is zero or exceeds Tokio's supported semaphore capacity. + pub fn try_new(max_concurrent_flushes: usize) -> Option { + let permits = max_concurrent_flushes; + ((1..=Semaphore::MAX_PERMITS).contains(&permits)).then(|| Self { + semaphore: Arc::new(Semaphore::new(permits)), + }) + } + + /// The caller must retain the returned permit until its flush finishes. + pub async fn acquire(&self) -> Result { + self.semaphore.clone().acquire_owned().await + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + + use super::*; + + #[test] + fn test_invalid_capacity() { + for capacity in [0, Semaphore::MAX_PERMITS + 1, usize::MAX] { + assert!(FlushLimiter::try_new(capacity).is_none()); + } + for capacity in [1, Semaphore::MAX_PERMITS] { + assert!(FlushLimiter::try_new(capacity).is_some()); + } + } + + #[tokio::test] + async fn test_clones_share_flush_budget() { + let limiter = FlushLimiter::try_new(1).unwrap(); + let other = limiter.clone(); + let permit = limiter.acquire().await.unwrap(); + let waiting = other.acquire(); + tokio::pin!(waiting); + // Poll admission without a sleep or an assumption about scheduler timing. + assert!( + std::future::poll_fn(|cx| { + std::task::Poll::Ready(waiting.as_mut().poll(cx).is_pending()) + }) + .await + ); + drop(permit); + let _next = waiting.await.unwrap(); + } +} diff --git a/src/common/batcher/src/flush_policy.rs b/src/common/batcher/src/flush_policy.rs new file mode 100644 index 00000000000..14804f815e2 --- /dev/null +++ b/src/common/batcher/src/flush_policy.rs @@ -0,0 +1,43 @@ +// 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. + +pub mod timing; + +use tokio::time::Instant; + +use crate::pending_batch::PendingBatch; + +/// The event that caused the worker to reconsider flushing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlushTrigger { + /// A complete submission was appended to the batch. + Submission, + /// The flush timer reached its deadline. + Deadline, +} + +/// Decides when a pending batch is ready without owning its payloads or execution. +/// +/// A deadline wakeup must either allow flushing or produce a future deadline; +/// otherwise the caller would repeatedly poll an expired timer. +pub trait FlushPolicy { + /// Returns the next timer deadline, or `None` when no timer is needed. + fn deadline(&self, batch: &PendingBatch) -> Option; + + /// Returns whether the batch should be flushed for this event at `now`. + /// + /// Workers never flush empty batches, regardless of this decision. + fn should_flush(&self, batch: &PendingBatch, now: Instant, trigger: FlushTrigger) + -> bool; +} diff --git a/src/common/batcher/src/flush_policy/timing.rs b/src/common/batcher/src/flush_policy/timing.rs new file mode 100644 index 00000000000..56a9172eaa0 --- /dev/null +++ b/src/common/batcher/src/flush_policy/timing.rs @@ -0,0 +1,157 @@ +// 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::time::Duration; + +use tokio::time::Instant; + +use crate::flush_policy::{FlushPolicy, FlushTrigger}; +use crate::pending_batch::PendingBatch; + +/// Decides whether the first-submission deadline or row threshold requires a flush. +/// +/// The row threshold is checked after appending a complete submission. It is not a +/// hard batch-size limit and never splits a submission. +#[derive(Debug, Clone, Copy)] +pub struct TimingFlushPolicy { + flush_interval: Duration, + max_batch_rows: usize, +} + +impl TimingFlushPolicy { + /// Creates a policy, rejecting zero limits, zero intervals, and unrepresentable deadlines. + pub fn try_new(flush_interval: Duration, max_batch_rows: usize) -> Option { + if max_batch_rows == 0 || !Self::validate(flush_interval) { + return None; + } + Some(Self { + flush_interval, + max_batch_rows, + }) + } + + /// Checks that the interval is nonzero and its deadline is representable. + pub fn validate(flush_interval: Duration) -> bool { + !flush_interval.is_zero() && Instant::now().checked_add(flush_interval).is_some() + } + + /// Checks only the row trigger when a caller drives deadline events separately. + pub fn reached_row_threshold(&self, batch: &PendingBatch) -> bool { + !batch.is_empty() && batch.total_rows() >= self.max_batch_rows + } +} + +impl FlushPolicy for TimingFlushPolicy { + /// Returns the deadline anchored to the first submission, or `None` for an empty batch. + /// + /// An unrepresentable deadline falls back to the first submission time so that + /// extreme timestamps cannot leave a nonempty batch waiting indefinitely. + fn deadline(&self, batch: &PendingBatch) -> Option { + batch + .first_submitted_at() + .map(|first| first.checked_add(self.flush_interval).unwrap_or(first)) + } + + /// Returns whether a nonempty batch has reached either trigger. + fn should_flush( + &self, + batch: &PendingBatch, + now: Instant, + trigger: FlushTrigger, + ) -> bool { + !batch.is_empty() + && (self.reached_row_threshold(batch) + || (trigger == FlushTrigger::Deadline + && self.deadline(batch).is_some_and(|deadline| now >= deadline))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_matches_construction() { + for interval in [Duration::ZERO, Duration::from_secs(1), Duration::MAX] { + assert_eq!( + TimingFlushPolicy::validate(interval), + TimingFlushPolicy::try_new(interval, 1).is_some() + ); + } + } + + #[test] + fn test_first_submission_deadline() { + let policy = TimingFlushPolicy::try_new(Duration::from_millis(10), 100).unwrap(); + let mut batch = PendingBatch::new(); + let first = Instant::now(); + assert_eq!(policy.deadline(&batch), None); + assert!(!policy.should_flush(&batch, first, FlushTrigger::Deadline)); + batch.push(1, 1, first); + batch.push(2, 1, first + Duration::from_millis(9)); + assert_eq!( + policy.deadline(&batch), + Some(first + Duration::from_millis(10)) + ); + assert!(!policy.should_flush( + &batch, + first + Duration::from_millis(9), + FlushTrigger::Deadline + )); + assert!(policy.should_flush( + &batch, + first + Duration::from_millis(10), + FlushTrigger::Deadline + )); + assert!(!policy.reached_row_threshold(&batch)); + assert!(!policy.should_flush( + &batch, + first + Duration::from_millis(10), + FlushTrigger::Submission, + )); + assert_eq!(batch.take(), vec![1, 2]); + let next = first + Duration::from_millis(20); + batch.push(3, 0, next); + assert!(!policy.should_flush(&batch, next, FlushTrigger::Deadline)); + assert!(policy.should_flush( + &batch, + next + Duration::from_millis(10), + FlushTrigger::Deadline + )); + } + + #[test] + fn test_row_threshold_preserves_complete_submissions() { + let policy = TimingFlushPolicy::try_new(Duration::from_secs(1), 3).unwrap(); + let now = Instant::now(); + for rows in [2, 3, 4] { + let mut batch = PendingBatch::new(); + batch.push(vec![0; rows], rows, now); + assert_eq!( + policy.should_flush(&batch, now, FlushTrigger::Submission), + rows >= 3 + ); + assert_eq!(policy.reached_row_threshold(&batch), rows >= 3); + assert_eq!(batch.take(), vec![vec![0; rows]]); + } + } + + #[test] + fn test_invalid_interval() { + assert!(TimingFlushPolicy::try_new(Duration::from_secs(1), 0).is_none()); + let max_batch_rows = 1; + assert!(TimingFlushPolicy::try_new(Duration::ZERO, max_batch_rows).is_none()); + assert!(TimingFlushPolicy::try_new(Duration::MAX, max_batch_rows).is_none()); + } +} diff --git a/src/common/batcher/src/flush_timer.rs b/src/common/batcher/src/flush_timer.rs new file mode 100644 index 00000000000..7ae8173201a --- /dev/null +++ b/src/common/batcher/src/flush_timer.rs @@ -0,0 +1,141 @@ +// 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::pin::Pin; + +use tokio::time::{Instant, Sleep, sleep_until}; + +/// Maintains one reusable timer for the current flush deadline. +/// +/// Creating a timer does not require a Tokio runtime. Arming it requires a Tokio +/// runtime with time enabled. Disabling retains the allocation for the next arm. +#[derive(Default)] +pub struct FlushTimer { + deadline: Option, + sleep: Option>>, +} + +impl FlushTimer { + /// Creates a disabled timer without allocating a Tokio sleep. + pub fn new() -> Self { + Self::default() + } + + /// Arms or disables the timer, resetting its sleep only when the deadline changes. + /// + /// # Panics + /// + /// Arming for the first time panics outside a Tokio runtime with time enabled. + pub fn set_deadline(&mut self, deadline: Option) { + if self.deadline == deadline { + return; + } + self.deadline = deadline; + if let Some(deadline) = deadline { + match &mut self.sleep { + Some(sleep) => sleep.as_mut().reset(deadline), + None => self.sleep = Some(Box::pin(sleep_until(deadline))), + } + } + } + + /// Waits until the armed deadline, or indefinitely while disabled. + /// + /// Cancelling this wait does not change the deadline. An elapsed timer stays + /// ready until the caller changes or disables its deadline. + pub async fn wait(&mut self) { + if self.deadline.is_some() + && let Some(sleep) = &mut self.sleep + { + sleep.as_mut().await; + } else { + std::future::pending::<()>().await; + } + } +} + +#[cfg(test)] +mod tests { + use std::future::{Future, poll_fn}; + use std::task::Poll; + use std::time::Duration; + + use super::*; + + async fn is_ready(timer: &mut FlushTimer) -> bool { + let wait = timer.wait(); + tokio::pin!(wait); + poll_fn(|cx| Poll::Ready(wait.as_mut().poll(cx).is_ready())).await + } + + #[test] + fn test_create_without_runtime() { + let mut timer = FlushTimer::new(); + timer.set_deadline(None); + assert!(timer.sleep.is_none()); + } + + #[tokio::test(start_paused = true)] + async fn test_deadline_and_cancelled_wait() { + let mut timer = FlushTimer::new(); + assert!(!is_ready(&mut timer).await); + let deadline = Instant::now() + Duration::from_millis(10); + timer.set_deadline(Some(deadline)); + assert!(!is_ready(&mut timer).await); + tokio::time::advance(Duration::from_millis(9)).await; + // Each poll drops its wait future. Repeating the same deadline must not + // move the timer forward or replace its sleep allocation. + let sleep = timer.sleep.as_ref().unwrap().as_ref().get_ref() as *const Sleep; + timer.set_deadline(Some(deadline)); + assert_eq!( + sleep, + timer.sleep.as_ref().unwrap().as_ref().get_ref() as *const Sleep + ); + assert!(!is_ready(&mut timer).await); + tokio::time::advance(Duration::from_millis(1)).await; + assert!(is_ready(&mut timer).await); + } + + #[tokio::test(start_paused = true)] + async fn test_disable_and_rearm_reuses_sleep() { + let mut timer = FlushTimer::new(); + timer.set_deadline(Some(Instant::now())); + assert!(is_ready(&mut timer).await); + let sleep = timer.sleep.as_ref().unwrap().as_ref().get_ref() as *const Sleep; + timer.set_deadline(None); + assert!(!is_ready(&mut timer).await); + assert!(!is_ready(&mut timer).await); + timer.set_deadline(Some(Instant::now() + Duration::from_millis(5))); + assert_eq!( + sleep, + timer.sleep.as_ref().unwrap().as_ref().get_ref() as *const Sleep + ); + assert!(!is_ready(&mut timer).await); + tokio::time::advance(Duration::from_millis(5)).await; + assert!(is_ready(&mut timer).await); + } + + #[tokio::test(start_paused = true)] + async fn test_change_armed_deadline() { + let mut timer = FlushTimer::new(); + let now = Instant::now(); + timer.set_deadline(Some(now + Duration::from_millis(5))); + assert!(!is_ready(&mut timer).await); + timer.set_deadline(Some(now + Duration::from_millis(10))); + tokio::time::advance(Duration::from_millis(5)).await; + assert!(!is_ready(&mut timer).await); + timer.set_deadline(Some(Instant::now())); + assert!(is_ready(&mut timer).await); + } +} diff --git a/src/common/batcher/src/lib.rs b/src/common/batcher/src/lib.rs new file mode 100644 index 00000000000..9ad29097aff --- /dev/null +++ b/src/common/batcher/src/lib.rs @@ -0,0 +1,27 @@ +// 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. + +//! Protocol-independent building blocks for timing-based batching. +//! +//! Payload preparation, grouping keys, write execution and request completion +//! remain the caller's responsibility. + +pub mod flush_limiter; +pub mod flush_policy; +pub mod flush_timer; +pub mod notifier; +pub mod pending_batch; +pub mod pending_worker; +pub mod request_limiter; +pub mod worker_registry; diff --git a/src/common/batcher/src/notifier.rs b/src/common/batcher/src/notifier.rs new file mode 100644 index 00000000000..efc795b938a --- /dev/null +++ b/src/common/batcher/src/notifier.rs @@ -0,0 +1,145 @@ +// 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::future::Future; +use std::num::NonZeroUsize; + +use futures::{StreamExt, stream}; +use tokio::sync::Semaphore; +use tokio::sync::mpsc::error::TrySendError; +use tokio::sync::mpsc::{self, Receiver, Sender}; + +/// Best-effort notification admission, independent of the write result. +/// +/// The caller owns the notification payload, delivery handler and failure metrics. +pub struct Notifier { + sender: Sender, +} + +impl Clone for Notifier { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + } + } +} + +impl Notifier { + /// Returns `None` if the queue capacity is zero or exceeds Tokio's supported capacity. + pub fn try_new(capacity: usize) -> Option<(Self, Receiver)> { + if capacity == 0 || capacity > Semaphore::MAX_PERMITS { + return None; + } + let (sender, receiver) = mpsc::channel(capacity); + Some((Self { sender }, receiver)) + } + + /// Configured queue capacity, independent of its current occupancy. + pub fn max_capacity(&self) -> usize { + self.sender.max_capacity() + } + + /// Never waits for delivery. Returns the payload on a full or closed queue + /// so the caller can record domain-specific diagnostics. + pub fn try_notify(&self, notification: T) -> Result<(), TrySendError> { + self.sender.try_send(notification) + } +} + +/// Delivers queued notifications with bounded concurrency. +/// +/// The caller schedules this future on its runtime. Closing all senders drains +/// queued and in-flight notifications. Handler failures are handled by the caller; +/// this worker does not retry or propagate them into write completion. +pub async fn run_notifier( + receiver: Receiver, + max_concurrent_notifications: NonZeroUsize, + handler: F, +) where + F: FnMut(T) -> Fut, + Fut: Future, +{ + stream::unfold(receiver, |mut receiver| async move { + receiver.recv().await.map(|item| (item, receiver)) + }) + .for_each_concurrent(max_concurrent_notifications.get(), handler) + .await; +} + +#[cfg(test)] +mod tests { + use std::future::{Future, poll_fn}; + use std::num::NonZeroUsize; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Poll; + + use tokio::sync::Semaphore; + use tokio::sync::mpsc::error::TrySendError; + + use crate::notifier::{Notifier, run_notifier}; + + #[test] + fn test_admission() { + assert!(Notifier::::try_new(0).is_none()); + assert!(Notifier::::try_new(usize::MAX).is_none()); + let (notifier, receiver) = Notifier::try_new(1).unwrap(); + let other = notifier.clone(); + assert_eq!(other.max_capacity(), 1); + assert_eq!(notifier.try_notify(1), Ok(())); + assert_eq!(other.try_notify(2), Err(TrySendError::Full(2))); + drop(receiver); + assert_eq!(other.try_notify(3), Err(TrySendError::Closed(3))); + } + + #[tokio::test] + async fn test_concurrency_and_drain() { + // One-factor comparison: only delivery concurrency changes. + for _ in 0..2 { + for concurrency in [1, 2] { + let (notifier, receiver) = Notifier::try_new(3).unwrap(); + for item in 0..3 { + notifier.try_notify(item).unwrap(); + } + drop(notifier); + let started = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + let gate = Arc::new(Semaphore::new(0)); + let worker = + run_notifier(receiver, NonZeroUsize::new(concurrency).unwrap(), |_| { + let started = started.clone(); + let completed = completed.clone(); + let gate = gate.clone(); + async move { + started.fetch_add(1, Ordering::SeqCst); + gate.acquire().await.unwrap().forget(); + completed.fetch_add(1, Ordering::SeqCst); + } + }); + tokio::pin!(worker); + poll_fn(|cx| { + assert!(worker.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + assert_eq!(started.load(Ordering::SeqCst), concurrency); + assert_eq!(completed.load(Ordering::SeqCst), 0); + gate.add_permits(3); + worker.await; + assert_eq!(started.load(Ordering::SeqCst), 3); + assert_eq!(completed.load(Ordering::SeqCst), 3); + } + } + } +} diff --git a/src/common/batcher/src/pending_batch.rs b/src/common/batcher/src/pending_batch.rs new file mode 100644 index 00000000000..44dc6565fa1 --- /dev/null +++ b/src/common/batcher/src/pending_batch.rs @@ -0,0 +1,115 @@ +// 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 tokio::time::Instant; + +/// Accumulates complete submissions in arrival order, without interpreting their data. +#[derive(Debug)] +pub struct PendingBatch { + items: Vec, + total_rows: usize, + first_submitted_at: Option, +} + +impl Default for PendingBatch { + fn default() -> Self { + Self { + items: Vec::new(), + total_rows: 0, + first_submitted_at: None, + } + } +} + +impl PendingBatch { + /// Creates an empty batch. + pub fn new() -> Self { + Self::default() + } + + /// Appends a complete submission without moving the first submission's timestamp. + pub fn push(&mut self, item: T, total_rows: usize, now: Instant) { + self.first_submitted_at.get_or_insert(now); + self.items.push(item); + // Saturation preserves threshold decisions even for caller-supplied oversized weights. + self.total_rows = self.total_rows.saturating_add(total_rows); + } + + /// Returns the accumulated row count, saturated at `usize::MAX`. + pub fn total_rows(&self) -> usize { + self.total_rows + } + + /// Returns the number of complete submissions. + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns whether there are no submissions, including zero-row submissions. + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Returns the time the first submission was appended to the current batch. + pub fn first_submitted_at(&self) -> Option { + self.first_submitted_at + } + + /// Takes all submissions in arrival order and resets the batch's accounting. + pub fn take(&mut self) -> Vec { + self.total_rows = 0; + self.first_submitted_at = None; + std::mem::take(&mut self.items) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn test_complete_submissions_and_reset() { + let mut batch = PendingBatch::new(); + let first = Instant::now(); + assert!(batch.is_empty()); + assert_eq!(batch.first_submitted_at(), None); + batch.push(vec![1, 2], 2, first); + batch.push(vec![3, 4, 5], 3, first + Duration::from_secs(1)); + assert_eq!(batch.len(), 2); + assert_eq!(batch.total_rows(), 5); + assert_eq!(batch.first_submitted_at(), Some(first)); + assert_eq!(batch.take(), vec![vec![1, 2], vec![3, 4, 5]]); + assert!(batch.is_empty()); + assert_eq!(batch.total_rows(), 0); + assert_eq!(batch.first_submitted_at(), None); + let next = first + Duration::from_secs(2); + batch.push(vec![6], 1, next); + assert_eq!(batch.first_submitted_at(), Some(next)); + } + + #[test] + fn test_zero_rows_and_saturating_count() { + let mut batch = PendingBatch::new(); + let now = Instant::now(); + batch.push(1, 0, now); + assert!(!batch.is_empty()); + assert_eq!(batch.total_rows(), 0); + batch.push(2, usize::MAX, now); + batch.push(3, 1, now); + assert_eq!(batch.total_rows(), usize::MAX); + assert_eq!(batch.take(), vec![1, 2, 3]); + } +} diff --git a/src/common/batcher/src/pending_worker.rs b/src/common/batcher/src/pending_worker.rs new file mode 100644 index 00000000000..2adda17a5ee --- /dev/null +++ b/src/common/batcher/src/pending_worker.rs @@ -0,0 +1,260 @@ +// 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 tokio::time::Instant; + +use crate::flush_policy::{FlushPolicy, FlushTrigger}; +use crate::flush_timer::FlushTimer; +use crate::pending_batch::PendingBatch; + +/// Batch state for one grouping key, independent of the caller's event loop. +/// +/// The caller owns channels, idle/shutdown decisions, execution limits, task +/// spawning and business completion. Taking a batch transfers its items to the +/// caller; no background task is started by this worker. +pub struct PendingWorker { + batch: PendingBatch, + flush_policy: P, + flush_timer: FlushTimer, +} + +impl PendingWorker { + /// Creates an empty worker without starting a timer or runtime task. + pub fn new(flush_policy: P) -> Self { + Self { + batch: PendingBatch::new(), + flush_policy, + flush_timer: FlushTimer::new(), + } + } + + /// Returns whether the worker has no submissions, including zero-row items. + pub fn is_empty(&self) -> bool { + self.batch.is_empty() + } + + /// Returns the pending row count before ownership is transferred to the caller. + pub fn total_rows(&self) -> usize { + self.batch.total_rows() + } + + /// Waits for a timer wakeup without taking or executing the batch. + /// + /// Cancellation is safe: selecting another event does not lose items or + /// reset the deadline. After a wakeup, call [`Self::take_ready`] with + /// [`FlushTrigger::Deadline`]. An empty or unarmed worker stays pending. + pub async fn wait_flush(&mut self) { + self.flush_timer.wait().await; + } + + /// Takes all pending submissions regardless of policy, for example on shutdown. + /// + /// Returns `None` for an empty batch. The caller decides whether to execute + /// inline, acquire a flush permit or discard the returned items. + pub fn take_pending(&mut self) -> Option> { + self.flush_timer.set_deadline(None); + if self.batch.is_empty() { + None + } else { + Some(self.batch.take()) + } + } +} + +impl PendingWorker { + /// Appends one complete submission and arms the policy's deadline. + /// + /// Call [`Self::take_ready`] with [`FlushTrigger::Submission`] afterwards to + /// check whether this submission triggered a flush. Timer-backed policies + /// require a Tokio runtime with time enabled. + /// + /// # Panics + /// + /// Panics if arming a new timer outside a Tokio runtime with time enabled. + pub fn submit(&mut self, item: T, total_rows: usize) { + self.batch.push(item, total_rows, Instant::now()); + self.refresh_deadline(); + } + + /// Takes the batch only when the policy permits flushing for this event. + /// + /// Like [`Self::submit`], this may arm a timer and requires a time-enabled + /// Tokio runtime when the policy starts using deadlines. + pub fn take_ready(&mut self, trigger: FlushTrigger) -> Option> { + if !self.batch.is_empty() + && self + .flush_policy + .should_flush(&self.batch, Instant::now(), trigger) + { + self.take_pending() + } else { + self.refresh_deadline(); + None + } + } + + fn refresh_deadline(&mut self) { + let deadline = if self.batch.is_empty() { + None + } else { + self.flush_policy.deadline(&self.batch) + }; + self.flush_timer.set_deadline(deadline); + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::task::Poll; + use std::time::Duration; + + use super::*; + use crate::flush_policy::timing::TimingFlushPolicy; + + fn worker(rows: usize) -> PendingWorker { + PendingWorker::new(TimingFlushPolicy::try_new(Duration::from_millis(10), rows).unwrap()) + } + + async fn assert_wait_pending(worker: &mut PendingWorker) { + let wait = worker.wait_flush(); + tokio::pin!(wait); + assert!(std::future::poll_fn(|cx| Poll::Ready(wait.as_mut().poll(cx).is_pending())).await); + } + + #[tokio::test(start_paused = true)] + async fn test_first_deadline_and_cancelled_wait() { + let mut worker = worker(100); + let start = Instant::now(); + worker.submit(1, 1); + assert_wait_pending(&mut worker).await; + tokio::time::advance(Duration::from_millis(5)).await; + worker.submit(2, 1); + assert!(worker.take_ready(FlushTrigger::Submission).is_none()); + assert_wait_pending(&mut worker).await; + worker.wait_flush().await; + assert_eq!(start + Duration::from_millis(10), Instant::now()); + assert_eq!(Some(vec![1, 2]), worker.take_ready(FlushTrigger::Deadline)); + assert!(worker.is_empty()); + assert_eq!(0, worker.total_rows()); + assert_wait_pending(&mut worker).await; + } + + #[tokio::test(start_paused = true)] + async fn test_submission_does_not_consume_expired_deadline() { + let mut worker = worker(100); + worker.submit(1, 1); + tokio::time::advance(Duration::from_millis(10)).await; + worker.submit(2, 1); + assert!(worker.take_ready(FlushTrigger::Submission).is_none()); + worker.wait_flush().await; + assert_eq!(Some(vec![1, 2]), worker.take_ready(FlushTrigger::Deadline)); + } + + #[tokio::test(start_paused = true)] + async fn test_size_flush_and_rearm() { + let mut worker = worker(2); + worker.submit(1, 3); + assert_eq!(3, worker.total_rows()); + assert_eq!(Some(vec![1]), worker.take_ready(FlushTrigger::Submission)); + tokio::time::advance(Duration::from_secs(1)).await; + assert_wait_pending(&mut worker).await; + let start = Instant::now(); + worker.submit(2, 1); + worker.wait_flush().await; + assert_eq!(start + Duration::from_millis(10), Instant::now()); + assert_eq!(Some(vec![2]), worker.take_ready(FlushTrigger::Deadline)); + } + + #[tokio::test(start_paused = true)] + async fn test_take_pending_without_waiting() { + let mut worker = worker(100); + assert_eq!(None, worker.take_pending()); + worker.submit(1, 0); + assert!(!worker.is_empty()); + let now = Instant::now(); + assert_eq!(Some(vec![1]), worker.take_pending()); + assert_eq!(now, Instant::now()); + tokio::time::advance(Duration::from_secs(1)).await; + assert_wait_pending(&mut worker).await; + } + + #[tokio::test(start_paused = true)] + async fn test_row_threshold_ablation() { + for (rows, early) in [(2, true), (100, false)] { + let mut worker = worker(rows); + let start = Instant::now(); + worker.submit(1, 1); + tokio::time::advance(Duration::from_millis(5)).await; + worker.submit(2, 1); + let batch = worker.take_ready(FlushTrigger::Submission); + if early { + assert_eq!(Some(vec![1, 2]), batch); + assert_eq!(start + Duration::from_millis(5), Instant::now()); + } else { + assert!(batch.is_none()); + worker.wait_flush().await; + assert_eq!(start + Duration::from_millis(10), Instant::now()); + assert_eq!(Some(vec![1, 2]), worker.take_ready(FlushTrigger::Deadline)); + } + } + } + + #[tokio::test] + async fn test_non_timing_policy() { + struct TwoSubmissionsPolicy; + impl FlushPolicy for TwoSubmissionsPolicy { + fn deadline(&self, _: &PendingBatch) -> Option { + None + } + fn should_flush( + &self, + batch: &PendingBatch, + _: Instant, + _: FlushTrigger, + ) -> bool { + batch.len() >= 2 + } + } + let mut worker = PendingWorker::new(TwoSubmissionsPolicy); + worker.submit(1, 0); + assert_wait_pending(&mut worker).await; + assert!(worker.take_ready(FlushTrigger::Submission).is_none()); + worker.submit(2, 0); + assert_eq!( + Some(vec![1, 2]), + worker.take_ready(FlushTrigger::Submission) + ); + } + + #[tokio::test(start_paused = true)] + async fn test_caller_owns_event_loop() { + let mut worker = worker(100); + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + tx.send(1).await.unwrap(); + tokio::select! { + _ = worker.wait_flush() => panic!("empty worker must not wake"), + item = rx.recv() => worker.submit(item.unwrap(), 1), + } + assert!(worker.take_ready(FlushTrigger::Submission).is_none()); + let start = Instant::now(); + tokio::select! { + _ = worker.wait_flush() => { + assert_eq!(Some(vec![1]), worker.take_ready(FlushTrigger::Deadline)); + } + _ = rx.recv() => panic!("sender is still open without another submission"), + } + assert_eq!(start + Duration::from_millis(10), Instant::now()); + } +} diff --git a/src/common/batcher/src/request_limiter.rs b/src/common/batcher/src/request_limiter.rs new file mode 100644 index 00000000000..c488bec02d9 --- /dev/null +++ b/src/common/batcher/src/request_limiter.rs @@ -0,0 +1,115 @@ +// 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::sync::Arc; + +use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore}; + +/// Limits unfinished original requests independently of their number of submissions. +/// +/// Acquire once for each original request, then clone its permit into all of that +/// request's submissions. Capacity is released when the last owner drops it. +#[derive(Clone)] +pub struct RequestLimiter { + semaphore: Arc, +} + +impl RequestLimiter { + /// Returns `None` if the limit is zero or exceeds Tokio's supported semaphore capacity. + pub fn try_new(max_inflight_requests: usize) -> Option { + let permits = max_inflight_requests; + ((1..=Semaphore::MAX_PERMITS).contains(&permits)).then(|| Self { + semaphore: Arc::new(Semaphore::new(permits)), + }) + } + + /// Waits for one request slot and returns a permit shared by its submissions. + /// + /// Cancelling a pending acquisition does not consume a slot. Once acquired, + /// callers must retain the permit until every submission has finished, even + /// if the original caller stops waiting for its response. + pub async fn acquire(&self) -> Result, AcquireError> { + self.semaphore.clone().acquire_owned().await.map(Arc::new) + } +} + +#[cfg(test)] +mod tests { + use std::future::{Future, poll_fn}; + use std::pin::Pin; + use std::task::Poll; + + use tokio::sync::Semaphore; + + use crate::request_limiter::RequestLimiter; + + async fn is_pending(mut future: Pin<&mut F>) -> bool { + poll_fn(|cx| Poll::Ready(future.as_mut().poll(cx).is_pending())).await + } + + #[test] + fn test_capacity_boundaries() { + for capacity in [1, Semaphore::MAX_PERMITS] { + assert!(RequestLimiter::try_new(capacity).is_some()); + } + for capacity in [0, Semaphore::MAX_PERMITS + 1, usize::MAX] { + assert!(RequestLimiter::try_new(capacity).is_none()); + } + } + + #[tokio::test] + async fn test_last_submission_releases_request_slot() { + let limiter = RequestLimiter::try_new(1).unwrap(); + let other = limiter.clone(); + let request = limiter.acquire().await.unwrap(); + let first_submission = request.clone(); + let second_submission = request.clone(); + let mut waiting = Box::pin(other.acquire()); + assert!(is_pending(waiting.as_mut()).await); + // The caller and one submission finish, but another submission remains. + drop(request); + drop(first_submission); + assert!(is_pending(waiting.as_mut()).await); + drop(second_submission); + let next = waiting.await.unwrap(); + assert_eq!(next.num_permits(), 1); + } + + #[tokio::test] + async fn test_cancelled_acquisition_does_not_leak_capacity() { + let limiter = RequestLimiter::try_new(1).unwrap(); + let permit = limiter.acquire().await.unwrap(); + let mut cancelled = Box::pin(limiter.acquire()); + assert!(is_pending(cancelled.as_mut()).await); + drop(cancelled); + let mut next = Box::pin(limiter.acquire()); + assert!(is_pending(next.as_mut()).await); + drop(permit); + let next = next.await.unwrap(); + drop(next); + let _available = limiter.acquire().await.unwrap(); + } + + #[tokio::test] + async fn test_independent_requests_consume_separate_slots() { + let limiter = RequestLimiter::try_new(2).unwrap(); + let first = limiter.acquire().await.unwrap(); + let second = limiter.acquire().await.unwrap(); + let mut third = Box::pin(limiter.acquire()); + assert!(is_pending(third.as_mut()).await); + drop(first); + let _third = third.await.unwrap(); + drop(second); + } +} diff --git a/src/common/batcher/src/worker_registry.rs b/src/common/batcher/src/worker_registry.rs new file mode 100644 index 00000000000..f5dbf48e480 --- /dev/null +++ b/src/common/batcher/src/worker_registry.rs @@ -0,0 +1,265 @@ +// 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::hash::Hash; + +use dashmap::DashMap; +use dashmap::mapref::entry::Entry; +use tokio::sync::mpsc::{self, Receiver, Sender}; + +/// Registers worker senders by key, without owning workers or their execution. +/// +/// Closed senders are replaced on lookup/creation. Cleanup checks channel +/// identity under the same lock as replacement, so an old worker cannot remove +/// a replacement registered under its key. +pub struct WorkerRegistry { + workers: DashMap>, +} + +impl Default for WorkerRegistry { + fn default() -> Self { + Self { + workers: DashMap::new(), + } + } +} + +impl WorkerRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Returns a live sender, if one is currently registered. + /// The receiver can close after this method returns; callers must handle a + /// failed send and retry worker lookup without discarding the unsent item. + pub async fn get(&self, key: &K) -> Option> { + self.workers + .get(key) + .filter(|tx| !tx.is_closed()) + .map(|tx| tx.value().clone()) + } + + /// Returns the registered sender and, only when created, its receiver. + /// + /// Start the new worker before the next await so cancellation cannot leave + /// a registered channel without a consumer. Existing channels retain their + /// original capacity. New channel capacity must satisfy `mpsc::channel`. + pub async fn get_or_create(&self, key: K, capacity: usize) -> (Sender, Option>) { + let mut receiver = None; + let sender = self + .get_or_insert_with(key, || { + let (sender, rx) = mpsc::channel(capacity); + receiver = Some(rx); + sender + }) + .await; + (sender, receiver) + } + + /// Reuses a live sender or atomically creates its replacement. + /// + /// `create` runs synchronously under the registry shard lock. It should only + /// prepare the sender and capture any initialization state (such as the + /// receiver) for the caller; start the worker after this method returns. + /// Do not block or reenter the registry from `create`. + pub async fn get_or_insert_with(&self, key: K, create: F) -> Sender + where + F: FnOnce() -> Sender, + { + if let Some(tx) = self.get(&key).await { + return tx; + } + + match self.workers.entry(key) { + Entry::Occupied(mut entry) => { + if entry.get().is_closed() { + entry.insert(create()); + } + entry.get().clone() + } + Entry::Vacant(entry) => entry.insert(create()).value().clone(), + } + } + + /// Removes the key only if it still points to this worker's channel. + pub async fn remove_if_same(&self, key: &K, tx: &Sender) -> bool { + self.workers + .remove_if(key, |_, current| current.same_channel(tx)) + .is_some() + } + + /// Number of registered entries, including senders whose receivers closed. + pub async fn len(&self) -> usize { + self.workers.len() + } + + pub async fn is_empty(&self) -> bool { + self.workers.is_empty() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::sync::{Barrier, mpsc}; + + use crate::worker_registry::WorkerRegistry; + + #[tokio::test] + async fn test_live_lookup_uses_read_lock() { + let registry = Arc::new(WorkerRegistry::<_, ()>::new()); + let (sender, _receiver) = registry.get_or_create(1, 1).await; + let guard = registry.workers.get(&1).unwrap(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let worker_registry = registry.clone(); + let runtime = tokio::runtime::Handle::current(); + let thread = std::thread::spawn(move || { + let result = runtime.block_on(worker_registry.get_or_create(1, 1)); + let _ = result_tx.send(result); + }); + // Release the read guard before joining, even if lookup needs a write lock. + let result = result_rx.recv_timeout(std::time::Duration::from_secs(5)); + drop(guard); + thread.join().unwrap(); + let (reused, receiver) = result.expect("live lookup must not wait for an exclusive lock"); + assert!(sender.same_channel(&reused)); + assert!(receiver.is_none()); + } + + #[tokio::test] + async fn test_reuses_live_sender_and_returns_receiver_to_caller() { + let registry = WorkerRegistry::new(); + assert!(registry.is_empty().await); + assert!(registry.get(&1).await.is_none()); + let mut receiver = None; + let first = registry + .get_or_insert_with(1, || { + let (tx, rx) = mpsc::channel(1); + receiver = Some(rx); + tx + }) + .await; + let second = registry + .get_or_insert_with(1, || panic!("live worker must be reused")) + .await; + assert!(first.same_channel(&second)); + assert!(registry.get(&1).await.unwrap().same_channel(&first)); + first.send(7).await.unwrap(); + assert_eq!(Some(7), receiver.as_mut().unwrap().recv().await); + assert_eq!(1, registry.len().await); + assert!(registry.remove_if_same(&1, &first).await); + assert!(!registry.remove_if_same(&1, &first).await); + assert!(registry.is_empty().await); + } + + #[tokio::test] + async fn test_old_cleanup_does_not_remove_replacement() { + let registry = WorkerRegistry::<_, ()>::new(); + let (first, mut receiver) = mpsc::channel(1); + registry.get_or_insert_with("table", || first.clone()).await; + receiver.close(); + assert!(registry.get(&"table").await.is_none()); + // Closed entries remain accounted until replacement or explicit removal. + assert_eq!(1, registry.len().await); + let (second, _receiver) = mpsc::channel(1); + registry + .get_or_insert_with("table", || second.clone()) + .await; + assert!(!registry.remove_if_same(&"table", &first).await); + assert!(registry.get(&"table").await.unwrap().same_channel(&second)); + assert_eq!(1, registry.len().await); + assert!(registry.remove_if_same(&"table", &second).await); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_concurrent_lookup_initializes_once() { + let registry = Arc::new(WorkerRegistry::<_, ()>::new()); + let barrier = Arc::new(Barrier::new(3)); + let count = Arc::new(AtomicUsize::new(0)); + let mut tasks = Vec::new(); + for _ in 0..2 { + let registry = registry.clone(); + let barrier = barrier.clone(); + let count = count.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + let mut receiver = None; + let tx = registry + .get_or_insert_with(1, || { + count.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::channel(1); + receiver = Some(rx); + tx + }) + .await; + // Keep the newly created receiver alive while both lookups run. + barrier.wait().await; + (tx, receiver) + })); + } + barrier.wait().await; + barrier.wait().await; + let first = tasks.remove(0).await.unwrap(); + let second = tasks.remove(0).await.unwrap(); + assert!(first.0.same_channel(&second.0)); + assert_eq!(1, count.load(Ordering::Relaxed)); + assert_eq!(1, registry.len().await); + } + #[tokio::test] + async fn test_channel_creation_replacement_and_cleanup() { + let registry = WorkerRegistry::<_, usize>::new(); + let (first, receiver) = registry.get_or_create(1, 2).await; + let mut receiver = receiver.unwrap(); + let (reused, absent) = registry.get_or_create(1, 3).await; + assert!(absent.is_none()); + assert!(first.same_channel(&reused)); + assert_eq!(2, reused.max_capacity()); + first.send(7).await.unwrap(); + assert_eq!(Some(7), receiver.recv().await); + receiver.close(); + let (replacement, receiver) = registry.get_or_create(1, 3).await; + assert!(receiver.is_some()); + assert_eq!(3, replacement.max_capacity()); + assert!(!first.same_channel(&replacement)); + assert!(!registry.remove_if_same(&1, &first).await); + assert!(registry.get(&1).await.unwrap().same_channel(&replacement)); + assert!(registry.remove_if_same(&1, &replacement).await); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_concurrent_channel_creation_returns_one_receiver() { + let registry = Arc::new(WorkerRegistry::<_, ()>::new()); + let barrier = Arc::new(Barrier::new(3)); + let mut tasks = Vec::new(); + for _ in 0..2 { + let registry = registry.clone(); + let barrier = barrier.clone(); + tasks.push(tokio::spawn(async move { + barrier.wait().await; + let channel = registry.get_or_create(1, 2).await; + barrier.wait().await; + channel + })); + } + barrier.wait().await; + barrier.wait().await; + let first = tasks.remove(0).await.unwrap(); + let second = tasks.remove(0).await.unwrap(); + assert!(first.0.same_channel(&second.0)); + assert_ne!(first.1.is_some(), second.1.is_some()); + assert_eq!(1, registry.len().await); + } +} diff --git a/src/common/grpc/src/flight.rs b/src/common/grpc/src/flight.rs index 1245a86efb1..4f5a47cb535 100644 --- a/src/common/grpc/src/flight.rs +++ b/src/common/grpc/src/flight.rs @@ -37,6 +37,35 @@ use vec1::{Vec1, vec1}; use crate::error; use crate::error::{DecodeFlightDataSnafu, InvalidFlightDataSnafu, Result}; +/// Encodes a batch into the schema, record-batch header and payload used by bulk inserts. +/// +/// Uses the default Flight compression and rejects dictionary batches, whose +/// additional Flight messages cannot be represented by this three-part format. +pub fn record_batch_to_ipc( + record_batch: DfRecordBatch, +) -> Result<(ProstBytes, ProstBytes, ProstBytes)> { + let mut encoder = FlightEncoder::default(); + let schema = encoder.encode_schema(record_batch.schema().as_ref()); + let mut iter = encoder + .encode(FlightMessage::RecordBatch(record_batch)) + .into_iter(); + let flight_data = iter.next().context(InvalidFlightDataSnafu { + reason: "Failed to encode empty flight data", + })?; + if iter.next().is_some() { + return error::NotSupportedSnafu { + feat: "bulk insert RecordBatch with dictionary arrays", + } + .fail(); + } + + Ok(( + schema.data_header, + flight_data.data_header, + flight_data.data_body, + )) +} + /// Flight metadata key used to carry flow query extensions as JSON pairs. pub const FLOW_EXTENSIONS_METADATA_KEY: &str = "x-greptime-flow-extensions"; /// Flight metadata key used to carry query snapshot read upper bounds as JSON. @@ -374,8 +403,52 @@ mod test { use datatypes::arrow::buffer::OffsetBuffer; use datatypes::arrow::datatypes::{DataType, Field, Schema}; - use super::*; use crate::Error; + use crate::flight::*; + + #[test] + fn test_record_batch_to_ipc_preserves_wire_bytes() { + let schema = Arc::new(Schema::new(vec![Field::new("n", DataType::Int32, true)])); + for values in [vec![], vec![Some(1), None, Some(3)]] { + let batch = + DfRecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(values))]) + .unwrap(); + // Reproduce the previous Prom encoding sequence and compare all bytes. + let mut encoder = FlightEncoder::default(); + let expected_schema = encoder.encode_schema(batch.schema().as_ref()); + let messages = encoder.encode(FlightMessage::RecordBatch(batch.clone())); + assert_eq!(messages.len(), 1); + let expected_batch = messages.first(); + assert_eq!( + record_batch_to_ipc(batch).unwrap(), + ( + expected_schema.data_header, + expected_batch.data_header.clone(), + expected_batch.data_body.clone(), + ) + ); + } + } + + #[test] + fn test_record_batch_to_ipc_rejects_dictionary() { + let schema = Arc::new(Schema::new(vec![Field::new_dictionary( + "tag", + DataType::UInt32, + DataType::Utf8, + true, + )])); + let dictionary = DictionaryArray::new( + UInt32Array::from_value(0, 3), + Arc::new(StringArray::from_iter_values(["x"])), + ); + let batch = DfRecordBatch::try_new(schema, vec![Arc::new(dictionary)]).unwrap(); + assert!(matches!( + record_batch_to_ipc(batch), + Err(Error::NotSupported { feat }) + if feat == "bulk insert RecordBatch with dictionary arrays" + )); + } #[test] fn test_try_decode() -> Result<()> { diff --git a/src/common/meta/src/ddl/alter_table.rs b/src/common/meta/src/ddl/alter_table.rs index 8e24f3ae2ab..e61250b47f6 100644 --- a/src/common/meta/src/ddl/alter_table.rs +++ b/src/common/meta/src/ddl/alter_table.rs @@ -31,6 +31,7 @@ use common_procedure::{ EventTrigger, LockKey, PoisonKey, PoisonKeys, Procedure, ProcedureId, Status, StringKey, }; use common_telemetry::{error, info, warn}; +use common_wal::options::WalOptions; use serde::{Deserialize, Serialize}; use snafu::{ResultExt, ensure}; use store_api::metadata::ColumnMetadata; @@ -47,12 +48,12 @@ use crate::ddl::event::table::{ TableDdlEvent, TableDdlEventType, TableDdlLocator, alter_table_kind_name, }; use crate::ddl::utils::{ - MultipleResults, extract_column_metadatas, handle_multiple_results, map_to_procedure_error, - sync_follower_regions, + MultipleResults, extract_column_metadatas, extract_region_wal_options, handle_multiple_results, + map_to_procedure_error, sync_follower_regions, }; use crate::error::{ AbortProcedureSnafu, ConvertAlterTableRequestSnafu, NoLeaderSnafu, PutPoisonSnafu, Result, - RetryLaterSnafu, UnsupportedSnafu, + RetryLaterSnafu, UnexpectedSnafu, UnsupportedSnafu, }; use crate::key::table_info::TableInfoValue; use crate::key::{DeserializedValueWithBytes, RegionDistribution}; @@ -148,11 +149,11 @@ impl AlterTableProcedure { // Safety: Checked in `AlterTableProcedure::new`. let alter_kind = self.data.task.alter_table.kind.as_ref().unwrap(); - if enables_skip_wal(alter_kind) { + if sets_skip_wal(alter_kind) { ensure!( - only_enables_skip_wal(alter_kind), + only_sets_skip_wal(alter_kind), UnsupportedSnafu { - operation: "combining skip_wal = 'true' with other table options".to_string() + operation: "combining skip_wal with other table options".to_string() } ); let engine = table_info_value.table_info.meta.engine.as_str(); @@ -162,7 +163,8 @@ impl AlterTableProcedure { operation: format!("setting skip_wal on {engine} engine tables") } ); - // Persist the irreversible intent before any region stops writing WAL. + // Keep skip-WAL changes serialized with region migration and use the same + // route snapshot for validation and RPC delivery. let (physical_table_id, physical_table_route) = self .context .table_metadata_manager @@ -199,6 +201,37 @@ impl AlterTableProcedure { table_id: physical_table_id } ); + // validate_alter_table_expr() has already parsed this single skip-WAL option. + let Some(skip_wal) = skip_wal_value(alter_kind) else { + return UnexpectedSnafu { + err_msg: "missing or invalid skip_wal value after validation".to_string(), + } + .fail(); + }; + if !skip_wal { + let datanode_table_values = self + .context + .table_metadata_manager + .datanode_table_manager() + .regions(physical_table_id, &physical_table_route) + .await?; + let region_wal_options = extract_region_wal_options(&datanode_table_values)?; + ensure!( + current_region_ids.iter().all(|region_id| { + matches!( + region_wal_options + .get(®ion_id.region_number()) + .cloned() + .unwrap_or_default(), + WalOptions::RaftEngine | WalOptions::Kafka(_) + ) + }), + UnsupportedSnafu { + operation: "setting skip_wal = 'false' without an existing WAL provider" + .to_string() + } + ); + } self.data.region_distribution = Some(region_distribution(&physical_table_route.region_routes)); } @@ -265,8 +298,8 @@ impl AlterTableProcedure { MultipleResults::PartialRetryable(error) => Err(error), MultipleResults::PartialNonRetryable(error) | MultipleResults::AllNonRetryable(error) => { - // The metadata already enables skip-WAL. Retry the idempotent request - // so later attempts can update the remaining replicas. + // The metadata already contains the requested skip-WAL value. Retry the + // idempotent request so later attempts can update the remaining replicas. Err(BoxedError::new(error)).context(RetryLaterSnafu { clean_poisons: true, }) @@ -450,24 +483,35 @@ impl AlterTableProcedure { } } -fn enables_skip_wal(alter_kind: &Kind) -> bool { +fn sets_skip_wal(alter_kind: &Kind) -> bool { let Kind::SetTableOptions(SetTableOptions { table_options }) = alter_kind else { return false; }; table_options .iter() - .any(|option| option.key == SKIP_WAL_KEY && option.value == "true") + .any(|option| option.key == SKIP_WAL_KEY) } -pub(crate) fn only_enables_skip_wal(alter_kind: &Kind) -> bool { +pub(crate) fn only_sets_skip_wal(alter_kind: &Kind) -> bool { let Kind::SetTableOptions(SetTableOptions { table_options }) = alter_kind else { return false; }; - table_options.len() == 1 - && table_options[0].key == SKIP_WAL_KEY - && table_options[0].value == "true" + table_options.len() == 1 && table_options[0].key == SKIP_WAL_KEY +} + +fn skip_wal_value(alter_kind: &Kind) -> Option { + let Kind::SetTableOptions(SetTableOptions { table_options }) = alter_kind else { + return None; + }; + let [option] = table_options.as_slice() else { + return None; + }; + if option.key != SKIP_WAL_KEY { + return None; + } + option.value.parse().ok() } fn is_metadata_only_alter(alter_kind: &Kind) -> Result { @@ -487,7 +531,7 @@ enum AlterTableFlow { impl AlterTableFlow { fn from_kind(kind: &Kind) -> Result { - Ok(if only_enables_skip_wal(kind) { + Ok(if only_sets_skip_wal(kind) { Self::MetadataFirst } else if is_metadata_only_alter(kind)? { Self::MetadataOnly @@ -611,7 +655,7 @@ pub struct AlterTableData { table_info_value: Option>, /// Region distribution for table in case we need to update region options. region_distribution: Option, - /// Region locks held by irreversible region-option alters. + /// Region locks held by skip-WAL alters. #[serde(default)] region_locks: Vec, } diff --git a/src/common/meta/src/ddl/alter_table/executor.rs b/src/common/meta/src/ddl/alter_table/executor.rs index 65fa45d0a8f..53bc4b3d05b 100644 --- a/src/common/meta/src/ddl/alter_table/executor.rs +++ b/src/common/meta/src/ddl/alter_table/executor.rs @@ -174,7 +174,7 @@ impl AlterTableExecutor { .await } - /// Alters all replicas for the irreversible skip-WAL flow. + /// Alters all replicas for the skip-WAL flow. pub(crate) async fn on_alter_skip_wal_regions( &self, node_manager: &NodeManagerRef, diff --git a/src/common/meta/src/ddl/tests/alter_table.rs b/src/common/meta/src/ddl/tests/alter_table.rs index f0ceaf592e1..305601af5be 100644 --- a/src/common/meta/src/ddl/tests/alter_table.rs +++ b/src/common/meta/src/ddl/tests/alter_table.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use api::region::RegionResponse; use api::v1::alter_table_expr::Kind; use api::v1::region::sync_request::ManifestInfo; -use api::v1::region::{RegionRequest, region_request}; +use api::v1::region::{RegionRequest, alter_request, region_request}; use api::v1::{ AddColumn, AddColumns, AlterTableExpr, ColumnDataType, ColumnDef as PbColumnDef, DropColumn, DropColumns, SemanticType, SetTableOptions, @@ -30,6 +30,7 @@ use common_error::status_code::StatusCode; use common_procedure::store::poison_store::PoisonStore; use common_procedure::{Procedure, ProcedureId, Status}; use common_procedure_test::{MockContextProvider, execute_procedure_until_done}; +use common_wal::options::{KafkaWalOptions, WalOptions}; use datatypes::prelude::ConcreteDataType; use datatypes::schema::ColumnSchema; use store_api::metadata::ColumnMetadata; @@ -164,6 +165,29 @@ fn assert_alter_request( assert_eq!(req.region_id, expected_region_id); } +fn assert_skip_wal_alter_request( + peer: Peer, + request: RegionRequest, + expected_peer_id: u64, + expected_region_id: RegionId, + expected_skip_wal: bool, +) { + assert_eq!(peer.id, expected_peer_id); + let Some(region_request::Body::Alter(req)) = request.body else { + unreachable!(); + }; + assert_eq!(req.region_id, expected_region_id); + let Some(alter_request::Kind::SetTableOptions(options)) = req.kind else { + unreachable!(); + }; + assert_eq!(1, options.table_options.len()); + assert_eq!(SKIP_WAL_KEY, options.table_options[0].key); + assert_eq!( + expected_skip_wal.to_string(), + options.table_options[0].value + ); +} + fn assert_sync_request( peer: Peer, request: RegionRequest, @@ -729,39 +753,42 @@ async fn test_skip_wal_rejects_file_engine_table() { fn test_skip_wal_holds_region_locks() { let table_id = 1024; let region_ids = vec![RegionId::new(table_id, 2), RegionId::new(table_id, 1)]; - let task = AlterTableTask { - alter_table: AlterTableExpr { - catalog_name: DEFAULT_CATALOG_NAME.to_string(), - schema_name: DEFAULT_SCHEMA_NAME.to_string(), - table_name: "foo".to_string(), - kind: Some(Kind::SetTableOptions(SetTableOptions { - table_options: vec![api::v1::Option { - key: SKIP_WAL_KEY.to_string(), - value: "true".to_string(), - }], - })), - }, - }; let context = new_ddl_context(Arc::new(MockDatanodeManager::new(()))); - let procedure = AlterTableProcedure::new_with_region_locks( - table_id, - task, - region_ids.clone(), - context.clone(), - ) - .unwrap(); + for value in ["true", "false"] { + let task = AlterTableTask { + alter_table: AlterTableExpr { + catalog_name: DEFAULT_CATALOG_NAME.to_string(), + schema_name: DEFAULT_SCHEMA_NAME.to_string(), + table_name: "foo".to_string(), + kind: Some(Kind::SetTableOptions(SetTableOptions { + table_options: vec![api::v1::Option { + key: SKIP_WAL_KEY.to_string(), + value: value.to_string(), + }], + })), + }, + }; + let procedure = AlterTableProcedure::new_with_region_locks( + table_id, + task, + region_ids.clone(), + context.clone(), + ) + .unwrap(); - let lock_key = procedure.lock_key(); - for region_id in ®ion_ids { - assert!( - lock_key - .keys_to_lock() - .any(|key| key == &RegionLock::Write(*region_id).into()) - ); + let lock_key = procedure.lock_key(); + for region_id in ®ion_ids { + assert!( + lock_key + .keys_to_lock() + .any(|key| key == &RegionLock::Write(*region_id).into()) + ); + } + + let recovered = + AlterTableProcedure::from_json(&procedure.dump().unwrap(), context.clone()).unwrap(); + assert_eq!(lock_key, recovered.lock_key()); } - - let recovered = AlterTableProcedure::from_json(&procedure.dump().unwrap(), context).unwrap(); - assert_eq!(lock_key, recovered.lock_key()); } #[tokio::test] @@ -875,6 +902,252 @@ async fn test_skip_wal_rejects_no_leader_before_updating_metadata() { assert!(!table_info.meta.options.skip_wal); } +#[tokio::test] +async fn test_enable_wal_rejects_noop_provider_before_updating_metadata() { + let ddl_context = new_ddl_context(Arc::new(MockDatanodeManager::new(()))); + let table_name = "foo"; + let table_id = 1024; + let mut task = test_create_table_task(table_name, table_id); + task.table_info.meta.options.skip_wal = true; + task.table_info + .meta + .options + .extra_options + .insert(SKIP_WAL_KEY.to_string(), "true".to_string()); + let table_route = prepare_table_route(table_id); + let region_locks = table_route + .region_routes() + .unwrap() + .iter() + .map(|route| route.region.id) + .collect(); + let region_wal_options = HashMap::from([ + (1, WalOptions::Noop), + (2, WalOptions::Noop), + (3, WalOptions::Noop), + ]); + ddl_context + .table_metadata_manager + .create_table_metadata(task.table_info, table_route, region_wal_options) + .await + .unwrap(); + + let alter_task = AlterTableTask { + alter_table: AlterTableExpr { + catalog_name: DEFAULT_CATALOG_NAME.to_string(), + schema_name: DEFAULT_SCHEMA_NAME.to_string(), + table_name: table_name.to_string(), + kind: Some(Kind::SetTableOptions(SetTableOptions { + table_options: vec![api::v1::Option { + key: SKIP_WAL_KEY.to_string(), + value: "false".to_string(), + }], + })), + }, + }; + let mut procedure = AlterTableProcedure::new_with_region_locks( + table_id, + alter_task, + region_locks, + ddl_context.clone(), + ) + .unwrap(); + + let error = procedure.on_prepare().await.unwrap_err(); + assert_matches!(error, Error::Unsupported { .. }); + assert!( + error + .to_string() + .contains("without an existing WAL provider") + ); + let table_info = ddl_context + .table_metadata_manager + .table_info_manager() + .get(table_id) + .await + .unwrap() + .unwrap() + .into_inner() + .table_info; + assert!(table_info.meta.options.skip_wal); +} + +#[tokio::test] +async fn test_enable_wal_accepts_legacy_raft_regions_without_wal_options() { + let ddl_context = new_ddl_context(Arc::new(MockDatanodeManager::new(()))); + let table_name = "legacy_raft"; + let table_id = 1024; + let mut task = test_create_table_task(table_name, table_id); + task.table_info.meta.options.skip_wal = true; + task.table_info + .meta + .options + .extra_options + .insert(SKIP_WAL_KEY.to_string(), "true".to_string()); + let table_route = prepare_table_route(table_id); + let region_locks = table_route + .region_routes() + .unwrap() + .iter() + .map(|route| route.region.id) + .collect(); + ddl_context + .table_metadata_manager + .create_table_metadata(task.table_info, table_route, HashMap::new()) + .await + .unwrap(); + + let alter_task = AlterTableTask { + alter_table: AlterTableExpr { + catalog_name: DEFAULT_CATALOG_NAME.to_string(), + schema_name: DEFAULT_SCHEMA_NAME.to_string(), + table_name: table_name.to_string(), + kind: Some(Kind::SetTableOptions(SetTableOptions { + table_options: vec![api::v1::Option { + key: SKIP_WAL_KEY.to_string(), + value: "false".to_string(), + }], + })), + }, + }; + let mut procedure = AlterTableProcedure::new_with_region_locks( + table_id, + alter_task, + region_locks, + ddl_context.clone(), + ) + .unwrap(); + + procedure.on_prepare().await.unwrap(); + procedure.on_update_metadata().await.unwrap(); + let table_info = ddl_context + .table_metadata_manager + .table_info_manager() + .get(table_id) + .await + .unwrap() + .unwrap() + .into_inner() + .table_info; + assert!(!table_info.meta.options.skip_wal); +} + +#[tokio::test] +async fn test_enable_wal_updates_metadata_and_region_request() { + let (tx, mut rx) = mpsc::channel(8); + let node_manager = Arc::new(MockDatanodeManager::new(DatanodeWatcher::new(tx))); + let ddl_context = new_ddl_context(node_manager); + let table_name = "foo"; + let table_id = 1024; + let mut task = test_create_table_task(table_name, table_id); + task.table_info.meta.options.skip_wal = true; + task.table_info + .meta + .options + .extra_options + .insert(SKIP_WAL_KEY.to_string(), "true".to_string()); + let table_route = prepare_table_route(table_id); + let region_locks = table_route + .region_routes() + .unwrap() + .iter() + .map(|route| route.region.id) + .collect(); + let region_wal_options = HashMap::from([ + (1, WalOptions::RaftEngine), + ( + 2, + WalOptions::Kafka(KafkaWalOptions::new("topic-2".to_string())), + ), + ( + 3, + WalOptions::Kafka(KafkaWalOptions::new("topic-3".to_string())), + ), + ]); + ddl_context + .table_metadata_manager + .create_table_metadata(task.table_info, table_route, region_wal_options) + .await + .unwrap(); + + let alter_task = AlterTableTask { + alter_table: AlterTableExpr { + catalog_name: DEFAULT_CATALOG_NAME.to_string(), + schema_name: DEFAULT_SCHEMA_NAME.to_string(), + table_name: table_name.to_string(), + kind: Some(Kind::SetTableOptions(SetTableOptions { + table_options: vec![api::v1::Option { + key: SKIP_WAL_KEY.to_string(), + value: "false".to_string(), + }], + })), + }, + }; + let mut procedure = AlterTableProcedure::new_with_region_locks( + table_id, + alter_task, + region_locks, + ddl_context.clone(), + ) + .unwrap(); + + procedure.on_prepare().await.unwrap(); + let persisted: serde_json::Value = serde_json::from_str(&procedure.dump().unwrap()).unwrap(); + assert_eq!("UpdateMetadata", persisted["state"]); + + let Some(alter_request::Kind::SetTableOptions(options)) = + procedure.make_region_alter_kind().unwrap() + else { + unreachable!(); + }; + assert_eq!(1, options.table_options.len()); + assert_eq!(SKIP_WAL_KEY, options.table_options[0].key); + assert_eq!("false", options.table_options[0].value); + + procedure.on_update_metadata().await.unwrap(); + let table_info = ddl_context + .table_metadata_manager + .table_info_manager() + .get(table_id) + .await + .unwrap() + .unwrap() + .into_inner() + .table_info; + assert!(!table_info.meta.options.skip_wal); + assert_eq!( + Some("false"), + table_info + .meta + .options + .extra_options + .get(SKIP_WAL_KEY) + .map(String::as_str) + ); + + let provider = Arc::new(MockContextProvider::default()); + procedure + .submit_alter_region_requests(ProcedureId::random(), provider.as_ref()) + .await + .unwrap(); + let mut requests = Vec::new(); + while let Ok(request) = rx.try_recv() { + requests.push(request); + } + requests.sort_unstable_by_key(|(peer, _)| peer.id); + let expected = [ + (1, RegionId::new(table_id, 1)), + (2, RegionId::new(table_id, 2)), + (3, RegionId::new(table_id, 3)), + (4, RegionId::new(table_id, 2)), + (5, RegionId::new(table_id, 1)), + ]; + assert_eq!(expected.len(), requests.len()); + for ((peer, request), (peer_id, region_id)) in requests.into_iter().zip(expected) { + assert_skip_wal_alter_request(peer, request, peer_id, region_id, false); + } +} + #[tokio::test] async fn test_skip_wal_updates_metadata_before_all_replicas() { let (tx, mut rx) = mpsc::channel(8); diff --git a/src/common/meta/src/ddl_manager.rs b/src/common/meta/src/ddl_manager.rs index 30911b7b272..51b0a90f304 100644 --- a/src/common/meta/src/ddl_manager.rs +++ b/src/common/meta/src/ddl_manager.rs @@ -35,7 +35,7 @@ use table::table_name::TableName; use crate::ddl::alter_database::AlterDatabaseProcedure; use crate::ddl::alter_logical_tables::AlterLogicalTablesProcedure; -use crate::ddl::alter_table::{AlterTableProcedure, RegionRouteChanged, only_enables_skip_wal}; +use crate::ddl::alter_table::{AlterTableProcedure, RegionRouteChanged, only_sets_skip_wal}; use crate::ddl::comment_on::CommentOnProcedure; use crate::ddl::create_database::{CreateDatabaseMetadataCommitterRef, CreateDatabaseProcedure}; use crate::ddl::create_flow::CreateFlowProcedure; @@ -433,7 +433,7 @@ impl DdlManager { .alter_table .kind .as_ref() - .is_some_and(only_enables_skip_wal); + .is_some_and(only_sets_skip_wal); let mut route_change_retries = 0; loop { diff --git a/src/datatypes/src/timestamp.rs b/src/datatypes/src/timestamp.rs index bf6fdaa1623..ac6ca1444f1 100644 --- a/src/datatypes/src/timestamp.rs +++ b/src/datatypes/src/timestamp.rs @@ -13,7 +13,7 @@ // limitations under the License. use arrow_array::{ - ArrayRef, PrimitiveArray, TimestampMicrosecondArray, TimestampMillisecondArray, + Array, ArrayRef, PrimitiveArray, TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray, TimestampSecondArray, }; use arrow_schema::DataType; @@ -179,8 +179,24 @@ pub fn timestamp_array_to_primitive( Some((ts_primitive, *unit)) } +/// Appends non-null timestamps in the source array's native time unit. +/// +/// Returns `None` for a non-timestamp array without changing `timestamps`. +pub fn append_timestamps(ts_array: &ArrayRef, timestamps: &mut Vec) -> Option<()> { + let (values, _) = timestamp_array_to_primitive(ts_array)?; + if values.null_count() == 0 { + timestamps.extend_from_slice(values.values()); + } else { + timestamps.extend(values.iter().flatten()); + } + Some(()) +} + #[cfg(test)] mod tests { + use std::sync::Arc; + + use arrow_array::Int64Array; use common_time::timezone::set_default_timezone; use super::*; @@ -213,4 +229,46 @@ mod tests { assert_eq!(ts, ts.as_scalar_ref()); assert_eq!(ts, ts.to_owned_scalar()); } + + #[test] + fn test_append_timestamps() { + let cases = [ + vec![Some(i64::MIN), Some(-1), Some(0), Some(i64::MAX)], + vec![Some(-1), None, Some(2), None], + vec![None, None], + vec![], + ]; + for values in cases { + let arrays: [ArrayRef; 4] = [ + Arc::new(TimestampSecondArray::from(values.clone())), + Arc::new(TimestampMillisecondArray::from(values.clone())), + Arc::new(TimestampMicrosecondArray::from(values.clone())), + Arc::new(TimestampNanosecondArray::from(values.clone())), + ]; + let mut expected = vec![42]; + expected.extend(values.iter().flatten().copied()); + for array in arrays { + for _ in 0..2 { + let (primitive, _) = timestamp_array_to_primitive(&array).unwrap(); + let mut reference = vec![42]; + reference.extend(primitive.iter().flatten()); + + let mut timestamps = vec![42]; + assert_eq!(append_timestamps(&array, &mut timestamps), Some(())); + assert_eq!(timestamps, expected); + assert_eq!(timestamps, reference); + } + } + } + } + + #[test] + fn test_append_timestamps_invalid_array_preserves_prefix() { + for values in [vec![], vec![Some(1), None]] { + let array: ArrayRef = Arc::new(Int64Array::from(values)); + let mut timestamps = vec![42, -1]; + assert_eq!(append_timestamps(&array, &mut timestamps), None); + assert_eq!(timestamps, vec![42, -1]); + } + } } diff --git a/src/flow/src/batching_mode/task.rs b/src/flow/src/batching_mode/task.rs index 95b989851f7..c9da0b28a53 100644 --- a/src/flow/src/batching_mode/task.rs +++ b/src/flow/src/batching_mode/task.rs @@ -510,6 +510,7 @@ impl BatchingTask { debug!("Generate new query: {}", new_query.plan); let res = self .execute_logical_plan_unlocked( + engine, frontend_client, &new_query.plan, &new_query.dirty_restore, @@ -640,6 +641,7 @@ impl BatchingTask { /// Executes the insert plan. Caller must reach this through the serialized path. async fn execute_logical_plan_unlocked( &self, + engine: &QueryEngineRef, frontend_client: &Arc, plan: &LogicalPlan, dirty_restore: &DirtyRestore, @@ -676,7 +678,7 @@ impl BatchingTask { // For incremental-mode SQL queries, attempt to rewrite the delta aggregate // plan into a safe delta-LEFT-JOIN-sink form before deciding on extensions. let incremental_plan = if coverage.is_incremental_delta() { - self.prepare_plan_for_incremental(&plan).await? + self.prepare_plan_for_incremental(engine, &plan).await? } else { None }; diff --git a/src/flow/src/batching_mode/task/inc.rs b/src/flow/src/batching_mode/task/inc.rs index 8c090d9814e..a980e1b9814 100644 --- a/src/flow/src/batching_mode/task/inc.rs +++ b/src/flow/src/batching_mode/task/inc.rs @@ -18,6 +18,7 @@ use common_error::ext::BoxedError; use common_telemetry::debug; use common_telemetry::tracing::warn; use datafusion_expr::{DmlStatement, LogicalPlan}; +use query::QueryEngineRef; use query::options::{ FLOW_INCREMENTAL_AFTER_SEQS, FLOW_INCREMENTAL_MODE, FLOW_INCREMENTAL_MODE_MEMTABLE_ONLY, FLOW_INCREMENTAL_MODE_SEQUENCE_RANGE, FLOW_SINK_TABLE_ID, @@ -119,6 +120,7 @@ impl BatchingTask { /// incremental safe without a rewrite, so they return `Some(original_plan)`. pub(super) async fn prepare_plan_for_incremental( &self, + engine: &QueryEngineRef, plan: &LogicalPlan, ) -> Result, Error> { let is_incremental_sql = { @@ -219,6 +221,7 @@ impl BatchingTask { let rewritten_inner = match rewrite_incremental_aggregate_with_sink_merge( &inner_plan, &analysis, + engine, sink_table, &self.config.sink_table_name, None, diff --git a/src/flow/src/batching_mode/task/test.rs b/src/flow/src/batching_mode/task/test.rs index 8685c644968..5ab13681ee2 100644 --- a/src/flow/src/batching_mode/task/test.rs +++ b/src/flow/src/batching_mode/task/test.rs @@ -24,8 +24,8 @@ use common_error::ext::BoxedError; use common_error::mock::MockError; use common_error::status_code::StatusCode; use common_query::Output; +use common_recordbatch::RecordBatch; use common_recordbatch::adapter::{RecordBatchMetrics, RegionWatermarkEntry}; -use common_recordbatch::{RecordBatch, RecordBatchStreamWrapper}; use datatypes::data_type::ConcreteDataType as CDT; use datatypes::schema::ColumnSchema; use datatypes::vectors::{ @@ -601,47 +601,6 @@ impl crate::batching_mode::frontend_client::GrpcQueryHandlerWithBoxedError } } -/// Injected frontend handler that serves the exact delta with an empty -/// (zero-row) stream whose terminal metrics still prove a complete region -/// watermark. It fails the test when the dispatched request is not the -/// expected `sequence_range` read after the stored checkpoint. -struct ExactEmptyDeltaHandler { - invoked: Arc, -} - -#[async_trait::async_trait] -impl crate::batching_mode::frontend_client::GrpcQueryHandlerWithBoxedError - for ExactEmptyDeltaHandler -{ - async fn do_query( - &self, - _query: api::v1::greptime_request::Request, - ctx: QueryContextRef, - ) -> std::result::Result { - self.invoked.store(true, Ordering::SeqCst); - assert_eq!(ctx.extension(FLOW_INCREMENTAL_MODE), Some("sequence_range")); - assert_eq!( - ctx.extension(FLOW_INCREMENTAL_AFTER_SEQS), - Some("{\"1\":10}") - ); - // Reuse the existing record-batch wrapper stream: an empty futures - // stream produces no rows, while the public metrics field carries the - // complete terminal watermark of the finished exact scan. - let wrapper = RecordBatchStreamWrapper::new( - aggregate_time_window_sink_schema(), - futures::stream::empty::>(), - ); - wrapper.metrics.store(Some(Arc::new(RecordBatchMetrics { - region_watermarks: vec![RegionWatermarkEntry { - region_id: 1, - watermark: Some(12), - }], - ..Default::default() - }))); - Ok(Output::new_with_stream(Box::pin(wrapper))) - } -} - struct CaptureScheduledNowHandler { expected_extension: String, captured_sql: Arc>>, @@ -1139,35 +1098,6 @@ fn test_apply_query_result_to_state_advances_incremental_subset() { ); } -#[test] -fn test_apply_query_result_to_state_empty_delta_complete_watermark_keeps_incremental() { - // An empty (zero-row) delta result is not a missing proof: the terminal - // watermark of the completed exact scan still advances the checkpoint and - // keeps incremental mode. - let query_ctx = QueryContext::arc(); - let (_tx, rx) = tokio::sync::oneshot::channel(); - let mut state = TaskState::new(query_ctx, rx); - state.advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); - let result = output_with_region_watermarks([(1_u64, Some(12_u64))]); - - let decision = BatchingTask::apply_query_result_to_state( - &mut state, - &result, - std::time::Duration::from_millis(1), - &QueryCoverage::IncrementalDelta, - ); - - assert_eq!( - decision, - FlowCheckpointDecision::AdvancedIncremental { - participating_regions: 1, - watermarks: 1, - } - ); - assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); - assert_eq!(state.checkpoints(), &BTreeMap::from([(1_u64, 12_u64)])); -} - #[test] fn test_scoped_base_repair_with_dirty_backlog_starts_fenced_repair_from_full_snapshot() { let query_ctx = QueryContext::arc(); @@ -2260,6 +2190,7 @@ async fn test_exact_required_executed_failure_selects_full_snapshot_repair() { QueryOptions::default(), )); task.execute_logical_plan_unlocked( + &query_engine, &frontend_client, &plan_info.plan, &plan_info.dirty_restore, @@ -2294,100 +2225,6 @@ async fn test_exact_required_executed_failure_selects_full_snapshot_repair() { assert!(!repair.plan.to_string().contains("Left Join")); } -#[tokio::test] -async fn test_exact_required_empty_delta_complete_watermark_advances_checkpoint() { - let TestTaskParts { - mut task, - query_engine, - .. - } = new_time_window_test_task_with_query( - "SELECT max(number) AS number, date_bin(INTERVAL '5 second', ts) AS time_window FROM numbers_with_ts GROUP BY time_window", - ) - .await; - configure_source_capability(&query_engine, "mito", true).await; - register_twe_sink(&query_engine, "missing_sink", 9201); - Arc::get_mut(&mut task.config) - .expect("test task config should be uniquely owned") - .exact_sequence_range_required = true; - { - let mut state = task.state.write().unwrap(); - state.advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); - state - .dirty_time_windows - .add_window(Timestamp::new_second(0), Some(Timestamp::new_second(5))); - } - - let plan_info = task - .gen_insert_plan_unlocked(&query_engine, Some(1)) - .await - .unwrap() - .expect("dirty incremental flow must generate an exact delta plan"); - assert!(matches!( - plan_info.coverage, - QueryCoverage::IncrementalDelta - )); - - let handler_invoked = Arc::new(AtomicBool::new(false)); - let handler: Arc = - Arc::new(ExactEmptyDeltaHandler { - invoked: handler_invoked.clone(), - }); - let frontend_client = Arc::new(FrontendClient::from_grpc_handler( - Arc::downgrade(&handler), - QueryOptions::default(), - )); - let (affected_rows, _) = task - .execute_logical_plan_unlocked( - &frontend_client, - &plan_info.plan, - &plan_info.dirty_restore, - &plan_info.coverage, - ) - .await - .expect("the dispatched exact delta must execute through the injected frontend handler") - .expect("executed exact delta must report its affected rows"); - assert!( - handler_invoked.load(Ordering::SeqCst), - "the injected frontend handler must receive the exact delta" - ); - assert_eq!( - affected_rows, 0, - "an empty exact delta must report zero rows" - ); - - { - let state = task.state.read().unwrap(); - assert_eq!(state.checkpoint_mode(), CheckpointMode::Incremental); - assert_eq!(state.checkpoints(), &BTreeMap::from([(1_u64, 12_u64)])); - assert!(state.dirty_time_windows.is_empty()); - } - - // Without a new dirty signal the incremental flow skips the next round - // instead of re-reading the delta or falling back to a full snapshot. - let skipped = task - .gen_insert_plan_unlocked(&query_engine, Some(1)) - .await - .unwrap(); - assert!( - skipped.is_none(), - "without a dirty signal the incremental flow must skip the round" - ); - - // New dirty work still advances through the exact delta, not through a - // filtered base repair or an unfiltered full snapshot. - task.state - .write() - .unwrap() - .dirty_time_windows - .add_window(Timestamp::new_second(5), Some(Timestamp::new_second(10))); - let followup = task - .gen_insert_plan_unlocked(&query_engine, Some(1)) - .await - .unwrap() - .expect("new dirty work must still generate an exact delta plan"); - assert!(matches!(followup.coverage, QueryCoverage::IncrementalDelta)); -} - #[tokio::test] async fn test_exact_required_incomplete_proof_selects_base_recomputation() { let TestTaskParts { @@ -2623,6 +2460,7 @@ async fn test_exact_required_unsupported_plan_keeps_exact_retry_state() { for _ in 0..2 { let err = task .execute_logical_plan_unlocked( + &query_engine, &frontend_client, &dml_plan, &DirtyRestore::Unscoped(dirty_range(10, 15)), @@ -2708,7 +2546,10 @@ async fn test_prepare_plan_for_incremental_disables_on_non_aggregate() { CheckpointMode::Incremental ); - let incremental_plan = task.prepare_plan_for_incremental(&dml_plan).await.unwrap(); + let incremental_plan = task + .prepare_plan_for_incremental(&query_engine, &dml_plan) + .await + .unwrap(); assert!(incremental_plan.is_none()); let state = task.state.read().unwrap(); assert!(state.is_incremental_disabled()); @@ -2785,6 +2626,7 @@ async fn test_unsafe_incremental_plan_skip_restores_dirty_without_query() { let result = task .execute_logical_plan_unlocked( + &query_engine, &Arc::new(frontend_client), &dml_plan, &dirty_restore, @@ -2871,7 +2713,7 @@ async fn test_prepare_plan_for_incremental_group_by_without_merge_columns_uses_o .advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); let incremental_plan = task - .prepare_plan_for_incremental(&dml_plan) + .prepare_plan_for_incremental(&query_engine, &dml_plan) .await .unwrap() .expect("plain GROUP BY is incremental-safe without a rewrite"); @@ -2916,7 +2758,10 @@ async fn test_auto_created_sql_aggregate_sink_reaches_incremental_safe() { .write() .unwrap() .advance_checkpoints(HashMap::from([(1_u64, 10_u64)])); - let incremental_plan = task.prepare_plan_for_incremental(&dml_plan).await.unwrap(); + let incremental_plan = task + .prepare_plan_for_incremental(&query_engine, &dml_plan) + .await + .unwrap(); let incremental_safe = incremental_plan.is_some(); assert!(incremental_safe); diff --git a/src/flow/src/batching_mode/utils.rs b/src/flow/src/batching_mode/utils.rs index cb8e7e50634..40fe95152ae 100644 --- a/src/flow/src/batching_mode/utils.rs +++ b/src/flow/src/batching_mode/utils.rs @@ -66,6 +66,9 @@ mod test; /// `max(numbers_with_ts.number)`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncrementalAggregateMergeColumn { + /// Delta-plan field containing the aggregate result/state column. Repeated + /// projections intentionally share this field while using distinct outputs. + pub input_field_name: String, /// Final output/sink field name for the aggregate result/state column. /// pub output_field_name: String, @@ -73,16 +76,17 @@ pub struct IncrementalAggregateMergeColumn { } impl IncrementalAggregateMergeColumn { - /// Create a new merge column. + /// Create a new merge column whose delta and output fields have the same name. pub fn new(output_field_name: String, merge_op: IncrementalAggregateMergeOp) -> Self { Self { + input_field_name: output_field_name.clone(), output_field_name, merge_op, } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum IncrementalAggregateMergeOp { Sum, Min, @@ -92,6 +96,10 @@ pub enum IncrementalAggregateMergeOp { BitAnd, BitOr, BitXor, + StateDeltaMerge { + function_name: &'static str, + params: Vec, + }, } /// Analysis result for an incremental aggregate plan. @@ -113,24 +121,6 @@ pub struct IncrementalAggregateAnalysis { pub unsupported_exprs: Vec, } -/// Recursively find all `Expr::Column` names inside an expression tree. -/// Only recurses into wrappers that are merge-transparent. -/// Non-transparent wrappers (e.g., `ScalarFunction`, `Negative`, `Cast`) are -/// intentionally not recursed into since their merge semantics would be -/// incorrect. -/// -/// `Cast`/`TryCast` are intentionally opaque: merging already-casted aggregate -/// outputs is not generally equivalent to casting the final merged aggregate. -fn find_column_names(expr: &Expr, names: &mut Vec) { - match expr { - Expr::Column(col) => { - names.push(col.name.clone()); - } - Expr::Alias(alias) => find_column_names(&alias.expr, names), - _ => {} - } -} - fn unqualified_col(name: impl Into) -> Expr { Expr::Column(Column::from_name(name.into())) } @@ -230,8 +220,8 @@ fn check_input_plan_shape(plan: &LogicalPlan) -> Result<(), String> { #[derive(Debug, Default)] struct OutputProjectionInfo { has_top_level_projection: bool, - output_aliases: HashMap, - duplicate_aggregate_aliases: BTreeSet, + /// Aggregate expression name and projected output field, in projection order. + aggregate_outputs: Vec<(String, String)>, literal_columns: HashSet, output_field_names: Vec, } @@ -265,46 +255,34 @@ fn collect_output_projection_info(plan: &LogicalPlan) -> OutputProjectionInfo { ..Default::default() }; - let mut output_aliases = HashMap::new(); if let LogicalPlan::Projection(projection) = plan { for expr in &projection.expr { match expr { Expr::Alias(alias) => { - // Alias resolution has three cases: - // - 0 Column refs (e.g., literal `42 AS lit`): record literal output - // - 1 Column ref: record the mapping (e.g., `sum(x) AS total`) - // - >1 Column refs (e.g., `COALESCE(sum(x), sum(y))`): - // skip — ambiguous merge semantics + // Only a direct aggregate output column has the same + // merge semantics as the original resolver. In particular, + // do not mine aggregate columns through CAST/TryCast or + // other output wrappers. let alias_name = alias.name.clone(); - let mut col_names = Vec::new(); - find_column_names(&alias.expr, &mut col_names); - match col_names.len() { - 0 if is_passthrough_output_column(&alias_name, alias.expr.as_ref()) => { - projection_info.literal_columns.insert(alias_name); - } - 1 => { - if let Some(col_name) = col_names.into_iter().next() { - if let Some(existing_alias) = output_aliases.get(&col_name) { - if existing_alias != &alias_name { - projection_info.duplicate_aggregate_aliases.insert(format!( - "same aggregate output {col_name} is used by multiple aliases: {existing_alias}, {alias_name}" - )); - } - } else { - output_aliases.insert(col_name, alias_name); - } - } - } - _ => {} + if let Expr::Column(column) = alias.expr.as_ref() { + projection_info + .aggregate_outputs + .push((column.name.clone(), alias_name)); + } else if let Expr::Alias(inner_alias) = alias.expr.as_ref() + && inner_alias.name.eq_ignore_ascii_case("count(*)") + && let Expr::Column(column) = inner_alias.expr.as_ref() + { + projection_info + .aggregate_outputs + .push((column.name.clone(), alias_name)); + } else if is_passthrough_output_column(&alias_name, alias.expr.as_ref()) { + projection_info.literal_columns.insert(alias_name); } - - // If >1 column references detected (e.g., COALESCE(sum(x), sum(y))), - // intentionally skip alias mapping — the merge semantics are ambiguous. } Expr::Column(col) => { - output_aliases - .entry(col.name.clone()) - .or_insert(col.name.clone()); + projection_info + .aggregate_outputs + .push((col.name.clone(), col.name.clone())); } Expr::Literal(_, _) => { projection_info @@ -326,7 +304,6 @@ fn collect_output_projection_info(plan: &LogicalPlan) -> OutputProjectionInfo { .insert(AUTO_CREATED_PLACEHOLDER_TS_COL.to_string()); } - projection_info.output_aliases = output_aliases; projection_info } @@ -362,6 +339,12 @@ fn merge_op_for_aggregate_expr(aggr_expr: &Expr) -> Result Ok(IncrementalAggregateMergeOp::Sum), "min" => Ok(IncrementalAggregateMergeOp::Min), @@ -371,28 +354,52 @@ fn merge_op_for_aggregate_expr(aggr_expr: &Expr) -> Result Ok(IncrementalAggregateMergeOp::BitAnd), "bit_or" => Ok(IncrementalAggregateMergeOp::BitOr), "bit_xor" => Ok(IncrementalAggregateMergeOp::BitXor), + // Preserve state-family parameters; value coercion is handled by the aggregate. + "hll" if aggr_func.params.args.len() == 1 => state_delta_merge("__hll_delta_merge", vec![]), + "stddev_pop_state" if aggr_func.params.args.len() == 1 => { + state_delta_merge("__stddev_pop_state_delta_merge", vec![]) + } + "uddsketch_state" if aggr_func.params.args.len() == 3 => { + let [bucket_size, error_rate, _] = aggr_func.params.args.as_slice() else { + unreachable!(); + }; + if !matches!(bucket_size, Expr::Literal(ScalarValue::Int64(Some(_)), _)) + || !matches!(error_rate, Expr::Literal(ScalarValue::Float64(Some(_)), _)) + { + return Err(aggr_expr.to_string()); + } + state_delta_merge( + "__uddsketch_state_delta_merge", + vec![bucket_size.clone(), error_rate.clone()], + ) + } _ => Err(aggr_expr.to_string()), } } -fn resolve_aggregate_output_field_name( +fn resolve_aggregate_output_fields( aggr_expr: &Expr, projection_info: &OutputProjectionInfo, output_field_name_set: &HashSet, -) -> Option { +) -> Vec<(String, String)> { // qualified_name() returns (Option, String) where the second // element is the unqualified column/alias name. This relies on // DataFusion's internal naming convention: aggregate expressions - // emit a column named after the aggregate itself (e.g. "SUM(x)"), - // which matches what the projection aliases reference. + // emit a column named after the aggregate itself (e.g. "SUM(x)"). + // Keep every matching projection occurrence because DataFusion can share + // one aggregate input field for identical expressions. let raw_name = aggr_expr.qualified_name().1; - if let Some(alias) = projection_info.output_aliases.get(&raw_name) { - Some(alias.clone()) - } else if !projection_info.has_top_level_projection && output_field_name_set.contains(&raw_name) - { - Some(raw_name) + if projection_info.has_top_level_projection { + projection_info + .aggregate_outputs + .iter() + .filter(|(input_name, _)| input_name == &raw_name) + .cloned() + .collect() + } else if output_field_name_set.contains(&raw_name) { + vec![(raw_name.clone(), raw_name)] } else { - None + vec![] } } @@ -478,7 +485,6 @@ pub fn analyze_incremental_aggregate_plan( .map(|name| format!("duplicate output field name: {name}")) .collect::>(); unsupported_exprs.push(reason); - unsupported_exprs.extend(projection_info.duplicate_aggregate_aliases.iter().cloned()); return Ok(Some(IncrementalAggregateAnalysis { group_key_names, merge_columns: vec![], @@ -512,7 +518,6 @@ pub fn analyze_incremental_aggregate_plan( aggregate, &group_key_names, )); - unsupported_exprs.extend(projection_info.duplicate_aggregate_aliases.iter().cloned()); for aggr_expr in aggr_exprs { let merge_op = match merge_op_for_aggregate_expr(&aggr_expr) { Ok(merge_op) => merge_op, @@ -521,18 +526,37 @@ pub fn analyze_incremental_aggregate_plan( continue; } }; - let Some(output_field_name) = resolve_aggregate_output_field_name( - &aggr_expr, - &projection_info, - &output_field_name_set, - ) else { + let aggregate_outputs = + resolve_aggregate_output_fields(&aggr_expr, &projection_info, &output_field_name_set); + if aggregate_outputs.is_empty() { unsupported_exprs.push(aggr_expr.to_string()); continue; + } + let Some((_, input_field_name)) = aggregate_outputs.first() else { + continue; }; - merge_columns.push(IncrementalAggregateMergeColumn::new( - output_field_name, - merge_op, - )); + let input_field_name = input_field_name.clone(); + for (_, output_field_name) in aggregate_outputs { + merge_columns.push(IncrementalAggregateMergeColumn { + input_field_name: input_field_name.clone(), + output_field_name, + merge_op: merge_op.clone(), + }); + } + } + if projection_info.has_top_level_projection { + let output_positions = projection_info + .output_field_names + .iter() + .enumerate() + .map(|(position, name)| (name.as_str(), position)) + .collect::>(); + merge_columns.sort_by_key(|column| { + output_positions + .get(column.output_field_name.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); } unsupported_exprs.extend( find_uncovered_output_fields(&projection_info, &group_key_names, &merge_columns) @@ -591,6 +615,7 @@ pub fn analyze_incremental_aggregate_plan( pub async fn rewrite_incremental_aggregate_with_sink_merge( delta_plan: &LogicalPlan, analysis: &IncrementalAggregateAnalysis, + engine: &QueryEngineRef, sink_table: TableRef, sink_table_name: &TableName, sink_dirty_filter: Option, @@ -625,6 +650,12 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge( let delta_alias = "__flow_delta"; let sink_alias = "__flow_sink"; + let state_merge = analysis.merge_columns.iter().any(|column| { + matches!( + &column.merge_op, + IncrementalAggregateMergeOp::StateDeltaMerge { .. } + ) + }); let mut selected_columns = analysis.group_key_names.clone(); selected_columns.extend( analysis @@ -632,8 +663,18 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge( .iter() .map(|c| c.output_field_name.clone()), ); - let mut delta_selected_columns = selected_columns.clone(); + let mut selected_column_names = HashSet::new(); + selected_columns.retain(|name| selected_column_names.insert(name.clone())); + let mut delta_selected_columns = analysis.group_key_names.clone(); + delta_selected_columns.extend( + analysis + .merge_columns + .iter() + .map(|c| c.input_field_name.clone()), + ); delta_selected_columns.extend(analysis.literal_columns.iter().cloned()); + let mut delta_selected_column_names = HashSet::new(); + delta_selected_columns.retain(|name| delta_selected_column_names.insert(name.clone())); let delta_selected_exprs = delta_selected_columns .iter() @@ -745,20 +786,25 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge( .map(|c| (&c.output_field_name, c)) .collect::>(); - let mut projection_exprs = Vec::with_capacity(analysis.output_field_names.len()); + let mut ordinary_exprs = Vec::with_capacity(analysis.output_field_names.len()); + let mut state_aggr_exprs = Vec::new(); for output_field_name in &analysis.output_field_names { if group_key_names.contains(output_field_name) || literal_columns.contains(output_field_name) { - projection_exprs.push( - qualified_col(delta_alias, output_field_name.clone()).alias(output_field_name), - ); + let expr = + qualified_col(delta_alias, output_field_name.clone()).alias(output_field_name); + ordinary_exprs.push(expr); } else if let Some(merge_col) = merge_columns.get(output_field_name) { - projection_exprs.push(build_left_join_merge_expr( - delta_alias, - sink_alias, - merge_col, - )?); + if matches!( + &merge_col.merge_op, + IncrementalAggregateMergeOp::StateDeltaMerge { .. } + ) { + state_aggr_exprs.push(build_state_delta_merge_expr(engine, merge_col)?); + } else { + let expr = build_left_join_merge_expr(delta_alias, sink_alias, merge_col)?; + ordinary_exprs.push(expr); + } } else { return InvalidQuerySnafu { reason: format!( @@ -769,15 +815,88 @@ pub async fn rewrite_incremental_aggregate_with_sink_merge( } } - LogicalPlanBuilder::from(joined) - .project(projection_exprs) - .with_context(|_| DatafusionSnafu { - context: "Failed to build projection merge plan for incremental sink merge".to_string(), - })? - .build() - .with_context(|_| DatafusionSnafu { - context: "Failed to finalize incremental aggregate sink merge plan".to_string(), + if state_merge { + let aggregated = LogicalPlanBuilder::from(joined) + .aggregate(ordinary_exprs, state_aggr_exprs) + .with_context(|_| DatafusionSnafu { + context: "Failed to aggregate state delta merge plan".to_string(), + })? + .build() + .with_context(|_| DatafusionSnafu { + context: "Failed to build state delta merge plan".to_string(), + })?; + let output_exprs = analysis + .output_field_names + .iter() + .cloned() + .map(unqualified_col) + .collect::>(); + LogicalPlanBuilder::from(aggregated) + .project(output_exprs) + .with_context(|_| DatafusionSnafu { + context: "Failed to project state delta merge plan".to_string(), + })? + .build() + .with_context(|_| DatafusionSnafu { + context: "Failed to finalize incremental aggregate sink merge plan".to_string(), + }) + } else { + LogicalPlanBuilder::from(joined) + .project(ordinary_exprs) + .with_context(|_| DatafusionSnafu { + context: "Failed to build projection merge plan for incremental sink merge" + .to_string(), + })? + .build() + .with_context(|_| DatafusionSnafu { + context: "Failed to finalize incremental aggregate sink merge plan".to_string(), + }) + } +} + +fn build_state_delta_merge_expr( + engine: &QueryEngineRef, + merge_col: &IncrementalAggregateMergeColumn, +) -> Result { + let (function_name, params) = match &merge_col.merge_op { + IncrementalAggregateMergeOp::StateDeltaMerge { + function_name, + params, + } => (*function_name, params.clone()), + _ => { + return InvalidQuerySnafu { + reason: "non-state aggregate passed to state delta merge".to_string(), + } + .fail(); + } + }; + let Some(udaf) = engine + .engine_state() + .aggr_function(function_name) + .or_else(|| { + engine + .engine_state() + .session_state() + .aggregate_functions() + .get(function_name) + .map(|udaf| udaf.as_ref().clone()) }) + else { + return InvalidQuerySnafu { + reason: format!("Aggregate function {function_name} is not registered"), + } + .fail(); + }; + let mut args = params; + args.push(qualified_col( + "__flow_delta", + merge_col.input_field_name.clone(), + )); + args.push(qualified_col( + "__flow_sink", + merge_col.output_field_name.clone(), + )); + Ok(udaf.call(args).alias(merge_col.output_field_name.clone())) } fn build_left_join_merge_expr( @@ -785,9 +904,9 @@ fn build_left_join_merge_expr( sink_alias: &str, merge_col: &IncrementalAggregateMergeColumn, ) -> Result { - let left = qualified_col(delta_alias, merge_col.output_field_name.clone()); + let left = qualified_col(delta_alias, merge_col.input_field_name.clone()); let right = qualified_col(sink_alias, merge_col.output_field_name.clone()); - let merged = match merge_col.merge_op { + let merged = match merge_col.merge_op.clone() { IncrementalAggregateMergeOp::Sum => when(is_null(left.clone()), right.clone()) .when(is_null(right.clone()), left.clone()) .otherwise(binary_expr(left.clone(), Operator::Plus, right.clone())) @@ -836,6 +955,12 @@ fn build_left_join_merge_expr( .with_context(|_| DatafusionSnafu { context: "Failed to build BIT_XOR merge expression".to_string(), })?, + IncrementalAggregateMergeOp::StateDeltaMerge { .. } => { + return InvalidQuerySnafu { + reason: "state aggregate must be built with its delta UDAF".to_string(), + } + .fail(); + } }; Ok(merged.alias(merge_col.output_field_name.clone())) } diff --git a/src/flow/src/batching_mode/utils/test.rs b/src/flow/src/batching_mode/utils/test.rs index 1c34caeedd7..08dfa4dd11e 100644 --- a/src/flow/src/batching_mode/utils/test.rs +++ b/src/flow/src/batching_mode/utils/test.rs @@ -15,10 +15,14 @@ use std::sync::Arc; use catalog::RegisterTableRequest; -use common_recordbatch::RecordBatch; +use common_query::OutputData; +use common_recordbatch::recordbatch::merge_record_batches; +use common_recordbatch::{RecordBatch, util}; use common_time::Timestamp; use datafusion_common::tree_node::TreeNode as _; use datafusion_expr::{GroupingSet, TableScanBuilder}; +use datatypes::arrow::array::{Array, AsArray}; +use datatypes::arrow::datatypes::{Float64Type, Int64Type, UInt64Type}; use datatypes::prelude::{ConcreteDataType, MutableVector, Scalar, ScalarVectorBuilder, VectorRef}; use datatypes::schema::{ColumnSchema, Schema}; use datatypes::timestamp::TimestampMillisecond; @@ -1104,13 +1108,16 @@ async fn test_rewrite_incremental_aggregate_allows_alias_wrapped_scan() { let query_engine = create_test_query_engine(); let ctx = QueryContext::arc(); let sql = "SELECT max(n.number) AS number, n.ts FROM numbers_with_ts AS n GROUP BY n.ts"; - let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap(); + let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false) + .await + .unwrap(); let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap(); assert!(analysis.unsupported_exprs.is_empty()); let rewritten = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, single_row_u32_table("alias_wrapped_sink", vec!["ts", "number"]), &[ "greptime".to_string(), @@ -1362,6 +1369,7 @@ async fn test_analyze_incremental_aggregate_plan_allows_literal_outputs() { let rewritten = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, sink_table.clone(), &sink_table_name, None, @@ -1435,7 +1443,9 @@ async fn test_rewrite_incremental_aggregate_preserves_non_identifier_aliases() { let query_engine = create_test_query_engine(); let ctx = QueryContext::arc(); let sql = "SELECT max(number) AS \"max value\", number, 42 AS \"literal value\" FROM numbers_with_ts GROUP BY number"; - let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap(); + let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false) + .await + .unwrap(); let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap(); assert!(analysis.unsupported_exprs.is_empty()); assert_eq!( @@ -1447,6 +1457,7 @@ async fn test_rewrite_incremental_aggregate_preserves_non_identifier_aliases() { let rewritten = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, sink_table, &[ "greptime".to_string(), @@ -1520,24 +1531,34 @@ async fn test_datafusion_rejects_duplicate_output_names() { } #[tokio::test] -async fn test_analyze_incremental_aggregate_plan_rejects_same_aggregate_multiple_aliases() { - let query_engine = create_test_query_engine(); - let ctx = QueryContext::arc(); - let sql = "SELECT sum(number) AS a, sum(number) AS b, ts FROM numbers_with_ts GROUP BY ts"; - let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap(); +async fn test_analyze_incremental_aggregate_plan_supports_same_aggregate_multiple_aliases() { + let analysis = analyze_test_sql( + "SELECT sum(number) AS a, sum(number) AS b, ts FROM numbers_with_ts GROUP BY ts", + ) + .await; - let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap(); + assert!(analysis.unsupported_exprs.is_empty()); + assert_eq!(analysis.merge_columns.len(), 2); + assert_eq!(analysis.merge_columns[0].input_field_name, "a"); + assert_eq!(analysis.merge_columns[1].input_field_name, "a"); assert!( analysis - .unsupported_exprs + .merge_columns .iter() - .any(|expr| expr.contains("same aggregate output") - && expr.contains("a") - && expr.contains("b")), - "same aggregate with multiple aliases should be unsupported until explicit reproduction is implemented: {:?}", - analysis.unsupported_exprs + .all(|column| { column.merge_op == IncrementalAggregateMergeOp::Sum }) + ); + assert!( + analysis + .merge_columns + .iter() + .any(|column| column.output_field_name == "a") + ); + assert!( + analysis + .merge_columns + .iter() + .any(|column| column.output_field_name == "b") ); - assert!(analysis.merge_columns.is_empty()); } #[test] @@ -1581,6 +1602,322 @@ async fn test_analyze_incremental_aggregate_plan_rejects_avg() { assert!(!analysis.unsupported_exprs.is_empty()); } +#[tokio::test] +async fn test_rewrite_incremental_aggregate_merges_populated_mixed_state_families() { + let query_engine = create_test_query_engine(); + let old_sql = "SELECT hll(CAST(number AS VARCHAR)) AS hll_a, hll(CAST(number AS VARCHAR)) AS hll_b, uddsketch_state(128, 0.000001, number) AS percentile_a, uddsketch_state(256, 0.02, number) AS percentile_b, stddev_pop_state(number) AS stddev_state, sum(number) AS total, CASE WHEN number <= 5 THEN CAST(NULL AS BIGINT) ELSE 2 END AS grp FROM numbers_with_ts WHERE number <= 3 OR number = 6 GROUP BY grp"; + let new_sql = "SELECT hll(CAST(number AS VARCHAR)) AS hll_a, hll(CAST(number AS VARCHAR)) AS hll_b, uddsketch_state(128, 0.000001, number) AS percentile_a, uddsketch_state(256, 0.02, number) AS percentile_b, stddev_pop_state(number) AS stddev_state, sum(number) AS total, CASE WHEN number <= 5 THEN CAST(NULL AS BIGINT) WHEN number <= 8 THEN 2 ELSE 3 END AS grp FROM numbers_with_ts WHERE number >= 4 AND number != 6 GROUP BY grp"; + let old_plan = sql_to_df_plan(QueryContext::arc(), query_engine.clone(), old_sql, false) + .await + .unwrap(); + let old_output = query_engine + .execute(old_plan, QueryContext::arc()) + .await + .unwrap(); + let OutputData::Stream(old_stream) = old_output.data else { + panic!("expected old aggregate execution to be a stream"); + }; + let old_batches = util::collect(old_stream).await.unwrap(); + let old_schema = old_batches.first().unwrap().schema.clone(); + let old_batch = merge_record_batches(old_schema, &old_batches).unwrap(); + assert_eq!( + old_batch.num_rows(), + 2, + "old state must have one row per group" + ); + let empty_state_sink_batch = old_batch.clone(); + let sink_table = MemTable::table("state_merge_sink", old_batch); + let sink_table_name = [ + "greptime".to_string(), + "public".to_string(), + "state_merge_sink".to_string(), + ]; + + let new_plan = sql_to_df_plan(QueryContext::arc(), query_engine.clone(), new_sql, false) + .await + .unwrap(); + let analysis = analyze_incremental_aggregate_plan(&new_plan) + .unwrap() + .unwrap(); + assert!(analysis.unsupported_exprs.is_empty()); + let rewritten = rewrite_incremental_aggregate_with_sink_merge( + &new_plan, + &analysis, + &query_engine, + sink_table, + &sink_table_name, + None, + ) + .await + .unwrap(); + assert_eq!( + analysis.output_field_names, + vec![ + "hll_a", + "hll_b", + "percentile_a", + "percentile_b", + "stddev_state", + "total", + "grp", + ], + "repeated HLL aliases must preserve output order" + ); + + let output = query_engine + .execute(rewritten, QueryContext::arc()) + .await + .unwrap(); + let OutputData::Stream(stream) = output.data else { + panic!("expected rewritten plan to execute as a stream"); + }; + let batches = util::collect(stream).await.unwrap(); + let merged_schema = batches.first().unwrap().schema.clone(); + let merged_batch = merge_record_batches(merged_schema, &batches).unwrap(); + assert_eq!( + merged_batch.num_rows(), + 3, + "rewrite must produce one row per group" + ); + let merged_table = MemTable::table("merged_states", merged_batch); + query_engine + .engine_state() + .catalog_manager() + .as_any() + .downcast_ref::() + .unwrap() + .register_table_sync(RegisterTableRequest { + catalog: "greptime".to_string(), + schema: "public".to_string(), + table_name: "merged_states".to_string(), + table_id: 4097, + table: merged_table, + }) + .unwrap(); + let checks = "SELECT grp, sum(total) AS total, hll_count(hll_merge(hll_a)) AS hll_a, hll_count(hll_merge(hll_b)) AS hll_b, stddev_pop_calc(stddev_pop_merge(stddev_state)) AS stddev, uddsketch_calc(0.5, uddsketch_merge(128, 0.000001, percentile_a)) AS p50_a, uddsketch_calc(0.5, uddsketch_merge(256, 0.02, percentile_b)) AS p50_b FROM merged_states GROUP BY grp ORDER BY grp NULLS FIRST"; + let checks_plan = sql_to_df_plan(QueryContext::arc(), query_engine.clone(), checks, false) + .await + .unwrap(); + let checks_output = query_engine + .execute(checks_plan, QueryContext::arc()) + .await + .unwrap(); + let OutputData::Stream(checks_stream) = checks_output.data else { + panic!("expected state check execution to be a stream"); + }; + let checks_batches = util::collect(checks_stream).await.unwrap(); + let checks_schema = checks_batches.first().unwrap().schema.clone(); + let checks = merge_record_batches(checks_schema, &checks_batches).unwrap(); + assert_eq!(checks.num_rows(), 3); + assert_eq!( + checks + .schema + .column_schemas() + .iter() + .map(|column| column.name.as_str()) + .collect::>(), + vec!["grp", "total", "hll_a", "hll_b", "stddev", "p50_a", "p50_b"] + ); + let group = checks.column(0).as_primitive::(); + let total = checks.column(1).as_primitive::(); + let hll_a = checks.column(2).as_primitive::(); + let hll_b = checks.column(3).as_primitive::(); + let stddev = checks.column(4).as_primitive::(); + let p50_a = checks.column(5).as_primitive::(); + let p50_b = checks.column(6).as_primitive::(); + assert_eq!(group.null_count(), 1); + assert_eq!(total.null_count(), 0); + assert_eq!(hll_a.null_count(), 0); + assert_eq!(hll_b.null_count(), 0); + assert_eq!(stddev.null_count(), 0); + assert_eq!(p50_a.null_count(), 0); + assert_eq!(p50_b.null_count(), 0); + for (index, expected) in [ + (None, 15_u64, 5_u64, 2_f64.sqrt(), 3_f64, 3_f64), + ( + Some(2_i64), + 21_u64, + 3_u64, + (2_f64 / 3.0).sqrt(), + 7_f64, + 7_f64, + ), + (Some(3_i64), 19_u64, 2_u64, 0.5_f64, 10_f64, 10_f64), + ] + .into_iter() + .enumerate() + { + assert_eq!( + (!group.is_null(index)).then(|| group.value(index)), + expected.0 + ); + assert_eq!(total.value(index), expected.1); + assert_eq!(hll_a.value(index), expected.2); + assert_eq!(hll_b.value(index), expected.2); + assert!((stddev.value(index) - expected.3).abs() < 1e-12); + // UDDSketch's relative-error bounds are 1e-6 and 2e-2 respectively. + assert!((p50_a.value(index) - expected.4).abs() <= expected.4 * 0.000001); + assert!((p50_b.value(index) - expected.5).abs() <= expected.5 * 0.02); + } + + let empty_state_plan = sql_to_df_plan( + QueryContext::arc(), + query_engine.clone(), + "SELECT hll(CAST(NULL AS VARCHAR)) AS hll_a, hll(CAST(NULL AS VARCHAR)) AS hll_b, uddsketch_state(128, 0.000001, CAST(NULL AS DOUBLE)) AS percentile_a, uddsketch_state(256, 0.02, CAST(NULL AS DOUBLE)) AS percentile_b, stddev_pop_state(CAST(NULL AS DOUBLE)) AS stddev_state, sum(number) AS total, CASE WHEN number <= 5 THEN CAST(NULL AS BIGINT) WHEN number <= 8 THEN 2 ELSE 3 END AS grp FROM numbers_with_ts WHERE number = 10 GROUP BY grp", + false, + ) + .await + .unwrap(); + let empty_state_analysis = analyze_incremental_aggregate_plan(&empty_state_plan) + .unwrap() + .unwrap(); + assert!(empty_state_analysis.unsupported_exprs.is_empty()); + let empty_state_rewritten = rewrite_incremental_aggregate_with_sink_merge( + &empty_state_plan, + &empty_state_analysis, + &query_engine, + MemTable::table("empty_state_sink", empty_state_sink_batch), + &[ + "greptime".to_string(), + "public".to_string(), + "empty_state_sink".to_string(), + ], + None, + ) + .await + .unwrap(); + let empty_state_output = query_engine + .execute(empty_state_rewritten, QueryContext::arc()) + .await + .unwrap(); + let OutputData::Stream(empty_state_stream) = empty_state_output.data else { + panic!("expected empty state merge to execute as a stream"); + }; + let empty_state_batches = util::collect(empty_state_stream).await.unwrap(); + let empty_state_schema = empty_state_batches.first().unwrap().schema.clone(); + let empty_state_batch = merge_record_batches(empty_state_schema, &empty_state_batches).unwrap(); + assert_eq!(empty_state_batch.num_rows(), 1); + let group_index = empty_state_batch + .schema + .column_index_by_name("grp") + .unwrap(); + let total_index = empty_state_batch + .schema + .column_index_by_name("total") + .unwrap(); + let group = empty_state_batch + .column(group_index) + .as_primitive::(); + let total = empty_state_batch + .column(total_index) + .as_primitive::(); + assert!(!group.is_null(0)); + assert_eq!(group.value(0), 3); + assert!(!total.is_null(0)); + assert_eq!(total.value(0), 10); + let empty_state_table = MemTable::table("empty_states", empty_state_batch); + query_engine + .engine_state() + .catalog_manager() + .as_any() + .downcast_ref::() + .unwrap() + .register_table_sync(RegisterTableRequest { + catalog: "greptime".to_string(), + schema: "public".to_string(), + table_name: "empty_states".to_string(), + table_id: 4098, + table: empty_state_table, + }) + .unwrap(); + let empty_checks_plan = sql_to_df_plan( + QueryContext::arc(), + query_engine.clone(), + "SELECT hll_count(hll_merge(hll_a)) AS hll, stddev_pop_calc(stddev_pop_merge(stddev_state)) AS stddev, uddsketch_calc(0.5, uddsketch_merge(128, 0.000001, percentile_a)) AS p50_a, uddsketch_calc(0.5, uddsketch_merge(256, 0.02, percentile_b)) AS p50_b FROM empty_states", + false, + ) + .await + .unwrap(); + let empty_checks_output = query_engine + .execute(empty_checks_plan, QueryContext::arc()) + .await + .unwrap(); + let OutputData::Stream(empty_checks_stream) = empty_checks_output.data else { + panic!("expected empty state check to execute as a stream"); + }; + let empty_checks_batches = util::collect(empty_checks_stream).await.unwrap(); + let empty_checks_schema = empty_checks_batches.first().unwrap().schema.clone(); + let empty_checks = merge_record_batches(empty_checks_schema, &empty_checks_batches).unwrap(); + assert_eq!(empty_checks.num_rows(), 1); + assert_eq!( + empty_checks.column(0).as_primitive::().value(0), + 0 + ); + assert!( + empty_checks + .column(1) + .as_primitive::() + .is_null(0) + ); + assert!( + empty_checks + .column(2) + .as_primitive::() + .is_null(0) + ); + assert!( + empty_checks + .column(3) + .as_primitive::() + .is_null(0) + ); +} + +#[tokio::test] +async fn test_analyze_incremental_aggregate_plan_state_producer_metadata_and_rejections() { + let analysis = analyze_test_sql( + "SELECT hll(CAST(number AS VARCHAR)) AS hll_state, \ + stddev_pop_state(number) AS stddev_state, \ + uddsketch_state(128, 0.000001, number) AS percentile_state, ts \ + FROM numbers_with_ts GROUP BY ts", + ) + .await; + assert!(analysis.unsupported_exprs.is_empty()); + let percentile = analysis + .merge_columns + .iter() + .find(|column| column.output_field_name == "percentile_state") + .unwrap(); + let IncrementalAggregateMergeOp::StateDeltaMerge { + function_name, + params, + } = &percentile.merge_op + else { + panic!("expected UDDSketch state delta merge"); + }; + assert_eq!(*function_name, "__uddsketch_state_delta_merge"); + assert_eq!(params.len(), 2); + assert!(matches!( + params[0], + Expr::Literal(ScalarValue::Int64(Some(128)), _) + )); + assert!(matches!( + params[1], + Expr::Literal(ScalarValue::Float64(Some(rate)), _) if rate == 0.000001 + )); + + for sql in [ + "SELECT uddsketch_state(CAST(number AS BIGINT), 0.01, number) AS state, ts FROM numbers_with_ts GROUP BY ts", + "SELECT uddsketch_state(128, NULL, number) AS state, ts FROM numbers_with_ts GROUP BY ts", + "SELECT uddsketch_state(NULL, 0.01, number) AS state, ts FROM numbers_with_ts GROUP BY ts", + "SELECT avg(number) AS state, ts FROM numbers_with_ts GROUP BY ts", + "SELECT stddev_pop(number) AS state, ts FROM numbers_with_ts GROUP BY ts", + ] { + let analysis = analyze_test_sql(sql).await; + assert!(!analysis.unsupported_exprs.is_empty(), "must reject {sql}"); + } +} + #[tokio::test] async fn test_analyze_incremental_aggregate_plan_rejects_distinct() { let query_engine = create_test_query_engine(); @@ -1638,6 +1975,7 @@ async fn test_rewrite_incremental_aggregate_with_left_join() { let rewritten = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, sink_table.clone(), &sink_table_name, None, @@ -1699,6 +2037,7 @@ async fn test_rewrite_incremental_aggregate_filters_sink_dirty_time_window() { let rewritten = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, sink_table.clone(), &sink_table_name, Some(sink_filter.clone()), @@ -1749,7 +2088,9 @@ async fn test_rewrite_incremental_aggregate_rejects_empty_group_keys() { let query_engine = create_test_query_engine(); let ctx = QueryContext::arc(); let sql = "SELECT max(number) AS number FROM numbers_with_ts"; - let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap(); + let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false) + .await + .unwrap(); let analysis = IncrementalAggregateAnalysis { group_key_names: vec![], merge_columns: vec![IncrementalAggregateMergeColumn::new( @@ -1770,6 +2111,7 @@ async fn test_rewrite_incremental_aggregate_rejects_empty_group_keys() { let err = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, sink_table, &sink_table_name, None, @@ -1788,7 +2130,9 @@ async fn test_rewrite_incremental_aggregate_preserves_raw_aggregate_field_name() let query_engine = create_test_query_engine(); let ctx = QueryContext::arc(); let sql = "SELECT max(number), number FROM numbers_with_ts GROUP BY number"; - let plan = sql_to_df_plan(ctx, query_engine, sql, false).await.unwrap(); + let plan = sql_to_df_plan(ctx, query_engine.clone(), sql, false) + .await + .unwrap(); let analysis = analyze_incremental_aggregate_plan(&plan).unwrap().unwrap(); assert!(analysis.unsupported_exprs.is_empty()); @@ -1802,6 +2146,7 @@ async fn test_rewrite_incremental_aggregate_preserves_raw_aggregate_field_name() let rewritten = rewrite_incremental_aggregate_with_sink_merge( &plan, &analysis, + &query_engine, sink_table.clone(), &sink_table_name, None, diff --git a/src/frontend/Cargo.toml b/src/frontend/Cargo.toml index 7ad24a466e8..677dbbbe55f 100644 --- a/src/frontend/Cargo.toml +++ b/src/frontend/Cargo.toml @@ -29,6 +29,7 @@ cache.workspace = true catalog.workspace = true client.workspace = true common-base.workspace = true +common-batcher.workspace = true common-catalog.workspace = true common-config.workspace = true common-datasource.workspace = true diff --git a/src/frontend/src/server.rs b/src/frontend/src/server.rs index e112e5ae2a9..1812dc71a51 100644 --- a/src/frontend/src/server.rs +++ b/src/frontend/src/server.rs @@ -567,7 +567,23 @@ mod tests { // returns `None`; in these cases no request can wait for a pending-row // flush, so the timeout must not be raised. type KnobMutator = fn(&mut FrontendOptions); - let cases: [(&str, KnobMutator); 4] = [ + let cases: [(&str, KnobMutator); 9] = [ + ("oversized flush concurrency", |opts| { + opts.prom_store.max_concurrent_flushes = usize::MAX + }), + ("oversized worker channel", |opts| { + opts.prom_store.worker_channel_capacity = usize::MAX + }), + ("oversized inflight limit", |opts| { + opts.prom_store.max_inflight_requests = usize::MAX + }), + ("oversized flow queue", |opts| { + opts.prom_store.flow_notification_queue_capacity = + std::num::NonZeroUsize::new(usize::MAX).unwrap() + }), + ("unrepresentable deadline", |opts| { + opts.prom_store.pending_rows_flush_interval = Duration::MAX + }), ("zero max_batch_rows", |opts| { opts.prom_store.max_batch_rows = 0 }), diff --git a/src/frontend/src/service_config/prom_store.rs b/src/frontend/src/service_config/prom_store.rs index d2e386caae0..f8429a86925 100644 --- a/src/frontend/src/service_config/prom_store.rs +++ b/src/frontend/src/service_config/prom_store.rs @@ -15,8 +15,10 @@ use std::num::NonZeroUsize; use std::time::Duration; +use common_batcher::flush_policy::timing::TimingFlushPolicy; use serde::{Deserialize, Serialize}; use servers::prom_remote_write::validation::PromValidationMode; +use tokio::sync::Semaphore; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct PromStoreOptions { @@ -64,18 +66,20 @@ fn default_flow_notification_queue_capacity() -> NonZeroUsize { } impl PromStoreOptions { - /// Returns whether the pending rows batcher can be enabled with these - /// options. Mirrors the enablement conditions of - /// `PendingRowsBatcher::try_new` in the servers crate, which returns - /// `None` when any of these knobs is zero. + /// Returns whether batching is enabled and its controls pass construction validation. pub fn pending_rows_batching_enabled(&self) -> bool { self.enable && self.with_metric_engine - && !self.pending_rows_flush_interval.is_zero() + && TimingFlushPolicy::validate(self.pending_rows_flush_interval) && self.max_batch_rows > 0 - && self.max_concurrent_flushes > 0 - && self.worker_channel_capacity > 0 - && self.max_inflight_requests > 0 + && [ + self.max_concurrent_flushes, + self.worker_channel_capacity, + self.max_inflight_requests, + self.flow_notification_queue_capacity.get(), + ] + .into_iter() + .all(|capacity| (1..=Semaphore::MAX_PERMITS).contains(&capacity)) } } @@ -107,6 +111,42 @@ mod tests { default_worker_channel_capacity, }; + #[test] + fn test_batching_capacity_boundaries() { + let max = tokio::sync::Semaphore::MAX_PERMITS; + let options = PromStoreOptions { + pending_rows_flush_interval: Duration::from_secs(5), + max_batch_rows: usize::MAX, + max_concurrent_flushes: max, + worker_channel_capacity: max, + max_inflight_requests: max, + flow_notification_queue_capacity: std::num::NonZeroUsize::new(max).unwrap(), + ..Default::default() + }; + assert!(options.pending_rows_batching_enabled()); + let cases = [ + PromStoreOptions { + max_concurrent_flushes: max + 1, + ..options.clone() + }, + PromStoreOptions { + worker_channel_capacity: max + 1, + ..options.clone() + }, + PromStoreOptions { + max_inflight_requests: max + 1, + ..options.clone() + }, + PromStoreOptions { + flow_notification_queue_capacity: std::num::NonZeroUsize::new(max + 1).unwrap(), + ..options + }, + ]; + for options in cases { + assert!(!options.pending_rows_batching_enabled()); + } + } + #[test] fn test_prom_store_options() { let default = PromStoreOptions::default(); diff --git a/src/meta-srv/src/procedure/repartition.rs b/src/meta-srv/src/procedure/repartition.rs index ee8b3ee749b..f42d8aacd0c 100644 --- a/src/meta-srv/src/procedure/repartition.rs +++ b/src/meta-srv/src/procedure/repartition.rs @@ -34,6 +34,7 @@ use common_meta::cache_invalidator::CacheInvalidatorRef; use common_meta::ddl::DdlContext; use common_meta::ddl::allocator::region_routes::RegionRoutesAllocatorRef; use common_meta::ddl::allocator::wal_options::WalOptionsAllocatorRef; +use common_meta::ddl::utils::get_region_wal_options; use common_meta::ddl_manager::{RepartitionProcedureFactory, RepartitionSource}; use common_meta::instruction::CacheIdent; use common_meta::key::datanode_table::RegionInfo; @@ -402,16 +403,27 @@ impl Context { let datanode_table_value = get_datanode_table_value(&self.table_metadata_manager, table_id, datanode_id).await?; - let RegionInfo { - region_options, - region_wal_options, - .. - } = &datanode_table_value.region_info; + let RegionInfo { region_options, .. } = &datanode_table_value.region_info; + + let mut region_wal_options = get_region_wal_options( + &self.table_metadata_manager, + current_table_route_value, + table_id, + ) + .await + .context(error::TableMetadataManagerSnafu)?; + // Legacy regions without a persisted WAL option use RaftEngine. Only + // existing routes get this default; allocated regions must supply one. + for route in current_table_route_value.region_routes().unwrap() { + region_wal_options + .entry(route.region.id.region_number()) + .or_default(); + } // Merge and validate the new region wal options. let validated_region_wal_options = crate::procedure::repartition::utils::merge_and_validate_region_wal_options( - region_wal_options, + ®ion_wal_options, new_region_wal_options, &new_region_routes, table_id, diff --git a/src/meta-srv/src/procedure/repartition/allocate_region.rs b/src/meta-srv/src/procedure/repartition/allocate_region.rs index 857ac946708..3b64d7fd7ce 100644 --- a/src/meta-srv/src/procedure/repartition/allocate_region.rs +++ b/src/meta-srv/src/procedure/repartition/allocate_region.rs @@ -19,6 +19,7 @@ use common_meta::ddl::create_table::executor::CreateTableExecutor; use common_meta::ddl::create_table::template::{ CreateRequestBuilder, build_template_from_raw_table_info_for_physical_table, }; +use common_meta::ddl::utils::get_region_wal_options; use common_meta::lock_key::TableLock; use common_meta::node_manager::NodeManagerRef; use common_meta::peer::PeerAllocContext; @@ -28,6 +29,7 @@ use common_meta::wal_provider::{ }; use common_procedure::{Context as ProcedureContext, Status}; use common_telemetry::{debug, info}; +use common_wal::options::WalOptions; use serde::{Deserialize, Deserializer, Serialize}; use snafu::{OptionExt, ResultExt}; use store_api::region_request::RegionRequirements; @@ -165,6 +167,29 @@ impl ExecutePlan { ) .await .context(error::AllocateRegionRoutesSnafu { table_id })?; + let skip_wal = table_info_value.table_info.meta.options.skip_wal; + let allocate_noop = if skip_wal { + let table_route_value = ctx.get_table_route_value().await?; + let region_wal_options = + get_region_wal_options(&ctx.table_metadata_manager, &table_route_value, table_id) + .await + .context(error::AllocateWalOptionsSnafu { table_id })?; + // A table disabled after creation keeps its real providers. A table + // created with skip_wal has Noop providers and must keep them. + !table_route_value + .region_routes() + .unwrap() + .iter() + .all(|route| { + region_wal_options + .get(&route.region.id.region_number()) + .is_none_or(|option| { + matches!(option, WalOptions::RaftEngine | WalOptions::Kafka(_)) + }) + }) + } else { + false + }; let mut wal_options = ctx .wal_options_allocator .allocate( @@ -172,7 +197,7 @@ impl ExecutePlan { .iter() .map(|r| r.region_id.region_number()) .collect::>(), - table_info_value.table_info.meta.options.skip_wal, + allocate_noop, ) .await .context(error::AllocateWalOptionsSnafu { table_id })?; @@ -414,14 +439,19 @@ mod tests { use std::sync::Arc; use api::v1::region::region_request::Body; + use common_meta::ddl::allocator::wal_options::WalOptionsAllocator; use common_meta::ddl::test_util::datanode_handler::DatanodeWatcher; use common_meta::key::TableMetadataManagerRef; use common_meta::key::datanode_table::DatanodeTableKey; + use common_meta::key::table_route::TableRouteValue; + use common_meta::key::test_utils::new_test_table_info_with_name; use common_meta::peer::Peer; use common_meta::rpc::router::{Region, RegionRoute}; use common_meta::test_util::MockDatanodeManager; use common_procedure::{ContextProvider, ProcedureId, ProcedureState}; use common_procedure_test::MockContextProvider; + use common_wal::options::{KafkaWalOptions, WAL_OPTIONS_KEY, WalOptions}; + use store_api::mito_engine_options::SKIP_WAL_KEY; use store_api::storage::RegionId; use tokio::sync::{mpsc, watch}; use uuid::Uuid; @@ -510,6 +540,29 @@ mod tests { region_wal_options: RegionWalOptions, } + struct TestKafkaWalOptionsAllocator; + + #[async_trait::async_trait] + impl WalOptionsAllocator for TestKafkaWalOptionsAllocator { + async fn allocate( + &self, + region_numbers: &[RegionNumber], + skip_wal: bool, + ) -> common_meta::error::Result { + Ok(region_numbers + .iter() + .map(|®ion_number| { + let options = if skip_wal { + WalOptions::Noop + } else { + WalOptions::Kafka(KafkaWalOptions::new("new-topic".to_string())) + }; + (region_number, options) + }) + .collect()) + } + } + #[async_trait::async_trait] impl ContextProvider for ConcurrentTableRouteUpdateProvider { async fn procedure_state( @@ -804,6 +857,101 @@ mod tests { ); } + async fn check_execute_plan_skip_wal_provider(existing_wal_options: Option) { + let env = TestingEnv::new(); + let table_id = 1024; + let routes = create_current_region_routes(table_id, &[1]); + let mut table_info = new_test_table_info_with_name(table_id, "test_table"); + table_info.meta.column_ids = vec![0, 1, 2]; + table_info.meta.options.skip_wal = true; + let stored_wal_options = existing_wal_options + .clone() + .map(|options| HashMap::from([(1, options)])) + .unwrap_or_default(); + env.table_metadata_manager + .create_table_metadata( + table_info, + TableRouteValue::physical(routes), + stored_wal_options, + ) + .await + .unwrap(); + + let (sender, mut receiver) = mpsc::channel(1); + let node_manager = Arc::new(MockDatanodeManager::new(DatanodeWatcher::new(sender))); + let mut ctx = new_parent_context(&env, node_manager, table_id); + if matches!(&existing_wal_options, Some(WalOptions::Kafka(_))) { + ctx.wal_options_allocator = Arc::new(TestKafkaWalOptionsAllocator); + } + ctx.persistent_ctx.plans = vec![RepartitionPlanEntry { + group_id: Uuid::new_v4(), + source_regions: vec![], + target_regions: vec![create_target_region_descriptor(table_id, 2, "x", 0, 100)], + allocated_region_ids: vec![RegionId::new(table_id, 2)], + pending_deallocate_region_ids: vec![], + transition_map: vec![], + original_target_routes: vec![], + }]; + let mut state = ExecutePlan; + state + .next(&mut ctx, &TestingEnv::procedure_context()) + .await + .unwrap(); + + let (_, request) = receiver.recv().await.unwrap(); + let Some(Body::Create(create)) = request.body else { + unreachable!() + }; + assert_eq!( + Some("true"), + create.options.get(SKIP_WAL_KEY).map(String::as_str) + ); + let new_wal_options: WalOptions = + serde_json::from_str(create.options.get(WAL_OPTIONS_KEY).unwrap()).unwrap(); + let expected = match &existing_wal_options { + Some(WalOptions::Noop) => WalOptions::Noop, + Some(WalOptions::Kafka(_)) => WalOptions::Kafka(KafkaWalOptions { + topic: "new-topic".to_string(), + initial_pruned_entry_id: Some(0), + }), + None | Some(WalOptions::RaftEngine) => WalOptions::RaftEngine, + }; + assert_eq!(expected, new_wal_options); + let route = ctx.get_table_route_value().await.unwrap(); + let wal_options = get_region_wal_options(&ctx.table_metadata_manager, &route, table_id) + .await + .unwrap(); + assert_eq!(Some(&expected), wal_options.get(&2)); + let legacy_default = WalOptions::RaftEngine; + assert_eq!( + Some(existing_wal_options.as_ref().unwrap_or(&legacy_default)), + wal_options.get(&1) + ); + } + + #[tokio::test] + async fn test_execute_plan_keeps_real_wal_provider_for_skipped_table() { + check_execute_plan_skip_wal_provider(Some(WalOptions::RaftEngine)).await; + } + + #[tokio::test] + async fn test_execute_plan_keeps_legacy_raft_provider_for_skipped_table() { + check_execute_plan_skip_wal_provider(None).await; + } + + #[tokio::test] + async fn test_execute_plan_keeps_kafka_wal_provider_for_skipped_table() { + check_execute_plan_skip_wal_provider(Some(WalOptions::Kafka(KafkaWalOptions::new( + "existing-topic".to_string(), + )))) + .await; + } + + #[tokio::test] + async fn test_execute_plan_keeps_noop_for_table_created_without_wal() { + check_execute_plan_skip_wal_provider(Some(WalOptions::Noop)).await; + } + #[test] fn test_allocate_region_state_backward_compatibility() { // Arrange diff --git a/src/metric-engine/src/engine/alter.rs b/src/metric-engine/src/engine/alter.rs index 8bc9ae00b7b..355e84cb322 100644 --- a/src/metric-engine/src/engine/alter.rs +++ b/src/metric-engine/src/engine/alter.rs @@ -284,6 +284,22 @@ mod test { use crate::test_util::{TestEnv, alter_logical_region_request, create_logical_region_request}; + async fn check_alter_physical_skip_wal( + engine: &super::MetricEngineInner, + region_id: RegionId, + skip_wal: bool, + ) { + let request = RegionAlterRequest { + kind: AlterKind::SetRegionOptions { + options: vec![SetRegionOption::SkipWal(skip_wal)], + }, + }; + engine + .alter_physical_region(region_id, request) + .await + .unwrap(); + } + #[tokio::test] async fn test_alter_region() { let env = TestEnv::new().await; @@ -304,16 +320,9 @@ mod test { "Alter request to physical region is forbidden".to_string() ); - // skip WAL on the physical region should be forwarded to the data region - let alter_region_option_request = RegionAlterRequest { - kind: AlterKind::SetRegionOptions { - options: vec![SetRegionOption::SkipWal], - }, - }; - engine_inner - .alter_physical_region(physical_region_id, alter_region_option_request) - .await - .unwrap(); + // skip-WAL changes on the physical region should be forwarded to the data region + check_alter_physical_skip_wal(&engine_inner, physical_region_id, true).await; + check_alter_physical_skip_wal(&engine_inner, physical_region_id, false).await; // alter logical region let metadata_region = env.metadata_region(); diff --git a/src/mito2/src/engine/skip_wal_test.rs b/src/mito2/src/engine/skip_wal_test.rs index 88304777b2d..363318d1cf5 100644 --- a/src/mito2/src/engine/skip_wal_test.rs +++ b/src/mito2/src/engine/skip_wal_test.rs @@ -16,7 +16,10 @@ use std::sync::Arc; use std::time::Duration; use api::v1::Rows; +use common_error::ext::ErrorExt; use common_wal::options::{WAL_OPTIONS_KEY, WalOptions}; +use rstest::rstest; +use rstest_reuse::{self, apply}; use store_api::logstore::provider::Provider; use store_api::mito_engine_options::SKIP_WAL_KEY; use store_api::region_engine::{RegionEngine, RegionRole}; @@ -29,9 +32,19 @@ use store_api::storage::{RegionId, ScanRequest}; use crate::config::MitoConfig; use crate::engine::listener::AlterFlushListener; use crate::test_util::{ - CreateRequestBuilder, TestEnv, build_rows, flush_region, put_rows, rows_schema, + CreateRequestBuilder, LogStoreFactory, TestEnv, build_rows, flush_region, + kafka_log_store_factory, multiple_log_store_factories, prepare_test_for_kafka_log_store, + put_rows, raft_engine_log_store_factory, rows_schema, }; +fn set_skip_wal_request(skip_wal: bool) -> RegionRequest { + RegionRequest::Alter(RegionAlterRequest { + kind: AlterKind::SetRegionOptions { + options: vec![SetRegionOption::SkipWal(skip_wal)], + }, + }) +} + #[tokio::test] async fn test_close_region_skip_wal_with_pending_data() { test_close_region_skip_wal(true).await; @@ -76,25 +89,11 @@ async fn test_alter_skip_wal_stops_wal_and_flushes_on_close() { assert!(!before_alter.version.memtables.is_empty()); engine - .handle_request( - region_id, - RegionRequest::Alter(RegionAlterRequest { - kind: AlterKind::SetRegionOptions { - options: vec![SetRegionOption::SkipWal], - }, - }), - ) + .handle_request(region_id, set_skip_wal_request(true)) .await .unwrap(); engine - .handle_request( - region_id, - RegionRequest::Alter(RegionAlterRequest { - kind: AlterKind::SetRegionOptions { - options: vec![SetRegionOption::SkipWal], - }, - }), - ) + .handle_request(region_id, set_skip_wal_request(true)) .await .unwrap(); @@ -170,6 +169,176 @@ async fn test_alter_skip_wal_stops_wal_and_flushes_on_close() { ); } +#[apply(multiple_log_store_factories)] +async fn test_alter_skip_wal_round_trip_reuses_provider(factory: Option) { + let Some(factory) = factory else { + return; + }; + let mut env = TestEnv::with_prefix("alter-skip-wal-round-trip") + .await + .with_log_store_factory(factory.clone()); + let engine = env.create_engine(MitoConfig::default()).await; + let region_id = RegionId::new(1, 1); + let topic = prepare_test_for_kafka_log_store(&factory).await; + let request = CreateRequestBuilder::new().kafka_topic(topic).build(); + let schema = rows_schema(&request); + + engine + .handle_request(region_id, RegionRequest::Create(request)) + .await + .unwrap(); + let region = engine.get_region(region_id).unwrap(); + let provider = region.provider.clone(); + assert!(!matches!(&provider, Provider::Noop)); + + put_rows( + &engine, + region_id, + Rows { + schema: schema.clone(), + rows: build_rows(0, 1), + }, + ) + .await; + let last_entry_id = region.version_control.current().last_entry_id; + + engine + .handle_request(region_id, set_skip_wal_request(true)) + .await + .unwrap(); + put_rows( + &engine, + region_id, + Rows { + schema: schema.clone(), + rows: build_rows(1, 2), + }, + ) + .await; + assert_eq!( + last_entry_id, + region.version_control.current().last_entry_id + ); + + for _ in 0..2 { + engine + .handle_request(region_id, set_skip_wal_request(false)) + .await + .unwrap(); + } + assert!(!region.version().options.skip_wal); + assert_eq!(&provider, ®ion.provider); + + put_rows( + &engine, + region_id, + Rows { + schema, + rows: build_rows(2, 3), + }, + ) + .await; + assert!(region.version_control.current().last_entry_id > last_entry_id); +} + +#[apply(multiple_log_store_factories)] +async fn test_create_skipped_region_with_real_wal_can_reenable(factory: Option) { + let Some(factory) = factory else { + return; + }; + let mut env = TestEnv::with_prefix("create-skipped-with-real-wal") + .await + .with_log_store_factory(factory.clone()); + let engine = env.create_engine(MitoConfig::default()).await; + let region_id = RegionId::new(1, 1); + let topic = prepare_test_for_kafka_log_store(&factory).await; + let request = CreateRequestBuilder::new() + .kafka_topic(topic) + .insert_option(SKIP_WAL_KEY, "true") + .build(); + let schema = rows_schema(&request); + + engine + .handle_request(region_id, RegionRequest::Create(request)) + .await + .unwrap(); + let region = engine.get_region(region_id).unwrap(); + let provider = region.provider.clone(); + assert!(!matches!(&provider, Provider::Noop)); + assert!(region.version().options.skip_wal); + + let last_entry_id = region.version_control.current().last_entry_id; + put_rows( + &engine, + region_id, + Rows { + schema: schema.clone(), + rows: build_rows(0, 1), + }, + ) + .await; + assert_eq!( + last_entry_id, + region.version_control.current().last_entry_id + ); + + engine + .handle_request(region_id, set_skip_wal_request(false)) + .await + .unwrap(); + assert!(!region.version().options.skip_wal); + assert_eq!(&provider, ®ion.provider); + put_rows( + &engine, + region_id, + Rows { + schema, + rows: build_rows(1, 2), + }, + ) + .await; + assert!(region.version_control.current().last_entry_id > last_entry_id); +} + +#[tokio::test] +async fn test_alter_skip_wal_rejects_noop_provider() { + let mut env = TestEnv::with_prefix("alter-skip-wal-noop").await; + let engine = env.create_engine(MitoConfig::default()).await; + let region_id = RegionId::new(1, 1); + let mut request = CreateRequestBuilder::new().build(); + request.options.insert( + WAL_OPTIONS_KEY.to_string(), + serde_json::to_string(&WalOptions::Noop).unwrap(), + ); + request + .options + .insert(SKIP_WAL_KEY.to_string(), "true".to_string()); + + engine + .handle_request(region_id, RegionRequest::Create(request)) + .await + .unwrap(); + let region = engine.get_region(region_id).unwrap(); + let provider = region.provider.clone(); + assert!(matches!(&provider, Provider::Noop)); + + engine + .handle_request(region_id, set_skip_wal_request(true)) + .await + .unwrap(); + assert!(region.version().options.skip_wal); + assert_eq!(&provider, ®ion.provider); + + let error = engine + .handle_request(region_id, set_skip_wal_request(false)) + .await + .unwrap_err(); + + assert!(error.output_msg().contains("uses the Noop WAL provider")); + assert!(region.version().options.skip_wal); + assert_eq!(&provider, ®ion.provider); +} + #[tokio::test] async fn test_alter_skip_wal_on_follower_survives_promotion() { let mut env = TestEnv::with_prefix("alter-skip-wal-follower").await; @@ -186,14 +355,7 @@ async fn test_alter_skip_wal_on_follower_survives_promotion() { .set_region_role(region_id, RegionRole::Follower) .unwrap(); engine - .handle_request( - region_id, - RegionRequest::Alter(RegionAlterRequest { - kind: AlterKind::SetRegionOptions { - options: vec![SetRegionOption::SkipWal], - }, - }), - ) + .handle_request(region_id, set_skip_wal_request(true)) .await .unwrap(); @@ -201,6 +363,12 @@ async fn test_alter_skip_wal_on_follower_survives_promotion() { assert!(region.is_follower()); assert!(region.version().options.skip_wal); + engine + .handle_request(region_id, set_skip_wal_request(false)) + .await + .unwrap(); + assert!(!region.version().options.skip_wal); + engine .set_region_role(region_id, RegionRole::Leader) .unwrap(); @@ -214,10 +382,7 @@ async fn test_alter_skip_wal_on_follower_survives_promotion() { }, ) .await; - assert_eq!( - last_entry_id, - region.version_control.current().last_entry_id - ); + assert!(region.version_control.current().last_entry_id > last_entry_id); } async fn test_close_region_skip_wal(insert: bool) { diff --git a/src/mito2/src/worker/handle_alter.rs b/src/mito2/src/worker/handle_alter.rs index b13c1badf83..66cb2dd95bb 100644 --- a/src/mito2/src/worker/handle_alter.rs +++ b/src/mito2/src/worker/handle_alter.rs @@ -23,6 +23,7 @@ use common_telemetry::{error, info}; use humantime_serde::re::humantime; use snafu::{ResultExt, ensure}; use store_api::logstore::LogStore; +use store_api::logstore::provider::Provider; use store_api::metadata::{ InvalidSetRegionOptionRequestSnafu, MetadataError, RegionMetadata, RegionMetadataBuilder, RegionMetadataRef, @@ -50,16 +51,19 @@ impl RegionWorkerLoop { request: RegionAlterRequest, sender: OptionOutputTx, ) { - let skip_wal_only = only_enables_skip_wal(&request.kind); - let (region, is_follower) = match self.regions.writable_non_staging_region(region_id) { - Ok(region) => (region, false), - Err(_) if skip_wal_only => match self.regions.follower_region(region_id) { - Ok(region) => (region, true), - Err(e) => { - sender.send(Err(e)); - return; + let requested_skip_wal = skip_wal_value(&request.kind); + let (region, follower_skip_wal) = match self.regions.writable_non_staging_region(region_id) + { + Ok(region) => (region, None), + Err(_) if requested_skip_wal.is_some() => { + match self.regions.follower_region(region_id) { + Ok(region) => (region, requested_skip_wal), + Err(e) => { + sender.send(Err(e)); + return; + } } - }, + } Err(e) => { sender.send(Err(e)); return; @@ -68,13 +72,20 @@ impl RegionWorkerLoop { info!("Try to alter region: {}, request: {:?}", region_id, request); - // Followers only accept skip-WAL, which is an in-memory option change and must + // Followers only accept skip-WAL changes, which are in-memory option changes and must // never enter the leader path that may flush memtables. - if is_follower { + if let Some(skip_wal) = follower_skip_wal { + if let Err(e) = validate_skip_wal_change(®ion, skip_wal) { + sender.send(Err(e).context(InvalidMetadataSnafu)); + return; + } let mut options = region.version().options.clone(); - if !options.skip_wal { - info!("Stop writing WAL for follower region: {}", region_id); - options.skip_wal = true; + if options.skip_wal != skip_wal { + info!( + "Set skip_wal for follower region: {}, previous: {} new: {}", + region_id, options.skip_wal, skip_wal + ); + options.skip_wal = skip_wal; region.version_control.alter_options(options); } sender.send(Ok(0)); @@ -317,10 +328,14 @@ impl RegionWorkerLoop { current_options.preserve_row_sequence = new_preserve; } } - SetRegionOption::SkipWal => { - if !current_options.skip_wal { - info!("Stop writing WAL for region: {}", region.region_id); - current_options.skip_wal = true; + SetRegionOption::SkipWal(skip_wal) => { + validate_skip_wal_change(region, skip_wal)?; + if current_options.skip_wal != skip_wal { + info!( + "Set skip_wal for region: {}, previous: {} new: {}", + region.region_id, current_options.skip_wal, skip_wal + ); + current_options.skip_wal = skip_wal; } } } @@ -352,12 +367,28 @@ impl RegionWorkerLoop { } } -fn only_enables_skip_wal(kind: &AlterKind) -> bool { - matches!( - kind, - AlterKind::SetRegionOptions { options } - if matches!(options.as_slice(), [SetRegionOption::SkipWal]) - ) +fn skip_wal_value(kind: &AlterKind) -> Option { + let AlterKind::SetRegionOptions { options } = kind else { + return None; + }; + let [SetRegionOption::SkipWal(skip_wal)] = options.as_slice() else { + return None; + }; + Some(*skip_wal) +} + +fn validate_skip_wal_change( + region: &MitoRegionRef, + skip_wal: bool, +) -> std::result::Result<(), MetadataError> { + ensure!( + skip_wal || !matches!(®ion.provider, Provider::Noop), + store_api::metadata::InvalidRegionRequestSnafu { + region_id: region.region_id, + err: "cannot enable WAL because the region uses the Noop WAL provider".to_string(), + } + ); + Ok(()) } /// Returns the new region options if there are updates to the options. @@ -382,7 +413,7 @@ fn new_region_options_on_empty_memtable( | SetRegionOption::Ttl(_) | SetRegionOption::Twsc(_, _) | SetRegionOption::AutoFlushInterval(_) - | SetRegionOption::SkipWal => (), + | SetRegionOption::SkipWal(_) => (), SetRegionOption::Format(format_str) => { // Safety: handle_alter_region_options_fast() has validated this. let new_format = format_str.parse::().unwrap(); diff --git a/src/operator/src/bulk_insert.rs b/src/operator/src/bulk_insert.rs index e35779ec979..c47236d7dbc 100644 --- a/src/operator/src/bulk_insert.rs +++ b/src/operator/src/bulk_insert.rs @@ -20,7 +20,6 @@ use api::v1::region::{ BulkInsertRequest, RegionRequest, RegionRequestHeader, bulk_insert_request, region_request, }; use api::v1::{ArrowIpc, PartitionExprVersion}; -use arrow::array::Array; use arrow::record_batch::RecordBatch; use bytes::Bytes; use common_base::AffectedRows; @@ -28,12 +27,13 @@ use common_grpc::FlightData; use common_grpc::flight::{FlightEncoder, FlightMessage}; use common_telemetry::error; use common_telemetry::tracing_context::TracingContext; -use snafu::{OptionExt, ResultExt, ensure}; +use snafu::{ResultExt, ensure}; use store_api::storage::RegionId; use table::TableRef; use table::metadata::TableInfoRef; use crate::insert::Inserter; +use crate::req_convert::insert::extract_timestamps; use crate::{error, metrics}; impl Inserter { @@ -319,22 +319,3 @@ impl Inserter { }); } } - -/// Calculate the timestamp range of record batch. Return `None` if record batch is empty. -fn extract_timestamps(rb: &RecordBatch, timestamp_index_name: &str) -> error::Result> { - let ts_col = rb - .column_by_name(timestamp_index_name) - .context(error::ColumnNotFoundSnafu { - msg: timestamp_index_name, - })?; - if rb.num_rows() == 0 { - return Ok(vec![]); - } - let (primitive, _) = - datatypes::timestamp::timestamp_array_to_primitive(ts_col).with_context(|| { - error::InvalidTimeIndexTypeSnafu { - ty: ts_col.data_type().clone(), - } - })?; - Ok(primitive.iter().flatten().collect()) -} diff --git a/src/operator/src/req_convert/insert.rs b/src/operator/src/req_convert/insert.rs index f9bf83d926e..72d6693c6be 100644 --- a/src/operator/src/req_convert/insert.rs +++ b/src/operator/src/req_convert/insert.rs @@ -17,6 +17,7 @@ mod fill_impure_default; mod row_to_region; mod stmt_to_region; mod table_to_region; +mod timestamps; use api::v1::SemanticType; pub use column_to_row::ColumnToRow; @@ -26,6 +27,7 @@ use snafu::{OptionExt, ResultExt}; pub use stmt_to_region::StatementToRegion; use table::metadata::TableInfo; pub use table_to_region::TableToRegion; +pub use timestamps::extract_timestamps; use crate::error::{ColumnNotFoundSnafu, MissingTimeIndexColumnSnafu, Result}; diff --git a/src/operator/src/req_convert/insert/timestamps.rs b/src/operator/src/req_convert/insert/timestamps.rs new file mode 100644 index 00000000000..f0315135f52 --- /dev/null +++ b/src/operator/src/req_convert/insert/timestamps.rs @@ -0,0 +1,38 @@ +// 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 arrow::record_batch::RecordBatch; +use datatypes::timestamp::append_timestamps; +use snafu::OptionExt; + +use crate::error::{self, Result}; + +/// Extracts non-null timestamps in the source column's native time unit. +pub fn extract_timestamps(rb: &RecordBatch, timestamp_index_name: &str) -> Result> { + let ts_col = rb + .column_by_name(timestamp_index_name) + .context(error::ColumnNotFoundSnafu { + msg: timestamp_index_name, + })?; + if rb.num_rows() == 0 { + return Ok(vec![]); + } + let mut timestamps = Vec::with_capacity(rb.num_rows()); + append_timestamps(ts_col, &mut timestamps).with_context(|| { + error::InvalidTimeIndexTypeSnafu { + ty: ts_col.data_type().clone(), + } + })?; + Ok(timestamps) +} diff --git a/src/promql/src/extension_plan/series_divide.rs b/src/promql/src/extension_plan/series_divide.rs index 6b1a5ee1da2..b850d9b2ddc 100644 --- a/src/promql/src/extension_plan/series_divide.rs +++ b/src/promql/src/extension_plan/series_divide.rs @@ -557,7 +557,9 @@ impl Stream for SeriesDivideStream { } error => return Poll::Ready(error), }; - self.buffer.push(batch); + if batch.num_rows() != 0 { + self.buffer.push(batch); + } continue; } } @@ -627,7 +629,8 @@ impl SeriesDivideStream { #[cfg(test)] mod test { use datafusion::arrow::array::{ - DictionaryArray, Int32Array, LargeStringArray, StringArray, StringViewArray, UInt32Array, + DictionaryArray, Int32Array, Int64Array, LargeStringArray, StringArray, StringViewArray, + UInt32Array, }; use datafusion::arrow::datatypes::{DataType, Field, Int32Type, Schema, UInt32Type}; use datafusion::common::ToDFSchema; @@ -1248,4 +1251,216 @@ mod test { assert!(tsid_array.iter().all(|v| v == Some(expected_tsid))); } } + + /// The input stream may legally start with a batch that has zero rows. + /// `SeriesDivideStream` must skip it and keep working on the real rows that + /// follow (here: two batches of the very same tagged series). + #[tokio::test] + async fn test_stream_leading_empty_batch_then_same_tags() { + let schema = Arc::new(Schema::new(vec![ + Field::new("host", DataType::Utf8, false), + Field::new("path", DataType::Utf8, false), + Field::new( + "time_index", + DataType::Timestamp(datafusion::arrow::datatypes::TimeUnit::Millisecond, None), + false, + ), + ])); + + let make_batch = |hosts: Vec<&str>, paths: Vec<&str>, times: Vec| { + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(hosts)), + Arc::new(StringArray::from(paths)), + Arc::new(datafusion::arrow::array::TimestampMillisecondArray::from( + times, + )), + ], + ) + .unwrap() + }; + + let memory_exec: Arc = Arc::new(DataSourceExec::new(Arc::new( + MemorySourceConfig::try_new( + &[vec![ + RecordBatch::new_empty(schema.clone()), + make_batch( + vec!["server1", "server1"], + vec!["/var/log", "/var/log"], + vec![1000, 2000], + ), + make_batch(vec!["server1"], vec!["/var/log"], vec![3000]), + ]], + schema.clone(), + None, + ) + .unwrap(), + ))); + + let divide_exec = Arc::new(SeriesDivideExec { + tag_columns: vec!["host".to_string(), "path".to_string()], + time_index_column: "time_index".to_string(), + input: memory_exec, + metric: ExecutionPlanMetricsSet::new(), + }); + + let mut stream = divide_exec + .execute(0, SessionContext::default().task_ctx()) + .unwrap(); + + let batch = stream.next().await.unwrap().unwrap(); + assert_eq!(batch.num_rows(), 3); + let expected = [ + ("server1", "/var/log", 1000i64), + ("server1", "/var/log", 2000), + ("server1", "/var/log", 3000), + ]; + for (row, (host, path, time)) in expected.into_iter().enumerate() { + assert_eq!( + string_array_value_at_index(batch.column(0), row), + Some(host) + ); + assert_eq!( + string_array_value_at_index(batch.column(1), row), + Some(path) + ); + assert_eq!( + batch + .column(2) + .as_any() + .downcast_ref::() + .unwrap() + .value(row), + time + ); + } + assert!(stream.next().await.is_none()); + } + + /// Several leading empty batches (and one empty batch in the middle of a + /// series) must not disturb series division: the two series are still + /// emitted separately, including the last one flushed at end of input. + #[tokio::test] + async fn test_stream_repeated_empty_batches_and_two_series() { + let schema = Arc::new(Schema::new(vec![ + Field::new("host", DataType::Utf8, false), + Field::new("value", DataType::Int64, false), + Field::new( + "time_index", + DataType::Timestamp(datafusion::arrow::datatypes::TimeUnit::Millisecond, None), + false, + ), + ])); + + let make_batch = |hosts: Vec<&str>, values: Vec| { + RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(StringArray::from(hosts)), + Arc::new(Int64Array::from(values.clone())), + Arc::new(datafusion::arrow::array::TimestampMillisecondArray::from( + values.iter().map(|v| v * 1000).collect::>(), + )), + ], + ) + .unwrap() + }; + + let memory_exec: Arc = Arc::new(DataSourceExec::new(Arc::new( + MemorySourceConfig::try_new( + &[vec![ + RecordBatch::new_empty(schema.clone()), + RecordBatch::new_empty(schema.clone()), + make_batch(vec!["server1", "server1"], vec![1, 2]), + RecordBatch::new_empty(schema.clone()), + make_batch(vec!["server1"], vec![3]), + make_batch(vec!["server2", "server2"], vec![4, 5]), + ]], + schema.clone(), + None, + ) + .unwrap(), + ))); + + let divide_exec = Arc::new(SeriesDivideExec { + tag_columns: vec!["host".to_string()], + time_index_column: "time_index".to_string(), + input: memory_exec, + metric: ExecutionPlanMetricsSet::new(), + }); + + let mut stream = divide_exec + .execute(0, SessionContext::default().task_ctx()) + .unwrap(); + + for (expected_host, expected_values) in + [("server1", vec![1i64, 2, 3]), ("server2", vec![4, 5])] + { + let batch = stream.next().await.unwrap().unwrap(); + assert_eq!(batch.num_rows(), expected_values.len()); + for (row, value) in expected_values.into_iter().enumerate() { + assert_eq!( + string_array_value_at_index(batch.column(0), row), + Some(expected_host) + ); + assert_eq!( + batch + .column(1) + .as_any() + .downcast_ref::() + .unwrap() + .value(row), + value + ); + } + } + assert!(stream.next().await.is_none()); + } + + /// A stream that only ever yields zero-row batches must terminate with no + /// batches instead of failing. + #[tokio::test] + async fn test_stream_all_empty_batches_yields_nothing() { + let schema = Arc::new(Schema::new(vec![ + Field::new("host", DataType::Utf8, false), + Field::new("value", DataType::Int64, false), + Field::new( + "time_index", + DataType::Timestamp(datafusion::arrow::datatypes::TimeUnit::Millisecond, None), + false, + ), + ])); + + let memory_exec: Arc = Arc::new(DataSourceExec::new(Arc::new( + MemorySourceConfig::try_new( + &[vec![ + RecordBatch::new_empty(schema.clone()), + RecordBatch::new_empty(schema.clone()), + RecordBatch::new_empty(schema.clone()), + ]], + schema.clone(), + None, + ) + .unwrap(), + ))); + + let divide_exec = Arc::new(SeriesDivideExec { + tag_columns: vec!["host".to_string()], + time_index_column: "time_index".to_string(), + input: memory_exec, + metric: ExecutionPlanMetricsSet::new(), + }); + + let mut stream = divide_exec + .execute(0, SessionContext::default().task_ctx()) + .unwrap(); + assert!(stream.next().await.is_none()); + + let collected = + datafusion::physical_plan::collect(divide_exec, SessionContext::default().task_ctx()) + .await + .unwrap(); + assert!(collected.is_empty()); + } } diff --git a/src/servers/Cargo.toml b/src/servers/Cargo.toml index 9d5bc257fdb..7eb008369ba 100644 --- a/src/servers/Cargo.toml +++ b/src/servers/Cargo.toml @@ -36,6 +36,7 @@ bytes.workspace = true catalog.workspace = true chrono.workspace = true common-base.workspace = true +common-batcher.workspace = true common-catalog.workspace = true common-decimal.workspace = true common-error.workspace = true diff --git a/src/servers/src/pending_rows_batcher.rs b/src/servers/src/pending_rows_batcher.rs index f8c4a869d5e..79ae645b98b 100644 --- a/src/servers/src/pending_rows_batcher.rs +++ b/src/servers/src/pending_rows_batcher.rs @@ -23,22 +23,27 @@ use api::v1::region::{ BulkInsertRequest, RegionRequest, RegionRequestHeader, bulk_insert_request, region_request, }; use api::v1::{ArrowIpc, ColumnSchema, RowInsertRequests, Rows}; -use arrow::array::Array; use arrow::compute::{concat_batches, filter_record_batch}; use arrow::datatypes::{DataType as ArrowDataType, Schema as ArrowSchema, TimeUnit}; use arrow::record_batch::RecordBatch; use async_trait::async_trait; -use bytes::Bytes; use catalog::CatalogManagerRef; -use common_grpc::flight::{FlightEncoder, FlightMessage}; +use common_batcher::flush_limiter::FlushLimiter; +use common_batcher::flush_policy::FlushTrigger; +use common_batcher::flush_policy::timing::TimingFlushPolicy; +use common_batcher::notifier::{Notifier, run_notifier}; +use common_batcher::pending_worker::PendingWorker as PendingCore; +use common_batcher::request_limiter::RequestLimiter; +use common_batcher::worker_registry::WorkerRegistry; +use common_grpc::error::Error as GrpcError; +use common_grpc::flight::record_batch_to_ipc; use common_meta::cache::TableFlownodeSetCacheRef; use common_meta::node_manager::NodeManagerRef; use common_query::prelude::{GREPTIME_PHYSICAL_TABLE, greptime_timestamp, greptime_value}; +use common_runtime::spawn_global; use common_telemetry::tracing_context::TracingContext; use common_telemetry::{debug, error, warn}; -use dashmap::DashMap; -use dashmap::mapref::entry::Entry; -use futures::StreamExt; +use datatypes::timestamp::append_timestamps; use metric_engine::batch_modifier::{TagColumnInfo, modify_batch_sparse}; use partition::manager::PartitionRuleManagerRef; use partition::partition::PartitionRuleRef; @@ -81,7 +86,7 @@ pub fn pending_rows_batch_sync_enabled() -> bool { } const WORKER_IDLE_TIMEOUT_MULTIPLIER: u32 = 3; const PHYSICAL_REGION_ESSENTIAL_COLUMN_COUNT: usize = 3; -const MAX_CONCURRENT_FLOW_NOTIFICATIONS: usize = 8; +const MAX_CONCURRENT_FLOW_NOTIFICATIONS: NonZeroUsize = NonZeroUsize::new(8).unwrap(); #[async_trait] pub trait PendingRowsSchemaAlterer: Send + Sync { /// Batch-create multiple logical tables that are missing. @@ -292,15 +297,13 @@ struct TableResolutionPlan { struct PendingBatch { tables: HashMap, - total_row_count: usize, db_string: String, ctx: QueryContextRef, - waiters: Vec, } struct FlushWaiter { response_tx: oneshot::Sender>>, - _permit: OwnedSemaphorePermit, + _permit: Arc, } struct FlushBatch { @@ -322,7 +325,7 @@ enum WorkerCommand { total_rows: usize, ctx: QueryContextRef, response_tx: oneshot::Sender>>, - _permit: OwnedSemaphorePermit, + _permit: Arc, }, #[cfg(test)] Ack { ack_tx: oneshot::Sender<()> }, @@ -344,15 +347,15 @@ fn batch_key_from_ctx(ctx: &QueryContextRef) -> BatchKey { /// Prometheus remote write pending rows batcher. pub struct PendingRowsBatcher { - workers: Arc>, + workers: Arc>, flush_interval: Duration, - max_batch_rows: usize, + flush_policy: TimingFlushPolicy, partition_manager: PartitionRuleManagerRef, node_manager: NodeManagerRef, catalog_manager: CatalogManagerRef, - flow_notification_tx: mpsc::Sender, - flush_semaphore: Arc, - inflight_semaphore: Arc, + flow_notification_tx: Notifier, + flush_limiter: FlushLimiter, + request_limiter: RequestLimiter, worker_channel_capacity: usize, prom_store_with_metric_engine: bool, schema_alterer: PendingRowsSchemaAltererRef, @@ -376,24 +379,21 @@ impl PendingRowsBatcher { max_inflight_requests: usize, flow_notification_queue_capacity: NonZeroUsize, ) -> Option> { - // Disable the batcher if flush is disabled or configuration is invalid. - // Zero values for these knobs either cause panics (e.g., zero-capacity channels) - // or deadlocks (e.g., semaphores with no permits). - if flush_interval.is_zero() - || max_batch_rows == 0 - || max_concurrent_flushes == 0 - || worker_channel_capacity == 0 - || max_inflight_requests == 0 - { + if worker_channel_capacity == 0 || worker_channel_capacity > Semaphore::MAX_PERMITS { return None; } + let flush_policy = TimingFlushPolicy::try_new(flush_interval, max_batch_rows)?; + let flush_limiter = FlushLimiter::try_new(max_concurrent_flushes)?; + + let request_limiter = RequestLimiter::try_new(max_inflight_requests)?; + let (flow_notification_tx, flow_notification_rx) = + Notifier::try_new(flow_notification_queue_capacity.get())?; + let (shutdown, _) = broadcast::channel(1); let pending_rows_batch_sync = pending_rows_batch_sync_enabled(); - let workers = Arc::new(DashMap::new()); - PENDING_WORKERS.set(workers.len() as i64); - let (flow_notification_tx, flow_notification_rx) = - mpsc::channel(flow_notification_queue_capacity.get()); + let workers = Arc::new(WorkerRegistry::new()); + PENDING_WORKERS.set(0); start_flow_notification_worker( flow_notification_rx, table_flownode_set_cache, @@ -403,15 +403,15 @@ impl PendingRowsBatcher { Some(Arc::new(Self { workers, flush_interval, - max_batch_rows, + flush_policy, partition_manager, node_manager, catalog_manager, flow_notification_tx, prom_store_with_metric_engine, schema_alterer, - flush_semaphore: Arc::new(Semaphore::new(max_concurrent_flushes)), - inflight_semaphore: Arc::new(Semaphore::new(max_inflight_requests)), + flush_limiter, + request_limiter, worker_channel_capacity, pending_rows_batch_sync, shutdown, @@ -433,9 +433,8 @@ impl PendingRowsBatcher { let _timer = PENDING_ROWS_BATCH_INGEST_STAGE_ELAPSED .with_label_values(&["submit_acquire_inflight_permit"]) .start_timer(); - self.inflight_semaphore - .clone() - .acquire_owned() + self.request_limiter + .acquire() .await .map_err(|_| error::BatcherChannelClosedSnafu.build())? }; @@ -457,7 +456,7 @@ impl PendingRowsBatcher { .start_timer(); for _ in 0..2 { - let worker = self.get_or_spawn_worker(batch_key.clone()); + let worker = self.get_or_spawn_worker(batch_key.clone()).await; let Some(worker_cmd) = cmd.take() else { break; }; @@ -470,7 +469,8 @@ impl PendingRowsBatcher { self.workers.as_ref(), &batch_key, &worker.tx, - ); + ) + .await; } } } @@ -832,39 +832,24 @@ impl PendingRowsBatcher { Ok(aligned_batches) } - - fn get_or_spawn_worker(&self, key: BatchKey) -> PendingWorker { - if let Some(worker) = self.workers.get(&key) - && !worker.tx.is_closed() - { - return worker.clone(); - } - - let entry = self.workers.entry(key.clone()); - match entry { - Entry::Occupied(mut worker) => { - if worker.get().tx.is_closed() { - let new_worker = self.spawn_worker(key); - worker.insert(new_worker.clone()); - PENDING_WORKERS.set(self.workers.len() as i64); - new_worker - } else { - worker.get().clone() - } - } - Entry::Vacant(vacant) => { - let worker = self.spawn_worker(key); - - vacant.insert(worker.clone()); - PENDING_WORKERS.set(self.workers.len() as i64); - worker - } + async fn get_or_spawn_worker(&self, key: BatchKey) -> PendingWorker { + let (tx, receiver) = self + .workers + .get_or_create(key.clone(), self.worker_channel_capacity) + .await; + if let Some(rx) = receiver { + self.spawn_worker(key, tx.clone(), rx); + PENDING_WORKERS.set(self.workers.len().await as i64); } + PendingWorker { tx } } - fn spawn_worker(&self, key: BatchKey) -> PendingWorker { - let (tx, rx) = mpsc::channel(self.worker_channel_capacity); - let worker = PendingWorker { tx: tx.clone() }; + fn spawn_worker( + &self, + key: BatchKey, + tx: mpsc::Sender, + rx: mpsc::Receiver, + ) { let worker_idle_timeout = self .flush_interval .checked_mul(WORKER_IDLE_TIMEOUT_MULTIPLIER) @@ -872,7 +857,7 @@ impl PendingRowsBatcher { start_worker( key, - worker.tx.clone(), + tx, self.workers.clone(), rx, self.shutdown.clone(), @@ -880,13 +865,10 @@ impl PendingRowsBatcher { self.node_manager.clone(), self.catalog_manager.clone(), self.flow_notification_tx.clone(), - self.flush_interval, worker_idle_timeout, - self.max_batch_rows, - self.flush_semaphore.clone(), + self.flush_policy, + self.flush_limiter.clone(), ); - - worker } } @@ -901,10 +883,8 @@ impl PendingBatch { let db_string = ctx.get_db_string(); Self { tables: HashMap::new(), - total_row_count: 0, db_string, ctx, - waiters: Vec::new(), } } @@ -929,22 +909,21 @@ impl PendingBatch { fn start_worker( key: BatchKey, worker_tx: mpsc::Sender, - workers: Arc>, + workers: Arc>, mut rx: mpsc::Receiver, shutdown: broadcast::Sender<()>, partition_manager: PartitionRuleManagerRef, node_manager: NodeManagerRef, catalog_manager: CatalogManagerRef, - flow_notification_tx: mpsc::Sender, - flush_interval: Duration, + flow_notification_tx: Notifier, worker_idle_timeout: Duration, - max_batch_rows: usize, - flush_semaphore: Arc, + flush_policy: TimingFlushPolicy, + flush_limiter: FlushLimiter, ) { tokio::spawn(async move { + // The business batch and pending flush are populated and drained together. let mut batch = None; - let flush_timer = tokio::time::sleep(flush_interval); - tokio::pin!(flush_timer); + let mut pending_flush = PendingCore::new(flush_policy); let mut shutdown_rx = shutdown.subscribe(); let idle_deadline = tokio::time::Instant::now() + worker_idle_timeout; let idle_timer = tokio::time::sleep_until(idle_deadline); @@ -955,43 +934,37 @@ fn start_worker( cmd = rx.recv() => { match cmd { Some(WorkerCommand::Submit { table_batches, total_rows, ctx, response_tx, _permit }) => { - idle_timer.as_mut().reset(tokio::time::Instant::now() + worker_idle_timeout); + let submitted_at = tokio::time::Instant::now(); + idle_timer.as_mut().reset(submitted_at + worker_idle_timeout); - if batch.is_none() { - // Anchor the flush deadline to this batch's first submission, - // rather than to the worker's creation time. - flush_timer - .as_mut() - .reset(tokio::time::Instant::now() + flush_interval); - } + pending_flush.submit( + FlushWaiter { response_tx, _permit }, + total_rows, + ); let pending_batch = batch.get_or_insert_with(||{ PENDING_BATCHES.inc(); PendingBatch::new(ctx) }); - pending_batch.waiters.push(FlushWaiter { response_tx, _permit }); - for (table_name, table_id, record_batch) in table_batches { pending_batch.add_table_batch(table_name, table_id, record_batch); } - pending_batch.total_row_count += total_rows; PENDING_ROWS.add(total_rows as i64); - if pending_batch.total_row_count >= max_batch_rows - && let Some(flush) = drain_batch(&mut batch) { - spawn_flush( - flush, - partition_manager.clone(), - node_manager.clone(), - catalog_manager.clone(), - flow_notification_tx.clone(), - flush_semaphore.clone(), - ).await; + if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, Some(FlushTrigger::Submission)) { + spawn_flush( + flush, + partition_manager.clone(), + node_manager.clone(), + catalog_manager.clone(), + flow_notification_tx.clone(), + flush_limiter.clone(), + ).await; } } None => { - if let Some(flush) = drain_batch(&mut batch) { + if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, None) { flush_batch_with_managers( flush, partition_manager.clone(), @@ -1010,7 +983,7 @@ fn start_worker( } _ = &mut idle_timer => { if !should_close_worker_on_idle_timeout( - batch.as_ref().map_or(0, |batch| batch.total_row_count), + pending_flush.total_rows(), rx.len(), ) { idle_timer @@ -1027,20 +1000,20 @@ fn start_worker( ); break; } - _ = &mut flush_timer, if batch.is_some() => { - if let Some(flush) = drain_batch(&mut batch) { + _ = pending_flush.wait_flush() => { + if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, Some(FlushTrigger::Deadline)) { spawn_flush( flush, partition_manager.clone(), node_manager.clone(), catalog_manager.clone(), flow_notification_tx.clone(), - flush_semaphore.clone(), + flush_limiter.clone(), ).await; } } _ = shutdown_rx.recv() => { - if let Some(flush) = drain_batch(&mut batch) { + if let Some(flush) = drain_batch(&mut batch, &mut pending_flush, None) { flush_batch_with_managers( flush, partition_manager.clone(), @@ -1054,41 +1027,46 @@ fn start_worker( } } - remove_worker_if_same_channel(workers.as_ref(), &key, &worker_tx); + remove_worker_if_same_channel(workers.as_ref(), &key, &worker_tx).await; }); } -fn remove_worker_if_same_channel( - workers: &DashMap, +async fn remove_worker_if_same_channel( + workers: &WorkerRegistry, key: &BatchKey, worker_tx: &mpsc::Sender, ) -> bool { - if let Some(worker) = workers.get(key) - && worker.tx.same_channel(worker_tx) - { - drop(worker); - workers.remove(key); - PENDING_WORKERS.set(workers.len() as i64); - return true; + if workers.remove_if_same(key, worker_tx).await { + PENDING_WORKERS.set(workers.len().await as i64); + true + } else { + false } - - false } fn should_close_worker_on_idle_timeout(total_row_count: usize, queued_requests: usize) -> bool { total_row_count == 0 && queued_requests == 0 } -fn drain_batch(batch: &mut Option) -> Option { +/// Transfers the ready batch, or drains unconditionally when no trigger is given. +/// Execution and flush permits remain owned by the caller. +fn drain_batch( + batch: &mut Option, + pending_flush: &mut PendingCore, + trigger: Option, +) -> Option { + let total_row_count = pending_flush.total_rows(); + let waiters = match trigger { + Some(trigger) => pending_flush.take_ready(trigger)?, + None => pending_flush.take_pending()?, + }; let batch = batch.take()?; - let total_row_count = batch.total_row_count; if total_row_count == 0 { return None; } let table_batches = batch.tables.into_values().collect(); - let waiters = batch.waiters; PENDING_ROWS.sub(total_row_count as i64); PENDING_BATCHES.dec(); @@ -1107,10 +1085,10 @@ async fn spawn_flush( partition_manager: PartitionRuleManagerRef, node_manager: NodeManagerRef, catalog_manager: CatalogManagerRef, - flow_notification_tx: mpsc::Sender, - semaphore: Arc, + flow_notification_tx: Notifier, + flush_limiter: FlushLimiter, ) { - match semaphore.acquire_owned().await { + match flush_limiter.acquire().await { Ok(permit) => { tokio::spawn(async move { let _permit = permit; @@ -1323,7 +1301,7 @@ async fn flush_batch_with_managers( partition_manager: PartitionRuleManagerRef, node_manager: NodeManagerRef, catalog_manager: CatalogManagerRef, - flow_notification_tx: mpsc::Sender, + flow_notification_tx: Notifier, ) { let partition_provider = PartitionManagerPhysicalFlushAdapter { partition_manager }; let node_requester = NodeManagerPhysicalFlushAdapter { @@ -1345,7 +1323,7 @@ async fn flush_batch( partition_manager: &(impl PhysicalFlushPartitionProvider + ?Sized), node_manager: &(impl PhysicalFlushNodeRequester + ?Sized), catalog_manager: &(impl PhysicalFlushCatalogProvider + ?Sized), - flow_notification_tx: mpsc::Sender, + flow_notification_tx: Notifier, ) { let FlushBatch { table_batches, @@ -1403,21 +1381,13 @@ fn extract_timestamps(table_batch: &TableBatch) -> Vec { let mut timestamps = Vec::with_capacity(table_batch.row_count); for batch in &table_batch.batches { let timestamp_column = batch.batch.column(batch.timestamp_index); - let Some((timestamp_values, _)) = - datatypes::timestamp::timestamp_array_to_primitive(timestamp_column) - else { + let Some(()) = append_timestamps(timestamp_column, &mut timestamps) else { error!( "Failed to extract timestamps from record batch, table_id: {}, timestamp_index: {}", table_batch.table_id, batch.timestamp_index ); continue; }; - - if timestamp_values.null_count() == 0 { - timestamps.extend_from_slice(timestamp_values.values()); - } else { - timestamps.extend(timestamp_values.iter().flatten()); - } } timestamps } @@ -1428,10 +1398,10 @@ struct FlowNotification { } fn try_enqueue_flow_notification( - tx: &mpsc::Sender, + tx: &Notifier, notification: FlowNotification, ) -> bool { - match tx.try_send(notification) { + match tx.try_notify(notification) { Ok(()) => true, Err(mpsc::error::TrySendError::Full(notification)) => { FLOW_NOTIFICATION_DROPPED.with_label_values(&["full"]).inc(); @@ -1456,7 +1426,7 @@ fn try_enqueue_flow_notification( } } -fn enqueue_flow_notifications(table_batches: Vec, tx: &mpsc::Sender) { +fn enqueue_flow_notifications(table_batches: Vec, tx: &Notifier) { for table_batch in table_batches { let timestamps = extract_timestamps(&table_batch); if timestamps.is_empty() { @@ -1517,14 +1487,17 @@ fn start_flow_notification_worker( table_flownode_set_cache: TableFlownodeSetCacheRef, node_manager: NodeManagerRef, ) { - common_runtime::spawn_global(async move { - tokio_stream::wrappers::ReceiverStream::new(notification_rx) - .for_each_concurrent(MAX_CONCURRENT_FLOW_NOTIFICATIONS, |notification| { + spawn_global(async move { + run_notifier( + notification_rx, + MAX_CONCURRENT_FLOW_NOTIFICATIONS, + |notification| { let table_flownode_set_cache = table_flownode_set_cache.clone(); let node_manager = node_manager.clone(); handle_flow_notification(notification, table_flownode_set_cache, node_manager) - }) - .await; + }, + ) + .await; }); } @@ -1534,7 +1507,7 @@ fn notify_flow_dirty_windows_after_flush( table_flownode_set_cache: TableFlownodeSetCacheRef, node_manager: NodeManagerRef, ) { - let (tx, rx) = mpsc::channel(table_batches.len().max(1)); + let (tx, rx) = Notifier::try_new(table_batches.len().max(1)).unwrap(); start_flow_notification_worker(rx, table_flownode_set_cache, node_manager); enqueue_flow_notifications(table_batches, &tx); } @@ -1804,7 +1777,7 @@ fn encode_region_write_requests( let _timer = PENDING_ROWS_BATCH_FLUSH_STAGE_ELAPSED .with_label_values(&["flush_physical_encode_ipc"]) .start_timer(); - record_batch_to_ipc(resolved.planned.batch)? + record_batch_to_ipc(resolved.planned.batch).map_err(map_ipc_error)? }; let request = RegionRequest { @@ -1847,36 +1820,24 @@ fn notify_waiters(waiters: Vec, result: Result<()>) { } } -fn record_batch_to_ipc(record_batch: RecordBatch) -> Result<(Bytes, Bytes, Bytes)> { - let mut encoder = FlightEncoder::default(); - let schema = encoder.encode_schema(record_batch.schema().as_ref()); - let mut iter = encoder - .encode(FlightMessage::RecordBatch(record_batch)) - .into_iter(); - let Some(flight_data) = iter.next() else { - return Err(Error::Internal { - err_msg: "Failed to encode empty flight data".to_string(), - }); - }; - if iter.next().is_some() { - return Err(Error::NotSupported { - feat: "bulk insert RecordBatch with dictionary arrays".to_string(), - }); +fn map_ipc_error(error: GrpcError) -> Error { + match error { + GrpcError::NotSupported { feat } => Error::NotSupported { feat }, + GrpcError::InvalidFlightData { reason, .. } => Error::Internal { err_msg: reason }, + error => Error::Internal { + err_msg: error.to_string(), + }, } - - Ok(( - schema.data_header, - flight_data.data_header, - flight_data.data_body, - )) } #[cfg(test)] mod tests { use std::any::Any; use std::collections::{HashMap, HashSet}; + use std::future::{Future, poll_fn}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; + use std::task::Poll; use std::time::Duration; use api::region::RegionResponse; @@ -1912,7 +1873,6 @@ mod tests { }; use common_query::request::QueryRequest; use common_recordbatch::SendableRecordBatchStream; - use dashmap::DashMap; use datatypes::schema::{ColumnSchema as DtColumnSchema, Schema as DtSchema}; use moka::future::CacheBuilder; use partition::cache::new_partition_info_cache; @@ -1927,21 +1887,22 @@ mod tests { use tokio::sync::{Notify, Semaphore, broadcast, mpsc, oneshot}; use tokio::time::{advance, sleep}; - use super::{ - BatchKey, Error, FlushBatch, FlushRegionWrite, FlushWaiter, PendingBatch, - PendingRowsBatcher, PendingWorker, PhysicalFlushCatalogProvider, + use crate::error; + use crate::metrics::FLOW_NOTIFICATION_DROPPED; + use crate::pending_rows_batcher::{ + BatchKey, Error, FlushBatch, FlushLimiter, FlushRegionWrite, FlushTrigger, FlushWaiter, + Notifier, PendingBatch, PendingCore, PendingRowsBatcher, PhysicalFlushCatalogProvider, PhysicalFlushNodeRequester, PhysicalFlushPartitionProvider, PhysicalTableMetadata, - PlannedRegionBatch, RecordBatchWithTsIdx, ResolvedRegionBatch, TableBatch, WorkerCommand, - batch_key_from_ctx, columns_taxonomy, drain_batch, encode_region_write_requests, - extract_timestamps, flush_batch, flush_batch_physical, flush_region_writes_concurrently, - greptime_timestamp, notify_flow_dirty_windows_after_flush, plan_region_batches, + PlannedRegionBatch, RecordBatchWithTsIdx, RequestLimiter, ResolvedRegionBatch, TableBatch, + TimingFlushPolicy, WorkerCommand, WorkerRegistry, batch_key_from_ctx, columns_taxonomy, + drain_batch, encode_region_write_requests, extract_timestamps, flush_batch, + flush_batch_physical, flush_region_writes_concurrently, greptime_timestamp, + notify_flow_dirty_windows_after_flush, notify_waiters, plan_region_batches, remove_worker_if_same_channel, should_close_worker_on_idle_timeout, should_dispatch_concurrently, start_flow_notification_worker, start_worker, strip_partition_columns_from_batch, transform_logical_batches_to_physical, try_enqueue_flow_notification, }; - use crate::error; - use crate::metrics::FLOW_NOTIFICATION_DROPPED; use crate::prom_row_builder::rows_to_aligned_record_batch; fn mock_rows(row_count: usize, schema_name: &str) -> Rows { @@ -2165,8 +2126,8 @@ mod tests { #[test] fn test_flow_notification_queue_drops_when_full() { - let (tx, mut rx) = mpsc::channel(1); - let notification = |table_id| super::FlowNotification { + let (tx, mut rx) = Notifier::try_new(1).unwrap(); + let notification = |table_id| crate::pending_rows_batcher::FlowNotification { table_id, timestamps: vec![table_id as i64], }; @@ -2178,6 +2139,12 @@ mod tests { assert_eq!(1, rx.try_recv().unwrap().table_id); assert_eq!(dropped_before + 1, dropped.get()); + + let closed = FLOW_NOTIFICATION_DROPPED.with_label_values(&["closed"]); + let closed_before = closed.get(); + drop(rx); + assert!(!try_enqueue_flow_notification(&tx, notification(3))); + assert_eq!(closed_before + 1, closed.get()); } fn mock_physical_table_metadata(table_id: TableId) -> PhysicalTableMetadata { @@ -2375,11 +2342,20 @@ mod tests { assert_eq!(2, table_rows[0].1.rows.len()); } - #[test] - fn test_drain_batch_takes_initialized_pending_batch_from_option() { + #[tokio::test] + async fn test_drain_batch_takes_initialized_pending_batch_from_option() { let ctx = session::context::QueryContext::arc(); let (response_tx, _response_rx) = oneshot::channel(); let permit = Arc::new(Semaphore::new(1)).try_acquire_owned().unwrap(); + let mut pending_flush = + PendingCore::new(TimingFlushPolicy::try_new(Duration::from_secs(10), 1).unwrap()); + pending_flush.submit( + FlushWaiter { + response_tx, + _permit: Arc::new(permit), + }, + 1, + ); let mut batch = Some(PendingBatch { tables: HashMap::from([( 42, @@ -2390,24 +2366,77 @@ mod tests { row_count: 1, }, )]), - total_row_count: 1, db_string: ctx.get_db_string(), ctx: ctx.clone(), - waiters: vec![FlushWaiter { - response_tx, - _permit: permit, - }], }); - let flush = drain_batch(&mut batch).unwrap(); + let flush = drain_batch( + &mut batch, + &mut pending_flush, + Some(FlushTrigger::Submission), + ) + .unwrap(); assert!(batch.is_none()); + assert!(pending_flush.is_empty()); + assert_eq!(0, pending_flush.total_rows()); + assert_eq!(1, flush.waiters.len()); assert_eq!(1, flush.total_row_count); assert_eq!(1, flush.table_batches.len()); assert_eq!(ctx.get_db_string(), flush.db_string); assert_eq!(ctx.current_catalog(), flush.ctx.current_catalog()); } + #[tokio::test] + async fn test_drain_batch_preserves_unready_state_and_clears_zero_rows() { + for total_rows in [0, 1] { + let mut pending_flush = + PendingCore::new(TimingFlushPolicy::try_new(Duration::from_secs(10), 2).unwrap()); + let mut batch = Some(PendingBatch::new(session::context::QueryContext::arc())); + let semaphore = Arc::new(Semaphore::new(1)); + let (response_tx, mut response_rx) = oneshot::channel(); + pending_flush.submit( + FlushWaiter { + response_tx, + _permit: Arc::new(semaphore.clone().acquire_owned().await.unwrap()), + }, + total_rows, + ); + assert!( + drain_batch( + &mut batch, + &mut pending_flush, + Some(FlushTrigger::Submission) + ) + .is_none() + ); + assert!(batch.is_some()); + assert!(!pending_flush.is_empty()); + assert_eq!(total_rows, pending_flush.total_rows()); + assert_eq!(0, semaphore.available_permits()); + + let drained = drain_batch(&mut batch, &mut pending_flush, None); + assert!(batch.is_none()); + assert!(pending_flush.is_empty()); + assert_eq!(0, pending_flush.total_rows()); + if total_rows == 0 { + assert!(drained.is_none()); + assert_eq!(1, semaphore.available_permits()); + assert!(matches!( + response_rx.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + } else { + let drained = drained.unwrap(); + assert_eq!(1, drained.total_row_count); + assert_eq!(1, drained.waiters.len()); + assert_eq!(0, semaphore.available_permits()); + drop(drained); + assert_eq!(1, semaphore.available_permits()); + } + } + } + #[test] fn test_pending_batch_keeps_same_name_batches_with_distinct_table_ids() { let ctx = session::context::QueryContext::arc(); @@ -2572,8 +2601,8 @@ mod tests { fn mock_flow_notification_sender( cache: TableFlownodeSetCacheRef, node_manager: NodeManagerRef, - ) -> mpsc::Sender { - let (tx, rx) = mpsc::channel(16); + ) -> Notifier { + let (tx, rx) = Notifier::try_new(16).unwrap(); start_flow_notification_worker(rx, cache, node_manager); tx } @@ -2930,9 +2959,25 @@ mod tests { assert_eq!(batches[&batch_key_from_ctx(&skip_wal_ctx)], 2); } - #[test] - fn test_remove_worker_if_same_channel_removes_matching_entry() { - let workers = DashMap::new(); + #[tokio::test] + async fn test_cancelled_waiter_retains_request_slot_until_notification() { + let limiter = RequestLimiter::try_new(1).unwrap(); + let (response_tx, response_rx) = oneshot::channel(); + let waiter = FlushWaiter { + response_tx, + _permit: limiter.acquire().await.unwrap(), + }; + drop(response_rx); + let next = limiter.acquire(); + tokio::pin!(next); + assert!(poll_fn(|cx| Poll::Ready(next.as_mut().poll(cx).is_pending())).await); + notify_waiters(vec![waiter], Ok(())); + let _permit = next.await.unwrap(); + } + + #[tokio::test] + async fn test_remove_worker_if_same_channel_removes_matching_entry() { + let workers = WorkerRegistry::new(); let key = BatchKey { catalog: "greptime".to_string(), schema: "public".to_string(), @@ -2941,15 +2986,15 @@ mod tests { }; let (tx, _rx) = mpsc::channel::(1); - workers.insert(key.clone(), PendingWorker { tx: tx.clone() }); + workers.get_or_insert_with(key.clone(), || tx.clone()).await; - assert!(remove_worker_if_same_channel(&workers, &key, &tx)); - assert!(!workers.contains_key(&key)); + assert!(remove_worker_if_same_channel(&workers, &key, &tx).await); + assert!(workers.is_empty().await); } - #[test] - fn test_remove_worker_if_same_channel_keeps_newer_entry() { - let workers = DashMap::new(); + #[tokio::test] + async fn test_remove_worker_if_same_channel_keeps_newer_entry() { + let workers = WorkerRegistry::new(); let key = BatchKey { catalog: "greptime".to_string(), schema: "public".to_string(), @@ -2959,16 +3004,13 @@ mod tests { let (stale_tx, _stale_rx) = mpsc::channel::(1); let (fresh_tx, _fresh_rx) = mpsc::channel::(1); - workers.insert( - key.clone(), - PendingWorker { - tx: fresh_tx.clone(), - }, - ); + workers + .get_or_insert_with(key.clone(), || fresh_tx.clone()) + .await; - assert!(!remove_worker_if_same_channel(&workers, &key, &stale_tx)); - assert!(workers.contains_key(&key)); - assert!(workers.get(&key).unwrap().tx.same_channel(&fresh_tx)); + assert!(!remove_worker_if_same_channel(&workers, &key, &stale_tx).await); + assert!(workers.get(&key).await.is_some()); + assert!(workers.get(&key).await.unwrap().same_channel(&fresh_tx)); } #[test] @@ -2997,7 +3039,7 @@ mod tests { total_rows, ctx: session::context::QueryContext::arc(), response_tx, - _permit: permit, + _permit: Arc::new(permit), }) .await .unwrap(); @@ -3039,7 +3081,7 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn test_worker_rearms_creation_relative_deadline_after_size_flush() { + async fn test_worker_preserves_first_deadline_and_inline_shutdown() { let flush_interval = Duration::from_secs(10); let worker_idle_timeout = Duration::from_secs(30); let key = BatchKey { @@ -3048,14 +3090,11 @@ mod tests { physical_table: "phy".to_string(), skip_wal: false, }; - let workers = Arc::new(DashMap::new()); + let workers = Arc::new(WorkerRegistry::new()); let (worker_tx, worker_rx) = mpsc::channel(1); - workers.insert( - key.clone(), - PendingWorker { - tx: worker_tx.clone(), - }, - ); + workers + .get_or_insert_with(key.clone(), || worker_tx.clone()) + .await; let backend = Arc::new(MemoryKvBackend::default()); let table_route_cache = Arc::new(new_table_route_cache( @@ -3077,9 +3116,10 @@ mod tests { datanodes: Arc::new(HashMap::new()), }); let catalog_manager = MemoryCatalogManager::with_default_setup(); - let (flow_notification_tx, _flow_notification_rx) = mpsc::channel(1); + let (flow_notification_tx, _flow_notification_rx) = Notifier::try_new(1).unwrap(); let (shutdown, _) = broadcast::channel(1); + let flush_limiter = FlushLimiter::try_new(1).unwrap(); start_worker( key.clone(), worker_tx.clone(), @@ -3090,10 +3130,9 @@ mod tests { node_manager, catalog_manager, flow_notification_tx, - flush_interval, worker_idle_timeout, - 2, - Arc::new(Semaphore::new(1)), + TimingFlushPolicy::try_new(flush_interval, 3).unwrap(), + flush_limiter.clone(), ); // Start the worker, then size-flush a batch halfway to the first @@ -3101,7 +3140,7 @@ mod tests { // drains the batch before that deadline is reached. tokio::task::yield_now().await; advance(flush_interval / 2).await; - let size_flush_rx = submit_mock_worker_batch(&worker_tx, 2, 1000).await; + let size_flush_rx = submit_mock_worker_batch(&worker_tx, 3, 1000).await; let size_flush_result = receive_mock_flush_result(size_flush_rx, "row threshold did not flush the first batch") .await; @@ -3112,7 +3151,10 @@ mod tests { advance(flush_interval / 5).await; let mut timed_flush_rx = submit_mock_worker_batch(&worker_tx, 1, 2000).await; - advance(flush_interval - Duration::from_millis(1)).await; + let first_submission = tokio::time::Instant::now(); + advance(flush_interval / 2).await; + let later_flush_rx = submit_mock_worker_batch(&worker_tx, 1, 3000).await; + advance(flush_interval / 2 - Duration::from_millis(1)).await; for _ in 0..10 { tokio::task::yield_now().await; } @@ -3129,15 +3171,39 @@ mod tests { .await; assert_missing_physical_table(timed_flush_result); + assert_eq!( + first_submission + flush_interval, + tokio::time::Instant::now() + ); + assert_missing_physical_table( + receive_mock_flush_result( + later_flush_rx, + "later submission was not included in the timed flush", + ) + .await, + ); + + // Shutdown retains the existing inline path even with no flush permits. + let _held_permit = flush_limiter.acquire().await.unwrap(); + let shutdown_flush_rx = submit_mock_worker_batch(&worker_tx, 1, 4000).await; + let shutdown_at = tokio::time::Instant::now(); let _ = shutdown.send(()); + assert_missing_physical_table( + receive_mock_flush_result( + shutdown_flush_rx, + "shutdown incorrectly waited for a flush permit", + ) + .await, + ); + assert_eq!(shutdown_at, tokio::time::Instant::now()); for _ in 0..10 { - if !workers.contains_key(&key) { + if workers.is_empty().await { break; } tokio::task::yield_now().await; } assert!( - !workers.contains_key(&key), + workers.is_empty().await, "worker did not exit after shutdown" ); } diff --git a/src/store-api/src/region_request.rs b/src/store-api/src/region_request.rs index 6a40f3dbc6b..826ef7d6bc9 100644 --- a/src/store-api/src/region_request.rs +++ b/src/store-api/src/region_request.rs @@ -42,7 +42,7 @@ use datatypes::error::time_index_not_widening_error; use datatypes::prelude::ConcreteDataType; use datatypes::schema::{FulltextOptions, SkippingIndexOptions}; use num_enum::TryFromPrimitive; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use snafu::{OptionExt, ResultExt, ensure}; use strum::{AsRefStr, IntoStaticStr}; @@ -1486,9 +1486,9 @@ impl From for ModifyColumnType { /// Region option changes used by ALTER requests. /// -/// This type currently derives serde for request persistence. Keep future changes -/// backward compatible with previously serialized variants. -#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] +/// This type is serialized for request persistence. Keep future changes backward +/// compatible with previously serialized variants. +#[derive(Debug, Eq, PartialEq, Clone)] pub enum SetRegionOption { WriteBufferSize(Option), Ttl(Option), @@ -1503,10 +1503,92 @@ pub enum SetRegionOption { // Modifying the max number of rows in a parquet row group. MaxRowGroupRowCount(Option), PreserveRowSequence(bool), - // Stops writing new WAL entries. This operation is irreversible. + // Whether to skip writing new WAL entries. + SkipWal(bool), +} + +#[derive(Serialize, Deserialize)] +enum SetRegionOptionSerde { + WriteBufferSize(Option), + Ttl(Option), + Twsc(String, String), + Format(String), + AppendMode(bool), + AutoFlushInterval(Option), + MaxRowGroupRowCount(Option), + PreserveRowSequence(bool), + SkipWal(bool), +} + +impl Serialize for SetRegionOption { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + // Older binaries deserialize the disable request as a unit variant. + if matches!(self, Self::SkipWal(true)) { + return serializer.serialize_unit_variant("SetRegionOption", 8, "SkipWal"); + } + + let option = match self { + Self::WriteBufferSize(value) => SetRegionOptionSerde::WriteBufferSize(*value), + Self::Ttl(value) => SetRegionOptionSerde::Ttl(*value), + Self::Twsc(key, value) => SetRegionOptionSerde::Twsc(key.clone(), value.clone()), + Self::Format(value) => SetRegionOptionSerde::Format(value.clone()), + Self::AppendMode(value) => SetRegionOptionSerde::AppendMode(*value), + Self::AutoFlushInterval(value) => SetRegionOptionSerde::AutoFlushInterval(*value), + Self::MaxRowGroupRowCount(value) => SetRegionOptionSerde::MaxRowGroupRowCount(*value), + Self::PreserveRowSequence(value) => SetRegionOptionSerde::PreserveRowSequence(*value), + Self::SkipWal(value) => SetRegionOptionSerde::SkipWal(*value), + }; + option.serialize(serializer) + } +} + +#[derive(Deserialize)] +enum LegacySetRegionOption { SkipWal, } +#[derive(Deserialize)] +#[serde(untagged)] +enum BackwardCompatibleSetRegionOption { + Current(SetRegionOptionSerde), + Legacy(LegacySetRegionOption), +} + +impl From for SetRegionOption { + fn from(option: SetRegionOptionSerde) -> Self { + match option { + SetRegionOptionSerde::WriteBufferSize(value) => Self::WriteBufferSize(value), + SetRegionOptionSerde::Ttl(value) => Self::Ttl(value), + SetRegionOptionSerde::Twsc(key, value) => Self::Twsc(key, value), + SetRegionOptionSerde::Format(value) => Self::Format(value), + SetRegionOptionSerde::AppendMode(value) => Self::AppendMode(value), + SetRegionOptionSerde::AutoFlushInterval(value) => Self::AutoFlushInterval(value), + SetRegionOptionSerde::MaxRowGroupRowCount(value) => Self::MaxRowGroupRowCount(value), + SetRegionOptionSerde::PreserveRowSequence(value) => Self::PreserveRowSequence(value), + SetRegionOptionSerde::SkipWal(value) => Self::SkipWal(value), + } + } +} + +impl<'de> Deserialize<'de> for SetRegionOption { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + Ok( + match BackwardCompatibleSetRegionOption::deserialize(deserializer)? { + BackwardCompatibleSetRegionOption::Current(option) => option.into(), + BackwardCompatibleSetRegionOption::Legacy(LegacySetRegionOption::SkipWal) => { + Self::SkipWal(true) + } + }, + ) + } +} + impl TryFrom<&PbOption> for SetRegionOption { type Error = MetadataError; @@ -1572,7 +1654,12 @@ impl TryFrom<&PbOption> for SetRegionOption { .map_err(|_| InvalidSetRegionOptionRequestSnafu { key, value }.build())?; Ok(Self::PreserveRowSequence(preserve)) } - SKIP_WAL_KEY if value == "true" => Ok(Self::SkipWal), + SKIP_WAL_KEY => { + let skip_wal = value + .parse::() + .map_err(|_| InvalidSetRegionOptionRequestSnafu { key, value }.build())?; + Ok(Self::SkipWal(skip_wal)) + } _ => InvalidSetRegionOptionRequestSnafu { key, value }.fail(), } } @@ -2047,11 +2134,20 @@ mod tests { value: "true".to_string(), }; assert_eq!( - SetRegionOption::SkipWal, + SetRegionOption::SkipWal(true), SetRegionOption::try_from(&pb).unwrap() ); - for value in ["false", "", "invalid"] { + let pb = PbOption { + key: SKIP_WAL_KEY.to_string(), + value: "false".to_string(), + }; + assert_eq!( + SetRegionOption::SkipWal(false), + SetRegionOption::try_from(&pb).unwrap() + ); + + for value in ["", "invalid"] { let pb = PbOption { key: SKIP_WAL_KEY.to_string(), value: value.to_string(), @@ -2062,6 +2158,37 @@ mod tests { assert!(UnsetRegionOption::try_from(SKIP_WAL_KEY).is_err()); } + #[test] + fn test_set_region_option_skip_wal_serde_compatibility() { + let legacy = serde_json::from_str::(r#""SkipWal""#).unwrap(); + assert_eq!(SetRegionOption::SkipWal(true), legacy); + + check_set_region_option_skip_wal_serde_compatibility(false); + check_set_region_option_skip_wal_serde_compatibility(true); + } + + fn check_set_region_option_skip_wal_serde_compatibility(skip_wal: bool) { + let option = SetRegionOption::SkipWal(skip_wal); + let serialized = serde_json::to_string(&option).unwrap(); + let expected = if skip_wal { + r#""SkipWal""# + } else { + r#"{"SkipWal":false}"# + }; + assert_eq!(expected, serialized); + assert_eq!( + option, + serde_json::from_str::(&serialized).unwrap() + ); + if skip_wal { + assert!(serde_json::from_str::(&serialized).is_ok()); + assert_eq!( + option, + serde_json::from_str::(r#"{"SkipWal":true}"#).unwrap() + ); + } + } + #[test] fn test_set_region_option_max_row_group_row_count_try_from() { let pb = PbOption { diff --git a/src/table/src/metadata.rs b/src/table/src/metadata.rs index b664c5cfd55..a784fbcf86b 100644 --- a/src/table/src/metadata.rs +++ b/src/table/src/metadata.rs @@ -431,13 +431,13 @@ impl TableMeta { new_options.extra_options.remove(PRESERVE_ROW_SEQUENCE); } } - SetRegionOption::SkipWal => { - new_options.skip_wal = true; + SetRegionOption::SkipWal(skip_wal) => { + new_options.skip_wal = *skip_wal; // Keep the explicit table option so it remains distinguishable // from a value inherited from the schema. new_options .extra_options - .insert(SKIP_WAL_KEY.to_string(), true.to_string()); + .insert(SKIP_WAL_KEY.to_string(), skip_wal.to_string()); } } } @@ -1926,7 +1926,7 @@ mod tests { .insert(SKIP_WAL_KEY.to_string(), false.to_string()); let alter_kind = AlterKind::SetTableOptions { - options: vec![SetRegionOption::SkipWal], + options: vec![SetRegionOption::SkipWal(true)], }; let new_meta = meta .builder_with_alter_kind("my_table", &alter_kind) @@ -1943,6 +1943,25 @@ mod tests { .get(SKIP_WAL_KEY) .map(String::as_str) ); + + let alter_kind = AlterKind::SetTableOptions { + options: vec![SetRegionOption::SkipWal(false)], + }; + let new_meta = new_meta + .builder_with_alter_kind("my_table", &alter_kind) + .unwrap() + .build() + .unwrap(); + + assert!(!new_meta.options.skip_wal); + assert_eq!( + Some("false"), + new_meta + .options + .extra_options + .get(SKIP_WAL_KEY) + .map(String::as_str) + ); } #[test] diff --git a/tests-integration/fixtures/certs/client.crt b/tests-integration/fixtures/certs/client.crt index 11fc099c42f..6e7baafa53f 100644 --- a/tests-integration/fixtures/certs/client.crt +++ b/tests-integration/fixtures/certs/client.crt @@ -1,19 +1,31 @@ -----BEGIN CERTIFICATE----- -MIIDFjCCAf6gAwIBAgIUIakjuzbmQLPnVJakOjLtjzjMEI8wDQYJKoZIhvcNAQEL -BQAwGzEZMBcGA1UEAwwQR3JlcHRpbWVEQlJvb3RDQTAeFw0yNTA5MTYxMTM3NTZa -Fw0yNjA5MTYxMTM3NTZaMBUxEzARBgNVBAMMCmdyZXB0aW1lZGIwggEiMA0GCSqG -SIb3DQEBAQUAA4IBDwAwggEKAoIBAQD3WM82qlcPut5bAqFNf3bKSJCeVWaDsVqy -YqdERyyqtXzz9lsOlLlnsOuqiC8uJna9xypbhiFlNGtKmOKnJ34uCzSoXlZy8Vdp -sjlkRxKEWt971IT0KDnC8+du2Y1XvnyNol29wRvq7L4iBtlvL3PObuYIqFPGo2C6 -AOiqRCqgekSnd0YjP1EHAIn7ltW5KZIxsZ1beIIvpUaAvKwLroozydy66Dw2gMl0 -OuqDDWYVAs+5njReBC1pG8eIuYP6BTHnwQ8g7kw4mle+w77V+gqo0VIqgbivynyn -V0SP0naRAJ31pVhNpg9KjxkmCrHxkfbH47Egvn0I2a8Qlh5rW3AVAgMBAAGjWDBW -MBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQUjC3lp5jhuLoLJySTIg67 -otB0xuAwHwYDVR0jBBgwFoAUcjFqs35tU0JvDB97G0nCHRxZ8rkwDQYJKoZIhvcN -AQELBQADggEBAKBylW71KVI/lbRfBUIZ8eUA5ZkXy5dYbGxC/utL6I0J5GsjnL5x -ShfBdBrIjY9da7b5ep365P56KG6KwG+9XWATtY6pRiCKbHPgpMxCye7ksF0fFFwP -UO7W0ayVnDpl1DqBqLtH+/l70+rfN82JFBmI75Djeof+LsyMeAR8aQB8Ii0ssHq1 -t9JJJjeO5vElr7i34gxKoYUuvp5wIGn1Nc06GOYykwGyJyxviXIF6MYDeWrrjYyC -l07YAAsMLt/deAS6WDQHGRE3eu8tDyFT7I4PnlAwzjXuG9lwkByGSPxeZEh4mRa7 -fSebkXFmB/NYJVg9mxOM0Ioz0j1pK6I7U7Q= +MIIFRTCCAy2gAwIBAgIUSkDnhXPIILj61plhJa+x1YP8Z6cwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQR3JlcHRpbWVEQlJvb3RDQTAeFw0yNjA5MTYxMzAzMzZa +Fw0zNjA5MTMxMzAzMzZaMBUxEzARBgNVBAMMCmdyZXB0aW1lZGIwggIiMA0GCSqG +SIb3DQEBAQUAA4ICDwAwggIKAoICAQDVvnPBkkqibs5OrTNMKzsjea/2IY1NWv60 +O7GEGUga+cNt0vECF67hGOtFsO85+EoM8k193/dho+OCb11oza6PE/rentTfMipY +w2VPV/8g6cuvqYlujONvduuLJY0GfHp9KEDt6XO23UcvXpvwFsOKMek1jutUTMNL +jSuOn+vRKsrV//np5ypWSI/2vBBiITTgdzZKfFdkO77p6LKGGPN29m5tZVhb65Z7 +Vy6iJp5Bjpyu3j2feIKDtJyHePMCO7GJyJRddF2gpjAnernJI+ObWFdZenDhv3FG +xHyXqVdwsYDDLCsafQbHho4VXQcO3krBA1KN449gjnbv++KUPjP8rlfGKeXI/3Ka +/wKePhjgCI4I7EsF34Bua52AeoczvTv8uPqkrbhsqqcj1cB5h0mIaIKiGqMyNKhz +gDTcQ/4yz4uF6wlNiD3Bs5BxhwaA3u+TKp6VzxwLDy/2MJG0plI5aglFNTBKbuYh +XMDq4OIehg3cuSGyS0RfQp+5akpC9lCbxFecpRTKY81hBBlJBdcRZ2R4htMYM01s +BYa/Qa5KDSe8kKO0DBu1TonTroplqulDxUUV5wc+9Hk8yMKIp0m2OLzcslsZHJMe +0g5xnvBVaTjwXLZLmQeht5YUXB2pYny83omEcQBxdd4qaSi3WYq4L8/d4t4x+ft+ +q5tgUZesMwIDAQABo4GGMIGDMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMBMGA1Ud +JQQMMAoGCCsGAQUFBwMCMBQGA1UdEQQNMAuCCWxvY2FsaG9zdDAdBgNVHQ4EFgQU +oi+s++Rc5jGNaHYGSp3+vnhWyUUwHwYDVR0jBBgwFoAUecaZaPw6wTbDM7sipqM6 +8MkZhO8wDQYJKoZIhvcNAQELBQADggIBACsjiHkTo3KrwjRkQPjj4RipRg+O+nkN +vy33TDf4Ouj3AO1cMYdbYOvC933arg4wys2QL/cqg8oiqkUKqhdNweFS1yTtzeDv +eyqWxYBD9fUV0iO+pOEX0uuBlBY4g8+SMmz4GQijcQEUiyEttNb2mEwtEeu2k7/k +GtZyRv/+qaeQt/rA/uuUibvE77y/Q8N2RIXkEEAmVXTuSpuN53+wiam0SNJyhxTj +CIId2njhMQcn+X/EnSWWWaaC5HqFNVwd1gx9laN3rMi11RKCn2reSjLclxLGX9vJ +Ri/bwX8IGPkhNRP66BiNnQaWqwxK/pfAWAAw1eHgO/oYzx4InVf0a7LkDy6g5+gU +mM75p5/luVXWhUnw9UzopJM7iaI4TUeGu1fII+M2z8cq45Njbo9DrakdiBnIxZjJ +zp+PcGbeEq0t9oujlNPQYmtMTD9EVxDHxXTcJe6faX3dc0pY0NM5h1fpvG7I/F3K +6TGjikBVzQH8O5xso73NFYvwSXuHj9oXLoFV4Rtvkf/VoxQ9nFcyj4lG0J9uofhL +hUNZ39dXrDKb6+OSKrWaLgWTTFK+X+uEjkak4GguM3BCZeXqCDUCfChI7Av8XsLq +EqfYgMaaBlo4kgglOZqfLl2Rp8Z07en8Xuuo6Tc8d9FJzG39sCPwAuXz5s/zutcp +G8ZBd3h+78oR -----END CERTIFICATE----- diff --git a/tests-integration/fixtures/certs/client.key b/tests-integration/fixtures/certs/client.key index 671c15b7cc0..f5ff22bf5ad 100644 --- a/tests-integration/fixtures/certs/client.key +++ b/tests-integration/fixtures/certs/client.key @@ -1,28 +1,52 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD3WM82qlcPut5b -AqFNf3bKSJCeVWaDsVqyYqdERyyqtXzz9lsOlLlnsOuqiC8uJna9xypbhiFlNGtK -mOKnJ34uCzSoXlZy8VdpsjlkRxKEWt971IT0KDnC8+du2Y1XvnyNol29wRvq7L4i -BtlvL3PObuYIqFPGo2C6AOiqRCqgekSnd0YjP1EHAIn7ltW5KZIxsZ1beIIvpUaA -vKwLroozydy66Dw2gMl0OuqDDWYVAs+5njReBC1pG8eIuYP6BTHnwQ8g7kw4mle+ -w77V+gqo0VIqgbivynynV0SP0naRAJ31pVhNpg9KjxkmCrHxkfbH47Egvn0I2a8Q -lh5rW3AVAgMBAAECggEAIAUbLhR3GM40IswAXav2kNE8B4M+tiWrfBRRWwFrwt2j -w2FNAGI4bjf1Cip/ERZhoMe7sAV5JUTX7txD2ye9qfBBbNppjo+3pTMxQs4Ak6m/ -7KPiYkki0R4KA8ueBJHgReWTBRGOr6beJkGEvGQENeEdXlmMHtbfmrSraf9RFVz9 -h3dMJiYmn3Nlf/3mKI7cYr07ed8AH5tjiUkRxQz4RvUDkMNMoXiPBmxqrW0cDmcW -emYOFa+oSIqFQObVnhiEX9tKoGb8n/JmCzJhMcdWKEf8qdNAp28s3iZ6NOfAhcAP -/uEATvqAFlSvy9IeglJ5lw1fW/yMhnsJsSTZfdM0AQKBgQD8BTvKAvgUEVyagsWS -dJCcb5PVIUzTlUq4aYeCqEZVegZHyveHPH0rWqaCg+b5N72CxrLX1GovNFMYRWh0 -g6g2tnn5ZPMjxkT/6K194mBFtl5pRY7wYYYxenDXYetGlGR8E2J+rZjrYB8X/nUc -fB8QqHPF4hdNn8JDDRkxS9JMQQKBgQD7QK8BF2XfP+3k/VyalDovCptcl4QX5Lfh -Wg5TU8EewscBgvwCrsiU1j9H9MbJshYzrZ9tkE7cCvAZQMeCdZMZmNixM8kJlIUz -N3ot/0rVvE/CGH0l4tnUP4Hf3F965Q5hZx6LaeTa/T42fNwfKWEos690MhpvpqkZ -VIUsxVp+1QKBgArSNrT/16MU88L2poEZXDQ7Xj+ihGf5WxH8BgOIMiztZmOixWob -IMzBwbCM9VfyGsMIwRkfV8fbGU5LfKa0GH7w6t/Yg9AfbUsHtJtZIwUbRYaDpBWS -Om7ZcQrue2m2aXIiavJBhfVlVTB0jpbAhZzzfrUadiGxIGg8ob5KISeBAoGBAOS9 -qdXziFJssFeGdpVgl2wWWwU8CA18tA+c3mfsW2h26ZituGN03rXqK+iFp6ke0emw -ivHDrJfV+Wr0N+7EvexS5AzH2aI4LeBIBJr1zhqye2Z4xw8D7U63lrTIu8B14ta4 -e+1uteTNI6OVnw1brdHRbM37g2fcshyWfzwYXGAVAoGAd5NcD+8cGEsEF1we9NVG -jrCCFHaJqga+fsNDPSwtyRBa0gcpyn+JSkmew7UcMC8xsXDWaRJYslz95j6JRIpb -aLO5P9m6yjK+IzwyYHO1YqB4f1JZPZo6I0D74RGWQJrTIWUgf1eWQq3PvzOM+BRp -xL68Sc3xmbloGYxY9m8n8fU= +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 +h4gAFxrXO0kT5EIyoB5LTDf17QW1KgE= -----END PRIVATE KEY----- diff --git a/tests-integration/fixtures/certs/root.crt b/tests-integration/fixtures/certs/root.crt index eba9c77a4ed..7ea00ae6b1e 100644 --- a/tests-integration/fixtures/certs/root.crt +++ b/tests-integration/fixtures/certs/root.crt @@ -1,77 +1,30 @@ -Certificate: - Data: - Version: 3 (0x2) - Serial Number: - 24:00:0d:99:42:39:90:76:c8:97:26:b9:54:6a:02:c7:ac:77:05:b7 - Signature Algorithm: sha256WithRSAEncryption - Issuer: CN = GreptimeDBRootCA - Validity - Not Before: Sep 16 11:37:56 2025 GMT - Not After : Sep 16 11:37:56 2026 GMT - Subject: CN = GreptimeDBRootCA - Subject Public Key Info: - Public Key Algorithm: rsaEncryption - Public-Key: (2048 bit) - Modulus: - 00:d3:38:dc:73:16:e7:4e:54:75:f6:39:37:b1:25: - 89:2b:e4:ba:26:2f:2f:b6:d1:3c:7b:c7:f1:54:a1: - 74:9d:d6:55:56:1a:e6:b3:ec:e8:d9:db:e7:47:46: - f9:02:48:ee:d8:09:c6:52:7b:5d:49:5b:40:8a:27: - 23:49:ee:66:3a:7c:19:38:3a:ae:ac:a8:d8:75:a9: - f5:96:d2:34:9a:ba:50:ff:05:27:ae:3b:3b:19:9c: - 30:58:1d:03:9f:a0:10:97:1f:ac:f7:65:d2:cd:ff: - 9f:10:72:40:3b:48:02:c7:b7:60:cb:7b:ca:cb:2d: - 27:a3:a3:84:7f:86:a4:62:e2:1b:c5:bd:4d:f0:56: - 3b:79:f7:fc:79:c9:da:5f:46:f4:36:f6:fe:02:8e: - 65:8b:a3:2f:4d:ac:f4:9e:ce:1b:a5:69:aa:ef:6d: - 84:58:39:02:b4:29:46:bf:df:22:91:d3:1d:80:22: - 2c:22:09:9d:1b:e5:5f:f7:81:14:91:b6:ae:9d:d1: - da:f5:f1:5a:28:a4:f9:78:76:af:0f:cc:f0:2a:9d: - e2:72:e1:ea:15:88:c6:50:c0:59:94:66:de:94:2e: - 7d:dd:be:76:8f:5e:7d:19:52:1b:1a:55:9c:77:8f: - 38:30:c4:17:6d:fd:d2:bd:51:80:60:0c:33:1c:19: - f9:bf - Exponent: 65537 (0x10001) - X509v3 extensions: - X509v3 Subject Key Identifier: - 72:31:6A:B3:7E:6D:53:42:6F:0C:1F:7B:1B:49:C2:1D:1C:59:F2:B9 - X509v3 Authority Key Identifier: - 72:31:6A:B3:7E:6D:53:42:6F:0C:1F:7B:1B:49:C2:1D:1C:59:F2:B9 - X509v3 Basic Constraints: critical - CA:TRUE - Signature Algorithm: sha256WithRSAEncryption - Signature Value: - be:8d:13:94:bd:9c:57:31:9b:80:88:5f:40:5b:fe:60:c3:7f: - a3:b3:e0:7a:41:db:3b:50:9e:63:50:46:f8:14:8a:15:01:b7: - e1:fb:66:93:2a:c5:fc:35:91:cd:30:d2:46:b3:93:f5:6c:25: - da:ee:22:35:be:9c:04:d8:40:3d:40:8d:67:09:dd:d6:fb:52: - 4e:07:7f:cf:1a:5a:c0:8b:2c:6f:56:3b:8c:e4:c1:db:1a:e9: - 37:93:44:43:18:7e:2d:5f:e2:bc:03:24:7a:c2:d9:6a:14:51: - 53:0d:81:c0:1d:1c:04:89:a0:7a:2d:54:23:d8:b3:36:25:b1: - 60:f5:71:e2:07:ab:62:49:9e:1a:f4:ef:37:91:1b:95:99:1f: - f9:58:59:54:0d:86:4b:9b:f4:18:fb:34:36:ce:6a:89:54:24: - fa:92:d5:e5:b2:80:c9:8a:b7:aa:86:fc:be:51:49:b1:ce:8f: - a6:be:e9:c7:d8:2e:6d:17:19:7e:e2:7d:a6:d0:38:2a:43:c8: - 8d:bf:5c:4c:42:80:1d:36:32:c4:5c:f0:82:8c:89:8b:da:d4: - 0d:c2:e7:65:af:ff:3b:d7:f5:e3:c4:3c:34:6d:e4:71:eb:fc: - 8f:fc:54:08:f6:d6:4a:06:36:3d:5b:2b:3c:1f:a6:97:88:9d: - e4:b2:25:49 -----BEGIN CERTIFICATE----- -MIIDFzCCAf+gAwIBAgIUJAANmUI5kHbIlya5VGoCx6x3BbcwDQYJKoZIhvcNAQEL -BQAwGzEZMBcGA1UEAwwQR3JlcHRpbWVEQlJvb3RDQTAeFw0yNTA5MTYxMTM3NTZa -Fw0yNjA5MTYxMTM3NTZaMBsxGTAXBgNVBAMMEEdyZXB0aW1lREJSb290Q0EwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDTONxzFudOVHX2OTexJYkr5Lom -Ly+20Tx7x/FUoXSd1lVWGuaz7OjZ2+dHRvkCSO7YCcZSe11JW0CKJyNJ7mY6fBk4 -Oq6sqNh1qfWW0jSaulD/BSeuOzsZnDBYHQOfoBCXH6z3ZdLN/58QckA7SALHt2DL -e8rLLSejo4R/hqRi4hvFvU3wVjt59/x5ydpfRvQ29v4CjmWLoy9NrPSezhulaarv -bYRYOQK0KUa/3yKR0x2AIiwiCZ0b5V/3gRSRtq6d0dr18VoopPl4dq8PzPAqneJy -4eoViMZQwFmUZt6ULn3dvnaPXn0ZUhsaVZx3jzgwxBdt/dK9UYBgDDMcGfm/AgMB -AAGjUzBRMB0GA1UdDgQWBBRyMWqzfm1TQm8MH3sbScIdHFnyuTAfBgNVHSMEGDAW -gBRyMWqzfm1TQm8MH3sbScIdHFnyuTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 -DQEBCwUAA4IBAQC+jROUvZxXMZuAiF9AW/5gw3+js+B6Qds7UJ5jUEb4FIoVAbfh -+2aTKsX8NZHNMNJGs5P1bCXa7iI1vpwE2EA9QI1nCd3W+1JOB3/PGlrAiyxvVjuM -5MHbGuk3k0RDGH4tX+K8AyR6wtlqFFFTDYHAHRwEiaB6LVQj2LM2JbFg9XHiB6ti -SZ4a9O83kRuVmR/5WFlUDYZLm/QY+zQ2zmqJVCT6ktXlsoDJireqhvy+UUmxzo+m -vunH2C5tFxl+4n2m0DgqQ8iNv1xMQoAdNjLEXPCCjImL2tQNwudlr/871/XjxDw0 -beRx6/yP/FQI9tZKBjY9Wys8H6aXiJ3ksiVJ +MIIFJzCCAw+gAwIBAgIUM8QE5q9TLE1CXDA9Eou/2MgIK7wwDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQR3JlcHRpbWVEQlJvb3RDQTAeFw0yNjA5MTYxMzAzMzVa +Fw0zNjA5MTMxMzAzMzVaMBsxGTAXBgNVBAMMEEdyZXB0aW1lREJSb290Q0EwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDZamy+GMjnCKpglKq/uTVYzfCg +AohHfNrYg5Io6WJUsXcld4ZfBa9QYQUTha2Sc41HIM5e2BX5N4H7kvJEt+adT0gt +h3gSudlGIEldUETxJYeRlPPWFPp0D/cT2TNJhANDyqu74xnLgoYxCQgXVzOIr2J9 +nGYB1fWrZuxZn4rtENvl53Ut/xPi9LuS9rvREmwcP86A4KrnR32sN8fQFxkyhe9u +/IY6e/Imxk445ejyk7n/Nff63nAanHtnP5YziSDj7nQK6ApRzOS6kSBOIynhqNx8 +7BegYIEnVd4AmQsIvlTpHeI8A3PyK45vXXFcTnJHuY4yKhzpvi0sd+rlDtEjayyf +JY8nFKrNTAZid+Ip0r5GGZDF07hn+XcwZiQMQdNnvZevLO+hzHpGnNfuk9vNzgxv +TDYet7mtl3Ryx/oSW8hM+ptcUJ/ui9G4T420NJFBDyLyjp/4I0Cg8NdguAnJULTd +ZfMxyXnXRMeV+ekjDOmswR5LfrGkPErBn24ZT0Rj+GzBcnlsosyUvLGD0bBwg+MS ++hQJ5UiXfosmLZ0z4DNmiJNER5G/+TEcsb4lKc9t0tmT+s/SsecSh/e1yEdzuLyI +0a41KdaQvNbsK1KlJUd1deezxnrLSa1L8IrYHDlERqzevSgqFgliM0yostHV12y5 +4O8dZ6R3ZxKglgNlLQIDAQABo2MwYTAdBgNVHQ4EFgQUecaZaPw6wTbDM7sipqM6 +8MkZhO8wHwYDVR0jBBgwFoAUecaZaPw6wTbDM7sipqM68MkZhO8wDwYDVR0TAQH/ +BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggIBAI9HQGPw +kVAeRTTqHJfeaprxEMX+Me9HP5IOKdQBBKM2YajA8f/rHXBRJIxEW6+NSX6y72eb +fRXNEt3bCiLd7ZKJACcsnGjE5Ot30QbG1TdO6bRp0sXxmcHrYPZAsRoluRs0TMOb +2mkKhkh386KwevAiA8l36BnCvlcq1Re8sNIbG22TNoQtZqa8Da2pOwctZuAGKDPg +ahV9ulIL4eAkoZm8akaPjwwLNWZNB7o4h7H6zMtTHlI71gtQ86lNvu5pKZ9JAhOK +cVP8nFBEayDw7dPK6YgQEV85jPL2mPNTmkLyL2t2olOYrsH2MZxBSYgo86uSRohG +tHj7x8MEo43vJOKSewvrt2RehO6bIq7t4be/Dp5xLBlKp5zCrJFwmAs2n+tyQ3lY +455cbFO0km7IJUvf+6yd+Q5gsCT3aPTdFppGSxLellcc0bA1Qs6vQL48AIFbFJK8 +qcGJNOpXlTieXQUA4pSAxRif8EJ0mWEbDDOpHtA7rSQUS+0ElbaRbBagupXnqNVJ +qTUIBFCeJlw7v4q0gbws2Nmu9xPp97fBrlSpBn0MbIrdp3tL2gFH39lOp6f1OLPM +x8Ejb+WfXrxFWcvWKcfDEo0oaISyiGzjAefQY5ukY3S1eY3AdH74qDQiJODLsBJD +n81vhFD2M5+a5Yusw7GFjvd7QnLu8P5zgLNu -----END CERTIFICATE----- diff --git a/tests-integration/fixtures/certs/root.key b/tests-integration/fixtures/certs/root.key index b46ef7838af..555f324991e 100644 --- a/tests-integration/fixtures/certs/root.key +++ b/tests-integration/fixtures/certs/root.key @@ -1,28 +1,52 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDTONxzFudOVHX2 -OTexJYkr5LomLy+20Tx7x/FUoXSd1lVWGuaz7OjZ2+dHRvkCSO7YCcZSe11JW0CK -JyNJ7mY6fBk4Oq6sqNh1qfWW0jSaulD/BSeuOzsZnDBYHQOfoBCXH6z3ZdLN/58Q -ckA7SALHt2DLe8rLLSejo4R/hqRi4hvFvU3wVjt59/x5ydpfRvQ29v4CjmWLoy9N -rPSezhulaarvbYRYOQK0KUa/3yKR0x2AIiwiCZ0b5V/3gRSRtq6d0dr18VoopPl4 -dq8PzPAqneJy4eoViMZQwFmUZt6ULn3dvnaPXn0ZUhsaVZx3jzgwxBdt/dK9UYBg -DDMcGfm/AgMBAAECggEAHji3ihwP0mxRSH6dUlJdkxMx8z6zJARCFC/ifspvJhpB -sYU91yahlnJZYCBuhn4q9mxHnQubqGBPFrVM3konBF03Oo1i6r9p4UQFKKRA9JaN -+j8hk1NUPHDVlfXoy9EaLpVQqUE/bUwS1UaTnCfMhgjd5595v6JhVo+R6LE6Gg+f -aBfvwZT35pZnDG4JNguxl5jCTr1tnyi3QDQkOIjwP0hyjJlO4RZ6KkBZVtphfaiy -+2jEpovAtS831Imph4rCNZ17dmZ2Hn8n7TswUUT8yASqpgR8Eq88y0S9pqOjbK4Z -wbxGGwzLu8amTcRd3HXErBTykbqj0TcKbE+e0CQomQKBgQD1WIWXxwrVQehuiHqr -nMxbU/RwsLglv2oGJ4/yrobvkRMK7O4zscVKDuRciHrAHL7AlYj6wj1jCE9HjJ6n -LJtaE89xTxKEX6SJkFrHBfzdqZYzMfbbK+vASsFYrkCTcxHqjKhGKtepxjdf5KwD -lXHQsKQklLWORnpzktmwlSkF3QKBgQDcZP2eliaHrLV/uTQqJMO4AHU+1+sm2yXL -EockQ7t6zxydhixVsKcfMei7K+f3G/3geehIInEW+CanJyo6CVc8LTHp/w3jN+Pl -bR2Vhc4M9jhl/JucPpCrpNFb0+eb52IgRoU51HZ6at98fPs1pkEVOEeEXv8BHfPO -w7IHWisqSwKBgAVeTH4Hsur7qDRS9CwxIB+XdK/Kjhz1VUKRNwseWoF/cSU6DfQ/ -nYuDS+fibJ0rg9j+7KJgAavbELEGGKqbxUJxUIawLj2LQjRoXjQgo2nOPiUcoMQm -79+jQxvHpE4VpHy3Wy893L4wQiHtBP6HFJh2ZQEP5/EXBbLlsbG1cdJZAoGBAKf2 -p3rm/7JCo4gVg3Xi0u42GySrtWDFf0YGdsvies0bbrl2ee9MU/FoPe/3hC9FO/SJ -s8bVyM4yjHKzXHEikUMhRnsIkHLsM1i0EenUMbabJ1xCae1nmSXW1F9hXY/phVUH -qZzrGOgu7ueW8HRwogUkAQ0WwMz/IMIhTlpgE6mFAoGAOKktvlw4LpLsM5d18ycI -LPGlXJBMRsbQVkn9F2gBDm1EudFhSz/UWHVrjIjKy1ldNoIXBTP4k/+mZmgL/sHY -9oy1NswlhZqImz6yedudMsudVzu0mRvZqwIK41bqi8ajkp5YTC4fIgKIILysB+YT -FfVzb/lOZCmysmZm3Mfe8Ug= +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 +0qPa/EERUvVLwyx/GENnr1Uks2lgnA== -----END PRIVATE KEY----- diff --git a/tests-integration/fixtures/certs/server.crt b/tests-integration/fixtures/certs/server.crt index b004401afde..04ee393d352 100644 --- a/tests-integration/fixtures/certs/server.crt +++ b/tests-integration/fixtures/certs/server.crt @@ -1,77 +1,31 @@ -Certificate: - Data: - Version: 3 (0x2) - Serial Number: - 21:a9:23:bb:36:e6:40:b3:e7:54:96:a4:3a:32:ed:8f:38:cc:10:8e - Signature Algorithm: sha256WithRSAEncryption - Issuer: CN = GreptimeDBRootCA - Validity - Not Before: Sep 16 11:37:56 2025 GMT - Not After : Sep 16 11:37:56 2026 GMT - Subject: CN = greptime - Subject Public Key Info: - Public Key Algorithm: rsaEncryption - Public-Key: (2048 bit) - Modulus: - 00:b5:df:d8:cd:4a:f2:6f:d0:1f:75:07:f5:ec:2b: - 44:af:59:94:c1:47:e1:8e:ad:a9:84:00:50:25:7f: - 91:1b:4d:0b:b7:d1:cb:95:7f:f5:83:f5:c1:c2:4c: - 83:6a:2b:99:7b:e6:a8:0a:4d:c5:fc:24:24:17:e1: - 30:df:5f:31:0a:97:96:9c:1f:0f:10:63:a2:6d:be: - 7c:f9:09:df:6e:87:bd:a0:94:6c:ee:71:60:a1:c2: - 9a:8c:04:69:eb:7e:0c:46:57:99:30:a3:46:89:d2: - f2:8e:7e:5b:be:07:77:07:22:bd:6b:4a:6b:dd:40: - e5:d2:bd:a7:d7:18:49:4b:d9:6b:73:64:48:b8:29: - 05:64:da:88:9b:2c:f4:eb:88:5e:e7:3e:f7:81:28: - 55:56:cb:df:07:23:20:49:39:a3:d8:c7:cf:d1:23: - 2b:f2:72:e1:95:1d:1c:72:ac:e7:60:09:40:c2:be: - 5c:c0:50:d7:79:6c:28:61:5c:55:b2:2a:2c:99:6b: - 29:4c:f7:4d:17:bc:d5:ba:44:56:0e:f0:61:24:42: - 05:e2:c0:77:c2:19:53:82:10:c0:31:a7:8f:01:88: - 71:87:46:d5:2b:00:75:33:06:3d:e3:bf:ff:dc:a9: - 6b:63:8f:14:60:88:a7:c6:85:18:4a:33:31:56:fb: - 1e:cb - Exponent: 65537 (0x10001) - X509v3 extensions: - X509v3 Subject Alternative Name: - DNS:localhost, IP Address:127.0.0.1 - X509v3 Subject Key Identifier: - 0D:54:D1:9C:02:61:1C:37:23:8C:E2:1D:4F:F6:00:66:C0:FD:97:54 - X509v3 Authority Key Identifier: - 72:31:6A:B3:7E:6D:53:42:6F:0C:1F:7B:1B:49:C2:1D:1C:59:F2:B9 - Signature Algorithm: sha256WithRSAEncryption - Signature Value: - 5d:61:15:7d:9e:60:05:bb:2c:e6:b0:a4:eb:9b:15:91:00:d3: - 91:65:b6:87:bd:29:ce:17:93:8b:81:fb:c3:ed:97:6e:c9:93: - 1d:5b:0d:35:1e:a8:cd:9d:ca:ae:70:84:cc:af:c6:ff:a9:d3: - b1:1c:cc:da:90:15:7b:85:19:80:3d:c9:dc:94:5d:28:fa:c5: - 2d:32:6e:48:d9:f1:74:62:18:d0:f7:9d:3a:80:8b:75:7a:4d: - 4a:a7:7a:8a:b4:28:d4:6d:6e:98:d6:96:4d:fc:a0:b1:ee:89: - 35:b8:3f:b7:07:29:34:dc:dc:be:c9:eb:6d:fd:fd:86:3d:11: - e1:8c:3e:00:84:91:15:ef:d5:c9:6a:ec:31:73:02:fb:81:ef: - 1a:c9:7f:62:a0:7f:8e:d0:d7:f0:b4:59:ba:a3:3f:e8:fb:04: - b3:ad:f9:9a:4e:ee:11:64:ca:a5:5a:4f:4a:36:ad:0e:55:82: - 37:6e:8b:6f:b3:a6:89:e0:a9:45:df:f4:1f:d3:7c:76:bd:d8: - a4:9d:44:7d:89:70:76:b4:4f:5f:02:99:13:3c:d5:11:d5:6f: - ca:f8:7a:26:a1:c3:d9:c1:cb:3a:89:7c:97:bf:0d:90:0d:78: - 37:ec:cb:6d:0f:13:a8:8f:f1:3e:f6:a9:82:22:1e:97:9c:e4: - 0f:8d:6c:3e -----BEGIN CERTIFICATE----- -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 +RKSX3LpRl9tau6O+oV8= -----END CERTIFICATE----- diff --git a/tests-integration/fixtures/certs/server.key b/tests-integration/fixtures/certs/server.key index 16282cfd409..f2ce37dbc2c 100644 --- a/tests-integration/fixtures/certs/server.key +++ b/tests-integration/fixtures/certs/server.key @@ -1,28 +1,52 @@ -----BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC139jNSvJv0B91 -B/XsK0SvWZTBR+GOramEAFAlf5EbTQu30cuVf/WD9cHCTINqK5l75qgKTcX8JCQX -4TDfXzEKl5acHw8QY6Jtvnz5Cd9uh72glGzucWChwpqMBGnrfgxGV5kwo0aJ0vKO -flu+B3cHIr1rSmvdQOXSvafXGElL2WtzZEi4KQVk2oibLPTriF7nPveBKFVWy98H -IyBJOaPYx8/RIyvycuGVHRxyrOdgCUDCvlzAUNd5bChhXFWyKiyZaylM900XvNW6 -RFYO8GEkQgXiwHfCGVOCEMAxp48BiHGHRtUrAHUzBj3jv//cqWtjjxRgiKfGhRhK -MzFW+x7LAgMBAAECggEAGJr2d3JVRuqZkY6zJvFaSH15EjEJrfDcByoNAxW1aZkh -0GDyBwFvE3GTIMM94DDn1zWxtDsott+jgRY2iMcVw8DAX9IVNVAVcH1zO4n/ZV0N -5YSkZsSF9ecgW3hZZW0BzMkbG35raPKrXWXGPvjpkdiRrHXpY98yIzGvXE/tJ/VA -nuXdopFQf8ZRI0qZ29e+zO14xGS0kVD4cPfL5CKboI1dosR2MK1AtH69MY7itZXV -aut0OSY0enHxHGyraZMUOxs4plviGqSTgCsE7K5dBwTYIkoKpbwU7TKob+eyySpW -pO5Xn4gnOkMDj2ePd8mVhg5nuhqc3c2+hRJzayfIGQKBgQD/sR3mWo08DkoI1bpT -K/EkN/WISTIhFDUsAoHtQw6vtDVY0Fmo+AjHqtipNaqvrrOMDmfASC3O4PWZqlJ6 -Vwr1cECts6qpQqEw5Grz8jjOPJJsWkRaQRPyMKSGFwcqvdBNZUlArrdeN7fJVvQf -DrKnfz4IBelA2fzN6HiMKjXLJQKBgQC2F/TtT4AnICTGhaev2sgS6CPcIeh9jyAz -ZmgtvgSxn7UKO8+Ycj62gwD2i5vpbrzDnDQ4EbOOFjabPEQeQgRSIfkbYFiNH3TM -5kq6NpHQ53VqavFBg/Ht6cUl3elpVLZip6UxTMHHABerKBBPvDcJqPl2cF0yTgn9 -UwYYnKiXLwKBgQDnnKPN9vZ6kSMFD71A+8vql/oEovESv8BOhlL0/co+5IlyUwD0 -qwEhBKRP2noru5+OXbojlf5QqLwwnvUpNtGJdE5SSi5sNhvXMwGiyiYkwv+LwNSr -wMl1mBRgFln6oIggo4AQZHNbmDMV4Xsu6txwFzW0hM4+XXe/eTZBGTczNQKBgQCE -isWN4ts+H2iFVA3ts8sD8BMCDXBjzEU9BKJ6ASD7hWtKiqSdqzK4uHx/+LjA3gjq -PQ3JbiELIGIY9nICsDzi57Wh1qw99JKY/luQVV4n+AIANt1qubbkXM9Ss8dNEws6 -nhpg2yUFS6zuuVe+IhrRnjwOmNVNY35j9QWv7n/MowKBgQDw0anDxc1QXvfI5lf8 -CaYhGxSWaxLDD2XEle5gQRpyBBGIzR1sQ2StZdcyeRbRTUI+5xsrC9FSg9fSqhh7 -7hRtt9jIHEQERElWIYgAe/1KdOzvCV+neWBKvjNdv6pinW/z+9dI4sWzrQNbMIo3 -ggjGS2/AuAzFB2Pb1QsfoBHA7Q== +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 +5cgCHJYsFbwfNHAnfVOPT9SA2EaiOA== -----END PRIVATE KEY----- diff --git a/tests/cases/standalone/common/flow/flow_incremental_state_merge.result b/tests/cases/standalone/common/flow/flow_incremental_state_merge.result new file mode 100644 index 00000000000..9a9b23dfef3 --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_state_merge.result @@ -0,0 +1,348 @@ +-- Incremental aggregate state merge over a partitioned source (two regions). +-- A WHERE-filtered flow mixes scalar aggregates (sum, count) with aggregate +-- state columns (hll, stddev_pop_state, uddsketch_state); each later delta in +-- the same time window must merge its state with the state already persisted +-- in the sink. Covers two nonempty deltas across both regions, one round whose +-- delta is empty because the flow's predicate drops the newly inserted row, +-- and a final nonempty delta checked against a direct source aggregation. +CREATE TABLE flow_incr_state_merge_input ( + host_id INT, + user_id BIGINT, + n INT, + v DOUBLE, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id, user_id) +) +PARTITION ON COLUMNS (host_id) ( + host_id < 3, + host_id >= 3 +) +WITH ( + append_mode = 'true' +); + +Affected Rows: 0 + +CREATE FLOW flow_incr_state_merge SINK TO flow_incr_state_merge_sink +WITH (experimental_enable_incremental_read = 'true') +AS +SELECT + sum(n) AS total, + count(n) AS row_count, + hll(user_id) AS user_hll, + stddev_pop_state(v) AS v_stddev_state, + uddsketch_state(128, 0.01, v) AS v_sketch_state, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + flow_incr_state_merge_input +WHERE + v > 0 +GROUP BY + time_window; + +Affected Rows: 0 + +-- ==== Phase 1: initial rows in the same time window, one per region ==== +INSERT INTO flow_incr_state_merge_input VALUES + (1, 1, 1, 1.0, '2024-01-01 00:00:00'), + (3, 2, 2, 2.0, '2024-01-01 00:00:10'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_state_merge') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +-- The first round seeds the sink with a full-snapshot aggregate state. +SELECT + total, + row_count, + time_window +FROM + flow_incr_state_merge_sink +ORDER BY + time_window; + ++-------+-----------+---------------------+ +| total | row_count | time_window | ++-------+-----------+---------------------+ +| 3 | 2 | 2024-01-01T00:00:00 | ++-------+-----------+---------------------+ + +-- Move the checkpointed source rows and the seeded sink state into SST, so +-- the next round has to merge a new delta with persisted state. +ADMIN FLUSH_TABLE('flow_incr_state_merge_input'); + ++--------------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_state_merge_input') | ++--------------------------------------------------+ +| 0 | ++--------------------------------------------------+ + +ADMIN FLUSH_TABLE('flow_incr_state_merge_sink'); + ++-------------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_state_merge_sink') | ++-------------------------------------------------+ +| 0 | ++-------------------------------------------------+ + +-- ==== Phase 2: first nonempty delta, same window, both regions ==== +INSERT INTO flow_incr_state_merge_input VALUES + (2, 3, 3, 3.0, '2024-01-01 00:00:20'), + (4, 4, 4, 4.0, '2024-01-01 00:00:40'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_state_merge') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +-- Scalars accumulate; each state column must merge with the persisted state. +SELECT + total, + row_count, + hll_count(hll_merge(user_hll)) AS users, + ROUND(stddev_pop_calc(stddev_pop_merge(v_stddev_state)), 6) AS v_stddev, + ROUND(uddsketch_calc(0.5, uddsketch_merge(128, 0.01, v_sketch_state)), 4) AS v_p50, + time_window +FROM + flow_incr_state_merge_sink +GROUP BY + total, + row_count, + time_window +ORDER BY + time_window; + ++-------+-----------+-------+----------+--------+---------------------+ +| total | row_count | users | v_stddev | v_p50 | time_window | ++-------+-----------+-------+----------+--------+---------------------+ +| 10 | 4 | 4 | 1.118034 | 2.9742 | 2024-01-01T00:00:00 | ++-------+-----------+-------+----------+--------+---------------------+ + +ADMIN FLUSH_TABLE('flow_incr_state_merge_input'); + ++--------------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_state_merge_input') | ++--------------------------------------------------+ +| 0 | ++--------------------------------------------------+ + +ADMIN FLUSH_TABLE('flow_incr_state_merge_sink'); + ++-------------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_state_merge_sink') | ++-------------------------------------------------+ +| 0 | ++-------------------------------------------------+ + +-- ==== Phase 3: filtered-out row must run one round with an empty delta ==== +-- Record the flow's last execution time before the excluded insert: the +-- comparison below proves the empty delta was actually evaluated. +CREATE TABLE flow_incr_state_merge_last_exec ( + marker STRING, + last_exec TIMESTAMP TIME INDEX +) WITH ( + append_mode = 'true' +); + +Affected Rows: 0 + +-- The background loop runs on its own interval; keep waiting until it has +-- executed at least once so information_schema.flows.last_execution_time is +-- non-null before snapshotting it. +-- SQLNESS SLEEP 8s +INSERT INTO flow_incr_state_merge_last_exec (marker, last_exec) +SELECT + 'before_empty_delta', + last_execution_time +FROM + information_schema.flows +WHERE + flow_name = 'flow_incr_state_merge'; + +Affected Rows: 1 + +-- The row's timestamp is inside the existing window, so the source write still +-- dirties that window; the flow's WHERE predicate then drops the row from the +-- delta, leaving the delta empty for the window. +INSERT INTO flow_incr_state_merge_input VALUES + (1, 9, 9, -1.0, '2024-01-01 00:00:30'); + +Affected Rows: 1 + +-- SQLNESS SLEEP 6s +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_state_merge') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +-- An empty delta must leave the persisted sink states untouched. +SELECT + total, + row_count, + hll_count(hll_merge(user_hll)) AS users, + ROUND(stddev_pop_calc(stddev_pop_merge(v_stddev_state)), 6) AS v_stddev, + ROUND(uddsketch_calc(0.5, uddsketch_merge(128, 0.01, v_sketch_state)), 4) AS v_p50, + time_window +FROM + flow_incr_state_merge_sink +GROUP BY + total, + row_count, + time_window +ORDER BY + time_window; + ++-------+-----------+-------+----------+--------+---------------------+ +| total | row_count | users | v_stddev | v_p50 | time_window | ++-------+-----------+-------+----------+--------+---------------------+ +| 10 | 4 | 4 | 1.118034 | 2.9742 | 2024-01-01T00:00:00 | ++-------+-----------+-------+----------+--------+---------------------+ + +-- last_execution_time must have advanced past the mark taken above, proving +-- the dirty window triggered a new round even though its delta was empty. A +-- NULL comparison means the mark never advanced: fail explicitly instead of +-- silently treating it as "unchanged". +INSERT INTO flow_incr_state_merge_last_exec (marker, last_exec) +SELECT + 'after_empty_delta', + last_execution_time +FROM + information_schema.flows +WHERE + flow_name = 'flow_incr_state_merge'; + +Affected Rows: 1 + +SELECT + ( + SELECT + last_exec + FROM + flow_incr_state_merge_last_exec + WHERE + marker = 'after_empty_delta' + ) > ( + SELECT + last_exec + FROM + flow_incr_state_merge_last_exec + WHERE + marker = 'before_empty_delta' + ) AS empty_delta_executed; + ++----------------------+ +| empty_delta_executed | ++----------------------+ +| true | ++----------------------+ + +-- Flush the input before the final delta so each phase's delta is isolated in +-- its own round; the background loop may have already consumed the last +-- insert, which is fine — the values below only depend on the merge outcome. +ADMIN FLUSH_TABLE('flow_incr_state_merge_input'); + ++--------------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_state_merge_input') | ++--------------------------------------------------+ +| 0 | ++--------------------------------------------------+ + +ADMIN FLUSH_TABLE('flow_incr_state_merge_sink'); + ++-------------------------------------------------+ +| ADMIN FLUSH_TABLE('flow_incr_state_merge_sink') | ++-------------------------------------------------+ +| 0 | ++-------------------------------------------------+ + +-- ==== Phase 4: final nonempty delta, same window, both regions ==== +INSERT INTO flow_incr_state_merge_input VALUES + (1, 5, 5, 5.0, '2024-01-01 00:00:05'), + (3, 6, 6, 6.0, '2024-01-01 00:00:55'); + +Affected Rows: 2 + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + ++-------------------------------------------+ +| ADMIN FLUSH_FLOW('flow_incr_state_merge') | ++-------------------------------------------+ +| FLOW_FLUSHED | ++-------------------------------------------+ + +-- Merge the final delta with the persisted states and compare the merged +-- result against a direct aggregation over the accepted source rows. +SELECT + merged.total = direct.total AS total_matches, + merged.row_count = direct.row_count AS count_matches, + merged.users = direct.users AS hll_matches, + abs(merged.v_stddev - direct.v_stddev) < 1e-9 AS stddev_matches, + ROUND(merged.v_p50, 4) = ROUND(direct.v_p50, 4) AS p50_matches +FROM + ( + SELECT + total, + row_count, + hll_count(hll_merge(user_hll)) AS users, + stddev_pop_calc(stddev_pop_merge(v_stddev_state)) AS v_stddev, + uddsketch_calc(0.5, uddsketch_merge(128, 0.01, v_sketch_state)) AS v_p50 + FROM + flow_incr_state_merge_sink + GROUP BY + total, + row_count + ) AS merged, + ( + SELECT + sum(n) AS total, + count(n) AS row_count, + hll_count(hll(user_id)) AS users, + stddev_pop(v) AS v_stddev, + uddsketch_calc(0.5, uddsketch_state(128, 0.01, v)) AS v_p50 + FROM + flow_incr_state_merge_input + WHERE + v > 0 + ) AS direct; + ++---------------+---------------+-------------+----------------+-------------+ +| total_matches | count_matches | hll_matches | stddev_matches | p50_matches | ++---------------+---------------+-------------+----------------+-------------+ +| true | true | true | true | true | ++---------------+---------------+-------------+----------------+-------------+ + +-- Clean up +DROP FLOW flow_incr_state_merge; + +Affected Rows: 0 + +DROP TABLE flow_incr_state_merge_input; + +Affected Rows: 0 + +DROP TABLE flow_incr_state_merge_sink; + +Affected Rows: 0 + +DROP TABLE flow_incr_state_merge_last_exec; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/flow/flow_incremental_state_merge.sql b/tests/cases/standalone/common/flow/flow_incremental_state_merge.sql new file mode 100644 index 00000000000..a48b39e9fd2 --- /dev/null +++ b/tests/cases/standalone/common/flow/flow_incremental_state_merge.sql @@ -0,0 +1,232 @@ +-- Incremental aggregate state merge over a partitioned source (two regions). +-- A WHERE-filtered flow mixes scalar aggregates (sum, count) with aggregate +-- state columns (hll, stddev_pop_state, uddsketch_state); each later delta in +-- the same time window must merge its state with the state already persisted +-- in the sink. Covers two nonempty deltas across both regions, one round whose +-- delta is empty because the flow's predicate drops the newly inserted row, +-- and a final nonempty delta checked against a direct source aggregation. +CREATE TABLE flow_incr_state_merge_input ( + host_id INT, + user_id BIGINT, + n INT, + v DOUBLE, + ts TIMESTAMP TIME INDEX, + PRIMARY KEY(host_id, user_id) +) +PARTITION ON COLUMNS (host_id) ( + host_id < 3, + host_id >= 3 +) +WITH ( + append_mode = 'true' +); + +CREATE FLOW flow_incr_state_merge SINK TO flow_incr_state_merge_sink +WITH (experimental_enable_incremental_read = 'true') +AS +SELECT + sum(n) AS total, + count(n) AS row_count, + hll(user_id) AS user_hll, + stddev_pop_state(v) AS v_stddev_state, + uddsketch_state(128, 0.01, v) AS v_sketch_state, + date_bin(INTERVAL '1 minute', ts, '2024-01-01 00:00:00') AS time_window +FROM + flow_incr_state_merge_input +WHERE + v > 0 +GROUP BY + time_window; + +-- ==== Phase 1: initial rows in the same time window, one per region ==== +INSERT INTO flow_incr_state_merge_input VALUES + (1, 1, 1, 1.0, '2024-01-01 00:00:00'), + (3, 2, 2, 2.0, '2024-01-01 00:00:10'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + +-- The first round seeds the sink with a full-snapshot aggregate state. +SELECT + total, + row_count, + time_window +FROM + flow_incr_state_merge_sink +ORDER BY + time_window; + +-- Move the checkpointed source rows and the seeded sink state into SST, so +-- the next round has to merge a new delta with persisted state. +ADMIN FLUSH_TABLE('flow_incr_state_merge_input'); + +ADMIN FLUSH_TABLE('flow_incr_state_merge_sink'); + +-- ==== Phase 2: first nonempty delta, same window, both regions ==== +INSERT INTO flow_incr_state_merge_input VALUES + (2, 3, 3, 3.0, '2024-01-01 00:00:20'), + (4, 4, 4, 4.0, '2024-01-01 00:00:40'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + +-- Scalars accumulate; each state column must merge with the persisted state. +SELECT + total, + row_count, + hll_count(hll_merge(user_hll)) AS users, + ROUND(stddev_pop_calc(stddev_pop_merge(v_stddev_state)), 6) AS v_stddev, + ROUND(uddsketch_calc(0.5, uddsketch_merge(128, 0.01, v_sketch_state)), 4) AS v_p50, + time_window +FROM + flow_incr_state_merge_sink +GROUP BY + total, + row_count, + time_window +ORDER BY + time_window; + +ADMIN FLUSH_TABLE('flow_incr_state_merge_input'); + +ADMIN FLUSH_TABLE('flow_incr_state_merge_sink'); + +-- ==== Phase 3: filtered-out row must run one round with an empty delta ==== +-- Record the flow's last execution time before the excluded insert: the +-- comparison below proves the empty delta was actually evaluated. +CREATE TABLE flow_incr_state_merge_last_exec ( + marker STRING, + last_exec TIMESTAMP TIME INDEX +) WITH ( + append_mode = 'true' +); + +-- The background loop runs on its own interval; keep waiting until it has +-- executed at least once so information_schema.flows.last_execution_time is +-- non-null before snapshotting it. +-- SQLNESS SLEEP 8s +INSERT INTO flow_incr_state_merge_last_exec (marker, last_exec) +SELECT + 'before_empty_delta', + last_execution_time +FROM + information_schema.flows +WHERE + flow_name = 'flow_incr_state_merge'; + +-- The row's timestamp is inside the existing window, so the source write still +-- dirties that window; the flow's WHERE predicate then drops the row from the +-- delta, leaving the delta empty for the window. +INSERT INTO flow_incr_state_merge_input VALUES + (1, 9, 9, -1.0, '2024-01-01 00:00:30'); + +-- SQLNESS SLEEP 6s +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + +-- An empty delta must leave the persisted sink states untouched. +SELECT + total, + row_count, + hll_count(hll_merge(user_hll)) AS users, + ROUND(stddev_pop_calc(stddev_pop_merge(v_stddev_state)), 6) AS v_stddev, + ROUND(uddsketch_calc(0.5, uddsketch_merge(128, 0.01, v_sketch_state)), 4) AS v_p50, + time_window +FROM + flow_incr_state_merge_sink +GROUP BY + total, + row_count, + time_window +ORDER BY + time_window; + +-- last_execution_time must have advanced past the mark taken above, proving +-- the dirty window triggered a new round even though its delta was empty. A +-- NULL comparison means the mark never advanced: fail explicitly instead of +-- silently treating it as "unchanged". +INSERT INTO flow_incr_state_merge_last_exec (marker, last_exec) +SELECT + 'after_empty_delta', + last_execution_time +FROM + information_schema.flows +WHERE + flow_name = 'flow_incr_state_merge'; + +SELECT + ( + SELECT + last_exec + FROM + flow_incr_state_merge_last_exec + WHERE + marker = 'after_empty_delta' + ) > ( + SELECT + last_exec + FROM + flow_incr_state_merge_last_exec + WHERE + marker = 'before_empty_delta' + ) AS empty_delta_executed; + +-- Flush the input before the final delta so each phase's delta is isolated in +-- its own round; the background loop may have already consumed the last +-- insert, which is fine — the values below only depend on the merge outcome. + +ADMIN FLUSH_TABLE('flow_incr_state_merge_input'); + +ADMIN FLUSH_TABLE('flow_incr_state_merge_sink'); + +-- ==== Phase 4: final nonempty delta, same window, both regions ==== +INSERT INTO flow_incr_state_merge_input VALUES + (1, 5, 5, 5.0, '2024-01-01 00:00:05'), + (3, 6, 6, 6.0, '2024-01-01 00:00:55'); + +-- SQLNESS REPLACE (ADMIN\sFLUSH_FLOW\('\w+'\)\s+\|\n\+-+\+\n\|\s+)[0-9]+\s+\| $1 FLOW_FLUSHED | +ADMIN FLUSH_FLOW('flow_incr_state_merge'); + +-- Merge the final delta with the persisted states and compare the merged +-- result against a direct aggregation over the accepted source rows. +SELECT + merged.total = direct.total AS total_matches, + merged.row_count = direct.row_count AS count_matches, + merged.users = direct.users AS hll_matches, + abs(merged.v_stddev - direct.v_stddev) < 1e-9 AS stddev_matches, + ROUND(merged.v_p50, 4) = ROUND(direct.v_p50, 4) AS p50_matches +FROM + ( + SELECT + total, + row_count, + hll_count(hll_merge(user_hll)) AS users, + stddev_pop_calc(stddev_pop_merge(v_stddev_state)) AS v_stddev, + uddsketch_calc(0.5, uddsketch_merge(128, 0.01, v_sketch_state)) AS v_p50 + FROM + flow_incr_state_merge_sink + GROUP BY + total, + row_count + ) AS merged, + ( + SELECT + sum(n) AS total, + count(n) AS row_count, + hll_count(hll(user_id)) AS users, + stddev_pop(v) AS v_stddev, + uddsketch_calc(0.5, uddsketch_state(128, 0.01, v)) AS v_p50 + FROM + flow_incr_state_merge_input + WHERE + v > 0 + ) AS direct; + +-- Clean up +DROP FLOW flow_incr_state_merge; + +DROP TABLE flow_incr_state_merge_input; + +DROP TABLE flow_incr_state_merge_sink; + +DROP TABLE flow_incr_state_merge_last_exec; diff --git a/tests/cases/standalone/common/skip_wal.result b/tests/cases/standalone/common/skip_wal.result index a3074779d35..25c18fed20e 100644 --- a/tests/cases/standalone/common/skip_wal.result +++ b/tests/cases/standalone/common/skip_wal.result @@ -54,6 +54,11 @@ SELECT * FROM system_metrics; | host2 | idc_a | 80.0 | 70.3 | 90.0 | 2022-11-03T03:39:57.450 | +-------+-------+----------+-------------+-----------+-------------------------+ +-- A table created without a real WAL provider cannot enable WAL later. +ALTER TABLE system_metrics SET 'skip_wal' = 'false'; + +Error: 1001(Unsupported), Unsupported operation setting skip_wal = 'false' without an existing WAL provider + DROP TABLE system_metrics; Affected Rows: 0 @@ -95,15 +100,45 @@ SHOW CREATE TABLE alter_skip_wal; | | ) | +----------------+-----------------------------------------------+ +-- This row is intentionally not written to WAL. +INSERT INTO alter_skip_wal VALUES ('host2', 2, 2000); + +Affected Rows: 1 + ALTER TABLE alter_skip_wal SET 'skip_wal' = 'false'; -Error: 1004(InvalidArguments), Invalid set table option request: Invalid set region option request, key: skip_wal, value: false +Affected Rows: 0 + +SHOW CREATE TABLE alter_skip_wal; + ++----------------+-----------------------------------------------+ +| Table | Create Table | ++----------------+-----------------------------------------------+ +| alter_skip_wal | CREATE TABLE IF NOT EXISTS "alter_skip_wal" ( | +| | "host" STRING NULL, | +| | "val" DOUBLE NULL, | +| | "ts" TIMESTAMP(3) NOT NULL, | +| | TIME INDEX ("ts"), | +| | PRIMARY KEY ("host") | +| | ) | +| | | +| | ENGINE=mito | +| | WITH( | +| | skip_wal = 'false' | +| | ) | ++----------------+-----------------------------------------------+ + +-- Repeating the transition is idempotent. +ALTER TABLE alter_skip_wal SET 'skip_wal' = 'false'; + +Affected Rows: 0 ALTER TABLE alter_skip_wal UNSET 'skip_wal'; Error: 1004(InvalidArguments), Invalid unset table option request: Invalid set region option request, key: skip_wal -INSERT INTO alter_skip_wal VALUES ('host2', 2, 2000); +-- This row uses the restored WAL provider. +INSERT INTO alter_skip_wal VALUES ('host3', 3, 3000); Affected Rows: 1 @@ -114,9 +149,10 @@ SELECT * FROM alter_skip_wal ORDER BY ts; +-------+-----+---------------------+ | host1 | 1.0 | 1970-01-01T00:00:01 | | host2 | 2.0 | 1970-01-01T00:00:02 | +| host3 | 3.0 | 1970-01-01T00:00:03 | +-------+-----+---------------------+ --- The post-ALTER row can be lost because restart does not flush skip-WAL memtables. +-- Re-enabling WAL does not retroactively write the skipped row to WAL. -- SQLNESS ARG restart=true SHOW CREATE TABLE alter_skip_wal; @@ -133,7 +169,7 @@ SHOW CREATE TABLE alter_skip_wal; | | | | | ENGINE=mito | | | WITH( | -| | skip_wal = 'true' | +| | skip_wal = 'false' | | | ) | +----------------+-----------------------------------------------+ @@ -143,6 +179,7 @@ SELECT * FROM alter_skip_wal ORDER BY ts; | host | val | ts | +-------+-----+---------------------+ | host1 | 1.0 | 1970-01-01T00:00:01 | +| host3 | 3.0 | 1970-01-01T00:00:03 | +-------+-----+---------------------+ DROP TABLE alter_skip_wal; diff --git a/tests/cases/standalone/common/skip_wal.sql b/tests/cases/standalone/common/skip_wal.sql index cdf0d551ab6..2e9cac1f272 100644 --- a/tests/cases/standalone/common/skip_wal.sql +++ b/tests/cases/standalone/common/skip_wal.sql @@ -29,6 +29,9 @@ ADMIN flush_table('system_metrics'); -- SQLNESS ARG restart=true SELECT * FROM system_metrics; +-- A table created without a real WAL provider cannot enable WAL later. +ALTER TABLE system_metrics SET 'skip_wal' = 'false'; + DROP TABLE system_metrics; CREATE TABLE alter_skip_wal ( @@ -45,15 +48,24 @@ ALTER TABLE alter_skip_wal SET 'skip_wal' = 'true'; SHOW CREATE TABLE alter_skip_wal; +-- This row is intentionally not written to WAL. +INSERT INTO alter_skip_wal VALUES ('host2', 2, 2000); + +ALTER TABLE alter_skip_wal SET 'skip_wal' = 'false'; + +SHOW CREATE TABLE alter_skip_wal; + +-- Repeating the transition is idempotent. ALTER TABLE alter_skip_wal SET 'skip_wal' = 'false'; ALTER TABLE alter_skip_wal UNSET 'skip_wal'; -INSERT INTO alter_skip_wal VALUES ('host2', 2, 2000); +-- This row uses the restored WAL provider. +INSERT INTO alter_skip_wal VALUES ('host3', 3, 3000); SELECT * FROM alter_skip_wal ORDER BY ts; --- The post-ALTER row can be lost because restart does not flush skip-WAL memtables. +-- Re-enabling WAL does not retroactively write the skipped row to WAL. -- SQLNESS ARG restart=true SHOW CREATE TABLE alter_skip_wal; diff --git a/tests/compatibility/ci.toml b/tests/compatibility/ci.toml index 817474add13..e56d67a26f2 100644 --- a/tests/compatibility/ci.toml +++ b/tests/compatibility/ci.toml @@ -6,7 +6,7 @@ # exercised by nightly runs (--nightly-window); they are not retained in the PR # window. Run .github/scripts/update-compat-versions.py --update --published-only # after release tags land. -from_versions = ["v1.1.4", "v1.2.0"] +from_versions = ["v1.1.4", "v1.2.1"] # Recent releases that must be able to reopen tables written by the PR build. downgrade_to_versions = ["v1.1.4"]