mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-10-16 08:52:30 +00:00
feat: add experimental Metric export to V2 snapshots (#9233)
* feat: add experimental Metric export to V2 snapshots Signed-off-by: jeremyhi <fengjiachun@gmail.com> * fix: validate the complete Metric export capability response Signed-off-by: jeremyhi <fengjiachun@gmail.com> * test: construct portable file URLs for Metric export fixtures Signed-off-by: jeremyhi <fengjiachun@gmail.com> * refactor: address Metric export review nits Signed-off-by: jeremyhi <fengjiachun@gmail.com> --------- Signed-off-by: jeremyhi <fengjiachun@gmail.com>
This commit is contained in:
@@ -37,6 +37,15 @@
|
||||
//! --start-time 2025-01-01T00:00:00Z \
|
||||
//! --end-time 2025-01-31T23:59:59Z
|
||||
//! ```
|
||||
//!
|
||||
//! `--experimental-metric-export` enables shared Metric physical scans for Parquet.
|
||||
//! Enable `experimental_metric_export = true` on the frontend or standalone server
|
||||
//! and use an endpoint whose frontends all support and enable this option.
|
||||
//! The command checks the server capability before modifying the snapshot.
|
||||
//! Each snapshot path belongs to one export task. Resume requires stable source
|
||||
//! schemas/data and confirmation that the previous export and storage writes ended;
|
||||
//! an HTTP timeout does not establish that. Only unfinished chunks are cleaned and
|
||||
//! rerun. Completed chunks and the V2 snapshot/import format remain unchanged.
|
||||
|
||||
mod chunker;
|
||||
mod command;
|
||||
|
||||
@@ -20,9 +20,11 @@ use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use clap::{Parser, Subcommand};
|
||||
use common_catalog::consts::DEFAULT_SCHEMA_NAME;
|
||||
use common_error::ext::BoxedError;
|
||||
use common_telemetry::info;
|
||||
use serde_json::Value;
|
||||
use servers::http::{ColumnSchema, GreptimeQueryOutput, OutputSchema};
|
||||
use snafu::{OptionExt, ResultExt};
|
||||
|
||||
use crate::Tool;
|
||||
@@ -289,6 +291,11 @@ pub struct ExportCreateCommand {
|
||||
#[clap(long, value_enum, default_value = "parquet")]
|
||||
format: DataFormat,
|
||||
|
||||
/// Use shared Metric physical scans (Parquet only). Resume requires that the previous
|
||||
/// export and storage writes have ended; an HTTP timeout does not establish this.
|
||||
#[clap(long)]
|
||||
experimental_metric_export: bool,
|
||||
|
||||
/// Delete existing snapshot and recreate.
|
||||
#[clap(long)]
|
||||
force: bool,
|
||||
@@ -379,9 +386,6 @@ impl ExportCreateCommand {
|
||||
Some(self.schemas.clone())
|
||||
};
|
||||
|
||||
// Build storage
|
||||
let storage = OpenDalStorage::from_uri(&self.to, &self.storage).map_err(BoxedError::new)?;
|
||||
|
||||
// Build database client
|
||||
let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
|
||||
let database_client = DatabaseClient::new(
|
||||
@@ -393,12 +397,43 @@ impl ExportCreateCommand {
|
||||
self.no_proxy,
|
||||
);
|
||||
|
||||
// The filesystem storage constructor can create the snapshot root.
|
||||
if self.experimental_metric_export {
|
||||
if self.format != DataFormat::Parquet {
|
||||
return crate::data::export_v2::error::MetricExportUnavailableSnafu
|
||||
.fail()
|
||||
.map_err(BoxedError::new);
|
||||
}
|
||||
let capability = database_client
|
||||
.sql_response(
|
||||
"SHOW VARIABLES experimental_metric_export",
|
||||
DEFAULT_SCHEMA_NAME,
|
||||
)
|
||||
.await
|
||||
.context(DatabaseSnafu)
|
||||
.map_err(BoxedError::new)?;
|
||||
let expected_schema = OutputSchema::new(vec![ColumnSchema::new(
|
||||
"EXPERIMENTAL_METRIC_EXPORT".to_string(),
|
||||
"String".to_string(),
|
||||
)]);
|
||||
if !matches!(capability.output(), [GreptimeQueryOutput::Records(records)]
|
||||
if records.schema() == &expected_schema
|
||||
&& records.rows() == &vec![vec![Value::String("true".to_string())]])
|
||||
{
|
||||
return crate::data::export_v2::error::MetricExportUnavailableSnafu
|
||||
.fail()
|
||||
.map_err(BoxedError::new);
|
||||
}
|
||||
}
|
||||
let storage = OpenDalStorage::from_uri(&self.to, &self.storage).map_err(BoxedError::new)?;
|
||||
|
||||
Ok(Box::new(ExportCreate {
|
||||
config: ExportConfig {
|
||||
catalog: self.catalog.clone(),
|
||||
schemas,
|
||||
schema_only: self.schema_only,
|
||||
format: self.format,
|
||||
experimental_metric_export: self.experimental_metric_export,
|
||||
force: self.force,
|
||||
time_range,
|
||||
chunk_time_window: self.chunk_time_window,
|
||||
@@ -426,6 +461,7 @@ struct ExportConfig {
|
||||
schemas: Option<Vec<String>>,
|
||||
schema_only: bool,
|
||||
format: DataFormat,
|
||||
experimental_metric_export: bool,
|
||||
force: bool,
|
||||
time_range: TimeRange,
|
||||
chunk_time_window: Option<Duration>,
|
||||
@@ -504,6 +540,8 @@ impl ExportCreate {
|
||||
storage_config: &self.config.storage_config,
|
||||
parallelism: self.config.parallelism,
|
||||
chunk_parallelism: self.config.chunk_parallelism,
|
||||
experimental_metric_export: self.config.experimental_metric_export,
|
||||
resume: true,
|
||||
},
|
||||
progress.as_ref(),
|
||||
)
|
||||
@@ -533,6 +571,14 @@ impl ExportCreate {
|
||||
self.config.chunk_time_window,
|
||||
)?;
|
||||
|
||||
if self.config.experimental_metric_export {
|
||||
for chunk in &manifest.chunks {
|
||||
self.storage
|
||||
.prepare_export_chunk(&schema_names, chunk.id, false)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write schema files
|
||||
self.storage.write_schema(&schema_snapshot).await?;
|
||||
info!("Exported {} schemas", schema_snapshot.schemas.len());
|
||||
@@ -566,6 +612,8 @@ impl ExportCreate {
|
||||
storage_config: &self.config.storage_config,
|
||||
parallelism: self.config.parallelism,
|
||||
chunk_parallelism: self.config.chunk_parallelism,
|
||||
experimental_metric_export: self.config.experimental_metric_export,
|
||||
resume: false,
|
||||
},
|
||||
progress.as_ref(),
|
||||
)
|
||||
@@ -1744,6 +1792,7 @@ mod tests {
|
||||
schemas: None,
|
||||
schema_only: false,
|
||||
format: DataFormat::Parquet,
|
||||
experimental_metric_export: false,
|
||||
force: false,
|
||||
time_range: TimeRange::unbounded(),
|
||||
chunk_time_window: None,
|
||||
@@ -1781,6 +1830,7 @@ mod tests {
|
||||
]),
|
||||
schema_only: false,
|
||||
format: DataFormat::Parquet,
|
||||
experimental_metric_export: false,
|
||||
force: false,
|
||||
time_range: TimeRange::unbounded(),
|
||||
chunk_time_window: None,
|
||||
@@ -1813,6 +1863,7 @@ mod tests {
|
||||
schemas: None,
|
||||
schema_only: false,
|
||||
format: DataFormat::Parquet,
|
||||
experimental_metric_export: false,
|
||||
force: false,
|
||||
time_range,
|
||||
chunk_time_window: Some(Duration::from_secs(3600)),
|
||||
@@ -1846,6 +1897,7 @@ mod tests {
|
||||
schemas: None,
|
||||
schema_only: false,
|
||||
format: DataFormat::Csv,
|
||||
experimental_metric_export: false,
|
||||
force: false,
|
||||
time_range: TimeRange::unbounded(),
|
||||
chunk_time_window: None,
|
||||
@@ -1881,6 +1933,7 @@ mod tests {
|
||||
schemas: None,
|
||||
schema_only: false,
|
||||
format: DataFormat::Parquet,
|
||||
experimental_metric_export: false,
|
||||
force: false,
|
||||
time_range: TimeRange::new(Some(start), Some(start)),
|
||||
chunk_time_window: None,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use common_telemetry::info;
|
||||
use common_telemetry::{error, info};
|
||||
use futures::StreamExt;
|
||||
use futures::stream::FuturesUnordered;
|
||||
|
||||
@@ -39,6 +39,8 @@ struct ExportContext<'a> {
|
||||
schemas: Vec<String>,
|
||||
format: DataFormat,
|
||||
parallelism: usize,
|
||||
experimental_metric_export: bool,
|
||||
resume: bool,
|
||||
}
|
||||
|
||||
pub struct ExportDataOptions<'a> {
|
||||
@@ -46,6 +48,8 @@ pub struct ExportDataOptions<'a> {
|
||||
pub storage_config: &'a ObjectStoreConfig,
|
||||
pub parallelism: usize,
|
||||
pub chunk_parallelism: usize,
|
||||
pub experimental_metric_export: bool,
|
||||
pub resume: bool,
|
||||
}
|
||||
|
||||
pub async fn export_data(
|
||||
@@ -68,6 +72,8 @@ pub async fn export_data(
|
||||
schemas: manifest.schemas.clone(),
|
||||
format: manifest.format,
|
||||
parallelism: options.parallelism,
|
||||
experimental_metric_export: options.experimental_metric_export,
|
||||
resume: options.resume,
|
||||
};
|
||||
|
||||
// One progress unit per chunk. Already completed/skipped chunks from a
|
||||
@@ -88,11 +94,11 @@ pub async fn export_data(
|
||||
export_data_serial(&context, storage, manifest, progress).await
|
||||
} else {
|
||||
export_data_concurrent(
|
||||
&context,
|
||||
storage,
|
||||
manifest,
|
||||
options.chunk_parallelism,
|
||||
progress,
|
||||
|id, range| export_chunk(&context, id, range),
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -134,11 +140,17 @@ async fn export_data_serial(
|
||||
};
|
||||
|
||||
manifest.touch();
|
||||
storage.write_manifest(manifest).await?;
|
||||
// The chunk is finalized (completed, skipped, or failed) and persisted.
|
||||
progress.inc(1);
|
||||
|
||||
result?;
|
||||
let persistence = storage.write_manifest(manifest).await;
|
||||
if persistence.is_ok() {
|
||||
progress.inc(1);
|
||||
}
|
||||
if let Err(err) = result {
|
||||
if let Err(secondary) = persistence {
|
||||
error!(secondary; "Failed to persist failed export chunk");
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
persistence?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -155,39 +167,44 @@ async fn export_data_serial(
|
||||
/// On the first chunk failure we stop scheduling new chunks but let already
|
||||
/// in-flight chunks finish and persist their final status, then return the
|
||||
/// first error.
|
||||
async fn export_data_concurrent(
|
||||
context: &ExportContext<'_>,
|
||||
async fn export_data_concurrent<F, Fut>(
|
||||
storage: &dyn SnapshotStorage,
|
||||
manifest: &mut Manifest,
|
||||
chunk_parallelism: usize,
|
||||
progress: &dyn ProgressReporter,
|
||||
) -> Result<()> {
|
||||
export: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: Fn(u32, TimeRange) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Vec<String>>>,
|
||||
{
|
||||
let mut pending = FuturesUnordered::new();
|
||||
let mut next_idx = 0;
|
||||
let mut first_error: Option<Error> = None;
|
||||
|
||||
loop {
|
||||
let mut scheduled = false;
|
||||
|
||||
let mut admitted = Vec::new();
|
||||
// Schedule eligible chunks in order up to the parallelism limit. Once a
|
||||
// failure is seen, stop scheduling but keep draining in-flight chunks.
|
||||
while first_error.is_none() && pending.len() < chunk_parallelism {
|
||||
while first_error.is_none() && pending.len() + admitted.len() < chunk_parallelism {
|
||||
let Some(idx) = next_eligible_chunk(manifest, &mut next_idx) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let (chunk_id, time_range) = mark_chunk_in_progress(manifest, idx);
|
||||
scheduled = true;
|
||||
|
||||
pending.push(async move {
|
||||
let result = export_chunk(context, chunk_id, time_range).await;
|
||||
(idx, result)
|
||||
});
|
||||
admitted.push((idx, chunk_id, time_range));
|
||||
}
|
||||
|
||||
if scheduled {
|
||||
if !admitted.is_empty() {
|
||||
manifest.touch();
|
||||
storage.write_manifest(manifest).await?;
|
||||
match storage.write_manifest(manifest).await {
|
||||
Ok(()) => {
|
||||
for (idx, chunk_id, time_range) in admitted {
|
||||
let export = &export;
|
||||
pending.push(async move { (idx, export(chunk_id, time_range).await) });
|
||||
}
|
||||
}
|
||||
Err(err) => first_error = Some(err),
|
||||
}
|
||||
}
|
||||
|
||||
let Some((idx, export_result)) = pending.next().await else {
|
||||
@@ -204,9 +221,11 @@ async fn export_data_concurrent(
|
||||
}
|
||||
}
|
||||
manifest.touch();
|
||||
storage.write_manifest(manifest).await?;
|
||||
// The chunk is finalized (completed, skipped, or failed) and persisted.
|
||||
progress.inc(1);
|
||||
match storage.write_manifest(manifest).await {
|
||||
Ok(()) => progress.inc(1),
|
||||
Err(err) if first_error.is_none() => first_error = Some(err),
|
||||
Err(err) => error!(err; "Failed to persist export chunk while draining"),
|
||||
}
|
||||
}
|
||||
|
||||
match first_error {
|
||||
@@ -257,12 +276,19 @@ async fn export_chunk(
|
||||
chunk_id: u32,
|
||||
time_range: TimeRange,
|
||||
) -> Result<Vec<String>> {
|
||||
if context.experimental_metric_export {
|
||||
context
|
||||
.storage
|
||||
.prepare_export_chunk(&context.schemas, chunk_id, context.resume)
|
||||
.await?;
|
||||
}
|
||||
let scheme = StorageScheme::from_uri(context.snapshot_uri)?;
|
||||
let needs_dir = matches!(scheme, StorageScheme::File);
|
||||
let copy_options = CopyOptions {
|
||||
format: context.format,
|
||||
time_range,
|
||||
parallelism: context.parallelism,
|
||||
experimental_metric_export: context.experimental_metric_export,
|
||||
};
|
||||
|
||||
for schema in &context.schemas {
|
||||
@@ -326,6 +352,113 @@ mod tests {
|
||||
manifest
|
||||
}
|
||||
|
||||
struct FailingManifest {
|
||||
fail_at: usize,
|
||||
writes: std::sync::atomic::AtomicUsize,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SnapshotStorage for FailingManifest {
|
||||
async fn write_manifest(&self, manifest: &Manifest) -> Result<()> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let write = self.writes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if write == 1 {
|
||||
assert_eq!(manifest.in_progress_count(), 2);
|
||||
}
|
||||
if write == self.fail_at {
|
||||
self.release.notify_one();
|
||||
return crate::data::export_v2::error::IoSnafu {
|
||||
operation: "injected manifest failure",
|
||||
error: std::io::Error::other("manifest failure"),
|
||||
}
|
||||
.fail();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self) -> Result<bool> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn read_manifest(&self) -> Result<Manifest> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn write_schema(
|
||||
&self,
|
||||
_: &crate::data::export_v2::schema::SchemaSnapshot,
|
||||
) -> Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn write_text(&self, _: &str, _: &str) -> Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn read_text(&self, _: &str) -> Result<String> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn create_dir_all(&self, _: &str) -> Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn list_files_recursive(&self, _: &str) -> Result<Vec<String>> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn delete_snapshot(&self) -> Result<()> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_manifest_failure_stops_admission_and_drains_started_chunks() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
for fail_at in [1, 2, 3] {
|
||||
let storage = FailingManifest {
|
||||
fail_at,
|
||||
writes: AtomicUsize::new(0),
|
||||
release: tokio::sync::Notify::new(),
|
||||
};
|
||||
let started = AtomicUsize::new(0);
|
||||
let finished = AtomicUsize::new(0);
|
||||
let second_started = tokio::sync::Notify::new();
|
||||
let mut manifest = pending_manifest(4);
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
export_data_concurrent(
|
||||
&storage,
|
||||
&mut manifest,
|
||||
2,
|
||||
&crate::data::progress::NoopProgress,
|
||||
|id, _| {
|
||||
let (started, finished, second_started, storage) =
|
||||
(&started, &finished, &second_started, &storage);
|
||||
async move {
|
||||
started.fetch_add(1, Ordering::SeqCst);
|
||||
match id {
|
||||
1 => second_started.notified().await,
|
||||
2 => {
|
||||
second_started.notify_one();
|
||||
storage.release.notified().await;
|
||||
}
|
||||
_ => panic!("new chunk started after manifest persistence failed"),
|
||||
}
|
||||
finished.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(vec![format!("data/public/{id}/a.parquet")])
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("injected manifest failure")
|
||||
);
|
||||
let expected = if fail_at == 1 { 0 } else { 2 };
|
||||
assert_eq!(started.load(Ordering::SeqCst), expected);
|
||||
assert_eq!(finished.load(Ordering::SeqCst), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_eligible_chunk_scans_in_order() {
|
||||
let manifest = pending_manifest(3);
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(super) struct CopyOptions {
|
||||
pub(super) format: DataFormat,
|
||||
pub(super) time_range: TimeRange,
|
||||
pub(super) parallelism: usize,
|
||||
pub(crate) experimental_metric_export: bool,
|
||||
}
|
||||
|
||||
pub(super) struct CopyTarget {
|
||||
@@ -210,6 +211,9 @@ pub(crate) async fn execute_copy_database_from(
|
||||
|
||||
fn build_with_options(options: &CopyOptions) -> String {
|
||||
let mut parts = vec![format!("FORMAT='{}'", options.format)];
|
||||
if options.experimental_metric_export {
|
||||
parts.push("experimental_metric_export='true'".to_string());
|
||||
}
|
||||
if let Some(start) = options.time_range.start {
|
||||
parts.push(format!(
|
||||
"START_TIME='{}'",
|
||||
|
||||
@@ -24,6 +24,14 @@ use snafu::{Location, Snafu};
|
||||
#[snafu(visibility(pub))]
|
||||
#[stack_trace_debug]
|
||||
pub enum Error {
|
||||
#[snafu(display(
|
||||
"Experimental Metric export requires Parquet and a frontend that explicitly reports experimental_metric_export=true. Use an upgraded, consistently configured frontend entry point."
|
||||
))]
|
||||
MetricExportUnavailable {
|
||||
#[snafu(implicit)]
|
||||
location: Location,
|
||||
},
|
||||
|
||||
#[snafu(display("Invalid URI '{}': {}", uri, reason))]
|
||||
InvalidUri {
|
||||
uri: String,
|
||||
@@ -211,7 +219,8 @@ pub type Result<T> = std::result::Result<T, Error>;
|
||||
impl ErrorExt for Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Error::InvalidUri { .. }
|
||||
Error::MetricExportUnavailable { .. }
|
||||
| Error::InvalidUri { .. }
|
||||
| Error::UnsupportedScheme { .. }
|
||||
| Error::SchemaOnlyModeMismatch { .. }
|
||||
| Error::ResumeConfigMismatch { .. }
|
||||
|
||||
@@ -40,6 +40,7 @@ use crate::data::export_v2::manifest::{MANIFEST_FILE, Manifest};
|
||||
#[cfg(test)]
|
||||
use crate::data::export_v2::schema::SchemaDefinition;
|
||||
use crate::data::export_v2::schema::{SCHEMA_DIR, SCHEMAS_FILE, SchemaSnapshot};
|
||||
use crate::data::path::data_dir_for_schema_chunk;
|
||||
|
||||
struct RemoteLocation {
|
||||
bucket_or_container: String,
|
||||
@@ -285,6 +286,21 @@ pub trait SnapshotStorage: Send + Sync {
|
||||
/// Lists files recursively under a relative prefix.
|
||||
async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>>;
|
||||
|
||||
/// Checks fresh output directories or removes a terminated, unfinished chunk's files.
|
||||
async fn prepare_export_chunk(
|
||||
&self,
|
||||
schemas: &[String],
|
||||
chunk_id: u32,
|
||||
resume: bool,
|
||||
) -> Result<()> {
|
||||
let _ = (schemas, chunk_id, resume);
|
||||
InvalidUriSnafu {
|
||||
uri: "snapshot",
|
||||
reason: "storage does not support preparing export chunks",
|
||||
}
|
||||
.fail()
|
||||
}
|
||||
|
||||
/// Deletes the entire snapshot (for --force).
|
||||
async fn delete_snapshot(&self) -> Result<()>;
|
||||
}
|
||||
@@ -696,6 +712,56 @@ impl SnapshotStorage for OpenDalStorage {
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
async fn prepare_export_chunk(
|
||||
&self,
|
||||
schemas: &[String],
|
||||
chunk_id: u32,
|
||||
resume: bool,
|
||||
) -> Result<()> {
|
||||
let mut files = Vec::new();
|
||||
for schema in schemas {
|
||||
let prefix = data_dir_for_schema_chunk(schema, chunk_id);
|
||||
let mut entries = match self.object_store.lister_with(&prefix).recursive(true).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => continue,
|
||||
Err(error) => {
|
||||
return Err(error).context(StorageOperationSnafu {
|
||||
operation: format!("list {prefix}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
while let Some(entry) = entries.try_next().await.context(StorageOperationSnafu {
|
||||
operation: format!("list {prefix}"),
|
||||
})? {
|
||||
let path = entry.path();
|
||||
if path == prefix && entry.metadata().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = path.strip_prefix(&prefix).unwrap_or("");
|
||||
if !resume || entry.metadata().is_dir() || !valid_chunk_filename(name) {
|
||||
return InvalidUriSnafu {
|
||||
uri: path,
|
||||
reason: "expected an empty new chunk or direct Parquet files in an owned unfinished chunk",
|
||||
}.fail();
|
||||
}
|
||||
files.push(path.to_string());
|
||||
}
|
||||
}
|
||||
// Validate every schema before deleting any files; COPY starts only after this returns.
|
||||
for path in files {
|
||||
match self.object_store.delete(&path).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
return Err(error).context(StorageOperationSnafu {
|
||||
operation: format!("delete unfinished chunk file {path}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_snapshot(&self) -> Result<()> {
|
||||
self.object_store
|
||||
.delete_with("/")
|
||||
@@ -707,6 +773,12 @@ impl SnapshotStorage for OpenDalStorage {
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_chunk_filename(name: &str) -> bool {
|
||||
name.strip_suffix(".parquet")
|
||||
.is_some_and(|stem| !stem.is_empty())
|
||||
&& !name.contains(['/', '\\', '\0'])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
@@ -971,6 +1043,140 @@ mod tests {
|
||||
assert!(storage.exists().await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prepare_export_chunk_preserves_other_chunks_and_schema_artifacts() {
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = make_storage_with_rooted_fs(dir.path());
|
||||
for path in [
|
||||
"data/public/1/keep.parquet",
|
||||
"data/public/2/cpu.v1.parquet",
|
||||
"data/other/2/a.parquet",
|
||||
"schema/ddl/public.sql",
|
||||
] {
|
||||
storage.write_text(path, "original").await.unwrap();
|
||||
}
|
||||
let schemas = vec!["public".to_string(), "other".to_string()];
|
||||
assert!(
|
||||
storage
|
||||
.prepare_export_chunk(&schemas, 2, false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
storage
|
||||
.file_exists("data/public/2/cpu.v1.parquet")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
storage
|
||||
.prepare_export_chunk(&schemas, 2, true)
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.prepare_export_chunk(&schemas, 2, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!storage
|
||||
.file_exists("data/public/2/cpu.v1.parquet")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!storage.file_exists("data/other/2/a.parquet").await.unwrap());
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_text("data/public/1/keep.parquet")
|
||||
.await
|
||||
.unwrap(),
|
||||
"original"
|
||||
);
|
||||
assert_eq!(
|
||||
storage.read_text("schema/ddl/public.sql").await.unwrap(),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prepare_export_chunk_rejects_foreign_or_nested_entries_before_deletion() {
|
||||
for foreign in ["notes.txt", "nested/a.parquet", "empty/"] {
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = make_storage_with_rooted_fs(dir.path());
|
||||
storage
|
||||
.write_text("data/public/2/cpu.parquet", "keep")
|
||||
.await
|
||||
.unwrap();
|
||||
let path = format!("data/other/2/{foreign}");
|
||||
if foreign.ends_with('/') {
|
||||
storage.create_dir_all(&path).await.unwrap();
|
||||
} else {
|
||||
storage.write_text(&path, "foreign").await.unwrap();
|
||||
}
|
||||
assert!(
|
||||
storage
|
||||
.prepare_export_chunk(&["public".into(), "other".into()], 2, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
storage
|
||||
.read_text("data/public/2/cpu.parquet")
|
||||
.await
|
||||
.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn test_prepare_export_chunk_reports_delete_failure() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = make_storage_with_rooted_fs(dir.path());
|
||||
storage
|
||||
.write_text("data/public/2/a.parquet", "keep")
|
||||
.await
|
||||
.unwrap();
|
||||
let chunk_dir = dir.path().join("data/public/2");
|
||||
std::fs::set_permissions(&chunk_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let result = storage
|
||||
.prepare_export_chunk(&["public".into()], 2, true)
|
||||
.await;
|
||||
std::fs::set_permissions(&chunk_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("delete unfinished chunk file")
|
||||
);
|
||||
assert_eq!(
|
||||
storage.read_text("data/public/2/a.parquet").await.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prepare_export_chunk_encodes_schema_boundary() {
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = make_storage_with_rooted_fs(dir.path());
|
||||
let schema = "../other".to_string();
|
||||
let owned = format!("{}a.parquet", data_dir_for_schema_chunk(&schema, 2));
|
||||
storage.write_text(&owned, "remove").await.unwrap();
|
||||
storage
|
||||
.write_text("data/other/2/a.parquet", "keep")
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.prepare_export_chunk(&[schema], 2, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!storage.file_exists(&owned).await.unwrap());
|
||||
assert_eq!(
|
||||
storage.read_text("data/other/2/a.parquet").await.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_snapshot_only_removes_rooted_contents() {
|
||||
let parent = tempdir().unwrap();
|
||||
|
||||
+13
-5
@@ -99,6 +99,18 @@ impl DatabaseClient {
|
||||
|
||||
/// Execute sql query.
|
||||
pub async fn sql(&self, sql: &str, schema: &str) -> Result<Option<Vec<Vec<Value>>>> {
|
||||
let body = self.sql_response(sql, schema).await?;
|
||||
Ok(body.output().first().and_then(|output| match output {
|
||||
GreptimeQueryOutput::Records(records) => Some(records.rows().clone()),
|
||||
GreptimeQueryOutput::AffectedRows(_) => None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) async fn sql_response(
|
||||
&self,
|
||||
sql: &str,
|
||||
schema: &str,
|
||||
) -> Result<GreptimedbV1Response> {
|
||||
let url = format!("http://{}/v1/sql", self.addr);
|
||||
let params = [
|
||||
("db", format!("{}-{}", self.catalog, schema)),
|
||||
@@ -138,11 +150,7 @@ impl DatabaseClient {
|
||||
reason: "cannot get response text".to_string(),
|
||||
})?;
|
||||
|
||||
let body = serde_json::from_str::<GreptimedbV1Response>(&text).context(SerdeJsonSnafu)?;
|
||||
Ok(body.output().first().and_then(|output| match output {
|
||||
GreptimeQueryOutput::Records(records) => Some(records.rows().clone()),
|
||||
GreptimeQueryOutput::AffectedRows(_) => None,
|
||||
}))
|
||||
serde_json::from_str::<GreptimedbV1Response>(&text).context(SerdeJsonSnafu)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user