Skip to content

Commit 0d74468

Browse files
committed
fix(format): address second-round review (flush serialization, pool, validation, CI deps)
- Serialize host OutputStream access between the Vortex writer task and a caller-thread Flush(): VortexOutputContext gains a stream mutex and a FlushStream() entry, and VortexFormatWriter::Flush() plus the write/flush callbacks all take it, so Write and Flush can no longer interleave on a stream with no concurrent-access contract. The remaining internally buffered data is drained when the sink closes in Finish(). - Thread the caller's Arrow memory pool into VortexFormatWriter (Create now takes it and the builder passes GetArrowPool(pool_)), so sliced-batch rebasing allocates from the Paimon pool instead of Arrow's default pool. - Validate Vortex-incompatible schema types wherever Vortex can be selected (default file format, file.format-per-level, changelog-file.format), mirroring the Lance validation, instead of only the default format. - setup_flatc.sh: install libclang before the flatc early-exit, so an environment that already has a matching flatc but lacks libclang no longer silently skips the bindgen build dependency. - Tests: FlushBetweenBatchesThenRead (flush interleaved with background writes) and SchemaValidationTest.TestVortexDataTypes (per-level/changelog validation).
1 parent 0b5fdff commit 0d74468

9 files changed

Lines changed: 151 additions & 39 deletions

‎ci/scripts/setup_flatc.sh‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ set -eux
3636
FLATBUFFERS_VERSION=${FLATBUFFERS_VERSION:-25.12.19}
3737
FLATC_INSTALL_DIR=${FLATC_INSTALL_DIR:-"${HOME}/.local/bin"}
3838

39+
# Vortex's native dependencies invoke bindgen in their build scripts, which loads libclang at
40+
# build time. Images that only carry GCC (e.g. the gcc8 test container) may lack it.
41+
if ! ldconfig -p 2>/dev/null | grep -q 'libclang'; then
42+
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
43+
apt_prefix="sudo"
44+
else
45+
apt_prefix=""
46+
fi
47+
${apt_prefix} apt-get update -y
48+
${apt_prefix} apt-get install -y libclang-dev
49+
fi
50+
3951
# Skip when a matching flatc is already available.
4052
if command -v flatc >/dev/null 2>&1 &&
4153
[[ "$(flatc --version 2>/dev/null | awk '{print $NF}')" == "${FLATBUFFERS_VERSION}" ]]; then
@@ -61,16 +73,4 @@ export PATH="${FLATC_INSTALL_DIR}:${PATH}"
6173
echo "${FLATC_INSTALL_DIR}" >>"${GITHUB_PATH:-/dev/null}" || true
6274
echo "FLATC=${FLATC_INSTALL_DIR}/flatc" >>"${GITHUB_ENV:-/dev/null}" || true
6375

64-
# Vortex's native dependencies invoke bindgen in their build scripts, which loads libclang at
65-
# build time. Images that only carry GCC (e.g. the gcc8 test container) may lack it.
66-
if ! ldconfig -p 2>/dev/null | grep -q 'libclang'; then
67-
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
68-
apt_prefix="sudo"
69-
else
70-
apt_prefix=""
71-
fi
72-
${apt_prefix} apt-get update -y
73-
${apt_prefix} apt-get install -y libclang-dev
74-
fi
75-
7676
"${FLATC_INSTALL_DIR}/flatc" --version

