feat(parquet): Writer + partition derivation + RecordBatch builder (PR-E2) - #44
Conversation
There was a problem hiding this comment.
Pull request overview
Implements the “writer half” of RFC 0005’s Parquet storage plan in ourios-parquet: partition-path derivation, Arrow RecordBatch construction for mined records, and a Parquet writer configured for the RFC’s row-group sizing and encoding policies, plus RFC-scenario tests.
Changes:
- Add
partition::PartitionKeyand tenant percent-encoding to derive Hive-style partition directories fromMinedRecordtimestamps. - Add
record_batch::mined_records_to_batchto build Arrow arrays/batches matchingdata_schema(). - Add
writer::Writerto write Parquet files (UUIDv7 filenames), apply compression/dictionary/bloom settings, and flush row groups based on in-progress size; add RFC0005.5 and RFC0005.8 tests.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/ourios-parquet/src/lib.rs | Exposes new partition, batch-building, and writer modules from the crate API. |
| crates/ourios-parquet/src/partition.rs | Derives (tenant_id, year, month, day, hour) partition keys and percent-encodes tenant IDs for paths. |
| crates/ourios-parquet/src/record_batch.rs | Builds Arrow RecordBatches from MinedRecord slices in RFC schema order. |
| crates/ourios-parquet/src/writer.rs | Implements Parquet writer + encoding configuration + row-group flushing. |
| crates/ourios-parquet/tests/partition_layout.rs | RFC0005.5 scenario test for partition layout + UUIDv7 filenames + tenant percent-encoding. |
| crates/ourios-parquet/tests/no_body_dict.rs | RFC0005.8 scenario test asserting body has no dictionary encoding in Parquet metadata. |
| crates/ourios-parquet/Cargo.toml | Adds Arrow array + Parquet + UUID + chrono dependencies needed by writer/batch code and tests. |
| Cargo.lock | Locks new transitive dependencies introduced by Arrow/Parquet/uuid/chrono/tempfile additions. |
Comments suppressed due to low confidence (3)
crates/ourios-parquet/src/writer.rs:214
- RFC 0005 §3.6 specifies
params(list values) must have dictionary encoding disabled, butwriter_properties()only disables dictionary forbody,attributes,trace_id, andspan_id. Withset_dictionary_enabled(true)globally, theparamscolumn (and in particular its nestedvaluefield) will still be dictionary-encoded unless explicitly opted out, which violates the encoding policy and can reintroduce the high-cardinality blow-up the RFC calls out.
// §3.6: NO dictionary on the high-entropy attribute / id
// columns (`attributes`, `trace_id`, `span_id`, `params`
// values). Page index stays on for `trace_id` / `span_id`
// per the §3.6 table; on `attributes` it's `no`/`no`/`no`.
for high_entropy in [
crate::columns::ATTRIBUTES,
crate::columns::TRACE_ID,
crate::columns::SPAN_ID,
] {
builder = builder
.set_column_dictionary_enabled(ColumnPath::new(vec![high_entropy.to_string()]), false);
}
crates/ourios-parquet/src/writer.rs:196
- The RFC 0005 §3.6 table has per-column Page index on/off settings, but the implementation only calls
set_statistics_enabled(EnabledStatistics::Page)globally and does not apply any per-column page-index overrides (and the comment conflates page index with statistics). This risks producing files that don’t match the RFC’s page-index policy; please align the WriterProperties configuration with the §3.6 page-index column (either via an explicit page-index setting or by documenting exactly howEnabledStatistics::Pagemaps to page indexes in parquet-rs).
// Dictionary on globally by default (most columns benefit
// per §3.6); we opt out per-column below for the high-
// entropy ones.
.set_dictionary_enabled(true)
// Page-index ON by default — §3.6's "page index" column
// is `yes` for most columns; cheaper to enable globally
// and skip per-column overrides.
.set_statistics_enabled(EnabledStatistics::Page);
crates/ourios-parquet/src/writer.rs:108
- RFC 0005 §3.5 requires row groups never exceed 1 GiB (the row that would cross the ceiling starts a new row group).
append_records()only flushes once after writing the wholeRecordBatch, so a single largerecordsslice (or one very large batch) can produce an oversized row group before the post-write flush runs. To meet the 1 GiB ceiling, consider chunking/splitting input batches and flushing in a loop based onin_progress_size, or configuring an explicit max row-group size in the Parquet writer if parquet-rs supports it.
pub fn append_records(&mut self, records: &[MinedRecord]) -> Result<(), WriterError> {
if records.is_empty() {
return Ok(());
}
let batch = mined_records_to_batch(records).map_err(WriterError::Batch)?;
self.inner.write(&batch).map_err(WriterError::Parquet)?;
if self.inner.in_progress_size() >= ROW_GROUP_FLUSH_BYTES {
self.inner.flush().map_err(WriterError::Parquet)?;
}
Ok(())
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
crates/ourios-parquet/src/record_batch.rs:351
encode_attributespanics for non-empty attribute lists (unimplemented!). This is reachable with realMinedRecords (the type allows non-emptyattributes/resource_attributes) and will crash the writer instead of returning a structuredBatchError. Consider changing this to return an error (e.g., a newBatchErrorvariant) or implementing a temporary encoding that preserves forward progress until the canonical-JSON PR lands.
fn encode_attributes(attrs: &[KeyValue]) -> String {
if attrs.is_empty() {
return "[]".to_string();
}
unimplemented!(
"ourios-parquet: canonical JSON encoding of non-empty KeyValue lists is deferred to \
the RFC 0005 §3.3 canonicalisation PR (see the PR-E1 breadcrumb on \
ourios_core::otlp::Body::Structured). Got {} entries — corpus / bench inputs \
today carry empty attributes; the RFC 0003 receiver is what populates them.",
attrs.len(),
);
}
crates/ourios-parquet/src/writer.rs:267
- The RFC 0005 §3.6 encoding table specifies dictionary encoding should be OFF for
paramslist values, butwriter_propertiescurrently only disables dictionary forbody,attributes,trace_id, andspan_id. Withset_dictionary_enabled(true)globally, the nestedparamsleaf columns will likely still be dictionary-encoded unless explicitly disabled via theirColumnPaths.
// §3.6: NO dictionary on the high-entropy attribute / id
// columns (`attributes`, `trace_id`, `span_id`, `params`
// values). Page index stays on for `trace_id` / `span_id`
// per the §3.6 table; on `attributes` it's `no`/`no`/`no`.
for high_entropy in [
crate::columns::ATTRIBUTES,
crate::columns::TRACE_ID,
crate::columns::SPAN_ID,
] {
builder = builder
.set_column_dictionary_enabled(ColumnPath::new(vec![high_entropy.to_string()]), false);
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated 6 comments.
Comments suppressed due to low confidence (2)
crates/ourios-parquet/src/writer.rs:267
- The comment says dictionary is disabled for high-entropy columns including
paramsvalues, but the implementation only disables it forattributes,trace_id, andspan_id(andbodyabove). Either extend the per-column overrides to includeparams(if intended by RFC §3.6) or adjust the comment to avoid misleading future changes.
// §3.6: NO dictionary on the high-entropy attribute / id
// columns (`attributes`, `trace_id`, `span_id`, `params`
// values). Page index stays on for `trace_id` / `span_id`
// per the §3.6 table; on `attributes` it's `no`/`no`/`no`.
for high_entropy in [
crates/ourios-parquet/src/partition.rs:305
- This unit test compares
PathBufviato_str()against a hard-coded Unix path string. This is OS-specific (path separators / prefix handling). Prefer comparingPathBufvalues (orcomponents()) built withPath::joinso the test remains portable.
let bucket = Path::new("/tmp/bucket");
let path = key.audit_path(bucket);
assert_eq!(
path.to_str().unwrap(),
"/tmp/bucket/audit/tenant_id=tenant-x/year=2026/month=04/day=02"
);
0a4c9f4 to
ac5871b
Compare
#45) * feat(parquet): reader with §3.9 contract + RFC0005.1/2/3/4/9/11 (PR-F) Lands the Reader half of RFC 0005 §3.10's crate-shape plan. Audit stream is still pending (PR-G). - `crates/ourios-core/src/audit.rs` — adds `ParamType::Unknown(i32)` for the RFC 0005 §3.9 catch-all variant. The reader produces it for type_tag ordinals 8..; the writer round-trips the carried ordinal back to disk via the updated `param_type_ordinal` match arm. No existing exhaustive matches on `ParamType` (audited via grep), so the new tuple variant doesn't break any callers. - `crates/ourios-parquet/src/reader.rs` — `Reader::open_partition` (production query path, enforces §3.9 row-vs-path validation) + `Reader::open_file` (diagnostic, no validation) + `read_all`. Implements §3.9's three normative contract clauses end-to-end: unknown columns silently ignored, missing OPTIONAL columns surface as None, missing baseline REQUIRED columns are a hard error naming the column. Row-vs-path validation reuses `PartitionKey::derive` from PR-E2 so writer and reader use the identical §3.4 fallback algorithm. `MinedRecord` reconstruction goes column-by-column with helper functions per Arrow type (string / u64 / u32 / u8 / f32 / bool / timestamp / fixed-bytes / list / struct-list). Body is UTF-8-lossy-decoded for the Option<String> ↔ Bytes column gap noted in PR-E1. Tests (14 new, all green): - `tests/round_trip.rs` — RFC0005.1: writes a populated record through Writer, reads back through Reader, asserts full struct equality. Sub-test pins the body raw-bytes round-trip. - `tests/reader_compat.rs` — hand-built Parquet files with: - RFC0005.2: omitted OPTIONAL column → reader returns None - RFC0005.3: extra unknown column → reader silently ignores - RFC0005.4: omitted REQUIRED column → reader hard-errors naming the column - RFC0005.9: type_tag = 99 → reader returns ParamType::Unknown(99) (round-trips through Writer → file → Reader) - `tests/row_vs_path_validation.rs` — RFC0005.11: - tenant_id mismatch → PartitionMismatch error - hour mismatch → PartitionMismatch error - §3.4 fallback (time=0, observed!=0) → validates cleanly when supplied partition matches the observed-time bucket - open_file mode skips validation entirely Verified locally: - cargo build --all-features — clean - cargo test --all-features — 200+ tests passing - cargo fmt --all --check — clean - cargo clippy --all-targets --all-features -- -D warnings — clean Phase 2 progress (per docs/roadmap.md): - PR-D ✅ scaffold (#42) - PR-E1 ✅ MinedRecord extension (#43) - PR-E2 ✅ Writer (#44) - PR-F ✅ Reader (this PR) - PR-G ⏳ AuditWriter / AuditReader Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fixup! feat(parquet): reader with §3.9 contract + RFC0005.1/2/3/4/9/11 (PR-F) * fixup! feat(parquet): reader with §3.9 contract + RFC0005.1/2/3/4/9/11 (PR-F) * fixup! feat(parquet): reader with §3.9 contract + RFC0005.1/2/3/4/9/11 (PR-F) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Lands the Writer half of RFC 0005 §3.10's crate-shape plan. Reader and audit stream are still pending (PR-F, PR-G).
partition::PartitionKey— derives the §3.4 partition tuple (tenant_id+year/month/day/hour) via the time-fallback algorithm (time_unix_nano→observed_time_unix_nano→ 1970 epoch). Same algorithm the reader's row-vs-path validation will use (§3.9).percent_encode_tenantimplements §3.4's RFC 3986 percent-encoding with explicit overrides.record_batch::mined_records_to_batch— column-by-column ArrowRecordBatchbuilder matchingdata_schema(). Per-record validation enforces RFC 0005 §3.2 / RFC 0001 §6.6 invariants: timestamp overflow rejection (u64→i64),BodyKind::Absentrejection (§3.2 ordinals pin to 0/1),BodyKind::Structuredrejection until the canonicalisation PR replaces the miner's interim Debug rendering,attributes/resource_attributesnon-empty rejection until the same canonicalisation PR,separators.len() >= params.len() + 1for clean-attach String rows,body.is_some()for lossy rows.attributes/resource_attributesserialise to literal"[]"for emptyVec<KeyValue>per §3.2's round-trip rule.writer::Writer— opens a UUIDv7-named Parquet file at the partition path, validates each record's derived partition against the writer's open partition (fails fast withPartitionMismatchso the §3.9 row-vs-path contract is enforced at write time too). Internal chunking ofrecordsinto 1024-row sub-batches plus per-sub-batch flush-on-threshold keeps row-group sizes bounded to roughly128 MiB + (per-record bytes × 1024)— well under §3.5's 1 GiB upper bound for log-scale per-record sizes. Applies the §3.6 encoding policy: ZSTD-3 across all columns; explicitDictionary = noontime_unix_nano/observed_time_unix_nano/attributes/trace_id/span_id/body/confidence/params.list.element.{type_tag,value}(both leaves per the §3.6 "(list values)" reading); per-columnEnabledStatistics::Chunk(page-index off) ontenant_id/attributes/resource_attributes/body/params.list.element.{type_tag,value}/separators.list.element; bloom filter ontemplate_idfor B2 predicate-pushdown.<uuid>.parquet.tmp,Writer::closerenames to<uuid>.parquetafter the footer is on disk,Dropremoves the.tmpifclosewasn't called. Satisfies RFC 0005 §7's atomic-publish open-question item.WriterError::Iocarries{op, path, source_path, source}so a failed rename leaves a.parquet.tmpfor diagnosis and the error message names exactly which file.Invariant coverage
tests/partition_layout.rs) — multi-tenant, multi-hour files land at the expected partition paths with UUIDv7 filenames; non-ASCII tenant id percent-encodes to%C3%A5; cross-partition records rejected at write time; atomic-publish round-trip (drop without close → temp gone, no final; close → final exists, no temp).tests/no_body_dict.rs) — writes 200 unique high-entropy bodies; Parquet footer shows thebodycolumn with ZSTD compression, noPLAIN_DICTIONARY/RLE_DICTIONARYencoding, and nodictionary_page_offseton disk. A sibling test exercises the §3.6params.list.element.{value, type_tag}no-dict contract over both leaves.record_batch.rspin the empty-attributes"[]"serialisation, the non-emptyAttributesNotYetEncodederrors on both attributes columns, theUnsupportedAbsentBodyrejection, theStructuredBodyNotYetCanonicalrejection, theInvalidSeparatorsForString { expected_at_least, actual }lower-bound check on clean-attach String rows, and theMissingBodyForLossyStringrejection on the lossy carve-out.Deferred to PR-F (reader) and PR-G (audit stream): RFC0005.1 (round-trip), RFC0005.2/3/4 (forward-compat), RFC0005.6 (
#[ignore]-d row-group sizing), RFC0005.7 (audit emission), RFC0005.9 (unknownParamTypeordinal), RFC0005.11 (row-vs-path validation — needs reader).Test plan
cargo build -p ourios-parquet— cleancargo test --all-features -p ourios-parquet— 25 passcargo fmt --all --check— cleancargo clippy --all-targets --all-features -- -D warnings— clean🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Chores