blob: b218bff2436b319a57fabd063a2a8be15013baf0 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =============================================================================
# The IPC writer, plus the file-format reader (`readfile`), over ArrowCore.
#
# * Batch encode is the inverse of decode, one implementation: ONE generic
# `encodefield!` walks the SAME `layoutspec` registry the decoder walks —
# node, registry buffers in registry order, children in declared order.
# There are no per-layout write methods to skew against the read side.
#
# * Dictionary state machine, replacement-on-change: each batch's pools
# are captured by identity. A dictionary batch is emitted before the
# first record batch that references its id and again ONLY when a later
# batch's pool for that id is a different snapshot. Replacement streams
# declare Feature.DICTIONARY_REPLACEMENT in the schema.
#
# * Compression at encode: per-buffer LZ4_FRAME/ZSTD with the Int64
# uncompressed-length prefix, the `-1` stored-raw fallback when
# compression does not help, codec objects owned per writer and
# explicitly finalized. Compressed streams declare Feature.COMPRESSED_BODY;
# the reader accepts compressed batches with or without the declaration.
#
# * File format = stream framing + a Block index + a Footer: the writer
# isolates footer bookkeeping from generic message writing; `readfile`
# exposes the footer's record-batch index as a lazy random-access handle
# (`length`/`getindex`) over one borrowed or mmapped region.
# =============================================================================
# ---------------------------------------------------------------------------
# Encode-side codec state: per-writer objects, explicitly finalized (both
# codecs share TranscodingStreams' one streams API)
# ---------------------------------------------------------------------------
mutable struct EncodeState
lz4::Union{Nothing,LZ4FrameCompressor}
zstd::Union{Nothing,ZstdCompressor}
end
EncodeState() = EncodeState(nothing, nothing)
function _lz4c!(s::EncodeState)
if s.lz4 === nothing
c = LZ4FrameCompressor()
TS.initialize(c)
s.lz4 = c
end
return s.lz4::LZ4FrameCompressor
end
function _zstdc!(s::EncodeState)
if s.zstd === nothing
c = ZstdCompressor()
TS.initialize(c)
s.zstd = c
end
return s.zstd::ZstdCompressor
end
function Base.close(s::EncodeState)
lz4 = s.lz4
s.lz4 = nothing
try
lz4 === nothing || TS.finalize(lz4)
finally
zstd = s.zstd
s.zstd = nothing
zstd === nothing || TS.finalize(zstd)
end
return nothing
end
# ---------------------------------------------------------------------------
# Metadata building: Core descriptors -> Meta tables (inverse of `coretype`)
# ---------------------------------------------------------------------------
_metatimeunit(u) =
u == AC.SECOND ? Meta.TimeUnit.SECOND :
u == AC.MILLISECOND ? Meta.TimeUnit.MILLISECOND :
u == AC.MICROSECOND ? Meta.TimeUnit.MICROSECOND : Meta.TimeUnit.NANOSECOND
"""
Build the flatbuffer TYPE table for one Core descriptor. Returns
`(tag type, table offset)` for `fieldAddTypeType`/`fieldAddType`. The isa
ladder is the encode half of `coretype`; a descriptor outside the mapped set
is a clean writer refusal, mirroring the reader's refusal of unmapped tags.
"""
function metatype!(b::FB.Builder, t::ArrowType)
if t isa IntType
Meta.intStart(b)
Meta.intAddBitWidth(b, Int32(t.bits))
Meta.intAddIsSigned(b, t.signed)
return Meta.Int, Meta.intEnd(b)
elseif t isa FloatType
Meta.floatingPointStart(b)
Meta.floatingPointAddPrecision(
b,
t.bits == 16 ? Meta.Precision.HALF :
t.bits == 32 ? Meta.Precision.SINGLE : Meta.Precision.DOUBLE,
)
return Meta.FloatingPoint, Meta.floatingPointEnd(b)
elseif t isa BoolType
Meta.boolStart(b)
return Meta.Bool, Meta.boolEnd(b)
elseif t isa Utf8Type
if t.large
Meta.largeUtf8Start(b)
return Meta.LargeUtf8, Meta.largeUtf8End(b)
end
Meta.utf8Start(b)
return Meta.Utf8, Meta.utf8End(b)
elseif t isa BinaryType
if t.large
Meta.largeBinaryStart(b)
return Meta.LargeBinary, Meta.largeBinaryEnd(b)
end
Meta.binaryStart(b)
return Meta.Binary, Meta.binaryEnd(b)
elseif t isa FixedSizeBinaryType
Meta.fixedSizeBinaryStart(b)
Meta.fixedSizeBinaryAddByteWidth(b, Int32(t.nbytes))
return Meta.FixedSizeBinary, Meta.fixedSizeBinaryEnd(b)
elseif t isa ListType
if t.large
Meta.largeListStart(b)
return Meta.LargeList, Meta.largeListEnd(b)
end
Meta.listStart(b)
return Meta.List, Meta.listEnd(b)
elseif t isa FixedSizeListType
Meta.fixedSizeListStart(b)
Meta.fixedSizeListAddListSize(b, Int32(t.listsize))
return Meta.FixedSizeList, Meta.fixedSizeListEnd(b)
elseif t isa StructType
Meta.structStart(b)
return Meta.Struct, Meta.structEnd(b)
elseif t isa MapType
Meta.mapStart(b)
t.keyssorted && Meta.mapAddKeysSorted(b, true)
return Meta.Map, Meta.mapEnd(b)
elseif t isa DateType
Meta.dateStart(b)
Meta.dateAddUnit(
b,
t.unit == AC.DAY ? Meta.DateUnit.DAY : Meta.DateUnit.MILLISECOND,
)
return Meta.Date, Meta.dateEnd(b)
elseif t isa TimeType
Meta.timeStart(b)
Meta.timeAddUnit(b, _metatimeunit(t.unit))
Meta.timeAddBitWidth(b, Int32(t.bits))
return Meta.Time, Meta.timeEnd(b)
elseif t isa TimestampType
tz = t.timezone === nothing ? FB.UOffsetT(0) : FB.createstring!(b, t.timezone)
Meta.timestampStart(b)
Meta.timestampAddUnit(b, _metatimeunit(t.unit))
tz == 0 || Meta.timestampAddTimezone(b, tz)
return Meta.Timestamp, Meta.timestampEnd(b)
elseif t isa DurationType
Meta.durationStart(b)
Meta.durationAddUnit(b, _metatimeunit(t.unit))
return Meta.Duration, Meta.durationEnd(b)
elseif t isa DecimalType
Meta.decimalStart(b)
Meta.decimalAddPrecision(b, Int32(t.precision))
Meta.decimalAddScale(b, Int32(t.scale))
Meta.decimalAddBitWidth(b, Int32(t.bits))
return Meta.Decimal, Meta.decimalEnd(b)
elseif t isa IntervalType
Meta.intervalStart(b)
Meta.intervalAddUnit(
b,
t.unit == AC.YEAR_MONTH ? Meta.IntervalUnit.YEAR_MONTH :
t.unit == AC.DAY_TIME ? Meta.IntervalUnit.DAY_TIME :
Meta.IntervalUnit.MONTH_DAY_NANO,
)
return Meta.Interval, Meta.intervalEnd(b)
elseif t isa UnionType
# The builder writes last-first, so every vector is prepended in
# reverse. A zero UOffsetT means "absent": the slot is left out and
# the getter returns the schema default.
Meta.unionStartTypeIdsVector(b, length(t.typeids))
foreach(x -> FB.prepend!(b, Int32(x)), Iterators.reverse(t.typeids))
idvec = FB.endvector!(b, length(t.typeids))
Meta.unionStart(b)
Meta.unionAddMode(
b,
t.mode == AC.DenseMode ? Meta.UnionMode.Dense : Meta.UnionMode.Sparse,
)
Meta.unionAddTypeIds(b, idvec)
return Meta.Union, Meta.unionEnd(b)
elseif t isa ViewType
if t.utf8
Meta.utf8ViewStart(b)
return Meta.Utf8View, Meta.utf8ViewEnd(b)
end
Meta.binaryViewStart(b)
return Meta.BinaryView, Meta.binaryViewEnd(b)
elseif t isa ListViewType
if t.large
Meta.largeListViewStart(b)
return Meta.LargeListView, Meta.largeListViewEnd(b)
end
Meta.listViewStart(b)
return Meta.ListView, Meta.listViewEnd(b)
elseif t isa RunEndEncodedType
Meta.runEndEncodedStart(b)
return Meta.RunEndEncoded, Meta.runEndEncodedEnd(b)
elseif t isa NullType
Meta.nullStart(b)
return Meta.Null, Meta.nullEnd(b)
else
throw(
ValidationError(
"IPC writer does not map descriptor " * "$(AC.descriptorname(t))",
),
)
end
end
function _metakeyvalues!(b::FB.Builder, metadata)
metadata === nothing && return FB.UOffsetT(0)
# Core metadata is an ordered Pair sequence. Preserve both that order and
# duplicate keys; callers that want canonical ordering can supply it.
pairs = collect(metadata)
kvs = FB.UOffsetT[]
for (k, v) in pairs
key = FB.createstring!(b, k)
val = FB.createstring!(b, v)
Meta.keyValueStart(b)
Meta.keyValueAddKey(b, key)
Meta.keyValueAddValue(b, val)
push!(kvs, Meta.keyValueEnd(b))
end
FB.startvector!(b, 4, length(kvs), 4)
foreach(x -> FB.prependoffset!(b, x), Iterators.reverse(kvs))
return FB.endvector!(b, length(kvs))
end
"""
Build the flatbuffer Field table for one Core Field (inverse of `corefield`).
A `DictionaryType` field writes its VALUE type into the type slots and its
index/id/ordering into a DictionaryEncoding table; the id comes from the
writer's side table — Core fields still never carry one.
"""
function metafield!(b::FB.Builder, f::Field, fielddictids::IdDict{Field,Int64})
t = f.type
valuetype = t
dictoff = FB.UOffsetT(0)
if t isa DictionaryType
valuetype = t.valuetype
idxtag, idxoff = metatype!(b, t.indextype)
idxtag === Meta.Int ||
throw(ValidationError("dictionary index type must be an integer"))
Meta.dictionaryEncodingStart(b)
Meta.dictionaryEncodingAddId(b, fielddictids[f])
Meta.dictionaryEncodingAddIndexType(b, idxoff)
t.ordered && Meta.dictionaryEncodingAddIsOrdered(b, true)
dictoff = Meta.dictionaryEncodingEnd(b)
end
children = FB.UOffsetT[metafield!(b, c, fielddictids) for c in f.children]
Meta.fieldStartChildrenVector(b, length(children))
foreach(x -> FB.prependoffset!(b, x), Iterators.reverse(children))
childvec = FB.endvector!(b, length(children))
kvvec = _metakeyvalues!(b, f.metadata)
name = FB.createstring!(b, f.name)
tag, typeoff = metatype!(b, valuetype)
Meta.fieldStart(b)
Meta.fieldAddName(b, name)
Meta.fieldAddNullable(b, f.nullable)
Meta.fieldAddTypeType(b, tag)
Meta.fieldAddType(b, typeoff)
dictoff == 0 || Meta.fieldAddDictionary(b, dictoff)
Meta.fieldAddChildren(b, childvec)
kvvec == 0 || Meta.fieldAddCustomMetadata(b, kvvec)
return Meta.fieldEnd(b)
end
_pad8!(bytes::Vector{UInt8}) = append!(bytes, zeros(UInt8, mod(-length(bytes), 8)))
"""
Finish the current builder content as one framed message: continuation
marker, padded metadata length, metadata, then the (already padded) body.
"""
function _finishmessage!(out::Vector{UInt8}, b::FB.Builder, msg, body::Vector{UInt8})
FB.finish!(b, msg)
meta = collect(FB.finishedbytes(b))
_pad8!(meta)
length(body) % 8 == 0 || throw(ArgumentError("message body must be padded"))
append!(out, reinterpret(UInt8, UInt32[CONTINUATION, UInt32(length(meta))]))
append!(out, meta)
append!(out, body)
return out
end
function _metaschema!(
b::FB.Builder,
sch::Schema,
fielddictids::IdDict{Field,Int64},
features::Vector{Int64},
)
fields = FB.UOffsetT[metafield!(b, f, fielddictids) for f in sch.fields]
Meta.schemaStartFieldsVector(b, length(fields))
foreach(x -> FB.prependoffset!(b, x), Iterators.reverse(fields))
fieldvec = FB.endvector!(b, length(fields))
kvvec = _metakeyvalues!(b, sch.metadata)
featurevec = FB.UOffsetT(0)
if !isempty(features)
FB.startvector!(b, 8, length(features), 8)
foreach(x -> FB.prepend!(b, x), Iterators.reverse(features))
featurevec = FB.endvector!(b, length(features))
end
Meta.schemaStart(b)
Meta.schemaAddEndianness(b, Meta.Endianness.Little)
Meta.schemaAddFields(b, fieldvec)
kvvec == 0 || Meta.schemaAddCustomMetadata(b, kvvec)
featurevec == 0 || Meta.schemaAddFeatures(b, featurevec)
return Meta.schemaEnd(b)
end
function _schemamessage!(
out::Vector{UInt8},
sch::Schema,
fielddictids::IdDict{Field,Int64},
features::Vector{Int64},
)
b = FB.Builder(1024)
schoff = _metaschema!(b, sch, fielddictids, features)
Meta.messageStart(b)
Meta.messageAddVersion(b, Meta.MetadataVersion.V5)
Meta.messageAddHeaderType(b, Meta.Schema)
Meta.messageAddHeader(b, schoff)
return _finishmessage!(out, b, Meta.messageEnd(b), UInt8[])
end
# ---------------------------------------------------------------------------
# THE generic encoder: registry-driven node/buffer emission
# ---------------------------------------------------------------------------
mutable struct EncodeCursor
nodes::Vector{NTuple{2,Int64}} # (length, null_count), forward order
buffers::Vector{NTuple{2,Int64}} # (offset, length), forward order
body::Vector{UInt8}
codec::Int8
state::Union{Nothing,EncodeState}
variadics::Vector{Int64} # per view field, depth-first order
end
EncodeCursor(codec::Int8, state::Union{Nothing,EncodeState}) =
EncodeCursor(NTuple{2,Int64}[], NTuple{2,Int64}[], UInt8[], codec, state, Int64[])
function _compressbytes(state::EncodeState, codec::Int8, raw::Vector{UInt8})
codec == CODEC_LZ4_FRAME && return transcode(_lz4c!(state), raw)
return transcode(_zstdc!(state), raw)
end
"""
Append one buffer to the message body: raw bytes for uncompressed batches;
for compressed batches, the spec's Int64 uncompressed-length prefix plus the
frame, falling back to `-1` + raw whenever compression does not shrink the
payload. Zero-length buffers write no body bytes in either mode. The recorded
Buffer length is the wire length; 8-byte alignment padding sits between
buffers and belongs to neither.
"""
function encodebuffer!(c::EncodeCursor, bytes::Vector{UInt8})
offset = Int64(length(c.body))
offset % 8 == 0 || throw(ArgumentError("encoder lost body alignment"))
if isempty(bytes)
push!(c.buffers, (offset, Int64(0)))
return nothing
end
if c.codec == CODEC_NONE
append!(c.body, bytes)
push!(c.buffers, (offset, Int64(length(bytes))))
_pad8!(c.body)
return nothing
end
compressed = _compressbytes(c.state::EncodeState, c.codec, bytes)
if length(compressed) < length(bytes)
append!(c.body, reinterpret(UInt8, Int64[Int64(length(bytes))]))
append!(c.body, compressed)
push!(c.buffers, (offset, Int64(8 + length(compressed))))
else
append!(c.body, reinterpret(UInt8, Int64[Int64(-1)]))
append!(c.body, bytes)
push!(c.buffers, (offset, Int64(8 + length(bytes))))
end
_pad8!(c.body)
return nothing
end
"""
encodefield!(cursor, f, d)
The write half of the registry walk — the exact mirror of `decodefield`:
one node, then the layout's buffers in registry order, then children in
declared order. Dictionary-encoded fields emit their INDEX buffers here;
their pool travels in a dictionary batch. Buffer content is emitted from the
`ArrayData` slices verbatim: the encoder adds no per-layout interpretation,
so read and write cannot skew.
"""
function encodefield!(c::EncodeCursor, f::Field, d::ArrayData)
t = f.type
AC.typeequal(t, d.type) ||
throw(ValidationError("column data type does not match its schema field"))
d.offset == 0 || throw(
ValidationError(
"IPC encode of sliced (nonzero-offset) arrays is not supported; materialize first",
),
)
push!(c.nodes, (d.len, AC.nullcount(d)))
spec = layoutspec(t)
if spec.variadic
length(d.buffers) >= length(spec.buffers) ||
throw(ValidationError("column buffer count does not match its layout"))
else
length(d.buffers) == length(spec.buffers) ||
throw(ValidationError("column buffer count does not match its layout"))
end
for (role, b) in zip(spec.buffers, d.buffers)
if role == AC.OFFSETS && d.len == 0 && b.len == 0
# Core canonicalizes an empty offset array without allocating its
# otherwise-unused physical buffer. IPC still requires the one
# terminal zero offset (length + 1 entries).
encodebuffer!(c, zeros(UInt8, spec.offsetwidth))
else
encodebuffer!(c, AC.slicebytes(b))
end
end
if spec.variadic
# View layouts append their variadic data buffers after the fixed
# validity/views pair; the count travels in the header's
# variadicBufferCounts vector, depth-first (format 1.4).
push!(c.variadics, Int64(length(d.buffers) - length(spec.buffers)))
for b in Iterators.drop(d.buffers, length(spec.buffers))
encodebuffer!(c, AC.slicebytes(b))
end
end
t isa DictionaryType && return nothing
nchildren = spec.childcount == -1 ? length(f.children) : spec.childcount
length(d.children) == nchildren ||
throw(ValidationError("column child count does not match its schema field"))
if t isa UnionType && t.mode == AC.SparseMode
all(child -> child.len == d.len, d.children) ||
throw(ValidationError("IPC sparse-union children must equal the union length"))
end
for i = 1:nchildren
encodefield!(c, f.children[i], d.children[i])
end
return nothing
end
function _batchheader!(b::FB.Builder, c::EncodeCursor, nrows::Int64)
Meta.recordBatchStartNodesVector(b, length(c.nodes))
for (len, nulls) in Iterators.reverse(c.nodes)
Meta.createFieldNode(b, len, nulls)
end
nodes = FB.endvector!(b, length(c.nodes))
Meta.recordBatchStartBuffersVector(b, length(c.buffers))
for (off, len) in Iterators.reverse(c.buffers)
Meta.createBuffer(b, off, len)
end
buffers = FB.endvector!(b, length(c.buffers))
compression = FB.UOffsetT(0)
if c.codec != CODEC_NONE
Meta.bodyCompressionStart(b)
Meta.bodyCompressionAddCodec(
b,
c.codec == CODEC_LZ4_FRAME ? Meta.CompressionType.LZ4_FRAME :
Meta.CompressionType.ZSTD,
)
compression = Meta.bodyCompressionEnd(b)
end
varvec = FB.UOffsetT(0)
if !isempty(c.variadics)
Meta.recordBatchStartVariadicBufferCountsVector(b, length(c.variadics))
foreach(x -> FB.prepend!(b, x), Iterators.reverse(c.variadics))
varvec = FB.endvector!(b, length(c.variadics))
end
Meta.recordBatchStart(b)
Meta.recordBatchAddLength(b, nrows)
Meta.recordBatchAddNodes(b, nodes)
Meta.recordBatchAddBuffers(b, buffers)
compression == 0 || Meta.recordBatchAddCompression(b, compression)
varvec == 0 || Meta.recordBatchAddVariadicBufferCounts(b, varvec)
return Meta.recordBatchEnd(b)
end
function _recordmessage!(
out::Vector{UInt8},
batch::AC.RecordBatch,
fields,
codec::Int8,
state::Union{Nothing,EncodeState},
)
c = EncodeCursor(codec, state)
for (f, col) in zip(fields, batch.columns)
encodefield!(c, f, col)
end
b = FB.Builder(1024)
rb = _batchheader!(b, c, batch.nrows)
Meta.messageStart(b)
Meta.messageAddVersion(b, Meta.MetadataVersion.V5)
Meta.messageAddHeaderType(b, Meta.RecordBatch)
Meta.messageAddHeader(b, rb)
Meta.messageAddBodyLength(b, Int64(length(c.body)))
return _finishmessage!(out, b, Meta.messageEnd(b), c.body)
end
function _dictionarymessage!(
out::Vector{UInt8},
id::Int64,
vf::Field,
pool::ArrayData,
codec::Int8,
state::Union{Nothing,EncodeState},
)
c = EncodeCursor(codec, state)
encodefield!(c, vf, pool)
b = FB.Builder(1024)
rb = _batchheader!(b, c, pool.len)
Meta.dictionaryBatchStart(b)
Meta.dictionaryBatchAddId(b, id)
Meta.dictionaryBatchAddData(b, rb)
dictbatch = Meta.dictionaryBatchEnd(b)
Meta.messageStart(b)
Meta.messageAddVersion(b, Meta.MetadataVersion.V5)
Meta.messageAddHeaderType(b, Meta.DictionaryBatch)
Meta.messageAddHeader(b, dictbatch)
Meta.messageAddBodyLength(b, Int64(length(c.body)))
return _finishmessage!(out, b, Meta.messageEnd(b), c.body)
end
# ---------------------------------------------------------------------------
# Stream writer driver
# ---------------------------------------------------------------------------
const CODEC_NAMES =
Dict{Symbol,Int8}(:none => CODEC_NONE, :lz4 => CODEC_LZ4_FRAME, :zstd => CODEC_ZSTD)
function _requirelittleendian(host_endian_bom::UInt32=Base.ENDIAN_BOM)
host_endian_bom == UInt32(0x04030201) ||
throw(ValidationError("the IPC writer requires a little-endian host"))
return nothing
end
"""
Assign one IPC dictionary id per dictionary-typed field, depth-first over the
schema — the writer-side half of the adapter id table (ids are adapter
bookkeeping; Core fields never carry them).
"""
function assigndictids(fields, given::IdDict{Field,Int64}=IdDict{Field,Int64}())
# `given` lets a caller preserve ids from a source (a reader's table): two
# fields sharing one id then share one dictionary batch, exactly as the
# source did (the arrow-testing 4.0.0-shareddict integration case). Fresh
# ids fill the lowest unoccupied values so they never collide with given
# ones — including given ids at the top of the signed-long domain, where
# `max + 1` would wrap.
ids = IdDict{Field,Int64}(given)
seen = IdDict{Field,Nothing}()
used = Set{Int64}(values(ids))
next = Ref(Int64(0))
function freshid()
while next[] in used
next[] < typemax(Int64) ||
throw(ValidationError("IPC dictionary id space is exhausted"))
next[] += 1
end
push!(used, next[])
return next[]
end
function walk(f::Field)
haskey(seen, f) && throw(
ValidationError(
"IPC writer schema reuses one Field object in multiple positions",
),
)
seen[f] = nothing
if f.type isa DictionaryType && !haskey(ids, f)
ids[f] = freshid()
end
foreach(walk, f.children)
end
foreach(walk, fields)
return ids
end
"""
Collect `(field, pool)` pairs for every dictionary-typed field in one batch,
paired with the schema walk (nested dictionaries included).
"""
function dictionarypools(fields, cols)
pairs = Tuple{Field,ArrayData}[]
function walk(f::Field, d::ArrayData)
if f.type isa DictionaryType
d.dictionary === nothing &&
throw(ValidationError("dictionary column carries no pool"))
# Post-order: pools nested INSIDE this pool's values are collected
# (and therefore emitted) before it — the dependency order the
# IPC spec requires for nested dictionary encoding.
walk(AC.dictvaluefield(f, f.type), d.dictionary)
push!(pairs, (f, d.dictionary))
return
end
for (cf, cd) in zip(f.children, d.children)
walk(cf, cd)
end
end
for (f, d) in zip(fields, cols)
walk(f, d)
end
return pairs
end
function _checkbatches(sch::Schema, batches)
for batch in batches
length(batch.columns) == length(sch.fields) ||
throw(ValidationError("batch column count does not match the schema"))
for (f, bf) in zip(sch.fields, batch.schema.fields)
AC.typeequal(f.type, bf.type) && f.name == bf.name ||
throw(ValidationError("batch schema does not match the stream schema"))
end
end
return nothing
end
function _validatewriterschema(sch::Schema)
AC._validate_schema(sch)
function walk(f::Field)
isvalid(f.name) || throw(ValidationError("field name is not valid UTF-8"))
AC._validate_metadata(f.metadata, "field")
foreach(walk, f.children)
return nothing
end
foreach(walk, sch.fields)
foreach(validateschemafield, sch.fields)
return nothing
end
function _validatewriterbatch!(
validated::AC._ValidatedDictionaries,
sch::Schema,
batch::AC.RecordBatch,
ids::IdDict{Field,Int64},
)
# A shared immutable pool must satisfy every value-field contract
# through which the schema refers to it. Identity caching is safe only
# after those field-specific checks have run.
current = Dict{Int64,ArrayData}()
for (f, pool) in dictionarypools(sch.fields, batch.columns)
# One id names ONE pool within a record batch: every dictionary
# message precedes the record message on the wire, so an
# intra-batch pool change is not temporal replacement — it would
# silently retarget the earlier field to the later pool.
id = ids[f]
haskey(current, id) &&
current[id] !== pool &&
throw(
ValidationError(
"dictionary id $id carries two different pools in one record batch",
),
)
current[id] = pool
validate_semantic(AC.dictvaluefield(f, f.type::DictionaryType), pool)
validated[pool] = nothing
end
for (f, col) in zip(sch.fields, batch.columns)
AC._validate_semantic(f, col, validated)
end
return nothing
end
function _validatewriterbatches(sch::Schema, batches, ids::IdDict{Field,Int64})
validated = AC._ValidatedDictionaries()
for batch in batches
_validatewriterbatch!(validated, sch, batch, ids)
end
return nothing
end
const FEATURE_DICTIONARY_REPLACEMENT = Int64(1)
const FEATURE_COMPRESSED_BODY = Int64(2)
"""
Which features must the schema declare for these batches? Replacement is
detected by pool-identity change per id across the batch sequence
(replacement-on-change); compression declares COMPRESSED_BODY.
"""
function _streamfeatures(sch::Schema, batches, ids::IdDict{Field,Int64}, codec::Int8)
features = Int64[]
current = Dict{Int64,ArrayData}()
replacement = false
for batch in batches
for (f, pool) in dictionarypools(sch.fields, batch.columns)
id = ids[f]
old = get(current, id, nothing)
old === pool || old === nothing || (replacement = true)
current[id] = pool
end
end
replacement && push!(features, FEATURE_DICTIONARY_REPLACEMENT)
isempty(batches) || codec == CODEC_NONE || push!(features, FEATURE_COMPRESSED_BODY)
return features
end
# ---------------------------------------------------------------------------
# Incremental writer core: begin/writebatch/finish over one IO
# ---------------------------------------------------------------------------
"""
One in-progress IPC output. `beginwrite!` emits the schema message (and the
file-format preamble), `writebatch!` validates and publishes one record batch
(with any dictionary messages it needs), and `finishwrite!` emits the
end-of-stream marker (and the file-format Footer). Batch bytes are staged in
full and published to `io` once, so a validation or encoding failure never
publishes a partial message — but the SINK gains bytes batch by batch, and an
abandoned state leaves a torn stream or an unfooted file.
The eager `writestream`/`writefile` drive this same state over an in-memory
sink, validating every batch up front (`validate=false` per batch) and
passing the features their whole batch sequence implies, so their output is
byte-identical to the pre-incremental writers.
"""
mutable struct IPCWriteState
const io::IO
const file::Bool
const schema::Schema
const fielddictids::IdDict{Field,Int64}
const ids::IdDict{Field,Int64}
const features::Vector{Int64}
const codec::Int8
const state::Union{Nothing,EncodeState}
const validated::AC._ValidatedDictionaries
const current::Dict{Int64,ArrayData} # last pool per id, for replacement
const dictblocks::Vector{NTuple{3,Int64}} # file format Footer Blocks
const recordblocks::Vector{NTuple{3,Int64}}
written::Int64 # bytes published to `io`
finished::Bool
end
# Batch sequences are unknown up front. A declared feature is a demand on
# readers (nanoarrow refuses any stream declaring one it does not support),
# so nothing is declared speculatively: pool replacement needs the caller's
# explicit opt-in, and a codec declares compressed bodies because every
# batch this state writes will use it.
function _incrementalfeatures(
ids::IdDict{Field,Int64},
codec::Int8,
file::Bool,
dictreplacement::Bool,
)
features = Int64[]
dictreplacement &&
!file &&
!isempty(ids) &&
push!(features, FEATURE_DICTIONARY_REPLACEMENT)
codec == CODEC_NONE || push!(features, FEATURE_COMPRESSED_BODY)
return features
end
function _publish!(st::IPCWriteState, out::Vector{UInt8})
Base.write(st.io, out)
st.written += length(out)
return nothing
end
function beginwrite!(
io::IO,
sch::Schema;
file::Bool,
compress::Symbol=:none,
dictids::IdDict{Field,Int64}=IdDict{Field,Int64}(),
features::Union{Nothing,Vector{Int64}}=nothing,
dictreplacement::Bool=false,
)
_requirelittleendian()
haskey(CODEC_NAMES, compress) ||
throw(ArgumentError("compress must be :none, :lz4, or :zstd"))
codec = CODEC_NAMES[compress]
_validatewriterschema(sch)
ids = assigndictids(sch.fields, dictids)
fielddictids = IdDict{Field,Int64}(ids)
validatedictionaryids(sch.fields, fielddictids)
feats =
features === nothing ? _incrementalfeatures(ids, codec, file, dictreplacement) :
features
st = IPCWriteState(
io,
file,
sch,
fielddictids,
ids,
feats,
codec,
codec == CODEC_NONE ? nothing : EncodeState(),
AC._ValidatedDictionaries(),
Dict{Int64,ArrayData}(),
NTuple{3,Int64}[],
NTuple{3,Int64}[],
Int64(0),
false,
)
out = UInt8[]
if file
append!(out, FILE_MAGIC)
append!(out, zeros(UInt8, 2)) # pad to 8 before the first message
end
_schemamessage!(out, sch, fielddictids, feats)
_publish!(st, out)
return st
end
# Record one file-format Block around `emit!`: the Block offset is global
# (bytes already published plus this staging buffer's position), and
# metaDataLength spans prefix + metadata (up to the body start).
function _fileblock!(emit!::F, st::IPCWriteState, out::Vector{UInt8}, blocks) where {F}
start = Int64(length(out))
emit!()
total = Int64(length(out)) - start
metalen = Int64(8) + Int64(reinterpret(UInt32, out[(start + 5):(start + 8)])[1])
push!(blocks, (st.written + start, metalen, total - metalen))
return nothing
end
"Order-sensitive pool content equality, in each pool's public value domain."
function _poolsequal(vf::Field, a::ArrayData, b::ArrayData)
a.len == b.len || return false
return isequal(AC.materialize(vf, a), AC.materialize(vf, b))
end
function writebatch!(st::IPCWriteState, batch::AC.RecordBatch; validate::Bool=true)
st.finished && throw(ArgumentError("this IPC writer is already finished"))
if validate
_checkbatches(st.schema, (batch,))
_validatewriterbatch!(st.validated, st.schema, batch, st.ids)
end
out = UInt8[]
for (f, pool) in dictionarypools(st.schema.fields, batch.columns)
id = st.ids[f]
old = get(st.current, id, nothing)
old === pool && continue
if old !== nothing
vf = AC.dictvaluefield(f, f.type::DictionaryType)
if st.file
# The file format carries one dictionary batch per id. A new
# pool object with identical content references the emitted
# batch; anything else is replacement, which files cannot say.
_poolsequal(vf, old, pool) || throw(
ValidationError(
"the IPC file format carries one dictionary batch per " *
"id; dictionary id $id changed pools across batches — " *
"use the stream format for replacement",
),
)
st.current[id] = pool
continue
end
if !(FEATURE_DICTIONARY_REPLACEMENT in st.features)
# Emitting a replacement the schema message did not declare
# would publish a stream this package's own reader refuses.
_poolsequal(vf, old, pool) || throw(
ValidationError(
"dictionary id $id changed pools, but the stream's " *
"schema message does not declare the " *
"DictionaryReplacement feature",
),
)
st.current[id] = pool
continue
end
end
vf = AC.dictvaluefield(f, f.type::DictionaryType)
if st.file
_fileblock!(st, out, st.dictblocks) do
_dictionarymessage!(out, id, vf, pool, st.codec, st.state)
end
else
_dictionarymessage!(out, id, vf, pool, st.codec, st.state)
end
st.current[id] = pool
end
if st.file
_fileblock!(st, out, st.recordblocks) do
_recordmessage!(out, batch, st.schema.fields, st.codec, st.state)
end
else
_recordmessage!(out, batch, st.schema.fields, st.codec, st.state)
end
_publish!(st, out)
return nothing
end
# The file-format trailer: Footer flatbuffer (schema again, then the two
# Block struct-vectors), the Int32 footer length, and the trailing magic.
function _filefooter!(
out::Vector{UInt8},
sch::Schema,
fielddictids::IdDict{Field,Int64},
features::Vector{Int64},
dictblocks::Vector{NTuple{3,Int64}},
recordblocks::Vector{NTuple{3,Int64}},
)
b = FB.Builder(1024)
schoff = _metaschema!(b, sch, fielddictids, features)
Meta.footerStartDictionariesVector(b, length(dictblocks))
for (off, metalen, bodylen) in Iterators.reverse(dictblocks)
Meta.createBlock(b, off, Int32(metalen), bodylen)
end
dictvec = FB.endvector!(b, length(dictblocks))
Meta.footerStartRecordBatchesVector(b, length(recordblocks))
for (off, metalen, bodylen) in Iterators.reverse(recordblocks)
Meta.createBlock(b, off, Int32(metalen), bodylen)
end
recordvec = FB.endvector!(b, length(recordblocks))
Meta.footerStart(b)
Meta.footerAddVersion(b, Meta.MetadataVersion.V5)
Meta.footerAddSchema(b, schoff)
Meta.footerAddDictionaries(b, dictvec)
Meta.footerAddRecordBatches(b, recordvec)
FB.finish!(b, Meta.footerEnd(b))
footer = collect(FB.finishedbytes(b))
append!(out, footer)
append!(out, reinterpret(UInt8, Int32[Int32(length(footer))]))
append!(out, FILE_MAGIC)
return nothing
end
function finishwrite!(st::IPCWriteState)
st.finished && return nothing
out = UInt8[]
append!(out, reinterpret(UInt8, UInt32[CONTINUATION, UInt32(0)]))
st.file && _filefooter!(
out,
st.schema,
st.fielddictids,
st.features,
st.dictblocks,
st.recordblocks,
)
_publish!(st, out)
st.finished = true
st.state === nothing || close(st.state)
return nothing
end
"Release the codec state without emitting anything; for error cleanup."
function abortwrite!(st::IPCWriteState)
st.finished && return nothing
st.finished = true
st.state === nothing || close(st.state)
return nothing
end
"""
Resume a STREAM whose schema message is already on the wire: a state that
emits no preamble. `features` must be the features that schema message
declared, and `current` the last pool each dictionary id has emitted —
`writebatch!` then skips content-identical pools and gates replacement on
the declared features exactly as it does mid-stream.
"""
function resumestream!(
io::IO,
sch::Schema,
dictids::IdDict{Field,Int64},
features::Vector{Int64};
compress::Symbol=:none,
current::Dict{Int64,ArrayData}=Dict{Int64,ArrayData}(),
)
_requirelittleendian()
haskey(CODEC_NAMES, compress) ||
throw(ArgumentError("compress must be :none, :lz4, or :zstd"))
codec = CODEC_NAMES[compress]
ids = assigndictids(sch.fields, dictids)
return IPCWriteState(
io,
false,
sch,
IdDict{Field,Int64}(ids),
ids,
features,
codec,
codec == CODEC_NONE ? nothing : EncodeState(),
AC._ValidatedDictionaries(),
current,
NTuple{3,Int64}[],
NTuple{3,Int64}[],
Int64(0),
false,
)
end
"""
writestream(sch, batches; compress=:none, dictids=IdDict{Field,Int64}()) -> Vector{UInt8}
writestream(stream::IPCStream; compress=:none) -> Vector{UInt8}
Encode a complete IPC stream: schema message, dictionary batches emitted
before the first record batch that references them (and again on
pool-identity change), record batches, end-of-stream marker. Every column is
semantically validated before any of its bytes are emitted — the writer
refuses to publish data Core would refuse to read. `dictids` preserves ids
from a source table (the `IPCStream` form passes the reader's), so fields
sharing one id keep sharing one dictionary batch, exactly as the source did.
"""
function writestream(
sch::Schema,
batches::AbstractVector{AC.RecordBatch};
compress::Symbol=:none,
dictids::IdDict{Field,Int64}=IdDict{Field,Int64}(),
)
_requirelittleendian()
haskey(CODEC_NAMES, compress) ||
throw(ArgumentError("compress must be :none, :lz4, or :zstd"))
codec = CODEC_NAMES[compress]
_checkbatches(sch, batches)
_validatewriterschema(sch)
# `beginwrite!` recomputes the same ids from `dictids` (assignment is
# deterministic); this pass exists so the whole batch sequence is
# validated — and its EXACT features computed — before any bytes exist.
# A declared feature is a demand on readers, so an eager stream declares
# only what these batches actually use.
ids = assigndictids(sch.fields, dictids)
validatedictionaryids(sch.fields, IdDict{Field,Int64}(ids))
_validatewriterbatches(sch, batches, ids)
io = IOBuffer()
st = beginwrite!(
io,
sch;
file=false,
compress=compress,
dictids=dictids,
features=_streamfeatures(sch, batches, ids, codec),
)
try
for batch in batches
writebatch!(st, batch; validate=false)
end
finishwrite!(st)
finally
abortwrite!(st)
end
return take!(io)
end
writestream(s::IPCStream; compress::Symbol=:none) =
writestream(s.schema, s.batches; compress=compress, dictids=s.fielddictids)
# ---------------------------------------------------------------------------
# File format: magic + stream messages + Block index + Footer
# ---------------------------------------------------------------------------
const FILE_MAGIC = b"ARROW1"
"""
writefile(sch, batches; compress=:none, dictids=IdDict{Field,Int64}()) -> Vector{UInt8}
writefile(stream::IPCStream; compress=:none) -> Vector{UInt8}
The file variant: leading magic, the same stream messages, an end-of-stream
marker, then the Footer with its dictionary and record-batch Block indexes,
the Int32 footer length, and the trailing magic. Footer bookkeeping is
isolated here; message writing is the stream code above. The file format
carries exactly one dictionary batch per id, so batches whose pools change
identity are a clean refusal (the stream format handles replacement).
`dictids` preserves ids from a source table (the `IPCStream` form passes the
reader's), so fields sharing one id keep sharing one dictionary batch,
exactly as the source did.
"""
function writefile(
sch::Schema,
batches::AbstractVector{AC.RecordBatch};
compress::Symbol=:none,
dictids::IdDict{Field,Int64}=IdDict{Field,Int64}(),
)
_requirelittleendian()
haskey(CODEC_NAMES, compress) ||
throw(ArgumentError("compress must be :none, :lz4, or :zstd"))
codec = CODEC_NAMES[compress]
_checkbatches(sch, batches)
_validatewriterschema(sch)
ids = assigndictids(sch.fields, dictids)
# CODEC_NONE so this refusal sees only DICTIONARY_REPLACEMENT.
# COMPRESSED_BODY is legal in a file and is declared through the features
# below.
isempty(_streamfeatures(sch, batches, ids, CODEC_NONE)) || throw(
ValidationError(
"the IPC file format carries one dictionary batch per id; " *
"a dictionary with changing pools requires the stream format",
),
)
# Same shared-id contract as the stream writer: compatible value schemas,
# one nested id topology, one pool per id within each batch.
validatedictionaryids(sch.fields, IdDict{Field,Int64}(ids))
_validatewriterbatches(sch, batches, ids)
io = IOBuffer()
st = beginwrite!(
io,
sch;
file=true,
compress=compress,
dictids=dictids,
features=_streamfeatures(sch, batches, ids, codec),
)
try
for batch in batches
writebatch!(st, batch; validate=false)
end
finishwrite!(st)
finally
abortwrite!(st)
end
return take!(io)
end
writefile(s::IPCStream; compress::Symbol=:none) =
writefile(s.schema, s.batches; compress=compress, dictids=s.fielddictids)
# ---------------------------------------------------------------------------
# File reader: footer verification + lazy random-access batch handle
# ---------------------------------------------------------------------------
_blocktuples(blocks) =
blocks === nothing ? NTuple{3,Int64}[] :
NTuple{3,Int64}[
(Int64(b.offset), Int64(b.metaDataLength), Int64(b.bodyLength)) for b in blocks
]
"""
Footer verification, same wrapper role as `verify_ipc_metadata`: the
generated walker bounds the whole table graph, then the verified getters
supply the Block indexes.
"""
function verify_footer(
bytes::Vector{UInt8},
limits::Limits,
reserve_limit::Int64=limits.max_total_allocated_bytes,
)
ctx = _verifyctx(limits, reserve_limit)
# Staged like verify_ipc_metadata: version gates between the inline and
# reference stages, so an unsupported footer rejects in constant time.
t = _verified(() -> Meta.verifyrootstart_Footer(bytes, ctx))
footer = FB.getrootas(Meta.Footer, bytes, 0)
version = Int16(Int64(footer.version))
version in (Int16(3), Int16(4)) ||
_vfail("unsupported footer version $version (only V4/V5 are accepted)")
_verified(() -> Meta.verifyrootrest_Footer(t, ctx))
features = _schemafeatures(footer.schema::Meta.Schema, version)
return version,
features,
_blocktuples(footer.dictionaries),
_blocktuples(footer.recordBatches),
ctx.reserved
end
function _metadataequal(a, b)
av = something(a, Meta.KeyValue[])
bv = something(b, Meta.KeyValue[])
length(av) == length(bv) || return false
for (x, y) in zip(av, bv)
x.key == y.key || return false
something(x.value, "") == something(y.value, "") || return false
end
return true
end
function _fieldequal(a::Field, b::Field)
a.name == b.name &&
a.nullable == b.nullable &&
AC.typeequal(a.type, b.type) &&
a.metadata == b.metadata &&
length(a.children) == length(b.children) || return false
return all(_fieldequal(x, y) for (x, y) in zip(a.children, b.children))
end
function _schemaside(metaschema::Meta.Schema)
dictids = Dict{Int64,Meta.Field}()
fielddictids = IdDict{Field,Int64}()
fields = Field[
corefield(f, dictids, fielddictids) for
f in something(metaschema.fields, Meta.Field[])
]
foreach(validateschemafield, fields)
valueschemas = validatedictionaryids(fields, fielddictids)
length(valueschemas) == length(dictids) ||
throw(ValidationError("duplicate dictionary id in file schema"))
return fields
end
function _fieldwireequal(a::Meta.Field, b::Meta.Field)
_metadataequal(a.custom_metadata, b.custom_metadata) || return false
adict, bdict = a.dictionary, b.dictionary
(adict === nothing) == (bdict === nothing) || return false
if adict !== nothing
adict.id == bdict.id || return false
end
achildren = something(a.children, Meta.Field[])
bchildren = something(b.children, Meta.Field[])
length(achildren) == length(bchildren) || return false
return all(_fieldwireequal(x, y) for (x, y) in zip(achildren, bchildren))
end
function _schemaequal(a::Meta.Schema, b::Meta.Schema)
something(a.endianness, Meta.Endianness.Little) ==
something(b.endianness, Meta.Endianness.Little) || return false
ametafields = something(a.fields, Meta.Field[])
bmetafields = something(b.fields, Meta.Field[])
length(ametafields) == length(bmetafields) || return false
all(_fieldwireequal(x, y) for (x, y) in zip(ametafields, bmetafields)) || return false
afields = _schemaside(a)
bfields = _schemaside(b)
length(afields) == length(bfields) || return false
_metadataequal(a.custom_metadata, b.custom_metadata) || return false
all(_fieldequal(x, y) for (x, y) in zip(afields, bfields)) || return false
return true
end
function _fileschema(
region::OwnerRegion,
footerstart::Int64,
limits::Limits,
budget::AllocationBudget,
)
blob = BufferSlice(region, 0, region.len)
AC.loadat(blob, UInt32, Int64(8)) == CONTINUATION ||
throw(ValidationError("file data section does not start with an IPC message"))
declared = Int64(AC.loadat(blob, Int32, Int64(12)))
0 < declared <= limits.max_metadata_bytes ||
throw(ValidationError("file schema metadata length is outside the limit"))
declared % 8 == 0 ||
throw(ValidationError("file schema metadata is not 8-byte aligned"))
metalen = AC.checked_add(Int64(8), declared)
fm = _blockmessage(region, (Int64(8), metalen, Int64(0)), footerstart, limits, budget)
fm.header_type == UInt8(1) && fm.msg.header isa Meta.Schema || # Schema
throw(ValidationError("file data section does not start with a schema"))
return fm, AC.checked_add(Int64(8), metalen)
end
"""
ArrowFile
The footer's record-batch index as a random-access handle: `length(file)`
batches, `file[i]` decodes batch `i` on demand. Open decodes only the schema
and the dictionary batches every record shares. Each `getindex` decodes from
the mapped bytes with its own allocation budget and codec contexts, and the
handle is read-only after open, so concurrent `getindex` calls are safe. The
region root (heap vector or Mmap array) is the only lifetime anchor, as in
Core.
"""
struct ArrowFile
region::OwnerRegion
schema::Schema
fields::AC.FrozenVector{Field}
fielddictids::IdDict{Field,Int64}
dictionaries::Dict{Int64,ArrayData}
validated::AC._ValidatedDictionaries
recordblocks::Vector{NTuple{3,Int64}}
dataend::Int64
limits::Limits
schemaversion::Int16
end
Base.length(f::ArrowFile) = length(f.recordblocks)
AC.schema(f::ArrowFile) = f.schema
"""
Range-check one Footer Block's declared extents against the data section and
return its `(offset, frameend)` span.
"""
function _blockextent(block::NTuple{3,Int64}, dataend::Int64)
offset, metalen, bodylen = block
# 16 is the smallest legal prefix + metadata: an 8-byte prefix plus at
# least one 8-byte-aligned metadata block.
(offset >= 0 && metalen >= 16 && bodylen >= 0) ||
throw(ValidationError("footer block has invalid extents"))
offset % 8 == 0 || throw(ValidationError("footer block is not 8-byte aligned"))
metalen % 8 == 0 ||
throw(ValidationError("footer block metadata length is not 8-byte aligned"))
bodylen % 8 == 0 ||
throw(ValidationError("footer block body length is not 8-byte aligned"))
frameend = AC.checked_add(AC.checked_add(offset, metalen), bodylen)
frameend <= dataend || throw(ValidationError("footer block escapes the data section"))
return offset, frameend
end
function _validateblockindex(dictblocks, recordblocks, dataend::Int64; datastart::Int64=0)
extents = Tuple{Int64,Int64}[]
indexedend = datastart
for block in Iterators.flatten((dictblocks, recordblocks))
extent = _blockextent(block, dataend)
extent[1] >= datastart ||
throw(ValidationError("footer block overlaps the file schema"))
push!(extents, extent)
indexedend = max(indexedend, extent[2])
end
sort!(extents; by=first)
for i = 2:length(extents)
extents[i - 1][2] <= extents[i][1] ||
throw(ValidationError("footer blocks overlap"))
end
return indexedend
end
# The `_block*` traversal below mirrors the schema-blind vtable primitives of
# src/metadata/VerifierRuntime.jl (`_vtable`/`_vfield`/`_vref`/`_vvector`/
# `_vrange`) over a `BufferSlice` source with block-specific diagnostics; the
# two must change in lockstep.
function _blockrange(b::BufferSlice, pos::Int64, len::Int64, what::AbstractString)
(pos >= 0 && len >= 0 && len <= b.len && pos <= b.len - len) ||
throw(ValidationError("$what escapes block metadata"))
return nothing
end
function _blockload(b::BufferSlice, ::Type{T}, pos::Int64, what::AbstractString) where {T}
_blockrange(b, pos, Int64(sizeof(T)), what)
return AC.loadat(b, T, pos)
end
function _blockadd(a::Int64, b::Int64, what::AbstractString)
try
return AC.checked_add(a, b)
catch e
e isa OverflowError || rethrow()
throw(ValidationError("$what overflows"))
end
end
function _blocksub(a::Int64, b::Int64, what::AbstractString)
try
return AC.checked_sub(a, b)
catch e
e isa OverflowError || rethrow()
throw(ValidationError("$what overflows"))
end
end
function _blockmul(a::Int64, b::Int64, what::AbstractString)
try
return AC.checked_mul(a, b)
catch e
e isa OverflowError || rethrow()
throw(ValidationError("$what overflows"))
end
end
struct _BlockTable
metadata::BufferSlice
pos::Int64
vpos::Int64
vlen::Int64
olen::Int64
end
function _blocktable(metadata::BufferSlice, pos::Int64, what::AbstractString)
pos % 4 == 0 || throw(ValidationError("$what is misaligned"))
back = Int64(_blockload(metadata, Int32, pos, what))
back != 0 || throw(ValidationError("$what has a zero vtable offset"))
vpos = _blocksub(pos, back, what)
vpos % 2 == 0 || throw(ValidationError("$what vtable is misaligned"))
vlen = Int64(_blockload(metadata, UInt16, vpos, what))
olen = Int64(_blockload(metadata, UInt16, _blockadd(vpos, Int64(2), what), what))
vlen >= 4 && iseven(vlen) || throw(ValidationError("invalid $what vtable length $vlen"))
olen >= 4 || throw(ValidationError("invalid $what object length $olen"))
_blockrange(metadata, vpos, vlen, what)
_blockrange(metadata, pos, olen, what)
return _BlockTable(metadata, pos, vpos, vlen, olen)
end
function _blockfield(
t::_BlockTable,
slot::Int,
width::Int,
what::AbstractString;
required::Bool=false,
)
entryoff = Int64(4 + 2slot)
if entryoff > t.vlen - 2
required && throw(ValidationError("required $what is absent"))
return nothing
end
entry = _blockadd(t.vpos, entryoff, what)
off = Int64(_blockload(t.metadata, UInt16, entry, what))
if off == 0
required && throw(ValidationError("required $what is absent"))
return nothing
end
(off >= 4 && width <= t.olen && off <= t.olen - width) ||
throw(ValidationError("$what exceeds its table object"))
pos = _blockadd(t.pos, off, what)
width > 1 && pos % min(width, 8) != 0 && throw(ValidationError("$what is misaligned"))
_blockrange(t.metadata, pos, Int64(width), what)
return pos
end
function _blockref(t::_BlockTable, slot::Int, what::AbstractString; required::Bool=false)
pos = _blockfield(t, slot, 4, what; required=required)
pos === nothing && return nothing
rel = Int64(_blockload(t.metadata, UInt32, pos, what))
rel > 0 || throw(ValidationError("$what has a null or backward offset"))
target = _blockadd(pos, rel, what)
_blockrange(t.metadata, target, Int64(1), what)
return target
end
function _blockvector(t::_BlockTable, slot::Int, elemsize::Int, what::AbstractString)
pos = _blockref(t, slot, what)
pos === nothing && return nothing
pos % 4 == 0 || throw(ValidationError("$what length is misaligned"))
n = Int64(_blockload(t.metadata, UInt32, pos, what))
start = _blockadd(pos, Int64(4), what)
bytes = _blockmul(n, Int64(elemsize), what)
_blockrange(t.metadata, start, bytes, what)
n > 0 &&
elemsize > 1 &&
start % min(elemsize, 8) != 0 &&
throw(ValidationError("$what data is misaligned"))
return start, n
end
"""
Read the fixed Message/RecordBatch envelope and wire-buffer structs that bind
one Footer Block to its on-wire frame. `_blockmessage` still verifies the
full metadata graph lazily. This preflight runs first so that EOS
classification never trusts a forged Footer extent.
"""
function _blockmessagebatch(metadata::BufferSlice)
root = Int64(_blockload(metadata, UInt32, Int64(0), "message root"))
root >= 4 || throw(ValidationError("invalid block message root offset"))
msg = _blocktable(metadata, root, "block message table")
bodypos = _blockfield(msg, 3, 8, "message body-length slot")
bodylen =
bodypos === nothing ? Int64(0) :
_blockload(metadata, Int64, bodypos, "message body-length slot")
headerpos = _blockfield(msg, 1, 1, "message header type"; required=true)
headertype = _blockload(metadata, UInt8, headerpos, "message header type")
headerref = _blockref(msg, 2, "message header"; required=true)
header = _blocktable(metadata, headerref, "message header table")
batch = if headertype == UInt8(2) # DictionaryBatch.data
dataref = _blockref(header, 1, "dictionary batch data"; required=true)
_blocktable(metadata, dataref, "dictionary record-batch table")
elseif headertype == UInt8(3) # RecordBatch
header
else
throw(ValidationError("footer block has unsupported message header type $headertype"))
end
return bodylen, headertype, batch
end
function _verifyblockbuffers(batch::_BlockTable, bodylen::Int64)
buffers = _blockvector(batch, 2, 16, "record-batch buffer vector")
buffers === nothing && return nothing
start, n = buffers
last_nonempty_end = Int64(0)
for i = Int64(0):(n - 1)
base = _blockadd(
start,
_blockmul(i, Int64(16), "record-batch buffer position"),
"record-batch buffer position",
)
offset = _blockload(batch.metadata, Int64, base, "record-batch buffer offset")
len = _blockload(
batch.metadata,
Int64,
_blockadd(base, Int64(8), "record-batch buffer length"),
"record-batch buffer length",
)
offset >= 0 || throw(ValidationError("negative batch buffer offset $offset"))
len >= 0 || throw(ValidationError("negative batch buffer length $len"))
offset % 8 == 0 ||
throw(ValidationError("batch buffer offset $offset is not 8-byte aligned"))
bufferend = _blockadd(offset, len, "batch buffer end")
bufferend <= bodylen ||
throw(ValidationError("batch buffer [$offset, $len] escapes its message body"))
if len > 0
offset >= last_nonempty_end ||
throw(ValidationError("batch buffers overlap or move backwards"))
last_nonempty_end = bufferend
end
end
return nothing
end
function _verifyblockframe(
blob::BufferSlice,
block::NTuple{3,Int64},
expectedheadertype::UInt8,
)
offset, metalen, bodylen = block
AC.loadat(blob, UInt32, offset) == CONTINUATION ||
throw(ValidationError("footer block does not point at a message"))
declared = Int64(AC.loadat(blob, Int32, offset + 4))
declared == metalen - 8 ||
throw(ValidationError("footer block metadata length does not match the message"))
metadata = AC.subslice(blob, offset + 8, declared)
messagebodylen, headertype, batch = _blockmessagebatch(metadata)
messagebodylen == bodylen ||
throw(ValidationError("footer block body length does not match the message"))
headertype == expectedheadertype ||
throw(ValidationError("footer block has the wrong message header type"))
_verifyblockbuffers(batch, bodylen)
return nothing
end
function _verifyblockframes(
blob::BufferSlice,
dictblocks,
recordblocks,
dataend::Int64;
datastart::Int64=0,
)
indexedend = _validateblockindex(dictblocks, recordblocks, dataend; datastart=datastart)
foreach(block -> _verifyblockframe(blob, block, UInt8(2)), dictblocks)
foreach(block -> _verifyblockframe(blob, block, UInt8(3)), recordblocks)
return indexedend
end
"""
Frame and verify the single message a Block points at, against the block's
own declared extents and the enclosing region.
"""
function _blockmessage(
region::OwnerRegion,
block::NTuple{3,Int64},
dataend::Int64,
limits::Limits,
budget::AllocationBudget,
)
offset, _ = _blockextent(block, dataend)
_, metalen, bodylen = block
blob = BufferSlice(region, 0, region.len)
AC.loadat(blob, UInt32, offset) == CONTINUATION ||
throw(ValidationError("footer block does not point at a message"))
declared = Int64(AC.loadat(blob, Int32, offset + 4))
declared == metalen - 8 ||
throw(ValidationError("footer block metadata length does not match the message"))
0 < declared <= limits.max_metadata_bytes || throw(
ValidationError(
"metadata length $declared outside (0, $(limits.max_metadata_bytes)]",
),
)
0 <= bodylen <= limits.max_body_bytes ||
throw(ValidationError("body length $bodylen outside [0, $(limits.max_body_bytes)]"))
_chargevector!(budget, UInt8, declared, "metadata allocation")
metabytes = AC.slicebytes(AC.subslice(blob, offset + 8, declared))
version, header_type, features, _ =
_verify_ipc_metadata_budgeted(metabytes, limits, budget)
msg = FB.getrootas(Meta.Message, metabytes, 0)
Int64(msg.bodyLength) == bodylen ||
throw(ValidationError("footer block body length does not match the message"))
return FramedMessage(
msg,
AC.subslice(blob, offset + metalen, bodylen),
version,
header_type,
features,
)
end
"""
readfile(bytes::Vector{UInt8}; limits=Limits()) -> ArrowFile
readfile(region::OwnerRegion; limits=Limits()) -> ArrowFile
Open an IPC-format file: verify both magics, the footer length, the footer
flatbuffer, and the schema; eagerly decode the dictionary blocks (shared by
every record batch); expose record batches lazily through the Block index.
Pass `mmapregion(path)` to read a file through Core's mmap path. Duplicate
dictionary ids and delta dictionaries are format errors here — the file
format carries exactly one dictionary batch per id.
"""
readfile(bytes::Vector{UInt8}; limits::Limits=Limits()) =
_readfile(heapregion(bytes), limits, AllocationBudget(limits.max_total_allocated_bytes))
readfile(region::OwnerRegion; limits::Limits=Limits()) =
_readfile(region, limits, AllocationBudget(limits.max_total_allocated_bytes))
function _readfile(region::OwnerRegion, limits::Limits, budget::AllocationBudget)
_requirelittleendian()
_validatelimits(limits)
blob = BufferSlice(region, 0, region.len)
# leading magic + pad (8), smallest footer flatbuffer (8), footer length
# (4), trailing magic (6).
minlen = Int64(8 + 8 + 4 + 6)
region.len >= minlen || throw(ValidationError("file is too short to be an IPC file"))
for (i, byte) in enumerate(FILE_MAGIC)
AC.loadat(blob, UInt8, Int64(i - 1)) == byte ||
throw(ValidationError("missing leading ARROW1 magic"))
AC.loadat(blob, UInt8, region.len - 6 + (i - 1)) == byte ||
throw(ValidationError("missing trailing ARROW1 magic"))
end
# The trailer is <footer><Int32 footer length><ARROW1>, so the length
# word sits 10 bytes from the end.
footerlen = Int64(AC.loadat(blob, Int32, region.len - 10))
0 < footerlen <= limits.max_metadata_bytes || throw(
ValidationError(
"footer length $footerlen outside (0, $(limits.max_metadata_bytes)]",
),
)
footerstart = region.len - 10 - footerlen
footerstart >= 8 || throw(ValidationError("footer escapes the file"))
_chargevector!(budget, UInt8, footerlen, "footer allocation")
footerbytes = AC.slicebytes(AC.subslice(blob, footerstart, footerlen))
version, features, dictblocks, recordblocks, _ =
_verify_footer_budgeted(footerbytes, limits, budget)
Int64(1) in features &&
throw(ValidationError("dictionary replacement is forbidden in the IPC file format"))
nmessages = AC.checked_add(
Int64(1),
AC.checked_add(Int64(length(dictblocks)), Int64(length(recordblocks))),
)
nmessages <= limits.max_messages ||
throw(ValidationError("message count exceeds limit"))
footer = FB.getrootas(Meta.Footer, footerbytes, 0)
metaschema = footer.schema
metaschema === nothing || (
something(metaschema.endianness, Meta.Endianness.Little) ==
Meta.Endianness.Little || throw(
ValidationError(
"big-endian IPC is not supported (no endianness normalization)",
),
)
)
metaschema === nothing && throw(ValidationError("file footer carries no schema"))
# Validate the leading schema and indexed messages against the Footer
# boundary first. Only then can the final eight bytes be classified as an
# optional EOS marker: a no-EOS file may end its last data buffer with the
# same byte pattern.
schemafm, schemaend = _fileschema(region, footerstart, limits, budget)
schemafm.version == version ||
throw(ValidationError("file schema and footer metadata versions differ"))
schemafm.features == features ||
throw(ValidationError("file schema and footer features differ"))
_schemaequal(schemafm.msg.header::Meta.Schema, metaschema) ||
throw(ValidationError("file schema and footer schema differ"))
_metadataequal(schemafm.msg.custom_metadata, footer.custom_metadata) ||
throw(ValidationError("file schema and footer custom metadata differ"))
indexedend =
_verifyblockframes(blob, dictblocks, recordblocks, footerstart; datastart=schemaend)
haseos =
footerstart - indexedend >= 8 &&
AC.loadat(blob, UInt32, footerstart - 8) == CONTINUATION &&
AC.loadat(blob, UInt32, footerstart - 4) == UInt32(0)
dataend = haseos ? footerstart - 8 : footerstart
_validateblockindex(dictblocks, recordblocks, dataend; datastart=schemaend)
dictids = Dict{Int64,Meta.Field}()
fielddictids = IdDict{Field,Int64}()
fields = Field[
corefield(f, dictids, fielddictids) for
f in something(metaschema.fields, Meta.Field[])
]
foreach(validateschemafield, fields)
dictvaluefields = validatedictionaryids(fields, fielddictids)
sch = Schema(
fields;
metadata=coremetadata(metaschema.custom_metadata),
endianness=AC.LittleEndian,
)
dicts = Dict{Int64,ArrayData}()
validated = AC._ValidatedDictionaries()
state = DecodeState(budget)
try
for block in dictblocks
fm = _blockmessage(region, block, dataend, limits, budget)
fm.version == version ||
throw(ValidationError("IPC metadata version changes within the file"))
rejectexperimentalcompression(fm)
header = fm.msg.header
header isa Meta.DictionaryBatch ||
throw(ValidationError("footer dictionary block is not a dictionary batch"))
header.isDelta && throw(ValidationError("delta dictionaries are not supported"))
haskey(dictids, header.id) ||
throw(ValidationError("dictionary batch has unknown id $(header.id)"))
haskey(dicts, header.id) && throw(
ValidationError("the file format carries one dictionary batch per id"),
)
rb = header.data
codec = _batchcodec(rb.compression, fm.version)
vf = dictvaluefields[header.id]
rblen = something(rb.length, Int64(0))
0 <= rblen <= limits.max_array_length ||
throw(ValidationError("dictionary batch length $rblen exceeds limit"))
cursor = DecodeCursor(
rb.nodes,
rb.buffers,
fm.body,
limits;
codec=codec,
state=state,
variadics=variadiccounts(rb),
)
decoded = decodefield(vf, cursor, dicts, fielddictids)
finishcursor!(cursor)
decoded.len == rblen || throw(
ValidationError(
"dictionary RecordBatch length does not match its field node",
),
)
validate_semantic(vf, decoded)
validated[decoded] = nothing
dicts[header.id] = decoded
end
# Every id a record batch may reference must be resolvable now unless
# that batch proves all-null use — checked per batch at decode.
return ArrowFile(
region,
sch,
AC.FrozenVector{Field}(fields),
fielddictids,
dicts,
validated,
recordblocks,
dataend,
limits,
version,
)
finally
close(state)
end
end
function _filebatch(f::ArrowFile, i::Integer, state::DecodeState)
1 <= i <= length(f.recordblocks) || throw(BoundsError(f, i))
fm = _blockmessage(f.region, f.recordblocks[i], f.dataend, f.limits, state.budget)
fm.version == f.schemaversion ||
throw(ValidationError("IPC metadata version changes within the file"))
rejectexperimentalcompression(fm)
fm.msg.header isa Meta.RecordBatch ||
throw(ValidationError("footer record block is not a record batch"))
for id in missingdicts(f.fields, fm.msg.header.nodes, f.dictionaries, f.fielddictids)
throw(
ValidationError(
"record batch references dictionary id $id " *
"with no dictionary batch in the file",
),
)
end
return decoderecord(
fm,
f.fields,
f.schema,
f.dictionaries,
f.fielddictids,
f.limits,
f.validated,
state,
)
end
function Base.getindex(f::ArrowFile, i::Integer)
budget = AllocationBudget(f.limits.max_total_allocated_bytes)
state = DecodeState(budget)
try
return _filebatch(f, i, state)
finally
close(state)
end
end