Skip to content

Parquet export: timestamp micros, schema validation, footer fixes and Avro parity - #1055

Open
kellen wants to merge 27 commits into
parquetQfrom
parquet-timestamp-micros
Open

kellen wants to merge 27 commits into
parquetQfrom
parquet-timestamp-micros

Conversation

@kellen

@kellen kellen commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-ups to #1040: closes its known limitations and the gaps found in review, and brings the Parquet and Avro exports to parity.

New options

  • --useLogicalTypes: format-neutral; --useAvroLogicalTypes stays as a deprecated alias (conflicting values fail).
  • --useTimestampMicros for Parquet: TIMESTAMP/DATE/TIME columns as TIMESTAMP(MICROS), sub-ms precision kept. The flag alone decides the unit in both formats.
  • --parquetCodec: defaults to snappy (was gzip via --avroCodec=deflate6); validated before any output; zstd1..zstd22 set the level.
  • --parquetNativeTypes (off by default): native Parquet DATE, TIME and DECIMAL; DECIMAL falls back to string when precision is unknown or > 38.

Correctness

  • Footer parquet.avro.schema / _AVRO_SCHEMA.avsc derived from the written Parquet schema via parquet-avro's AvroSchemaConverter (lists unwrapped). Previously built from JDBC metadata and could contradict the data (array nullability, element types, schema files).
  • bytesWritten counted once per file after close, in both Avro and Parquet (previously undercounted / double-counted).
  • Statements and connections closed on failure.

@kellen kellen changed the title Parquet timestamp micros Parquet export: timestamp micros, schema validation, footer fixes and Avro parity Oct 21, 2026

@labianchin labianchin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

  1. Oops, I ended up rebasing #1040. Can you rebase or may I?

  2. Perhaps split out the dbeam-core Avro changes into their own PR against master. They seem to change default behaviour for existent avro users. I think we can review and ship those changes independently.

About this PR, I've noticed a few changes:

  1. array element types other than int/float/bool/bytea/string (_numeric, _timestamp, _date, _json) now fail, where #1040 wrote them as strings

  2. NULL array items now fail by default

  3. the footer schema drops dbeam's per-field properties

  4. Is the idea to be deliberate about failure? Traditionally we'd favor some sane fallback (e.g. to string) because people might evolve schema with new fields and we'd rather avoid unnecessary failures .

kellen and others added 26 commits October 22, 2026 13:52
When --useTimestampMicros is set (with --useAvroLogicalTypes), TIMESTAMP,
DATE, TIME and TIME_WITH_TIMEZONE columns are written as INT64
TIMESTAMP(MICROS, isAdjustedToUTC=true) with microsecond precision taken
from java.sql.Timestamp nanos, matching dbeam-core's Avro semantics. The
footer/_AVRO_SCHEMA.avsc Avro schema now uses timestamp-micros to match,
replacing the previous workaround that always disabled micros.

The writer picks the unit from the target Parquet field's logical type,
so values also match a user-provided --parquetSchemaFilePath. Default
(MILLIS) behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds --useLogicalTypes to JdbcExportPipelineOptions and keeps
--useAvroLogicalTypes as a deprecated, backward-compatible alias.
Both options are now nullable with no default; JdbcExportArgsFactory
resolves the effective value (whichever is set, false by default) and
fails with an IllegalArgumentException if both are set and disagree.
The --useTimestampMicros check now names --useLogicalTypes.

Documents --useTimestampMicros in docs/type-conversion.md and README,
and fixes the Avro doc table to say timestamp-millis (not time-millis).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When --parquetSchemaFilePath is supplied, reject TIMESTAMP fields whose
unit disagrees with --useTimestampMicros, so the written values and the
footer Avro schema / _AVRO_SCHEMA.avsc cannot contradict each other.
Add H2 round-trip tests reading output through the footer Avro schema
with AvroParquetReader (MICROS and MILLIS), and clarify the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parquet.avro.schema footer key and _AVRO_SCHEMA.avsc were built from
the JDBC metadata by dbeam-core's JdbcAvroSchema, independently of the
Parquet schema, and could contradict the written columns: LIST items were
non-null while Parquet elements are optional, typed_first_row element
types came from the first row instead of the type name, a NULL first-row
ARRAY dropped the footer, and --parquetSchemaFilePath field names/types
were ignored.

Add ParquetAvroSchema, an in-module Parquet->Avro converter following
parquet-avro's AvroSchemaConverter, and derive the footer from the
MessageType used for writing, keeping dbeam's namespace, doc and column
properties. Add AvroParquetReader round-trip tests and an e2e footer vs
_AVRO_SCHEMA.avsc check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rter

