Skip to content

feat(parquet): Writer + partition derivation + RecordBatch builder (PR-E2) - #44

Merged
jensholdgaard merged 18 commits into
mainfrom
feat/ourios-parquet-writer
May 21, 2026
Merged

feat(parquet): Writer + partition derivation + RecordBatch builder (PR-E2)#44
jensholdgaard merged 18 commits into
mainfrom
feat/ourios-parquet-writer

Conversation

@jensholdgaard

@jensholdgaard jensholdgaard commented May 21, 2026

Copy link
Copy Markdown
Owner

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_nanoobserved_time_unix_nano → 1970 epoch). Same algorithm the reader's row-vs-path validation will use (§3.9). percent_encode_tenant implements §3.4's RFC 3986 percent-encoding with explicit overrides.
  • record_batch::mined_records_to_batch — column-by-column Arrow RecordBatch builder matching data_schema(). Per-record validation enforces RFC 0005 §3.2 / RFC 0001 §6.6 invariants: timestamp overflow rejection (u64i64), BodyKind::Absent rejection (§3.2 ordinals pin to 0/1), BodyKind::Structured rejection until the canonicalisation PR replaces the miner's interim Debug rendering, attributes/resource_attributes non-empty rejection until the same canonicalisation PR, separators.len() >= params.len() + 1 for clean-attach String rows, body.is_some() for lossy rows. attributes/resource_attributes serialise to literal "[]" for empty Vec<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 with PartitionMismatch so the §3.9 row-vs-path contract is enforced at write time too). Internal chunking of records into 1024-row sub-batches plus per-sub-batch flush-on-threshold keeps row-group sizes bounded to roughly 128 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; explicit Dictionary = no on time_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-column EnabledStatistics::Chunk (page-index off) on tenant_id/attributes/resource_attributes/body/params.list.element.{type_tag,value}/separators.list.element; bloom filter on template_id for B2 predicate-pushdown.
  • Atomic publish — writes to <uuid>.parquet.tmp, Writer::close renames to <uuid>.parquet after the footer is on disk, Drop removes the .tmp if close wasn't called. Satisfies RFC 0005 §7's atomic-publish open-question item. WriterError::Io carries {op, path, source_path, source} so a failed rename leaves a .parquet.tmp for diagnosis and the error message names exactly which file.

Invariant coverage

  • RFC0005.5 (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).
  • RFC0005.8 (tests/no_body_dict.rs) — writes 200 unique high-entropy bodies; Parquet footer shows the body column with ZSTD compression, no PLAIN_DICTIONARY/RLE_DICTIONARY encoding, and no dictionary_page_offset on disk. A sibling test exercises the §3.6 params.list.element.{value, type_tag} no-dict contract over both leaves.
  • Six unit tests in record_batch.rs pin the empty-attributes "[]" serialisation, the non-empty AttributesNotYetEncoded errors on both attributes columns, the UnsupportedAbsentBody rejection, the StructuredBodyNotYetCanonical rejection, the InvalidSeparatorsForString { expected_at_least, actual } lower-bound check on clean-attach String rows, and the MissingBodyForLossyString rejection 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 (unknown ParamType ordinal), RFC0005.11 (row-vs-path validation — needs reader).

Test plan

  • cargo build -p ourios-parquet — clean
  • cargo test --all-features -p ourios-parquet — 25 pass
  • cargo fmt --all --check — clean
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • CI green

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Parquet file writing capabilities with RFC 0005–compliant partitioning
    • Implemented atomic file publishing and row-level partition validation
    • Added support for non-ASCII tenant identifiers with RFC 3986 percent-encoding
    • Introduced comprehensive error handling for timestamp overflow, batch validation, and I/O operations
  • Chores

    • Updated project dependencies to support Parquet and Arrow functionality

Review Change Stack

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::PartitionKey and tenant percent-encoding to derive Hive-style partition directories from MinedRecord timestamps.
  • Add record_batch::mined_records_to_batch to build Arrow arrays/batches matching data_schema().
  • Add writer::Writer to 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, but writer_properties() only disables dictionary for body, attributes, trace_id, and span_id. With set_dictionary_enabled(true) globally, the params column (and in particular its nested value field) 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 how EnabledStatistics::Page maps 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 whole RecordBatch, so a single large records slice (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 on in_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.

Comment thread crates/ourios-parquet/src/writer.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_attributes panics for non-empty attribute lists (unimplemented!). This is reachable with real MinedRecords (the type allows non-empty attributes/resource_attributes) and will crash the writer instead of returning a structured BatchError. Consider changing this to return an error (e.g., a new BatchError variant) 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 params list values, but writer_properties currently only disables dictionary for body, attributes, trace_id, and span_id. With set_dictionary_enabled(true) globally, the nested params leaf columns will likely still be dictionary-encoded unless explicitly disabled via their ColumnPaths.
    // §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);
    }

Comment thread crates/ourios-parquet/src/record_batch.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs Outdated
Comment thread crates/ourios-parquet/src/writer.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 params values, but the implementation only disables it for attributes, trace_id, and span_id (and body above). Either extend the per-column overrides to include params (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 PathBuf via to_str() against a hard-coded Unix path string. This is OS-specific (path separators / prefix handling). Prefer comparing PathBuf values (or components()) built with Path::join so 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"
        );

Comment thread crates/ourios-parquet/src/record_batch.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs
Comment thread crates/ourios-parquet/src/writer.rs Outdated
Comment thread crates/ourios-parquet/tests/partition_layout.rs Outdated
Comment thread crates/ourios-parquet/src/partition.rs Outdated
Comment thread crates/ourios-parquet/Cargo.toml Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Comment thread crates/ourios-parquet/src/writer.rs Outdated
Comment thread crates/ourios-parquet/src/writer.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Comment thread crates/ourios-parquet/src/writer.rs
Comment thread crates/ourios-parquet/src/writer.rs
Comment thread crates/ourios-parquet/src/writer.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Comment thread crates/ourios-parquet/src/writer.rs Outdated
Comment thread crates/ourios-parquet/src/record_batch.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Comment thread crates/ourios-parquet/src/record_batch.rs Outdated
Comment thread crates/ourios-parquet/src/partition.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Comment thread crates/ourios-parquet/src/record_batch.rs
Comment thread crates/ourios-parquet/src/writer.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.

Comment thread crates/ourios-parquet/src/writer.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.

Comment thread crates/ourios-parquet/src/writer.rs Outdated
@jensholdgaard
jensholdgaard force-pushed the feat/ourios-parquet-writer branch from 0a4c9f4 to ac5871b Compare May 21, 2026 16:04
@jensholdgaard
jensholdgaard merged commit 4206d59 into main May 21, 2026
8 checks passed
@jensholdgaard
jensholdgaard deleted the feat/ourios-parquet-writer branch May 21, 2026 16:06
jensholdgaard added a commit that referenced this pull request May 21, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants