wireform-arrow-0.1.0.0: test/Test/Arrow/Sections.hs
{-# LANGUAGE OverloadedStrings #-}
{- | Section-at-a-time reading of Arrow IPC containers: the footer
'FileIndex', the pure 'Arrow.Stream.FileReader', and the
semi-streaming 'Handle' iterators that decode one section live at a
time.
The parity tests compare every sectioning path against the eager
decoders over the same bytes; the corruption test proves the handle
iterator really works section by section (earlier batches arrive
before a broken later section is reported).
-}
module Test.Arrow.Sections (tests) where
import Arrow.Column (ColumnArray, PrimType (..), primColumn)
import Arrow.Column qualified as C
import Arrow.FlatBufferIPC (ArrowBlock (..), FileIndex (..), readFileIndex)
import Arrow.Stream (
ReadOptions,
decodeArrowFile,
decodeArrowStream,
defaultReadOptions,
defaultWriteOptions,
encodeArrowFile,
encodeArrowStream,
fileBatchesHandle,
fileReaderSchema,
fileReaderToList,
openFileReader,
streamBatchesHandle,
)
import Arrow.Types (ArrowType (..), Schema, defaultLeafField, defaultSchema)
import Arrow.Types qualified as A
import Columnar.Stream (IterIO, IterIOStep (..), iterIOStep, iterIOToList)
import Control.Exception (bracket)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as BB
import Data.ByteString.Lazy qualified as BL
import Data.Int (Int64)
import Data.List (isPrefixOf)
import Data.Vector qualified as V
import Data.Vector.Storable qualified as VS
import FlatBuffers.Reader qualified as FB
import Hedgehog
import System.Directory (getTemporaryDirectory, removeFile)
import System.IO (Handle, SeekMode (..), hClose, hSeek, openBinaryTempFile)
tests :: IO Bool
tests =
checkParallel $
Group
"Arrow.Sections"
[ ("file sections decode to the eager batches", prop_fileSectionsMatchEager)
, ("pyarrow file sections decode to the eager batches", prop_pyarrowFileSections)
, ("handle iteration decodes one file section at a time", prop_fileBatchesHandle)
, ("handle iteration decodes one stream frame at a time (dictionaries included)", prop_streamBatchesHandle)
, ("a corrupt section fails only when its turn comes", prop_corruptSectionFailsLate)
, ("Rust 64-byte aligned files use their footer index", prop_rustAlignedFile)
, ("Rust empty 64-byte aligned files keep their schema", prop_rustEmptyFile)
, ("footer blocks cannot address footer bytes", prop_blockCannotPointIntoFooter)
, ("negative message body lengths fail every file reader", prop_negativeMessageBody)
]
prop_rustAlignedFile :: Property
prop_rustAlignedFile = withTests 1 $ property $ do
bytes <- evalIO (BS.readFile "test/golden/rs_file_alignment64.arrow")
-- The old eager reader mistook this alignment padding for stream EOS.
BS.take 4 (BS.drop 8 bytes) === BS.replicate 4 0
let expected = [V.singleton (C.fromMaybes PInt32 (V.fromList [Just 1, Nothing, Just (-7), Just maxBound]))]
(schema, batches) <- evalEither (decodeArrowFile defaultReadOptions bytes)
V.map A.fieldName (A.arrowFields schema) === V.singleton "value"
V.map A.fieldType (A.arrowFields schema) === V.singleton (AInt 32 True)
batches === expected
reader <- evalEither (openFileReader defaultReadOptions bytes)
fileReaderSchema reader === schema
streamed <- evalEither (fileReaderToList reader)
streamed === expected
prop_rustEmptyFile :: Property
prop_rustEmptyFile = withTests 1 $ property $ do
bytes <- evalIO (BS.readFile "test/golden/rs_file_empty_alignment64.arrow")
(schema, batches) <- evalEither (decodeArrowFile defaultReadOptions bytes)
V.map A.fieldName (A.arrowFields schema) === V.singleton "value"
V.map A.fieldType (A.arrowFields schema) === V.singleton (AInt 32 True)
batches === []
reader <- evalEither (openFileReader defaultReadOptions bytes)
fileReaderSchema reader === schema
streamed <- evalEither (fileReaderToList reader)
streamed === []
prop_blockCannotPointIntoFooter :: Property
prop_blockCannotPointIntoFooter = withTests 1 $ property $ do
bytes <- evalIO (BS.readFile "test/golden/rs_file_alignment64.arrow")
footerLength <- evalEither (FB.peekI32 bytes (BS.length bytes - 10))
let footerStart = BS.length bytes - 10 - fromIntegral footerLength
footer = BS.take (fromIntegral footerLength) (BS.drop footerStart bytes)
root <- evalEither (FB.peekU32 footer 0)
slots <- evalEither (FB.resolveTable footer (fromIntegral root))
slot <- evalMaybe (slots 3)
vector <- evalEither (FB.followUOffset footer slot)
(_, positions) <- evalEither (FB.readVectorOfStructs footer vector 24)
position <- evalMaybe (positions V.!? 0)
let patchPosition = footerStart + position
-- A tiny block fits within the whole file but lies in its footer.
encoded = BL.toStrict (BB.toLazyByteString (BB.int64LE (fromIntegral footerStart :: Int64) <> BB.int32LE 8 <> BB.int32LE 0 <> BB.int64LE 0))
corrupted = BS.take patchPosition bytes <> encoded <> BS.drop (patchPosition + 24) bytes
case readFileIndex corrupted of
Left _ -> success
Right _ -> failure
prop_negativeMessageBody :: Property
prop_negativeMessageBody = withTests 1 $ property $ do
bytes <- evalIO (BS.readFile "test/golden/rs_file_alignment64.arrow")
index <- evalEither (readFileIndex bytes)
descriptor <- evalMaybe (fiRecordBatches index V.!? 0)
let metadataStart = fromIntegral (abOffset descriptor) + 8
metadata = BS.take (fromIntegral (abMetaLen descriptor) - 8) (BS.drop metadataStart bytes)
root <- evalEither (FB.peekU32 metadata 0)
slots <- evalEither (FB.resolveTable metadata (fromIntegral root))
bodyLengthSlot <- evalMaybe (slots 3)
let position = metadataStart + bodyLengthSlot
encoded = BL.toStrict (BB.toLazyByteString (BB.int64LE (-1)))
corrupted = BS.take position bytes <> encoded <> BS.drop (position + 8) bytes
rejected result = case result of Left _ -> True; Right _ -> False
assert (rejected (decodeArrowFile defaultReadOptions corrupted))
reader <- evalEither (openFileReader defaultReadOptions corrupted)
assert (rejected (fileReaderToList reader))
handled <- evalIO (withTempFile corrupted (drainFile defaultReadOptions))
assert (rejected handled)
-- * Fixtures
-- | A single-column schema and three batches of different lengths.
smallBatches :: (Schema, [V.Vector ColumnArray])
smallBatches =
( defaultSchema (V.fromList [defaultLeafField "a" False (AInt 32 True)])
,
[ V.singleton (primColumn PInt32 (VS.fromList [1, 2, 3]))
, V.singleton (primColumn PInt32 (VS.fromList [4]))
, V.singleton (primColumn PInt32 (VS.fromList [5, 6, 7, 8]))
]
)
fileBytesOf :: (Schema, [V.Vector ColumnArray]) -> Either String ByteString
fileBytesOf (sch, batches) = encodeArrowFile defaultWriteOptions sch batches
-- * Properties
{- | The pure footer-indexed reader and the eager decoder agree on
every batch, and the footer's block table names one block per record
batch and no dictionary blocks.
-}
prop_fileSectionsMatchEager :: Property
prop_fileSectionsMatchEager = withTests 1 . property $ do
let (sch, batches) = smallBatches
bytes <- evalEither (fileBytesOf (sch, batches))
(_, eager) <- evalEither (decodeArrowFile defaultReadOptions bytes)
length eager === length batches
idx <- evalEither (readFileIndex bytes)
V.length (fiRecordBatches idx) === length batches
V.null (fiDictBlocks idx) === True
rd <- evalEither (openFileReader defaultReadOptions bytes)
fileReaderSchema rd === sch
got <- evalEither (fileReaderToList rd)
got === eager
-- | The same parity against @test/golden/pa_file.arrow@, written by pyarrow.
prop_pyarrowFileSections :: Property
prop_pyarrowFileSections = withTests 1 . property $ do
bytes <- evalIO (BS.readFile "test/golden/pa_file.arrow")
(_, eager) <- evalEither (decodeArrowFile defaultReadOptions bytes)
idx <- evalEither (readFileIndex bytes)
V.length (fiRecordBatches idx) === length eager
rd <- evalEither (openFileReader defaultReadOptions bytes)
got <- evalEither (fileReaderToList rd)
got === eager
-- | 'fileBatchesHandle' over a temp file yields exactly the eager batches.
prop_fileBatchesHandle :: Property
prop_fileBatchesHandle = withTests 1 . property $ do
let (sch, batches) = smallBatches
bytes <- evalEither (fileBytesOf (sch, batches))
(_, eager) <- evalEither (decodeArrowFile defaultReadOptions bytes)
got <- evalIO (withTempFile bytes (drainFile defaultReadOptions))
got === Right eager
{- | 'streamBatchesHandle' over a temp stream yields exactly the eager
batches, including for a stream with dictionary batches (which the
reader must apply as it walks).
-}
prop_streamBatchesHandle :: Property
prop_streamBatchesHandle = withTests 1 . property $ do
let (sch, batches) = smallBatches
streamBytes <- evalEither (encodeArrowStream defaultWriteOptions sch batches)
(_, eager) <- evalEither (decodeArrowStream defaultReadOptions streamBytes)
got <- evalIO (withTempFile streamBytes (drainStream defaultReadOptions))
got === Right eager
dictBytes <- evalIO (BS.readFile "test/golden/pa_dict.arrows")
(_, eagerDicts) <- evalEither (decodeArrowStream defaultReadOptions dictBytes)
gotDicts <- evalIO (withTempFile dictBytes (drainStream defaultReadOptions))
gotDicts === Right eagerDicts
{- | Zeroing the last section's message header costs nothing before
its turn: the handle iterator hands over every earlier batch and only
then reports the broken section.
-}
prop_corruptSectionFailsLate :: Property
prop_corruptSectionFailsLate = withTests 1 . property $ do
let (sch, batches) = smallBatches
bytes <- evalEither (fileBytesOf (sch, batches))
(_, eager) <- evalEither (decodeArrowFile defaultReadOptions bytes)
idx <- evalEither (readFileIndex bytes)
let lastBlock = V.unsafeIndex (fiRecordBatches idx) (V.length (fiRecordBatches idx) - 1)
off = fromIntegral (abOffset lastBlock)
metaLen = fromIntegral (abMetaLen lastBlock)
broken = BS.take off bytes <> BS.replicate metaLen 0 <> BS.drop (off + metaLen) bytes
(tag, got) <- evalIO $ withTempFile broken $ \h -> do
eiter <- fileBatchesHandle defaultReadOptions h
case eiter of
Left e -> pure ("open failed: " ++ e, [])
Right it -> drainSteps it []
take (length eager - 1) got === take (length eager - 1) eager
length got === length eager - 1
True === ("error" `isPrefixOf` tag)
-- * Helpers
-- | Step an iterator to its end, collecting the batches it yields.
drainSteps :: IterIO (V.Vector ColumnArray) -> [V.Vector ColumnArray] -> IO (String, [V.Vector ColumnArray])
drainSteps it acc = do
estep <- iterIOStep it
case estep of
Left e -> pure ("error: " ++ e, reverse acc)
Right IterIODone -> pure ("done", reverse acc)
Right (IterIOYield batch next) -> drainSteps next (batch : acc)
-- | Write bytes to a temp file, position the handle at 0, run the action.
withTempFile :: ByteString -> (Handle -> IO a) -> IO a
withTempFile bs act =
bracket
(getTemporaryDirectory >>= \dir -> openBinaryTempFile dir "wf-arrow-sections.tmp")
(\(path, h) -> hClose h >> removeFile path)
(\(_path, h) -> BS.hPut h bs >> hSeek h AbsoluteSeek 0 >> act h)
-- | Drain a file iterator into the batches it yields.
drainFile :: ReadOptions -> Handle -> IO (Either String [V.Vector ColumnArray])
drainFile opts h = do
eiter <- fileBatchesHandle opts h
case eiter of
Left e -> pure (Left e)
Right it -> iterIOToList it
-- | Drain a stream iterator into the batches it yields.
drainStream :: ReadOptions -> Handle -> IO (Either String [V.Vector ColumnArray])
drainStream opts h = do
eiter <- streamBatchesHandle opts h
case eiter of
Left e -> pure (Left e)
Right it -> iterIOToList it