Skip to content

refactor: typed extension metadata, parsed and serialized at the wire - #625

Merged
dfa1 merged 6 commits into
mainfrom
refactor/typed-extension-metadata
Nov 1, 2026
Merged

dfa1 merged 6 commits into
mainfrom
refactor/typed-extension-metadata

Conversation

@dfa1

@dfa1 dfa1 commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #624; merge that first. This diff is only the two commits on top.

1. Extension metadata is a typed record. DType.Extension held its metadata as a MemorySegment, a buffer view inside a value type. That needed a hand-written byte-comparing equals/hashCode, a copy off the file mapping so a dtype could outlive its reader, and every consumer re-parsed the bytes. A heap segment from a caller wasn't copied either, so changing its array changed the dtype and its hash.

Rust holds an extension's ExtVTable::Metadata, typed, and converts to bytes only in serialize_metadata/deserialize_metadata. Java now does the same:

  • core holds only the types: ExtensionMetadata, TimeUnitMetadata (date, time), TimestampMetadata(TimeUnit, ZoneId) and OpaqueMetadata.
    • ExtensionMetadata is an open interface, since it's an extension point. DType.Extension requires a record, so dtypes compare by value.
    • OpaqueMetadata is Rust's ForeignExtMetadata, kept so a rewritten file loses nothing. The javadoc explains why it isn't null.
  • The reader parses:
    • ExtensionMetadataParser handles the well-known ids, straight from the segment with no copy.
    • ExtensionDecoder#parseMetadata handles any other registered id. ReadRegistry.resolveExtensions (formerly validateExtensions) runs it when a file opens.
  • The writer serializes:
    • DTypeFbsSerializer writes the built-in records and opaque bytes.
    • ExtensionEncoder#serializeMetadata writes any other record.
    • This is checked at VortexWriter.create, so a dtype whose metadata nothing can serialize fails before any data is written.

Behavior changes: a timestamp zone that ZoneId can't parse now refuses the file at open. Calcite's Temporal read the unit of date and time columns through TimestampDtype.readUnit, which only worked while it read raw bytes; it now uses ExtensionStorage.readUnit.

2. MemorySize moves to core.io, next to IoBounds.

Tests

  • CustomExtensionIntegrationTest round-trips example.vector's dimension as a typed VectorMetadata through its own encoder and decoder. It also checks that a writer without the encoder refuses at create.
  • ExtensionMetadataParserTest covers the wire-format cases that left ExtensionIdTest.
  • ReadRegistryTest covers the decoder hook: nested resolution, and a non-record result wrapped as VortexException.
  • The vortex-jni interop tests for date, timestamp, json and the well-known extensions pass unchanged.
  • ./mvnw verify is green locally.

