mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-09-26 23:25:33 +00:00
feat: export logical tables from Metric physical scans (#9159)
* feat: add physical Metric table exporter Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: drain Metric export writes before cancellation cleanup Signed-off-by: jeremyhi <fengjiachun@gmail.com> * perf: construct Metric export error context lazily Signed-off-by: jeremyhi <fengjiachun@gmail.com> * refactor: name the logical table export entry point Signed-off-by: jeremyhi <fengjiachun@gmail.com> * refactor: share Parquet writer for logical table exports Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: preserve Parquet destinations and cancellation boundaries Signed-off-by: jeremyhi <fengjiachun@gmail.com> * refactor: clarify logical table export field names Signed-off-by: jeremyhi <fengjiachun@gmail.com> * refactor: clarify logical table export helper responsibilities Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: validate logical export membership by table ID Signed-off-by: jeremyhi <fengjiachun@gmail.com> * test: simplify logical table export coverage and strengthen assertions Signed-off-by: jeremyhi <fengjiachun@gmail.com> --------- Signed-off-by: jeremyhi <fengjiachun@gmail.com>
This commit is contained in:
Generated
+1
@@ -15229,6 +15229,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tonic 0.14.2",
|
||||
"tower 0.5.2",
|
||||
"url",
|
||||
|
||||
@@ -47,3 +47,4 @@ url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
common-test-util.workspace = true
|
||||
object-store = { workspace = true, features = ["testing"] }
|
||||
|
||||
@@ -27,6 +27,15 @@ use url::ParseError;
|
||||
#[snafu(visibility(pub))]
|
||||
#[stack_trace_debug]
|
||||
pub enum Error {
|
||||
#[snafu(display("Parquet write cancelled"))]
|
||||
ParquetWriteCancelled {},
|
||||
|
||||
#[snafu(display("Parquet writer limits must be positive"))]
|
||||
InvalidParquetWriterLimits {},
|
||||
|
||||
#[snafu(display("Parquet writer resource limit exceeded: {reason}"))]
|
||||
ParquetWriterResource { reason: String },
|
||||
|
||||
#[snafu(display("Unsupported compression type: {}", compression_type))]
|
||||
UnsupportedCompressionType {
|
||||
compression_type: String,
|
||||
@@ -275,6 +284,9 @@ impl ErrorExt for Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
use Error::*;
|
||||
match self {
|
||||
ParquetWriteCancelled {} => StatusCode::Cancelled,
|
||||
InvalidParquetWriterLimits {} => StatusCode::InvalidArguments,
|
||||
ParquetWriterResource { .. } => StatusCode::Suspended,
|
||||
BuildBackend { .. }
|
||||
| ListObjects { .. }
|
||||
| ReadObject { .. }
|
||||
|
||||
@@ -29,22 +29,17 @@ use datafusion::parquet::file::metadata::{
|
||||
use datafusion::physical_plan::SendableRecordBatchStream;
|
||||
use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet;
|
||||
use datafusion_datasource::PartitionedFile;
|
||||
use datatypes::schema::SchemaRef;
|
||||
use futures::StreamExt;
|
||||
use futures::future::BoxFuture;
|
||||
use object_store::{FuturesAsyncReader, ObjectStore};
|
||||
use parquet::arrow::AsyncArrowWriter;
|
||||
use parquet::arrow::arrow_reader::ArrowReaderOptions;
|
||||
use parquet::basic::{Compression, Encoding, ZstdLevel};
|
||||
use parquet::file::properties::{WriterProperties, WriterPropertiesBuilder};
|
||||
use parquet::schema::types::ColumnPath;
|
||||
use snafu::ResultExt;
|
||||
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt, FuturesAsyncWriteCompatExt};
|
||||
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
|
||||
|
||||
use crate::DEFAULT_WRITE_BUFFER_SIZE;
|
||||
use crate::buffered_writer::{ArrowWriterCloser, DfRecordBatchEncoder};
|
||||
use crate::error::{self, Result, WriteObjectSnafu, WriteParquetSnafu};
|
||||
use crate::error::{self, Result};
|
||||
use crate::file_format::FileFormat;
|
||||
use crate::parquet_writer::ParquetFileWriter;
|
||||
use crate::share_buffer::SharedBuffer;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -212,61 +207,23 @@ impl ArrowWriterCloser for ArrowWriter<SharedBuffer> {
|
||||
/// Returns number of rows written.
|
||||
pub async fn stream_to_parquet(
|
||||
mut stream: SendableRecordBatchStream,
|
||||
schema: datatypes::schema::SchemaRef,
|
||||
store: ObjectStore,
|
||||
path: &str,
|
||||
concurrency: usize,
|
||||
) -> Result<usize> {
|
||||
let write_props = column_wise_config(
|
||||
WriterProperties::builder()
|
||||
.set_compression(Compression::ZSTD(ZstdLevel::default()))
|
||||
.set_statistics_truncate_length(None)
|
||||
.set_column_index_truncate_length(None),
|
||||
schema,
|
||||
)
|
||||
.build();
|
||||
let inner_writer = store
|
||||
.writer_with(path)
|
||||
.concurrent(concurrency)
|
||||
.chunk(DEFAULT_WRITE_BUFFER_SIZE.as_bytes() as usize)
|
||||
.await
|
||||
.map(|w| w.into_futures_async_write().compat_write())
|
||||
.context(WriteObjectSnafu { path })?;
|
||||
|
||||
let mut writer = AsyncArrowWriter::try_new(inner_writer, stream.schema(), Some(write_props))
|
||||
.context(WriteParquetSnafu { path })?;
|
||||
let mut writer =
|
||||
ParquetFileWriter::open(stream.schema(), store, path, concurrency, None).await?;
|
||||
let mut rows_written = 0;
|
||||
|
||||
while let Some(batch) = stream.next().await {
|
||||
let batch = batch.context(error::ReadRecordBatchSnafu)?;
|
||||
writer
|
||||
.write(&batch)
|
||||
.await
|
||||
.context(WriteParquetSnafu { path })?;
|
||||
rows_written += batch.num_rows();
|
||||
let rows = batch.num_rows();
|
||||
writer.write(batch, None).await?;
|
||||
rows_written += rows;
|
||||
}
|
||||
writer.close().await.context(WriteParquetSnafu { path })?;
|
||||
writer.finish(None).await?;
|
||||
Ok(rows_written)
|
||||
}
|
||||
|
||||
/// Customizes per-column properties.
|
||||
fn column_wise_config(
|
||||
mut props: WriterPropertiesBuilder,
|
||||
schema: SchemaRef,
|
||||
) -> WriterPropertiesBuilder {
|
||||
// Disable dictionary for timestamp column, since for increasing timestamp column,
|
||||
// the dictionary pages will be larger than data pages.
|
||||
for col in schema.column_schemas() {
|
||||
if col.data_type.is_timestamp() {
|
||||
let path = ColumnPath::new(vec![col.name.clone()]);
|
||||
props = props
|
||||
.set_column_dictionary_enabled(path.clone(), false)
|
||||
.set_column_encoding(path, Encoding::DELTA_BINARY_PACKED)
|
||||
}
|
||||
}
|
||||
props
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use common_test_util::find_workspace_path;
|
||||
|
||||
@@ -12,11 +12,215 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use arrow::datatypes::{DataType, SchemaRef};
|
||||
use arrow::record_batch::RecordBatch;
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
use object_store::Writer;
|
||||
use object_store::{ObjectStore, Writer};
|
||||
use parquet::arrow::ArrowWriter;
|
||||
use parquet::arrow::async_writer::AsyncFileWriter;
|
||||
use parquet::basic::{Compression, Encoding, ZstdLevel};
|
||||
use parquet::errors::ParquetError;
|
||||
use parquet::file::properties::WriterProperties;
|
||||
use parquet::schema::types::ColumnPath;
|
||||
use snafu::{IntoError, ResultExt, ensure};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::DEFAULT_WRITE_BUFFER_SIZE;
|
||||
use crate::error::{self, Result};
|
||||
|
||||
/// Limits for one Parquet file. Flush thresholds are not hard memory caps.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ParquetWriterLimits {
|
||||
/// Maximum rows in a row group.
|
||||
pub row_group_rows: usize,
|
||||
/// Flush when encoder memory or encoded size reaches this threshold.
|
||||
pub flush_threshold_bytes: usize,
|
||||
/// Maximum row groups retained in the file footer.
|
||||
pub max_row_groups: usize,
|
||||
}
|
||||
|
||||
/// Encodes batches on a blocking runtime and writes one object-store file.
|
||||
/// Callers must await each operation before aborting; dropping an in-flight
|
||||
/// operation can leave a filesystem worker running after cleanup.
|
||||
pub struct ParquetFileWriter {
|
||||
encoder: Option<ArrowWriter<Vec<u8>>>,
|
||||
sink: Writer,
|
||||
store: ObjectStore,
|
||||
path: String,
|
||||
limits: Option<ParquetWriterLimits>,
|
||||
}
|
||||
|
||||
impl ParquetFileWriter {
|
||||
/// Open a file using COPY's encoding settings. Destination ownership and
|
||||
/// overwrite policy belong to the caller. None preserves Parquet's defaults.
|
||||
pub async fn open(
|
||||
schema: SchemaRef,
|
||||
store: ObjectStore,
|
||||
path: &str,
|
||||
concurrency: usize,
|
||||
limits: Option<ParquetWriterLimits>,
|
||||
) -> Result<Self> {
|
||||
let mut props = WriterProperties::builder()
|
||||
.set_compression(Compression::ZSTD(ZstdLevel::default()))
|
||||
.set_statistics_truncate_length(None)
|
||||
.set_column_index_truncate_length(None);
|
||||
if let Some(limits) = limits {
|
||||
ensure!(
|
||||
limits.row_group_rows > 0
|
||||
&& limits.flush_threshold_bytes > 0
|
||||
&& limits.max_row_groups > 0,
|
||||
error::InvalidParquetWriterLimitsSnafu
|
||||
);
|
||||
props = props
|
||||
.set_max_row_group_row_count(Some(limits.row_group_rows))
|
||||
.set_max_row_group_bytes(None);
|
||||
}
|
||||
for field in schema.fields() {
|
||||
if matches!(field.data_type(), DataType::Timestamp(_, _)) {
|
||||
let column = ColumnPath::new(vec![field.name().clone()]);
|
||||
props = props
|
||||
.set_column_dictionary_enabled(column.clone(), false)
|
||||
.set_column_encoding(column, Encoding::DELTA_BINARY_PACKED);
|
||||
}
|
||||
}
|
||||
let encoder = ArrowWriter::try_new(Vec::new(), schema, Some(props.build()))
|
||||
.context(error::WriteParquetSnafu { path })?;
|
||||
let sink = store
|
||||
.writer_with(path)
|
||||
.concurrent(concurrency)
|
||||
.chunk(DEFAULT_WRITE_BUFFER_SIZE.as_bytes() as usize)
|
||||
.await
|
||||
.context(error::WriteObjectSnafu { path })?;
|
||||
Ok(Self {
|
||||
encoder: Some(encoder),
|
||||
sink,
|
||||
store,
|
||||
path: path.to_owned(),
|
||||
limits,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a batch, enforcing file limits across batch and row-group boundaries.
|
||||
pub async fn write(
|
||||
&mut self,
|
||||
batch: RecordBatch,
|
||||
cancellation: Option<&CancellationToken>,
|
||||
) -> Result<()> {
|
||||
let mut offset = 0;
|
||||
while offset < batch.num_rows() {
|
||||
check_cancelled(cancellation)?;
|
||||
let mut encoder = self.encoder.take().ok_or_else(|| {
|
||||
error::WriteParquetSnafu { path: &self.path }
|
||||
.into_error(ParquetError::General("Parquet writer is closed".into()))
|
||||
})?;
|
||||
let len = self.limits.map_or(batch.num_rows() - offset, |limits| {
|
||||
(limits.row_group_rows - encoder.in_progress_rows()).min(batch.num_rows() - offset)
|
||||
});
|
||||
let slice = batch.slice(offset, len);
|
||||
let limits = self.limits;
|
||||
let path = self.path.clone();
|
||||
let (encoder, bytes) = common_runtime::spawn_blocking_global(move || {
|
||||
if let Some(limits) = limits {
|
||||
ensure!(
|
||||
encoder.flushed_row_groups().len() < limits.max_row_groups,
|
||||
error::ParquetWriterResourceSnafu {
|
||||
reason: "Parquet row-group metadata budget exceeded"
|
||||
}
|
||||
);
|
||||
}
|
||||
encoder
|
||||
.write(&slice)
|
||||
.context(error::WriteParquetSnafu { path: &path })?;
|
||||
if limits.is_some_and(|limits| {
|
||||
encoder.memory_size() >= limits.flush_threshold_bytes
|
||||
|| encoder.in_progress_size() >= limits.flush_threshold_bytes
|
||||
}) {
|
||||
encoder
|
||||
.flush()
|
||||
.context(error::WriteParquetSnafu { path: &path })?;
|
||||
}
|
||||
// Draining preserves ArrowWriter's cumulative file offsets.
|
||||
let bytes = std::mem::take(encoder.inner_mut());
|
||||
Ok::<_, error::Error>((encoder, bytes))
|
||||
})
|
||||
.await
|
||||
.context(error::JoinHandleSnafu)??;
|
||||
self.encoder = Some(encoder);
|
||||
check_cancelled(cancellation)?;
|
||||
self.write_bytes(bytes).await?;
|
||||
check_cancelled(cancellation)?;
|
||||
offset += len;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_bytes(&mut self, bytes: Vec<u8>) -> Result<()> {
|
||||
if !bytes.is_empty() {
|
||||
self.sink
|
||||
.write(bytes)
|
||||
.await
|
||||
.context(error::WriteObjectSnafu { path: &self.path })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the footer and close the file. Retains the handle for cleanup on error.
|
||||
pub async fn finish(&mut self, cancellation: Option<&CancellationToken>) -> Result<()> {
|
||||
check_cancelled(cancellation)?;
|
||||
let mut encoder = self.encoder.take().ok_or_else(|| {
|
||||
error::WriteParquetSnafu { path: &self.path }
|
||||
.into_error(ParquetError::General("Parquet writer is closed".into()))
|
||||
})?;
|
||||
let path = self.path.clone();
|
||||
let bytes = common_runtime::spawn_blocking_global(move || {
|
||||
encoder
|
||||
.finish()
|
||||
.context(error::WriteParquetSnafu { path: &path })?;
|
||||
Ok::<_, error::Error>(std::mem::take(encoder.inner_mut()))
|
||||
})
|
||||
.await
|
||||
.context(error::JoinHandleSnafu)??;
|
||||
self.write_bytes(bytes).await?;
|
||||
check_cancelled(cancellation)?;
|
||||
self.sink
|
||||
.close()
|
||||
.await
|
||||
.context(error::WriteObjectSnafu { path: &self.path })?;
|
||||
check_cancelled(cancellation)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Abort an exclusively owned file after all in-flight operations have completed.
|
||||
/// If the backend cannot abort, deletion assumes this attempt owns the path.
|
||||
pub async fn abort(mut self) -> Result<()> {
|
||||
let result = self.sink.abort().await;
|
||||
if result
|
||||
.as_ref()
|
||||
.is_err_and(|e| e.kind() == object_store::ErrorKind::Unsupported)
|
||||
{
|
||||
let store = self.store.clone();
|
||||
let path = self.path.clone();
|
||||
// Secure filesystem writers require dropping the handle before deletion.
|
||||
drop(self);
|
||||
store
|
||||
.delete(&path)
|
||||
.await
|
||||
.context(error::WriteObjectSnafu { path })?;
|
||||
} else {
|
||||
result.context(error::WriteObjectSnafu { path: &self.path })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_cancelled(cancellation: Option<&CancellationToken>) -> Result<()> {
|
||||
ensure!(
|
||||
cancellation.is_none_or(|token| !token.is_cancelled()),
|
||||
error::ParquetWriteCancelledSnafu
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bridges opendal [Writer] with parquet [AsyncFileWriter].
|
||||
pub struct AsyncWriter {
|
||||
@@ -50,3 +254,244 @@ impl AsyncFileWriter for AsyncWriter {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow::array::{Int64Array, TimestampMillisecondArray};
|
||||
use common_error::ext::ErrorExt;
|
||||
use common_error::status_code::StatusCode;
|
||||
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
|
||||
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
||||
|
||||
use super::*;
|
||||
use crate::file_format::parquet::stream_to_parquet;
|
||||
|
||||
fn batch() -> RecordBatch {
|
||||
RecordBatch::try_from_iter([
|
||||
(
|
||||
"value",
|
||||
Arc::new(Int64Array::from(vec![Some(1), None, Some(3), Some(4)]))
|
||||
as arrow::array::ArrayRef,
|
||||
),
|
||||
(
|
||||
"ts",
|
||||
Arc::new(TimestampMillisecondArray::from(vec![1, 2, 3, 4])),
|
||||
),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn read(store: &ObjectStore, path: &str) -> ParquetRecordBatchReaderBuilder<Bytes> {
|
||||
ParquetRecordBatchReaderBuilder::try_new(store.read(path).await.unwrap().to_bytes())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn row_and_byte_limits_split_batches() {
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let batch = batch();
|
||||
for (row_group_rows, flush_threshold_bytes, split) in [(2, usize::MAX, 3), (100, 1, 2)] {
|
||||
let mut writer = ParquetFileWriter::open(
|
||||
batch.schema(),
|
||||
store.clone(),
|
||||
"groups.parquet",
|
||||
1,
|
||||
Some(ParquetWriterLimits {
|
||||
row_group_rows,
|
||||
flush_threshold_bytes,
|
||||
max_row_groups: 2,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
writer.write(batch.slice(0, split), None).await.unwrap();
|
||||
writer
|
||||
.write(batch.slice(split, batch.num_rows() - split), None)
|
||||
.await
|
||||
.unwrap();
|
||||
writer.finish(None).await.unwrap();
|
||||
let reader = read(&store, "groups.parquet").await;
|
||||
assert_eq!(reader.metadata().num_row_groups(), 2);
|
||||
for group in reader.metadata().row_groups() {
|
||||
assert_eq!(group.num_rows(), 2);
|
||||
let encodings = group.column(1).encodings().collect::<Vec<_>>();
|
||||
assert!(encodings.contains(&Encoding::DELTA_BINARY_PACKED));
|
||||
assert!(!encodings.contains(&Encoding::RLE_DICTIONARY));
|
||||
assert_eq!(
|
||||
group.column(0).compression(),
|
||||
Compression::ZSTD(ZstdLevel::default())
|
||||
);
|
||||
}
|
||||
let actual = reader
|
||||
.build()
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
arrow::compute::concat_batches(&batch.schema(), &actual).unwrap(),
|
||||
batch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_batch_stops_at_footer_limit_and_can_abort() {
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let batch = batch();
|
||||
let mut writer = ParquetFileWriter::open(
|
||||
batch.schema(),
|
||||
store.clone(),
|
||||
"limited.parquet",
|
||||
1,
|
||||
Some(ParquetWriterLimits {
|
||||
row_group_rows: 2,
|
||||
flush_threshold_bytes: usize::MAX,
|
||||
max_row_groups: 1,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = writer.write(batch, None).await.unwrap_err();
|
||||
assert!(matches!(err, error::Error::ParquetWriterResource { .. }));
|
||||
assert_eq!(err.status_code(), StatusCode::Suspended);
|
||||
writer.abort().await.unwrap();
|
||||
assert!(!store.exists("limited.parquet").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_stream_preserves_values_and_empty_schema() {
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let batch = batch();
|
||||
for (path, batches) in [
|
||||
("copy.parquet", vec![batch.clone()]),
|
||||
("empty.parquet", vec![]),
|
||||
] {
|
||||
let stream = RecordBatchStreamAdapter::new(
|
||||
batch.schema(),
|
||||
futures::stream::iter(batches.clone().into_iter().map(Ok)),
|
||||
);
|
||||
assert_eq!(
|
||||
stream_to_parquet(Box::pin(stream), store.clone(), path, 2)
|
||||
.await
|
||||
.unwrap(),
|
||||
batches.iter().map(RecordBatch::num_rows).sum::<usize>()
|
||||
);
|
||||
let reader = read(&store, path).await;
|
||||
assert_eq!(reader.schema().fields(), batch.schema().fields());
|
||||
let actual = reader
|
||||
.build()
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(actual, batches);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_copy_preserves_untouched_securefs_destination() {
|
||||
let directory = common_test_util::temp_dir::create_temp_dir("copy_existing");
|
||||
let path = directory.path().join("existing.parquet");
|
||||
std::fs::write(&path, b"original bytes").unwrap();
|
||||
let access = crate::object_store::LocalFileAccess::sandboxed(directory.path()).unwrap();
|
||||
let store = crate::object_store::build_backend_for_write(
|
||||
&format!("{}/", directory.path().display()),
|
||||
&Default::default(),
|
||||
&access,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stream = RecordBatchStreamAdapter::new(
|
||||
batch().schema(),
|
||||
futures::stream::iter(vec![Err(datafusion::error::DataFusionError::Execution(
|
||||
"injected input failure".into(),
|
||||
))]),
|
||||
);
|
||||
let err = stream_to_parquet(Box::pin(stream), store, "existing.parquet", 1)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, error::Error::ReadRecordBatch { .. }));
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"original bytes");
|
||||
}
|
||||
|
||||
struct PausedFooterWriter {
|
||||
inner: object_store::layers::mock::oio::Writer,
|
||||
paused: bool,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
closed: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl object_store::layers::mock::oio::Write for PausedFooterWriter {
|
||||
async fn write(&mut self, bytes: object_store::Buffer) -> object_store::Result<()> {
|
||||
if !self.paused {
|
||||
self.paused = true;
|
||||
self.started.notify_one();
|
||||
self.release.notified().await;
|
||||
}
|
||||
self.inner.write(bytes).await
|
||||
}
|
||||
async fn close(&mut self) -> object_store::Result<object_store::layers::mock::Metadata> {
|
||||
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
self.inner.close().await
|
||||
}
|
||||
async fn abort(&mut self) -> object_store::Result<()> {
|
||||
self.inner.abort().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_footer_write_waits_then_aborts_before_close() {
|
||||
use object_store::layers::mock::{MockLayerBuilder, MockWriterFactory};
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let closed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let factory: MockWriterFactory = Arc::new({
|
||||
let (started, release, closed) = (started.clone(), release.clone(), closed.clone());
|
||||
move |_, _, inner| {
|
||||
Box::new(PausedFooterWriter {
|
||||
inner,
|
||||
paused: false,
|
||||
started: started.clone(),
|
||||
release: release.clone(),
|
||||
closed: closed.clone(),
|
||||
})
|
||||
}
|
||||
});
|
||||
let store = ObjectStore::new(object_store::services::Memory::default())
|
||||
.unwrap()
|
||||
.layer(
|
||||
MockLayerBuilder::default()
|
||||
.writer_factory(factory)
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut writer =
|
||||
ParquetFileWriter::open(batch().schema(), store.clone(), "cancel.parquet", 1, None)
|
||||
.await
|
||||
.unwrap();
|
||||
// Force footer bytes through the sink before its close operation.
|
||||
writer.sink = store.writer_with("cancel.parquet").chunk(1).await.unwrap();
|
||||
let cancellation = CancellationToken::new();
|
||||
let result = {
|
||||
let finish = writer.finish(Some(&cancellation));
|
||||
tokio::pin!(finish);
|
||||
tokio::select! {
|
||||
result = &mut finish => panic!("finished before footer write: {result:?}"),
|
||||
_ = started.notified() => {},
|
||||
}
|
||||
cancellation.cancel();
|
||||
assert!(futures::poll!(&mut finish).is_pending());
|
||||
release.notify_one();
|
||||
finish.await
|
||||
};
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error::Error::ParquetWriteCancelled {})
|
||||
));
|
||||
assert!(!closed.load(std::sync::atomic::Ordering::SeqCst));
|
||||
writer.abort().await.unwrap();
|
||||
assert!(!store.exists("cancel.parquet").await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@ axum.workspace = true
|
||||
catalog = { workspace = true, features = ["testing"] }
|
||||
common-meta = { workspace = true, features = ["testing"] }
|
||||
common-test-util.workspace = true
|
||||
object-store = { workspace = true, features = ["testing"] }
|
||||
path-slash = "0.2"
|
||||
url.workspace = true
|
||||
|
||||
@@ -85,6 +85,15 @@ pub enum Error {
|
||||
source: common_meta::error::Error,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid logical table export: {reason}"))]
|
||||
InvalidLogicalTableExport { reason: String },
|
||||
|
||||
#[snafu(display("Logical table export resource limit exceeded: {reason}"))]
|
||||
LogicalTableExportResource { reason: String },
|
||||
|
||||
#[snafu(display("Logical table export cancelled"))]
|
||||
LogicalTableExportCancelled {},
|
||||
|
||||
#[snafu(display("Unexpected, violated: {}", violated))]
|
||||
Unexpected {
|
||||
violated: String,
|
||||
@@ -1071,6 +1080,9 @@ impl ErrorExt for Error {
|
||||
Error::InvalidTimeIndexType { .. } | Error::InvalidTimezone { .. } => {
|
||||
StatusCode::InvalidArguments
|
||||
}
|
||||
Error::InvalidLogicalTableExport { .. } => StatusCode::InvalidArguments,
|
||||
Error::LogicalTableExportResource { .. } => StatusCode::Suspended,
|
||||
Error::LogicalTableExportCancelled { .. } => StatusCode::Cancelled,
|
||||
Error::InvalidProcessId { .. } => StatusCode::InvalidArguments,
|
||||
Error::ProcessManagerMissing { .. } => StatusCode::Unexpected,
|
||||
Error::TimestampFormatNotSupported { .. } => StatusCode::InvalidArguments,
|
||||
|
||||
@@ -22,6 +22,7 @@ mod cursor;
|
||||
pub mod ddl;
|
||||
mod describe;
|
||||
mod dml;
|
||||
pub mod export_logical_tables;
|
||||
mod kill;
|
||||
pub mod semantic_graph;
|
||||
mod set;
|
||||
|
||||
@@ -25,8 +25,8 @@ use common_datasource::object_store::build_backend_for_write_with_path;
|
||||
use common_query::Output;
|
||||
use common_recordbatch::adapter::DfRecordBatchStreamAdapter;
|
||||
use common_recordbatch::{
|
||||
RecordBatchStream, SendableRecordBatchMapper, SendableRecordBatchStream,
|
||||
map_json_type_to_string, map_json_type_to_string_schema,
|
||||
SendableRecordBatchMapper, SendableRecordBatchStream, map_json_type_to_string,
|
||||
map_json_type_to_string_schema,
|
||||
};
|
||||
use common_telemetry::{debug, tracing};
|
||||
use datafusion::datasource::DefaultTableSource;
|
||||
@@ -85,18 +85,14 @@ impl StatementExecutor {
|
||||
)
|
||||
.await
|
||||
.context(error::WriteStreamToFileSnafu { path }),
|
||||
Format::Parquet(_) => {
|
||||
let schema = stream.schema();
|
||||
stream_to_parquet(
|
||||
Box::pin(DfRecordBatchStreamAdapter::new(stream)),
|
||||
schema,
|
||||
object_store,
|
||||
path,
|
||||
WRITE_CONCURRENCY,
|
||||
)
|
||||
.await
|
||||
.context(error::WriteStreamToFileSnafu { path })
|
||||
}
|
||||
Format::Parquet(_) => stream_to_parquet(
|
||||
Box::pin(DfRecordBatchStreamAdapter::new(stream)),
|
||||
object_store,
|
||||
path,
|
||||
WRITE_CONCURRENCY,
|
||||
)
|
||||
.await
|
||||
.context(error::WriteStreamToFileSnafu { path }),
|
||||
_ => error::UnsupportedFormatSnafu {
|
||||
format: format.clone(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
// 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.
|
||||
|
||||
//! Internal physical-table export into logical-table Parquet files.
|
||||
//!
|
||||
//! Callers must authorize the captured tables and destination before invoking
|
||||
//! this component, and keep the source stable. Each destination must be owned
|
||||
//! exclusively by the export attempt. Completion and cleanup of closed files
|
||||
//! belong to the caller's chunk protocol, not individual file completion.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow::array::{Array, AsArray, UInt32Array};
|
||||
use arrow::compute::cast;
|
||||
use arrow::datatypes::{DataType, SchemaRef};
|
||||
use arrow::downcast_dictionary_array;
|
||||
use arrow::record_batch::RecordBatch;
|
||||
use common_datasource::object_store::build_backend_for_write;
|
||||
use common_datasource::parquet_writer::{ParquetFileWriter, ParquetWriterLimits};
|
||||
use common_meta::key::table_route::{TableRouteManager, TableRouteValue};
|
||||
use common_query::OutputData;
|
||||
use common_recordbatch::SendableRecordBatchStream;
|
||||
use common_time::range::TimestampRange;
|
||||
use datafusion::datasource::DefaultTableSource;
|
||||
use datafusion_common::TableReference as DfTableReference;
|
||||
use datafusion_expr::{LogicalPlan, LogicalPlanBuilder, col};
|
||||
use futures::StreamExt;
|
||||
use object_store::ObjectStore;
|
||||
use session::context::QueryContextRef;
|
||||
use snafu::{IntoError, OptionExt, ResultExt, ensure};
|
||||
use store_api::metric_engine_consts::{
|
||||
DATA_SCHEMA_TABLE_ID_COLUMN_NAME as TABLE_ID, DATA_SCHEMA_TSID_COLUMN_NAME as TSID,
|
||||
LOGICAL_TABLE_METADATA_KEY, METRIC_ENGINE_NAME, PHYSICAL_TABLE_METADATA_KEY,
|
||||
};
|
||||
use table::TableRef;
|
||||
use table::metadata::TableId;
|
||||
use table::table::adapter::DfTableProviderAdapter;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::{self, InvalidLogicalTableExportSnafu, LogicalTableExportResourceSnafu, Result};
|
||||
use crate::statement::StatementExecutor;
|
||||
|
||||
/// Export preprocessing and per-file limits. Query memory and spill remain
|
||||
/// governed by the query engine.
|
||||
/// Batch checks happen after allocation; flush thresholds are not hard RSS caps.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LogicalTableExportLimits {
|
||||
/// Maximum retained scan batch before routing.
|
||||
pub input_batch_bytes: usize,
|
||||
/// Maximum estimated expanded logical values in one conversion slice.
|
||||
pub conversion_bytes: usize,
|
||||
/// Limits owned by the single-file Parquet writer.
|
||||
pub writer: ParquetWriterLimits,
|
||||
}
|
||||
|
||||
impl Default for LogicalTableExportLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
input_batch_bytes: 64 * 1024 * 1024,
|
||||
conversion_bytes: 1024 * 1024,
|
||||
writer: ParquetWriterLimits {
|
||||
row_group_rows: 8192,
|
||||
flush_threshold_bytes: 8 * 1024 * 1024,
|
||||
max_row_groups: 4096,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogicalTableExportLimits {
|
||||
fn validate(self) -> Result<()> {
|
||||
ensure!(
|
||||
self.input_batch_bytes > 0 && self.conversion_bytes > 0,
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "export limits must be positive"
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata captured for one physical table and its selected logical tables.
|
||||
/// Construct one unit per physical table, schema and time chunk. Capturing these
|
||||
/// references supplies no snapshot isolation or locking guarantee.
|
||||
pub struct LogicalTableExport {
|
||||
physical_table: TableRef,
|
||||
scan_projection: Vec<usize>,
|
||||
logical_tables: BTreeMap<TableId, LogicalTableProjection>,
|
||||
}
|
||||
|
||||
struct LogicalTableProjection {
|
||||
name: String,
|
||||
schema: SchemaRef,
|
||||
projection: Vec<usize>,
|
||||
}
|
||||
|
||||
impl LogicalTableExport {
|
||||
/// Capture schemas from selected Metric table references.
|
||||
/// Export validates their physical-table association against table routes.
|
||||
pub fn try_new(physical: TableRef, tables: &[TableRef]) -> Result<Self> {
|
||||
let physical_info = physical.table_info();
|
||||
ensure!(
|
||||
physical_info.meta.engine == METRIC_ENGINE_NAME
|
||||
&& physical_info
|
||||
.meta
|
||||
.options
|
||||
.extra_options
|
||||
.contains_key(PHYSICAL_TABLE_METADATA_KEY)
|
||||
&& !tables.is_empty(),
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "expected a Metric physical table and selected logical tables"
|
||||
}
|
||||
);
|
||||
let physical_schema = physical.schema();
|
||||
let id_index = physical_schema.column_index_by_name(TABLE_ID).context(
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "physical schema has no __table_id",
|
||||
},
|
||||
)?;
|
||||
let mut scan_projection = BTreeSet::from([id_index]);
|
||||
let mut logical_tables = BTreeMap::new();
|
||||
for table in tables {
|
||||
let info = table.table_info();
|
||||
let name = &info.name;
|
||||
ensure!(
|
||||
!name.contains('/') && !name.contains('\\'),
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "logical table names must not contain path separators"
|
||||
}
|
||||
);
|
||||
ensure!(
|
||||
info.catalog_name == physical_info.catalog_name
|
||||
&& info.schema_name == physical_info.schema_name
|
||||
&& info.meta.engine == METRIC_ENGINE_NAME
|
||||
&& info
|
||||
.meta
|
||||
.options
|
||||
.extra_options
|
||||
.contains_key(LOGICAL_TABLE_METADATA_KEY),
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: format!(
|
||||
"{name} is not a Metric logical table in the physical table schema"
|
||||
)
|
||||
}
|
||||
);
|
||||
let schema = table.schema().arrow_schema().clone();
|
||||
let indices = schema
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| {
|
||||
ensure!(
|
||||
field.name() != TABLE_ID && field.name() != TSID,
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "logical schema contains internal Metric columns"
|
||||
}
|
||||
);
|
||||
let index = physical_schema
|
||||
.column_index_by_name(field.name())
|
||||
.with_context(|| InvalidLogicalTableExportSnafu {
|
||||
reason: format!("physical schema lacks {}", field.name()),
|
||||
})?;
|
||||
ensure!(
|
||||
physical_schema.arrow_schema().field(index).data_type()
|
||||
== field.data_type(),
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: format!("physical/logical type mismatch for {}", field.name())
|
||||
}
|
||||
);
|
||||
Ok(index)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
scan_projection.extend(indices.iter().copied());
|
||||
ensure!(
|
||||
logical_tables
|
||||
.insert(
|
||||
info.table_id(),
|
||||
LogicalTableProjection {
|
||||
name: name.clone(),
|
||||
schema,
|
||||
projection: indices,
|
||||
}
|
||||
)
|
||||
.is_none(),
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "duplicate logical table"
|
||||
}
|
||||
);
|
||||
}
|
||||
let scan_projection = scan_projection.into_iter().collect::<Vec<_>>();
|
||||
for file in logical_tables.values_mut() {
|
||||
for index in &mut file.projection {
|
||||
*index = scan_projection.binary_search(index).map_err(|_| {
|
||||
error::UnexpectedSnafu {
|
||||
violated: "logical column missing from physical projection",
|
||||
}
|
||||
.build()
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
physical_table: physical,
|
||||
scan_projection,
|
||||
logical_tables,
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_table_routes(&self, manager: &TableRouteManager) -> Result<()> {
|
||||
let table_ids = self.logical_tables.keys().copied().collect::<Vec<_>>();
|
||||
let routes = manager
|
||||
.table_route_storage()
|
||||
.batch_get(&table_ids)
|
||||
.await
|
||||
.context(error::TableMetadataManagerSnafu)?;
|
||||
let physical_table_id = self.physical_table.table_info().table_id();
|
||||
for (table_id, route) in table_ids.into_iter().zip(routes) {
|
||||
ensure!(
|
||||
matches!(route, Some(TableRouteValue::Logical(route)) if route.physical_table_id() == physical_table_id),
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: format!(
|
||||
"logical table {table_id} does not belong to physical table {physical_table_id}"
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_plan(&self, time_range: Option<&TimestampRange>) -> Result<LogicalPlan> {
|
||||
let info = self.physical_table.table_info();
|
||||
let filters = self
|
||||
.physical_table
|
||||
.schema()
|
||||
.timestamp_column()
|
||||
.and_then(|column| {
|
||||
common_query::logical_plan::build_filter_from_timestamp(&column.name, time_range)
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let source = Arc::new(DefaultTableSource::new(Arc::new(
|
||||
DfTableProviderAdapter::new(self.physical_table.clone()),
|
||||
)));
|
||||
let mut builder = LogicalPlanBuilder::scan_with_filters(
|
||||
DfTableReference::full(
|
||||
info.catalog_name.clone(),
|
||||
info.schema_name.clone(),
|
||||
info.name.clone(),
|
||||
),
|
||||
source,
|
||||
Some(self.scan_projection.clone()),
|
||||
filters.clone(),
|
||||
)
|
||||
.context(error::BuildDfLogicalPlanSnafu)?;
|
||||
for filter in filters {
|
||||
builder = builder
|
||||
.filter(filter)
|
||||
.context(error::BuildDfLogicalPlanSnafu)?;
|
||||
}
|
||||
builder
|
||||
.sort(vec![col(TABLE_ID).sort(true, false)])
|
||||
.context(error::BuildDfLogicalPlanSnafu)?
|
||||
.build()
|
||||
.context(error::BuildDfLogicalPlanSnafu)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts returned only after every selected logical file has been closed.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct LogicalTableExportSummary {
|
||||
pub rows: usize,
|
||||
pub skipped_rows: usize,
|
||||
pub files: usize,
|
||||
}
|
||||
|
||||
impl StatementExecutor {
|
||||
/// Export selected logical tables to a fresh directory using one physical query.
|
||||
/// The caller bounds concurrent units and owns retry/cleanup of this directory.
|
||||
/// Cancellation waits for in-flight I/O before aborting the active upload.
|
||||
/// Closed files remain an incomplete chunk until the caller publishes its
|
||||
/// completion state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn export_logical_tables(
|
||||
&self,
|
||||
unit: &LogicalTableExport,
|
||||
directory: &str,
|
||||
connection: &HashMap<String, String>,
|
||||
time_range: Option<&TimestampRange>,
|
||||
limits: LogicalTableExportLimits,
|
||||
cancellation: &CancellationToken,
|
||||
query_ctx: QueryContextRef,
|
||||
) -> Result<LogicalTableExportSummary> {
|
||||
limits.validate()?;
|
||||
let (store, stream) = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return error::LogicalTableExportCancelledSnafu.fail(),
|
||||
result = async {
|
||||
unit.validate_table_routes(self.table_metadata_manager.table_route_manager()).await?;
|
||||
let store = build_backend_for_write(&format!("{}/", directory.trim_end_matches('/')), connection, &self.local_file_access)
|
||||
.await.context(error::BuildBackendSnafu)?;
|
||||
let output = self.query_engine.execute(unit.build_plan(time_range)?, query_ctx)
|
||||
.await.context(error::ExecLogicalPlanSnafu)?;
|
||||
let stream = match output.data {
|
||||
OutputData::Stream(stream) => stream,
|
||||
OutputData::RecordBatches(batches) => batches.as_stream(),
|
||||
_ => return error::UnexpectedSnafu { violated: "expected physical query rows" }.fail(),
|
||||
};
|
||||
Ok((store, stream))
|
||||
} => result?,
|
||||
};
|
||||
export_stream(unit, stream, &store, limits, cancellation).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn export_stream(
|
||||
unit: &LogicalTableExport,
|
||||
stream: SendableRecordBatchStream,
|
||||
store: &ObjectStore,
|
||||
limits: LogicalTableExportLimits,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<LogicalTableExportSummary> {
|
||||
let mut active = None;
|
||||
let result = write_tables(unit, stream, store, limits, cancellation, &mut active).await;
|
||||
if result.is_err()
|
||||
&& let Some(writer) = active
|
||||
&& let Err(cleanup_error) = writer
|
||||
.writer
|
||||
.abort()
|
||||
.await
|
||||
.map_err(|error| map_writer_error(error, &writer.path))
|
||||
{
|
||||
common_telemetry::warn!(cleanup_error; "Failed to clean up incomplete Metric export file");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn check_cancelled(cancellation: &CancellationToken) -> Result<()> {
|
||||
ensure!(
|
||||
!cancellation.is_cancelled(),
|
||||
error::LogicalTableExportCancelledSnafu
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_tables(
|
||||
unit: &LogicalTableExport,
|
||||
mut stream: SendableRecordBatchStream,
|
||||
store: &ObjectStore,
|
||||
limits: LogicalTableExportLimits,
|
||||
cancellation: &CancellationToken,
|
||||
active: &mut Option<ActiveWriter>,
|
||||
) -> Result<LogicalTableExportSummary> {
|
||||
let id_index =
|
||||
stream
|
||||
.schema()
|
||||
.column_index_by_name(TABLE_ID)
|
||||
.context(error::UnexpectedSnafu {
|
||||
violated: "physical query omitted __table_id",
|
||||
})?;
|
||||
let mut summary = LogicalTableExportSummary::default();
|
||||
let mut previous = None;
|
||||
let mut written = BTreeSet::new();
|
||||
loop {
|
||||
let batch = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return error::LogicalTableExportCancelledSnafu.fail(),
|
||||
batch = stream.next() => batch,
|
||||
};
|
||||
let Some(batch) = batch else {
|
||||
break;
|
||||
};
|
||||
let batch = batch
|
||||
.context(error::BuildRecordBatchSnafu)?
|
||||
.into_df_record_batch();
|
||||
ensure!(
|
||||
batch.get_array_memory_size() <= limits.input_batch_bytes,
|
||||
LogicalTableExportResourceSnafu {
|
||||
reason: "scan batch exceeds input byte budget"
|
||||
}
|
||||
);
|
||||
let ids = batch
|
||||
.column(id_index)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt32Array>()
|
||||
.context(error::UnexpectedSnafu {
|
||||
violated: "__table_id must be UInt32",
|
||||
})?;
|
||||
ensure!(
|
||||
ids.null_count() == 0,
|
||||
error::UnexpectedSnafu {
|
||||
violated: "null __table_id"
|
||||
}
|
||||
);
|
||||
let mut start = 0;
|
||||
while start < batch.num_rows() {
|
||||
check_cancelled(cancellation)?;
|
||||
let id = ids.value(start);
|
||||
ensure!(
|
||||
previous.is_none_or(|last| last <= id),
|
||||
error::UnexpectedSnafu {
|
||||
violated: "physical query is not ordered by __table_id"
|
||||
}
|
||||
);
|
||||
previous = Some(id);
|
||||
let mut end = start + 1;
|
||||
while end < batch.num_rows() && ids.value(end) == id {
|
||||
end += 1;
|
||||
}
|
||||
if active.as_ref().is_some_and(|writer| writer.table_id != id) {
|
||||
finish_active(active, cancellation).await?;
|
||||
}
|
||||
check_cancelled(cancellation)?;
|
||||
if let Some(file) = unit.logical_tables.get(&id) {
|
||||
if active.is_none() {
|
||||
*active = Some(ActiveWriter::open(id, file, store, limits).await?);
|
||||
written.insert(id);
|
||||
summary.files += 1;
|
||||
}
|
||||
let projected = batch
|
||||
.project(&file.projection)
|
||||
.context(error::ProjectSchemaSnafu)?;
|
||||
let writer = active.as_mut().context(error::UnexpectedSnafu {
|
||||
violated: "missing logical writer",
|
||||
})?;
|
||||
let mut offset = start;
|
||||
while offset < end {
|
||||
let (expanded, consumed) = expand_bounded_slice(
|
||||
projected.clone(),
|
||||
file.schema.clone(),
|
||||
offset,
|
||||
end,
|
||||
limits.conversion_bytes,
|
||||
)
|
||||
.await?;
|
||||
check_cancelled(cancellation)?;
|
||||
// Do not drop an in-flight file operation before cleanup.
|
||||
writer
|
||||
.writer
|
||||
.write(expanded, Some(cancellation))
|
||||
.await
|
||||
.map_err(|error| map_writer_error(error, &writer.path))?;
|
||||
check_cancelled(cancellation)?;
|
||||
offset += consumed;
|
||||
summary.rows += consumed;
|
||||
}
|
||||
} else {
|
||||
summary.skipped_rows += end - start;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
finish_active(active, cancellation).await?;
|
||||
for (&id, file) in &unit.logical_tables {
|
||||
if !written.contains(&id) {
|
||||
check_cancelled(cancellation)?;
|
||||
*active = Some(ActiveWriter::open(id, file, store, limits).await?);
|
||||
finish_active(active, cancellation).await?;
|
||||
summary.files += 1;
|
||||
}
|
||||
}
|
||||
check_cancelled(cancellation)?;
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
struct ActiveWriter {
|
||||
table_id: u32,
|
||||
path: String,
|
||||
writer: ParquetFileWriter,
|
||||
}
|
||||
|
||||
impl ActiveWriter {
|
||||
async fn open(
|
||||
id: u32,
|
||||
table: &LogicalTableProjection,
|
||||
store: &ObjectStore,
|
||||
limits: LogicalTableExportLimits,
|
||||
) -> Result<Self> {
|
||||
let path = format!("{}.parquet", table.name);
|
||||
ensure!(
|
||||
!store
|
||||
.exists(&path)
|
||||
.await
|
||||
.context(error::ReadObjectSnafu { path: &path })?,
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: format!("output already exists: {path}")
|
||||
}
|
||||
);
|
||||
let writer = ParquetFileWriter::open(
|
||||
table.schema.clone(),
|
||||
store.clone(),
|
||||
&path,
|
||||
1,
|
||||
Some(limits.writer),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_writer_error(error, &path))?;
|
||||
Ok(Self {
|
||||
table_id: id,
|
||||
path,
|
||||
writer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_active(
|
||||
active: &mut Option<ActiveWriter>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<()> {
|
||||
if let Some(writer) = active.as_mut() {
|
||||
writer
|
||||
.writer
|
||||
.finish(Some(cancellation))
|
||||
.await
|
||||
.map_err(|error| map_writer_error(error, &writer.path))?;
|
||||
check_cancelled(cancellation)?;
|
||||
*active = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expand_bounded_slice(
|
||||
batch: RecordBatch,
|
||||
schema: SchemaRef,
|
||||
start: usize,
|
||||
end: usize,
|
||||
budget: usize,
|
||||
) -> Result<(RecordBatch, usize)> {
|
||||
common_runtime::spawn_blocking_global(move || {
|
||||
let len = rows_within_budget(&batch, start, end, budget)?;
|
||||
let slice = batch.slice(start, len);
|
||||
let arrays = slice
|
||||
.columns()
|
||||
.iter()
|
||||
.zip(schema.fields())
|
||||
.map(|(array, field)| cast(array, field.data_type()).context(error::ComputeArrowSnafu))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let expanded = RecordBatch::try_new(schema, arrays).context(error::ComputeArrowSnafu)?;
|
||||
Ok((expanded, len))
|
||||
})
|
||||
.await
|
||||
.context(error::JoinTaskSnafu)?
|
||||
}
|
||||
|
||||
fn map_writer_error(source: common_datasource::error::Error, path: &str) -> error::Error {
|
||||
match source {
|
||||
common_datasource::error::Error::ParquetWriteCancelled {} => {
|
||||
error::LogicalTableExportCancelledSnafu.build()
|
||||
}
|
||||
common_datasource::error::Error::InvalidParquetWriterLimits {} => {
|
||||
InvalidLogicalTableExportSnafu {
|
||||
reason: "Parquet writer limits must be positive",
|
||||
}
|
||||
.build()
|
||||
}
|
||||
common_datasource::error::Error::ParquetWriterResource { reason } => {
|
||||
LogicalTableExportResourceSnafu { reason }.build()
|
||||
}
|
||||
source => error::WriteStreamToFileSnafu { path }.into_error(source),
|
||||
}
|
||||
}
|
||||
|
||||
// Count only selected logical values before dictionary expansion, including nested
|
||||
// histogram lists/structs. Offset and validity overhead is charged per value.
|
||||
fn estimate_value_size(array: &dyn Array, row: usize) -> Result<usize> {
|
||||
if array.is_null(row) {
|
||||
return Ok(32);
|
||||
}
|
||||
let bytes = match array.data_type() {
|
||||
DataType::Boolean => 1,
|
||||
DataType::Null => 0,
|
||||
DataType::Utf8 => array.as_string::<i32>().value(row).len(),
|
||||
DataType::LargeUtf8 => array.as_string::<i64>().value(row).len(),
|
||||
DataType::Binary => array.as_binary::<i32>().value(row).len(),
|
||||
DataType::LargeBinary => array.as_binary::<i64>().value(row).len(),
|
||||
DataType::Struct(_) => {
|
||||
array
|
||||
.as_struct()
|
||||
.columns()
|
||||
.iter()
|
||||
.try_fold(0usize, |sum, child| {
|
||||
Ok::<_, error::Error>(
|
||||
sum.saturating_add(estimate_value_size(child.as_ref(), row)?),
|
||||
)
|
||||
})?
|
||||
}
|
||||
DataType::List(_) => {
|
||||
let list = array.as_list::<i32>();
|
||||
let offsets = list.value_offsets();
|
||||
(offsets[row] as usize..offsets[row + 1] as usize).try_fold(0usize, |sum, index| {
|
||||
Ok::<_, error::Error>(
|
||||
sum.saturating_add(estimate_value_size(list.values().as_ref(), index)?),
|
||||
)
|
||||
})?
|
||||
}
|
||||
DataType::Dictionary(_, _) => {
|
||||
downcast_dictionary_array! {
|
||||
array => {
|
||||
match array.key(row) {
|
||||
Some(index) => estimate_value_size(array.values().as_ref(), index)?,
|
||||
None => 0,
|
||||
}
|
||||
},
|
||||
_ => return error::UnexpectedSnafu { violated: "invalid dictionary array" }.fail(),
|
||||
}
|
||||
}
|
||||
other => other
|
||||
.primitive_width()
|
||||
.with_context(|| InvalidLogicalTableExportSnafu {
|
||||
reason: format!("unsupported Metric Parquet type: {other}"),
|
||||
})?,
|
||||
};
|
||||
Ok(bytes.saturating_add(16))
|
||||
}
|
||||
|
||||
fn rows_within_budget(
|
||||
batch: &RecordBatch,
|
||||
start: usize,
|
||||
end: usize,
|
||||
budget: usize,
|
||||
) -> Result<usize> {
|
||||
let mut bytes = 0usize;
|
||||
let mut row = start;
|
||||
while row < end {
|
||||
let row_bytes = batch.columns().iter().try_fold(0usize, |sum, array| {
|
||||
Ok::<_, error::Error>(sum.saturating_add(estimate_value_size(array.as_ref(), row)?))
|
||||
})?;
|
||||
if row_bytes > budget.saturating_sub(bytes) {
|
||||
break;
|
||||
}
|
||||
bytes += row_bytes;
|
||||
row += 1;
|
||||
}
|
||||
ensure!(
|
||||
row > start,
|
||||
LogicalTableExportResourceSnafu {
|
||||
reason: "one expanded logical row exceeds conversion byte budget"
|
||||
}
|
||||
);
|
||||
Ok(row - start)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,578 @@
|
||||
// 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::array::{
|
||||
ArrayRef, DictionaryArray, Float64Array, Int32Array, ListArray, StringArray, StructArray,
|
||||
};
|
||||
use arrow::buffer::OffsetBuffer;
|
||||
use arrow::datatypes::{Field, Schema, UInt32Type};
|
||||
use bytes::Bytes;
|
||||
use common_recordbatch::{RecordBatch as GreptimeRecordBatch, RecordBatches};
|
||||
use datafusion::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
||||
use table::test_util::EmptyTable;
|
||||
use table::test_util::table_info::test_table_info;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn table(id: u32, name: &str, fields: Vec<Field>, physical: bool) -> TableRef {
|
||||
let schema = Arc::new(datatypes::schema::Schema::try_from(Schema::new(fields)).unwrap());
|
||||
let mut info = test_table_info(id, name, "public", "greptime", schema);
|
||||
info.meta.engine = METRIC_ENGINE_NAME.into();
|
||||
let (key, value) = if physical {
|
||||
(PHYSICAL_TABLE_METADATA_KEY, "")
|
||||
} else {
|
||||
(LOGICAL_TABLE_METADATA_KEY, "phy")
|
||||
};
|
||||
info.meta
|
||||
.options
|
||||
.extra_options
|
||||
.insert(key.into(), value.into());
|
||||
EmptyTable::from_table_info(&info)
|
||||
}
|
||||
|
||||
fn export_limits() -> LogicalTableExportLimits {
|
||||
let mut limits = LogicalTableExportLimits::default();
|
||||
limits.writer.row_group_rows = 1;
|
||||
limits
|
||||
}
|
||||
|
||||
fn unit() -> LogicalTableExport {
|
||||
let ts = Field::new(
|
||||
"ts",
|
||||
DataType::Timestamp(arrow::datatypes::TimeUnit::Millisecond, None),
|
||||
false,
|
||||
);
|
||||
let host = Field::new("host", DataType::Utf8, true);
|
||||
let a = Field::new("a", DataType::Float64, true);
|
||||
let b = Field::new("b", DataType::Float64, true);
|
||||
let physical = table(
|
||||
1024,
|
||||
"phy",
|
||||
vec![
|
||||
Field::new(TABLE_ID, DataType::UInt32, false),
|
||||
ts.clone(),
|
||||
host.clone(),
|
||||
a.clone(),
|
||||
b.clone(),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let tables = vec![
|
||||
table(
|
||||
1025,
|
||||
"cpu.v1",
|
||||
vec![host.clone(), a.clone(), ts.clone()],
|
||||
false,
|
||||
),
|
||||
table(1026, "empty", vec![ts.clone(), a], false),
|
||||
table(1027, "requests", vec![ts, b, host], false),
|
||||
];
|
||||
LogicalTableExport::try_new(physical, &tables).unwrap()
|
||||
}
|
||||
|
||||
fn batch(ids: Vec<Option<u32>>, hosts: Vec<Option<&str>>) -> RecordBatch {
|
||||
use arrow::array::TimestampMillisecondArray;
|
||||
let rows = ids.len();
|
||||
let host: DictionaryArray<UInt32Type> = hosts.into_iter().collect();
|
||||
RecordBatch::try_from_iter_with_nullable([
|
||||
(TABLE_ID, Arc::new(UInt32Array::from(ids)) as ArrayRef, true),
|
||||
(
|
||||
"ts",
|
||||
Arc::new(TimestampMillisecondArray::from(vec![100; rows])),
|
||||
false,
|
||||
),
|
||||
("host", Arc::new(host), true),
|
||||
(
|
||||
"a",
|
||||
Arc::new(Float64Array::from(vec![Some(1.5); rows])),
|
||||
true,
|
||||
),
|
||||
("b", Arc::new(Float64Array::from(vec![None; rows])), true),
|
||||
])
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn stream(batches: Vec<RecordBatch>) -> SendableRecordBatchStream {
|
||||
let schema = Arc::new(datatypes::schema::Schema::try_from(batches[0].schema()).unwrap());
|
||||
let batches = batches
|
||||
.into_iter()
|
||||
.map(|batch| GreptimeRecordBatch::from_df_record_batch(schema.clone(), batch))
|
||||
.collect();
|
||||
RecordBatches::try_new(schema, batches).unwrap().as_stream()
|
||||
}
|
||||
|
||||
async fn read(store: &ObjectStore, path: &str) -> (SchemaRef, Vec<RecordBatch>) {
|
||||
let bytes = store.read(path).await.unwrap().to_bytes();
|
||||
let builder = ParquetRecordBatchReaderBuilder::try_new(bytes).unwrap();
|
||||
let schema = builder.schema().clone();
|
||||
(
|
||||
schema,
|
||||
builder
|
||||
.build()
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn routes_across_batches_and_writes_empty_files() {
|
||||
let unit = unit();
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let batches = vec![
|
||||
batch(vec![Some(1025), Some(1025)], vec![Some(""), None]),
|
||||
batch(
|
||||
vec![Some(1025), Some(1027), Some(1028)],
|
||||
vec![Some("a"), Some("b"), Some("unselected")],
|
||||
),
|
||||
];
|
||||
let result = write_tables(
|
||||
&unit,
|
||||
stream(batches),
|
||||
&store,
|
||||
export_limits(),
|
||||
&CancellationToken::new(),
|
||||
&mut None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
LogicalTableExportSummary {
|
||||
rows: 4,
|
||||
skipped_rows: 1,
|
||||
files: 3
|
||||
}
|
||||
);
|
||||
let (schema, cpu) = read(&store, "cpu.v1.parquet").await;
|
||||
assert_eq!(schema.fields(), unit.logical_tables[&1025].schema.fields());
|
||||
let values: Vec<_> = cpu
|
||||
.iter()
|
||||
.flat_map(|batch| {
|
||||
batch
|
||||
.column(0)
|
||||
.as_string::<i32>()
|
||||
.iter()
|
||||
.map(|s| s.map(str::to_owned))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(values, vec![Some("".into()), None, Some("a".into())]);
|
||||
let (schema, empty) = read(&store, "empty.parquet").await;
|
||||
assert_eq!(schema.fields(), unit.logical_tables[&1026].schema.fields());
|
||||
assert!(empty.is_empty());
|
||||
let (schema, requests) = read(&store, "requests.parquet").await;
|
||||
assert_eq!(schema.fields(), unit.logical_tables[&1027].schema.fields());
|
||||
assert_eq!(requests[0].column(1).null_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_order_ids_and_resource_exhaustion() {
|
||||
let unit = unit();
|
||||
for (batches, limits, message) in [
|
||||
(
|
||||
vec![
|
||||
batch(vec![Some(1027)], vec![Some("a")]),
|
||||
batch(vec![Some(1025)], vec![Some("b")]),
|
||||
],
|
||||
LogicalTableExportLimits::default(),
|
||||
"not ordered",
|
||||
),
|
||||
(
|
||||
vec![batch(vec![None], vec![None])],
|
||||
LogicalTableExportLimits::default(),
|
||||
"null __table_id",
|
||||
),
|
||||
(
|
||||
vec![batch(vec![Some(1025)], vec![Some("a")])],
|
||||
LogicalTableExportLimits {
|
||||
input_batch_bytes: 1,
|
||||
..Default::default()
|
||||
},
|
||||
"input byte budget",
|
||||
),
|
||||
(
|
||||
vec![batch(vec![Some(1025)], vec![Some("too big")])],
|
||||
LogicalTableExportLimits {
|
||||
conversion_bytes: 1,
|
||||
..Default::default()
|
||||
},
|
||||
"one expanded logical row",
|
||||
),
|
||||
] {
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let mut active = None;
|
||||
let err = write_tables(
|
||||
&unit,
|
||||
stream(batches),
|
||||
&store,
|
||||
limits,
|
||||
&CancellationToken::new(),
|
||||
&mut active,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(message), "{err}");
|
||||
if let Some(writer) = active {
|
||||
writer.writer.abort().await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_outputs_are_not_overwritten() {
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
store.write("cpu.v1.parquet", "keep").await.unwrap();
|
||||
let err = write_tables(
|
||||
&unit(),
|
||||
stream(vec![batch(vec![Some(1025)], vec![None])]),
|
||||
&store,
|
||||
LogicalTableExportLimits::default(),
|
||||
&CancellationToken::new(),
|
||||
&mut None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("already exists"));
|
||||
assert_eq!(
|
||||
store.read("cpu.v1.parquet").await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"keep")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_and_nested_histogram_values_are_bounded_before_expansion() {
|
||||
let dictionary = DictionaryArray::<UInt32Type>::try_new(
|
||||
UInt32Array::from(vec![0, 0, 0]),
|
||||
Arc::new(StringArray::from(vec!["x".repeat(4096)])),
|
||||
)
|
||||
.unwrap();
|
||||
let nested = ListArray::new(
|
||||
Arc::new(Field::new("item", DataType::Int32, true)),
|
||||
OffsetBuffer::new(vec![0i32, 2, 3, 5].into()),
|
||||
Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])),
|
||||
None,
|
||||
);
|
||||
let histogram = StructArray::from(vec![(
|
||||
Arc::new(Field::new("buckets", nested.data_type().clone(), true)),
|
||||
Arc::new(nested) as ArrayRef,
|
||||
)]);
|
||||
let batch = RecordBatch::try_from_iter([
|
||||
("tag", Arc::new(dictionary) as ArrayRef),
|
||||
("histogram", Arc::new(histogram)),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(rows_within_budget(&batch, 0, 3, 4300).unwrap(), 1);
|
||||
// The dictionary and container overhead fit; the nested list elements do not.
|
||||
assert!(rows_within_budget(&batch, 0, 3, 4180).is_err());
|
||||
assert_eq!(rows_within_budget(&batch, 0, 3, 15000).unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_selected_schemas_and_projects_only_selected_columns() {
|
||||
let unit = unit();
|
||||
let selected = table(
|
||||
1025,
|
||||
"only",
|
||||
vec![Field::new("a", DataType::Float64, true)],
|
||||
false,
|
||||
);
|
||||
let one =
|
||||
LogicalTableExport::try_new(unit.physical_table.clone(), std::slice::from_ref(&selected))
|
||||
.unwrap();
|
||||
assert_eq!(one.scan_projection, vec![0, 3]);
|
||||
assert_eq!(one.logical_tables[&1025].projection, vec![1]);
|
||||
assert!(
|
||||
LogicalTableExport::try_new(unit.physical_table.clone(), &[selected.clone(), selected])
|
||||
.is_err()
|
||||
);
|
||||
let unsafe_name = table(1030, "a/b", vec![], false);
|
||||
assert!(LogicalTableExport::try_new(unit.physical_table.clone(), &[unsafe_name]).is_err());
|
||||
let wrong_type = table(
|
||||
1030,
|
||||
"wrong",
|
||||
vec![Field::new("a", DataType::Int32, true)],
|
||||
false,
|
||||
);
|
||||
assert!(LogicalTableExport::try_new(unit.physical_table, &[wrong_type]).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_drops_input_and_aborts_active_upload() {
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let unit = unit();
|
||||
let batch = batch(vec![Some(1025)], vec![Some("a")]);
|
||||
let schema = batch.schema();
|
||||
let token = CancellationToken::new();
|
||||
let trigger = token.clone();
|
||||
let batches = async_stream::stream! {
|
||||
yield Ok(batch);
|
||||
// Resuming the source proves the previous batch reached the writer.
|
||||
trigger.cancel();
|
||||
std::future::pending::<()>().await;
|
||||
};
|
||||
let df_stream =
|
||||
datafusion::physical_plan::stream::RecordBatchStreamAdapter::new(schema, batches);
|
||||
let stream =
|
||||
common_recordbatch::adapter::RecordBatchStreamAdapter::try_new(Box::pin(df_stream))
|
||||
.unwrap();
|
||||
let result = export_stream(&unit, Box::pin(stream), &store, export_limits(), &token).await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error::Error::LogicalTableExportCancelled { .. })
|
||||
));
|
||||
assert!(!store.exists("cpu.v1.parquet").await.unwrap());
|
||||
assert!(!store.exists("empty.parquet").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_histogram_parquet_roundtrip() {
|
||||
fn sample(data_type: &DataType) -> ArrayRef {
|
||||
match data_type {
|
||||
DataType::Struct(fields) => Arc::new(StructArray::new(
|
||||
fields.clone(),
|
||||
fields.iter().map(|f| sample(f.data_type())).collect(),
|
||||
None,
|
||||
)),
|
||||
DataType::List(field) => Arc::new(ListArray::new(
|
||||
field.clone(),
|
||||
OffsetBuffer::new(vec![0i32, 1, 2].into()),
|
||||
sample(field.data_type()),
|
||||
None,
|
||||
)),
|
||||
DataType::Int32 => Arc::new(Int32Array::from(vec![Some(1), None])),
|
||||
DataType::Int64 => Arc::new(arrow::array::Int64Array::from(vec![Some(2), None])),
|
||||
DataType::Float64 => Arc::new(Float64Array::from(vec![Some(3.5), None])),
|
||||
DataType::Timestamp(_, _) => {
|
||||
Arc::new(arrow::array::TimestampMillisecondArray::from(vec![
|
||||
Some(4),
|
||||
None,
|
||||
]))
|
||||
}
|
||||
other => panic!("unexpected histogram field {other}"),
|
||||
}
|
||||
}
|
||||
let data_type = common_query::native_histogram::native_histogram_arrow_type();
|
||||
let field = Field::new("histogram", data_type.clone(), true);
|
||||
let unit = LogicalTableExport::try_new(
|
||||
table(
|
||||
1024,
|
||||
"phy",
|
||||
vec![Field::new(TABLE_ID, DataType::UInt32, false), field.clone()],
|
||||
true,
|
||||
),
|
||||
&[table(1025, "histogram", vec![field], false)],
|
||||
)
|
||||
.unwrap();
|
||||
let batch = RecordBatch::try_from_iter_with_nullable([
|
||||
(
|
||||
TABLE_ID,
|
||||
Arc::new(UInt32Array::from(vec![1025; 2])) as ArrayRef,
|
||||
false,
|
||||
),
|
||||
("histogram", sample(&data_type), true),
|
||||
])
|
||||
.unwrap();
|
||||
let store = ObjectStore::new(object_store::services::Memory::default()).unwrap();
|
||||
let summary = export_stream(
|
||||
&unit,
|
||||
stream(vec![batch.clone()]),
|
||||
&store,
|
||||
export_limits(),
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.rows, 2);
|
||||
let (schema, batches) = read(&store, "histogram.parquet").await;
|
||||
assert_eq!(schema.fields(), unit.logical_tables[&1025].schema.fields());
|
||||
assert_eq!(
|
||||
arrow::compute::concat_batches(&schema, &batches).unwrap(),
|
||||
batch.project(&[1]).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
struct PausedFileWriter {
|
||||
inner: Option<object_store::layers::mock::oio::Writer>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl object_store::layers::mock::oio::Write for PausedFileWriter {
|
||||
async fn write(&mut self, bytes: object_store::Buffer) -> object_store::Result<()> {
|
||||
let mut inner = self.inner.take().unwrap();
|
||||
let started = self.started.clone();
|
||||
let release = self.release.clone();
|
||||
// Like SecureFs's blocking open, this task survives a dropped I/O future.
|
||||
let (inner, result) = tokio::spawn(async move {
|
||||
started.notify_one();
|
||||
release.notified().await;
|
||||
let result = inner.write(bytes).await;
|
||||
(inner, result)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
self.inner = Some(inner);
|
||||
result
|
||||
}
|
||||
|
||||
async fn close(&mut self) -> object_store::Result<object_store::layers::mock::Metadata> {
|
||||
self.inner.as_mut().unwrap().close().await
|
||||
}
|
||||
|
||||
async fn abort(&mut self) -> object_store::Result<()> {
|
||||
match self.inner.as_mut() {
|
||||
Some(inner) => inner.abort().await,
|
||||
None => Err(object_store::Error::new(
|
||||
object_store::ErrorKind::Unsupported,
|
||||
"open is pending",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_waits_for_file_creation_before_cleanup() {
|
||||
use object_store::layers::mock::{MockLayerBuilder, MockWriterFactory};
|
||||
let directory = common_test_util::temp_dir::create_temp_dir("metric_export_pending_open");
|
||||
let access =
|
||||
common_datasource::object_store::LocalFileAccess::sandboxed(directory.path()).unwrap();
|
||||
let store = build_backend_for_write(
|
||||
&format!("{}/", directory.path().display()),
|
||||
&HashMap::new(),
|
||||
&access,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let started = Arc::new(tokio::sync::Notify::new());
|
||||
let release = Arc::new(tokio::sync::Notify::new());
|
||||
let factory: MockWriterFactory = Arc::new({
|
||||
let started = started.clone();
|
||||
let release = release.clone();
|
||||
move |_, _, inner| {
|
||||
Box::new(PausedFileWriter {
|
||||
inner: Some(inner),
|
||||
started: started.clone(),
|
||||
release: release.clone(),
|
||||
})
|
||||
}
|
||||
});
|
||||
let store = store.layer(
|
||||
MockLayerBuilder::default()
|
||||
.writer_factory(factory)
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let cancellation = CancellationToken::new();
|
||||
let unit = unit();
|
||||
let export = export_stream(
|
||||
&unit,
|
||||
stream(vec![batch(vec![Some(1025)], vec![Some("a")])]),
|
||||
&store,
|
||||
LogicalTableExportLimits::default(),
|
||||
&cancellation,
|
||||
);
|
||||
tokio::pin!(export);
|
||||
tokio::select! {
|
||||
result = &mut export => panic!("export completed before the file open: {result:?}"),
|
||||
_ = started.notified() => {},
|
||||
}
|
||||
cancellation.cancel();
|
||||
assert!(futures::poll!(&mut export).is_pending());
|
||||
release.notify_one();
|
||||
let result = export.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error::Error::LogicalTableExportCancelled { .. })
|
||||
));
|
||||
assert!(!store.exists("cpu.v1.parquet").await.unwrap());
|
||||
}
|
||||
|
||||
struct FailedAbortWriter(object_store::layers::mock::oio::Writer);
|
||||
|
||||
impl object_store::layers::mock::oio::Write for FailedAbortWriter {
|
||||
async fn write(&mut self, bytes: object_store::Buffer) -> object_store::Result<()> {
|
||||
self.0.write(bytes).await
|
||||
}
|
||||
async fn close(&mut self) -> object_store::Result<object_store::layers::mock::Metadata> {
|
||||
self.0.close().await
|
||||
}
|
||||
async fn abort(&mut self) -> object_store::Result<()> {
|
||||
Err(object_store::Error::new(
|
||||
object_store::ErrorKind::PermissionDenied,
|
||||
"injected abort failure",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_failure_preserves_resource_error() {
|
||||
use object_store::layers::mock::{MockLayerBuilder, MockWriterFactory};
|
||||
let factory: MockWriterFactory = Arc::new(|_, _, inner| Box::new(FailedAbortWriter(inner)));
|
||||
let store = ObjectStore::new(object_store::services::Memory::default())
|
||||
.unwrap()
|
||||
.layer(
|
||||
MockLayerBuilder::default()
|
||||
.writer_factory(factory)
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut limits = export_limits();
|
||||
limits.writer.max_row_groups = 1;
|
||||
let result = export_stream(
|
||||
&unit(),
|
||||
stream(vec![batch(vec![Some(1025); 2], vec![Some("a"); 2])]),
|
||||
&store,
|
||||
limits,
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error::Error::LogicalTableExportResource { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validates_membership_by_table_route() {
|
||||
use common_meta::kv_backend::TxnService;
|
||||
use common_meta::kv_backend::memory::MemoryKvBackend;
|
||||
|
||||
let unit = unit();
|
||||
for later_physical_id in [None, Some(2048), Some(1024)] {
|
||||
let kv = Arc::new(MemoryKvBackend::default());
|
||||
let manager = TableRouteManager::new(kv.clone());
|
||||
for (&table_id, physical_id) in
|
||||
unit.logical_tables
|
||||
.keys()
|
||||
.zip([Some(1024), later_physical_id, Some(1024)])
|
||||
{
|
||||
if let Some(physical_id) = physical_id {
|
||||
let (txn, _) = manager
|
||||
.table_route_storage()
|
||||
.build_create_txn(table_id, &TableRouteValue::logical(physical_id))
|
||||
.unwrap();
|
||||
assert!(kv.txn(txn).await.unwrap().succeeded);
|
||||
}
|
||||
}
|
||||
let result = unit.validate_table_routes(&manager).await;
|
||||
if later_physical_id == Some(1024) {
|
||||
result.unwrap();
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error::Error::InvalidLogicalTableExport { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ session = { workspace = true, features = ["testing"] }
|
||||
similar-asserts.workspace = true
|
||||
store-api.workspace = true
|
||||
tokio-postgres = { workspace = true }
|
||||
tokio-util.workspace = true
|
||||
url = "2.3"
|
||||
urlencoding = "2.1"
|
||||
yaml-rust = "0.4"
|
||||
|
||||
@@ -35,6 +35,7 @@ use client::Client;
|
||||
use client::client_manager::NodeClients;
|
||||
use cmd::frontend::create_heartbeat_task;
|
||||
use common_base::Plugins;
|
||||
use common_datasource::object_store::LocalFileAccess;
|
||||
use common_grpc::channel_manager::{ChannelConfig, ChannelManager};
|
||||
use common_meta::DatanodeId;
|
||||
use common_meta::cache::{CacheRegistryBuilder, LayeredCacheRegistryBuilder};
|
||||
@@ -172,6 +173,7 @@ pub struct GreptimeDbClusterBuilder {
|
||||
frontend_auto_create_table: bool,
|
||||
shared_home_dir: Option<Arc<TempDir>>,
|
||||
meta_selector: Option<SelectorRef>,
|
||||
local_file_access: LocalFileAccess,
|
||||
}
|
||||
|
||||
impl GreptimeDbClusterBuilder {
|
||||
@@ -206,6 +208,7 @@ impl GreptimeDbClusterBuilder {
|
||||
frontend_auto_create_table: true,
|
||||
shared_home_dir: None,
|
||||
meta_selector: None,
|
||||
local_file_access: LocalFileAccess::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +272,12 @@ impl GreptimeDbClusterBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure the frontend COPY sandbox for filesystem integration tests.
|
||||
pub fn with_local_file_access(mut self, access: LocalFileAccess) -> Self {
|
||||
self.local_file_access = access;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn build_with(
|
||||
&self,
|
||||
datanode_options: Vec<DatanodeOptions>,
|
||||
@@ -512,6 +521,7 @@ impl GreptimeDbClusterBuilder {
|
||||
meta_client.clone(),
|
||||
Arc::new(ProcessManager::new(fe_opts.grpc.server_addr.clone(), None)),
|
||||
)
|
||||
.with_local_file_access(self.local_file_access.clone())
|
||||
.with_local_cache_invalidator(cache_registry)
|
||||
.try_build()
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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 common_query::OutputData;
|
||||
use common_time::Timestamp;
|
||||
use common_time::range::TimestampRange;
|
||||
use frontend::instance::Instance;
|
||||
use operator::statement::export_logical_tables::{LogicalTableExport, LogicalTableExportLimits};
|
||||
use session::context::QueryContext;
|
||||
use tests_integration::cluster::GreptimeDbClusterBuilder;
|
||||
use tests_integration::standalone::GreptimeDbStandaloneBuilder;
|
||||
use tests_integration::test_util::execute_sql as sql;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
async fn table(instance: &Arc<Instance>, name: &str) -> table::TableRef {
|
||||
instance
|
||||
.catalog_manager()
|
||||
.table("greptime", "public", name, Some(&QueryContext::arc()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn values(instance: &Arc<Instance>, query: &str) -> Vec<Vec<datatypes::value::Value>> {
|
||||
let output = sql(instance, query).await;
|
||||
let batches = match output.data {
|
||||
OutputData::Stream(stream) => common_recordbatch::util::collect_batches(stream)
|
||||
.await
|
||||
.unwrap(),
|
||||
OutputData::RecordBatches(batches) => batches,
|
||||
_ => panic!("expected query rows"),
|
||||
};
|
||||
batches
|
||||
.iter()
|
||||
.flat_map(|b| {
|
||||
let columns = datatypes::vectors::Helper::try_into_vectors(b.columns()).unwrap();
|
||||
(0..b.num_rows())
|
||||
.map(|row| columns.iter().map(|col| col.get(row)).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn roundtrip(instance: &Arc<Instance>) {
|
||||
let destination = tempfile::tempdir_in(common_test_util::find_workspace_path(".")).unwrap();
|
||||
for (physical, encoding) in [("phy", "dense"), ("other_phy", "sparse")] {
|
||||
sql(instance, &format!("CREATE TABLE {physical} (ts TIMESTAMP TIME INDEX, val DOUBLE, host STRING PRIMARY KEY) PARTITION ON COLUMNS (host) (host < 'm', host >= 'm') ENGINE=metric WITH (physical_metric_table='', primary_key_encoding='{encoding}')")).await;
|
||||
for (suffix, extra, key) in [
|
||||
("cpu.v1", "zone_tag STRING,", ", zone_tag"),
|
||||
("requests", "service_tag STRING,", ", service_tag"),
|
||||
("empty", "", ""),
|
||||
] {
|
||||
let name = format!("{physical}_{suffix}");
|
||||
sql(instance, &format!("CREATE TABLE \"{name}\" (host STRING, {extra} val DOUBLE, ts TIMESTAMP TIME INDEX, PRIMARY KEY(host{key})) ENGINE=metric WITH (on_physical_table='{physical}')")).await;
|
||||
if suffix != "empty" {
|
||||
sql(instance, &format!("INSERT INTO \"{name}\" (host,val,ts) VALUES ('a',1,1),('a',NULL,2),('z',3,3),('z',4,4)")).await;
|
||||
}
|
||||
}
|
||||
// This live but unselected table models rows outside the routing whitelist.
|
||||
sql(instance, &format!("CREATE TABLE {physical}_excluded (host STRING, huge_tag STRING, val DOUBLE, ts TIMESTAMP TIME INDEX, PRIMARY KEY(host, huge_tag)) ENGINE=metric WITH (on_physical_table='{physical}')")).await;
|
||||
sql(
|
||||
instance,
|
||||
&format!("INSERT INTO {physical}_excluded (host, huge_tag, val, ts) VALUES ('z','ignore',9,2)"),
|
||||
)
|
||||
.await;
|
||||
let names = ["cpu.v1", "requests", "empty"].map(|suffix| format!("{physical}_{suffix}"));
|
||||
let tables = vec![
|
||||
table(instance, &names[0]).await,
|
||||
table(instance, &names[1]).await,
|
||||
table(instance, &names[2]).await,
|
||||
];
|
||||
let renamed = format!("renamed_{physical}");
|
||||
sql(
|
||||
instance,
|
||||
&format!("ALTER TABLE {physical} RENAME {renamed}"),
|
||||
)
|
||||
.await;
|
||||
let unit = LogicalTableExport::try_new(table(instance, &renamed).await, &tables).unwrap();
|
||||
let range =
|
||||
TimestampRange::new(Timestamp::new_millisecond(2), Timestamp::new_millisecond(4))
|
||||
.unwrap();
|
||||
sql(instance, &format!("CREATE TABLE target_{physical} (ts TIMESTAMP TIME INDEX, val DOUBLE, host STRING PRIMARY KEY) ENGINE=metric WITH (physical_metric_table='')")).await;
|
||||
let mut limits = LogicalTableExportLimits::default();
|
||||
limits.writer.row_group_rows = 1;
|
||||
for partitions in [1, 2, 4] {
|
||||
let directory = destination.path().join(format!("{physical}_{partitions}"));
|
||||
let mut ctx = QueryContext::with("greptime", "public");
|
||||
ctx.set_extension(
|
||||
query::datafusion::QUERY_PARALLELISM_HINT,
|
||||
partitions.to_string(),
|
||||
);
|
||||
let summary = instance
|
||||
.statement_executor()
|
||||
.export_logical_tables(
|
||||
&unit,
|
||||
directory.to_str().unwrap(),
|
||||
&Default::default(),
|
||||
Some(&range),
|
||||
limits,
|
||||
&CancellationToken::new(),
|
||||
Arc::new(ctx),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.rows, 4);
|
||||
assert_eq!(summary.files, 3);
|
||||
assert_eq!(summary.skipped_rows, 1);
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let restored = format!("restore_{physical}_{partitions}_{index}");
|
||||
let (extra, key) = [
|
||||
("zone_tag STRING,", ", zone_tag"),
|
||||
("service_tag STRING,", ", service_tag"),
|
||||
("", ""),
|
||||
][index];
|
||||
sql(instance, &format!("CREATE TABLE {restored} (host STRING, {extra} val DOUBLE, ts TIMESTAMP TIME INDEX, PRIMARY KEY(host{key})) ENGINE=metric WITH (on_physical_table='target_{physical}')")).await;
|
||||
sql(
|
||||
instance,
|
||||
&format!(
|
||||
"COPY {restored} FROM '{}/{}.parquet' WITH (FORMAT='parquet')",
|
||||
directory.display(),
|
||||
name
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let expected = values(
|
||||
instance,
|
||||
&format!("SELECT * FROM \"{name}\" WHERE ts >= 2 AND ts < 4 ORDER BY host, ts"),
|
||||
)
|
||||
.await;
|
||||
let actual = values(
|
||||
instance,
|
||||
&format!("SELECT * FROM {restored} ORDER BY host, ts"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
let cancellation = CancellationToken::new();
|
||||
cancellation.cancel();
|
||||
let directory = destination.path().join(format!("cancel_{physical}"));
|
||||
let result = instance
|
||||
.statement_executor()
|
||||
.export_logical_tables(
|
||||
&unit,
|
||||
directory.to_str().unwrap(),
|
||||
&Default::default(),
|
||||
None,
|
||||
LogicalTableExportLimits::default(),
|
||||
&cancellation,
|
||||
QueryContext::arc(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(operator::error::Error::LogicalTableExportCancelled { .. })
|
||||
));
|
||||
assert!(!directory.exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn physical_export_standalone_roundtrip() {
|
||||
common_telemetry::init_default_ut_logging();
|
||||
let standalone = GreptimeDbStandaloneBuilder::new("physical_export")
|
||||
.build()
|
||||
.await;
|
||||
roundtrip(standalone.fe_instance()).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn physical_export_distributed_roundtrip() {
|
||||
common_telemetry::init_default_ut_logging();
|
||||
let cluster = GreptimeDbClusterBuilder::new("physical_export")
|
||||
.await
|
||||
.with_datanodes(2)
|
||||
.with_local_file_access(
|
||||
common_datasource::object_store::LocalFileAccess::sandboxed(
|
||||
common_test_util::find_workspace_path("."),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.build(false)
|
||||
.await;
|
||||
roundtrip(cluster.fe_instance()).await;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ mod repartition;
|
||||
mod repartition_event;
|
||||
#[macro_use]
|
||||
mod repartition_expr_version;
|
||||
mod export_logical_tables;
|
||||
mod mysql;
|
||||
mod reconciliation_event;
|
||||
mod view_ddl_event;
|
||||
|
||||
Reference in New Issue
Block a user