wireform-arrow-0.1.0.0: src/Arrow/Read/Columns.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{- | Decode Arrow IPC record batch and dictionary batch bodies into
'ColumnArray's without copying.
Every buffer of the result aliases the message body (or, for a
compressed body, the freshly decompressed buffer). A buffer is copied
only when its address is not aligned for the element type (arrow-rs
@align_buffers@) or when the schema declares big-endian data, which is
byte-swapped into a fresh buffer.
Decoding validates everything arrow-rs validates by default, in O(1)
Haskell work per array plus C kernels: buffer bounds, buffer sizes
against array lengths, null counts against the validity bitmap
(popcount), offsets (monotonic, in range), UTF-8 and character
boundaries, list views, union type ids and dense offsets, run ends,
views. Dictionary keys are checked against their values when the
dictionary is resolved ('Arrow.Column.resolveDictionaryColumn'). A
corrupt or hostile batch is a 'Left', never an out-of-bounds read.
The body must use the IPC specification's buffer layout: a validity
slot for every array except null, union and run-end-encoded arrays
(an empty slot when the array has no nulls).
-}
module Arrow.Read.Columns (
decodeRecordBatch,
decodeDictionaryBatch,
-- * Decode budgets
ReadOptions (..),
defaultReadOptions,
-- * Body compression
decompressBody,
-- * Buffer bounds
validateRecordBatchBuffers,
) where
import Arrow.Column (columnLength, placeholderColumn)
import Arrow.Column.Internal (
ColumnArray,
IntegralPrim (..),
Offset,
PrimType (..),
SomePrimType (..),
integralPrim,
primTypeFor,
withPrim,
)
import Arrow.Column.Internal qualified as I
import Arrow.FlatBufferIPC.Common (DictBatch (..))
import Arrow.Types (
ArrowType (..),
BodyCompressionCodec (..),
Buffer (..),
DictionaryEncoding (..),
Endianness (..),
Field (..),
FieldNode (..),
RecordBatchDef (..),
Schema (..),
UnionMode (..),
)
import Control.DeepSeq (NFData)
import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Unsafe qualified as BSU
import Data.Int (Int32, Int64, Int8)
import Data.Text (Text)
import Data.Vector qualified as V
import Data.Vector.Storable qualified as VS
import Foreign.Marshal.Utils (copyBytes, fillBytes)
import Foreign.Ptr (castPtr, plusPtr)
import Foreign.Storable (Storable (..))
import GHC.Generics (Generic)
import System.IO.Unsafe (unsafeDupablePerformIO)
import Wireform.FFI (validateArrowBuffers)
#ifdef HAVE_ZSTD
import qualified Codec.Compression.Zstd as Zstd
#endif
#ifdef HAVE_LZ4
import qualified Codec.Lz4 as Lz4
import qualified Control.Exception as Exc
import qualified Data.ByteString.Lazy as BL
#endif
-- ============================================================
-- Reader options
-- ============================================================
{- | Caller-supplied decode budgets. Every entry point that turns wire
bytes into 'ColumnArray's takes one, mirroring arrow-cpp's
@IpcReadOptions@: an application that reads untrusted input passes
tight limits and gets a decode 'Left' instead of an oversized
allocation or an absurd logical row count. A batch that fits its
declared @length@ in real body bytes is still bounded by the input
size (columns alias the body); the budgets cap what the wire
/says/:
* 'roMaxRows' caps the record batch's declared row count.
* 'roMaxValues' caps the total declared length across every field
node of one batch, i.e. the logical values a consumer walking the
columns would visit (a run-end-encoded or list column can name
billions of rows from a few hundred bytes).
* 'roMaxUnbackedRows' caps rows claimed with no body bytes backing
them at all (nullable structs or fixed-size lists over null or
run-end-encoded children, zero-width fixed-size binary).
* 'roMaxDecompressedBytes' caps the total uncompressed bytes a
compressed body may expand to.
'defaultReadOptions' is generous enough for any batch that can fit in
memory while keeping every budget finite.
-}
data ReadOptions = ReadOptions
{ roMaxRows :: !Int64
-- ^ Largest row count ('rbLength') one record batch may declare.
, roMaxValues :: !Int64
-- ^ Largest total declared length across the field nodes of one batch.
, roMaxUnbackedRows :: !Int64
-- ^ Largest number of rows a batch may claim with no buffer backing.
, roMaxDecompressedBytes :: !Int64
-- ^ Largest total uncompressed size a compressed body may expand to.
}
deriving stock (Show, Eq, Generic)
deriving anyclass (NFData)
{- | The budgets used when a caller has no stricter policy: same row
cap as 'maxArrayLength', @2^34@ total declared values (a batch
claiming more than that cannot be backed by body bytes that fit in
memory), @2^20@ unbacked rows, and @2^34@ decompressed bytes.
-}
defaultReadOptions :: ReadOptions
defaultReadOptions =
ReadOptions
{ roMaxRows = fromIntegral maxArrayLength
, roMaxValues = 1 `shiftL` 34
, roMaxUnbackedRows = 1 `shiftL` 20
, roMaxDecompressedBytes = 1 `shiftL` 34
}
{- | Check the wire-claim budgets that apply before any buffer is
touched: the declared row count and the total declared value count
across all field nodes.
-}
checkBatchBudgets :: ReadOptions -> RecordBatchDef -> Either String ()
checkBatchBudgets opts rb = do
let !rows = rbLength rb
if rows < 0 || rows > roMaxRows opts
then
Left
( "Arrow.Read.Columns: record batch declares "
++ show rows
++ " rows (limit "
++ show (roMaxRows opts)
++ ")"
)
else Right ()
let !values = nodeLengthSum (rbNodes rb)
if values > roMaxValues opts
then
Left
( "Arrow.Read.Columns: record batch field nodes declare "
++ show values
++ " values in total (limit "
++ show (roMaxValues opts)
++ ")"
)
else Right ()
-- | Sum of the field node lengths, saturating at @maxBound@.
nodeLengthSum :: VS.Vector FieldNode -> Int64
nodeLengthSum = VS.foldl' step 0
where
step !acc fn
| acc >= maxBound = maxBound
| otherwise =
let !l = max 0 (fnLength fn)
in if acc > maxBound - l then maxBound else acc + l
-- ============================================================
-- Entry points
-- ============================================================
{- | Decode one record batch: the schema's top-level fields, in order,
from the batch header and its body. Body compression is undone first
(per buffer). Every top-level column has exactly 'rbLength' rows.
Dictionary-encoded columns come back as @ColDictionary id keys
placeholder@: the keys at their wire width with the row validity, and
an empty placeholder for the values; resolve them with
'Arrow.Column.resolveDictionaryColumn'.
-}
decodeRecordBatch :: ReadOptions -> Schema -> RecordBatchDef -> ByteString -> Either String (V.Vector ColumnArray)
decodeRecordBatch opts schema rb body = do
checkBatchBudgets opts rb
src <- bodySource opts rb body
let !ctx = Ctx (arrowEndianness schema == Big) rb src opts
Outs cols _ <- fieldsN ctx (arrowFields schema) (Cur 0 0 0 0)
case V.findIndex (\c -> fromIntegral (columnLength c) /= rbLength rb) cols of
Nothing -> Right cols
Just k ->
Left
( "Arrow.Read.Columns: column #"
++ show k
++ " has "
++ show (columnLength (V.unsafeIndex cols k))
++ " rows, the record batch declares "
++ show (rbLength rb)
)
{- | Decode the values of a dictionary batch. The values field is the
schema field (at any depth) whose dictionary encoding carries the
batch's id, with the encoding removed. Returns the id and the values
column; dictionaries nested inside the values stay unresolved
placeholders.
-}
decodeDictionaryBatch :: ReadOptions -> Schema -> DictBatch -> Either String (Int64, ColumnArray)
decodeDictionaryBatch opts sch db = do
f <- case findDictField (dbId db) (arrowFields sch) of
Just f -> Right f
Nothing ->
Left
( "Arrow.Read.Columns: dictionary batch with id "
++ show (dbId db)
++ " doesn't match any field in the schema"
)
let !valuesField = f {fieldDictionary = Nothing}
!inner = sch {arrowFields = V.singleton valuesField, arrowMetadata = V.empty, arrowFeatures = V.empty}
cols <- decodeRecordBatch opts inner (dbData db) (dbBody db)
if V.length cols == 1
then Right (dbId db, V.unsafeHead cols)
else Left "Arrow.Read.Columns: dictionary batch must hold exactly one column"
-- | The field whose dictionary encoding carries the id (depth first).
findDictField :: Int64 -> V.Vector Field -> Maybe Field
findDictField did = go
where
go fs = V.foldr (\f rest -> pick f rest) Nothing fs
pick f rest = case fieldDictionary f of
Just de | deId de == did -> Just f
_ -> case go (fieldChildren f) of
Just g -> Just g
Nothing -> rest
{- | Validate that all buffer offset/length pairs in a 'RecordBatchDef' are
non-negative, within the given body length, and non-overlapping.
SIMD-accelerated; reads the Storable buffer vector in place.
-}
validateRecordBatchBuffers :: RecordBatchDef -> Int64 -> Bool
validateRecordBatchBuffers rb bodyLen
| n == 0 = True
| otherwise = unsafeDupablePerformIO $ VS.unsafeWith bufs $ \p ->
pure $! validateArrowBuffers (castPtr p) n bodyLen
where
!bufs = rbBuffers rb
!n = VS.length bufs
-- ============================================================
-- Body sources
-- ============================================================
-- | Where buffer @i@ lives.
data Source
= -- | Uncompressed body; buffer descriptors already bounds-checked.
Plain !ByteString
| -- | One decompressed buffer per descriptor.
Unpacked !(V.Vector ByteString)
bodySource :: ReadOptions -> RecordBatchDef -> ByteString -> Either String Source
bodySource opts rb body = case rbBodyCompression rb of
Nothing
| validateRecordBatchBuffers rb (fromIntegral (BS.length body)) -> Right (Plain body)
| otherwise -> Left "Arrow.Read.Columns: invalid buffer bounds in RecordBatchDef"
Just codec -> Unpacked <$> decompressBuffers opts codec (rbBuffers rb) body
-- ============================================================
-- Walk state
-- ============================================================
data Ctx = Ctx
{ ctxSwap :: !Bool
-- ^ Big-endian body: multi-byte values are byte-swapped into fresh buffers.
, ctxRb :: !RecordBatchDef
, ctxSrc :: !Source
, ctxOpts :: !ReadOptions
}
-- | Next field node, next buffer, next variadic-count entry, unbacked rows so far.
data Cur = Cur
{ curNode :: {-# UNPACK #-} !Int
, curBuf :: {-# UNPACK #-} !Int
, curVar :: {-# UNPACK #-} !Int
, curUnbacked :: {-# UNPACK #-} !Int
}
data Out = Out !ColumnArray {-# UNPACK #-} !Cur
data Outs = Outs !(V.Vector ColumnArray) {-# UNPACK #-} !Cur
-- | Advance past @nodes@ field nodes and @bufs@ buffers.
adv :: Int -> Int -> Cur -> Cur
adv nodes bufs (Cur n b v u) = Cur (n + nodes) (b + bufs) v u
{-# INLINE adv #-}
{- | Upper bound on any array length taken from a field node. Far above
any batch that fits in memory, and small enough that @len * 32@ and
@(len + 1) * 8@ cannot overflow 'Int'.
-}
maxArrayLength :: Int
maxArrayLength = 1 `shiftL` 40
-- | Field node @i@ with its length checked to lie in @[0, 'maxArrayLength']@.
nodeAt :: Ctx -> Int -> Either String FieldNode
nodeAt ctx i = case rbNodes (ctxRb ctx) VS.!? i of
Nothing ->
Left
( "Arrow.Read.Columns: record batch has "
++ show (VS.length (rbNodes (ctxRb ctx)))
++ " field nodes, schema needs node #"
++ show i
)
Just fn
| fnLength fn < 0 || fnLength fn > fromIntegral maxArrayLength ->
Left ("Arrow.Read.Columns: invalid field node length " ++ show (fnLength fn))
| otherwise -> Right fn
{-# INLINE nodeAt #-}
-- | Bytes of buffer @i@.
bufferAt :: Ctx -> Int -> Either String ByteString
bufferAt ctx i
| i < 0 || i >= VS.length bufs =
Left
( "Arrow.Read.Columns: record batch has "
++ show (VS.length bufs)
++ " buffers, schema layout needs buffer #"
++ show i
)
| otherwise = case ctxSrc ctx of
Unpacked v -> Right (V.unsafeIndex v i)
Plain body ->
let Buffer o l = VS.unsafeIndex bufs i
in if o < 0 || l < 0 || o > fromIntegral (BS.length body) - l
then Left "Arrow.Read.Columns: buffer outside the body"
else Right $! BSU.unsafeTake (fromIntegral l) (BSU.unsafeDrop (fromIntegral o) body)
where
!bufs = rbBuffers (ctxRb ctx)
{-# INLINE bufferAt #-}
-- ============================================================
-- Schema walk
-- ============================================================
fieldsN :: Ctx -> V.Vector Field -> Cur -> Either String Outs
fieldsN ctx fields cur0 = go 0 cur0 []
where
!n = V.length fields
go !i !cur acc
| i >= n = Right (Outs (V.fromListN n (reverse acc)) cur)
| otherwise = do
Out c cur' <- field ctx (V.unsafeIndex fields i) cur
go (i + 1) cur' (c : acc)
field :: Ctx -> Field -> Cur -> Either String Out
field ctx f cur = do
fn <- nodeAt ctx (curNode cur)
let !len = fromIntegral (fnLength fn) :: Int
!lim = roMaxUnbackedRows (ctxOpts ctx)
!ub = if allocatesUnbacked f then curUnbacked cur + len else curUnbacked cur
if fromIntegral ub > lim
then
Left
( "Arrow.Read.Columns: record batch claims "
++ show ub
++ " rows with no buffer backing them (limit "
++ show lim
++ ")"
)
else fieldAt ctx f fn len cur {curUnbacked = ub}
fieldAt :: Ctx -> Field -> FieldNode -> Int -> Cur -> Either String Out
fieldAt ctx f fn len cur = case fieldDictionary f of
Just de -> dictKeys ctx f de fn len cur
Nothing -> case fieldType f of
ANull -> Right (Out (I.ColNull len) (adv 1 0 cur))
ABool -> do
v <- validityAt ctx "bool" len fn bi
bs <- bufferAt ctx (bi + 1)
bits <- case I.mkBitmap bs 0 len of
Right b -> Right b
Left _ -> Left "Arrow.Read.Columns: bool data buffer too small"
Right (Out (I.ColBool v bits) (adv 1 2 cur))
AUtf8 -> varLen @Int32 ctx "utf8" True I.ColUtf8 fn len cur
ABinary -> varLen @Int32 ctx "binary" False I.ColBinary fn len cur
ALargeUtf8 -> varLen @Int64 ctx "large utf8" True I.ColLargeUtf8 fn len cur
ALargeBinary -> varLen @Int64 ctx "large binary" False I.ColLargeBinary fn len cur
AFixedSizeBinary w -> fixedSizeBinary ctx w fn len cur
AUtf8View -> views ctx True fn len cur
ABinaryView -> views ctx False fn len cur
AStruct -> struct ctx f fn len cur
AList -> list @Int32 ctx "list" I.ColList f fn len cur
ALargeList -> list @Int64 ctx "large list" I.ColLargeList f fn len cur
AListView -> listView @Int32 ctx "list view" I.ColListView f fn len cur
ALargeListView -> listView @Int64 ctx "large list view" I.ColLargeListView f fn len cur
AFixedSizeList size -> fixedSizeList ctx size f fn len cur
AMap _ -> mapCol ctx f fn len cur
AUnion mode ids -> union ctx mode ids f len cur
ARunEndEncoded -> runEndEncoded ctx f len cur
ty -> case primTypeFor ty of
Just (SomePrimType t) -> do
c <- prim ctx t fn len bi
Right (Out c (adv 1 2 cur))
Nothing -> Left ("Arrow.Read.Columns: unsupported type: " ++ show ty)
where
!bi = curBuf cur
-- | Does this array claim rows that no buffer backs?
allocatesUnbacked :: Field -> Bool
allocatesUnbacked f
| Just _ <- fieldDictionary f = False
| otherwise = case fieldType f of
AStruct -> fieldNullable f && not (lengthBacked f)
AFixedSizeList _ -> fieldNullable f && not (lengthBacked f)
AFixedSizeBinary 0 -> True
_ -> False
{- | Is the length of an array of this field paid for by bytes in some
buffer of the array or its descendants? Null arrays have no buffers,
and a run-end-encoded array's logical length can be any value its last
run end names, so neither backs a parent.
-}
lengthBacked :: Field -> Bool
lengthBacked f
| Just _ <- fieldDictionary f = True
| otherwise = case fieldType f of
ANull -> False
ARunEndEncoded -> False
AStruct -> V.any lengthBacked (fieldChildren f)
AFixedSizeList n -> n > 0 && V.any lengthBacked (fieldChildren f)
AFixedSizeBinary w -> w > 0
_ -> True
-- ============================================================
-- Buffers
-- ============================================================
{- | The validity of an array of @len@ rows from its slot. No nulls is
'Nothing' (whatever the slot holds, as in arrow-rs); otherwise the
bitmap must cover @len@ bits and its clear bits must equal the node's
null count (C popcount).
-}
validityAt :: Ctx -> String -> Int -> FieldNode -> Int -> Either String (Maybe I.Validity)
validityAt ctx what len fn bi
| nc == 0 = bufferAt ctx bi >> Right Nothing
| nc < 0 || nc > len = Left ("Arrow.Read.Columns: " ++ what ++ ": null count " ++ show nc ++ " for " ++ show len ++ " rows")
| otherwise = do
bs <- bufferAt ctx bi
let !nbytes = (len + 7) `quot` 8
if BS.length bs < nbytes
then Left ("Arrow.Read.Columns: " ++ what ++ ": validity bitmap too small for " ++ show len ++ " rows")
else do
let !b = I.Bitmap (BSU.unsafeTake nbytes bs) 0 len
!nulls = len - I.bitmapSetCount b
if nulls /= nc
then
Left
( "Arrow.Read.Columns: "
++ what
++ ": null count "
++ show nc
++ " does not match the validity bitmap ("
++ show nulls
++ ")"
)
else Right (Just (I.Validity b nc))
where
!nc = fromIntegral (fnNullCount fn) :: Int
-- | @n@ elements of @w@ bytes from buffer @i@, aligned and in native byte order.
storableAt :: forall a. Storable a => Ctx -> String -> [Int] -> Int -> Int -> Either String (VS.Vector a)
storableAt ctx what swapPat n i = do
bs <- bufferAt ctx i
let !w = sizeOf (undefined :: a)
!need = n * w
if BS.length bs < need
then Left ("Arrow.Read.Columns: " ++ what ++ " buffer too small for " ++ show n ++ " values")
else
let !exact = BSU.unsafeTake need bs
in Right $!
if ctxSwap ctx && not (null swapPat)
then I.unsafeBytesToStorable (I.swapCopy swapPat w n exact)
else I.bytesToStorable exact
{-# INLINE storableAt #-}
{- | @rows + 1@ offsets from buffer @i@. A zero-row array may omit the
offsets buffer (it then has the single offset 0).
-}
offsetsAt :: forall o. Offset o => Ctx -> String -> Int -> Int -> Either String (VS.Vector o)
offsetsAt ctx what rows i = do
bs <- bufferAt ctx i
if rows == 0 && BS.length bs < sizeOf (undefined :: o)
then Right (VS.singleton 0)
else storableAt ctx (what ++ " offsets") [sizeOf (undefined :: o)] (rows + 1) i
{-# INLINE offsetsAt #-}
-- ============================================================
-- Leaf arrays
-- ============================================================
-- | A fixed-width array: validity at @bi@, values at @bi + 1@.
prim :: Ctx -> PrimType a -> FieldNode -> Int -> Int -> Either String ColumnArray
prim ctx t fn len bi = withPrim t $ do
v <- validityAt ctx (I.primTypeName t) len fn bi
xs <- storableAt ctx (I.primTypeName t) (I.swapPattern t) len (bi + 1)
Right (I.ColPrim t v xs)
varLen
:: forall o
. Offset o
=> Ctx
-> String
-> Bool
-> (Maybe I.Validity -> VS.Vector o -> ByteString -> ColumnArray)
-> FieldNode
-> Int
-> Cur
-> Either String Out
varLen ctx what utf8 con fn len cur = do
let !bi = curBuf cur
v <- validityAt ctx what len fn bi
offs <- offsetsAt @o ctx what len (bi + 1)
dat <- bufferAt ctx (bi + 2)
I.validateOffsets what (BS.length dat) offs
if utf8 then I.validateUtf8 what offs dat else Right ()
Right (Out (con v offs dat) (adv 1 3 cur))
{-# INLINE varLen #-}
fixedSizeBinary :: Ctx -> Int -> FieldNode -> Int -> Cur -> Either String Out
fixedSizeBinary ctx w fn len cur
| w < 0 = Left ("Arrow.Read.Columns: negative fixed-size binary width " ++ show w)
| w > 0 && len > maxArrayLength `quot` w =
Left ("Arrow.Read.Columns: fixed-size binary of width " ++ show w ++ " cannot have " ++ show len ++ " rows")
| otherwise = do
let !bi = curBuf cur
v <- validityAt ctx "fixed-size binary" len fn bi
dat <- bufferAt ctx (bi + 1)
if BS.length dat < w * len
then Left "Arrow.Read.Columns: fixed-size binary data buffer too small"
else Right (Out (I.ColFixedSizeBinary w len v (BSU.unsafeTake (w * len) dat)) (adv 1 2 cur))
{- | Utf8View / BinaryView: validity, the 16-byte views, then this
column's variadic data buffers (their count is the next
'rbVariadicBufferCounts' entry, in preorder over view columns).
-}
views :: Ctx -> Bool -> FieldNode -> Int -> Cur -> Either String Out
views ctx utf8 fn len cur = do
let !rb = ctxRb ctx
!bi = curBuf cur
!what = if utf8 then "utf8 view" else "binary view"
nVar <- case rbVariadicBufferCounts rb V.!? curVar cur of
Nothing -> Left "Arrow.Read.Columns: view column has no variadicBufferCounts entry"
Just c
| c < 0 || c > fromIntegral (VS.length (rbBuffers rb)) ->
Left ("Arrow.Read.Columns: invalid variadic buffer count " ++ show c)
| otherwise -> Right (fromIntegral c :: Int)
v <- validityAt ctx what len fn bi
vs <- bufferAt ctx (bi + 1)
if BS.length vs < len * 16
then Left ("Arrow.Read.Columns: " ++ what ++ " buffer too small")
else Right ()
dataBufs <- V.generateM nVar (\k -> bufferAt ctx (bi + 2 + k))
let !rawViews = BSU.unsafeTake (len * 16) vs
!viewBytes = if ctxSwap ctx then I.swapViewCopy True rawViews else rawViews
I.validateViews what utf8 len v viewBytes dataBufs
let !col = if utf8 then I.ColUtf8View v viewBytes dataBufs else I.ColBinaryView v viewBytes dataBufs
Cur n b x u = cur
Right (Out col (Cur (n + 1) (b + 2 + nVar) (x + 1) u))
-- ============================================================
-- Nested arrays
-- ============================================================
singleChild :: String -> Field -> Either String Field
singleChild what f
| V.length (fieldChildren f) == 1 = Right (V.unsafeHead (fieldChildren f))
| otherwise =
Left ("Arrow.Read.Columns: " ++ what ++ " field must have exactly one child, has " ++ show (V.length (fieldChildren f)))
-- | Every child of a struct-like parent must cover the parent's rows.
checkChildLengths :: String -> Int -> V.Vector ColumnArray -> Either String ()
checkChildLengths what len cols = case V.findIndex (\c -> columnLength c < len) cols of
Nothing -> Right ()
Just k ->
Left
( "Arrow.Read.Columns: "
++ what
++ " child #"
++ show k
++ " has "
++ show (columnLength (V.unsafeIndex cols k))
++ " rows, parent has "
++ show len
)
struct :: Ctx -> Field -> FieldNode -> Int -> Cur -> Either String Out
struct ctx f fn len cur = do
v <- validityAt ctx "struct" len fn (curBuf cur)
Outs cols cur' <- fieldsN ctx (fieldChildren f) (adv 1 1 cur)
checkChildLengths "struct" len cols
let named = V.zipWith (\c col -> (fieldName c, col)) (fieldChildren f) cols :: V.Vector (Text, ColumnArray)
Right (Out (I.ColStruct len v named) cur')
list
:: forall o
. Offset o
=> Ctx
-> String
-> (Maybe I.Validity -> VS.Vector o -> ColumnArray -> ColumnArray)
-> Field
-> FieldNode
-> Int
-> Cur
-> Either String Out
list ctx what con f fn len cur = do
let !bi = curBuf cur
childField <- singleChild what f
v <- validityAt ctx what len fn bi
offs <- offsetsAt @o ctx what len (bi + 1)
Out child cur' <- field ctx childField (adv 1 2 cur)
I.validateOffsets what (columnLength child) offs
Right (Out (con v offs child) cur')
listView
:: forall o
. Offset o
=> Ctx
-> String
-> (Maybe I.Validity -> VS.Vector o -> VS.Vector o -> ColumnArray -> ColumnArray)
-> Field
-> FieldNode
-> Int
-> Cur
-> Either String Out
listView ctx what con f fn len cur = do
let !bi = curBuf cur
!w = sizeOf (undefined :: o)
childField <- singleChild what f
v <- validityAt ctx what len fn bi
offs <- storableAt @o ctx (what ++ " offsets") [w] len (bi + 1)
sizes <- storableAt @o ctx (what ++ " sizes") [w] len (bi + 2)
Out child cur' <- field ctx childField (adv 1 3 cur)
I.validateListView what v offs sizes (columnLength child)
Right (Out (con v offs sizes child) cur')
fixedSizeList :: Ctx -> Int -> Field -> FieldNode -> Int -> Cur -> Either String Out
fixedSizeList ctx size f fn len cur
| size < 0 || (size > 0 && len > maxArrayLength `quot` size) =
Left ("Arrow.Read.Columns: fixed-size list of size " ++ show size ++ " cannot have " ++ show len ++ " rows")
| otherwise = do
childField <- singleChild "fixed-size list" f
v <- validityAt ctx "fixed-size list" len fn (curBuf cur)
Out child cur' <- field ctx childField (adv 1 1 cur)
if columnLength child < len * size
then
Left
( "Arrow.Read.Columns: fixed-size list child has "
++ show (columnLength child)
++ " rows, needs "
++ show (len * size)
)
else Right (Out (I.ColFixedSizeList size len v child) cur')
{- | Map: validity, offsets, then the entries struct (key, value),
which may hold no nulls of its own (arrow-rs @MapArray@).
-}
mapCol :: Ctx -> Field -> FieldNode -> Int -> Cur -> Either String Out
mapCol ctx f fn len cur = do
let !bi = curBuf cur
entries <- singleChild "map" f
v <- validityAt ctx "map" len fn bi
offs <- offsetsAt @Int32 ctx "map" len (bi + 1)
Out ent cur' <- field ctx entries (adv 1 2 cur)
case ent of
I.ColStruct n Nothing kv
| V.length kv == 2 -> do
I.validateOffsets "map" n offs
Right (Out (I.ColMap v offs (snd (V.unsafeIndex kv 0)) (snd (V.unsafeIndex kv 1))) cur')
_ -> Left "Arrow.Read.Columns: map entries must be a struct of exactly (key, value) without nulls"
{- | Unions carry no validity. Wire type ids become child positions
through the declared @typeIds@ (aliased as is when they are
positional, the common case; otherwise one table-lookup pass).
-}
union :: Ctx -> UnionMode -> V.Vector Int32 -> Field -> Int -> Cur -> Either String Out
union ctx mode declared f len cur = do
let !bi = curBuf cur
!k = V.length (fieldChildren f)
!what = "union"
table <- unionChildTable k declared
wire <- storableAt @Int8 ctx "union type ids" [] len bi
let !types = case table of
Nothing -> wire
Just tbl -> VS.map (\t -> if t < 0 then -1 else VS.unsafeIndex tbl (fromIntegral t)) wire
case mode of
Dense -> do
offs <- storableAt @Int32 ctx "dense union offsets" [4] len (bi + 1)
Outs children cur' <- fieldsN ctx (fieldChildren f) (adv 1 2 cur)
let !lens = VS.generate k (\j -> fromIntegral (columnLength (V.unsafeIndex children j))) :: VS.Vector Int64
I.validateDenseUnion what types offs lens
Right (Out (I.ColDenseUnion types offs children) cur')
Sparse -> do
Outs children cur' <- fieldsN ctx (fieldChildren f) (adv 1 1 cur)
checkChildLengths "sparse union" len children
I.validateSparseUnionTypes what types k
Right (Out (I.ColSparseUnion types children) cur')
{- | Lookup table from wire type id (0..127) to child position (@-1@ when
undeclared), or 'Nothing' when the ids are positional (child @k@ has
id @k@), so the wire buffer already holds child positions.
-}
unionChildTable :: Int -> V.Vector Int32 -> Either String (Maybe (VS.Vector Int8))
unionChildTable nChildren declared
| nChildren > 128 = Left ("Arrow.Read.Columns: union has " ++ show nChildren ++ " children, at most 128 allowed")
| V.null declared = Right Nothing
| V.length declared /= nChildren =
Left
( "Arrow.Read.Columns: union declares "
++ show (V.length declared)
++ " type ids for "
++ show nChildren
++ " children"
)
| V.any (\t -> t < 0 || t > 127) declared = Left "Arrow.Read.Columns: union type ids must be in 0..127"
| V.and (V.imap (\j t -> fromIntegral t == j) declared) = Right Nothing
| otherwise =
let table = VS.generate 128 (\t -> maybe (-1) fromIntegral (V.elemIndex (fromIntegral t) declared)) :: VS.Vector Int8
in if VS.length (VS.filter (>= 0) table) /= nChildren
then Left "Arrow.Read.Columns: union declares a type id twice"
else Right (Just table)
{- | Run-end encoded: no buffers of its own, children run ends (non-null
int16/32/64, positive, strictly increasing, last >= length) and values
(at least one per run).
-}
runEndEncoded :: Ctx -> Field -> Int -> Cur -> Either String Out
runEndEncoded ctx f len cur
| V.length (fieldChildren f) /= 2 =
Left "Arrow.Read.Columns: RunEndEncoded must have exactly two children (run_ends, values)"
| otherwise = do
Out re cur1 <- field ctx (V.unsafeIndex (fieldChildren f) 0) (adv 1 0 cur)
Out vals cur2 <- field ctx (V.unsafeIndex (fieldChildren f) 1) cur1
I.validateRunEnds "run-end encoded" re len
let !runs = columnLength re
if columnLength vals < runs
then
Left
( "Arrow.Read.Columns: run-end encoded array has "
++ show runs
++ " runs but "
++ show (columnLength vals)
++ " values"
)
else Right (Out (I.ColRunEndEncoded 0 len re vals) cur2)
{- | The wire payload of a dictionary-encoded field: the keys (an
integer array at the index type's width, carrying the row validity)
and an empty placeholder for the values.
-}
dictKeys :: Ctx -> Field -> DictionaryEncoding -> FieldNode -> Int -> Cur -> Either String Out
dictKeys ctx f de fn len cur = case primTypeFor (deIndexType de) of
Just (SomePrimType t)
| Just IntegralPrim <- integralPrim t -> do
keys <- prim ctx t fn len (curBuf cur)
placeholder <- placeholderColumn f
Right (Out (I.ColDictionary (deId de) keys placeholder) (adv 1 2 cur))
_ -> Left ("Arrow.Read.Columns: dictionary index type must be an 8/16/32/64-bit integer, got " ++ show (deIndexType de))
-- ============================================================
-- Body compression
-- ============================================================
{- | Decompress every buffer of a compressed body (one envelope per
buffer descriptor). Each result is a fresh buffer.
-}
decompressBuffers :: ReadOptions -> BodyCompressionCodec -> VS.Vector Buffer -> ByteString -> Either String (V.Vector ByteString)
decompressBuffers opts codec bufs body = do
checkDecompressedBudget opts bufs body
V.generateM (VS.length bufs) $ \i -> do
let Buffer o l = VS.unsafeIndex bufs i
env <- sliceWire "compressed buffer" o l body
decompressBufferEnvelope codec env
{- | Sum the buffer envelopes' declared uncompressed sizes and reject
the body /before/ anything is decompressed when the expansion the
decoder would have to materialize exceeds 'roMaxDecompressedBytes'.
Envelopes marked stored (@-1@) do not expand and are not charged. Each
individual claim is still bounded by 'maxCompressionRatio' against its
payload; this bounds the total.
-}
checkDecompressedBudget :: ReadOptions -> VS.Vector Buffer -> ByteString -> Either String ()
checkDecompressedBudget opts bufs body = go 0 0
where
!n = VS.length bufs
go !i !acc
| acc > roMaxDecompressedBytes opts =
Left
( "Arrow.Read.Columns: compressed body declares "
++ show acc
++ " decompressed bytes in total (limit "
++ show (roMaxDecompressedBytes opts)
++ ")"
)
| i >= n = Right ()
| otherwise = do
let Buffer o l = VS.unsafeIndex bufs i
env <- sliceWire "compressed buffer" o l body
let !claim
| BS.length env < 8 = 0
| otherwise = case leInt64 env of
r | r > 0 -> r
_ -> 0
go (i + 1) (if acc > maxBound - claim then maxBound else acc + claim)
{- | Decode a body written with body compression into an uncompressed
body plus buffer descriptors that point into it (each buffer padded to
8 bytes). 'decodeRecordBatch' does this itself, per buffer and without
the concatenation; this is for callers that need the uncompressed
message body.
-}
decompressBody :: ReadOptions -> BodyCompressionCodec -> VS.Vector Buffer -> ByteString -> Either String (VS.Vector Buffer, ByteString)
decompressBody opts codec bufs body = do
payloads <- decompressBuffers opts codec bufs body
let padded b = (BS.length b + 7) `quot` 8 * 8
offsets = V.prescanl' (+) 0 (V.map padded payloads)
!total = V.sum (V.map padded payloads)
newBufs = VS.generate (V.length payloads) $ \i ->
Buffer (fromIntegral (V.unsafeIndex offsets i)) (fromIntegral (BS.length (V.unsafeIndex payloads i)))
out = I.createAligned total $ \dst -> do
let fill !i
| i >= V.length payloads = pure ()
| otherwise = do
let !p = V.unsafeIndex payloads i
!off = V.unsafeIndex offsets i
!pl = padded p
I.withBytesPtr p $ \src -> copyBytes (dst `plusPtr` off) src (BS.length p)
fillBytes (dst `plusPtr` (off + BS.length p)) 0 (pl - BS.length p)
fill (i + 1)
fill 0
Right (newBufs, out)
-- | Slice @len@ bytes at @off@ out of @bs@ (both wire values).
sliceWire :: String -> Int64 -> Int64 -> ByteString -> Either String ByteString
sliceWire what off len bs
| off < 0 || len < 0 = Left ("Arrow.Read.Columns: " ++ what ++ ": negative offset or length")
| off > n || len > n - off =
Left
( "Arrow.Read.Columns: "
++ what
++ " ["
++ show off
++ ", +"
++ show len
++ ") runs past the "
++ show n
++ "-byte input"
)
| otherwise = Right $! BSU.unsafeTake (fromIntegral len) (BSU.unsafeDrop (fromIntegral off) bs)
where
n = fromIntegral (BS.length bs) :: Int64
{- | Decompress one buffer envelope per the @BUFFER@ method.
The 8-byte uncompressed length is a wire claim. It must be @-1@
(stored uncompressed) or a size the payload could actually expand to
(see 'maxCompressionRatio'), and the decompressed bytes must match it
exactly; a lying header can never size an allocation.
-}
decompressBufferEnvelope :: BodyCompressionCodec -> ByteString -> Either String ByteString
decompressBufferEnvelope codec env
| BS.null env = Right env
| BS.length env < 8 = Left "Arrow.Read.Columns: buffer envelope shorter than 8 bytes"
| rawLen == -1 = Right payload
| rawLen < 0 = Left ("Arrow.Read.Columns: negative uncompressed buffer length " ++ show rawLen)
| rawLen > fromIntegral (BS.length payload) * maxCompressionRatio =
Left
( "Arrow.Read.Columns: buffer claims "
++ show rawLen
++ " uncompressed bytes from a "
++ show (BS.length payload)
++ "-byte payload"
)
| otherwise = do
out <- decompressBuffer codec (fromIntegral rawLen) payload
if BS.length out /= fromIntegral rawLen
then
Left
( "Arrow.Read.Columns: buffer decompressed to "
++ show (BS.length out)
++ " bytes, header says "
++ show rawLen
)
else Right out
where
-- Lazy on purpose: only forced once the guards above have checked
-- that the envelope holds the 8-byte length.
rawLen = leInt64 env
payload = BSU.unsafeDrop 8 env
{- | No LZ4 frame or ZSTD frame expands by more than this factor (a
ZSTD RLE block turns 4 bytes into at most 128 KiB; LZ4 tops out near
255x), so a larger claimed size is a lie, not data.
-}
maxCompressionRatio :: Int64
maxCompressionRatio = 32768
-- | Little-endian 'Int64' in the first 8 bytes (the caller checked the length).
leInt64 :: ByteString -> Int64
leInt64 bs = go 7 0
where
go :: Int -> Int64 -> Int64
go !k !acc
| k < 0 = acc
| otherwise = go (k - 1) ((acc `shiftL` 8) .|. fromIntegral (BSU.unsafeIndex bs k))
{- | Decompress a payload that must expand to exactly @rawLen@ bytes
('decompressBufferEnvelope' has already bounded @rawLen@). Neither
backend may allocate more than @rawLen + 1@ output bytes.
-}
decompressBuffer :: BodyCompressionCodec -> Int -> ByteString -> Either String ByteString
#if defined(HAVE_ZSTD) || defined(HAVE_LZ4)
decompressBuffer codec rawLen comp = case codec of
#else
decompressBuffer codec _ _ = case codec of
#endif
#ifdef HAVE_ZSTD
BodyZstd ->
-- Zstd.decompress sizes its output from the frame header, so the
-- header must agree with the (bounded) envelope length first.
case Zstd.decompressedSize comp of
Just n | n == rawLen ->
case Zstd.decompress comp of
Zstd.Decompress out -> Right out
Zstd.Skip -> Left "Arrow.Read.Columns: ZSTD decompress: skipped frame"
Zstd.Error msg -> Left ("Arrow.Read.Columns: ZSTD decompress: " ++ msg)
other ->
Left
( "Arrow.Read.Columns: ZSTD frame content size "
++ show other
++ " does not match the buffer's uncompressed length "
++ show rawLen
)
#else
BodyZstd -> Left "Arrow.Read.Columns: ZSTD body compression requires building wireform-arrow with -fzstd"
#endif
#ifdef HAVE_LZ4
LZ4Frame ->
-- lz4-hs's decompress is lazy and throws on malformed input; take at
-- most one byte past the expected size (so an overlong frame is
-- detected without being fully inflated) and catch the exception to
-- keep the Either-shaped contract.
case unsafeDupablePerformIO $
Exc.try @Exc.SomeException
(Exc.evaluate (BL.toStrict (BL.take (fromIntegral rawLen + 1) (Lz4.decompress (BL.fromStrict comp))))) of
Right out -> Right out
Left e -> Left ("Arrow.Read.Columns: LZ4_FRAME decompress: " ++ show e)
#else
LZ4Frame -> Left "Arrow.Read.Columns: LZ4 body compression requires building wireform-arrow with -flz4"
#endif