Skip to content

Commit cac761e

Browse files
Zstd manifest (#1738)
New Features - Added zstd-compressed manifest storage with backward-compatible decoding of uncompressed manifests. - Added ranged object retrieval for large downloads across storage backends. - Updated metrics series hashing to emit an accurate 64-bit series hash for grouping. Performance - Improved object listing/downloading using bounded concurrency while preserving listing order. - Improved manifest fetching behavior by removing the manifest stats escape hatch. Bug Fixes - Prevented precision loss for full 64-bit series hash values. - Improved parquet bloom filter placement for the metric column. --------- Co-authored-by: Nikhil Sinha <nikhil.sinha@parseable.com>
1 parent 0c4ce73 commit cac761e

18 files changed

Lines changed: 698 additions & 91 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ thiserror = "2.0"
186186
ulid = { version = "1.2.1", features = ["serde"] }
187187
uuid = { version = "1.23.3", features = ["v4"] }
188188
xxhash-rust = { version = "0.8.15", features = ["xxh3"] }
189+
zstd = "0.13.3"
189190
rustc-hash = "2.1.2"
190191
futures-core = "0.3.31"
191192
tempfile = "3.20.0"

‎src/catalog/manifest.rs‎

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,92 @@ pub enum SortOrder {
5050
pub type SortInfo = (String, SortOrder);
5151
pub const CURRENT_MANIFEST_VERSION: &str = "v1";
5252

53+
/// Leading bytes of a zstd frame, used to tell a compressed manifest from a
54+
/// plain JSON one. Manifests written before compression was introduced are
55+
/// still read as-is, and the file name is identical either way, so this is the
56+
/// only thing distinguishing the two encodings.
57+
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
58+
59+
/// Compression level for manifests. Manifest JSON repeats the same column names
60+
/// across thousands of file entries, so it compresses roughly 15-25x even at
61+
/// this cheap level; higher levels cost write time for very little extra.
62+
const ZSTD_LEVEL: i32 = 3;
63+
64+
/// Cap on manifests being decoded at once by [`decode_manifest_blocking`].
65+
///
66+
/// Decoding is CPU bound, so there is nothing to gain past the core count, and
67+
/// each in-flight decode holds a fully decompressed manifest in memory — which
68+
/// is two orders of magnitude larger than the compressed form.
69+
static DECODE_PERMITS: std::sync::LazyLock<std::sync::Arc<tokio::sync::Semaphore>> =
70+
std::sync::LazyLock::new(|| std::sync::Arc::new(tokio::sync::Semaphore::new(num_cpus::get())));
71+
72+
#[derive(Debug, thiserror::Error)]
73+
pub enum ManifestCodecError {
74+
#[error("failed to compress manifest: {0}")]
75+
Compress(std::io::Error),
76+
77+
#[error("manifest decode task failed: {0}")]
78+
Join(#[from] tokio::task::JoinError),
79+
80+
#[error("failed to decompress manifest: {0}")]
81+
Decompress(std::io::Error),
82+
83+
#[error("failed to serialize manifest: {0}")]
84+
Serialize(serde_json::Error),
85+
86+
#[error("failed to parse manifest: {0}")]
87+
Parse(serde_json::Error),
88+
}
89+
90+
/// Encode a manifest for storage. This and [`decode_manifest`] are the only
91+
/// places manifest bytes are produced or interpreted; going around them would
92+
/// write a manifest nothing else can read.
93+
pub fn encode_manifest(manifest: &Manifest) -> Result<bytes::Bytes, ManifestCodecError> {
94+
let json = serde_json::to_vec(manifest).map_err(ManifestCodecError::Serialize)?;
95+
let compressed =
96+
zstd::encode_all(json.as_slice(), ZSTD_LEVEL).map_err(ManifestCodecError::Compress)?;
97+
Ok(bytes::Bytes::from(compressed))
98+
}
99+
100+
/// Decode a manifest, accepting both the compressed and the plain JSON form.
101+
///
102+
/// Existing manifests are never rewritten, so uncompressed ones stay readable
103+
/// indefinitely rather than being migrated.
104+
pub fn decode_manifest(bytes: &[u8]) -> Result<Manifest, ManifestCodecError> {
105+
if bytes.starts_with(&ZSTD_MAGIC) {
106+
let json = zstd::decode_all(bytes).map_err(ManifestCodecError::Decompress)?;
107+
serde_json::from_slice(&json).map_err(ManifestCodecError::Parse)
108+
} else {
109+
serde_json::from_slice(bytes).map_err(ManifestCodecError::Parse)
110+
}
111+
}
112+
113+
/// Decode a manifest on the blocking pool.
114+
///
115+
/// Decompressing and parsing a manifest is hundreds of milliseconds of pure
116+
/// CPU. Run inline on an async task it serialises against every other decode
117+
/// that task drives — callers fetch manifests with `buffered(..)`, which gives
118+
/// concurrent I/O but polls all those futures from one task, so the decodes
119+
/// queue up behind each other and each pending GET appears to take longer and
120+
/// longer. Handing the work to the blocking pool lets them actually run in
121+
/// parallel.
122+
pub async fn decode_manifest_blocking(bytes: bytes::Bytes) -> Result<Manifest, ManifestCodecError> {
123+
// Owned permit moved into the task: dropping this future cancels the await
124+
// but not the blocking work, so the permit has to outlive it to keep
125+
// detached decodes counted.
126+
let permit = DECODE_PERMITS
127+
.clone()
128+
.acquire_owned()
129+
.await
130+
.expect("decode semaphore is never closed");
131+
132+
tokio::task::spawn_blocking(move || {
133+
let _permit = permit;
134+
decode_manifest(&bytes)
135+
})
136+
.await?
137+
}
138+
53139
/// An entry in a manifest which points to a single file.
54140
/// Additionally, it is meant to store the statistics for the file it
55141
/// points to. Used for pruning file at planning level.
@@ -199,3 +285,101 @@ fn column_statistics(row_groups: &[RowGroupMetaData]) -> HashMap<String, Column>
199285
}
200286
columns
201287
}
288+
289+
#[cfg(test)]
290+
mod codec_tests {
291+
use super::*;
292+
293+
fn sample_manifest(file_count: usize) -> Manifest {
294+
let files = (0..file_count)
295+
.map(|i| File {
296+
file_path: format!("stream/date=2026-07-29/hour=00/file-{i}.parquet"),
297+
num_rows: 262_144,
298+
file_size: 1_048_576,
299+
ingestion_size: 2_097_152,
300+
columns: (0..64)
301+
.map(|c| Column {
302+
name: format!("some_reasonably_long_column_name_{c}"),
303+
stats: None,
304+
uncompressed_size: 1024,
305+
compressed_size: 512,
306+
})
307+
.collect(),
308+
sort_order_id: Vec::new(),
309+
})
310+
.collect();
311+
Manifest {
312+
version: CURRENT_MANIFEST_VERSION.to_string(),
313+
files,
314+
}
315+
}
316+
317+
#[test]
318+
fn round_trips() {
319+
let manifest = sample_manifest(4);
320+
let decoded = decode_manifest(&encode_manifest(&manifest).unwrap()).unwrap();
321+
322+
assert_eq!(decoded.version, manifest.version);
323+
assert_eq!(decoded.files.len(), manifest.files.len());
324+
assert_eq!(decoded.files[0].file_path, manifest.files[0].file_path);
325+
assert_eq!(
326+
decoded.files[0].columns.len(),
327+
manifest.files[0].columns.len()
328+
);
329+
}
330+
331+
#[test]
332+
fn encodes_as_zstd() {
333+
let encoded = encode_manifest(&sample_manifest(1)).unwrap();
334+
assert!(
335+
encoded.starts_with(&ZSTD_MAGIC),
336+
"manifest was not compressed"
337+
);
338+
}
339+
340+
/// Manifests written before compression are never rewritten, so plain JSON
341+
/// has to stay readable forever.
342+
#[test]
343+
fn reads_uncompressed_manifests() {
344+
let manifest = sample_manifest(3);
345+
let plain = serde_json::to_vec(&manifest).unwrap();
346+
assert!(!plain.starts_with(&ZSTD_MAGIC));
347+
348+
let decoded = decode_manifest(&plain).unwrap();
349+
assert_eq!(decoded.files.len(), 3);
350+
assert_eq!(decoded.files[2].file_path, manifest.files[2].file_path);
351+
}
352+
353+
/// Guards against the codec silently degrading to a no-op: this shape is
354+
/// the whole reason compression is worth doing.
355+
#[test]
356+
fn compresses_repetitive_manifests_substantially() {
357+
let manifest = sample_manifest(64);
358+
let plain_len = serde_json::to_vec(&manifest).unwrap().len();
359+
let encoded_len = encode_manifest(&manifest).unwrap().len();
360+
361+
assert!(
362+
encoded_len * 10 < plain_len,
363+
"expected >10x compression, got {plain_len} -> {encoded_len}"
364+
);
365+
}
366+
367+
#[test]
368+
fn rejects_truncated_compressed_input() {
369+
let encoded = encode_manifest(&sample_manifest(8)).unwrap();
370+
let truncated = &encoded[..encoded.len() / 2];
371+
372+
assert!(matches!(
373+
decode_manifest(truncated),
374+
Err(ManifestCodecError::Decompress(_))
375+
));
376+
}
377+
378+
#[test]
379+
fn rejects_garbage_input() {
380+
assert!(matches!(
381+
decode_manifest(b"not json, not zstd"),
382+
Err(ManifestCodecError::Parse(_))
383+
));
384+
}
385+
}

‎src/cli.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ pub struct Options {
228228
)]
229229
pub target_partitions: u64,
230230

