- Status: Accepted (all phases done as of 2026-06-13)
- Date: 2026-06-11
- Deciders: project maintainer
- Supersedes: —
- Superseded by: —
The current module layout collapses the file-format model, the read runtime,
and the write runtime into a single core module. The other modules
(reader, writer, inspector, cli, csv, jdbc, parquet) are thin
orchestration layers that call back into core for every meaningful operation.
io.github.dfa1.vortex.core — DType, PType, Footer, Layout,
VortexException, VortexFormat,
Array hierarchy, ArrayStats
io.github.dfa1.vortex.encoding — Encoding (encode + decode on one type),
Registry (read + write dispatch),
DecodeContext, EncodeContext,
FlatSegmentDecoder, ArrayNode,
30+ concrete *Encoding.java classes
io.github.dfa1.vortex.extension — Extension interface, ExtensionId,
4 spec extension impls
io.github.dfa1.vortex.core.proto — generated proto records (in-tree codec)
io.github.dfa1.vortex.core.fbs — generated flatbuffer types
-
Encodingis bifunctional. Every encoding implements bothencode(DType, Object, EncodeContext)anddecode(DecodeContext). A read-only consumer (the most common deployment shape — analytical engines reading columnar files) pulls in the full write path, including Zstd compression libraries, dictionary builders, and stats sketchers. -
Registryis a dual dispatcher. It mapsEncodingIdto a singleEncodinginstance and exposes both read (decode,decodeAsSegment) and write surfaces. Read-only callers carry write-side identity even when no write code path will ever fire. -
readeris a shell, not a runtime.VortexReadermemory-maps the file, parses the trailer/postscript/footer/layout, then hands off toFlatSegmentDecoder— which lives incore. From that point on every meaningful operation (per-buffer slicing,Registry.decodedispatch,Encoding.decodecall) happens insidecore. Thereadermodule contributes ~3 KLOC of orchestration around ~30 KLOC of decode runtime that lives incore. -
The
slice()escape hatch is forced by this layout. PR #27 wraps untrustedMemorySegment.asSlicecalls in aBoundedSegmenttype, but ended up shipping 33unwrapForSubParser(...)sites because the consumers (Encoding.decode,FlatSegmentDecoder,Registry) live incorewhile the byte-producing handle (VortexHandle.slice) lives inreader. They cannot share package-private access; the cross-module API must be public; the public API must expose raw segments because that is what the consumers take. Every typed wrapper added to plug the gap (BoundedSegment,unwrapForSubParser,MemorySegments.slice) is a workaround for the fact that the read runtime should not be in a different module than the byte source it consumes. -
Concrete consequences of (1)–(4):
Registry.decodeAsSegmentexists purely soScanIterator(inreader) can decode a child node back into a rawMemorySegment— an inversion of the natural data-flow direction.DecodeContext.segmentBuffershad to becomeBoundedSegment[]so the security contract survives the cross-module hand-off; if decoders lived alongsideFlatSegmentDecoder, package-privateMemorySegment[]would have sufficed.- Read-only jars cannot be smaller than the full reactor. The CLI
uber-jar pulls every encoder, every writer dependency (
zstd-jni,air-compressor, etc.) even if the binary only reads files. Extensionmirrors the same problem at a smaller scale —encodeAlland decode helpers live on one interface, pulling write-side dependencies into read-only consumers.
Split core into three logical surfaces along the read/write axis:
core/ — only the model
Encoding — id() + accepts(); no encode/decode methods
EncodingId
DType, PType, ArrayStats, Footer, Layout
NullableData — the one Array-shaped type both sides need
BoundedSegment — the byte-access primitive
proto, fbs — generated code (already pure data)
reader/ — read runtime
VortexReader, VortexHandle, VortexHttpReader, ScanIterator
Array hierarchy — BoolArray, IntArray, LongArray, VarBinArray,
StructArray, ... (the read-only data exchange
format; writer never touches these)
ReadRegistry — Map<EncodingId, EncodingDecoder>
EncodingDecoder — Array decode(DecodeContext)
DecodeContext, FlatSegmentDecoder
BitpackedDecoder, AlpDecoder, PcoDecoder, ... (30+ files)
writer/ — write runtime
VortexWriter
WriteRegistry — Map<EncodingId, EncodingEncoder>
EncodingEncoder — EncodeResult encode(DType, Object, EncodeContext)
EncodeContext
BitpackedEncoder, AlpEncoder, PcoEncoder, ... (30+ files)
Registry splits into two distinct types — ReadRegistry and
WriteRegistry — not a generic Registry<T>. Each is passed alongside
its corresponding entry point, not folded into an options record:
ReadRegistry rr = ReadRegistry.builder().registerServiceLoaded().build();
VortexReader.open(path, rr); // ReadRegistry directly
WriteRegistry wr = WriteRegistry.builder().registerServiceLoaded().build();
VortexWriter.builder(path, schema)
.registry(wr) // WriteRegistry directly
.options(WriteOptions.defaults()) // tuning knobs only
.build();Two design choices feed this shape.
Distinct types, not Registry<T>. Reasons:
- Different builder ergonomics: the read side has no cascade chain to
configure; the write side does (
cascadeCodecs, allowed-cascading depth). A generic type would carry irrelevant builder methods on both sides. ServiceLoadermanifests are already separate (META-INF/services/...EncodingDecodervs...EncodingEncoder), so type-level separation matches the runtime story.- Mistakes like passing a write registry to
VortexReader.openbecome compile errors, not runtime errors.
Alongside the options, not inside them. Reasons:
WriteOptionsis a record (an immutable value).WriteRegistryis a configured map with lifecycle: typically built once at app startup and reused across many file writes. Mixing forces re-creating options every time you want a new file with the same registry.- Records work badly for fields with non-trivial equality semantics
(
Registry.equals?). - Today the registry already lives on the method signature; keeping that split is the lowest-migration shape.
The same applies to read-side configuration. There is no ReadOptions
record today (the reader takes ScanOptions per-scan instead); the
proposal keeps that as-is. ReadRegistry is the file-open parameter;
ScanOptions is the per-scan parameter.
Effect on caller code:
- Read-only callers (analytics engines, inspector, CLI inspector)
construct only
ReadRegistry. No transitive dependency on writer encoders — thewritermodule isn't on their classpath at all. - Write-only callers (CSV importer, JDBC importer) construct only
WriteRegistry. - Tools that do both (integration tests, parquet bridge) construct both.
Encodingbecomes a small metadata-only interface incore. It carriesEncodingIdandaccepts(DType)and nothing else. No bifunctional decode- encode methods.
- Each encoding's
Decoderstatic inner class becomes a top-levelEncodingDecoderimplementation inreader. TheEncoderinner class becomesEncodingEncoderinwriter. CLAUDE.md already documents this split via private inner classes; the migration largely lifts those into separate compilation units across modules. Registrysplits intoReadRegistryandWriteRegistry. Each registry exposes only the dispatch surface its side needs. ThedecodeAsSegmentescape hatch is deleted; the corresponding adapter logic lives inFlatSegmentDecoder(inreader) instead.DecodeContextmoves toreader;EncodeContextmoves towriter.FlatSegmentDecodermoves toreader, into the same package asVortexReader. Theslice()method onVortexHandlebecomes package-private —FlatSegmentDecoder,Trailer, andPostscriptParserare its only callers, all co-resident inreader/io.unwrapForSubParserand the corresponding audit trail collapse to the minority of decoders that genuinely call into a sub-parser (ProtoReader) with a rawMemorySegment. Cross-module byte hand-offs disappear.Extensionsimilarly splits intoExtensionDecoder+ExtensionEncoder, or keeps a single interface with read-only and write-only sub-types.
The motivating problem disappears as a side effect:
VortexHandle.slice(long, long)→ package-private. External callers cannot see it; cross-module consumers (ScanIterator,InspectorTree) move into the same module so the package-private access works.BoundedSegmentstays incoreas the primitive, but no longer needs to travel through public API surfaces. Most internal uses can drop back to rawMemorySegmentbecause they live in the same package as the byte source and the trust boundary is now spatially local.- The 33
unwrapForSubParsersites from PR #27 are mostly eliminated — not because we wrote more wrappers, but because the wrappers are no longer needed once read code stops crossing module boundaries to reach its bytes.
Each phase is a separate PR, lands independently green, and keeps the old shape running side-by-side until cut-over.
Phase 0 — preparation (≈0.5 day)
- Land this ADR.
- Add
Encodingmetadata-only interface incore(extends the existing one for now). Verify all currentEncodingimpls already implementid()andaccepts(DType). - Introduce
ReadRegistryandWriteRegistryskeletons that for now delegate to the existingRegistry. No call-site changes yet.
Phase 1 — split DecodeContext and the read registry (≈1 day)
- Move
DecodeContext,ArrayNode,FlatSegmentDecodertoreader. ReadRegistrybecomes the canonical read dispatcher;Registry.decodeforwards to it during transition.ScanIteratorusesReadRegistrydirectly.decodeAsSegmentdeleted;FlatSegmentDecodergains the equivalent package-private helper.
Phase 2 — lift *Decoder impls into reader (≈1 day per family, ≈3 days)
- Pick one encoding family at a time (Fastlanes, ALP, Pco, …).
- For each: extract the
Decoderinner class into a new*EncodingDecoderinreader/encoding; register viaMETA-INF/services/...EncodingDecoder; delete thedecode(...)method from the old*Encodingincore. - After all families lifted,
Encodingincoreno longer has adecodemethod.Registry(the old dual) no longer has a read surface.
Phase 3 — repeat for the write side (≈3 days)
- Mirror Phase 2 for writers.
Encodingincorebecomes the metadata-only shape promised in the Decision section.
Phase 4 — VortexHandle.slice to package-private (≈0.5 day)
- Drop
slice()from the publicVortexHandleinterface. All remaining callers are now inreaderand use a package-private accessor on the concreteVortexReader/VortexHttpReadertypes. - Inspector and CLI inspector code that today calls
handle.slice(...)receives a new typed accessor instead (e.g.FlatSegmentInspector.peek(handle, spec)). - The 33
unwrapForSubParsersites from PR #27 are deleted at the same time; the corresponding decoders take rawMemorySegmentagain because they live in the same package as the byte source.
Phase 5 — Extension split (≈0.5 day)
ExtensionDecoderandExtensionEncoderin their respective modules.- Confirm the four spec extensions (
Date,Time,Timestamp,Uuid) ride through the split cleanly.
Phase 6 — read-only jar artifact (≈0.5 day)
- Verify the CLI's "read-only" personality (the inspector) can be built
without the writer module on the classpath. Document in
compatibility.md.
Cumulative effort estimate: ~9 person-days of focused work, plus ~3 days of CI / integration-test fallout, plus reviewer time. Not a weekend.
- Public API never exposes raw
MemorySegmentfor the read path.VortexHandle.slicedisappears from the public surface. The CLAUDE.md §Security contract is enforced architecturally, not by audit-trail convention. - PR #27's 33
unwrapForSubParsersites collapse to a handful — only the decoders that genuinely call a sub-parser (ProtoReader-bound decoders: Constant, Pco, Sparse, plus Zstd's native lib hand-off) retain a documented trust transfer. Registry.decodeAsSegmentdeleted. The current adapter exists only because cross-module dispatch needs a raw-segment escape; once decoders are co-resident with the byte source, the adapter is no longer needed.- Read-only deployments shrink. No transitive pull on
zstd-jniencode paths, FSST dictionary builders, ALP encoders, etc. The CLI inspector becomes a true read-only artifact. - Dependency direction matches data flow.
readerdepends oncore;writerdepends oncore; neither depends on the other. Today both live insidecoreand the dependency direction is invisible.
- Multi-day refactor. ~9 person-days plus CI iteration. Cannot land in a single PR; must be staged carefully so each phase runs green.
- Encoding impls double in file count during transition.
BitpackedEncodingbecomesBitpackedDecoder(inreader) +BitpackedEncoder(inwriter). Test files split similarly. - CLAUDE.md updates — the "three touch-points for adding an encoding" rule becomes "decoder side + encoder side + EncodingId enum constant", each in its own module.
- CHANGELOG breaking-changes section grows. External users (none today,
but any future ones) see
Encoding,Registry, andDecodeContextmoved. Probably worth bundling under a 0.7.0 release boundary. - Two
ServiceLoadermanifests per encoding instead of one. - Integration tests need re-routing. Tests that today construct a
Registryand calldecodedirectly will need to construct aReadRegistryinstead — mechanical but pervasive.
- Side-by-side period drift. Phases 1–3 leave both the old
Registryand the newReadRegistry/WriteRegistryregistered for each encoding during transition. Risk: divergent behavior if a bug fix lands on one side and not the other. Mitigation: integration tests run against both paths during the transition; the oldRegistrybecomes a thin forwarder early in Phase 1. - Extension split.
Extensioncarries the same encode/decode tension asEncoding; the migration plan assumes a parallel split. If the extension API has tighter user-facing constraints (it does — seeDateExtension.decodeAll), Phase 5 may need a separate ADR. - JMH benchmarks.
JavaVsJniReadBenchmarkand friends constructRegistry+DecodeContextdirectly. They live in theperformancemodule, which depends onreader. The benchmarks need re-wiring at the end of Phase 2.
- Keep
coreas-is, hideslice()via Java modules (JPMS). Drops PR #27's escape-hatch noise but does not address the underlying smell —corestill hosts the read runtime,readerstill calls back intocorefor every operation,Registrystill dispatches both sides, and read-only deployments still pull the writer surface. Rejected as cosmetic. - Move
FlatSegmentDecoderalone intoreader, leave everything else. Solves the immediateslice()problem at the cost of a circular module dependency:Encoding.decode(incore) would callRegistry.decode(incore) which would route intoFlatSegmentDecoder(inreader). Rejected as architecturally worse than the current state. - Adopt an existing pluggable codec framework (e.g. Arrow's
CompressionCodecSPI shape). Considered briefly. Vortex's cascading-encoding model has tighter requirements than Arrow's flat codec model; an external SPI does not fit. Rejected. - Status quo + documentation. Document that
coreis the read runtime andreaderis a shell. Cheapest. Rejected because every future feature that needs cross-module byte access reintroduces the same escape-hatch problem.
- The 33
unwrapForSubParsersites in PR #27 are a strong proxy signal: every one of them documents a place where read code needs bytes that live in another module. - A genuinely read-only deployment (inspector + scan) should be possible without pulling Zstd encoders or FSST builders. Today it is not.
- The
Encodinginterface bifunctional shape blocks ahead-of-time pruning of the write surface; the refactor is the only path to a smaller read-only artifact.