Replace the hand-written Parquet-to-Avro converter with AvroSchemaConverter
(parquet.avro.add-list-element-records=false) and only set the top-level
record name, namespace and doc. Drop record/field props and field docs.
parquet-avro moves to compile scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Parquet list elements are always optional, so the flag has no effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Parquet writers take column types from JDBC and write fields by
position, so a user-provided schema can only rename fields or change
annotations safely. Fail fast with a FailedValidationException listing
every offending column when the field count, primitive/group shape,
primitive type, fixed length, repetition or LIST annotation differs, or
when a logical type annotation would change how written bytes are read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cros

Fold checkTimestampUnits into ParquetSchemaValidator: the expected
schema is derived with the job's --useTimestampMicros value, so
TIMESTAMP(MICROS,true) is accepted exactly with the flag and
TIMESTAMP(MILLIS,true) exactly without it; NANOS and non-UTC are
rejected with a FailedValidationException. The error output shows the
schema for the job's actual flags. Validation uses the single LIMIT 1
query on the schema-file path; the footer Avro schema is still derived
from the write schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a annotation

The writer no longer inspects the target field's TIMESTAMP annotation; the flag is threaded
through JdbcParquetArgs to the write support. The validator still rejects annotations that
contradict the flag, so unannotated int64 schema-file fields are written in the flag's unit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…seTimestampMicros

Also document that the flag alone decides the unit for Avro and Parquet exports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fail fast in JdbcParquetJob.create, before args parsing or any output,
with an IllegalArgumentException pointing to --parquetSchemaFilePath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Schema-file-vs-data mismatches are validation failures: both the Avro
--avroSchemaFilePath check and the Parquet --parquetSchemaFilePath
validator now fail with FailedValidationException (exit code 50). The
Avro check moves from JdbcExportArgsFactory.fromPipelineOptions to
JdbcAvroJob.prepareExport (still before any output). Pure CLI option
conflicts remain IllegalArgumentException (exit code 43). Document the
exit-code mapping in the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Avro writer binary-encodes values by field position from the JDBC
column types, with union index 0 for NULL and 1 otherwise, while a
schema file is used verbatim as the writer schema. Any mismatch silently
produces files that cannot be read correctly.

JdbcAvroJob.prepareExport now runs one LIMIT 1 query, derives the
expected schema with JdbcAvroSchema (with and without logical types) and
checks the schema file with the new AvroSchemaValidator: field count,
["null", T] unions, Avro types incl. array items and their nullability,
and logical types (absent or the one dbeam would generate). Renames and
doc/namespace changes are allowed. The timestamp-unit check and its
messages are kept and run first. Failures are FailedValidationException.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roCodec

--parquetCodec now defaults to snappy and is validated at the very start
of prepareExport, before any metadata file is written, with the list of
supported values. zstd1 .. zstd22 select a Zstandard compression level
(parquet.compression.codec.zstd.level). --avroCodec is no longer mapped;
a non-default value logs a warning (an explicit --avroCodec=deflate6
cannot be distinguished from the default).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The metric was reported after each query with the cumulative position of
the output stream, which double-counted when one writer handles several
queries and missed the last row group and the footer (often reporting
just the 4-byte magic). Report the file size once, after
parquetWriter.close(). Beam closes writers in @FinishBundle, where the
DirectRunner has no current metrics container, so fall back to the
container captured while writing.

dbeam-core's Avro writer still reports the cumulative size per query
(over-counting with several queries per writer); left unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match dbeam-core's JdbcAvroSchema, which fails schema generation for
these types with the same messages, instead of silently exporting them
as strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use try-with-resources for the per-query PreparedStatement and the
--preCommand Statement, close the connection when creating the
ParquetWriter or running a query fails (Beam then discards the writer
via cleanup() without calling finishWrite), and close the connection in
finishWrite even if closing the ParquetWriter fails. --preCommand still
runs before every query, matching JdbcAvroIO.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opt-in (default false, requires --useLogicalTypes): DATE is written as
int32 (DATE), TIME/TIME_WITH_TIMEZONE as TIME(MILLIS|MICROS, true) per
--useTimestampMicros, and DECIMAL/NUMERIC with a JDBC precision of 1..38
(0 <= scale <= precision) as fixed_len_byte_array(n) (DECIMAL(p,s)).
Unbounded precisions (PostgreSQL NUMERIC reports 0 or 131089) and
precisions above 38 keep the string encoding. Values are rescaled to the
column scale; values that need rounding or exceed the precision fail.

DECIMAL uses fixed_len_byte_array for all precisions instead of
int32/int64 for small ones: parquet-avro maps int32/int64 DECIMAL to
plain Avro int/long, which would drop the scale from the footer Avro
schema read by AvroParquetReader.