‎src/paimon/core/schema/schema_validation.cpp‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -935,21 +935,35 @@ Status SchemaValidation::ValidateVortexDataField(const std::shared_ptr<arrow::Fi
935935

936936
Status SchemaValidation::ValidateVortexDataFields(const TableSchema& schema,
937937
const CoreOptions& options) {
938-
if (StringUtils::ToLowerCase(options.GetFileFormat()->Identifier()) != "vortex") {
939-
return Status::OK();
940-
}
941-
942938
const std::vector<std::string> inline_blob_fields = options.GetBlobInlineFields();
943939
const std::set<std::string> inline_blob_field_set(inline_blob_fields.begin(),
944940
inline_blob_fields.end());
945-
// Mirror the Mosaic path: only validate fields stored in the normal data file. A non-inline
946-
// BLOB lives in a separate blob file, so it is skipped here; an inline BLOB is rejected.
947-
for (const DataField& field : schema.Fields()) {
948-
if (BlobUtils::IsBlobField(field.ArrowField()) &&
949-
inline_blob_field_set.count(field.Name()) == 0) {
950-
continue;
941+
// Mirror the Lance path: Vortex can be selected not only as the default file format but also
942+
// per level or for the changelog, so validate the schema against every place it can be chosen.
943+
auto validate_format = [&](const std::string&, const std::string& file_format) -> Status {
944+
if (!StringUtils::EqualsIgnoreCase(file_format, "vortex")) {
945+
return Status::OK();
946+
}
947+
// Mirror the Mosaic path: only validate fields stored in the normal data file. A non-inline
948+
// BLOB lives in a separate blob file, so it is skipped here; an inline BLOB is rejected.
949+
for (const DataField& field : schema.Fields()) {
950+
if (BlobUtils::IsBlobField(field.ArrowField()) &&
951+
inline_blob_field_set.count(field.Name()) == 0) {
952+
continue;
953+
}
954+
PAIMON_RETURN_NOT_OK(ValidateVortexDataField(field.ArrowField()));
951955
}
952-
PAIMON_RETURN_NOT_OK(ValidateVortexDataField(field.ArrowField()));
956+
return Status::OK();
957+
};
958+
959+
PAIMON_RETURN_NOT_OK(
960+
validate_format(Options::FILE_FORMAT, options.GetFileFormat()->Identifier()));
961+
PAIMON_RETURN_NOT_OK(
962+
ValidatePerLevelOption(options.ToMap(), Options::FILE_FORMAT_PER_LEVEL, validate_format));
963+
std::shared_ptr<FileFormat> changelog_format = options.GetChangelogFileFormat();
964+
if (changelog_format) {
965+
PAIMON_RETURN_NOT_OK(
966+
validate_format(Options::CHANGELOG_FILE_FORMAT, changelog_format->Identifier()));
953967
}
954968
return Status::OK();
955969
}

‎src/paimon/core/schema/schema_validation_test.cpp‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,37 @@ TEST(SchemaValidationTest, TestLanceDataTypes) {
332332
}
333333
#endif
334334

335+
#ifdef PAIMON_ENABLE_VORTEX
336+
TEST(SchemaValidationTest, TestVortexDataTypes) {
337+
std::shared_ptr<TableSchema> table_schema;
338+
// Vortex as the default file format: unsupported types are rejected at validation time.
339+
std::map<std::string, std::string> vortex_options = {{Options::BUCKET, "-1"},
340+
{Options::FILE_FORMAT, "vortex"}};
341+
ASSERT_OK_AND_ASSIGN(
342+
table_schema,
343+
TableSchema::Create(
344+
/*schema_id=*/0,
345+
arrow::schema({arrow::field("map", arrow::map(arrow::int32(), arrow::utf8()))}),
346+
/*partition_keys=*/{}, /*primary_keys=*/{}, vortex_options));
347+
ASSERT_NOK_WITH_MSG(SchemaValidation::ValidateTableSchema(*table_schema), "type MAP");
348+
349+
// Vortex selected per level or for the changelog must be validated too, not only the default.
350+
for (const auto& [option_key, option_value] : std::vector<std::pair<std::string, std::string>>{
351+
{Options::FILE_FORMAT_PER_LEVEL, "1:vortex"},
352+
{Options::CHANGELOG_FILE_FORMAT, "vortex"}}) {
353+
std::map<std::string, std::string> alternate_format_options = {
354+
{Options::BUCKET, "-1"}, {Options::FILE_FORMAT, "parquet"}, {option_key, option_value}};
355+
ASSERT_OK_AND_ASSIGN(
356+
table_schema,
357+
TableSchema::Create(
358+
/*schema_id=*/0,
359+
arrow::schema({arrow::field("map", arrow::map(arrow::int32(), arrow::utf8()))}),
360+
/*partition_keys=*/{}, /*primary_keys=*/{}, alternate_format_options));
361+
ASSERT_NOK_WITH_MSG(SchemaValidation::ValidateTableSchema(*table_schema), "type MAP");
362+
}
363+
}
364+
#endif
365+
335366
TEST(SchemaValidationTest, TestRowTracking) {
336367
auto f0 = arrow::field("f0", arrow::utf8());
337368
auto f1 = arrow::field("f1", arrow::int32());

‎src/paimon/format/vortex/vortex_file_format_test.cpp‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,40 @@ TEST_F(VortexFileFormatTest, WriteThenReadSlicedStructColumn) {
387387
AssertReadWithBatchSizes(path, schema, expected, {1, 2, 4});
388388
}
389389

390+
// Regression: Flush() between batches must be safe (it is serialized with the background writer
391+
// task's writes on the host OutputStream) and must not lose or reorder any data.
392+
TEST_F(VortexFileFormatTest, FlushBetweenBatchesThenRead) {
393+
std::string path = PathUtil::JoinPath(directory_->Str(), "flush-between.vortex");
394+
arrow::FieldVector fields = {arrow::field("id", arrow::int32(), false),
395+
arrow::field("name", arrow::utf8())};
396+
std::shared_ptr<arrow::Schema> schema = arrow::schema(fields);
397+
std::shared_ptr<arrow::Array> written =
398+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields),
399+
R"([[1,"one"],[2,null],[3,"three"],[4,"four"]])")
400+
.ValueOrDie();
401+
402+
::ArrowSchema ffi_schema = {};
403+
ASSERT_TRUE(arrow::ExportSchema(*schema, &ffi_schema).ok());
404+
ASSERT_OK_AND_ASSIGN(std::unique_ptr<WriterBuilder> writer_builder,
405+
format_->CreateWriterBuilder(&ffi_schema, /*batch_size=*/2));
406+
writer_builder->WithMemoryPool(pool_);
407+
ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> output,
408+
file_system_->Create(path, /*overwrite=*/false));
409+
ASSERT_OK_AND_ASSIGN(std::shared_ptr<FormatWriter> writer,
410+
writer_builder->Build(output, "zstd"));
411+
for (int64_t offset = 0; offset < written->length(); offset += 2) {
412+
std::shared_ptr<arrow::Array> slice = written->Slice(offset, 2);
413+
::ArrowArray ffi_array = {};
414+
ASSERT_TRUE(arrow::ExportArray(*slice, &ffi_array).ok());
415+
ASSERT_OK(writer->AddBatch(&ffi_array));
416+
ASSERT_OK(writer->Flush());
417+
}
418+
ASSERT_OK(writer->Finish());
419+
ASSERT_OK(output->Close());
420+
421+
AssertReadWithBatchSizes(path, schema, written, {1, 2, 4});
422+
}
423+
390424
// The IO callback bridge must surface a paimon IO failure as that paimon error, not Vortex's
391425
// opaque "stream error": the callback can only hand a status code back across the FFI boundary, so
392426
// the reader/writer stashes the real error and prefers it. `IOHook` injects a failure at the Nth

‎src/paimon/format/vortex/vortex_format_writer.cpp‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,21 @@ namespace paimon::vortex {
3535
VortexFormatWriter::VortexFormatWriter(std::shared_ptr<OutputStream> output,
3636
std::shared_ptr<arrow::Schema> schema, VxSessionPtr session,
3737
std::shared_ptr<VortexOutputContext> output_context,
38-
vx_callback_sink* sink)
38+
vx_callback_sink* sink,
39+
std::shared_ptr<arrow::MemoryPool> arrow_pool)
3940
: output_(std::move(output)),
4041
schema_(std::move(schema)),
42+
arrow_pool_(std::move(arrow_pool)),
4143
session_(std::move(session)),
4244
output_context_(std::move(output_context)),
4345
sink_(sink),
4446
metrics_(std::make_shared<MetricsImpl>()) {}
4547

4648
Result<std::unique_ptr<VortexFormatWriter>> VortexFormatWriter::Create(
47-
const std::shared_ptr<OutputStream>& output, const std::shared_ptr<arrow::Schema>& schema) {
48-
if (output == nullptr || schema == nullptr) {
49-
return Status::Invalid("Vortex writer requires non-null output and schema");
49+
const std::shared_ptr<OutputStream>& output, const std::shared_ptr<arrow::Schema>& schema,
50+
const std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
51+
if (output == nullptr || schema == nullptr || arrow_pool == nullptr) {
52+
return Status::Invalid("Vortex writer requires non-null output, schema and arrow pool");
5053
}
5154
VxSessionPtr session(vx_session_new(), vx_session_free);
5255
if (session == nullptr) {
@@ -69,7 +72,7 @@ Result<std::unique_ptr<VortexFormatWriter>> VortexFormatWriter::Create(
6972
output_context->GetCallbackStatus());
7073
}
7174
return std::unique_ptr<VortexFormatWriter>(new VortexFormatWriter(
72-
output, schema, std::move(session), std::move(output_context), sink));
75+
output, schema, std::move(session), std::move(output_context), sink, arrow_pool));
7376
}
7477

