From 810df266958fa5ca7a9f23b9cb24a2ca2ec19311 Mon Sep 17 00:00:00 2001 From: Martin Prammer Date: Sat, 10 Oct 2026 16:45:20 -0400 Subject: [PATCH 1/5] Add Apache Arrow Go Parquet writer and reader Signed-off-by: Martin Prammer --- .github/workflows/ci.yml | 19 ++ .gitignore | 2 + CHANGELOG.md | 7 + README.md | 7 + raincloud/_registry.py | 1 + raincloud/pipeline/export/readers.py | 1 + sidecars/README.md | 61 +++-- sidecars/go/compare.go | 276 ++++++++++++++++++++ sidecars/go/go.mod | 27 ++ sidecars/go/go.sum | 84 +++++++ sidecars/go/main.go | 361 +++++++++++++++++++++++++++ tests/test_go_sidecar.py | 84 +++++++ tests/test_parquet_options.py | 9 +- tests/test_reader_fidelity.py | 4 +- 14 files changed, 923 insertions(+), 20 deletions(-) create mode 100644 sidecars/go/compare.go create mode 100644 sidecars/go/go.mod create mode 100644 sidecars/go/go.sum create mode 100644 sidecars/go/main.go create mode 100644 tests/test_go_sidecar.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10c0222..006c301 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,25 @@ jobs: - name: Test (pytest) run: uv run pytest -q + go-sidecar: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: sidecars/go/go.mod + cache-dependency-path: sidecars/go/go.sum + - name: Build Apache Arrow Go writer and reader + working-directory: sidecars/go + run: go build -o raincloud-parquet-go . + - uses: astral-sh/setup-uv@v5 + - run: uv sync --python 3.11 --extra dev --extra build --inexact + - name: Verify settings and read-back fidelity + env: + RAINCLOUD_SIDECAR_PARQUET_GO: ${{ github.workspace }}/sidecars/go/raincloud-parquet-go + RAINCLOUD_READER_PARQUET_GO: ${{ github.workspace }}/sidecars/go/raincloud-parquet-go + run: uv run --no-sync pytest -q tests/test_go_sidecar.py tests/test_parquet_options.py tests/test_reader_fidelity.py + java-sidecars: # Validate the JVM conformance lanes: the parquet@java lane is a Gradle # composite build over the parquet-arrow-java git submodule, so this job diff --git a/.gitignore b/.gitignore index 1bd4a7d..b157013 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,5 @@ docs/snapshot.json /clients/c/build/ # profile promotion lock beside tracked profiles docs/**/.profiles-write.lock + +/sidecars/go/raincloud-parquet-go diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f9005..c3b836f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- `parquet@go`: Apache Arrow Go 18.8.0's `pqarrow` writer and reader sidecars, + with streaming IPC input, shared Parquet settings, page indexes on with + statistics, and read-back verification before publication. Explicit page + checksum and page-row-limit requests fail because the library cannot write them. + ### Changed - **`parquet@py` writes a page index and page checksums by default.** With diff --git a/README.md b/README.md index 7c4fff5..5a6fb7a 100644 --- a/README.md +++ b/README.md @@ -340,3 +340,10 @@ are the upstream licenses; the catalog records them but does not grant them. - [`SKILLS.md`](SKILLS.md) — task-by-task procedures - [`CONTRIBUTING.md`](CONTRIBUTING.md) · [`SECURITY.md`](SECURITY.md) · [`DISCLAIMER.md`](DISCLAIMER.md) - [`CITATION.cff`](CITATION.cff) — how to cite this work + +### Apache Arrow Go Parquet writer + +The optional `parquet@go` sidecar writes and verifies Parquet with Apache Arrow Go's +`pqarrow`. It accepts the shared writer settings, enables page indexes with +statistics, and refuses page-checksum and exact page-row-limit requests. See +[the build instructions and option matrix](sidecars/README.md#apache-arrow-go). diff --git a/raincloud/_registry.py b/raincloud/_registry.py index 00e2843..0f32a0d 100644 --- a/raincloud/_registry.py +++ b/raincloud/_registry.py @@ -94,6 +94,7 @@ # class serves all of them, so the whole cell is data — which is why these are # declared rather than written out as five near-identical registrations. SIDECAR_EXPORTERS: dict[str, tuple[str, str]] = { + "parquet@go": ("parquet", "raincloud-export-parquet-go"), "parquet@rs": ("parquet", "raincloud-export-parquet-rs"), "parquet@java": ("parquet", "raincloud-export-parquet-java"), "parquet@hardwood": ("parquet", "raincloud-export-parquet-hardwood"), diff --git a/raincloud/pipeline/export/readers.py b/raincloud/pipeline/export/readers.py index b137c61..d833f2d 100644 --- a/raincloud/pipeline/export/readers.py +++ b/raincloud/pipeline/export/readers.py @@ -472,6 +472,7 @@ def run_reader( register_reader(PyarrowParquetReader()) register_reader(VortexPyReader()) register_reader(PyarrowOrcReader()) +register_reader(SidecarReader("parquet@go", {"parquet"}, "raincloud-read-parquet-go")) register_reader(SidecarReader("parquet@rs", {"parquet"}, "raincloud-read-parquet-rs")) register_reader(SidecarReader("vortex@rs", {"vortex"}, "raincloud-read-vortex-rs")) register_reader(SidecarReader("parquet@java", {"parquet"}, "raincloud-read-parquet-java")) diff --git a/sidecars/README.md b/sidecars/README.md index 65931fd..0ddce17 100644 --- a/sidecars/README.md +++ b/sidecars/README.md @@ -171,7 +171,7 @@ passes to a sidecar in this form: | `RAINCLOUD_PARQUET_PAGE_CHECKSUMS` | `1` or `0`: a CRC in every page header, or none | the install, only when set | Page indexes and checksums default on wherever the pinned writer supports them: -`parquet@py` and `parquet@java` write both, `parquet@rs` writes indexes but cannot write +`parquet@py` and `parquet@java` write both, `parquet@rs` and `parquet@go` write indexes but cannot write checksums, and `parquet@hardwood` writes checksums but cannot write indexes. Page statistics require the recipe's statistics to be enabled. Other unset variables use the library's defaults (zstd and statistics on, for the first two). Counts use the @@ -183,21 +183,21 @@ or none, or out of the codec's range) are refused for every lane before any writ A lane refuses, as a failed round-trip naming the variable, a setting its library cannot honour: -| | py (pyarrow 24) | rs (arrow-rs 59.2) | java (parquet-arrow-java 0.3.0) | hardwood (1.1.0.Beta1) | -|---|---|---|---|---| -| codec | all | all | not `brotli` | all | -| compression level | yes | yes | yes | no | -| statistics off | yes | yes | yes | no | -| statistics, first N columns | yes | yes | yes | no | -| page index on | yes (default) | yes (default) | yes (default) | no | -| page index off | yes | yes | no, with statistics on | yes | -| page index, first N columns | no: all or none | yes | no | no | -| page bytes | yes | yes | yes | yes | -| page rows | yes | yes, checked every 1,024 values | yes | no | -| dictionary on / off | yes | yes | yes | yes (off is PLAIN) | -| dictionary page bytes | yes | yes | yes | no | -| page checksums on | yes (default) | no | yes (default) | yes (always) | -| page checksums off | yes | yes (always) | yes | no | +| | py (pyarrow 24) | rs (arrow-rs 59.2) | java (parquet-arrow-java 0.3.0) | hardwood (1.1.0.Beta1) | go (Apache Arrow Go 18.8.0) | +|---|---|---|---|---| --- | +| codec | all | all | not `brotli` | all | all | +| compression level | yes | yes | yes | no | yes | +| statistics off | yes | yes | yes | no | yes | +| statistics, first N columns | yes | yes | yes | no | yes | +| page index on | yes (default) | yes (default) | yes (default) | no | yes (default) | +| page index off | yes | yes | no, with statistics on | yes | yes | +| page index, first N columns | no: all or none | yes | no | no | yes | +| page bytes | yes | yes | yes | yes | yes | +| page rows | yes | yes, checked every 1,024 values | yes | no | no | +| dictionary on / off | yes | yes | yes | yes (off is PLAIN) | yes | +| dictionary page bytes | yes | yes | yes | no | yes | +| page checksums on | yes (default) | no | yes (default) | yes (always) | no | +| page checksums off | yes | yes (always) | yes | no | yes (always) | Each library measures a page its own way, so the same `RAINCLOUD_PARQUET_PAGE_BYTES` does not give identical pages in every lane. The java column's page index gaps are @@ -407,3 +407,32 @@ Upgrade their pins together and re-measure supported cells; a package version change alone is not evidence of conformance. Preserve skipped and unsupported lanes in the results. Software updates do not move stored data or refresh an active catalog revision. + +## Apache Arrow Go + +`parquet@go` uses Apache Arrow Go's `parquet/pqarrow` (18.8.0), which streams +Arrow IPC record batches directly into its pure-Go Parquet writer. It is a +different implementation from `github.com/parquet-go/parquet-go`. + +```bash +cd sidecars/go +go build -o raincloud-parquet-go . +# Both contracts are served by the same binary (--canonical selects read mode). +export RAINCLOUD_SIDECAR_PARQUET_GO="$PWD/raincloud-parquet-go" +export RAINCLOUD_READER_PARQUET_GO="$PWD/raincloud-parquet-go" +cd ../.. +python -m raincloud.pipeline.export --format parquet@go +``` + +For PATH discovery, install the binary as both `raincloud-export-parquet-go` and +`raincloud-read-parquet-go`. Use `export.priority: {"parquet": ["go", "py"]}` +to prefer it. It reads its own output through pqarrow before promotion. Comparison +is streamed, permits reversible type normalization, ignores field metadata, and +preserves float sign bits through nested values. The writer stores the Arrow +schema but makes no claim to preserve the Parquet VARIANT logical annotation. + +Row groups accumulate across IPC batches, capped by the recipe's row limit and +checked every 1,024 rows against encoded bytes and estimated decoded bytes. Page +rows and checksums-on are refused: Arrow Go exposes neither an exact page-row +limit nor a checksum writer option. Other controls are listed above. Compression +and statistics come from the recipe, and page indexes default on with statistics. diff --git a/sidecars/go/compare.go b/sidecars/go/compare.go new file mode 100644 index 0000000..1a0ac70 --- /dev/null +++ b/sidecars/go/compare.go @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: 2026 Raincloud Maintainers +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "context" + "fmt" + "math" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/array" + "github.com/apache/arrow-go/v18/arrow/compute" + "github.com/apache/arrow-go/v18/arrow/ipc" + "github.com/apache/arrow-go/v18/parquet/pqarrow" +) + +// Compare batches independently of their boundaries. Only one batch from each reader is live. +func compareRecords(got pqarrow.RecordReader, expected *ipc.FileReader) error { + if got.Schema().NumFields() != expected.Schema().NumFields() { + return fmt.Errorf("column count differs") + } + for i, f := range expected.Schema().Fields() { + if got.Schema().Field(i).Name != f.Name { + return fmt.Errorf("column names differ") + } + } + var actual arrow.RecordBatch + var offset, rows int64 + next := func() bool { + for got.Next() { + actual = got.RecordBatch() + offset = 0 + if actual.NumRows() != 0 { + return true + } + } + actual = nil + return false + } + for i := 0; i < expected.NumRecords(); i++ { + batch, err := expected.RecordBatchAt(i) + if err != nil { + return err + } + rows += batch.NumRows() + err = func() error { + defer batch.Release() + for start := int64(0); start < batch.NumRows(); { + if actual == nil || offset == actual.NumRows() { + if !next() { + if got.Err() != nil { + return got.Err() + } + return fmt.Errorf("row count differs") + } + } + n := min(batch.NumRows()-start, actual.NumRows()-offset) + for c := 0; c < int(batch.NumCols()); c++ { + a := array.NewSlice(actual.Column(c), offset, offset+n) + b := array.NewSlice(batch.Column(c), start, start+n) + equal := logicalEqual(a, b) + a.Release() + b.Release() + if !equal { + return fmt.Errorf("data mismatch in column %q", batch.ColumnName(c)) + } + } + start += n + offset += n + } + return nil + }() + if err != nil { + return err + } + } + if actual != nil && offset < actual.NumRows() || next() { + return fmt.Errorf("row count differs") + } + if rows == 0 { + for i, f := range expected.Schema().Fields() { + if !emptyTypesEqual(got.Schema().Field(i).Type, f.Type) { + return fmt.Errorf("empty column %q has incompatible types", f.Name) + } + } + } + return got.Err() +} + +// A safe cast alone permits lossy float narrowing and int->bool. Require the +// reverse cast to recover the original values, including float sign bits. +func logicalEqual(a, b arrow.Array) bool { + if ext, ok := a.(array.ExtensionArray); ok { + a = ext.Storage() + } + if ext, ok := b.(array.ExtensionArray); ok { + b = ext.Storage() + } + if arrow.TypeEqual(a.DataType(), b.DataType()) { + return exactEqual(a, b) + } + cast, err := compute.CastArray(context.Background(), a, compute.SafeCastOptions(b.DataType())) + if err != nil { + return false + } + defer cast.Release() + back, err := compute.CastArray(context.Background(), cast, compute.SafeCastOptions(a.DataType())) + if err != nil { + return false + } + defer back.Release() + return exactEqual(a, back) && exactEqual(cast, b) +} + +type listArray interface { + arrow.Array + ValueOffsets(int) (int64, int64) + ListValues() arrow.Array +} + +// Ignore payload under null parents; preserve signed zero and accept NaNs at +// every nesting level. Arrow's approximate comparator collapses signed zero. +func exactEqual(a, b arrow.Array) bool { + if a.Len() != b.Len() || a.NullN() != b.NullN() { + return false + } + switch left := a.(type) { + case *array.Float16: + right, ok := b.(*array.Float16) + if !ok { + return false + } + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) != b.IsNull(i) { + return false + } + if a.IsNull(i) { + continue + } + x, y := left.Value(i), right.Value(i) + if x.Uint16() != y.Uint16() && !(math.IsNaN(float64(x.Float32())) && math.IsNaN(float64(y.Float32()))) { + return false + } + } + return true + case *array.Float64: + right, ok := b.(*array.Float64) + if !ok { + return false + } + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) != b.IsNull(i) { + return false + } + if a.IsNull(i) { + continue + } + x, y := left.Value(i), right.Value(i) + if math.Float64bits(x) != math.Float64bits(y) && !(math.IsNaN(x) && math.IsNaN(y)) { + return false + } + } + return true + case *array.Float32: + right, ok := b.(*array.Float32) + if !ok { + return false + } + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) != b.IsNull(i) { + return false + } + if a.IsNull(i) { + continue + } + x, y := left.Value(i), right.Value(i) + if math.Float32bits(x) != math.Float32bits(y) && !(math.IsNaN(float64(x)) && math.IsNaN(float64(y))) { + return false + } + } + return true + case *array.Struct: + right, ok := b.(*array.Struct) + if !ok || left.NumField() != right.NumField() { + return false + } + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) != b.IsNull(i) { + return false + } + if a.IsNull(i) { + continue + } + for c := 0; c < left.NumField(); c++ { + x, y := array.NewSlice(left.Field(c), int64(i), int64(i+1)), array.NewSlice(right.Field(c), int64(i), int64(i+1)) + equal := logicalEqual(x, y) + x.Release() + y.Release() + if !equal { + return false + } + } + } + return true + case listArray: + right, ok := b.(listArray) + if !ok { + return false + } + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) != b.IsNull(i) { + return false + } + if a.IsNull(i) { + continue + } + lo, hi := left.ValueOffsets(i) + ro, rh := right.ValueOffsets(i) + x, y := array.NewSlice(left.ListValues(), lo, hi), array.NewSlice(right.ListValues(), ro, rh) + equal := logicalEqual(x, y) + x.Release() + y.Release() + if !equal { + return false + } + } + return true + case *array.Dictionary: + right, ok := b.(*array.Dictionary) + if !ok { + return false + } + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) != b.IsNull(i) { + return false + } + if a.IsNull(i) { + continue + } + li, ri := int64(left.GetValueIndex(i)), int64(right.GetValueIndex(i)) + x, y := array.NewSlice(left.Dictionary(), li, li+1), array.NewSlice(right.Dictionary(), ri, ri+1) + equal := logicalEqual(x, y) + x.Release() + y.Release() + if !equal { + return false + } + } + return true + default: + return array.Equal(a, b) + } +} + +// Empty files still have a schema. Reversible casts of empty arrays alone +// would incorrectly accept unrelated types such as string and integer. +func emptyTypesEqual(a, b arrow.DataType) bool { + if ext, ok := a.(arrow.ExtensionType); ok { + a = ext.StorageType() + } + if ext, ok := b.(arrow.ExtensionType); ok { + b = ext.StorageType() + } + if arrow.TypeEqual(a, b) { + return true + } + if d, ok := a.(*arrow.DictionaryType); ok { + return emptyTypesEqual(d.ValueType, b) + } + if d, ok := b.(*arrow.DictionaryType); ok { + return emptyTypesEqual(a, d.ValueType) + } + strings := func(t arrow.Type) bool { return t == arrow.STRING || t == arrow.LARGE_STRING || t == arrow.STRING_VIEW } + binary := func(t arrow.Type) bool { return t == arrow.BINARY || t == arrow.LARGE_BINARY || t == arrow.BINARY_VIEW } + return strings(a.ID()) && strings(b.ID()) || binary(a.ID()) && binary(b.ID()) +} diff --git a/sidecars/go/go.mod b/sidecars/go/go.mod new file mode 100644 index 0000000..50f5019 --- /dev/null +++ b/sidecars/go/go.mod @@ -0,0 +1,27 @@ +module github.com/spiraldb/raincloud/sidecars/go + +go 1.25.0 + +require github.com/apache/arrow-go/v18 v18.8.0 + +require ( + github.com/andybalholm/brotli v1.2.3 // indirect + github.com/apache/thrift v0.24.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/google/flatbuffers v25.12.19+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/pierrec/lz4/v4 v4.1.29 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/sidecars/go/go.sum b/sidecars/go/go.sum new file mode 100644 index 0000000..52eb63f --- /dev/null +++ b/sidecars/go/go.sum @@ -0,0 +1,84 @@ +github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ= +github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.8.0 h1:BLOzbPv7bxMPgXPacAg6HQjnxupYsZzC4tf+FkqPU/M= +github.com/apache/arrow-go/v18 v18.8.0/go.mod h1:uJCFfCwq0KsxCmsCfQg4ft+LsW+iHYzAXiSDh5ug/8U= +github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ= +github.com/apache/thrift v0.24.0/go.mod h1:zPt6WxgvTOM6hF92y8C+MkEM5LMxZuk4JcQOiU4Esvs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/flatbuffers v25.12.19+incompatible h1:haMV2JRRJCe1998HeW/p0X9UaMTK6SDo0ffLn2+DbLs= +github.com/google/flatbuffers v25.12.19+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/pierrec/lz4/v4 v4.1.29 h1:CDQY6qZOLI4DW0Nx6R1vRrifrCeQHnNXkMb0hZWXFjg= +github.com/pierrec/lz4/v4 v4.1.29/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sidecars/go/main.go b/sidecars/go/main.go new file mode 100644 index 0000000..dad929c --- /dev/null +++ b/sidecars/go/main.go @@ -0,0 +1,361 @@ +// SPDX-FileCopyrightText: 2026 Raincloud Maintainers +// SPDX-License-Identifier: Apache-2.0 +// The same binary serves the writer and reader contracts; --canonical selects read mode. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "math" + "os" + "regexp" + "strconv" + "strings" + + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/ipc" + "github.com/apache/arrow-go/v18/arrow/memory" + "github.com/apache/arrow-go/v18/arrow/util" + "github.com/apache/arrow-go/v18/parquet" + "github.com/apache/arrow-go/v18/parquet/compress" + "github.com/apache/arrow-go/v18/parquet/file" + "github.com/apache/arrow-go/v18/parquet/pqarrow" +) + +var countGrammar = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`) + +func number(name string, fallback int64) (int64, error) { + raw, present := os.LookupEnv(name) + if !present { + return fallback, nil + } + raw = strings.Trim(raw, " \t\n\r\v\f") + limit := int64(1<<31 - 1) + if strings.HasPrefix(name, "RAINCLOUD_ROW_GROUP_TARGET_") { + limit = 1 << 62 + } + if raw == "" { + return limit, nil + } + if !countGrammar.MatchString(raw) { + return 0, fmt.Errorf("%s=%q is not a number", name, raw) + } + n, err := strconv.ParseFloat(raw, 64) + if err != nil || math.IsInf(n, 0) || math.IsNaN(n) || (n > 0 && n < 1) { + return 0, fmt.Errorf("%s=%q must be finite and at least 1, or 0 for no limit", name, raw) + } + if n == 0 || n >= float64(limit) { + return limit, nil + } + return int64(n), nil +} +func switchValue(name string, fallback bool) (bool, error) { + raw := strings.ToLower(strings.TrimSpace(os.Getenv(name))) + switch raw { + case "": + return fallback, nil + case "0", "false", "off", "no": + return false, nil + case "1", "true", "on", "yes": + return true, nil + default: + return false, fmt.Errorf("%s=%q must be a switch", name, raw) + } +} +func refused(name string) error { + return fmt.Errorf("parquet@go cannot honour %s=%s", name, os.Getenv(name)) +} + +func properties(sc *arrow.Schema, rowCap int64) (*parquet.WriterProperties, error) { + stats, err := switchValue("RAINCLOUD_PARQUET_STATISTICS", true) + if err != nil { + return nil, err + } + index, err := switchValue("RAINCLOUD_PARQUET_PAGE_INDEX", stats) + if err != nil { + return nil, err + } + checksums, err := switchValue("RAINCLOUD_PARQUET_PAGE_CHECKSUMS", false) + if err != nil { + return nil, err + } + if checksums { + return nil, refused("RAINCLOUD_PARQUET_PAGE_CHECKSUMS") + } + if strings.TrimSpace(os.Getenv("RAINCLOUD_PARQUET_PAGE_ROWS")) != "" { + return nil, refused("RAINCLOUD_PARQUET_PAGE_ROWS") + } + dict, err := switchValue("RAINCLOUD_PARQUET_DICTIONARY", true) + if err != nil { + return nil, err + } + codec := strings.ToLower(strings.TrimSpace(os.Getenv("RAINCLOUD_PARQUET_COMPRESSION"))) + if codec == "" { + codec = "zstd" + } + codecs := map[string]compress.Compression{"zstd": compress.Codecs.Zstd, "gzip": compress.Codecs.Gzip, "snappy": compress.Codecs.Snappy, "brotli": compress.Codecs.Brotli, "lz4": compress.Codecs.Lz4Raw, "none": compress.Codecs.Uncompressed} + compression, ok := codecs[codec] + if !ok { + return nil, refused("RAINCLOUD_PARQUET_COMPRESSION") + } + opts := []parquet.WriterProperty{parquet.WithCompression(compression), parquet.WithStats(stats), parquet.WithPageIndexEnabled(index), parquet.WithDictionaryDefault(dict), parquet.WithMaxRowGroupLength(rowCap)} + if raw := strings.TrimSpace(os.Getenv("RAINCLOUD_PARQUET_COMPRESSION_LEVEL")); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil { + return nil, err + } + valid := (codec == "zstd" && n >= 1 && n <= 22) || (codec == "gzip" && n >= 0 && n <= 9) || (codec == "brotli" && n >= 0 && n <= 11) + if !valid { + return nil, refused("RAINCLOUD_PARQUET_COMPRESSION_LEVEL") + } + opts = append(opts, parquet.WithCompressionLevel(n)) + } + for name, apply := range map[string]func(int64) parquet.WriterProperty{"RAINCLOUD_PARQUET_PAGE_BYTES": parquet.WithDataPageSize, "RAINCLOUD_PARQUET_DICTIONARY_PAGE_BYTES": parquet.WithDictionaryPageSizeLimit} { + if strings.TrimSpace(os.Getenv(name)) != "" { + n, err := number(name, 0) + if err != nil { + return nil, err + } + opts = append(opts, apply(n)) + } + } + statCols, err := number("RAINCLOUD_PARQUET_STATISTICS_COLUMNS", 1<<31-1) + if err != nil { + return nil, err + } + indexCols, err := number("RAINCLOUD_PARQUET_PAGE_INDEX_COLUMNS", 1<<31-1) + if err != nil { + return nil, err + } + if !stats && (index || os.Getenv("RAINCLOUD_PARQUET_STATISTICS_COLUMNS") != "" || os.Getenv("RAINCLOUD_PARQUET_PAGE_INDEX_COLUMNS") != "") { + return nil, fmt.Errorf("page statistics requested with statistics off") + } + if !index && os.Getenv("RAINCLOUD_PARQUET_PAGE_INDEX_COLUMNS") != "" { + return nil, fmt.Errorf("page index columns requested with page index off") + } + schema, err := pqarrow.ToParquet(sc, parquet.NewWriterProperties(opts...), pqarrow.DefaultWriterProps()) + if err != nil { + return nil, err + } + for i := 0; i < schema.NumColumns(); i++ { + path := schema.Column(i).Path() + opts = append(opts, parquet.WithStatsFor(path, stats && int64(i) < statCols), parquet.WithPageIndexEnabledFor(path, index && int64(i) < statCols && int64(i) < indexCols)) + } + return parquet.NewWriterProperties(opts...), nil +} + +func write(input, output string) error { + source, err := os.Open(input) + if err != nil { + return err + } + defer source.Close() + reader, err := ipc.NewFileReader(source) + if err != nil { + return err + } + defer reader.Close() + cap, err := number("RAINCLOUD_ROW_GROUP_MAX_ROWS", 10_000_000) + if err != nil { + return err + } + encoded, err := number("RAINCLOUD_ROW_GROUP_TARGET_ENCODED_BYTES", 128<<20) + if err != nil { + return err + } + decoded, err := number("RAINCLOUD_ROW_GROUP_TARGET_BYTES", 512<<20) + if err != nil { + return err + } + props, err := properties(reader.Schema(), cap) + if err != nil { + return err + } + dest, err := os.Create(output) + if err != nil { + return err + } + defer dest.Close() + writer, err := pqarrow.NewFileWriter(reader.Schema(), dest, props, pqarrow.NewArrowWriterProperties(pqarrow.WithStoreSchema())) + if err != nil { + return err + } + defer writer.Close() + var rows, bytes int64 + for i := 0; i < reader.NumRecords(); i++ { + batch, err := reader.RecordBatchAt(i) + if err != nil { + return err + } + err = func() error { + defer batch.Release() + for offset := int64(0); offset < batch.NumRows(); { + if rows >= cap || bytes >= decoded || writer.RowGroupTotalBytesWritten() >= encoded { + if err := writer.NewBufferedRowGroupChecked(); err != nil { + return err + } + rows, bytes = 0, 0 + } + // Check byte bounds every small slice, rather than retaining a whole canonical batch in the encoder. + n := min(cap-rows, batch.NumRows()-offset, 1024) + part := batch.NewSlice(offset, offset+n) + err := writer.WriteBuffered(part) + bytes += util.TotalRecordSize(part) * n / max(batch.NumRows(), 1) + part.Release() + if err != nil { + return err + } + rows += n + offset += n + } + return nil + }() + if err != nil { + return err + } + } + return writer.Close() +} + +func verify(artifact, canonical string) error { + input, err := os.Open(artifact) + if err != nil { + return err + } + defer input.Close() + pq, err := file.NewParquetReader(input) + if err != nil { + return err + } + defer pq.Close() + batchRows := int64(1024) + for i := 0; i < pq.NumRowGroups(); i++ { + group := pq.MetaData().RowGroup(i) + if group.NumRows() > 0 { + batchRows = min(batchRows, max(1, (16<<20)*group.NumRows()/max(1, group.TotalByteSize()))) + } + } + reader, err := pqarrow.NewFileReader(pq, pqarrow.ArrowReadProperties{BatchSize: batchRows}, memory.DefaultAllocator) + if err != nil { + return err + } + records, err := reader.GetRecordReader(context.Background(), nil, nil) + if err != nil { + return err + } + defer records.Release() + source, err := os.Open(canonical) + if err != nil { + return err + } + defer source.Close() + expected, err := ipc.NewFileReader(source) + if err != nil { + return err + } + defer expected.Close() + return compareRecords(records, expected) +} + +func run(input, output, canonical string) (err error) { + // Upstream reports some unsupported types with a panic. Preserve the sidecar report contract. + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("Apache Arrow Go: %v", r) + } + }() + if canonical != "" { + return verify(input, canonical) + } + if err := write(input, output); err != nil { + return err + } + return verify(output, input) +} +func main() { + input := flag.String("input", "", "artifact or canonical IPC file") + output := flag.String("output", "", "Parquet output") + canonical := flag.String("canonical", "", "canonical to compare (reader mode)") + report := flag.String("report", "", "JSON verdict path") + flag.Parse() + if *input == "" || *report == "" || (*canonical == "") == (*output == "") { + flag.Usage() + os.Exit(2) + } + err := run(*input, *output, *canonical) + note := "Apache Arrow Go pqarrow: round-trips to canonical" + if err != nil { + note = err.Error() + } + var verdict any + if *canonical != "" { + status := "pass" + if err != nil { + status = "fail" + } + verdict = map[string]any{"status": status, "note": note} + } else { + // Measure both the physical logical annotation and the native Arrow extension on read. + verdict = map[string]any{"roundtrip": err == nil, "variant_faithful": err == nil && variantFaithful(*input, *output), "note": note} + } + data, err := json.Marshal(verdict) + if err == nil { + err = os.WriteFile(*report, data, 0600) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// A metadata marker alone is not a Parquet VARIANT logical annotation. +func variantFaithful(canonical, artifact string) bool { + source, err := os.Open(canonical) + if err != nil { + return false + } + defer source.Close() + original, err := ipc.NewFileReader(source) + if err != nil { + return false + } + defer original.Close() + input, err := os.Open(artifact) + if err != nil { + return false + } + defer input.Close() + parquet, err := file.NewParquetReader(input) + if err != nil { + return false + } + defer parquet.Close() + reader, err := pqarrow.NewFileReader(parquet, pqarrow.ArrowReadProperties{}, memory.DefaultAllocator) + if err != nil { + return false + } + restored, err := reader.Schema() + if err != nil { + return false + } + isExtension := func(t arrow.DataType) bool { + ext, ok := t.(arrow.ExtensionType) + return ok && ext.ExtensionName() == "arrow.parquet.variant" + } + root := parquet.MetaData().Schema.Root() + for _, f := range original.Schema().Fields() { + name, _ := f.Metadata.GetValue("ARROW:extension:name") + _, marked := f.Metadata.GetValue("__variant_type") + if !marked && name != "arrow.parquet.variant" && !isExtension(f.Type) { + continue + } + idx := root.FieldIndexByName(f.Name) + fields := restored.FieldIndices(f.Name) + if idx < 0 || root.Field(idx).LogicalType().String() != "Variant" || len(fields) != 1 || !isExtension(restored.Field(fields[0]).Type) { + return false + } + } + return true +} diff --git a/tests/test_go_sidecar.py b/tests/test_go_sidecar.py new file mode 100644 index 0000000..2a3406f --- /dev/null +++ b/tests/test_go_sidecar.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: 2026 Raincloud Maintainers +# SPDX-License-Identifier: Apache-2.0 +"""Apache Arrow Go writes the canonical IPC, not a converted intermediate.""" +import json +import subprocess + +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +from raincloud.pipeline.export import get_exporter, get_reader +from tests._helpers import sidecar, write_ipc + + +@pytest.mark.parametrize("rows", [0, 1, 31]) +def test_go_streams_groups_across_canonical_batches(tmp_path, monkeypatch, rows): + sidecar("parquet@go") + table = pa.table({"n": pa.array(range(rows), pa.int64()), + "nested": pa.array([[float(i), None] if i % 3 else None for i in range(rows)], + pa.list_(pa.float64()))}) + canonical = write_ipc(tmp_path / "sample.arrow.zstd", table, max_chunksize=3) + monkeypatch.setenv("RAINCLOUD_ROW_GROUP_MAX_ROWS", "5") + spec = {"slug": "sample", "write": {"row_group_size_rows": 7}} + result = get_exporter("parquet@go").export(spec, canonical, tmp_path / "out.parquet") + assert result.compliance.roundtrip is True, result.compliance + assert result.compliance.variant_faithful is True + parquet = pq.ParquetFile(result.out_path) + assert parquet.read().equals(table) + assert [parquet.metadata.row_group(i).num_rows for i in range(parquet.num_row_groups)] == ( + [7] * (rows // 7) + ([rows % 7] if rows % 7 else [])) + sidecar("parquet@go", "read") + assert get_reader("parquet@go").read_conformance(result.out_path, canonical).status == "pass" + + +def test_go_refuses_page_row_limit_without_publishing(tmp_path, monkeypatch): + sidecar("parquet@go") + canonical = write_ipc(tmp_path / "x.arrow.zstd", pa.table({"x": range(10)})) + dest = tmp_path / "x.parquet" + dest.write_bytes(b"previous generation") + monkeypatch.setenv("RAINCLOUD_PARQUET_PAGE_ROWS", "10") + result = get_exporter("parquet@go").export({"slug": "x"}, canonical, dest) + assert result.compliance.roundtrip is False + assert "cannot honour RAINCLOUD_PARQUET_PAGE_ROWS=10" in result.compliance.note + assert dest.read_bytes() == b"previous generation" + + +def test_go_corrupt_file_reports_failure(tmp_path): + binary = sidecar("parquet@go", "read") + canonical = write_ipc(tmp_path / "x.arrow.zstd", pa.table({"x": [1]})) + artifact, report = tmp_path / "bad.parquet", tmp_path / "report.json" + artifact.write_bytes(b"broken parquet") + subprocess.run([binary, "--input", str(artifact), "--canonical", str(canonical), + "--report", str(report)], check=True, timeout=30) + assert json.loads(report.read_text())["status"] == "fail" + + +def test_go_empty_file_still_checks_types(tmp_path): + sidecar("parquet@go", "read") + canonical = write_ipc(tmp_path / "empty.arrow.zstd", pa.table({"x": pa.array([], pa.int64())})) + artifact = tmp_path / "empty.parquet" + pq.write_table(pa.table({"x": pa.array([], pa.string())}), artifact) + assert get_reader("parquet@go").read_conformance(artifact, canonical).status == "fail" + + +@pytest.mark.parametrize("value", ["7e0", "7.9"]) +def test_go_row_cap_uses_shared_number_grammar(tmp_path, monkeypatch, value): + sidecar("parquet@go") + monkeypatch.setenv("RAINCLOUD_ROW_GROUP_MAX_ROWS", value) + canonical = write_ipc(tmp_path / "tiny.arrow.zstd", pa.table({"x": range(20)}), max_chunksize=3) + result = get_exporter("parquet@go").export({"slug": "tiny"}, canonical, tmp_path / "tiny.parquet") + assert result.compliance.roundtrip is True, result.compliance + metadata = pq.ParquetFile(result.out_path).metadata + assert [metadata.row_group(i).num_rows for i in range(metadata.num_row_groups)] == [7, 7, 6] + + +@pytest.mark.parametrize("null_row", [False, True]) +def test_go_preserves_native_variant(tmp_path, null_row): + from tests.test_parquet_variant_sidecars import _variant_table + sidecar("parquet@go") + canonical = write_ipc(tmp_path / "variant.arrow.zstd", _variant_table(null_row), max_chunksize=1) + result = get_exporter("parquet@go").export({"slug": "variant"}, canonical, tmp_path / "variant.parquet") + assert result.compliance.roundtrip is True, result.compliance + assert result.compliance.variant_faithful is True + assert "Variant(" in str(pq.ParquetFile(result.out_path).schema) diff --git a/tests/test_parquet_options.py b/tests/test_parquet_options.py index 7ac6e51..bc9e47f 100644 --- a/tests/test_parquet_options.py +++ b/tests/test_parquet_options.py @@ -198,7 +198,7 @@ def _write(tmp_path, cell: str, spec: dict = SPEC): return result.compliance.roundtrip, result.compliance.note, dest -CELLS = ("parquet@py", "parquet@rs", "parquet@java", "parquet@hardwood") +CELLS = ("parquet@go", "parquet@py", "parquet@rs", "parquet@java", "parquet@hardwood") @pytest.mark.parametrize("cell", CELLS) @@ -245,7 +245,7 @@ def test_page_indexes_and_checksums_default_on_where_supported(tmp_path, cell): assert chunk.has_offset_index == (cell != "parquet@hardwood") pages = _pages(dest, c, g) assert pages - assert all((4 in page) == (cell != "parquet@rs") for page in pages) # crc + assert all((4 in page) == (cell not in ("parquet@rs", "parquet@go")) for page in pages) # crc @pytest.mark.parametrize("cell", CELLS) @@ -335,6 +335,11 @@ def _codec(path) -> str: } +# Apache Arrow Go supports the same controls as arrow-rs here, including per-column indexes. +for _env, _checks in SETTINGS.values(): + _checks["parquet@go"] = _checks["parquet@rs"] + + @pytest.mark.parametrize("cell", CELLS) @pytest.mark.parametrize("setting", SETTINGS) def test_each_setting_is_honoured_or_refused_by_every_writer(tmp_path, monkeypatch, setting, cell): diff --git a/tests/test_reader_fidelity.py b/tests/test_reader_fidelity.py index 01b212c..5e291b9 100644 --- a/tests/test_reader_fidelity.py +++ b/tests/test_reader_fidelity.py @@ -39,12 +39,12 @@ def _cases(): @pytest.mark.parametrize("got,expected,status", list(_cases())) -@pytest.mark.parametrize("reader", ["python", "rust", "java"]) +@pytest.mark.parametrize("reader", ["python", "rust", "java", "go"]) def test_reader_fidelity(tmp_path, got, expected, status, reader): canonical = pa.table({"x": expected}) artifact = pa.table({"x": got}) if reader != "python": - binary = sidecar({"rust": "parquet@rs", "java": "parquet@java"}[reader], "read") + binary = sidecar({"rust": "parquet@rs", "java": "parquet@java", "go": "parquet@go"}[reader], "read") ipc, parquet, report = (tmp_path / name for name in ("c.arrow.zstd", "a.parquet", "r.json")) write_ipc(ipc, canonical) pq.write_table(artifact, parquet) From b137972b8e1c25ca1c2d756f84c29e49320efb76 Mon Sep 17 00:00:00 2001 From: Martin Prammer Date: Sat, 10 Oct 2026 17:38:20 -0400 Subject: [PATCH 2/5] Fix Go verification and decoded row-group sizing Signed-off-by: Martin Prammer --- sidecars/go/compare.go | 93 +++++++++++++++++++++++++++++++--------- sidecars/go/main.go | 5 ++- sidecars/go/size.go | 64 +++++++++++++++++++++++++++ tests/test_go_sidecar.py | 43 +++++++++++++++++++ 4 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 sidecars/go/size.go diff --git a/sidecars/go/compare.go b/sidecars/go/compare.go index 1a0ac70..1892d98 100644 --- a/sidecars/go/compare.go +++ b/sidecars/go/compare.go @@ -23,9 +23,12 @@ func compareRecords(got pqarrow.RecordReader, expected *ipc.FileReader) error { if got.Schema().Field(i).Name != f.Name { return fmt.Errorf("column names differ") } + if !compatibleTypes(got.Schema().Field(i).Type, f.Type) { + return fmt.Errorf("column %q has incompatible types", f.Name) + } } var actual arrow.RecordBatch - var offset, rows int64 + var offset int64 next := func() bool { for got.Next() { actual = got.RecordBatch() @@ -42,7 +45,6 @@ func compareRecords(got pqarrow.RecordReader, expected *ipc.FileReader) error { if err != nil { return err } - rows += batch.NumRows() err = func() error { defer batch.Release() for start := int64(0); start < batch.NumRows(); { @@ -77,19 +79,15 @@ func compareRecords(got pqarrow.RecordReader, expected *ipc.FileReader) error { if actual != nil && offset < actual.NumRows() || next() { return fmt.Errorf("row count differs") } - if rows == 0 { - for i, f := range expected.Schema().Fields() { - if !emptyTypesEqual(got.Schema().Field(i).Type, f.Type) { - return fmt.Errorf("empty column %q has incompatible types", f.Name) - } - } - } return got.Err() } // A safe cast alone permits lossy float narrowing and int->bool. Require the // reverse cast to recover the original values, including float sign bits. func logicalEqual(a, b arrow.Array) bool { + if !compatibleTypes(a.DataType(), b.DataType()) { + return false + } if ext, ok := a.(array.ExtensionArray); ok { a = ext.Storage() } @@ -118,7 +116,7 @@ type listArray interface { ListValues() arrow.Array } -// Ignore payload under null parents; preserve signed zero and accept NaNs at +// Ignore payload under null parents; preserve signed zero and NaN payloads at // every nesting level. Arrow's approximate comparator collapses signed zero. func exactEqual(a, b arrow.Array) bool { if a.Len() != b.Len() || a.NullN() != b.NullN() { @@ -138,7 +136,7 @@ func exactEqual(a, b arrow.Array) bool { continue } x, y := left.Value(i), right.Value(i) - if x.Uint16() != y.Uint16() && !(math.IsNaN(float64(x.Float32())) && math.IsNaN(float64(y.Float32()))) { + if x.Uint16() != y.Uint16() { return false } } @@ -156,7 +154,7 @@ func exactEqual(a, b arrow.Array) bool { continue } x, y := left.Value(i), right.Value(i) - if math.Float64bits(x) != math.Float64bits(y) && !(math.IsNaN(x) && math.IsNaN(y)) { + if math.Float64bits(x) != math.Float64bits(y) { return false } } @@ -174,7 +172,7 @@ func exactEqual(a, b arrow.Array) bool { continue } x, y := left.Value(i), right.Value(i) - if math.Float32bits(x) != math.Float32bits(y) && !(math.IsNaN(float64(x)) && math.IsNaN(float64(y))) { + if math.Float32bits(x) != math.Float32bits(y) { return false } } @@ -252,9 +250,9 @@ func exactEqual(a, b arrow.Array) bool { } } -// Empty files still have a schema. Reversible casts of empty arrays alone -// would incorrectly accept unrelated types such as string and integer. -func emptyTypesEqual(a, b arrow.DataType) bool { +// Match the logical families in export/compare.py independently of values. +// Empty arrays and null containers must satisfy the same schema contract. +func compatibleTypes(a, b arrow.DataType) bool { if ext, ok := a.(arrow.ExtensionType); ok { a = ext.StorageType() } @@ -265,12 +263,67 @@ func emptyTypesEqual(a, b arrow.DataType) bool { return true } if d, ok := a.(*arrow.DictionaryType); ok { - return emptyTypesEqual(d.ValueType, b) + return compatibleTypes(d.ValueType, b) } if d, ok := b.(*arrow.DictionaryType); ok { - return emptyTypesEqual(a, d.ValueType) + return compatibleTypes(a, d.ValueType) + } + if left, ok := a.(*arrow.StructType); ok { + right, ok := b.(*arrow.StructType) + return ok && compatibleFields(left.Fields(), right.Fields()) + } + if left, ok := a.(*arrow.MapType); ok { + right, ok := b.(*arrow.MapType) + return ok && compatibleFields([]arrow.Field{left.KeyField(), left.ItemField()}, []arrow.Field{right.KeyField(), right.ItemField()}) + } + lists := func(t arrow.Type) bool { + return t == arrow.LIST || t == arrow.LARGE_LIST || t == arrow.FIXED_SIZE_LIST || t == arrow.LIST_VIEW || t == arrow.LARGE_LIST_VIEW + } + if lists(a.ID()) && lists(b.ID()) { + if left, ok := a.(*arrow.FixedSizeListType); ok { + if right, ok := b.(*arrow.FixedSizeListType); ok && left.Len() != right.Len() { + return false + } + } + return compatibleTypes(a.(interface{ Elem() arrow.DataType }).Elem(), b.(interface{ Elem() arrow.DataType }).Elem()) + } + if left, ok := a.(*arrow.TimestampType); ok { + right, ok := b.(*arrow.TimestampType) + return ok && (left.TimeZone == "") == (right.TimeZone == "") + } + if left, ok := a.(arrow.DecimalType); ok && arrow.IsInteger(b.ID()) { + return left.GetScale() == 0 + } + if right, ok := b.(arrow.DecimalType); ok && arrow.IsInteger(a.ID()) { + return right.GetScale() == 0 + } + if left, ok := a.(*arrow.FixedSizeBinaryType); ok { + if right, ok := b.(*arrow.FixedSizeBinaryType); ok && left.ByteWidth != right.ByteWidth { + return false + } } strings := func(t arrow.Type) bool { return t == arrow.STRING || t == arrow.LARGE_STRING || t == arrow.STRING_VIEW } - binary := func(t arrow.Type) bool { return t == arrow.BINARY || t == arrow.LARGE_BINARY || t == arrow.BINARY_VIEW } - return strings(a.ID()) && strings(b.ID()) || binary(a.ID()) && binary(b.ID()) + binary := func(t arrow.Type) bool { + return t == arrow.BINARY || t == arrow.LARGE_BINARY || t == arrow.BINARY_VIEW || t == arrow.FIXED_SIZE_BINARY + } + date := func(t arrow.Type) bool { return t == arrow.DATE32 || t == arrow.DATE64 } + time := func(t arrow.Type) bool { return t == arrow.TIME32 || t == arrow.TIME64 } + for _, family := range []func(arrow.Type) bool{arrow.IsInteger, arrow.IsFloating, arrow.IsDecimal, strings, binary, date, time} { + if family(a.ID()) && family(b.ID()) { + return true + } + } + return a.ID() == arrow.DURATION && b.ID() == arrow.DURATION +} + +func compatibleFields(a, b []arrow.Field) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Name != b[i].Name || !compatibleTypes(a[i].Type, b[i].Type) { + return false + } + } + return true } diff --git a/sidecars/go/main.go b/sidecars/go/main.go index dad929c..9f438d1 100644 --- a/sidecars/go/main.go +++ b/sidecars/go/main.go @@ -17,7 +17,6 @@ import ( "github.com/apache/arrow-go/v18/arrow" "github.com/apache/arrow-go/v18/arrow/ipc" "github.com/apache/arrow-go/v18/arrow/memory" - "github.com/apache/arrow-go/v18/arrow/util" "github.com/apache/arrow-go/v18/parquet" "github.com/apache/arrow-go/v18/parquet/compress" "github.com/apache/arrow-go/v18/parquet/file" @@ -202,7 +201,9 @@ func write(input, output string) error { n := min(cap-rows, batch.NumRows()-offset, 1024) part := batch.NewSlice(offset, offset+n) err := writer.WriteBuffered(part) - bytes += util.TotalRecordSize(part) * n / max(batch.NumRows(), 1) + for _, col := range part.Columns() { + bytes += decodedSize(col) + } part.Release() if err != nil { return err diff --git a/sidecars/go/size.go b/sidecars/go/size.go new file mode 100644 index 0000000..2cd9af2 --- /dev/null +++ b/sidecars/go/size.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Raincloud Maintainers +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "github.com/apache/arrow-go/v18/arrow" + "github.com/apache/arrow-go/v18/arrow/array" + "github.com/apache/arrow-go/v18/arrow/util" +) + +// Size the selected values, not a fraction of the backing buffers: large +// values can be concentrated in a small slice of a canonical batch. +func decodedSize(a arrow.Array) int64 { + validity := int64((a.Len() + 7) / 8) + if ext, ok := a.(array.ExtensionArray); ok { + return decodedSize(ext.Storage()) + } + if values, ok := a.(*array.Dictionary); ok { + size := validity + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) { + continue + } + index := int64(values.GetValueIndex(i)) + part := array.NewSlice(values.Dictionary(), index, index+1) + size += decodedSize(part) + part.Release() + } + return size + } + if dt, ok := a.DataType().(arrow.FixedWidthDataType); ok { + return validity + (int64(a.Len())*int64(dt.BitWidth())+7)/8 + } + size := validity + switch values := a.(type) { + case interface{ Value(int) string }: + size += int64(a.Len()+1) * 8 + for i := 0; i < a.Len(); i++ { + size += int64(len(values.Value(i))) + } + case interface{ Value(int) []byte }: + size += int64(a.Len()+1) * 8 + for i := 0; i < a.Len(); i++ { + size += int64(len(values.Value(i))) + } + case *array.Struct: + for i := 0; i < values.NumField(); i++ { + size += decodedSize(values.Field(i)) + } + case listArray: + size += int64(a.Len()+1) * 16 + for i := 0; i < a.Len(); i++ { + lo, hi := values.ValueOffsets(i) + part := array.NewSlice(values.ListValues(), lo, hi) + size += decodedSize(part) + part.Release() + } + default: + // For uncommon representations, overcount backing buffers rather + // than underestimate memory retained by the row group. + return util.TotalArraySize(a) + } + return size +} diff --git a/tests/test_go_sidecar.py b/tests/test_go_sidecar.py index 2a3406f..b1ba8fc 100644 --- a/tests/test_go_sidecar.py +++ b/tests/test_go_sidecar.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 """Apache Arrow Go writes the canonical IPC, not a converted intermediate.""" import json +import struct import subprocess import pyarrow as pa @@ -82,3 +83,45 @@ def test_go_preserves_native_variant(tmp_path, null_row): assert result.compliance.roundtrip is True, result.compliance assert result.compliance.variant_faithful is True assert "Variant(" in str(pq.ParquetFile(result.out_path).schema) + + +@pytest.mark.parametrize("got,expected,status", [ + (pa.array(["123"]), pa.array([123]), "fail"), + (pa.array([1]), pa.array([True]), "fail"), + (pa.array([None], pa.string()), pa.array([None], pa.int64()), "fail"), + (pa.array([0], pa.timestamp("us")), pa.array([0], pa.timestamp("us", "UTC")), "fail"), + (pa.array([[]], pa.list_(pa.struct([("a", pa.int32())]))), + pa.array([[]], pa.list_(pa.struct([("b", pa.int32())]))), "fail"), + (pa.array([], pa.int32()), pa.array([], pa.int64()), "pass"), + (pa.array([], pa.binary(4)), pa.array([], pa.binary()), "pass"), + (pa.array([], pa.list_(pa.int32(), 2)), pa.array([], pa.large_list(pa.int64())), "pass"), + (pa.array([], pa.list_(pa.int32(), 2)), pa.array([], pa.list_(pa.int32(), 3)), "fail"), + (pa.array([struct.unpack("d", struct.pack("Q", 0x7ff8000000000001))[0]]), + pa.array([struct.unpack("d", struct.pack("Q", 0x7ff8000000000002))[0]]), "fail"), +]) +def test_go_schema_and_float_fidelity(tmp_path, got, expected, status): + sidecar("parquet@go", "read") + canonical = write_ipc(tmp_path / "c.arrow.zstd", pa.table({"x": expected})) + artifact = tmp_path / "a.parquet" + pq.write_table(pa.table({"x": got}), artifact) + result = get_reader("parquet@go").read_conformance(artifact, canonical) + assert result.status == status, result + + +@pytest.mark.parametrize("dictionary", [False, True]) +def test_go_decoded_guard_counts_skewed_slice_values(tmp_path, monkeypatch, dictionary): + sidecar("parquet@go") + monkeypatch.setenv("RAINCLOUD_ROW_GROUP_TARGET_BYTES", str(4 << 20)) + monkeypatch.setenv("RAINCLOUD_ROW_GROUP_TARGET_ENCODED_BYTES", "0") + monkeypatch.setenv("RAINCLOUD_PARQUET_DICTIONARY", "0") + table = pa.table({"x": ["x" * 1024] * 8192 + [""] * 57344}) + if dictionary: + table = pa.table({"x": table["x"].dictionary_encode()}) + canonical = write_ipc(tmp_path / "skewed.arrow.zstd", table) + result = get_exporter("parquet@go").export({"slug": "skewed"}, canonical, tmp_path / "skewed.parquet") + assert result.compliance.roundtrip is True, result.compliance + file = pq.ParquetFile(result.out_path) + assert file.read().equals(table) + # The writer checks after each 1024-row slice, allowing at most one slice of overshoot. + assert max(file.read_row_group(i).cast(pa.schema([("x", pa.string())])).nbytes + for i in range(file.num_row_groups)) < (5 << 20) From f8f6cf24b864895ec4dd66ade384cb58004a3685 Mon Sep 17 00:00:00 2001 From: Martin Prammer Date: Sat, 10 Oct 2026 20:00:13 -0400 Subject: [PATCH 3/5] Honor Go logical comparison and row-group sizing contracts Signed-off-by: Martin Prammer --- sidecars/README.md | 6 ++- sidecars/go/compare.go | 104 +++++++++++++++++++++++++++++++++++++++ sidecars/go/main.go | 41 +++++++++++++-- tests/test_go_sidecar.py | 73 +++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 5 deletions(-) diff --git a/sidecars/README.md b/sidecars/README.md index 0ddce17..d5a9218 100644 --- a/sidecars/README.md +++ b/sidecars/README.md @@ -432,7 +432,11 @@ preserves float sign bits through nested values. The writer stores the Arrow schema but makes no claim to preserve the Parquet VARIANT logical annotation. Row groups accumulate across IPC batches, capped by the recipe's row limit and -checked every 1,024 rows against encoded bytes and estimated decoded bytes. Page +checked every 1,024 rows against encoded bytes and estimated decoded bytes. An +uncompressed planning encoder runs alongside the writer so compression does not +move row-group boundaries; disabling the encoded-byte target skips that encoder. +Selective statistics/index settings are refused when dotted field names create +ambiguous leaf paths requiring different settings. Page rows and checksums-on are refused: Arrow Go exposes neither an exact page-row limit nor a checksum writer option. Other controls are listed above. Compression and statistics come from the recipe, and page indexes default on with statistics. diff --git a/sidecars/go/compare.go b/sidecars/go/compare.go index 1892d98..f45666e 100644 --- a/sidecars/go/compare.go +++ b/sidecars/go/compare.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "context" "fmt" "math" @@ -94,6 +95,53 @@ func logicalEqual(a, b arrow.Array) bool { if ext, ok := b.(array.ExtensionArray); ok { b = ext.Storage() } + if a.Len() != b.Len() { + return false + } + for i := 0; i < a.Len(); i++ { + if logicalNull(a, i) != logicalNull(b, i) { + return false + } + } + // Compare dictionary values, not their index representation. Go has no + // general cast from primitive values back into a dictionary. + _, adict := a.(*array.Dictionary) + _, bdict := b.(*array.Dictionary) + _, aunion := a.(array.Union) + _, bunion := b.(array.Union) + if adict || bdict || aunion || bunion { + for i := 0; i < a.Len(); i++ { + if logicalNull(a, i) { + continue + } + x, y := valueSlice(a, i), valueSlice(b, i) + equal := logicalEqual(x, y) + x.Release() + y.Release() + if !equal { + return false + } + } + return true + } + // Nested and binary representations normalize without casting whole + // arrays. This also ignores arbitrary payload beneath null parents. + switch a.(type) { + case *array.Struct, listArray: + return exactEqual(a, b) + case interface{ Value(int) []byte }: + left := a.(interface{ Value(int) []byte }) + right, ok := b.(interface{ Value(int) []byte }) + if !ok { + return false + } + for i := 0; i < a.Len(); i++ { + if !a.IsNull(i) && !bytes.Equal(left.Value(i), right.Value(i)) { + return false + } + } + return true + } if arrow.TypeEqual(a.DataType(), b.DataType()) { return exactEqual(a, b) } @@ -110,6 +158,41 @@ func logicalEqual(a, b arrow.Array) bool { return exactEqual(a, back) && exactEqual(cast, b) } +func valueSlice(a arrow.Array, i int) arrow.Array { + if dict, ok := a.(*array.Dictionary); ok { + index := int64(dict.GetValueIndex(i)) + return array.NewSlice(dict.Dictionary(), index, index+1) + } + if union, ok := a.(array.Union); ok { + index := int64(i) + if dense, ok := a.(*array.DenseUnion); ok { + index = int64(dense.ValueOffset(i)) + } + return array.NewSlice(union.Field(union.ChildID(i)), index, index+1) + } + return array.NewSlice(a, int64(i), int64(i+1)) +} + +func logicalNull(a arrow.Array, i int) bool { + if a.DataType().ID() == arrow.NULL || a.IsNull(i) { + return true + } + if ext, ok := a.(array.ExtensionArray); ok { + return logicalNull(ext.Storage(), i) + } + if dict, ok := a.(*array.Dictionary); ok { + return logicalNull(dict.Dictionary(), dict.GetValueIndex(i)) + } + if union, ok := a.(array.Union); ok { + index := i + if dense, ok := a.(*array.DenseUnion); ok { + index = int(dense.ValueOffset(i)) + } + return logicalNull(union.Field(union.ChildID(i)), index) + } + return false +} + type listArray interface { arrow.Array ValueOffsets(int) (int64, int64) @@ -259,6 +342,12 @@ func compatibleTypes(a, b arrow.DataType) bool { if ext, ok := b.(arrow.ExtensionType); ok { b = ext.StorageType() } + if member := nullableMember(a); member != nil { + return compatibleTypes(member, b) + } + if member := nullableMember(b); member != nil { + return compatibleTypes(a, member) + } if arrow.TypeEqual(a, b) { return true } @@ -316,6 +405,21 @@ func compatibleTypes(a, b arrow.DataType) bool { return a.ID() == arrow.DURATION && b.ID() == arrow.DURATION } +func nullableMember(t arrow.DataType) arrow.DataType { + if union, ok := t.(arrow.UnionType); ok { + fields := union.Fields() + if len(fields) == 2 { + if fields[0].Type.ID() == arrow.NULL { + return fields[1].Type + } + if fields[1].Type.ID() == arrow.NULL { + return fields[0].Type + } + } + } + return nil +} + func compatibleFields(a, b []arrow.Field) bool { if len(a) != len(b) { return false diff --git a/sidecars/go/main.go b/sidecars/go/main.go index 9f438d1..be4ff28 100644 --- a/sidecars/go/main.go +++ b/sidecars/go/main.go @@ -8,6 +8,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "math" "os" "regexp" @@ -67,7 +68,7 @@ func refused(name string) error { return fmt.Errorf("parquet@go cannot honour %s=%s", name, os.Getenv(name)) } -func properties(sc *arrow.Schema, rowCap int64) (*parquet.WriterProperties, error) { +func properties(sc *arrow.Schema, rowCap int64, planning bool) (*parquet.WriterProperties, error) { stats, err := switchValue("RAINCLOUD_PARQUET_STATISTICS", true) if err != nil { return nil, err @@ -138,9 +139,21 @@ func properties(sc *arrow.Schema, rowCap int64) (*parquet.WriterProperties, erro if err != nil { return nil, err } + type columnOptions struct{ stats, index bool } + paths := make(map[string]columnOptions) for i := 0; i < schema.NumColumns(); i++ { path := schema.Column(i).Path() - opts = append(opts, parquet.WithStatsFor(path, stats && int64(i) < statCols), parquet.WithPageIndexEnabledFor(path, index && int64(i) < statCols && int64(i) < indexCols)) + selected := columnOptions{stats && int64(i) < statCols, index && int64(i) < statCols && int64(i) < indexCols} + if previous, exists := paths[path]; exists && previous != selected { + return nil, fmt.Errorf("parquet@go cannot honour selective statistics/page indexes: leaf path %q is ambiguous", path) + } + paths[path] = selected + opts = append(opts, parquet.WithStatsFor(path, selected.stats), parquet.WithPageIndexEnabledFor(path, selected.index)) + } + if planning { + // RowGroupTotalBytesWritten counts compressed pages. An otherwise + // identical uncompressed encoder makes the boundary codec-independent. + opts = append(opts, parquet.WithCompression(compress.Codecs.Uncompressed)) } return parquet.NewWriterProperties(opts...), nil } @@ -168,7 +181,7 @@ func write(input, output string) error { if err != nil { return err } - props, err := properties(reader.Schema(), cap) + props, err := properties(reader.Schema(), cap, false) if err != nil { return err } @@ -182,6 +195,18 @@ func write(input, output string) error { return err } defer writer.Close() + var planner *pqarrow.FileWriter + if encoded < 1<<62 { + planningProps, err := properties(reader.Schema(), cap, true) + if err != nil { + return err + } + planner, err = pqarrow.NewFileWriter(reader.Schema(), io.Discard, planningProps, pqarrow.DefaultWriterProps()) + if err != nil { + return err + } + defer planner.Close() + } var rows, bytes int64 for i := 0; i < reader.NumRecords(); i++ { batch, err := reader.RecordBatchAt(i) @@ -191,16 +216,24 @@ func write(input, output string) error { err = func() error { defer batch.Release() for offset := int64(0); offset < batch.NumRows(); { - if rows >= cap || bytes >= decoded || writer.RowGroupTotalBytesWritten() >= encoded { + if rows >= cap || bytes >= decoded || planner != nil && planner.RowGroupTotalBytesWritten() >= encoded { if err := writer.NewBufferedRowGroupChecked(); err != nil { return err } + if planner != nil { + if err := planner.NewBufferedRowGroupChecked(); err != nil { + return err + } + } rows, bytes = 0, 0 } // Check byte bounds every small slice, rather than retaining a whole canonical batch in the encoder. n := min(cap-rows, batch.NumRows()-offset, 1024) part := batch.NewSlice(offset, offset+n) err := writer.WriteBuffered(part) + if err == nil && planner != nil { + err = planner.WriteBuffered(part) + } for _, col := range part.Columns() { bytes += decodedSize(col) } diff --git a/tests/test_go_sidecar.py b/tests/test_go_sidecar.py index b1ba8fc..d4417c0 100644 --- a/tests/test_go_sidecar.py +++ b/tests/test_go_sidecar.py @@ -11,6 +11,9 @@ from raincloud.pipeline.export import get_exporter, get_reader from tests._helpers import sidecar, write_ipc +from tests.test_compare_cases import CASES, _read +from tests.test_java_reader_schema_compare import _cases as schema_cases +from tests.test_java_reader_schema_compare import _fixed_list_cases as fixed_list_cases @pytest.mark.parametrize("rows", [0, 1, 31]) @@ -125,3 +128,73 @@ def test_go_decoded_guard_counts_skewed_slice_values(tmp_path, monkeypatch, dict # The writer checks after each 1024-row slice, allowing at most one slice of overshoot. assert max(file.read_row_group(i).cast(pa.schema([("x", pa.string())])).nbytes for i in range(file.num_row_groups)) < (5 << 20) + + +@pytest.mark.parametrize("expected,got,status", [*schema_cases(), *fixed_list_cases()]) +def test_go_shared_schema_contract(tmp_path, expected, got, status): + sidecar("parquet@go", "read") + canonical = write_ipc(tmp_path / "canonical.arrow.zstd", expected) + artifact = tmp_path / "artifact.parquet" + pq.write_table(got, artifact) + verdict = get_reader("parquet@go").read_conformance(artifact, canonical) + assert verdict.status == status, verdict + + +@pytest.mark.parametrize("case", json.loads((CASES / "cases.json").read_text())["cases"], ids=lambda c: c["name"]) +def test_go_shared_comparison_contract(tmp_path, case): + sidecar("parquet@go", "read") + got = _read(CASES / f"{case['name']}.got.arrow") + expected = _read(CASES / f"{case['name']}.expected.arrow") + # Parquet cannot store unions: the union representation belongs on the + # canonical side. Equality must be independent of which side normalizes. + if case["name"].startswith("union-"): + got, expected = expected, got + canonical = write_ipc(tmp_path / "canonical.arrow.zstd", expected) + artifact = tmp_path / "artifact.parquet" + pq.write_table(got, artifact) + verdict = get_reader("parquet@go").read_conformance(artifact, canonical) + assert verdict.status == ("pass" if case["verdict"] == "equal" else "fail"), verdict + + +@pytest.mark.parametrize("nested", [False, True]) +def test_go_roundtrips_integer_dictionary(tmp_path, nested): + sidecar("parquet@go") + values = pa.array([1, None, 2]).dictionary_encode() + if nested: + values = pa.StructArray.from_arrays([values], names=["v"]) + canonical = write_ipc(tmp_path / "dictionary.arrow.zstd", pa.table({"x": values})) + result = get_exporter("parquet@go").export({"slug": "dictionary"}, canonical, tmp_path / "dictionary.parquet") + assert result.compliance.roundtrip is True, result.compliance + + +def test_go_encoded_target_is_codec_independent(tmp_path, monkeypatch): + sidecar("parquet@go") + monkeypatch.setenv("RAINCLOUD_ROW_GROUP_TARGET_ENCODED_BYTES", str(128 << 10)) + monkeypatch.setenv("RAINCLOUD_ROW_GROUP_TARGET_BYTES", "0") + monkeypatch.setenv("RAINCLOUD_PARQUET_PAGE_BYTES", "1024") + monkeypatch.setenv("RAINCLOUD_PARQUET_DICTIONARY", "0") + table = pa.table({"x": ["x" * 256] * 16384}) + canonical = write_ipc(tmp_path / "encoded.arrow.zstd", table) + boundaries = [] + for codec in ("none", "zstd"): + result = get_exporter("parquet@go").export({"slug": "encoded", "write": {"compression": codec}}, + canonical, tmp_path / f"{codec}.parquet") + assert result.compliance.roundtrip is True, result.compliance + file = pq.ParquetFile(result.out_path) + assert file.read().equals(table) + boundaries.append([file.metadata.row_group(i).num_rows for i in range(file.num_row_groups)]) + assert boundaries[0] == boundaries[1] == [1024] * 16 + + +@pytest.mark.parametrize("setting", ["RAINCLOUD_PARQUET_STATISTICS_COLUMNS", "RAINCLOUD_PARQUET_PAGE_INDEX_COLUMNS"]) +def test_go_refuses_ambiguous_selective_column_paths(tmp_path, monkeypatch, setting): + sidecar("parquet@go") + table = pa.table({"a.b": [1], "a": pa.array([{"b": 2}], pa.struct([("b", pa.int64())]))}) + canonical = write_ipc(tmp_path / "ambiguous.arrow.zstd", table) + dest = tmp_path / "ambiguous.parquet" + dest.write_bytes(b"previous generation") + monkeypatch.setenv(setting, "1") + result = get_exporter("parquet@go").export({"slug": "ambiguous"}, canonical, dest) + assert result.compliance.roundtrip is False + assert "leaf path" in result.compliance.note and "ambiguous" in result.compliance.note + assert dest.read_bytes() == b"previous generation" From 286841d1c93ead09c71f8f507551ccb8e47e165b Mon Sep 17 00:00:00 2001 From: Martin Prammer Date: Sat, 10 Oct 2026 20:14:15 -0400 Subject: [PATCH 4/5] Fix Go nullable-union and narrow-numeric comparison Signed-off-by: Martin Prammer --- sidecars/go/compare.go | 61 +++++++++++++++++++++++++++++++++++-- tests/test_go_sidecar.py | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/sidecars/go/compare.go b/sidecars/go/compare.go index f45666e..0d0ef1a 100644 --- a/sidecars/go/compare.go +++ b/sidecars/go/compare.go @@ -11,7 +11,9 @@ import ( "github.com/apache/arrow-go/v18/arrow" "github.com/apache/arrow-go/v18/arrow/array" "github.com/apache/arrow-go/v18/arrow/compute" + "github.com/apache/arrow-go/v18/arrow/decimal128" "github.com/apache/arrow-go/v18/arrow/ipc" + "github.com/apache/arrow-go/v18/arrow/memory" "github.com/apache/arrow-go/v18/parquet/pqarrow" ) @@ -145,6 +147,16 @@ func logicalEqual(a, b arrow.Array) bool { if arrow.TypeEqual(a.DataType(), b.DataType()) { return exactEqual(a, b) } + // Go's cast kernels omit half floats and narrow decimals. Widen these + // losslessly before using the reversible primitive comparison below. + if wide := widenPrimitive(a); wide != nil { + defer wide.Release() + return logicalEqual(wide, b) + } + if wide := widenPrimitive(b); wide != nil { + defer wide.Release() + return logicalEqual(a, wide) + } cast, err := compute.CastArray(context.Background(), a, compute.SafeCastOptions(b.DataType())) if err != nil { return false @@ -158,6 +170,51 @@ func logicalEqual(a, b arrow.Array) bool { return exactEqual(a, back) && exactEqual(cast, b) } +func widenPrimitive(a arrow.Array) arrow.Array { + if half, ok := a.(*array.Float16); ok { + builder := array.NewFloat32Builder(memory.DefaultAllocator) + defer builder.Release() + builder.Reserve(a.Len()) + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) { + builder.AppendNull() + } else { + value := half.Value(i) + wide := value.Float32() + bits := value.Uint16() + if bits&0x7c00 == 0 && bits&0x03ff != 0 { + // The pinned Float32 helper does not normalize subnormals. + wide = float32(math.Ldexp(float64(bits&0x03ff), -24)) + wide = math.Float32frombits(math.Float32bits(wide) | uint32(bits&0x8000)<<16) + } + builder.Append(wide) + } + } + return builder.NewArray() + } + var value func(int) int64 + switch narrow := a.(type) { + case *array.Decimal32: + value = func(i int) int64 { return int64(narrow.Value(i)) } + case *array.Decimal64: + value = func(i int) int64 { return int64(narrow.Value(i)) } + default: + return nil + } + dtype := a.DataType().(arrow.DecimalType) + builder := array.NewDecimal128Builder(memory.DefaultAllocator, &arrow.Decimal128Type{Precision: dtype.GetPrecision(), Scale: dtype.GetScale()}) + defer builder.Release() + builder.Reserve(a.Len()) + for i := 0; i < a.Len(); i++ { + if a.IsNull(i) { + builder.AppendNull() + } else { + builder.Append(decimal128.FromI64(value(i))) + } + } + return builder.NewArray() +} + func valueSlice(a arrow.Array, i int) arrow.Array { if dict, ok := a.(*array.Dictionary); ok { index := int64(dict.GetValueIndex(i)) @@ -409,10 +466,10 @@ func nullableMember(t arrow.DataType) arrow.DataType { if union, ok := t.(arrow.UnionType); ok { fields := union.Fields() if len(fields) == 2 { - if fields[0].Type.ID() == arrow.NULL { + if fields[0].Type.ID() == arrow.NULL && fields[1].Type.ID() != arrow.NULL { return fields[1].Type } - if fields[1].Type.ID() == arrow.NULL { + if fields[1].Type.ID() == arrow.NULL && fields[0].Type.ID() != arrow.NULL { return fields[0].Type } } diff --git a/tests/test_go_sidecar.py b/tests/test_go_sidecar.py index d4417c0..a5e60e9 100644 --- a/tests/test_go_sidecar.py +++ b/tests/test_go_sidecar.py @@ -4,7 +4,9 @@ import json import struct import subprocess +from decimal import Decimal +import numpy as np import pyarrow as pa import pyarrow.parquet as pq import pytest @@ -198,3 +200,66 @@ def test_go_refuses_ambiguous_selective_column_paths(tmp_path, monkeypatch, sett assert result.compliance.roundtrip is False assert "leaf path" in result.compliance.note and "ambiguous" in result.compliance.note assert dest.read_bytes() == b"previous generation" + + +@pytest.mark.parametrize("empty", [False, True]) +@pytest.mark.parametrize("dense", [False, True]) +def test_go_union_of_two_null_members_is_not_plain_null(tmp_path, empty, dense): + sidecar("parquet@go", "read") + codes = pa.array([0, 1], pa.int8()) + children = [pa.nulls(2), pa.nulls(2)] + if dense: + union = pa.UnionArray.from_dense(codes, pa.array([0, 0], pa.int32()), children, ["left", "right"]) + else: + union = pa.UnionArray.from_sparse(codes, children, ["left", "right"]) + if empty: + union = union.slice(0, 0) + canonical = write_ipc(tmp_path / "null-union.arrow.zstd", pa.table({"x": union})) + artifact = tmp_path / "null.parquet" + pq.write_table(pa.table({"x": pa.nulls(len(union))}), artifact) + result = get_reader("parquet@go").read_conformance(artifact, canonical) + assert result.status == "fail", result + assert "incompatible types" in result.note + + +@pytest.mark.parametrize("dtype", [pa.decimal32(8, 0), pa.decimal64(16, 0), + pa.decimal32(8, 2), pa.decimal64(16, 2)]) +def test_go_reads_narrow_decimal_canonicals(tmp_path, dtype): + sidecar("parquet@go", "read") + table = pa.table({"x": pa.array([Decimal("-123"), None, Decimal("456")], dtype)}) + canonical = write_ipc(tmp_path / "decimal.arrow.zstd", table) + artifact = tmp_path / "decimal.parquet" + pq.write_table(table, artifact) + result = get_reader("parquet@go").read_conformance(artifact, canonical) + assert result.status == "pass", result + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_go_compares_every_finite_half_float_across_widths(tmp_path, reverse): + sidecar("parquet@go", "read") + bits = np.arange(65536, dtype=np.uint16) + bits = bits[(bits & 0x7c00) != 0x7c00] # all finite values, including both signs of zero and subnormals + half = bits.view(np.float16) + expected, got = pa.table({"x": half}), pa.table({"x": half.astype(np.float32)}) + if reverse: + expected, got = got, expected + canonical = write_ipc(tmp_path / "half.arrow.zstd", expected, max_chunksize=333) + artifact = tmp_path / "half.parquet" + pq.write_table(got, artifact, row_group_size=511) + result = get_reader("parquet@go").read_conformance(artifact, canonical) + assert result.status == "pass", result + + +@pytest.mark.parametrize("expected,got", [ + (pa.array([Decimal("1.23")], pa.decimal32(8, 2)), pa.array([Decimal("1.24")], pa.decimal128(20, 2))), + (pa.array([Decimal("1")], pa.decimal64(16, 0)), pa.array([Decimal("1.01")], pa.decimal128(20, 2))), + (pa.array([1.1], pa.float16()), pa.array([1.1], pa.float32())), + (pa.array([-0.0], pa.float16()), pa.array([0.0], pa.float32())), +]) +def test_go_numeric_widening_preserves_differences(tmp_path, expected, got): + sidecar("parquet@go", "read") + canonical = write_ipc(tmp_path / "different.arrow.zstd", pa.table({"x": expected})) + artifact = tmp_path / "different.parquet" + pq.write_table(pa.table({"x": got}), artifact) + result = get_reader("parquet@go").read_conformance(artifact, canonical) + assert result.status == "fail", result From c5fbc32963f0ba99c77d17c3080af157c9596475 Mon Sep 17 00:00:00 2001 From: Martin Prammer Date: Mon, 12 Oct 2026 20:18:27 -0400 Subject: [PATCH 5/5] Keep Go writer documentation with its integration Signed-off-by: Martin Prammer --- README.md | 14 +++++++------- sidecars/README.md | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5a6fb7a..2dd50b7 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,13 @@ its own. To try again with nothing changed, pass `--retry-errors` (`raincloud build --retry-errors`, `raincloud load --retry-errors`, or `load(..., retry_errors=True)`). +### Apache Arrow Go Parquet writer + +The optional `parquet@go` sidecar writes and verifies Parquet with Apache Arrow Go's +`pqarrow`. It accepts the shared writer settings, enables page indexes with +statistics, and refuses requests to write page checksums or enforce an exact page-row limit. See +[the build instructions and option matrix](sidecars/README.md#apache-arrow-go). + ## Licensing and redistribution Each entry carries an SPDX identifier, a source URL, and an explicit @@ -340,10 +347,3 @@ are the upstream licenses; the catalog records them but does not grant them. - [`SKILLS.md`](SKILLS.md) — task-by-task procedures - [`CONTRIBUTING.md`](CONTRIBUTING.md) · [`SECURITY.md`](SECURITY.md) · [`DISCLAIMER.md`](DISCLAIMER.md) - [`CITATION.cff`](CITATION.cff) — how to cite this work - -### Apache Arrow Go Parquet writer - -The optional `parquet@go` sidecar writes and verifies Parquet with Apache Arrow Go's -`pqarrow`. It accepts the shared writer settings, enables page indexes with -statistics, and refuses page-checksum and exact page-row-limit requests. See -[the build instructions and option matrix](sidecars/README.md#apache-arrow-go). diff --git a/sidecars/README.md b/sidecars/README.md index d5a9218..51b6c05 100644 --- a/sidecars/README.md +++ b/sidecars/README.md @@ -429,7 +429,8 @@ For PATH discovery, install the binary as both `raincloud-export-parquet-go` and to prefer it. It reads its own output through pqarrow before promotion. Comparison is streamed, permits reversible type normalization, ignores field metadata, and preserves float sign bits through nested values. The writer stores the Arrow -schema but makes no claim to preserve the Parquet VARIANT logical annotation. +schema and preserves native Parquet VARIANT, including null rows; its verdict +checks both the Parquet logical annotation and the Arrow extension on read. Row groups accumulate across IPC batches, capped by the recipe's row limit and checked every 1,024 rows against encoded bytes and estimated decoded bytes. An