dataframe-parquet-1.6.0.0: src/DataFrame/IO/Parquet/Writer.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
module DataFrame.IO.Parquet.Writer (
writeParquet,
writeParquetWithOptions,
ParquetWriteOptions (..),
WriterStrategy (..),
defaultParquetWriteOptions,
nativeTypeKeyPrefix,
nativeTypeKeyValues,
) where
import Control.Exception (evaluate)
import Control.Monad (forM_, unless, when)
import Control.Monad.ST (RealWorld, ST, stToIO)
import qualified Data.ByteString as BS
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.Maybe (fromJust)
import Data.Primitive.ByteArray (getSizeofMutableByteArray)
import Data.STRef (STRef, modifySTRef', newSTRef, readSTRef, writeSTRef)
import qualified Data.Text as T
import qualified Data.Vector as VB
import DataFrame.IO.Parquet.Thrift hiding (schema)
import DataFrame.IO.Parquet.Writer.DefLevels (
DefLevels (..),
flushDef,
newDefLevels,
pushDef,
)
import DataFrame.IO.Parquet.Writer.Encoder (Encoder (..), buildEncoder)
import DataFrame.IO.Parquet.Writer.Metadata (
magic,
mkColumnChunk,
mkDataPageHeader,
mkRowGroup,
mkSchemaElem,
rootSchemaElement,
writeFooter,
)
import DataFrame.IO.Parquet.Writer.Options (
ParquetWriteOptions (..),
WriterStrategy (..),
defaultParquetWriteOptions,
)
import DataFrame.IO.Utils.RandomAccess (
MemoryBuffer (..),
WritableBinaryHandle,
atomicallyWriteFile,
bufferResidency,
ensureCapacity,
flushBufferToBuffer,
flushBufferToFile,
mallocBuffer,
resetPosition,
unsafeBufferView,
withWritableBinaryFile,
writeByteString,
writeByteStringToFile,
writeWord32LE,
)
import DataFrame.Internal.Column (Column, columnTypeString, hasMissing)
import DataFrame.Internal.DataFrame (
DataFrame,
columnNames,
dataframeDimensions,
getColumn,
)
import qualified Pinch
import qualified Snappy
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)
import Text.Printf (printf)
data ParquetWriterState = ParquetWriterState
{ outputFileHandle :: !WritableBinaryHandle
, columnChunks :: !(VB.Vector (ColumnChunkState RealWorld))
, currentFileOffsetRef :: !(IORef Int64)
, scratchBuffer :: !(MemoryBuffer RealWorld)
, rowGroupMetadataRef :: !(IORef [RowGroup])
, rowNumberRef :: !(IORef Int)
}
data ColumnChunkState s = ColumnChunkState
{ columnName :: !T.Text
, nullable :: !Bool
, schema :: !SchemaElement
, encoder :: !(Encoder s)
, buffer :: !(MemoryBuffer s)
, uncompressedBufferSize :: !(STRef s Int64)
, pageState :: !(PageState s)
}
data PageState s = PageState
{ pageBuffer :: !(MemoryBuffer s)
, definitionLevels :: !(DefLevels s)
, currentRowCount :: !(STRef s Int)
}
writeParquet :: FilePath -> DataFrame -> IO ()
writeParquet = writeParquetWithOptions defaultParquetWriteOptions
writeParquetWithOptions :: ParquetWriteOptions -> FilePath -> DataFrame -> IO ()
writeParquetWithOptions options path df = do
when (options.strategy == TwoPass) $
error
"The Two Pass Strategy for the Parquet Writer has not yet been implemented"
case options.compressionCodec of
UNCOMPRESSED _ -> pure ()
SNAPPY _ -> pure ()
other -> error ("writeParquet: unsupported codec " <> show other)
let (totalRows, _) = dataframeDimensions df
case options.maxRowsPerFile of
Nothing -> do
when (isShardPattern path) $
error
( "writeParquet: path "
<> show path
<> " contains a '*' placeholder but maxRowsPerFile is not set"
)
writeShard options path df 0 totalRows
Just rowsPerFile -> do
when (rowsPerFile <= 0) $
error "writeParquet: maxRowsPerFile must be positive"
unless (isShardPattern path) $
error
( "writeParquet: maxRowsPerFile requires a path with a '*' placeholder, got "
<> show path
)
let starts = case [0, rowsPerFile .. totalRows - 1] of
[] -> [0] -- empty frame still produces one (empty) shard
ss -> ss
forM_ (zip [0 ..] starts) $ \(shardIndex, start) -> do
let shardPath = shardPathFor path shardIndex
createDirectoryIfMissing True (takeDirectory shardPath)
writeShard options shardPath df start (min totalRows (start + rowsPerFile))
isShardPattern :: FilePath -> Bool
isShardPattern = elem '*'
-- | Replace every @*@ in the pattern with a zero-padded shard index.
shardPathFor :: FilePath -> Int -> FilePath
shardPathFor pattern_ shardIndex =
concatMap (\c -> if c == '*' then printf "%05d" shardIndex else [c]) pattern_
-- | Write rows @[startRow, endRow)@ of the frame to a single Parquet file.
writeShard ::
ParquetWriteOptions ->
FilePath ->
DataFrame ->
Int ->
Int ->
IO ()
writeShard options path_ df startRow endRow = do
let names = columnNames df
shardRows = max 0 (endRow - startRow)
columnChunks_ <-
stToIO $
VB.fromList
<$> mapM
( \columnName_ ->
initColumnChunkState
options
columnName_
(fromJust (getColumn columnName_ df))
)
names
scratchBuffer_ <- stToIO (mallocBuffer (max 1 options.pageSize))
atomicallyWriteFile path_ $ \path -> withWritableBinaryFile path $ \output -> do
writeByteStringToFile output magic
currentFileOffsetRef_ <- newIORef 4
rowGroupMetadataRef_ <- newIORef []
rowNumberRef_ <- newIORef 0
let writerState =
ParquetWriterState
output
columnChunks_
currentFileOffsetRef_
scratchBuffer_
rowGroupMetadataRef_
rowNumberRef_
interval = max 1 options.batchRows
subBatch = max 1 options.subBatchRows
writeBatch :: Int -> Int -> IO ()
writeBatch rowNum batchEnd
| rowNum >= batchEnd = pure ()
| otherwise = do
let count = min subBatch (batchEnd - rowNum)
VB.forM_ columnChunks_ $ \ccs -> do
pageFull <- stToIO (writeRows options rowNum count ccs)
when pageFull (flushPage options scratchBuffer_ ccs)
modifyIORef' rowNumberRef_ (+ count)
writeBatch (rowNum + count) batchEnd
loop :: Int -> IO ()
loop rowNum
| rowNum >= endRow = pure ()
| otherwise = do
let batchEnd = rowNum + min interval (endRow - rowNum)
writeBatch rowNum batchEnd
size <- stToIO (bufferedSize columnChunks_)
when (size >= options.rowGroupSize) $
flushRowGroup options writerState
loop batchEnd
loop startRow
flushRowGroup options writerState
rowGroupMetadata <- reverse <$> readIORef rowGroupMetadataRef_
let schemaElements =
rootSchemaElement (VB.length columnChunks_)
: VB.toList (VB.map schema columnChunks_)
writeFooter
output
schemaElements
shardRows
rowGroupMetadata
(nativeTypeKeyValues names df)
nativeTypeKeyPrefix :: T.Text
nativeTypeKeyPrefix = "dataframe.type."
-- | The type stamp for every column of @df@, as footer key-value pairs.
nativeTypeKeyValues :: [T.Text] -> DataFrame -> [(T.Text, T.Text)]
nativeTypeKeyValues names df =
[ (nativeTypeKeyPrefix <> name, T.pack (columnTypeString col))
| name <- names
, Just col <- [getColumn name df]
]
-- | Encode @count@ rows from @firstRow@ into the column's page.
writeRows ::
ParquetWriteOptions -> Int -> Int -> ColumnChunkState s -> ST s Bool
writeRows options firstRow count ccs = do
let page = ccs.pageState
buf = page.pageBuffer
encode = ccs.encoder.encodeValue
dl = page.definitionLevels
end = firstRow + count
pos0 <- readSTRef buf.positionRef
let margin = options.pageSize
arr0 <- ensureCapacity buf (pos0 + max margin (count * 64))
size0 <- getSizeofMutableByteArray arr0
let go !size !pos !row
| row >= end = writeSTRef buf.positionRef pos
| pos + margin > size = do
-- Rare: buffer nearly full, grow it
writeSTRef buf.positionRef pos
arr' <-
ensureCapacity
buf
(pos + max margin ((end - row) * 64))
size' <- getSizeofMutableByteArray arr'
go size' pos row
| otherwise = do
(pos', notNull) <- encode buf pos row
when ccs.nullable $
pushDef dl (if notNull then 1 else 0)
go size pos' (row + 1)
go size0 pos0 firstRow
-- Batch bookkeeping: once per sub-batch instead of per value
modifySTRef' page.currentRowCount (+ count)
flushDef dl
pageRes <- bufferResidency buf
defRes <- bufferResidency dl.dlBuf
pure (pageRes + defRes >= options.pageSize)
flushPage ::
ParquetWriteOptions ->
MemoryBuffer RealWorld ->
ColumnChunkState RealWorld ->
IO ()
flushPage options scratch columnChunkState = do
let page = columnChunkState.pageState
numPageRows <- stToIO (readSTRef page.currentRowCount)
when (numPageRows > 0) $ do
body <- stToIO $ do
pos <- readSTRef page.pageBuffer.positionRef
pos' <- columnChunkState.encoder.finishValues page.pageBuffer pos
writeSTRef page.pageBuffer.positionRef pos'
assemblePageBody scratch columnChunkState
writeDataPage options.compressionCodec numPageRows body columnChunkState
stToIO $ do
resetPosition page.pageBuffer
resetPosition page.definitionLevels.dlBuf
resetPosition scratch
writeSTRef page.currentRowCount 0
assemblePageBody ::
MemoryBuffer s -> ColumnChunkState s -> ST s (MemoryBuffer s)
assemblePageBody scratch columnChunkState
| not columnChunkState.nullable = pure columnChunkState.pageState.pageBuffer
| otherwise = do
let page = columnChunkState.pageState
flushDef page.definitionLevels
resetPosition scratch
defLevelsSize <- bufferResidency page.definitionLevels.dlBuf
writeWord32LE scratch (fromIntegral defLevelsSize)
flushBufferToBuffer page.definitionLevels.dlBuf scratch
flushBufferToBuffer page.pageBuffer scratch
pure scratch
writeDataPage ::
CompressionCodec ->
Int ->
MemoryBuffer RealWorld ->
ColumnChunkState RealWorld ->
IO ()
writeDataPage codec numPageRows body columnChunkState = do
uncompressedPageSize <- stToIO (bufferResidency body)
compressedBody <- case codec of
UNCOMPRESSED _ -> pure Nothing
SNAPPY _ -> do
-- Compress now: the page buffer is reused for the next page.
page <- unsafeBufferView body
Just <$> evaluate (Snappy.compress page)
other -> error ("writeParquet: unsupported codec " <> show other)
let compressedPageSize = maybe uncompressedPageSize BS.length compressedBody
headerBytes =
Pinch.encode
Pinch.compactProtocol
(mkDataPageHeader numPageRows uncompressedPageSize compressedPageSize)
writeByteString columnChunkState.buffer headerBytes
case compressedBody of
Nothing -> stToIO (flushBufferToBuffer body columnChunkState.buffer)
Just bytes -> writeByteString columnChunkState.buffer bytes
stToIO $
modifySTRef'
columnChunkState.uncompressedBufferSize
(+ fromIntegral (BS.length headerBytes + uncompressedPageSize))
flushRowGroup :: ParquetWriteOptions -> ParquetWriterState -> IO ()
flushRowGroup options writerState = do
rowNumber <- readIORef writerState.rowNumberRef
when (rowNumber > 0) $ do
VB.forM_
writerState.columnChunks
(flushPage options writerState.scratchBuffer)
(reversedColumnChunks, totalCompressed, totalUncompressed) <-
VB.foldM'
( \(acc, totalCompressedSize, totalUncompressedSize) columnChunkState -> do
offset <- readIORef writerState.currentFileOffsetRef
(compressedSize, uncompressedSize) <-
stToIO $
(,)
<$> bufferResidency columnChunkState.buffer
<*> readSTRef columnChunkState.uncompressedBufferSize
flushBufferToFile
writerState.outputFileHandle
columnChunkState.buffer
writeIORef
writerState.currentFileOffsetRef
(offset + fromIntegral compressedSize)
stToIO (writeSTRef columnChunkState.uncompressedBufferSize 0)
let columnChunk =
mkColumnChunk
options.compressionCodec
columnChunkState.encoder.encType
columnChunkState.columnName
offset
compressedSize
uncompressedSize
rowNumber
pure
( columnChunk : acc
, totalCompressedSize + fromIntegral compressedSize
, totalUncompressedSize + uncompressedSize
)
)
([], 0 :: Int64, 0 :: Int64)
writerState.columnChunks
modifyIORef'
writerState.rowGroupMetadataRef
( mkRowGroup
(reverse reversedColumnChunks)
totalCompressed
totalUncompressed
rowNumber
:
)
writeIORef writerState.rowNumberRef 0
bufferedSize :: VB.Vector (ColumnChunkState s) -> ST s Int
bufferedSize =
VB.foldM'
( \total columnChunkState -> do
chunkSize <- bufferResidency columnChunkState.buffer
valuesSize <-
bufferResidency columnChunkState.pageState.pageBuffer
defLevelsSize <-
bufferResidency
columnChunkState.pageState.definitionLevels.dlBuf
pure (total + chunkSize + valuesSize + defLevelsSize)
)
0
initColumnChunkState ::
ParquetWriteOptions -> T.Text -> Column -> ST s (ColumnChunkState s)
initColumnChunkState options columnName_ column = do
encoder_ <- buildEncoder column
let nullable_ = hasMissing column
schema_ =
mkSchemaElem
columnName_
encoder_.encType
nullable_
encoder_.convertedType
encoder_.logicalType
bufferSize = max 1 options.pageSize
-- Buffers start at page size and grow, as chunk sizes vary by encoding.
buffer_ <- mallocBuffer bufferSize
uncompressedBufferSize_ <- newSTRef 0
pageState_ <- initPageState bufferSize
pure
ColumnChunkState
{ columnName = columnName_
, nullable = nullable_
, schema = schema_
, encoder = encoder_
, buffer = buffer_
, uncompressedBufferSize = uncompressedBufferSize_
, pageState = pageState_
}
initPageState :: Int -> ST s (PageState s)
initPageState bufferSize = do
pageBuffer_ <- mallocBuffer bufferSize
definitionLevels_ <- newDefLevels
currentRowCount_ <- newSTRef 0
pure
PageState
{ pageBuffer = pageBuffer_
, definitionLevels = definitionLevels_
, currentRowCount = currentRowCount_
}