7578
VortexFormatWriter::~VortexFormatWriter() {
@@ -99,9 +102,8 @@ Status VortexFormatWriter::AddBatch(::ArrowArray* batch) {
99102
PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(
100103
std::shared_ptr<arrow::Array> imported,
101104
arrow::ImportArray(batch, arrow::struct_(schema_->fields())));
102-
PAIMON_ASSIGN_OR_RAISE(
103-
std::shared_ptr<arrow::Array> normalized,
104-
ArrowUtils::NormalizeArrayOffsets(imported, arrow::default_memory_pool()));
105+
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> normalized,
106+
ArrowUtils::NormalizeArrayOffsets(imported, arrow_pool_.get()));
105107
PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*normalized, &rebased_batch));
106108
vortex_batch = &rebased_batch;
107109
}
@@ -127,8 +129,10 @@ Status VortexFormatWriter::Flush() {
127129
if (finished_) {
128130
return Status::OK();
129131
}
130-
// Vortex buffers internally, so this only flushes what its sink has already handed over.
131-
return output_->Flush();
132+
// Flush through the output context so it is serialized with the background writer task's writes
133+
// (OutputStream has no concurrent Write/Flush contract). Vortex buffers internally, so this
134+
// flushes the bytes handed over so far; the rest is drained when the sink closes in Finish().
135+
return output_context_->FlushStream();
132136
}
133137

