Skip to content

Commit 9f413e2

Browse files
committed
Parquet output: harden teardown, basket metadata, and tests
Signed-off-by: Arham Chopra <arham.chopra@cubistsystematic.com>
1 parent 0002b0d commit 9f413e2

7 files changed

Lines changed: 438 additions & 36 deletions

File tree

cpp/csp/adapters/parquet/ParquetDictBasketOutputWriter.cpp

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#include <csp/adapters/parquet/ParquetDictBasketOutputWriter.h>
22
#include <csp/adapters/parquet/ParquetOutputAdapter.h>
33
#include <arrow/record_batch.h>
4+
#include <exception>
45

56
namespace csp::adapters::parquet
67
{
@@ -19,7 +20,7 @@ void ParquetDictBasketOutputWriter::start()
1920
ParquetWriter::start();
2021
m_indexSchema = ::arrow::schema( { ::arrow::field(
2122
m_cycleIndexOutputAdapter -> getColumnArrayBuilder( 0 ) -> getColumnName(),
22-
m_cycleIndexOutputAdapter -> getColumnArrayBuilder( 0 ) -> getDataType() ) } );
23+
m_cycleIndexOutputAdapter -> getColumnArrayBuilder( 0 ) -> getDataType() ) }, getFileMetaData() );
2324
if( m_indexSink.onStart )
2425
m_indexSink.onStart( m_indexSchema );
2526
auto & fileName = m_adapterMgr.getFileName();
@@ -29,17 +30,31 @@ void ParquetDictBasketOutputWriter::start()
2930

3031
void ParquetDictBasketOutputWriter::stop()
3132
{
33+
// Exception-safe teardown: a failure writing/closing the index sink must not skip the value+symbol
34+
// writer's stop() (which flushes the final batch and writes its footer) -- otherwise an index-side
35+
// I/O error would leave the main basket data file unreadable. Attempt every step, rethrow first.
36+
std::exception_ptr firstError;
37+
auto guard = [&firstError]( auto && fn )
38+
{
39+
try { fn(); }
40+
catch( ... ) { if( !firstError ) firstError = std::current_exception(); }
41+
};
42+
3243
auto && indexBuilder = m_cycleIndexOutputAdapter -> getColumnArrayBuilder( 0 );
3344
if( indexBuilder -> length() > 0 && m_indexSink.onBatch )
34-
{
35-
auto arr = indexBuilder -> buildArray();
36-
auto rb = ::arrow::RecordBatch::Make( m_indexSchema, arr -> length(), { arr } );
37-
m_indexSink.onBatch( rb );
38-
}
45+
guard( [&]
46+
{
47+
auto arr = indexBuilder -> buildArray();
48+
auto rb = ::arrow::RecordBatch::Make( m_indexSchema, arr -> length(), { arr } );
49+
m_indexSink.onBatch( rb );
50+
} );
3951
if( m_indexSink.onStop )
40-
m_indexSink.onStop();
52+
guard( [&] { m_indexSink.onStop(); } );
4153

42-
ParquetWriter::stop();
54+
guard( [&] { ParquetWriter::stop(); } );
55+
56+
if( firstError )
57+
std::rethrow_exception( firstError );
4358
}
4459

4560
void ParquetDictBasketOutputWriter::writeValue( const std::string &valueKey, const TimeSeriesProvider *ts )
@@ -74,17 +89,32 @@ void ParquetDictBasketOutputWriter::onEndCycle()
7489

7590
void ParquetDictBasketOutputWriter::onFileNameChange( const std::string &fileName )
7691
{
77-
ParquetWriter::onFileNameChange( fileName );
92+
// Rotate the main (value+symbol) writer, then flush pending index data and rotate the index file.
93+
// Each step is guarded so a failure in one cannot leave the index file un-rotated and
94+
// desynchronised from the main file; the first error is rethrown after all steps are attempted.
95+
std::exception_ptr firstError;
96+
auto guard = [&firstError]( auto && fn )
97+
{
98+
try { fn(); }
99+
catch( ... ) { if( !firstError ) firstError = std::current_exception(); }
100+
};
101+
102+
guard( [&] { ParquetWriter::onFileNameChange( fileName ); } );
103+
78104
// Flush any pending index data
79105
auto && indexBuilder = m_cycleIndexOutputAdapter -> getColumnArrayBuilder( 0 );
80106
if( indexBuilder -> length() > 0 && m_indexSink.onBatch )
81-
{
82-
auto arr = indexBuilder -> buildArray();
83-
auto rb = ::arrow::RecordBatch::Make( m_indexSchema, arr -> length(), { arr } );
84-
m_indexSink.onBatch( rb );
85-
}
107+
guard( [&]
108+
{
109+
auto arr = indexBuilder -> buildArray();
110+
auto rb = ::arrow::RecordBatch::Make( m_indexSchema, arr -> length(), { arr } );
111+
m_indexSink.onBatch( rb );
112+
} );
86113
if( m_indexSink.onFileChange )
87-
m_indexSink.onFileChange( fileName );
114+
guard( [&] { m_indexSink.onFileChange( fileName ); } );
115+
116+
if( firstError )
117+
std::rethrow_exception( firstError );
88118
}
89119