Review fixes (b8c567c)

  • The writer checks serialized metadata against the reader's 64 KiB cap and refuses an encoder that returns null, both naming the id.
  • The reader applies the cap to every id again, including the temporal ones.
  • A timestamp zone is kept as the stored string (TimestampMetadata#zone() parses it), as Rust does, so a zone the JDK doesn't know still opens and round-trips byte for byte.
  • OpaqueMetadata copies every segment, so a caller's array can't change it later.
  • Each extension's metadata is serialized once per writer. A built-in record under a custom id goes through that id's encoder.
  • resolveExtensions returns the same node when nothing changed, since it runs per chunk.
  • There is one untrusted-id guard (ExtensionMetadataParser.parseId) instead of three.

Examples

  • CurrencyExtensionIntegrationTest (c4ccc1a): one java.util.Currency per row over Utf8, next to the vector example. The how-to links both.

🤖 Generated with Claude Code

@dfa1
dfa1 force-pushed the fix/dtype-clinit-deadlock branch from 7f3d1f8 to 56234b0 Compare November 1, 2026 13:10
dfa1 and others added 6 commits November 1, 2026 14:19
… at the wire

DType.Extension held its metadata as a MemorySegment: a buffer view inside a value type.
It needed a hand-written byte-comparing equals/hashCode, a copy off the file mapping so a
dtype could outlive its reader, and every consumer re-parsed the bytes (TimestampDtype,
TimeDtype, ExtensionStorage, the datetimeparts encoder and decoder). A heap segment from a
caller was not copied, so changing its array changed the dtype and its hash.

Rust holds an extension's ExtVTable::Metadata, typed, and converts to bytes only in the
vtable's serialize_metadata / deserialize_metadata. So does Java now:

- core keeps only the types: ExtensionMetadata (an open interface, an extension point;
  DType.Extension requires a record so dtypes compare by value), TimeUnitMetadata for date
  and time, TimestampMetadata(TimeUnit, ZoneId) for timestamp, and OpaqueMetadata for bytes
  nothing parses (Rust's ForeignExtMetadata: uuid's version byte, the st.* SpatialMetadata,
  foreign ids), which keeps them so a rewritten file loses nothing.
- the reader parses: ExtensionMetadataParser for the well-known ids, straight from the
  segment with no copy, and ExtensionDecoder#parseMetadata for any other registered id, run
  by ReadRegistry.resolveExtensions (was validateExtensions) when a file opens.
- the writer serializes: DTypeFbsSerializer for the built-ins and opaque bytes,
  ExtensionEncoder#serializeMetadata for other records, checked at VortexWriter.create so
  metadata no encoder can serialize fails before any data is written.

A timestamp zone java.time.ZoneId cannot parse now refuses the file at open. Calcite's
Temporal read the unit of date and time columns through TimestampDtype.readUnit, which only
worked while it read raw bytes; it uses ExtensionStorage.readUnit, which takes both.

CustomExtensionIntegrationTest round-trips example.vector's dimension as a typed record
through its own encoder and decoder; ExtensionMetadataParserTest takes over the wire-format
cases that left ExtensionIdTest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MemorySize is a byte count used by the I/O bounds (extension metadata cap, layout metadata
cap, global-dict retention), not part of the dtype model, so it moves next to IoBounds in
core.io. The AGENTS.md module map also lists the DTypes and extension metadata types it
was missing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The how-to's "Add a custom extension type" recipe and CustomExtensionIntegrationTest show the
same extension SPI; each now points at the other so they change together, the test being the
complete, tested example.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- The writer checks serialized metadata against the reader's 64 KiB cap, and refuses an
  encoder returning null, both naming the id: it could write a file its own reader refuses.
- The reader applies the cap to every id again, the temporal ones included.
- A timestamp's zone is kept as the string the file stores, as Rust keeps it: a file whose
  zone this JDK's database does not know opens again, and a rewrite keeps its bytes (ZoneId
  normalized "+05" to "+05:00"). TimestampMetadata#zone() parses it.
- OpaqueMetadata copies every segment: a caller's heap array, changed later, changed the
  value and its hash.
- The writer serializes each extension's metadata once (one serializer per writer), so the
  bytes written at close are the bytes checked at create; a built-in record under a custom id
  goes through that id's encoder, so it reads back through its own decoder.
- resolveExtensions returns the same node when nothing changed: the Variant and Constant
  decoders run it per chunk.
- One untrusted-id guard, ExtensionMetadataParser.parseId, instead of three.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
acme.currency stores one Currency per row as its ISO 4217 code over Utf8, with no metadata:
string storage where the vector example has non-primitive storage and typed metadata, and a
decoder that refuses a code that is not ISO 4217. The how-to links it beside the vector test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
toString hex-dumped the whole value, up to the 64 KiB cap: a 128 K-character string that
ends up in exception messages ("metadata must be TimeUnitMetadata, got ...") built from
an untrusted file. It now shows the size and at most the first 16 bytes. hashCode copied the
bytes out on every call; it reads them in place, same hash as Arrays.hashCode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dfa1
dfa1 force-pushed the refactor/typed-extension-metadata branch from c4ccc1a to fcf4f0e Compare November 1, 2026 13:19
@dfa1
dfa1 changed the base branch from fix/dtype-clinit-deadlock to main November 1, 2026 13:19
@dfa1 dfa1 closed this Nov 1, 2026
@dfa1 dfa1 reopened this Nov 1, 2026
@dfa1
dfa1 merged commit 9824461 into main Nov 1, 2026
7 checks passed
@dfa1
dfa1 deleted the refactor/typed-extension-metadata branch November 1, 2026 13:29
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.

1 participant