134138
Status VortexFormatWriter::Finish() {

‎src/paimon/format/vortex/vortex_format_writer.h‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#include "paimon/result.h"
3131

3232
namespace arrow {
33+
class MemoryPool;
3334
class Schema;
3435
} // namespace arrow
3536
namespace paimon {
@@ -48,7 +49,8 @@ namespace paimon::vortex {
4849
class VortexFormatWriter : public FormatWriter {
4950
public:
5051
static Result<std::unique_ptr<VortexFormatWriter>> Create(
51-
const std::shared_ptr<OutputStream>& output, const std::shared_ptr<arrow::Schema>& schema);
52+
const std::shared_ptr<OutputStream>& output, const std::shared_ptr<arrow::Schema>& schema,
53+
const std::shared_ptr<arrow::MemoryPool>& arrow_pool);
5254

5355
~VortexFormatWriter() override;
5456

@@ -62,10 +64,13 @@ class VortexFormatWriter : public FormatWriter {
6264
private:
6365
VortexFormatWriter(std::shared_ptr<OutputStream> output, std::shared_ptr<arrow::Schema> schema,
6466
VxSessionPtr session, std::shared_ptr<VortexOutputContext> output_context,
65-
vx_callback_sink* sink);
67+
vx_callback_sink* sink, std::shared_ptr<arrow::MemoryPool> arrow_pool);
6668

6769
std::shared_ptr<OutputStream> output_;
6870
std::shared_ptr<arrow::Schema> schema_;
71+
// The caller's Arrow pool, used for any buffer (re)allocation on the write path so it counts
72+
// against Paimon's memory accounting instead of Arrow's default pool.
73+
std::shared_ptr<arrow::MemoryPool> arrow_pool_;
6974
VxSessionPtr session_;
7075
std::shared_ptr<VortexOutputContext> output_context_;
7176
// Raw pointer on purpose: close (in Finish) and abort (in the destructor) are mutually

‎src/paimon/format/vortex/vortex_io_callbacks.cpp‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,12 @@ int32_t VortexOutputContext::Write(void* ctx, const uint8_t* src, size_t length)
171171
return -1;
172172
}
173173
auto write_length = static_cast<int64_t>(length);
174-
Result<int64_t> result =
175-
context->output_->Write(reinterpret_cast<const char*>(src), write_length);
174+
// The Vortex writer task is the only other accessor of `output_`; hold the stream lock so a
175+
// concurrent caller-thread FlushStream cannot interleave with this write.
176+
Result<int64_t> result = [&] {
177+
std::lock_guard<std::mutex> stream_lock(context->stream_mutex_);
178+
return context->output_->Write(reinterpret_cast<const char*>(src), write_length);
179+
}();
176180
if (!result.ok()) {
177181
context->SetCallbackStatus(result.status());
178182
return -1;
@@ -194,12 +198,17 @@ int32_t VortexOutputContext::Flush(void* ctx) noexcept {
194198
if (context == nullptr) {
195199
return -1;
196200
}
197-
Status status = context->output_->Flush();
201+
Status status = context->FlushStream();
198202
if (!status.ok()) {
199203
context->SetCallbackStatus(status);
200204
return -1;
201205
}
202206
return 0;
203207
}
204208

209+
Status VortexOutputContext::FlushStream() {
210+
std::lock_guard<std::mutex> stream_lock(stream_mutex_);
211+
return output_->Flush();
212+
}
213+
205214
} // namespace paimon::vortex

‎src/paimon/format/vortex/vortex_io_callbacks.h‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,25 @@ class VortexOutputContext {
143143
/// @return The number of bytes written to the paimon stream so far.
144144
int64_t BytesWritten() const;
145145

146+
/// Flush the paimon stream, serialized with the background writer task's writes.
147+
///
148+
/// `OutputStream` carries no thread-safety contract for concurrent `Write`/`Flush`, and
149+
/// Vortex's writer task invokes the write callback from its own thread, so a caller-thread
150+
/// flush must take the same lock the write callback holds. Vortex buffers internally, so this
151+
/// flushes the bytes handed over so far; whatever is still buffered is drained when the sink is
152+
/// closed (`Finish`).
153+
///
154+
/// @return OK on success, or the flush error.
155+
Status FlushStream();
156+
146157
private:
147158
void SetCallbackStatus(const Status& status);
148159

149160
std::shared_ptr<OutputStream> output_;
150161
mutable std::mutex mutex_;
162+
// Serializes access to `output_` between the Vortex writer task (write/flush callbacks) and a
163+
// caller-thread `FlushStream`; `mutex_` only guards the status/byte-count bookkeeping.
164+
std::mutex stream_mutex_;
151165
Status callback_status_;
152166
int64_t bytes_written_ = 0;
153167
};

‎src/paimon/format/vortex/vortex_writer_builder.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
#include <string>
2323

24+
#include "paimon/common/utils/arrow/mem_utils.h"
2425
#include "paimon/format/vortex/vortex_format_writer.h"
2526
#include "paimon/status.h"
2627

@@ -33,7 +34,7 @@ Result<std::unique_ptr<FormatWriter>> VortexWriterBuilder::Build(
3334
}
3435
// The paimon compression hint is not mapped to the sink; Vortex picks its own encodings.
3536
(void)compression;
36-
return VortexFormatWriter::Create(output, schema_);
37+
return VortexFormatWriter::Create(output, schema_, GetArrowPool(pool_));
3738
}
3839

3940
} // namespace paimon::vortex

0 commit comments

Comments
 (0)