Schema files may use these annotations only with the flag. Default
behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Field names replace characters outside [A-Za-z0-9_] with '_', so columns
like a-b and a_b map to the same field. JdbcParquetSchema silently built
a schema with duplicate names and JdbcAvroSchema failed with Avro's
generic duplicate-field error. Both now fail listing the colliding
source column names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Parquet suite now mirrors the Avro runs (codecs via --parquetCodec,
parallel queries, array modes) and adds --useLogicalTypes (uuid,
timestamp-millis), --useTimestampMicros, --parquetNativeTypes and
--dataOnly. Each run is checked by the new e2e/VerifyParquetOutput.java,
which reads all .parquet files of all run_N directories through the
footer Avro schema with AvroParquetReader: rows per run, row_number
coverage, logical types, timestamp units and demo_table values, and
that the footer equals _AVRO_SCHEMA.avsc (or that no metadata files
exist with --dataOnly). The timestamp-micros check also reads all files.

parquet-tools, whose failures were masked by '|| echo skipping', is
dropped from e2e.sh and from the CI setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Avro writer reported the cumulative output size after each query, so
a writer handling several queries over-counted. Report the file size once,
after dataFileWriter.close() (last block flushed), falling back to the
metrics container captured while writing because Beam closes writers in
@FinishBundle where the DirectRunner has no current container. Same
approach as the Parquet writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typed Parquet arrays defaulted unknown element types (timestamp, date,
numeric, json, ...) to binary (STRING) and wrote them with toString().
Fail schema generation instead, naming the column and element type and
suggesting --arrayMode=bytes. Integer, floating point, boolean and bytea
elements stay typed; text, varchar, bpchar/char, name and uuid stay
STRING. H2-style names (SMALLINT/DOUBLE PRECISION/CHARACTER VARYING/UUID
ARRAY, ...) resolve to the same element types; unknown ones are rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Avro: a nested array element ended in the generic "Value of type ... is
not supported" error. Parquet typed arrays threw ClassCastException for
numeric/bool/bytea elements and wrote "[Ljava.lang.String;@..." strings
for text/uuid elements. Both now fail with "Column 'x' contains a
multidimensional array, which is not supported; use --arrayMode=bytes
...". JDBC metadata does not expose the dimensions (int4[][] is _int4),
so this is detected on the first such value; Parquet checks all elements
before writing anything of the value. bytea[] elements (byte[]) are still
written; bytea[][] (byte[][] elements) is rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ArrayHandlingMode.validateValue threw a plain RuntimeException, which
mapped to the generic exit code 49. Throw IllegalArgumentException with
the allowed values, like other invalid-argument checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match the Avro export: typed LIST elements are required by default and
optional with --nullableArrayItems. Without the flag a NULL array item
fails with the Avro export's message ("Array item is null in column 'x',
use --nullableArrayItems") before any part of the value is written.
The footer Avro schema follows (array<T> vs array<["null",T]>), and
--parquetSchemaFilePath element repetition must agree with the flag.
Removes the "--nullableArrayItems has no effect" warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kellen
kellen force-pushed the parquet-timestamp-micros branch from 353ded8 to a392f4a Compare October 23, 2026 08:39
…allback

The footer parquet.avro.schema / _AVRO_SCHEMA.avsc now carries the same
record properties (tableName, connectionUrl) and field docs and
properties (columnName, sqlCode, typeName, columnClassName,
columnTypeName) as dbeam's Avro export. They are taken from the JDBC
metadata by column position, so --parquetSchemaFilePath field names are
kept. Previously the derived schema had none of them.

Typed ARRAY element types without a Parquet mapping (numeric, timestamp,
date, json, ...) still fail by default. The new
--parquetUnsupportedArrayItemsAsString flag writes them as BINARY
(STRING) with each element's toString() and logs a warning per column.
The writer only falls back to toString() when the schema element is
STRING, which only the flag produces (schema files are validated against
the same derived schema).

Docs: clarify the footer schema wording and document both changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kellen

kellen commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

@labianchin

(1) Done.

(2) They change default behavior but mostly buggy behavior, so I think it's OK to keep here unless you object.

  • --avroSchemaFilePath is checked against metadata where previously dbeam would accept non-matching schemas
  • Column names that would have collided silently now fail loudly
  • Invalid --arrayMode changes exit code (43 [illegal argument] instead of 49 [fallback]), negligible change IMO
  • bytesWritten is corrected

(3) This matches Avro. Users can use --arrayMode=bytes or --parquetUnsupportedArrayItemsAsString (added in response to (6))

(4) This also matches Avro: items are non-nullable unless --nullableArrayItems is set. Before, Parquet declared the elements as required but wrote NULLs anyway.

(5) Fixed: the footer schema now keeps dbeam's record and field properties and docs, matched to columns by position.

(6) Yes; the old fallback was silent. Added --parquetUnsupportedArrayItemsAsString so users can opt in.

This branch has not been deployed

No deployments
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