feat(cmd): add parquet development tools (#8939)

* feat(cmd): add parquet metadata development tool

Signed-off-by: evenyag <realevenyag@gmail.com>

* feat(cmd): add parquet rewrite development tool

Signed-off-by: evenyag <realevenyag@gmail.com>

* feat(cmd): add SST replacement development tool

Signed-off-by: evenyag <realevenyag@gmail.com>

* feat(cmd): support local parquet files in parquetbench

Signed-off-by: evenyag <realevenyag@gmail.com>

* fix(cmd): clean up parquet development tools

Signed-off-by: evenyag <realevenyag@gmail.com>

* refactor(cmd): share datanode tool utilities

Signed-off-by: evenyag <realevenyag@gmail.com>

* fix(cmd): satisfy parquet tool lints

Signed-off-by: evenyag <realevenyag@gmail.com>

* docs: document parquet development tools

Signed-off-by: evenyag <realevenyag@gmail.com>

* refactor(cmd): rename SST replacement module

Signed-off-by: evenyag <realevenyag@gmail.com>

* fix(cmd): validate parquet rewrite options

Signed-off-by: evenyag <realevenyag@gmail.com>

---------

Signed-off-by: evenyag <realevenyag@gmail.com>
This commit is contained in:
Yingwen
2026-08-24 13:34:30 +00:00
committed by GitHub
parent 1c5eabcbbf
commit 0cc83c4570
11 changed files with 2126 additions and 263 deletions
+253
View File
@@ -0,0 +1,253 @@
# Parquet Development Tools
GreptimeDB provides the following datanode CLI commands for inspecting,
rewriting, benchmarking, and replacing Parquet SST files:
- `parquet-meta`: inspect a local Parquet file.
- `parquet-rewrite`: rewrite a local Parquet file with different writer
properties.
- `parquetbench`: benchmark reads from a local GreptimeDB SST or an SST in a
configured object store.
- `sst-replace`: replace an existing Mito SST object and update its manifest
metadata.
These are development and recovery tools, not stable user-facing interfaces.
Always test a workflow on disposable data before using it on an important
region.
## Build
`parquet-meta`, `parquet-rewrite`, and `sst-replace` require the `dev-tools`
feature. The same build also includes `parquetbench`:
```bash
cargo build -p cmd --bin greptime --features dev-tools
```
The examples below use:
```bash
GREPTIME=./target/debug/greptime
```
Run `$GREPTIME datanode <COMMAND> --help` for the authoritative argument list.
## Inspect a Parquet file
`parquet-meta` reads a local Parquet footer and optional page indexes. It reports
file, row-group, and column metadata, including compression, encodings, sizes,
page offsets, statistics presence, and bloom-filter/index offsets.
Text output:
```bash
$GREPTIME datanode parquet-meta \
--input /tmp/source.parquet
```
Machine-readable JSON output:
```bash
$GREPTIME datanode parquet-meta \
--input /tmp/source.parquet \
--format json > /tmp/source-meta.json
```
`--format` accepts `text` (the default) or `json`. This command can inspect a
general Parquet file; it does not require GreptimeDB SST key-value metadata.
## Rewrite a Parquet file
`parquet-rewrite` has two modes. First, dump a TOML properties file inferred
from an existing file:
```bash
$GREPTIME datanode parquet-rewrite \
--input /tmp/source.parquet \
--dump-properties /tmp/writer-properties.toml
```
Review and edit the generated file. Its shape is:
```toml
[writer]
dictionary_enabled = true
compression = "zstd"
max_row_group_row_count = 8192
[[columns]]
path = ["host"]
dictionary_enabled = true
compression = "zstd"
encoding = "plain"
```
Supported compression names are `uncompressed`, `snappy`, `gzip`, `lzo`,
`brotli`, `lz4`, `zstd`, and `lz4-raw`. Supported encodings are `plain`,
`delta-binary-packed`, `delta-length-byte-array`, `delta-byte-array`, and
`byte-stream-split`.
The `[writer]` table also accepts `compression_level`,
`data_page_size_limit`, `data_page_row_count_limit`, and
`dictionary_page_size_limit`. A `[[columns]]` entry overrides dictionary,
compression, compression level, or encoding for its column path. Unknown TOML
fields are rejected. Compression levels are supported for `gzip`, `brotli`, and
`zstd`. A column-level `compression_level` without `compression` inherits the
writer compression codec; it is rejected when there is no writer codec to
inherit.
Rewrite the data using the edited properties:
```bash
$GREPTIME datanode parquet-rewrite \
--input /tmp/source.parquet \
--properties /tmp/writer-properties.toml \
--output /tmp/rewritten.parquet
```
Use `--batch-size <ROWS>` to control reader batch size. Output and dumped
properties files are not replaced unless `--overwrite` is passed. The output or
dump path must not be exactly the same path as the input, even with
`--overwrite`. This check does not resolve symbolic links, hard links, or other
spellings of the same path.
The rewrite decodes and writes the Arrow record batches and copies the Parquet
key-value metadata, but it creates a new physical Parquet layout. Inspect and
validate the result before using it as an SST:
```bash
$GREPTIME datanode parquet-meta \
--input /tmp/rewritten.parquet
```
The dumped properties are inferred primarily from the first row group. Review
them when the source uses different properties across row groups.
## Benchmark an SST
`parquetbench` expects GreptimeDB region metadata embedded in the SST. It can
read a local file with the direct reader:
```bash
$GREPTIME datanode parquetbench \
--file-path /tmp/rewritten.parquet \
--reader direct \
--iterations 5 \
--batch-size 8192
```
Local-file mode cannot be combined with `--config`, `--region-id`,
`--table-dir`, or `--file-id`, and it does not support the `flat-prune` reader.
To benchmark an SST in the object store configured for a datanode or standalone
deployment:
```bash
$GREPTIME datanode parquetbench \
--config /path/to/datanode.toml \
--region-id 1024:0 \
--table-dir data/greptime/public/1024 \
--file-id 00020380-009c-426d-953e-b4e34c15af34 \
--path-type bare \
--reader flat-prune \
--iterations 5
```
Region mode requires all four of `--config`, `--region-id`, `--table-dir`, and
`--file-id`. `--region-id` accepts either the packed unsigned integer or
`<table-id>:<region-number>`. `--path-type` accepts `bare`, `data`, or
`metadata` and defaults to `bare`.
An optional scan configuration selects columns and row groups:
```json
{
"projection_names": ["host", "value", "ts"],
"row_groups": [0, 2]
}
```
```bash
$GREPTIME datanode parquetbench \
--file-path /tmp/rewritten.parquet \
--scan-config /tmp/parquet-scan.json \
--iterations 5
```
Use `--pk-as-binary` to expose `__primary_key` as binary with the direct reader.
On Unix, `--pprof-file <SVG>` writes a flamegraph. Add
`--pprof-after-warmup` and use at least two iterations to exclude the first
iteration from profiling.
## Replace an existing region SST
> **Warning:** `sst-replace` mutates both an SST object and its region manifest.
> Stop the datanode that owns the region and back up the target SST and manifest
> before using `--confirm`. The SST write happens before the manifest update, so
> an interrupted or failed operation may require restoring the backup.
`sst-replace` replaces the contents of an existing SST file ID. It does not add
a new file ID to a region. The command requires the replacement to have the same
row count and row-group count recorded in the manifest when those manifest
values are nonzero. It does not validate schema or row contents. The replacement
is loaded into memory in full, so ensure the machine has enough memory for the
SST.
Start with the default dry run using a local replacement file:
```bash
$GREPTIME datanode sst-replace \
--config /path/to/datanode.toml \
--region-id 1024:0 \
--table-dir data/greptime/public/1024 \
--file-id 00020380-009c-426d-953e-b4e34c15af34 \
--replacement-file /tmp/rewritten.parquet
```
The dry run locates the manifest and target SST, reads and validates the
replacement footer, and prints the old and new sizes without writing anything.
`--path-type` defaults to `auto`, which probes `bare`, `data`, and `metadata`.
Specify the path type if the file ID is visible in more than one manifest.
The replacement can instead be read from the configured object store:
```bash
$GREPTIME datanode sst-replace \
--config /path/to/datanode.toml \
--region-id 1024:0 \
--table-dir data/greptime/public/1024 \
--file-id 00020380-009c-426d-953e-b4e34c15af34 \
--replacement-object staging/rewritten.parquet \
--path-type bare
```
After reviewing the dry-run output and confirming that the datanode is stopped,
repeat the exact command with `--confirm`:
```bash
$GREPTIME datanode sst-replace \
--config /path/to/datanode.toml \
--region-id 1024:0 \
--table-dir data/greptime/public/1024 \
--file-id 00020380-009c-426d-953e-b4e34c15af34 \
--replacement-file /tmp/rewritten.parquet \
--path-type bare \
--confirm
```
The confirmed operation overwrites the existing SST object, recalculates file
size and row-group statistics, and appends a manifest edit for the existing file
ID. Restart the datanode and validate queries against the region before removing
the backup.
## Recommended rewrite and replacement workflow
1. Back up the source SST and its region manifest.
2. Inspect the source with `parquet-meta`.
3. Dump and edit writer properties with `parquet-rewrite`.
4. Rewrite to a new local file; never rewrite directly over the source SST.
5. Inspect the output and benchmark it with `parquetbench`.
6. Stop the owning datanode.
7. Run `sst-replace` without `--confirm` and review its resolved target and
statistics.
8. Repeat with `--confirm`, restart the datanode, and validate the region.
+1 -1
View File
@@ -27,7 +27,7 @@ default = [
"meta-srv/mysql_kvbackend",
]
enterprise = ["common-meta/enterprise", "frontend/enterprise", "meta-srv/enterprise"]
# Developer-only helper binary gate for `query_perf_fixture`.
# Developer-only helper binaries and diagnostic datanode commands.
# Kept out of `default` so normal/release builds don't compile them.
dev-tools = []
mysql-object-store = ["object-store/mysql-object-store"]
+6
View File
@@ -118,6 +118,12 @@ async fn start(cli: Command) -> Result<()> {
datanode::SubCommand::Objbench(ref bench) => bench.run().await,
datanode::SubCommand::Scanbench(ref bench) => bench.run().await,
datanode::SubCommand::Parquetbench(ref bench) => bench.run().await,
#[cfg(feature = "dev-tools")]
datanode::SubCommand::ParquetMeta(ref cmd) => cmd.run().await,
#[cfg(feature = "dev-tools")]
datanode::SubCommand::ParquetRewrite(ref cmd) => cmd.run().await,
#[cfg(feature = "dev-tools")]
datanode::SubCommand::SstReplace(ref cmd) => cmd.run().await,
},
SubCommand::Flownode(cmd) => {
cmd.build(cmd.load_options(&cli.global_options)?)
+46
View File
@@ -15,10 +15,20 @@
pub mod builder;
#[allow(clippy::print_stdout)]
pub(crate) mod objbench;
#[cfg(feature = "dev-tools")]
#[allow(clippy::print_stdout)]
mod parquet_meta;
#[cfg(feature = "dev-tools")]
#[allow(clippy::print_stdout)]
mod parquet_rewrite;
#[allow(clippy::print_stdout)]
pub mod parquetbench;
#[allow(clippy::print_stdout)]
pub mod scanbench;
#[cfg(feature = "dev-tools")]
#[allow(clippy::print_stdout)]
mod sst_replace;
mod tool_util;
use std::path::Path;
use std::time::Duration;
@@ -39,8 +49,14 @@ use tracing_appender::non_blocking::WorkerGuard;
use crate::App;
use crate::datanode::builder::InstanceBuilder;
use crate::datanode::objbench::ObjbenchCommand;
#[cfg(feature = "dev-tools")]
use crate::datanode::parquet_meta::ParquetMetaCommand;
#[cfg(feature = "dev-tools")]
use crate::datanode::parquet_rewrite::ParquetRewriteCommand;
use crate::datanode::parquetbench::ParquetbenchCommand;
use crate::datanode::scanbench::ScanbenchCommand;
#[cfg(feature = "dev-tools")]
use crate::datanode::sst_replace::SstReplaceCommand;
use crate::error::{
LoadLayeredConfigSnafu, MissingConfigSnafu, Result, ShutdownDatanodeSnafu, StartDatanodeSnafu,
};
@@ -114,6 +130,12 @@ impl Command {
// Bench commands are standalone utilities and don't need to load DatanodeOptions.
SubCommand::Objbench(_) | SubCommand::Scanbench(_) => Self::default_bench_options(),
SubCommand::Parquetbench(_) => Self::default_bench_options(),
#[cfg(feature = "dev-tools")]
SubCommand::ParquetMeta(_) => Self::default_bench_options(),
#[cfg(feature = "dev-tools")]
SubCommand::ParquetRewrite(_) => Self::default_bench_options(),
#[cfg(feature = "dev-tools")]
SubCommand::SstReplace(_) => Self::default_bench_options(),
}
}
@@ -139,6 +161,15 @@ pub enum SubCommand {
Scanbench(ScanbenchCommand),
/// Benchmark scanning a single parquet SST.
Parquetbench(ParquetbenchCommand),
/// Display metadata of a parquet file.
#[cfg(feature = "dev-tools")]
ParquetMeta(ParquetMetaCommand),
/// Rewrite a parquet file with different writer properties.
#[cfg(feature = "dev-tools")]
ParquetRewrite(ParquetRewriteCommand),
/// Replace a Mito region SST and update its manifest metadata.
#[cfg(feature = "dev-tools")]
SstReplace(SstReplaceCommand),
}
impl SubCommand {
@@ -160,6 +191,21 @@ impl SubCommand {
cmd.run().await?;
std::process::exit(0);
}
#[cfg(feature = "dev-tools")]
SubCommand::ParquetMeta(cmd) => {
cmd.run().await?;
std::process::exit(0);
}
#[cfg(feature = "dev-tools")]
SubCommand::ParquetRewrite(cmd) => {
cmd.run().await?;
std::process::exit(0);
}
#[cfg(feature = "dev-tools")]
SubCommand::SstReplace(cmd) => {
cmd.run().await?;
std::process::exit(0);
}
}
}
}
+8 -99
View File
@@ -18,8 +18,6 @@ use std::time::Instant;
use clap::Parser;
use colored::Colorize;
use datanode::config::RegionEngineConfig;
use datanode::store;
use futures::stream;
use mito2::access_layer::{
AccessLayer, AccessLayerRef, Metrics, OperationType, SstWriteRequest, WriteType,
@@ -32,19 +30,20 @@ use mito2::sst::file::{FileHandle, FileMeta};
use mito2::sst::file_purger::{FilePurger, FilePurgerRef};
use mito2::sst::index::intermediate::IntermediateManager;
use mito2::sst::index::puffin_manager::PuffinManagerFactory;
use mito2::sst::parquet::WriteOptions;
use mito2::sst::parquet::reader::ParquetReaderBuilder;
use mito2::sst::parquet::{PARQUET_METADATA_KEY, WriteOptions};
use mito2::worker::write_cache_from_config;
use object_store::ObjectStore;
use parquet::file::metadata::{FooterTail, KeyValue};
use parquet::file::metadata::FooterTail;
use regex::Regex;
use snafu::OptionExt;
use store_api::metadata::{RegionMetadata, RegionMetadataRef};
use store_api::path_utils::region_name;
use store_api::region_request::PathType;
use store_api::storage::FileId;
use crate::datanode::{StorageConfig, StorageConfigWrapper};
use crate::datanode::tool_util::{
build_object_store, extract_region_metadata, max_row_group_uncompressed_size, parse_config,
};
use crate::error;
/// Object storage benchmark command
@@ -67,46 +66,6 @@ pub struct ObjbenchCommand {
pub pprof_file: Option<PathBuf>,
}
pub(crate) fn parse_config(
config_path: &PathBuf,
) -> error::Result<(
StorageConfig,
MitoConfig,
common_wal::config::DatanodeWalConfig,
)> {
let cfg_str = std::fs::read_to_string(config_path).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to read config {}: {e}", config_path.display()),
}
.build()
})?;
let store_cfg: StorageConfigWrapper = toml::from_str(&cfg_str).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to parse config {}: {e}", config_path.display()),
}
.build()
})?;
let wal_config = store_cfg.wal;
let storage_config = store_cfg.storage;
let mito_engine_config = store_cfg
.region_engine
.into_iter()
.filter_map(|c| {
if let RegionEngineConfig::Mito(mito) = c {
Some(mito)
} else {
None
}
})
.next()
.with_context(|| error::IllegalConfigSnafu {
msg: format!("Engine config not found in {:?}", config_path),
})?;
Ok((storage_config, mito_engine_config, wal_config))
}
impl ObjbenchCommand {
pub async fn run(&self) -> error::Result<()> {
if self.verbose {
@@ -154,17 +113,7 @@ impl ObjbenchCommand {
let region_meta = extract_region_metadata(&self.source, &parquet_meta)?;
let num_rows = parquet_meta.file_metadata().num_rows() as u64;
let num_row_groups = parquet_meta.num_row_groups() as u64;
let max_row_group_uncompressed_size: u64 = parquet_meta
.row_groups()
.iter()
.map(|rg| {
rg.columns()
.iter()
.map(|c| c.uncompressed_size() as u64)
.sum::<u64>()
})
.max()
.unwrap_or(0);
let max_row_group_uncompressed_size = max_row_group_uncompressed_size(&parquet_meta);
println!(
"{} Metadata loaded - rows: {}, size: {} bytes",
@@ -485,47 +434,6 @@ fn parse_file_dir_components(path: &str) -> error::Result<FileDirComponents> {
})
}
pub(crate) fn extract_region_metadata(
file_path: &str,
meta: &parquet::file::metadata::ParquetMetaData,
) -> error::Result<RegionMetadataRef> {
let kvs: Option<&Vec<KeyValue>> = meta.file_metadata().key_value_metadata();
let Some(kvs) = kvs else {
return Err(error::IllegalConfigSnafu {
msg: format!("{file_path}: missing parquet key_value metadata"),
}
.build());
};
let json = kvs
.iter()
.find(|kv| kv.key == PARQUET_METADATA_KEY)
.and_then(|kv| kv.value.as_ref())
.ok_or_else(|| {
error::IllegalConfigSnafu {
msg: format!("{file_path}: key {PARQUET_METADATA_KEY} not found or empty"),
}
.build()
})?;
let region: RegionMetadata = RegionMetadata::from_json(json).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region metadata json: {e}"),
}
.build()
})?;
Ok(Arc::new(region))
}
pub(crate) async fn build_object_store(sc: &StorageConfig) -> error::Result<ObjectStore> {
store::new_object_store(sc.store.clone(), &sc.data_home)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("Failed to build object store: {e:?}"),
}
.build()
})
}
async fn build_access_layer_simple(
components: &FileDirComponents,
object_store: ObjectStore,
@@ -666,7 +574,8 @@ mod tests {
use common_base::readable_size::ReadableSize;
use store_api::region_request::PathType;
use crate::datanode::objbench::{parse_config, parse_file_dir_components};
use crate::datanode::objbench::parse_file_dir_components;
use crate::datanode::tool_util::parse_config;
#[test]
fn test_parse_dir() {
+340
View File
@@ -0,0 +1,340 @@
// 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::collections::BTreeMap;
use std::path::{Path, PathBuf};
use clap::{Parser, ValueEnum};
use parquet::file::metadata::ParquetMetaData;
use serde::Serialize;
use snafu::ResultExt;
use crate::datanode::tool_util::{compression_name, load_local_parquet_metadata};
use crate::error;
/// Display parquet file metadata.
#[derive(Debug, Parser)]
pub struct ParquetMetaCommand {
/// Path to input parquet file.
#[clap(long, value_name = "FILE")]
input: PathBuf,
/// Output format.
#[clap(long, value_enum, default_value = "text")]
format: MetaOutputFormat,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum MetaOutputFormat {
Text,
Json,
}
#[derive(Debug, Serialize)]
struct FileMetaView {
input: String,
num_rows: i64,
num_row_groups: usize,
num_columns: usize,
key_value_metadata: BTreeMap<String, Option<String>>,
row_groups: Vec<RowGroupMetaView>,
}
#[derive(Debug, Serialize)]
struct RowGroupMetaView {
index: usize,
num_rows: i64,
uncompressed_size: i64,
compressed_size: i64,
compression_ratio: Option<f64>,
data_pages: Option<usize>,
dictionary_page_bytes: i64,
columns: Vec<ColumnChunkMetaView>,
}
#[derive(Debug, Serialize)]
struct ColumnChunkMetaView {
index: usize,
path: String,
physical_type: String,
encodings: Vec<String>,
compression: String,
num_values: i64,
uncompressed_size: i64,
compressed_size: i64,
compression_ratio: Option<f64>,
data_page_offset: i64,
dictionary_page_offset: Option<i64>,
dictionary_page_bytes: Option<i64>,
data_pages: Option<usize>,
has_statistics: bool,
column_index_offset: Option<i64>,
column_index_length: Option<i32>,
offset_index_offset: Option<i64>,
offset_index_length: Option<i32>,
bloom_filter_offset: Option<i64>,
bloom_filter_length: Option<i32>,
}
impl ParquetMetaCommand {
pub async fn run(&self) -> error::Result<()> {
let metadata = load_local_parquet_metadata(&self.input)?;
let view = build_file_meta_view(&self.input, &metadata);
match self.format {
MetaOutputFormat::Text => print_meta_text(&view),
MetaOutputFormat::Json => {
let json = serde_json::to_string_pretty(&view).context(error::SerdeJsonSnafu)?;
println!("{json}");
}
}
Ok(())
}
}
fn build_file_meta_view(path: &Path, metadata: &ParquetMetaData) -> FileMetaView {
let key_value_metadata = metadata
.file_metadata()
.key_value_metadata()
.into_iter()
.flatten()
.map(|kv| (kv.key.clone(), kv.value.clone()))
.collect();
let offset_index = metadata.offset_index();
let row_groups = metadata
.row_groups()
.iter()
.enumerate()
.map(|(row_group_idx, row_group)| {
let columns: Vec<_> = row_group
.columns()
.iter()
.enumerate()
.map(|(column_idx, column)| {
let data_pages = offset_index
.and_then(|index| index.get(row_group_idx))
.and_then(|columns| columns.get(column_idx))
.map(|index| index.page_locations().len());
let dictionary_page_bytes = dictionary_page_bytes(
column.dictionary_page_offset(),
column.data_page_offset(),
);
ColumnChunkMetaView {
index: column_idx,
path: column.column_path().string(),
physical_type: format!("{:?}", column.column_type()),
encodings: column.encodings().map(|enc| format!("{enc:?}")).collect(),
compression: compression_name(column.compression()).to_string(),
num_values: column.num_values(),
uncompressed_size: column.uncompressed_size(),
compressed_size: column.compressed_size(),
compression_ratio: compression_ratio(
column.uncompressed_size(),
column.compressed_size(),
),
data_page_offset: column.data_page_offset(),
dictionary_page_offset: column.dictionary_page_offset(),
dictionary_page_bytes,
data_pages,
has_statistics: column.statistics().is_some(),
column_index_offset: column.column_index_offset(),
column_index_length: column.column_index_length(),
offset_index_offset: column.offset_index_offset(),
offset_index_length: column.offset_index_length(),
bloom_filter_offset: column.bloom_filter_offset(),
bloom_filter_length: column.bloom_filter_length(),
}
})
.collect();
let data_pages = if columns.iter().all(|column| column.data_pages.is_some()) {
Some(
columns
.iter()
.map(|column| column.data_pages.unwrap_or_default())
.sum(),
)
} else {
None
};
let dictionary_page_bytes = columns
.iter()
.map(|column| column.dictionary_page_bytes.unwrap_or_default())
.sum();
RowGroupMetaView {
index: row_group_idx,
num_rows: row_group.num_rows(),
uncompressed_size: row_group.total_byte_size(),
compressed_size: row_group.compressed_size(),
compression_ratio: compression_ratio(
row_group.total_byte_size(),
row_group.compressed_size(),
),
data_pages,
dictionary_page_bytes,
columns,
}
})
.collect();
FileMetaView {
input: path.display().to_string(),
num_rows: metadata.file_metadata().num_rows(),
num_row_groups: metadata.num_row_groups(),
num_columns: metadata.file_metadata().schema_descr().num_columns(),
key_value_metadata,
row_groups,
}
}
fn print_meta_text(view: &FileMetaView) {
println!("file: {}", view.input);
println!("rows: {}", view.num_rows);
println!("row_groups: {}", view.num_row_groups);
println!("columns: {}", view.num_columns);
println!("key_value_metadata: {}", view.key_value_metadata.len());
for row_group in &view.row_groups {
println!(
"row_group[{}]: rows={}, uncompressed_size={}, compressed_size={}, compression_ratio={}, data_pages={}, dictionary_page_bytes={}",
row_group.index,
row_group.num_rows,
row_group.uncompressed_size,
row_group.compressed_size,
format_ratio(row_group.compression_ratio),
format_optional_usize(row_group.data_pages),
row_group.dictionary_page_bytes,
);
for column in &row_group.columns {
println!(
" column[{}] path={}: type={}, compression={}, encodings=[{}], values={}, uncompressed_size={}, compressed_size={}, compression_ratio={}, data_pages={}, dictionary_page_offset={}, dictionary_page_bytes={}, data_page_offset={}, statistics={}, column_index={}/{}, offset_index={}/{}, bloom_filter={}/{}",
column.index,
column.path,
column.physical_type,
column.compression,
column.encodings.join(","),
column.num_values,
column.uncompressed_size,
column.compressed_size,
format_ratio(column.compression_ratio),
format_optional_usize(column.data_pages),
format_optional_i64(column.dictionary_page_offset),
column
.dictionary_page_bytes
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
column.data_page_offset,
column.has_statistics,
format_optional_i64(column.column_index_offset),
format_optional_i32(column.column_index_length),
format_optional_i64(column.offset_index_offset),
format_optional_i32(column.offset_index_length),
format_optional_i64(column.bloom_filter_offset),
format_optional_i32(column.bloom_filter_length),
);
}
}
}
fn dictionary_page_bytes(
dictionary_page_offset: Option<i64>,
data_page_offset: i64,
) -> Option<i64> {
dictionary_page_offset.map(|offset| data_page_offset.saturating_sub(offset))
}
fn compression_ratio(uncompressed: i64, compressed: i64) -> Option<f64> {
(uncompressed > 0).then(|| compressed as f64 / uncompressed as f64)
}
fn format_ratio(ratio: Option<f64>) -> String {
ratio
.map(|value| format!("{value:.4}"))
.unwrap_or_else(|| "unknown".to_string())
}
fn format_optional_usize(value: Option<usize>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn format_optional_i64(value: Option<i64>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
}
fn format_optional_i32(value: Option<i32>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
}
#[cfg(test)]
mod tests {
use std::fs::File;
use std::sync::Arc;
use datatypes::arrow::array::{Int32Array, RecordBatch, StringArray};
use datatypes::arrow::datatypes::{DataType, Field, Schema};
use parquet::arrow::ArrowWriter;
use parquet::file::metadata::KeyValue;
use parquet::file::properties::WriterProperties;
use tempfile::tempdir;
use super::*;
#[test]
fn test_meta_view_unknown_page_count() {
let dir = tempdir().unwrap();
let path = dir.path().join("input.parquet");
write_test_parquet(&path);
let metadata = load_local_parquet_metadata(&path).unwrap();
let view = build_file_meta_view(&path, &metadata);
assert_eq!(view.num_row_groups, 1);
assert!(view.row_groups[0].uncompressed_size > 0);
assert!(view.row_groups[0].compressed_size > 0);
assert!(view.row_groups[0].compression_ratio.is_some());
assert_eq!(view.row_groups[0].columns.len(), 2);
}
fn write_test_parquet(path: &Path) {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("host", DataType::Utf8, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3, 4])),
Arc::new(StringArray::from(vec!["a", "a", "b", "b"])),
],
)
.unwrap();
let props = WriterProperties::builder()
.set_key_value_metadata(Some(vec![KeyValue::new(
"greptime:test".to_string(),
"value".to_string(),
)]))
.build();
let mut writer =
ArrowWriter::try_new(File::create(path).unwrap(), schema, Some(props)).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
}
+579
View File
@@ -0,0 +1,579 @@
// 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::fs::File;
use std::path::{Path, PathBuf};
use clap::Parser;
use datatypes::arrow::record_batch::RecordBatchReader;
use parquet::arrow::ArrowWriter;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::basic::{BrotliLevel, Compression, Encoding, GzipLevel, ZstdLevel};
use parquet::file::metadata::ParquetMetaData;
use parquet::file::properties::WriterProperties;
use parquet::file::reader::FileReader;
use parquet::file::serialized_reader::SerializedFileReader;
use parquet::schema::types::ColumnPath;
use serde::{Deserialize, Serialize};
use snafu::ResultExt;
use crate::datanode::tool_util::load_local_parquet_metadata;
use crate::error;
/// Read and rewrite a parquet file with different writer properties.
#[derive(Debug, Parser)]
pub struct ParquetRewriteCommand {
/// Path to input parquet file.
#[clap(long, value_name = "FILE")]
input: PathBuf,
/// Path to output parquet file in rewrite mode.
#[clap(long, value_name = "FILE")]
output: Option<PathBuf>,
/// Path to writer properties TOML in rewrite mode.
#[clap(long, value_name = "FILE")]
properties: Option<PathBuf>,
/// Dump writer properties TOML inferred from the input parquet file.
#[clap(long, value_name = "FILE")]
dump_properties: Option<PathBuf>,
/// Number of rows per record batch.
#[clap(long)]
batch_size: Option<usize>,
/// Overwrite output files.
#[clap(long, default_value_t = false)]
overwrite: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
struct RewriteProperties {
writer: WriterConfig,
columns: Vec<ColumnConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default, deny_unknown_fields)]
struct WriterConfig {
dictionary_enabled: Option<bool>,
compression: Option<CompressionConfig>,
compression_level: Option<u32>,
encoding: Option<EncodingConfig>,
max_row_group_row_count: Option<usize>,
data_page_size_limit: Option<usize>,
data_page_row_count_limit: Option<usize>,
dictionary_page_size_limit: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
struct ColumnConfig {
path: Vec<String>,
dictionary_enabled: Option<bool>,
compression: Option<CompressionConfig>,
compression_level: Option<u32>,
encoding: Option<EncodingConfig>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum CompressionConfig {
Uncompressed,
Snappy,
Gzip,
Lzo,
Brotli,
Lz4,
Zstd,
Lz4Raw,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum EncodingConfig {
Plain,
DeltaBinaryPacked,
DeltaLengthByteArray,
DeltaByteArray,
ByteStreamSplit,
}
impl ParquetRewriteCommand {
pub async fn run(&self) -> error::Result<()> {
match (&self.dump_properties, &self.output, &self.properties) {
(Some(path), None, None) => self.dump_properties(path),
(None, Some(output), Some(properties)) => self.rewrite(output, properties),
(Some(_), Some(_), _) | (Some(_), _, Some(_)) => illegal_config(
"use either --dump-properties or rewrite mode, not both".to_string(),
),
(None, _, _) => illegal_config(
"rewrite mode requires --output and --properties; config dump mode requires --dump-properties".to_string(),
),
}
}
fn dump_properties(&self, path: &Path) -> error::Result<()> {
ensure_distinct_paths(&self.input, path)?;
ensure_can_write(path, self.overwrite)?;
let metadata = load_local_parquet_metadata(&self.input)?;
let properties = infer_rewrite_properties(&metadata);
let content = toml::to_string_pretty(&properties).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to serialize writer properties: {e}"),
}
.build()
})?;
std::fs::write(path, content).context(error::FileIoSnafu)?;
println!("Wrote writer properties to {}", path.display());
Ok(())
}
fn rewrite(&self, output: &Path, properties: &Path) -> error::Result<()> {
ensure_distinct_paths(&self.input, output)?;
ensure_can_write(output, self.overwrite)?;
let props = load_rewrite_properties(properties)?;
let input_reader = SerializedFileReader::new(open_file(&self.input)?)
.map_err(|e| parquet_error("read source parquet metadata", &self.input, e))?;
let key_value_metadata = input_reader
.metadata()
.file_metadata()
.key_value_metadata()
.cloned();
let mut reader_builder = ParquetRecordBatchReaderBuilder::try_new(open_file(&self.input)?)
.map_err(|e| parquet_error("open source parquet", &self.input, e))?;
if let Some(batch_size) = self.batch_size {
if batch_size == 0 {
return illegal_config("--batch-size must be greater than 0".to_string());
}
reader_builder = reader_builder.with_batch_size(batch_size);
}
let reader = reader_builder
.build()
.map_err(|e| parquet_error("build parquet reader", &self.input, e))?;
let schema = reader.schema();
let writer_props = build_writer_properties(props, key_value_metadata)?;
let mut writer = ArrowWriter::try_new(create_file(output)?, schema, Some(writer_props))
.map_err(|e| parquet_error("create parquet writer", output, e))?;
for batch in reader {
let batch =
batch.map_err(|e| parquet_error("read parquet batch", &self.input, e.into()))?;
writer
.write(&batch)
.map_err(|e| parquet_error("write parquet batch", output, e))?;
}
writer
.close()
.map_err(|e| parquet_error("close parquet writer", output, e))?;
println!("Wrote parquet file to {}", output.display());
Ok(())
}
}
fn infer_rewrite_properties(metadata: &ParquetMetaData) -> RewriteProperties {
let first_column = metadata
.row_groups()
.first()
.and_then(|row_group| row_group.columns().first());
let compression = first_column.map(|column| compression_to_config(column.compression()));
let max_row_group_row_count = metadata
.row_groups()
.first()
.and_then(|row_group| usize::try_from(row_group.num_rows()).ok());
let dictionary_enabled = first_column
.map(|column| column.dictionary_page_offset().is_some())
.or(Some(true));
let columns = metadata
.file_metadata()
.schema_descr()
.columns()
.iter()
.enumerate()
.map(|(idx, column)| {
let first_chunk = metadata
.row_groups()
.first()
.and_then(|row_group| row_group.columns().get(idx));
ColumnConfig {
path: column.path().parts().to_vec(),
dictionary_enabled: first_chunk
.map(|chunk| chunk.dictionary_page_offset().is_some()),
compression: first_chunk.map(|chunk| compression_to_config(chunk.compression())),
compression_level: None,
encoding: first_chunk.and_then(infer_data_encoding),
}
})
.collect();
RewriteProperties {
writer: WriterConfig {
dictionary_enabled,
compression,
compression_level: None,
encoding: first_column.and_then(infer_data_encoding),
max_row_group_row_count,
data_page_size_limit: None,
data_page_row_count_limit: None,
dictionary_page_size_limit: None,
},
columns,
}
}
fn load_rewrite_properties(path: &Path) -> error::Result<RewriteProperties> {
let content = std::fs::read_to_string(path).context(error::FileIoSnafu)?;
toml::from_str(&content).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to parse writer properties {}: {e}", path.display()),
}
.build()
})
}
fn build_writer_properties(
config: RewriteProperties,
key_value_metadata: Option<Vec<parquet::file::metadata::KeyValue>>,
) -> error::Result<WriterProperties> {
let mut builder = WriterProperties::builder().set_key_value_metadata(key_value_metadata);
let writer_compression = config.writer.compression;
if let Some(dictionary_enabled) = config.writer.dictionary_enabled {
builder = builder.set_dictionary_enabled(dictionary_enabled);
}
match (writer_compression, config.writer.compression_level) {
(Some(compression), level) => {
builder = builder.set_compression(to_parquet_compression(compression, level)?);
}
(None, Some(_)) => {
return illegal_config(
"writer compression_level requires writer compression".to_string(),
);
}
(None, None) => {}
}
if let Some(encoding) = config.writer.encoding {
builder = builder.set_encoding(to_parquet_encoding(encoding));
}
if let Some(max_row_group_row_count) = config.writer.max_row_group_row_count {
if max_row_group_row_count == 0 {
return illegal_config("max_row_group_row_count must be greater than 0".to_string());
}
builder = builder.set_max_row_group_row_count(Some(max_row_group_row_count));
}
if let Some(data_page_size_limit) = config.writer.data_page_size_limit {
builder = builder.set_data_page_size_limit(data_page_size_limit);
}
if let Some(data_page_row_count_limit) = config.writer.data_page_row_count_limit {
builder = builder.set_data_page_row_count_limit(data_page_row_count_limit);
}
if let Some(dictionary_page_size_limit) = config.writer.dictionary_page_size_limit {
builder = builder.set_dictionary_page_size_limit(dictionary_page_size_limit);
}
for column in config.columns {
if column.path.is_empty() {
return illegal_config("column path must not be empty".to_string());
}
let path = ColumnPath::new(column.path);
if let Some(dictionary_enabled) = column.dictionary_enabled {
builder = builder.set_column_dictionary_enabled(path.clone(), dictionary_enabled);
}
if column.compression.is_some() || column.compression_level.is_some() {
let Some(compression) = column.compression.or(writer_compression) else {
return illegal_config(format!(
"compression_level for column {} requires column or writer compression",
path.string()
));
};
builder = builder.set_column_compression(
path.clone(),
to_parquet_compression(compression, column.compression_level)?,
);
}
if let Some(encoding) = column.encoding {
builder = builder.set_column_encoding(path, to_parquet_encoding(encoding));
}
}
Ok(builder.build())
}
fn ensure_distinct_paths(input: &Path, output: &Path) -> error::Result<()> {
if input == output {
return illegal_config(format!(
"input and output paths must be different: {}",
input.display()
));
}
Ok(())
}
fn ensure_can_write(path: &Path, overwrite: bool) -> error::Result<()> {
if !overwrite && path.exists() {
return illegal_config(format!(
"{} already exists; pass --overwrite to replace it",
path.display()
));
}
Ok(())
}
fn open_file(path: &Path) -> error::Result<File> {
File::open(path).context(error::FileIoSnafu)
}
fn create_file(path: &Path) -> error::Result<File> {
File::create(path).context(error::FileIoSnafu)
}
fn illegal_config<T>(msg: String) -> error::Result<T> {
error::IllegalConfigSnafu { msg }.fail()
}
fn parquet_error(
action: &'static str,
path: &Path,
error: parquet::errors::ParquetError,
) -> error::Error {
error::IllegalConfigSnafu {
msg: format!("{action} failed for {}: {error}", path.display()),
}
.build()
}
fn to_parquet_compression(
compression: CompressionConfig,
level: Option<u32>,
) -> error::Result<Compression> {
if level.is_some()
&& !matches!(
compression,
CompressionConfig::Gzip | CompressionConfig::Brotli | CompressionConfig::Zstd
)
{
return illegal_config(format!(
"compression level is not supported for {}",
compression.name()
));
}
Ok(match compression {
CompressionConfig::Uncompressed => Compression::UNCOMPRESSED,
CompressionConfig::Snappy => Compression::SNAPPY,
CompressionConfig::Gzip => Compression::GZIP(match level {
Some(level) => GzipLevel::try_new(level).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid gzip compression level {level}: {e}"),
}
.build()
})?,
None => GzipLevel::default(),
}),
CompressionConfig::Lzo => Compression::LZO,
CompressionConfig::Brotli => Compression::BROTLI(match level {
Some(level) => BrotliLevel::try_new(level).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid brotli compression level {level}: {e}"),
}
.build()
})?,
None => BrotliLevel::default(),
}),
CompressionConfig::Lz4 => Compression::LZ4,
CompressionConfig::Zstd => Compression::ZSTD(match level {
Some(level) => {
let converted_level = i32::try_from(level).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid zstd compression level {level}: {e}"),
}
.build()
})?;
ZstdLevel::try_new(converted_level).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid zstd compression level {level}: {e}"),
}
.build()
})?
}
None => ZstdLevel::default(),
}),
CompressionConfig::Lz4Raw => Compression::LZ4_RAW,
})
}
impl CompressionConfig {
fn name(self) -> &'static str {
match self {
CompressionConfig::Uncompressed => "uncompressed",
CompressionConfig::Snappy => "snappy",
CompressionConfig::Gzip => "gzip",
CompressionConfig::Lzo => "lzo",
CompressionConfig::Brotli => "brotli",
CompressionConfig::Lz4 => "lz4",
CompressionConfig::Zstd => "zstd",
CompressionConfig::Lz4Raw => "lz4-raw",
}
}
}
fn to_parquet_encoding(encoding: EncodingConfig) -> Encoding {
match encoding {
EncodingConfig::Plain => Encoding::PLAIN,
EncodingConfig::DeltaBinaryPacked => Encoding::DELTA_BINARY_PACKED,
EncodingConfig::DeltaLengthByteArray => Encoding::DELTA_LENGTH_BYTE_ARRAY,
EncodingConfig::DeltaByteArray => Encoding::DELTA_BYTE_ARRAY,
EncodingConfig::ByteStreamSplit => Encoding::BYTE_STREAM_SPLIT,
}
}
fn infer_data_encoding(
column: &parquet::file::metadata::ColumnChunkMetaData,
) -> Option<EncodingConfig> {
let encodings: Vec<_> = column.encodings().collect();
if encodings.contains(&Encoding::DELTA_BINARY_PACKED) {
Some(EncodingConfig::DeltaBinaryPacked)
} else if encodings.contains(&Encoding::DELTA_LENGTH_BYTE_ARRAY) {
Some(EncodingConfig::DeltaLengthByteArray)
} else if encodings.contains(&Encoding::DELTA_BYTE_ARRAY) {
Some(EncodingConfig::DeltaByteArray)
} else if encodings.contains(&Encoding::BYTE_STREAM_SPLIT) {
Some(EncodingConfig::ByteStreamSplit)
} else if encodings.contains(&Encoding::PLAIN) {
Some(EncodingConfig::Plain)
} else {
None
}
}
fn compression_to_config(compression: Compression) -> CompressionConfig {
match compression {
Compression::UNCOMPRESSED => CompressionConfig::Uncompressed,
Compression::SNAPPY => CompressionConfig::Snappy,
Compression::GZIP(_) => CompressionConfig::Gzip,
Compression::LZO => CompressionConfig::Lzo,
Compression::BROTLI(_) => CompressionConfig::Brotli,
Compression::LZ4 => CompressionConfig::Lz4,
Compression::ZSTD(_) => CompressionConfig::Zstd,
Compression::LZ4_RAW => CompressionConfig::Lz4Raw,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use datatypes::arrow::array::{Int32Array, RecordBatch, StringArray};
use datatypes::arrow::datatypes::{DataType, Field, Schema};
use parquet::file::metadata::KeyValue;
use tempfile::tempdir;
use super::*;
#[test]
fn test_dump_and_rewrite_preserves_key_value_metadata_and_disables_dictionary() {
let dir = tempdir().unwrap();
let input = dir.path().join("input.parquet");
let output = dir.path().join("output.parquet");
write_test_parquet(&input, true);
let metadata = load_local_parquet_metadata(&input).unwrap();
let mut properties = infer_rewrite_properties(&metadata);
properties.columns[1].dictionary_enabled = Some(false);
let props_path = dir.path().join("props.toml");
std::fs::write(&props_path, toml::to_string(&properties).unwrap()).unwrap();
let command = ParquetRewriteCommand {
input: input.clone(),
output: Some(output.clone()),
properties: Some(props_path),
dump_properties: None,
batch_size: Some(2),
overwrite: false,
};
command
.rewrite(&output, command.properties.as_ref().unwrap())
.unwrap();
let rewritten = load_local_parquet_metadata(&output).unwrap();
assert_eq!(rewritten.file_metadata().num_rows(), 4);
let key_values = rewritten
.file_metadata()
.key_value_metadata()
.cloned()
.unwrap_or_default();
assert!(key_values.iter().any(|kv| kv.key == "greptime:test"));
assert!(
rewritten
.row_groups()
.iter()
.all(|row_group| row_group.column(1).dictionary_page_offset().is_none())
);
}
#[test]
fn test_column_compression_level_inherits_writer_compression() {
let path = ColumnPath::new(vec!["host".to_string()]);
let config = RewriteProperties {
writer: WriterConfig {
compression: Some(CompressionConfig::Zstd),
compression_level: Some(1),
..Default::default()
},
columns: vec![ColumnConfig {
path: vec!["host".to_string()],
compression_level: Some(3),
..Default::default()
}],
};
let properties = build_writer_properties(config, None).unwrap();
assert_eq!(
properties.compression(&path),
Compression::ZSTD(ZstdLevel::try_new(3).unwrap())
);
}
fn write_test_parquet(path: &Path, dictionary_enabled: bool) {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("host", DataType::Utf8, false),
]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3, 4])),
Arc::new(StringArray::from(vec!["a", "a", "b", "b"])),
],
)
.unwrap();
let props = WriterProperties::builder()
.set_dictionary_enabled(dictionary_enabled)
.set_key_value_metadata(Some(vec![KeyValue::new(
"greptime:test".to_string(),
"value".to_string(),
)]))
.build();
let mut writer =
ArrowWriter::try_new(File::create(path).unwrap(), schema, Some(props)).unwrap();
writer.write(&batch).unwrap();
writer.close().unwrap();
}
}
+292 -107
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -42,7 +42,10 @@ use store_api::region_request::PathType;
use store_api::storage::consts::{PRIMARY_KEY_COLUMN_NAME, is_internal_column};
use store_api::storage::{ColumnId, FileId, RegionId};
use crate::datanode::objbench::{build_object_store, extract_region_metadata, parse_config};
use crate::datanode::tool_util::{
build_object_store, extract_region_metadata, format_bytes, parse_config, parse_file_id,
parse_path_type, parse_region_id,
};
use crate::error;
const DEFAULT_READ_BATCH_SIZE: usize = 8 * 1024;
@@ -52,19 +55,23 @@ const DEFAULT_READ_BATCH_SIZE: usize = 8 * 1024;
pub struct ParquetbenchCommand {
/// Path to config TOML file (same format as standalone/datanode config)
#[clap(long, value_name = "FILE")]
config: PathBuf,
config: Option<PathBuf>,
/// Region ID: either numeric u64 (e.g. "4398046511104") or "table_id:region_num" (e.g. "1024:0")
#[clap(long)]
region_id: String,
region_id: Option<String>,
/// Table directory relative to data home (e.g. "data/greptime/public/1024/")
#[clap(long)]
table_dir: String,
table_dir: Option<String>,
/// SST file id to benchmark.
#[clap(long)]
file_id: String,
file_id: Option<String>,
/// Local parquet SST file to benchmark with the direct reader.
#[clap(long, value_name = "FILE")]
file_path: Option<PathBuf>,
/// Path to scan request JSON config file (supports projection_names only)
#[clap(long, value_name = "FILE")]
@@ -129,6 +136,32 @@ struct IterationStats {
elapsed: Duration,
}
#[derive(Debug)]
enum ParquetbenchInput {
LocalFile {
file_path: PathBuf,
},
Region {
config: PathBuf,
region_id: String,
table_dir: String,
file_id: String,
path_type: PathType,
},
}
struct ParquetbenchSource {
object_store: object_store::ObjectStore,
file_path: String,
display_path: String,
region_id_label: String,
region_id: RegionId,
file_id: FileId,
region_file_id: RegionFileId,
path_type: Option<PathType>,
table_dir: Option<String>,
}
impl ParquetbenchCommand {
pub async fn run(&self) -> error::Result<()> {
if self.verbose {
@@ -145,41 +178,40 @@ impl ParquetbenchCommand {
.fail();
}
let region_id = parse_region_id(&self.region_id)?;
let path_type = parse_path_type(&self.path_type)?;
let file_id = FileId::parse_str(&self.file_id).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid file_id '{}': {}", self.file_id, e),
}
.build()
})?;
let region_file_id = RegionFileId::new(region_id, file_id);
let file_path = sst_file_path(&self.table_dir, region_file_id, path_type);
let input = self.resolve_input()?;
let mut source = build_source(input).await?;
let (store_cfg, _mito_config, _wal_config) = parse_config(&self.config)?;
let object_store = build_object_store(&store_cfg).await?;
let file_size = object_store
.stat(&file_path)
let file_size = source
.object_store
.stat(&source.file_path)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("stat failed for {}: {}", file_path, e),
msg: format!("stat failed for {}: {}", source.display_path, e),
}
.build()
})?
.content_length();
let mut metadata_metrics = MetadataCacheMetrics::default();
let parquet_meta = MetadataLoader::new(object_store.clone(), &file_path, file_size)
.load(&mut metadata_metrics)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("read parquet metadata failed for {}: {:?}", file_path, e),
}
.build()
})?;
let region_meta = extract_region_metadata(&file_path, &parquet_meta)?;
let parquet_meta =
MetadataLoader::new(source.object_store.clone(), &source.file_path, file_size)
.load(&mut metadata_metrics)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!(
"read parquet metadata failed for {}: {:?}",
source.display_path, e
),
}
.build()
})?;
let region_meta = extract_region_metadata(&source.display_path, &parquet_meta)?;
if source.table_dir.is_none() {
source.region_id = region_meta.region_id;
source.region_id_label = source.region_id.as_u64().to_string();
source.region_file_id = RegionFileId::new(source.region_id, source.file_id);
}
let scan_config = self.load_scan_config().await?;
let projection = if self.reader == ReaderMode::Direct {
resolve_projection_names(&scan_config, &region_meta)?
@@ -216,10 +248,10 @@ impl ParquetbenchCommand {
println!(
"{} Region ID: {} (u64: {})",
"".green(),
self.region_id,
region_id.as_u64()
source.region_id_label,
source.region_id.as_u64()
);
println!("{} File path: {}", "".green(), file_path.cyan());
println!("{} File path: {}", "".green(), source.display_path.cyan());
println!(
"{} Columns: {}",
"".green(),
@@ -307,8 +339,8 @@ impl ParquetbenchCommand {
let mut schema_printed = false;
let file_handle = FileHandle::new(
FileMeta {
region_id,
file_id,
region_id: source.region_id,
file_id: source.file_id,
time_range: Default::default(),
level: 0,
file_size,
@@ -332,9 +364,9 @@ impl ParquetbenchCommand {
let stats = match self.reader {
ReaderMode::Direct => {
run_direct_iteration(
object_store.clone(),
file_path.clone(),
region_file_id,
source.object_store.clone(),
source.file_path.clone(),
source.region_file_id,
parquet_meta.clone(),
projection.clone(),
row_groups.clone(),
@@ -345,9 +377,19 @@ impl ParquetbenchCommand {
}
ReaderMode::FlatPrune => {
run_flat_prune_iteration(
object_store.clone(),
self.table_dir.clone(),
path_type,
source.object_store.clone(),
source.table_dir.clone().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: "flat-prune reader requires --table-dir".to_string(),
}
.build()
})?,
source.path_type.ok_or_else(|| {
error::IllegalConfigSnafu {
msg: "flat-prune reader requires --path-type".to_string(),
}
.build()
})?,
file_handle.clone(),
region_meta.clone(),
projection_column_ids.clone(),
@@ -450,6 +492,65 @@ impl ParquetbenchCommand {
Ok(())
}
fn resolve_input(&self) -> error::Result<ParquetbenchInput> {
let has_region_args = self.config.is_some()
|| self.region_id.is_some()
|| self.table_dir.is_some()
|| self.file_id.is_some();
if let Some(file_path) = &self.file_path {
if self.reader == ReaderMode::FlatPrune {
return Err(error::IllegalConfigSnafu {
msg: "--file-path currently supports only --reader direct".to_string(),
}
.build());
}
if has_region_args {
return Err(error::IllegalConfigSnafu {
msg: "--file-path cannot be used with --config, --region-id, --table-dir, or --file-id".to_string(),
}
.build());
}
return Ok(ParquetbenchInput::LocalFile {
file_path: file_path.clone(),
});
}
let config = self.config.clone().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: "missing --config unless --file-path is specified".to_string(),
}
.build()
})?;
let region_id = self.region_id.clone().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: "missing --region-id unless --file-path is specified".to_string(),
}
.build()
})?;
let table_dir = self.table_dir.clone().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: "missing --table-dir unless --file-path is specified".to_string(),
}
.build()
})?;
let file_id = self.file_id.clone().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: "missing --file-id unless --file-path is specified".to_string(),
}
.build()
})?;
let path_type = parse_path_type(&self.path_type)?;
Ok(ParquetbenchInput::Region {
config,
region_id,
table_dir,
file_id,
path_type,
})
}
async fn load_scan_config(&self) -> error::Result<ParquetScanConfig> {
if let Some(path) = &self.scan_config {
let content = tokio::fs::read_to_string(path)
@@ -462,6 +563,117 @@ impl ParquetbenchCommand {
}
}
async fn build_source(input: ParquetbenchInput) -> error::Result<ParquetbenchSource> {
match input {
ParquetbenchInput::LocalFile { file_path } => build_local_file_source(&file_path),
ParquetbenchInput::Region {
config,
region_id,
table_dir,
file_id,
path_type,
} => build_region_source(&config, &region_id, table_dir, &file_id, path_type).await,
}
}
fn build_local_file_source(file_path: &Path) -> error::Result<ParquetbenchSource> {
let file_path = std::fs::canonicalize(file_path).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid --file-path {}: {e}", file_path.display()),
}
.build()
})?;
if !file_path.is_file() {
return Err(error::IllegalConfigSnafu {
msg: format!("--file-path {} is not a file", file_path.display()),
}
.build());
}
let parent = file_path.parent().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: format!(
"--file-path {} has no parent directory",
file_path.display()
),
}
.build()
})?;
let file_name = file_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
error::IllegalConfigSnafu {
msg: format!("invalid UTF-8 file name in {}", file_path.display()),
}
.build()
})?
.to_string();
let object_store = object_store::ObjectStore::new(object_store::services::Fs::default().root(
parent.to_str().ok_or_else(|| {
error::IllegalConfigSnafu {
msg: format!("invalid UTF-8 parent directory in {}", file_path.display()),
}
.build()
})?,
))
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to build local file object store: {e:?}"),
}
.build()
})?
.finish();
let file_id = file_path
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| FileId::parse_str(stem).ok())
.unwrap_or_else(FileId::random);
let region_id = RegionId::new(0, 0);
let region_file_id = RegionFileId::new(region_id, file_id);
let display_path = file_path.display().to_string();
Ok(ParquetbenchSource {
object_store,
file_path: file_name,
display_path,
region_id_label: region_id.as_u64().to_string(),
region_id,
file_id,
region_file_id,
path_type: None,
table_dir: None,
})
}
async fn build_region_source(
config: &Path,
region_id: &str,
table_dir: String,
file_id: &str,
path_type: PathType,
) -> error::Result<ParquetbenchSource> {
let region = parse_region_id(region_id)?;
let file_id = parse_file_id(file_id)?;
let region_file_id = RegionFileId::new(region, file_id);
let file_path = sst_file_path(&table_dir, region_file_id, path_type);
let (store_cfg, _mito_config, _wal_config) = parse_config(config)?;
let object_store = build_object_store(&store_cfg).await?;
Ok(ParquetbenchSource {
object_store,
display_path: file_path.clone(),
file_path,
region_id_label: region_id.to_string(),
region_id: region,
file_id,
region_file_id,
path_type: Some(path_type),
table_dir: Some(table_dir),
})
}
#[allow(clippy::too_many_arguments)]
async fn run_direct_iteration(
object_store: object_store::ObjectStore,
@@ -773,60 +985,6 @@ fn parse_batch_size(s: &str) -> Result<usize, String> {
Ok(batch_size)
}
fn parse_region_id(s: &str) -> error::Result<RegionId> {
if s.contains(':') {
let parts: Vec<&str> = s.splitn(2, ':').collect();
let table_id: u32 = parts[0].parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid table_id in region_id '{}': {}", s, e),
}
.build()
})?;
let region_num: u32 = parts[1].parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region_num in region_id '{}': {}", s, e),
}
.build()
})?;
Ok(RegionId::new(table_id, region_num))
} else {
let id: u64 = s.parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region_id '{}': {}", s, e),
}
.build()
})?;
Ok(RegionId::from_u64(id))
}
}
fn parse_path_type(s: &str) -> error::Result<PathType> {
match s.to_lowercase().as_str() {
"bare" => Ok(PathType::Bare),
"data" => Ok(PathType::Data),
"metadata" => Ok(PathType::Metadata),
_ => Err(error::IllegalConfigSnafu {
msg: format!("invalid path_type '{}', expected: bare, data, metadata", s),
}
.build()),
}
}
fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;
if bytes >= GIB {
format!("{:.2} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.2} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.2} KiB", bytes as f64 / KIB as f64)
} else {
format!("{} B", bytes)
}
}
fn format_rate(rate: f64) -> String {
if !rate.is_finite() {
return "inf rows".to_string();
@@ -851,6 +1009,25 @@ mod tests {
use super::*;
fn test_command() -> ParquetbenchCommand {
ParquetbenchCommand {
config: None,
region_id: None,
table_dir: None,
file_id: None,
file_path: None,
scan_config: None,
iterations: 1,
batch_size: DEFAULT_READ_BATCH_SIZE,
path_type: "bare".to_string(),
verbose: false,
pprof_file: None,
pprof_after_warmup: false,
pk_as_binary: false,
reader: ReaderMode::Direct,
}
}
fn new_test_metadata() -> RegionMetadata {
let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 0));
builder
@@ -882,19 +1059,27 @@ mod tests {
}
#[test]
fn test_parse_region_id() {
assert_eq!(parse_region_id("1024:7").unwrap(), RegionId::new(1024, 7));
assert_eq!(
parse_region_id(&RegionId::new(1, 2).as_u64().to_string()).unwrap(),
RegionId::new(1, 2)
);
fn test_resolve_input_accepts_direct_file_for_direct_reader() {
let mut command = test_command();
command.file_path = Some(PathBuf::from("/tmp/source.parquet"));
let input = command.resolve_input().unwrap();
match input {
ParquetbenchInput::LocalFile { file_path } => {
assert_eq!(file_path, PathBuf::from("/tmp/source.parquet"));
}
ParquetbenchInput::Region { .. } => panic!("expected local file input"),
}
}
#[test]
fn test_parse_path_type() {
assert_eq!(parse_path_type("bare").unwrap(), PathType::Bare);
assert_eq!(parse_path_type("data").unwrap(), PathType::Data);
assert_eq!(parse_path_type("metadata").unwrap(), PathType::Metadata);
fn test_resolve_input_rejects_mixed_input_modes() {
let mut command = test_command();
command.file_path = Some(PathBuf::from("/tmp/source.parquet"));
command.config = Some(PathBuf::from("config.toml"));
let err = command.resolve_input().unwrap_err();
assert!(err.to_string().contains("--file-path cannot be used with"));
}
#[test]
+4 -56
View File
@@ -52,11 +52,13 @@ use sqlparser::parser::Parser as SqlParser;
use store_api::metadata::RegionMetadata;
use store_api::path_utils::WAL_DIR;
use store_api::region_engine::{PrepareRequest, QueryScanContext, RegionEngine};
use store_api::region_request::{PathType, RegionOpenRequest, RegionRequest};
use store_api::region_request::{RegionOpenRequest, RegionRequest};
use store_api::storage::{RegionId, ScanRequest, TimeSeriesDistribution, TimeSeriesRowSelector};
use tokio::fs;
use crate::datanode::objbench::{build_object_store, parse_config};
use crate::datanode::tool_util::{
build_object_store, format_bytes, parse_config, parse_path_type, parse_region_id,
};
use crate::error;
/// Scan benchmark command - benchmarks scanning a region directly from storage.
@@ -164,60 +166,6 @@ fn resolve_projection(
Ok(None)
}
fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;
if bytes >= GIB {
format!("{:.2} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.2} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.2} KiB", bytes as f64 / KIB as f64)
} else {
format!("{} B", bytes)
}
}
fn parse_region_id(s: &str) -> error::Result<RegionId> {
if s.contains(':') {
let parts: Vec<&str> = s.splitn(2, ':').collect();
let table_id: u32 = parts[0].parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid table_id in region_id '{}': {}", s, e),
}
.build()
})?;
let region_num: u32 = parts[1].parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region_num in region_id '{}': {}", s, e),
}
.build()
})?;
Ok(RegionId::new(table_id, region_num))
} else {
let id: u64 = s.parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region_id '{}': {}", s, e),
}
.build()
})?;
Ok(RegionId::from_u64(id))
}
}
fn parse_path_type(s: &str) -> error::Result<PathType> {
match s.to_lowercase().as_str() {
"bare" => Ok(PathType::Bare),
"data" => Ok(PathType::Data),
"metadata" => Ok(PathType::Metadata),
_ => Err(error::IllegalConfigSnafu {
msg: format!("invalid path_type '{}', expected: bare, data, metadata", s),
}
.build()),
}
}
/// Rewrites literal values in comparison expressions to match the column's arrow type.
struct LiteralTypeCaster {
schema: DFSchemaRef,
+361
View File
@@ -0,0 +1,361 @@
// 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::path::PathBuf;
use clap::Parser;
use colored::Colorize;
use mito2::manifest::action::{RegionEdit, RegionMetaAction, RegionMetaActionList};
use mito2::manifest::manager::{RegionManifestManager, RegionManifestOptions};
use mito2::region::ManifestStats;
use mito2::sst::file::RegionFileId;
use mito2::sst::location::{region_dir_from_table_dir, sst_file_path};
use object_store::ObjectStore;
use parquet::file::FOOTER_SIZE;
use parquet::file::metadata::{FooterTail, ParquetMetaDataReader};
use store_api::region_request::PathType;
use crate::datanode::tool_util::{
build_object_store, max_row_group_uncompressed_size, parse_config, parse_file_id,
parse_path_type, parse_region_id,
};
use crate::error;
/// Replace a mito region SST and update the corresponding manifest metadata.
#[derive(Debug, Parser)]
pub struct SstReplaceCommand {
/// Path to config TOML file (same format as standalone/datanode config).
#[clap(long, value_name = "FILE")]
config: PathBuf,
/// Region ID: either numeric u64 (e.g. "4398046511104") or "table_id:region_num" (e.g. "1024:0").
#[clap(long)]
region_id: String,
/// Table directory relative to data home (e.g. "data/greptime/public/1024/").
#[clap(long)]
table_dir: String,
/// SST file id to replace.
#[clap(long)]
file_id: String,
/// Local parquet file used as the replacement.
#[clap(long, value_name = "FILE", conflicts_with = "replacement_object")]
replacement_file: Option<PathBuf>,
/// Object-store parquet path used as the replacement.
#[clap(long, value_name = "PATH", conflicts_with = "replacement_file")]
replacement_object: Option<String>,
/// Path type for the region: auto, bare, data, metadata.
#[clap(long, default_value = "auto")]
path_type: String,
/// Actually overwrite the SST object and append a manifest delta.
#[clap(long, default_value_t = false)]
confirm: bool,
/// Verbose output.
#[clap(short, long, default_value_t = false)]
verbose: bool,
}
impl SstReplaceCommand {
pub async fn run(&self) -> error::Result<()> {
if self.verbose {
common_telemetry::init_default_ut_logging();
}
println!("{}", "Starting sst-replace...".cyan().bold());
let region_id = parse_region_id(&self.region_id)?;
let file_id = parse_file_id(&self.file_id)?;
let path_type = parse_optional_path_type(&self.path_type)?;
let replacement = self.replacement()?;
let (store_cfg, mito_config, _wal_config) = parse_config(&self.config)?;
let object_store = build_object_store(&store_cfg).await?;
println!("{} Object store initialized", "[ok]".green());
let candidates = path_type
.map(|path_type| vec![path_type])
.unwrap_or_else(|| vec![PathType::Bare, PathType::Data, PathType::Metadata]);
let mut found = None;
let mut existing_manifests = Vec::new();
for candidate in candidates {
let stats = ManifestStats::default();
let region_dir = region_dir_from_table_dir(&self.table_dir, region_id, candidate);
let manifest_opts =
RegionManifestOptions::new(&mito_config, &region_dir, &object_store);
let Some(manifest_manager) = RegionManifestManager::open(manifest_opts, &stats)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("open {} manifest failed: {e:?}", path_type_name(candidate)),
}
.build()
})?
else {
continue;
};
existing_manifests.push(path_type_name(candidate));
let manifest = manifest_manager.manifest();
if let Some(file) = manifest.files.get(&file_id) {
if found.is_some() {
return error::IllegalConfigSnafu {
msg: format!(
"file {} exists in multiple manifests under region {}; specify --path-type",
file_id, region_id
),
}
.fail();
}
found = Some((candidate, manifest_manager, file.clone()));
}
}
let Some((path_type, mut manifest_manager, old_file)) = found else {
let suffix = if existing_manifests.is_empty() {
"no manifest found".to_string()
} else {
format!(
"manifests found for [{}], but none contains file {}",
existing_manifests.join(", "),
file_id
)
};
return error::IllegalConfigSnafu {
msg: format!("region manifest not found for {}: {}", region_id, suffix),
}
.fail();
};
let region_file_id = RegionFileId::new(old_file.region_id, old_file.file_id);
let target_path = sst_file_path(&self.table_dir, region_file_id, path_type);
let target_stat = object_store.stat(&target_path).await.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("stat target SST {} failed: {e}", target_path),
}
.build()
})?;
let replacement_bytes = load_replacement_bytes(&object_store, replacement).await?;
let new_file_size = replacement_bytes.len() as u64;
let parquet_meta = decode_parquet_metadata(&replacement_bytes).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("read replacement parquet metadata failed: {e}"),
}
.build()
})?;
let new_num_rows = parquet_meta.file_metadata().num_rows() as u64;
let new_num_row_groups = parquet_meta.num_row_groups() as u64;
validate_replacement(&old_file, new_num_rows, new_num_row_groups)?;
let mut new_file = old_file.clone();
new_file.file_size = new_file_size;
new_file.num_rows = new_num_rows;
new_file.num_row_groups = new_num_row_groups;
new_file.max_row_group_uncompressed_size = max_row_group_uncompressed_size(&parquet_meta);
println!("{} Region: {}", "[ok]".green(), region_id);
println!("{} Target SST: {}", "[ok]".green(), target_path.cyan());
println!(
"{} Size: {} -> {} bytes",
"[ok]".green(),
target_stat.content_length(),
new_file_size
);
println!(
"{} Rows: {}, row groups: {}",
"[ok]".green(),
new_num_rows,
new_num_row_groups
);
if !self.confirm {
println!(
"{} Dry run only. Re-run with --confirm to overwrite the SST and update the manifest.",
"[dry-run]".yellow()
);
return Ok(());
}
object_store
.write(&target_path, replacement_bytes)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("write replacement SST {} failed: {e}", target_path),
}
.build()
})?;
let edit = RegionEdit {
files_to_add: vec![new_file],
files_to_remove: vec![],
timestamp_ms: None,
compaction_time_window: None,
flushed_entry_id: None,
flushed_sequence: None,
committed_sequence: None,
};
let version = manifest_manager
.update(
RegionMetaActionList::with_action(RegionMetaAction::Edit(edit)),
false,
)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("update manifest failed: {e:?}"),
}
.build()
})?;
println!(
"{} Replacement committed, manifest version {}",
"[ok]".green(),
version
);
Ok(())
}
fn replacement(&self) -> error::Result<Replacement<'_>> {
match (&self.replacement_file, &self.replacement_object) {
(Some(path), None) => Ok(Replacement::Local(path)),
(None, Some(path)) => Ok(Replacement::Object(path)),
_ => error::IllegalConfigSnafu {
msg: "specify exactly one of --replacement-file or --replacement-object"
.to_string(),
}
.fail(),
}
}
}
enum Replacement<'a> {
Local(&'a PathBuf),
Object(&'a str),
}
async fn load_replacement_bytes(
object_store: &ObjectStore,
replacement: Replacement<'_>,
) -> error::Result<Vec<u8>> {
match replacement {
Replacement::Local(path) => tokio::fs::read(path).await.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("read replacement file {} failed: {e}", path.display()),
}
.build()
}),
Replacement::Object(path) => {
object_store
.read(path)
.await
.map(|b| b.to_vec())
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("read replacement object {} failed: {e}", path),
}
.build()
})
}
}
}
fn validate_replacement(
old_file: &mito2::sst::file::FileMeta,
new_num_rows: u64,
new_num_row_groups: u64,
) -> error::Result<()> {
if old_file.num_rows != 0 && old_file.num_rows != new_num_rows {
return error::IllegalConfigSnafu {
msg: format!(
"replacement row count mismatch: manifest has {}, replacement has {}",
old_file.num_rows, new_num_rows
),
}
.fail();
}
if old_file.num_row_groups != 0 && old_file.num_row_groups != new_num_row_groups {
return error::IllegalConfigSnafu {
msg: format!(
"replacement row group count mismatch: manifest has {}, replacement has {}",
old_file.num_row_groups, new_num_row_groups
),
}
.fail();
}
Ok(())
}
fn decode_parquet_metadata(
data: &[u8],
) -> Result<parquet::file::metadata::ParquetMetaData, Box<dyn std::error::Error + Send + Sync>> {
if data.len() < FOOTER_SIZE {
return Err("file too small".into());
}
let footer_start = data.len() - FOOTER_SIZE;
let mut footer = [0; FOOTER_SIZE];
footer.copy_from_slice(&data[footer_start..]);
let footer = FooterTail::try_new(&footer)?;
let metadata_len = footer.metadata_length();
if footer_start < metadata_len {
return Err("invalid footer/metadata length".into());
}
let metadata_start = footer_start - metadata_len;
Ok(ParquetMetaDataReader::decode_metadata(
&data[metadata_start..footer_start],
)?)
}
fn parse_optional_path_type(value: &str) -> error::Result<Option<PathType>> {
match value.to_lowercase().as_str() {
"auto" => Ok(None),
_ => parse_path_type(value).map(Some).map_err(|_| {
error::IllegalConfigSnafu {
msg: format!("invalid path_type '{value}', expected: auto, bare, data, metadata"),
}
.build()
}),
}
}
fn path_type_name(path_type: PathType) -> &'static str {
match path_type {
PathType::Bare => "bare",
PathType::Data => "data",
PathType::Metadata => "metadata",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_replacement_counts() {
let old_file = mito2::sst::file::FileMeta {
num_rows: 4,
num_row_groups: 1,
..Default::default()
};
assert!(validate_replacement(&old_file, 4, 1).is_ok());
assert!(validate_replacement(&old_file, 3, 1).is_err());
assert!(validate_replacement(&old_file, 4, 2).is_err());
let legacy_file = mito2::sst::file::FileMeta::default();
assert!(validate_replacement(&legacy_file, 4, 1).is_ok());
}
}
+236
View File
@@ -0,0 +1,236 @@
// 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.
#[cfg(feature = "dev-tools")]
use std::fs::File;
use std::path::Path;
use std::sync::Arc;
use common_wal::config::DatanodeWalConfig;
use datanode::config::RegionEngineConfig;
use datanode::store;
use mito2::config::MitoConfig;
use object_store::ObjectStore;
#[cfg(feature = "dev-tools")]
use parquet::basic::Compression;
use parquet::file::metadata::{KeyValue, ParquetMetaData};
#[cfg(feature = "dev-tools")]
use parquet::file::metadata::{PageIndexPolicy, ParquetMetaDataReader};
use snafu::OptionExt;
#[cfg(feature = "dev-tools")]
use snafu::ResultExt;
use store_api::metadata::{RegionMetadata, RegionMetadataRef};
use store_api::region_request::PathType;
use store_api::storage::{FileId, RegionId};
use crate::datanode::{StorageConfig, StorageConfigWrapper};
use crate::error;
pub(crate) fn parse_config(
config_path: &Path,
) -> error::Result<(StorageConfig, MitoConfig, DatanodeWalConfig)> {
let cfg_str = std::fs::read_to_string(config_path).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to read config {}: {e}", config_path.display()),
}
.build()
})?;
let store_cfg: StorageConfigWrapper = toml::from_str(&cfg_str).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("failed to parse config {}: {e}", config_path.display()),
}
.build()
})?;
let wal_config = store_cfg.wal;
let storage_config = store_cfg.storage;
let mito_engine_config = store_cfg
.region_engine
.into_iter()
.find_map(|config| match config {
RegionEngineConfig::Mito(mito) => Some(mito),
_ => None,
})
.with_context(|| error::IllegalConfigSnafu {
msg: format!("Engine config not found in {:?}", config_path),
})?;
Ok((storage_config, mito_engine_config, wal_config))
}
pub(crate) async fn build_object_store(config: &StorageConfig) -> error::Result<ObjectStore> {
store::new_object_store(config.store.clone(), &config.data_home)
.await
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("Failed to build object store: {e:?}"),
}
.build()
})
}
pub(crate) fn extract_region_metadata(
file_path: &str,
metadata: &ParquetMetaData,
) -> error::Result<RegionMetadataRef> {
let key_values: Option<&Vec<KeyValue>> = metadata.file_metadata().key_value_metadata();
let Some(key_values) = key_values else {
return Err(error::IllegalConfigSnafu {
msg: format!("{file_path}: missing parquet key_value metadata"),
}
.build());
};
let json = key_values
.iter()
.find(|key_value| key_value.key == mito2::sst::parquet::PARQUET_METADATA_KEY)
.and_then(|key_value| key_value.value.as_ref())
.ok_or_else(|| {
error::IllegalConfigSnafu {
msg: format!(
"{file_path}: key {} not found or empty",
mito2::sst::parquet::PARQUET_METADATA_KEY
),
}
.build()
})?;
let region = RegionMetadata::from_json(json).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region metadata json: {e}"),
}
.build()
})?;
Ok(Arc::new(region))
}
pub(crate) fn parse_region_id(value: &str) -> error::Result<RegionId> {
if let Some((table_id, region_number)) = value.split_once(':') {
let table_id = table_id.parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid table_id in region_id '{value}': {e}"),
}
.build()
})?;
let region_number = region_number.parse().map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region_num in region_id '{value}': {e}"),
}
.build()
})?;
Ok(RegionId::new(table_id, region_number))
} else {
value.parse().map(RegionId::from_u64).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid region_id '{value}': {e}"),
}
.build()
})
}
}
pub(crate) fn parse_file_id(value: &str) -> error::Result<FileId> {
FileId::parse_str(value).map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("invalid file_id '{value}': {e}"),
}
.build()
})
}
pub(crate) fn parse_path_type(value: &str) -> error::Result<PathType> {
match value.to_lowercase().as_str() {
"bare" => Ok(PathType::Bare),
"data" => Ok(PathType::Data),
"metadata" => Ok(PathType::Metadata),
_ => Err(error::IllegalConfigSnafu {
msg: format!("invalid path_type '{value}', expected: bare, data, metadata"),
}
.build()),
}
}
pub(crate) fn format_bytes(bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;
if bytes >= GIB {
format!("{:.2} GiB", bytes as f64 / GIB as f64)
} else if bytes >= MIB {
format!("{:.2} MiB", bytes as f64 / MIB as f64)
} else if bytes >= KIB {
format!("{:.2} KiB", bytes as f64 / KIB as f64)
} else {
format!("{bytes} B")
}
}
pub(crate) fn max_row_group_uncompressed_size(metadata: &ParquetMetaData) -> u64 {
metadata
.row_groups()
.iter()
.map(|row_group| {
row_group
.columns()
.iter()
.map(|column| column.uncompressed_size() as u64)
.sum::<u64>()
})
.max()
.unwrap_or(0)
}
#[cfg(feature = "dev-tools")]
pub(crate) fn load_local_parquet_metadata(path: &Path) -> error::Result<ParquetMetaData> {
let file = File::open(path).context(error::FileIoSnafu)?;
ParquetMetaDataReader::new()
.with_page_index_policy(PageIndexPolicy::Optional)
.parse_and_finish(&file)
.map_err(|e| {
error::IllegalConfigSnafu {
msg: format!("read parquet metadata failed for {}: {e}", path.display()),
}
.build()
})
}
#[cfg(feature = "dev-tools")]
pub(crate) fn compression_name(compression: Compression) -> &'static str {
match compression {
Compression::UNCOMPRESSED => "uncompressed",
Compression::SNAPPY => "snappy",
Compression::GZIP(_) => "gzip",
Compression::LZO => "lzo",
Compression::BROTLI(_) => "brotli",
Compression::LZ4 => "lz4",
Compression::ZSTD(_) => "zstd",
Compression::LZ4_RAW => "lz4-raw",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_region_and_path_type() {
assert_eq!(parse_region_id("1024:7").unwrap(), RegionId::new(1024, 7));
assert_eq!(
parse_region_id(&RegionId::new(1, 2).as_u64().to_string()).unwrap(),
RegionId::new(1, 2)
);
assert_eq!(parse_path_type("bare").unwrap(), PathType::Bare);
assert_eq!(parse_path_type("data").unwrap(), PathType::Data);
assert_eq!(parse_path_type("metadata").unwrap(), PathType::Metadata);
}
}