231+
// DataFusion bloom filter position
232+
// defaults to true, which means `BloomFilterPosition::AfterRowGroup`
233+
#[arg(
234+
long,
235+
env = "P_DATAFUSION_BLOOM_FILTER_DEFAULT_POSITION",
236+
default_value_t = true,
237+
help = "DataFusion bloom filter position"
238+
)]
239+
pub bloom_filter_default_position: bool,
240+
231241
#[arg(
232242
long = "origin",
233243
env = "P_ORIGIN_URI",

‎src/event/format/json.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,9 +526,38 @@ fn validate_struct(
526526
mod tests {
527527
use std::str::FromStr;
528528

529+
use arrow_array::UInt64Array;
529530
use serde_json::json;
530531

531532
use super::*;
533+
use crate::otel::metrics::SERIES_HASH_COLUMN;
534+
535+
#[test]
536+
fn series_hash_is_decoded_as_uint64_without_precision_loss() {
537+
let hash = u64::MAX;
538+
let event = Event::new(json!({ "__series_hash_u64": hash }), Utc::now());
539+
540+
let (data, fields, _) = event
541+
.to_data(&HashMap::new(), None, SchemaVersion::V1, false, true)
542+
.unwrap();
543+
let schema = Arc::new(Schema::new(fields));
544+
assert_eq!(
545+
schema
546+
.field_with_name(SERIES_HASH_COLUMN)
547+
.unwrap()
548+
.data_type(),
549+
&DataType::UInt64
550+
);
551+
552+
let batch = Event::decode(data, schema).unwrap();
553+
let hashes = batch
554+
.column_by_name(SERIES_HASH_COLUMN)
555+
.unwrap()
556+
.as_any()
557+
.downcast_ref::<UInt64Array>()
558+
.unwrap();
559+
assert_eq!(hashes.value(0), hash);
560+
}
532561

533562
#[test]
534563
fn parse_time_parition_from_value() {

‎src/event/format/mod.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use tracing::info_span;
3434
use crate::{
3535
handlers::TelemetryType,
3636
metadata::SchemaVersion,
37+
otel::metrics::SERIES_HASH_COLUMN,
3738
storage::StreamType,
3839
utils::arrow::{add_parseable_fields, get_field},
3940
};
@@ -369,6 +370,12 @@ pub fn override_data_type(
369370
let mut field_name = field.name().to_string();
370371
normalize_field_name(&mut field_name);
371372
match (schema_version, map.get(field.name())) {
373+
// Series identity must retain all 64 bits. Schema V1 normally
374+
// coerces JSON numbers to Float64, which cannot represent the
375+
// full u64 range exactly.
376+
(_, Some(Value::Number(_))) if field_name == SERIES_HASH_COLUMN => {
377+
Field::new(field_name, DataType::UInt64, true)
378+
}
372379
// in V1 for new fields in json named "time"/"date" or such and having
373380
// inferred type string, that can be parsed as timestamp, use the
374381
// timestamp type. Gated on `infer_timestamp` (default true) — settable

‎src/metastore/metastore_traits.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,9 +277,12 @@ pub trait Metastore: std::fmt::Debug + Send + Sync {
277277
manifest_url: Option<String>,
278278
tenant_id: &Option<String>,
279279
) -> Result<Option<Manifest>, MetastoreError>;
280+
/// Takes a concrete `&Manifest` rather than `&dyn MetastoreObject` because
281+
/// manifests have their own encoding (see `catalog::manifest::encode_manifest`)
282+
/// and must not go through the generic JSON path used by other metadata.
280283
async fn put_manifest(
281284
&self,
282-
obj: &dyn MetastoreObject,
285+
manifest: &Manifest,
283286
stream_name: &str,
284287
lower_bound: DateTime<Utc>,
285288
upper_bound: DateTime<Utc>,

‎src/metastore/metastores/object_store_metastore.rs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ use crate::{
3838
outbound_http_policy::AlertTargetPolicyConfig,
3939
target::Target,
4040
},
41-
catalog::{manifest::Manifest, partition_path},
41+
catalog::{
42+
manifest::{Manifest, decode_manifest_blocking, encode_manifest},
43+
partition_path,
44+
},
4245
handlers::http::{
4346
modal::{Metadata, NodeMetadata, NodeType},
4447
users::USERS_ROOT_DIR,
@@ -1020,7 +1023,7 @@ impl Metastore for ObjectStoreMetastore {
10201023
.storage
10211024
.get_object(&RelativePathBuf::from(path), &tenant)
10221025
.await?;
1023-
Ok::<Manifest, MetastoreError>(serde_json::from_slice(&bytes)?)
1026+
Ok::<Manifest, MetastoreError>(decode_manifest_blocking(bytes).await?)
10241027
}
10251028
})
10261029
.buffer_unordered(16)
@@ -1083,7 +1086,7 @@ impl Metastore for ObjectStoreMetastore {
10831086
};
10841087
match self.storage.get_object(&path, tenant_id).await {
10851088
Ok(bytes) => {
1086-
let manifest = serde_json::from_slice(&bytes)?;
1089+
let manifest = decode_manifest_blocking(bytes).await?;
10871090
Ok(Some(manifest))
10881091
}
10891092
Err(ObjectStorageError::NoSuchKey(_)) => Ok(None),
@@ -1120,7 +1123,7 @@ impl Metastore for ObjectStoreMetastore {
11201123

11211124
async fn put_manifest(
11221125
&self,
1123-
obj: &dyn MetastoreObject,
1126+
manifest: &Manifest,
11241127
stream_name: &str,
11251128
lower_bound: DateTime<Utc>,
11261129
upper_bound: DateTime<Utc>,
@@ -1132,7 +1135,7 @@ impl Metastore for ObjectStoreMetastore {
11321135

11331136
Ok(self
11341137
.storage
1135-
.put_object(&path, to_bytes(obj), tenant_id)
1138+
.put_object(&path, encode_manifest(manifest)?, tenant_id)
11361139
.await?)
11371140
}
11381141

‎src/metastore/mod.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ pub enum MetastoreError {
4343
#[error("JSON parsing error: {0}")]
4444
JsonParseError(#[from] serde_json::Error),
4545

46+
#[error("Manifest encoding error: {0}")]
47+
ManifestCodecError(#[from] crate::catalog::manifest::ManifestCodecError),
48+
4649
#[error("JSON schema validation error: {message}")]
4750
JsonSchemaError { message: String },
4851

@@ -97,6 +100,15 @@ impl MetastoreError {
97100
metadata: std::collections::HashMap::new(),
98101
status_code: 400,
99102
},
103+
MetastoreError::ManifestCodecError(e) => MetastoreErrorDetail {
104+
operation: "ManifestCodecError".to_string(),
105+
message: e.to_string(),
106+
stream_name: None,
107+
file_path: None,
108+
timestamp: Some(chrono::Utc::now()),
109+
metadata: std::collections::HashMap::new(),
110+
status_code: 500,
111+
},
100112
MetastoreError::JsonSchemaError { message } => MetastoreErrorDetail {
101113
operation: "JsonSchemaError".to_string(),
102114
message: message.clone(),
@@ -150,6 +162,7 @@ impl MetastoreError {
150162
match self {
151163
MetastoreError::ObjectStorageError(..) => StatusCode::INTERNAL_SERVER_ERROR,
152164
MetastoreError::JsonParseError(..) => StatusCode::INTERNAL_SERVER_ERROR,
165+
MetastoreError::ManifestCodecError(..) => StatusCode::INTERNAL_SERVER_ERROR,
153166
MetastoreError::JsonSchemaError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
154167
MetastoreError::InvalidJsonStructure { .. } => StatusCode::INTERNAL_SERVER_ERROR,
155168
MetastoreError::MissingJsonField { .. } => StatusCode::INTERNAL_SERVER_ERROR,

0 commit comments

Comments
 (0)