feat: export Metric snapshots with packed Parquet objects (#9382)

* feat: export Metric snapshots with packed Parquet objects

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* fix: reject empty packed export time ranges

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

* test: cover cancellation during packed export I/O

Signed-off-by: jeremyhi <fengjiachun@gmail.com>

---------

Signed-off-by: jeremyhi <fengjiachun@gmail.com>
This commit is contained in:
jeremyhi
2026-09-28 15:22:02 +00:00
committed by GitHub
parent e2b7e5e0a8
commit 3c0e2a8d55
24 changed files with 1584 additions and 127 deletions
+1
View File
@@ -71,3 +71,4 @@ common-test-util.workspace = true
object-store = { workspace = true, features = ["testing"] }
serde.workspace = true
tempfile.workspace = true
tokio-util.workspace = true
+3 -1
View File
@@ -41,11 +41,13 @@
//! `--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.
//! Add `--metric-data-layout packed` to write version-2 packed snapshots.
//! This requires the server packed-export capability; schema-only exports remain version 1.
//! 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.
//! rerun. Resume retains the snapshot version/layout and completed chunks.
mod chunker;
mod command;
+65 -4
View File
@@ -296,6 +296,10 @@ pub struct ExportCreateCommand {
#[clap(long)]
experimental_metric_export: bool,
/// Store independent Metric Parquet streams in shared packed objects.
#[clap(long, value_parser = ["packed"], requires = "experimental_metric_export")]
metric_data_layout: Option<String>,
/// Delete existing snapshot and recreate.
#[clap(long)]
force: bool,
@@ -345,6 +349,16 @@ impl ExportCreateCommand {
let time_range = TimeRange::parse(self.start_time.as_deref(), self.end_time.as_deref())
.map_err(BoxedError::new)?;
if self.metric_data_layout.is_some()
&& time_range.is_bounded()
&& time_range.start == time_range.end
{
return crate::error::InvalidArgumentsSnafu {
msg: "Packed export requires --start-time to be earlier than --end-time",
}
.fail()
.map_err(BoxedError::new);
}
if self.chunk_time_window.is_some() && !time_range.is_bounded() {
return ChunkTimeWindowRequiresBoundsSnafu
.fail()
@@ -425,6 +439,13 @@ impl ExportCreateCommand {
.map_err(BoxedError::new);
}
}
if self.metric_data_layout.is_some() && !self.schema_only {
database_client
.require_packed_export()
.await
.context(DatabaseSnafu)
.map_err(BoxedError::new)?;
}
let storage = OpenDalStorage::from_uri(&self.to, &self.storage).map_err(BoxedError::new)?;
Ok(Box::new(ExportCreate {
@@ -434,6 +455,7 @@ impl ExportCreateCommand {
schema_only: self.schema_only,
format: self.format,
experimental_metric_export: self.experimental_metric_export,
packed: self.metric_data_layout.is_some(),
force: self.force,
time_range,
chunk_time_window: self.chunk_time_window,
@@ -462,6 +484,7 @@ struct ExportConfig {
schema_only: bool,
format: DataFormat,
experimental_metric_export: bool,
packed: bool,
force: bool,
time_range: TimeRange,
chunk_time_window: Option<Duration>,
@@ -504,7 +527,7 @@ impl ExportCreate {
let mut manifest = self.storage.read_manifest().await?;
// Check version compatibility
if manifest.version != MANIFEST_VERSION || manifest.data_layout.is_some() {
if manifest.validate_layout().is_err() {
return ManifestVersionMismatchSnafu {
expected: MANIFEST_VERSION,
found: manifest.version,
@@ -513,6 +536,15 @@ impl ExportCreate {
}
validate_resume_config(&manifest, &self.config)?;
if manifest.is_packed() {
self.database_client
.require_packed_export()
.await
.context(DatabaseSnafu)?;
if !self.config.experimental_metric_export {
return crate::data::export_v2::error::MetricExportUnavailableSnafu.fail();
}
}
info!(
"Resuming existing snapshot: {} (completed: {}/{} chunks)",
@@ -571,10 +603,14 @@ impl ExportCreate {
self.config.chunk_time_window,
)?;
if self.config.packed && !self.config.schema_only {
manifest.version = 2;
manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
}
if self.config.experimental_metric_export {
for chunk in &manifest.chunks {
self.storage
.prepare_export_chunk(&schema_names, chunk.id, false)
.prepare_export_chunk(&schema_names, chunk.id, false, manifest.is_packed())
.await?;
}
}
@@ -796,6 +832,14 @@ fn build_schema_ddl(
}
fn validate_resume_config(manifest: &Manifest, config: &ExportConfig) -> Result<()> {
if config.packed && !manifest.schema_only && !manifest.is_packed() {
return ResumeConfigMismatchSnafu {
field: "metric_data_layout",
existing: "standalone".to_string(),
requested: "packed".to_string(),
}
.fail();
}
if manifest.schema_only != config.schema_only {
return SchemaOnlyModeMismatchSnafu {
existing_schema_only: manifest.schema_only,
@@ -1801,6 +1845,7 @@ mod tests {
schema_only: false,
format: DataFormat::Parquet,
experimental_metric_export: false,
packed: false,
force: false,
time_range: TimeRange::unbounded(),
chunk_time_window: None,
@@ -1820,7 +1865,7 @@ mod tests {
#[test]
fn test_validate_resume_config_accepts_schema_selection_with_different_case_and_order() {
let manifest = Manifest::new_for_export(
let mut manifest = Manifest::new_for_export(
"greptime".to_string(),
vec!["public".to_string(), "analytics".to_string()],
false,
@@ -1829,7 +1874,7 @@ mod tests {
None,
)
.unwrap();
let config = ExportConfig {
let mut config = ExportConfig {
catalog: "greptime".to_string(),
schemas: Some(vec![
"ANALYTICS".to_string(),
@@ -1839,6 +1884,7 @@ mod tests {
schema_only: false,
format: DataFormat::Parquet,
experimental_metric_export: false,
packed: false,
force: false,
time_range: TimeRange::unbounded(),
chunk_time_window: None,
@@ -1850,6 +1896,18 @@ mod tests {
};
assert!(validate_resume_config(&manifest, &config).is_ok());
config.packed = true;
assert!(
validate_resume_config(&manifest, &config)
.unwrap_err()
.to_string()
.contains("metric_data_layout")
);
manifest.version = 2;
manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
assert!(validate_resume_config(&manifest, &config).is_ok());
config.packed = false;
assert!(validate_resume_config(&manifest, &config).is_ok());
}
#[test]
@@ -1872,6 +1930,7 @@ mod tests {
schema_only: false,
format: DataFormat::Parquet,
experimental_metric_export: false,
packed: false,
force: false,
time_range,
chunk_time_window: Some(Duration::from_secs(3600)),
@@ -1906,6 +1965,7 @@ mod tests {
schema_only: false,
format: DataFormat::Csv,
experimental_metric_export: false,
packed: false,
force: false,
time_range: TimeRange::unbounded(),
chunk_time_window: None,
@@ -1942,6 +2002,7 @@ mod tests {
schema_only: false,
format: DataFormat::Parquet,
experimental_metric_export: false,
packed: false,
force: false,
time_range: TimeRange::new(Some(start), Some(start)),
chunk_time_window: None,
+189 -1
View File
@@ -40,6 +40,7 @@ struct ExportContext<'a> {
format: DataFormat,
parallelism: usize,
experimental_metric_export: bool,
packed: bool,
resume: bool,
}
@@ -71,6 +72,7 @@ pub async fn export_data(
catalog: manifest.catalog.clone(),
schemas: manifest.schemas.clone(),
format: manifest.format,
packed: manifest.is_packed(),
parallelism: options.parallelism,
experimental_metric_export: options.experimental_metric_export,
resume: options.resume,
@@ -279,7 +281,7 @@ async fn export_chunk(
if context.experimental_metric_export {
context
.storage
.prepare_export_chunk(&context.schemas, chunk_id, context.resume)
.prepare_export_chunk(&context.schemas, chunk_id, context.resume, context.packed)
.await?;
}
let scheme = StorageScheme::from_uri(context.snapshot_uri)?;
@@ -289,6 +291,7 @@ async fn export_chunk(
time_range,
parallelism: context.parallelism,
experimental_metric_export: context.experimental_metric_export,
packed: context.packed,
};
for schema in &context.schemas {
@@ -314,6 +317,39 @@ async fn export_chunk(
}
let files = list_chunk_files(context.storage, &context.schemas, chunk_id).await?;
if context.packed {
use common_datasource::packed_snapshot::{PACK_INDEX_FILE, PackIndex};
let mut expected = Vec::new();
for schema in &context.schemas {
let prefix = data_dir_for_schema_chunk(schema, chunk_id);
let index_path = format!("{prefix}{PACK_INDEX_FILE}");
let text = context.storage.read_text(&index_path).await?;
let index: PackIndex = serde_json::from_str(&text).map_err(|e| {
crate::data::export_v2::error::InvalidUriSnafu {
uri: context.snapshot_uri,
reason: e.to_string(),
}
.build()
})?;
index.validate().map_err(|e| {
crate::data::export_v2::error::InvalidUriSnafu {
uri: context.snapshot_uri,
reason: e.to_string(),
}
.build()
})?;
expected.push(index_path);
expected.extend(index.objects.iter().map(|o| format!("{prefix}{}", o.path)));
}
expected.sort();
if files != expected {
return crate::data::export_v2::error::InvalidUriSnafu {
uri: context.snapshot_uri,
reason: "chunk inventory differs from packed indexes",
}
.fail();
}
}
info!("Collected {} files for chunk {}", files.len(), chunk_id);
Ok(files)
}
@@ -462,6 +498,158 @@ mod tests {
}
}
struct PausedClose {
inner: Option<object_store::layers::mock::oio::Writer>,
started: std::sync::Arc<tokio::sync::Notify>,
release: std::sync::Arc<tokio::sync::Notify>,
}
impl object_store::layers::mock::oio::Write for PausedClose {
async fn write(&mut self, bytes: object_store::Buffer) -> object_store::Result<()> {
self.inner.as_mut().unwrap().write(bytes).await
}
async fn close(&mut self) -> object_store::Result<object_store::layers::mock::Metadata> {
let mut inner = self.inner.take().unwrap();
let (started, release) = (self.started.clone(), self.release.clone());
// Model blocking storage I/O that survives dropping its caller.
let (inner, result) = tokio::spawn(async move {
started.notify_one();
release.notified().await;
let result = inner.close().await;
(inner, result)
})
.await
.unwrap();
self.inner = Some(inner);
result
}
async fn abort(&mut self) -> object_store::Result<()> {
self.inner.as_mut().unwrap().abort().await
}
}
#[tokio::test]
async fn packed_cancellation_drains_close_before_failing_chunk() {
use std::sync::Arc;
use common_datasource::packed_snapshot::PACK_INDEX_FILE;
use common_datasource::packed_writer::{PackedTableWriter, PackedWriter};
use common_datasource::parquet_writer::ParquetFileWriter;
use datatypes::arrow::datatypes::{DataType, Field, Schema};
use object_store::layers::mock::{MockLayerBuilder, MockWriterFactory};
use tokio_util::sync::CancellationToken;
use crate::data::snapshot_storage::OpenDalStorage;
for paused_path in ["pack-000000.bin", PACK_INDEX_FILE] {
let directory = tempfile::tempdir().unwrap();
let uri = url::Url::from_directory_path(directory.path()).unwrap();
let storage =
OpenDalStorage::from_uri(uri.as_str(), &ObjectStoreConfig::default()).unwrap();
let started = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let factory: MockWriterFactory = Arc::new({
let (started, release) = (started.clone(), release.clone());
move |path, _, inner| {
if path.trim_start_matches('/') == paused_path {
Box::new(PausedClose {
inner: Some(inner),
started: started.clone(),
release: release.clone(),
})
} else {
inner
}
}
});
let store = object_store::secure_fs::SecureFsRoot::open(directory.path())
.unwrap()
.build_operator()
.layer(
MockLayerBuilder::default()
.writer_factory(factory)
.build()
.unwrap(),
);
let packed = PackedWriter::new(store.clone()).unwrap();
let schema = Arc::new(Schema::new(vec![Field::new(
"value",
DataType::Int64,
true,
)]));
let mut table = ParquetFileWriter::open_packed(
schema,
store.clone(),
"unused",
None,
PackedTableWriter::new(packed.clone(), "empty".into(), 1, false),
)
.unwrap();
table.finish(None).await.unwrap();
let token = CancellationToken::new();
let mut manifest = pending_manifest(1);
manifest.version = 2;
manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
let export = export_data_concurrent(
&storage,
&mut manifest,
2,
&crate::data::progress::NoopProgress,
|_, _| async {
let mut packed = packed.lock().await;
let result = packed.finish(&token).await;
if result.is_err() {
packed.abort().await.unwrap();
}
result.map_err(|error| {
crate::data::export_v2::error::IoSnafu {
operation: "exporting packed chunk",
error: std::io::Error::other(error),
}
.build()
})
},
);
tokio::pin!(export);
tokio::select! {
_ = started.notified() => {},
result = &mut export => panic!("completed while close paused: {result:?}"),
}
token.cancel();
assert!(futures::poll!(&mut export).is_pending());
assert_eq!(
storage.read_manifest().await.unwrap().chunks[0].status,
ChunkStatus::InProgress
);
release.notify_one();
assert!(export.await.unwrap_err().to_string().contains("cancelled"));
let persisted = storage.read_manifest().await.unwrap();
assert_eq!(persisted.chunks[0].status, ChunkStatus::Failed);
assert!(!persisted.is_complete());
assert!(persisted.chunks[0].files.is_empty());
assert!(
store
.stat("pack-000000.bin")
.await
.unwrap()
.content_length()
> 0
);
assert_eq!(
store.exists(PACK_INDEX_FILE).await.unwrap(),
paused_path == PACK_INDEX_FILE
);
if paused_path == PACK_INDEX_FILE {
let index: common_datasource::packed_snapshot::PackIndex =
serde_json::from_slice(&store.read(PACK_INDEX_FILE).await.unwrap().to_bytes())
.unwrap();
index.validate_membership(["empty"]).unwrap();
}
}
}
#[test]
fn test_next_eligible_chunk_scans_in_order() {
let manifest = pending_manifest(3);
+4
View File
@@ -31,6 +31,7 @@ pub(super) struct CopyOptions {
pub(super) time_range: TimeRange,
pub(super) parallelism: usize,
pub(crate) experimental_metric_export: bool,
pub(super) packed: bool,
}
pub(super) struct CopyTarget {
@@ -220,6 +221,9 @@ fn build_with_options(options: &CopyOptions) -> String {
if options.experimental_metric_export {
parts.push("experimental_metric_export='true'".to_string());
}
if options.packed {
parts.push("metric_data_layout='packed'".to_string());
}
if let Some(start) = options.time_range.start {
parts.push(format!(
"START_TIME='{}'",
+1 -1
View File
@@ -28,7 +28,7 @@ use crate::data::export_v2::error::{
TimeParseInvalidFormatSnafu,
};
/// Manifest format version produced by the current exporter.
/// Default standalone manifest version. Packed data snapshots use version 2.
pub const MANIFEST_VERSION: u32 = 1;
/// Manifest file name within snapshot directory.
+93 -9
View File
@@ -295,8 +295,9 @@ pub trait SnapshotStorage: Send + Sync {
schemas: &[String],
chunk_id: u32,
resume: bool,
packed: bool,
) -> Result<()> {
let _ = (schemas, chunk_id, resume);
let _ = (schemas, chunk_id, resume, packed);
InvalidUriSnafu {
uri: "snapshot",
reason: "storage does not support preparing export chunks",
@@ -730,6 +731,7 @@ impl SnapshotStorage for OpenDalStorage {
schemas: &[String],
chunk_id: u32,
resume: bool,
packed: bool,
) -> Result<()> {
let mut files = Vec::new();
for schema in schemas {
@@ -751,10 +753,17 @@ impl SnapshotStorage for OpenDalStorage {
continue;
}
let name = path.strip_prefix(&prefix).unwrap_or("");
if !resume || entry.metadata().is_dir() || !valid_chunk_filename(name) {
if !resume
|| entry.metadata().is_dir()
|| !(if packed {
valid_packed_chunk_filename(name)
} else {
valid_chunk_filename(name)
})
{
return InvalidUriSnafu {
uri: path,
reason: "expected an empty new chunk or direct Parquet files in an owned unfinished chunk",
reason: "expected an empty new chunk or recognized files in an owned unfinished chunk",
}.fail();
}
files.push(path.to_string());
@@ -786,6 +795,17 @@ impl SnapshotStorage for OpenDalStorage {
}
}
fn valid_packed_chunk_filename(name: &str) -> bool {
name == common_datasource::packed_snapshot::PACK_INDEX_FILE
|| [("pack-", ".bin"), ("table-", ".parquet")]
.iter()
.any(|(prefix, suffix)| {
name.strip_prefix(prefix)
.and_then(|n| n.strip_suffix(suffix))
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
})
}
fn valid_chunk_filename(name: &str) -> bool {
name.strip_suffix(".parquet")
.is_some_and(|stem| !stem.is_empty())
@@ -1074,7 +1094,7 @@ mod tests {
let schemas = vec!["public".to_string(), "other".to_string()];
assert!(
storage
.prepare_export_chunk(&schemas, 2, false)
.prepare_export_chunk(&schemas, 2, false, false)
.await
.is_err()
);
@@ -1085,11 +1105,11 @@ mod tests {
.unwrap()
);
storage
.prepare_export_chunk(&schemas, 2, true)
.prepare_export_chunk(&schemas, 2, true, false)
.await
.unwrap();
storage
.prepare_export_chunk(&schemas, 2, true)
.prepare_export_chunk(&schemas, 2, true, false)
.await
.unwrap();
assert!(
@@ -1129,7 +1149,7 @@ mod tests {
}
assert!(
storage
.prepare_export_chunk(&["public".into(), "other".into()], 2, true)
.prepare_export_chunk(&["public".into(), "other".into()], 2, true, false)
.await
.is_err()
);
@@ -1143,6 +1163,70 @@ mod tests {
}
}
#[tokio::test]
async fn packed_retry_preserves_foreign_files_and_completed_chunks() {
let dir = tempdir().unwrap();
let storage = make_storage_with_rooted_fs(dir.path());
let owned = ["pack-000000.bin", "table-42.parquet", "pack-index.json"];
for name in owned {
storage
.write_text(&format!("data/public/2/{name}"), "partial")
.await
.unwrap();
}
for path in [
"data/public/1/pack-000000.bin",
"data/public/2/foreign.parquet",
] {
storage.write_text(path, "keep").await.unwrap();
}
let schemas = ["public".into()];
assert!(
storage
.prepare_export_chunk(&schemas, 2, true, true)
.await
.is_err()
);
for name in owned {
assert!(
storage
.file_exists(&format!("data/public/2/{name}"))
.await
.unwrap()
);
}
assert_eq!(
storage
.read_text("data/public/2/foreign.parquet")
.await
.unwrap(),
"keep"
);
storage
.object_store
.delete("data/public/2/foreign.parquet")
.await
.unwrap();
storage
.prepare_export_chunk(&schemas, 2, true, true)
.await
.unwrap();
assert!(
storage
.list_files_recursive("data/public/2/")
.await
.unwrap()
.is_empty()
);
assert_eq!(
storage
.read_text("data/public/1/pack-000000.bin")
.await
.unwrap(),
"keep"
);
}
#[tokio::test]
async fn test_prepare_export_chunk_reports_delete_failure() {
let dir = tempdir().unwrap();
@@ -1159,7 +1243,7 @@ mod tests {
.await
.unwrap();
let error = storage
.prepare_export_chunk(&["public".into()], 2, true)
.prepare_export_chunk(&["public".into()], 2, true, false)
.await
.unwrap_err();
let Error::StorageOperation {
@@ -1191,7 +1275,7 @@ mod tests {
.await
.unwrap();
storage
.prepare_export_chunk(&[schema], 2, true)
.prepare_export_chunk(&[schema], 2, true, false)
.await
.unwrap();
assert!(!storage.file_exists(&owned).await.unwrap());
+62 -40
View File
@@ -99,6 +99,15 @@ impl DatabaseClient {
/// Requires the explicit packed-import protocol before any restore mutation.
pub async fn require_packed_import(&self) -> Result<()> {
self.require_packed_capability("metric_packed_import").await
}
/// Requires packed export support before creating snapshot artifacts.
pub async fn require_packed_export(&self) -> Result<()> {
self.require_packed_capability("metric_packed_export").await
}
async fn require_packed_capability(&self, capability: &str) -> Result<()> {
let url = format!("http://{}/v1/capabilities", self.addr);
let mut builder = reqwest::Client::builder().timeout(self.timeout);
if let Some(proxy) = self.proxy.clone() {
@@ -113,18 +122,18 @@ impl DatabaseClient {
request = request.header("Authorization", auth);
}
let response = request.send().await.with_context(|_| HttpQuerySqlSnafu {
reason: "packed import capability request failed",
reason: "packed snapshot capability request failed",
})?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return crate::error::InvalidArgumentsSnafu {
msg: "target does not support packed import",
msg: format!("server does not support {capability}"),
}
.fail();
}
let response = response
.error_for_status()
.with_context(|_| HttpQuerySqlSnafu {
reason: "packed import capability request rejected",
reason: "packed snapshot capability request rejected",
})?;
let body = response.text().await.with_context(|_| HttpQuerySqlSnafu {
reason: "cannot read capability response",
@@ -136,9 +145,9 @@ impl DatabaseClient {
}
.fail();
}
if value.get("metric_packed_import").and_then(Value::as_u64) != Some(1) {
if value.get(capability).and_then(Value::as_u64) != Some(1) {
return crate::error::InvalidArgumentsSnafu {
msg: "target does not support packed import version 1",
msg: format!("server does not support {capability} version 1"),
}
.fail();
}
@@ -221,41 +230,54 @@ mod tests {
#[tokio::test]
async fn packed_capability_probe_checks_auth_and_protocol_version() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
for (status, body, supported) in [
(200, r#"{"metric_packed_import":1,"future":2}"#, true),
(200, "{}", false),
(200, r#"{"metric_packed_import":2}"#, false),
(404, "{}", false),
(401, "{}", false),
(403, "{}", false),
(200, "[]", false),
(200, "invalid-json", false),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = vec![0; 4096];
let n = socket.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..n]).to_lowercase();
assert!(request.starts_with("get /v1/capabilities "));
assert!(request.contains("authorization: basic dxnlcjpwyxnzd29yza=="));
socket.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
});
let client = super::DatabaseClient::new(
address.to_string(),
"greptime".into(),
Some("user:password".into()),
std::time::Duration::from_secs(5),
None,
true,
);
assert_eq!(
client.require_packed_import().await.is_ok(),
supported,
"{status}: {body}"
);
server.await.unwrap();
for export in [false, true] {
for (status, body, supported) in [
(200, r#"{"metric_packed_import":1,"future":2}"#, true),
(200, "{}", false),
(200, r#"{"metric_packed_import":2}"#, false),
(404, "{}", false),
(401, "{}", false),
(403, "{}", false),
(200, "[]", false),
(200, "invalid-json", false),
] {
let body = if export {
body.replace("metric_packed_import", "metric_packed_export")
} else {
body.to_string()
};
let response_body = body.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let body = response_body;
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = vec![0; 4096];
let n = socket.read(&mut request).await.unwrap();
let request = String::from_utf8_lossy(&request[..n]).to_lowercase();
assert!(request.starts_with("get /v1/capabilities "));
assert!(request.contains("authorization: basic dxnlcjpwyxnzd29yza=="));
socket.write_all(format!("HTTP/1.1 {status} Response\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
});
let client = super::DatabaseClient::new(
address.to_string(),
"greptime".into(),
Some("user:password".into()),
std::time::Duration::from_secs(5),
None,
true,
);
assert_eq!(
if export {
client.require_packed_export().await.is_ok()
} else {
client.require_packed_import().await.is_ok()
},
supported,
"{status}: {body}"
);
server.await.unwrap();
}
}
}