90120
SingleColumnParquetOutputHandler *ParquetDictBasketOutputWriter::createScalarOutputHandler( CspTypePtr type, const std::string &name )

cpp/csp/adapters/parquet/ParquetOutputAdapterManager.cpp

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <csp/adapters/parquet/ParquetDictBasketOutputWriter.h>
55
#include <csp/adapters/parquet/ParquetWriter.h>
66
#include <csp/engine/Dictionary.h>
7+
#include <exception>
78

89
namespace csp::adapters::parquet
910
{
@@ -33,15 +34,26 @@ void ParquetOutputAdapterManager::start( DateTime starttime, DateTime endtime )
3334

3435
void ParquetOutputAdapterManager::stop()
3536
{
36-
// Stop all writers (flush + close their files) before destroying any of them, so a basket
37-
// writer can never observe a destroyed sibling during teardown.
38-
m_parquetWriter -> stop();
37+
// Stop every writer (flush + close its files) before destroying any of them, so a basket writer
38+
// can never observe a destroyed sibling during teardown. A failure stopping one writer must not
39+
// skip the others (that would leave their files without footers / leak fds): attempt them all and
40+
// rethrow the first error.
41+
std::exception_ptr firstError;
42+
auto stopWriter = [&firstError]( auto *writer )
43+
{
44+
try { writer -> stop(); }
45+
catch( ... ) { if( !firstError ) firstError = std::current_exception(); }
46+
};
47+
if( m_parquetWriter )
48+
stopWriter( m_parquetWriter.get() );
3949
for( auto &&writer:m_dictBasketWriters )
4050
{
41-
writer -> stop();
51+
stopWriter( writer.get() );
4252
}
4353
m_parquetWriter = nullptr;
4454
m_dictBasketWriters.clear();
55+
if( firstError )
56+
std::rethrow_exception( firstError );
4557
}
4658

4759
DateTime ParquetOutputAdapterManager::processNextSimTimeSlice( DateTime time )
@@ -86,6 +98,10 @@ ParquetOutputAdapterManager::createDictOutputBasketWriter( const char *columnNam
8698

8799
// Provide data sink via factory if available
88100
auto * writer = m_dictBasketWriters.back().get();
101+
// Share the main writer's file_metadata, and claim any column_metadata the user attached to this
102+
// basket's columns, so the basket files carry that metadata and the main writer does not later
103+
// reject those keys as unmapped columns.
104+
writer -> adoptMetadataFrom( *m_parquetWriter );
89105
if( m_sinkFactory )
90106
{
91107
writer -> setSink( m_sinkFactory( columnName ) );

cpp/csp/adapters/parquet/ParquetWriter.cpp

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#include <arrow/array.h>
66
#include <arrow/builder.h>
77
#include <arrow/util/key_value_metadata.h>
8+
#include <exception>
89

910
namespace csp::adapters::parquet
1011
{
@@ -175,15 +176,51 @@ void ParquetWriter::start()
175176

176177
void ParquetWriter::stop()
177178
{
179+
// Exception-safe teardown: a failure flushing the final batch must not skip onStop() (which
180+
// writes the file footer/closes the stream), and a failure in onStop() must not be masked.
181+
// Attempt both and rethrow the first error.
182+
std::exception_ptr firstError;
178183
if( m_curChunkSize > 0 )
179-
flushBatch();
184+
{
185+
try { flushBatch(); }
186+
catch( ... ) { firstError = std::current_exception(); }
187+
}
180188
if( m_sink.onStop )
181-
m_sink.onStop();
189+
{
190+
try { m_sink.onStop(); }
191+
catch( ... ) { if( !firstError ) firstError = std::current_exception(); }
192+
}
182193
m_fileOpen = false;
194+
if( firstError )
195+
std::rethrow_exception( firstError );
196+
}
197+
198+
void ParquetWriter::adoptMetadataFrom( ParquetWriter & source )
199+
{
200+
// Used by dict-basket writers: take the main writer's file-level metadata, and move any column
201+
// metadata the user attached to one of THIS writer's columns out of the source writer (so the
202+
// basket files carry it and the main writer no longer rejects those keys as "unmapped").
203+
if( !m_fileMetaData )
204+
m_fileMetaData = source.m_fileMetaData;
205+
for( auto && columnName : m_publishedColumnNames )
206+
{
207+
auto it = source.m_columnMetaData.find( columnName );
208+
if( it != source.m_columnMetaData.end() )
209+
{
210+
m_columnMetaData[ columnName ] = it -> second;
211+
source.m_columnMetaData.erase( it );
212+
}
213+
}
183214
}
184215

185216
void ParquetWriter::onEndCycle()
186217
{
218+
// NOTE: when no file is open -- e.g. a filename_provider ticked "" to "pause" output -- this body is
219+
// skipped, so handleRowFinished() does NOT run and each scratch column's isSet bit is never cleared
220+
// for that cycle. If a column ticks *while paused* and then does NOT tick on the cycle the file
221+
// reopens, the stale paused-window value is emitted instead of null (the dropped tick "leaks" into
222+
// the first row after resume). This matches the previous writer's behaviour. If dropping ticks while
223+
// paused should instead null them out, clear each builder's scratch/isSet here in the else path.
187224
if( isFileOpen() ) [[likely]]
188225
{
189226
DateTime now;
@@ -236,8 +273,11 @@ std::shared_ptr<::arrow::RecordBatch> ParquetWriter::buildRecordBatch()
236273
{
237274
auto column = builder -> buildArray();
238275
// Every column must contain exactly the rows accumulated this chunk; a mismatch would build a
239-
// corrupt RecordBatch (debug-only guard, compiled out in release).
240-
CSP_ASSERT( column -> length() == static_cast<std::int64_t>( m_curChunkSize ) );
276+
// corrupt RecordBatch. Enforced at runtime (not just in debug) so a future column-builder bug
277+
// can never silently produce a malformed parquet/arrow file.
278+
CSP_TRUE_OR_THROW_RUNTIME( column -> length() == static_cast<std::int64_t>( m_curChunkSize ),
279+
"ParquetWriter column '" << builder -> getColumnName() << "' produced " << column -> length()
280+
<< " rows but the current chunk has " << m_curChunkSize );
241281
columns.push_back( std::move( column ) );
242282
}
243283
return ::arrow::RecordBatch::Make( m_schema, m_curChunkSize, std::move( columns ) );

cpp/csp/adapters/parquet/ParquetWriter.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ class ParquetWriter : public EndCycleListener
3636

3737
void setSink( RecordBatchSink sink ) { m_sink = std::move( sink ); }
3838

39+
// Dict-basket writers adopt the main writer's file-level metadata and any column metadata that
40+
// targets their own columns, so basket files carry the same metadata and the main writer does not
41+
// reject those keys as "unmapped".
42+
const std::shared_ptr<::arrow::KeyValueMetadata> & getFileMetaData() const { return m_fileMetaData; }
43+
void adoptMetadataFrom( ParquetWriter & source );
44+
3945
SingleColumnParquetOutputAdapter *getScalarOutputAdapter( CspTypePtr &type, const std::string &columnName );
4046
StructParquetOutputAdapter *getStructOutputAdapter( CspTypePtr &type, csp::DictionaryPtr fieldMap );
4147
ListColumnParquetOutputAdapter *getListOutputAdapter( CspTypePtr &elemType, const std::string &columnName );

cpp/csp/adapters/parquet/RecordBatchFileSink.cpp

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
#include <arrow/table.h>
99
#include <arrow/type.h>
1010
#include <arrow/util/compression.h>
11-
#include <arrow/util/config.h>
1211
#include <parquet/arrow/writer.h>
1312

1413
#include <algorithm>
@@ -77,11 +76,10 @@ FileWriter makeParquetFileWriter( const std::string & path, const std::shared_pt
7776

7877
::parquet::WriterProperties::Builder props;
7978
props.compression( resolveCompression( compression ) );
80-
#if ARROW_VERSION_MAJOR >= 20
79+
// csp builds against libparquet >= 16.0.0, where PARQUET_2_6 (the modern default, available since
80+
// Arrow 6.0) is always present, so there is no need to gate on the Arrow version or fall back to
81+
// the deprecated PARQUET_2_0.
8182
props.version( ::parquet::ParquetVersion::PARQUET_2_6 );
82-
#else
83-
props.version( ::parquet::ParquetVersion::PARQUET_2_0 );
84-
#endif
8583
::parquet::ArrowWriterProperties::Builder arrowProps;
8684
arrowProps.store_schema(); // preserve arrow (file/column) schema metadata in the parquet file
8785

@@ -128,9 +126,13 @@ FileWriter makeIpcFileWriter( const std::string & path, const std::shared_ptr<::
128126
std::shared_ptr<::arrow::ipc::RecordBatchWriter> writer = result.MoveValueUnsafe();
129127

130128
return FileWriter{
131-
[writer]( const std::shared_ptr<::arrow::RecordBatch> & rb )
129+
[writer, compression]( const std::shared_ptr<::arrow::RecordBatch> & rb )
132130
{
133-
STATUS_OK_OR_THROW_RUNTIME( writer -> WriteRecordBatch( *rb ), "Failed to write arrow record batch" );
131+
// Arrow validates which codecs IPC allows here and appends its own reason (e.g. "Only
132+
// LZ4_FRAME and ZSTD compression allowed for IPC"); surface which codec was requested
133+
// rather than a bare "failed to write". The allowed set is Arrow's to define, not ours.
134+
STATUS_OK_OR_THROW_RUNTIME( writer -> WriteRecordBatch( *rb ),
135+
"Failed to write Arrow IPC record batch with compression '" << compression << "'" );
134136
},
135137
[writer, stream]()
136138
{
@@ -154,11 +156,26 @@ FileWriter makeSplitWriter( const std::string & dir, const std::shared_ptr<::arr
154156
subWriters -> reserve( schema -> num_fields() );
155157
colSchemas -> reserve( schema -> num_fields() );
156158

157-
for( int i = 0; i < schema -> num_fields(); ++i )
159+
try
160+
{
161+
for( int i = 0; i < schema -> num_fields(); ++i )
162+
{
163+
auto colSchema = ::arrow::schema( { schema -> field( i ) }, schema -> metadata() );
164+
colSchemas -> push_back( colSchema );
165+
subWriters -> push_back( perColumn( dir + "/" + schema -> field( i ) -> name() + extension, colSchema ) );
166+
}
167+
}
168+
catch( ... )
158169
{
159-
auto colSchema = ::arrow::schema( { schema -> field( i ) }, schema -> metadata() );
160-
colSchemas -> push_back( colSchema );
161-
subWriters -> push_back( perColumn( dir + "/" + schema -> field( i ) -> name() + extension, colSchema ) );
170+
// A later column failed to open (e.g. allow_overwrite=false and that file already exists).
171+
// Close the sub-writers already opened so we don't leak file descriptors or leave half-open
172+
// files behind, then rethrow the original error.
173+
for( auto & w : *subWriters )
174+
{
175+
try { w.close(); }
176+
catch( ... ) {}
177+
}
178+
throw;
162179
}
163180

164181
return FileWriter{
@@ -230,8 +247,12 @@ RecordBatchSink makeFileSink( bool writeArrowBinary, bool splitColumns,
230247
sink.onStart = [schemaHolder]( const std::shared_ptr<::arrow::Schema> & schema ) { *schemaHolder = schema; };
231248
sink.onBatch = [current]( const std::shared_ptr<::arrow::RecordBatch> & rb )
232249
{
233-
if( current -> has_value() )
234-
( *current ) -> writeBatch( rb );
250+
// A batch must only ever arrive while a file is open (the writer guards this via isFileOpen()
251+
// before flushing). If the writer and sink ever desync, fail loudly instead of silently
252+
// dropping the batch's rows.
253+
CSP_TRUE_OR_THROW_RUNTIME( current -> has_value(),
254+
"RecordBatchFileSink received a record batch with no open output file" );
255+
( *current ) -> writeBatch( rb );
235256
};
236257
sink.onFileChange = [factory, schemaHolder, current, currentPath, closeCurrent]( const std::string & path )
237258
{

csp/adapters/output_adapters/parquet.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def __init__(
7777
and at shutdown). It runs synchronously on the engine thread with the GIL held, so a slow visitor (e.g.
7878
uploading to remote storage) blocks the engine for its duration -- offload heavy work to a background
7979
queue/thread if that matters. An exception raised by the visitor propagates out of csp.run.
80+
Note: file_visitor is NOT invoked for dict-basket files (the value/symbol/index files written by
81+
publish_dict_basket); it only covers the main writer's files (and any regular published columns).
8082
"""
8183
super().__init__()
8284
config = ParquetOutputConfig() if config is None else config.copy()

0 commit comments

Comments
 (0)