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:
jeremyhi
2026-09-21 09:10:01 +00:00
committed by GitHub
parent e27167139f
commit 263e229103
21 changed files with 1172 additions and 35 deletions
+9
View File
@@ -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;
+56 -3
View File
@@ -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,
+158 -25
View File
@@ -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);
+4
View File
@@ -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='{}'",
+10 -1
View File
@@ -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 { .. }
+206
View File
@@ -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
View File
@@ -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)
}
}