Repository navigation
refactor: typed extension metadata, parsed and serialized at the wire - #625
Merged
Merged
Conversation
dfa1
force-pushed
the
fix/dtype-clinit-deadlock
branch
from
November 1, 2026 13:10
7f3d1f8 to
56234b0
Compare
… 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
force-pushed
the
refactor/typed-extension-metadata
branch
from
November 1, 2026 13:19
c4ccc1a to
fcf4f0e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #624; merge that first. This diff is only the two commits on top.
1. Extension metadata is a typed record.
DType.Extensionheld its metadata as aMemorySegment, a buffer view inside a value type. That needed a hand-written byte-comparingequals/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 inserialize_metadata/deserialize_metadata. Java now does the same:ExtensionMetadata,TimeUnitMetadata(date, time),TimestampMetadata(TimeUnit, ZoneId)andOpaqueMetadata.ExtensionMetadatais an open interface, since it's an extension point.DType.Extensionrequires a record, so dtypes compare by value.OpaqueMetadatais Rust'sForeignExtMetadata, kept so a rewritten file loses nothing. The javadoc explains why it isn'tnull.ExtensionMetadataParserhandles the well-known ids, straight from the segment with no copy.ExtensionDecoder#parseMetadatahandles any other registered id.ReadRegistry.resolveExtensions(formerlyvalidateExtensions) runs it when a file opens.DTypeFbsSerializerwrites the built-in records and opaque bytes.ExtensionEncoder#serializeMetadatawrites any other record.VortexWriter.create, so a dtype whose metadata nothing can serialize fails before any data is written.Behavior changes: a timestamp zone that
ZoneIdcan't parse now refuses the file at open. Calcite'sTemporalread the unit of date and time columns throughTimestampDtype.readUnit, which only worked while it read raw bytes; it now usesExtensionStorage.readUnit.2.
MemorySizemoves tocore.io, next toIoBounds.Tests
CustomExtensionIntegrationTestround-tripsexample.vector's dimension as a typedVectorMetadatathrough its own encoder and decoder. It also checks that a writer without the encoder refuses atcreate.ExtensionMetadataParserTestcovers the wire-format cases that leftExtensionIdTest.ReadRegistryTestcovers the decoder hook: nested resolution, and a non-record result wrapped asVortexException../mvnw verifyis green locally.Review fixes (b8c567c)
null, both naming the id.TimestampMetadata#zone()parses it), as Rust does, so a zone the JDK doesn't know still opens and round-trips byte for byte.OpaqueMetadatacopies every segment, so a caller's array can't change it later.resolveExtensionsreturns the same node when nothing changed, since it runs per chunk.ExtensionMetadataParser.parseId) instead of three.Examples
CurrencyExtensionIntegrationTest(c4ccc1a): onejava.util.Currencyper row overUtf8, next to the vector example. The how-to links both.🤖 Generated with Claude Code