dataframe-fastcsv-1.1.1.0: tests/Operations/ChunkParallel.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{- | Round-2 (WS-E2) behavior pins for chunk-parallel extraction: a chunked
read must be indistinguishable from the sequential one — same column types
(promotion resolved across chunks), same values, same errors.
-}
module Operations.ChunkParallel (tests) where
import qualified Data.Map as M
import qualified Data.Proxy as P
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Control.Exception (ErrorCall, try)
import Data.Time (Day)
import Data.Word (Word8)
import DataFrame.IO.CSV (
ReadOptions (..),
TypeSpec (..),
defaultReadOptions,
)
import qualified DataFrame.IO.CSV.Fast as D
import qualified DataFrame.Internal.Column as DI
import DataFrame.Internal.DataFrame (DataFrame, getColumn)
import DataFrame.Internal.Schema (Schema (..), SchemaType (..), elements)
import DataFrame.Operations.Typing (SafeReadMode (..))
import Test.HUnit
comma :: Word8
comma = 0x2C
-- | Read @body@ with a forced chunk count (bypasses the size/caps gate).
readChunked :: Int -> ReadOptions -> T.Text -> IO DataFrame
readChunked n opts body =
D.readSeparatedFromBytesChunks n comma opts (TE.encodeUtf8 body)
readSequential :: ReadOptions -> T.Text -> IO DataFrame
readSequential opts body =
D.readSeparatedFromBytes comma opts (TE.encodeUtf8 body)
-- | Assert chunked == sequential for several chunk counts.
assertChunkInvariant :: String -> ReadOptions -> T.Text -> Assertion
assertChunkInvariant what opts body = do
seqDf <- readSequential opts body
mapM_
( \n -> do
parDf <- readChunked n opts body
assertEqual (what <> " (chunks=" <> show n <> ")") seqDf parDf
)
[2, 3, 5, 8]
expectColumn :: String -> T.Text -> DataFrame -> DI.Column -> Assertion
expectColumn what name df expected = case getColumn name df of
Nothing -> assertFailure (what <> ": column missing")
Just col -> assertEqual what expected col
-- A 200-row mixed dataset: nullable ints, doubles, quoted text with
-- embedded separators/newlines/doubled quotes, bools, nullable dates.
mixedBody :: T.Text
mixedBody = "i,d,t,b,dt\n" <> T.concat (map row [0 .. 199 :: Int])
where
row k =
T.intercalate
","
[ if k `mod` 7 == 3 then "" else T.pack (show k)
, T.pack (show (fromIntegral k * 0.25 :: Double))
, textCell k
, if even k then "True" else "false"
, if k `mod` 11 == 5
then ""
else "2024-01-" <> T.justifyRight 2 '0' (T.pack (show (1 + k `mod` 28)))
]
<> "\n"
textCell k = case k `mod` 5 of
0 -> "\"a,b" <> T.pack (show k) <> "\""
1 -> "\"line\nbreak" <> T.pack (show k) <> "\""
2 -> "\"quote\"\"inside" <> T.pack (show k) <> "\""
3 -> ""
_ -> "plain" <> T.pack (show k)
testParallelMatchesSequentialMixed :: Test
testParallelMatchesSequentialMixed =
TestLabel "par_matches_seq_mixed" . TestCase $
assertChunkInvariant "mixed nulls+quoted" defaultReadOptions mixedBody
-- A Double cell far past the sample, in the last chunk: every chunk that
-- resolved Int must be re-parsed as Double by the merger.
testPromotionAcrossChunks :: Test
testPromotionAcrossChunks = TestLabel "par_promotion_int_double" . TestCase $ do
let body =
"a\n"
<> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 189 :: Int]]
<> "190.5\n"
assertChunkInvariant "late double promotes ints" defaultReadOptions body
df <- readChunked 4 defaultReadOptions body
expectColumn
"merged column is Double"
"a"
df
(DI.fromList @Double ([fromIntegral k | k <- [0 .. 189 :: Int]] <> [190.5]))
-- A Bool cell in the last chunk of an Int column: candidate escalates past
-- Double (the bool cell fails it) all the way to Text.
testDemotionToTextAcrossChunks :: Test
testDemotionToTextAcrossChunks = TestLabel "par_demotion_text" . TestCase $ do
let body =
"a\n"
<> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 189 :: Int]]
<> "True\n"
assertChunkInvariant "late bool demotes ints to Text" defaultReadOptions body
df <- readChunked 4 defaultReadOptions body
expectColumn
"merged column is Text"
"a"
df
(DI.fromList @T.Text ([T.pack (show k) | k <- [0 .. 189 :: Int]] <> ["True"]))
-- An all-null column must merge to the same all-null Maybe Text column.
testAllNullColumnAcrossChunks :: Test
testAllNullColumnAcrossChunks = TestLabel "par_all_null" . TestCase $ do
let body =
"a,b\n" <> T.concat [",x" <> T.pack (show k) <> "\n" | k <- [0 .. 199 :: Int]]
assertChunkInvariant "all-null column" defaultReadOptions body
df <- readChunked 4 defaultReadOptions body
expectColumn
"all-null column is Maybe Text"
"a"
df
(DI.fromList @(Maybe T.Text) (replicate 200 Nothing))
-- All-null sample (NoAssumption), data only in the last chunk: all-null
-- chunks must be re-parsed at the type the data chunk resolved.
testLateDataAfterAllNullSample :: Test
testLateDataAfterAllNullSample = TestLabel "par_late_bool" . TestCase $ do
let cell k
| k < 150 = ""
| even k = "True"
| otherwise = "false"
body =
"a,b\n"
<> T.concat [cell k <> ",x\n" | k <- [0 .. 199 :: Int]]
assertChunkInvariant "late bools after null sample" defaultReadOptions body
df <- readChunked 4 defaultReadOptions body
expectColumn
"column is Maybe Bool"
"a"
df
( DI.fromList @(Maybe Bool)
[ if k < 150 then Nothing else Just (even k)
| k <- [0 .. 199 :: Int]
]
)
-- Schema + NoSafeRead failure in a late chunk: same error, same row index
-- as the sequential read.
testSchemaErrorSameRowAsSequential :: Test
testSchemaErrorSameRowAsSequential = TestLabel "par_schema_error" . TestCase $ do
let body =
"a\n"
<> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 179 :: Int]]
<> "boom\n"
<> T.concat [T.pack (show k) <> "\n" | k <- [181 .. 199 :: Int]]
opts =
defaultReadOptions
{ typeSpec =
SpecifyTypes
(M.toList (elements (Schema (M.fromList [("a", SType (P.Proxy @Int))]))))
(typeSpec defaultReadOptions)
}
seqErr <- try @ErrorCall (readSequential opts body)
parErr <- try @ErrorCall (readChunked 4 opts body)
case (seqErr, parErr) of
(Left e1, Left e2) -> assertEqual "same schema error" (show e1) (show e2)
_ -> assertFailure "both reads should raise on the unparseable schema cell"
-- MaybeRead missing tokens with chunked extraction.
testMaybeReadAcrossChunks :: Test
testMaybeReadAcrossChunks = TestLabel "par_mayberead" . TestCase $ do
let cell k = if k `mod` 13 == 7 then "NA" else T.pack (show k)
body = "a\n" <> T.concat [cell k <> "\n" | k <- [0 .. 199 :: Int]]
opts = defaultReadOptions{safeRead = MaybeRead}
assertChunkInvariant "MaybeRead missing tokens" opts body
df <- readChunked 4 opts body
expectColumn
"column is Maybe Int"
"a"
df
( DI.fromList @(Maybe Int)
[ if k `mod` 13 == 7 then Nothing else Just k
| k <- [0 .. 199 :: Int]
]
)
-- Ragged rows (PadWithNull default) must behave identically chunked.
testRaggedRowsAcrossChunks :: Test
testRaggedRowsAcrossChunks = TestLabel "par_ragged" . TestCase $ do
let row k
| k `mod` 9 == 4 = T.pack (show k) <> "\n"
| k `mod` 9 == 8 = T.pack (show k) <> ",x,extra\n"
| otherwise = T.pack (show k) <> ",x" <> T.pack (show k) <> "\n"
body = "a,b\n" <> T.concat (map row [0 .. 199 :: Int])
assertChunkInvariant "ragged rows pad with null" defaultReadOptions body
-- Date column with nulls: boxed chunk columns must splice correctly.
testDateNullsAcrossChunks :: Test
testDateNullsAcrossChunks = TestLabel "par_date_nulls" . TestCase $ do
-- A second column keeps null rows from looking like blank lines
-- (blank lines are skipped by documented behavior).
let cell k =
if k `mod` 17 == 9
then ""
else "2023-05-" <> T.justifyRight 2 '0' (T.pack (show (1 + k `mod` 28)))
body = "a,b\n" <> T.concat [cell k <> ",x\n" | k <- [0 .. 199 :: Int]]
assertChunkInvariant "nullable dates" defaultReadOptions body
df <- readChunked 4 defaultReadOptions body
expectColumn
"column is Maybe Day"
"a"
df
( DI.fromList @(Maybe Day)
[ if k `mod` 17 == 9
then Nothing
else Just (read ("2023-05-" <> pad (1 + k `mod` 28)))
| k <- [0 .. 199 :: Int]
]
)
where
pad n = if n < 10 then '0' : show n else show n
-- An unclosed quote must still raise under the chunked (parallel) scan.
testUnclosedQuoteAcrossChunks :: Test
testUnclosedQuoteAcrossChunks = TestLabel "par_unclosed_quote" . TestCase $ do
let body =
"a\n"
<> T.concat [T.pack (show k) <> "\n" | k <- [0 .. 199 :: Int]]
<> "\"dangling"
r <- try @D.CsvParseError (readChunked 4 defaultReadOptions body)
case r of
Left D.CsvUnclosedQuote -> pure ()
other -> assertFailure ("expected CsvUnclosedQuote, got " <> show other)
-- WS-E2 ingest pin: the parallel byte-level merge wraps the merged shared
-- buffer as 'PackedText' (no Text spine), so the parallel path is also an
-- RSS win. Values stay identical to the sequential read.
testParallelTextFreezesPacked :: Test
testParallelTextFreezesPacked = TestLabel "par_text_freezes_packed" . TestCase $ do
let body =
"a,b\n"
<> T.concat
[ "row" <> T.pack (show k) <> ",t" <> T.pack (show k) <> "\n"
| k <- [0 .. 199 :: Int]
]
df <- readChunked 4 defaultReadOptions body
case getColumn "b" df of
Nothing -> assertFailure "missing text column"
Just col -> do
assertBool "parallel text column is PackedText" (DI.isPackedText col)
assertEqual
"parallel text values byte-identical"
(DI.fromList @T.Text ["t" <> T.pack (show k) | k <- [0 .. 199 :: Int]])
col
tests :: [Test]
tests =
[ testParallelMatchesSequentialMixed
, testPromotionAcrossChunks
, testDemotionToTextAcrossChunks
, testAllNullColumnAcrossChunks
, testLateDataAfterAllNullSample
, testSchemaErrorSameRowAsSequential
, testMaybeReadAcrossChunks
, testRaggedRowsAcrossChunks
, testDateNullsAcrossChunks
, testUnclosedQuoteAcrossChunks
, testParallelTextFreezesPacked
]