Skip to content

Commit bb15596

Browse files
eshishkiclaude
authored andcommitted
[Enhancement] Expose Kafka/Pulsar message metadata via an INCLUDE METADATA clause in Routine Load (#73840)
Signed-off-by: Evgeniy Shishkin <eshishki@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 0796ea6)
1 parent 3c780d0 commit bb15596

45 files changed

Lines changed: 2635 additions & 105 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

be/src/exec/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ set(EXEC_FILES
129129
file_scanner/json_scanner.cpp
130130
file_scanner/orc_scanner.cpp
131131
file_scanner/parquet_scanner.cpp
132+
file_scanner/stream_source_meta.cpp
132133
schema_scanner/schema_analyze_status.cpp
133134
schema_scanner/schema_tables_scanner.cpp
134135
schema_scanner/schema_dummy_scanner.cpp

be/src/exec/file_scanner/avro_scanner.cpp

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include "column/chunk.h"
2727
#include "column/column_helper.h"
2828
#include "exec/file_scanner/json_scanner.h"
29+
#include "exec/file_scanner/stream_source_meta.h"
2930
#include "exec/json_parser.h"
3031
#include "exprs/cast_expr.h"
3132
#include "exprs/column_ref.h"
@@ -160,6 +161,8 @@ Status AvroScanner::open() {
160161
}
161162
++_counter->num_files_read;
162163

164+
_meta_col_by_slot_id = build_stream_source_meta_columns(_scan_range.params.stream_source_meta_columns);
165+
int column_index = 0;
163166
for (size_t i = 0; i < _src_slot_descriptors.size(); ++i) {
164167
const auto& desc = _src_slot_descriptors[i];
165168
if (desc == nullptr) {
@@ -169,6 +172,13 @@ Status AvroScanner::open() {
169172
// Remember the intermediate avro type per slot so the no-jsonpath path can dispatch
170173
// _construct_column on the source column's type rather than the destination type.
171174
_slot_id_to_avro_type.emplace(desc->id(), _avro_types[i]);
175+
// A hidden metadata slot is filled from the message meta, not the payload. The by-name null-fill
176+
// path iterates chunk columns, so key its descriptor by chunk column index (the append order in
177+
// _create_src_chunk, which skips null slots just like this loop).
178+
if (auto m = _meta_col_by_slot_id.find(desc->id()); m != _meta_col_by_slot_id.end()) {
179+
_meta_col_by_index.emplace(column_index, m->second);
180+
}
181+
column_index++;
172182
}
173183
_init_data_idx_to_slot_once = false;
174184
return Status::OK();
@@ -205,20 +215,33 @@ void AvroScanner::_report_error(const std::string& line, const std::string& err_
205215
_state->append_error_msg_to_file(line, err_msg);
206216
}
207217

208-
Status AvroScanner::_construct_row(const avro_value_t& avro_value, Chunk* chunk) {
218+
Status AvroScanner::_construct_row(const avro_value_t& avro_value, Chunk* chunk, const StreamMessageMeta* meta) {
209219
size_t slot_size = _src_slot_descriptors.size();
210220
size_t jsonpath_size = _json_paths.size();
221+
// Hidden metadata columns carry no jsonpath, so they are transparent to the positional
222+
// slot->jsonpath mapping: a jsonpath maps to the i-th non-metadata slot. This keeps payload columns
223+
// aligned even when a metadata column sits before jsonpath-backed columns.
224+
size_t meta_count = 0;
211225
for (size_t i = 0; i < slot_size; i++) {
212226
if (_src_slot_descriptors[i] == nullptr) {
213227
continue;
214228
}
215229
auto column = down_cast<NullableColumn*>(chunk->get_column_raw_ptr_by_slot_id(_src_slot_descriptors[i]->id()));
216-
if (UNLIKELY(i >= jsonpath_size)) {
230+
// Hidden source-metadata slots are filled from the message meta (by slot id), not the payload,
231+
// before the jsonpath extraction below.
232+
if (auto it = _meta_col_by_slot_id.find(_src_slot_descriptors[i]->id());
233+
UNLIKELY(it != _meta_col_by_slot_id.end())) {
234+
RETURN_IF_ERROR(fill_stream_source_meta_column(it->second.kind, meta, column));
235+
meta_count++;
236+
continue;
237+
}
238+
size_t jp = i - meta_count;
239+
if (UNLIKELY(jp >= jsonpath_size)) {
217240
column->append_nulls(1);
218241
continue;
219242
}
220243
avro_value_t output_value;
221-
auto st = _extract_field(avro_value, _json_paths[i], &output_value);
244+
auto st = _extract_field(avro_value, _json_paths[jp], &output_value);
222245
if (LIKELY(st.ok())) {
223246
// Dispatch on the intermediate avro type (which matches the source column created in
224247
// _create_src_chunk), not the destination type. Otherwise a complex column whose
@@ -241,6 +264,9 @@ Status AvroScanner::_parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFi
241264
DCHECK_EQ(0, chunk->num_rows());
242265
for (size_t num_rows = chunk->num_rows(); num_rows < capacity; /**/) {
243266
avro_value_t avro_value;
267+
// Routine-load source-metadata for this message (null for non-routine-load); read from the pipe
268+
// buffer below, or injected by the test in BE_TEST.
269+
const StreamMessageMeta* meta = nullptr;
244270
#ifdef BE_TEST
245271
// In general, we want to test component injection schemastr.
246272
avro_schema_error_t error;
@@ -273,6 +299,7 @@ Status AvroScanner::_parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFi
273299
return Status::InternalError(err_msg);
274300
}
275301
free(avro_as_json);
302+
meta = _test_meta;
276303
#else
277304
const uint8_t* data{};
278305
size_t length = 0;
@@ -284,6 +311,7 @@ Status AvroScanner::_parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFi
284311
}
285312
data = reinterpret_cast<uint8_t*>(_parser_buf->ptr);
286313
length = _parser_buf->remaining();
314+
meta = stream_source_meta_of(_parser_buf);
287315
serdes_schema_t* schema;
288316
serdes_err_t err =
289317
serdes_deserialize_avro(_serdes, &avro_value, &schema, data, length, _err_buf, sizeof(_err_buf));
@@ -299,7 +327,7 @@ Status AvroScanner::_parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFi
299327
size_t chunk_row_num = chunk->num_rows();
300328
Status st = Status::OK();
301329
if (!_json_paths.empty()) {
302-
st = _construct_row(avro_value, chunk);
330+
st = _construct_row(avro_value, chunk, meta);
303331
} else {
304332
if (!_init_data_idx_to_slot_once) {
305333
size_t element_count;
@@ -320,7 +348,7 @@ Status AvroScanner::_parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFi
320348

321349
_init_data_idx_to_slot_once = true;
322350
}
323-
st = _construct_row_without_jsonpath(avro_value, chunk);
351+
st = _construct_row_without_jsonpath(avro_value, chunk, meta);
324352
}
325353
if (!st.ok()) {
326354
if (_counter->num_rows_filtered++ < MAX_ERROR_LINES_IN_FILE) {
@@ -338,7 +366,8 @@ Status AvroScanner::_parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFi
338366
return Status::OK();
339367
}
340368

341-
Status AvroScanner::_construct_row_without_jsonpath(const avro_value_t& avro_value, Chunk* chunk) {
369+
Status AvroScanner::_construct_row_without_jsonpath(const avro_value_t& avro_value, Chunk* chunk,
370+
const StreamMessageMeta* meta) {
342371
_found_columns.assign(chunk->num_columns(), false);
343372
size_t element_count = _data_idx_to_fieldname.size();
344373
avro_value_t element_value;
@@ -362,6 +391,12 @@ Status AvroScanner::_construct_row_without_jsonpath(const avro_value_t& avro_val
362391
continue;
363392
}
364393
auto slot_desc = itr->second;
394+
// A metadata alias is filled from the message meta in the null-fill pass, never from the
395+
// payload; if a payload field uses the same name, skip it (cache as id = -1) so metadata wins.
396+
if (_meta_col_by_slot_id.find(slot_desc->id()) != _meta_col_by_slot_id.end()) {
397+
slot_info.id = -1;
398+
continue;
399+
}
365400
slot_info.id = slot_desc->id();
366401
// Store the intermediate avro type (not the destination type) so _construct_column
367402
// dispatches on the type the source column was built with. See _construct_row.
@@ -386,7 +421,13 @@ Status AvroScanner::_construct_row_without_jsonpath(const avro_value_t& avro_val
386421
for (int i = 0; i < _found_columns.size(); i++) {
387422
if (UNLIKELY(!_found_columns[i])) {
388423
auto* column = chunk->get_column_raw_ptr_by_index(i);
389-
column->append_nulls(1);
424+
// A hidden metadata slot always lands here (the by-name resolve above skips any same-named
425+
// payload field); fill it from the message meta, NULL when the buffer carries none.
426+
if (auto it = _meta_col_by_index.find(i); it != _meta_col_by_index.end()) {
427+
RETURN_IF_ERROR(fill_stream_source_meta_column(it->second.kind, meta, column));
428+
} else {
429+
column->append_nulls(1);
430+
}
390431
}
391432
}
392433
return Status::OK();

be/src/exec/file_scanner/avro_scanner.h

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#include "common/status.h"
2222
#include "exec/file_scanner/file_scanner.h"
2323
#include "exec/file_scanner/json_scanner.h"
24+
#include "exec/file_scanner/stream_source_meta.h"
2425
#include "exprs/json_functions.h"
2526
#include "fs/fs.h"
2627
#include "runtime/stream_load/load_stream_mgr.h"
@@ -39,6 +40,8 @@ namespace starrocks {
3940

4041
using AvroPath = SimpleJsonPath;
4142

43+
class StreamMessageMeta;
44+
4245
class AvroScanner final : public FileScanner {
4346
public:
4447
AvroScanner(RuntimeState* state, RuntimeProfile* profile, const TBrokerScanRange& scan_range,
@@ -73,7 +76,7 @@ class AvroScanner final : public FileScanner {
7376
Status _create_src_chunk(ChunkPtr* chunk);
7477
Status _parse_avro(Chunk* chunk, const std::shared_ptr<SequentialFile>& file);
7578
void _report_error(const std::string& line, const std::string& err_msg);
76-
Status _construct_row(const avro_value_t& avro_value, Chunk* chunk);
79+
Status _construct_row(const avro_value_t& avro_value, Chunk* chunk, const StreamMessageMeta* meta);
7780
void _materialize_src_chunk_adaptive_nullable_column(ChunkPtr& chunk);
7881
Status _construct_column(const avro_value_t& input_value, Column* column, const TypeDescriptor& type_desc,
7982
const std::string& col_name);
@@ -82,7 +85,7 @@ class AvroScanner final : public FileScanner {
8285
Status _handle_union(const avro_value_t* input_value, avro_value_t* branch);
8386
Status _get_array_element(const avro_value_t* cur_value, size_t idx, avro_value_t* element);
8487
std::string _preprocess_jsonpaths(std::string jsonpath);
85-
Status _construct_row_without_jsonpath(const avro_value_t& avro_value, Chunk* chunk);
88+
Status _construct_row_without_jsonpath(const avro_value_t& avro_value, Chunk* chunk, const StreamMessageMeta* meta);
8689

8790
const TBrokerScanRange& _scan_range;
8891
serdes_t* _serdes;
@@ -99,13 +102,26 @@ class AvroScanner final : public FileScanner {
99102
std::unordered_map<std::string_view, SlotDescriptor*> _slot_desc_dict;
100103
// Maps each source slot id to its intermediate avro load type (see AvroScanner::_construct_avro_types).
101104
std::unordered_map<SlotId, TypeDescriptor> _slot_id_to_avro_type;
105+
// Hidden source-metadata slots (routine load), filled from the message's ByteBuffer meta rather than
106+
// the avro payload. _meta_col_by_slot_id keys by source slot id (the jsonpath path iterates slots);
107+
// _meta_col_by_index keys by chunk column index for the by-name null-fill path. Both empty otherwise.
108+
StreamSourceMetaColumns _meta_col_by_slot_id;
109+
std::unordered_map<int, TRoutineLoadMetaColumn> _meta_col_by_index;
102110
std::vector<bool> _found_columns;
103111
std::vector<SlotInfo> _data_idx_to_slot;
104112
std::vector<std::string> _data_idx_to_fieldname;
105113
bool _init_data_idx_to_slot_once;
106114

107115
#if BE_TEST
116+
public:
117+
// Test-only: inject the per-message source metadata that production reads from the pipe buffer.
118+
// BE_TEST reads avro from a file rather than the Kafka/Pulsar pipe, so the metadata-column fill path
119+
// is otherwise unreachable from a test; this lets AvroScannerTest exercise it.
120+
void set_test_stream_meta(const StreamMessageMeta* meta) { _test_meta = meta; }
121+
122+
private:
108123
avro_file_reader_t _dbreader;
124+
const StreamMessageMeta* _test_meta = nullptr;
109125
#endif
110126
};
111127

be/src/exec/file_scanner/json_scanner.cpp

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include "column/chunk.h"
2525
#include "column/column_helper.h"
2626
#include "column/vectorized_fwd.h"
27+
#include "exec/file_scanner/stream_source_meta.h"
2728
#include "exec/json_parser.h"
2829
#include "exprs/cast_expr.h"
2930
#include "exprs/column_ref.h"
@@ -287,6 +288,7 @@ JsonReader::JsonReader(RuntimeState* state, ScannerCounter* counter, JsonScanner
287288
_type_descs(std::move(type_descs)),
288289
_op_col_index(-1),
289290
_range_desc(range_desc) {
291+
_meta_col_by_slot_id = build_stream_source_meta_columns(_scanner->_scan_range.params.stream_source_meta_columns);
290292
int index = 0;
291293
for (size_t i = 0; i < _slot_descs.size(); ++i) {
292294
const auto& desc = _slot_descs[i];
@@ -295,6 +297,8 @@ JsonReader::JsonReader(RuntimeState* state, ScannerCounter* counter, JsonScanner
295297
}
296298
if (UNLIKELY(desc->col_name() == "__op")) {
297299
_op_col_index = index;
300+
} else if (auto m = _meta_col_by_slot_id.find(desc->id()); m != _meta_col_by_slot_id.end()) {
301+
_meta_col_by_index.emplace(index, m->second);
298302
}
299303
index++;
300304
_slot_desc_dict.emplace(desc->col_name(), desc);
@@ -479,6 +483,15 @@ Status JsonReader::_read_rows(Chunk* chunk, int32_t rows_to_read, int32_t* rows_
479483
Status JsonReader::_construct_row_without_jsonpath(simdjson::ondemand::object* row, Chunk* chunk) {
480484
_parsed_columns.assign(chunk->num_columns(), false);
481485

486+
// Hidden source-metadata columns are filled from the buffer's Kafka/Pulsar metadata (routine load),
487+
// never from the payload. If a payload field uses the same name as a metadata alias, the by-name
488+
// resolve below skips it so the metadata value wins.
489+
#if BE_TEST
490+
const StreamMessageMeta* meta = _scanner->_test_meta;
491+
#else
492+
const StreamMessageMeta* meta = stream_source_meta_of(_file_stream_buffer);
493+
#endif
494+
482495
faststring buffer;
483496
try {
484497
uint32_t key_index = 0;
@@ -530,8 +543,22 @@ Status JsonReader::_construct_row_without_jsonpath(simdjson::ondemand::object* r
530543
auto slot_desc = itr->second;
531544
auto type_desc = _type_desc_dict[key];
532545

533-
// update the prev parsed position
534546
column_index = chunk->get_index_by_slot_id(slot_desc->id());
547+
// A hidden metadata slot is filled from the message meta in the null-fill pass, never
548+
// from the payload; if a payload field uses the same name as the metadata alias, skip it
549+
// (cache as column_index = -1) so the metadata value still wins.
550+
if (_meta_col_by_index.count(column_index) > 0) {
551+
if (_prev_parsed_position.size() <= key_index) {
552+
_prev_parsed_position.emplace_back(key);
553+
} else {
554+
_prev_parsed_position[key_index].key = key;
555+
_prev_parsed_position[key_index].column_index = -1;
556+
}
557+
key_index++;
558+
continue;
559+
}
560+
561+
// update the prev parsed position
535562
if (_prev_parsed_position.size() <= key_index) {
536563
_prev_parsed_position.emplace_back(key, column_index, type_desc);
537564
} else {
@@ -566,7 +593,7 @@ Status JsonReader::_construct_row_without_jsonpath(simdjson::ondemand::object* r
566593
return Status::DataQualityError(err_msg);
567594
}
568595

569-
// append null to the column without data.
596+
// append null (or synthetic metadata) to the columns without data.
570597
for (int i = 0; i < chunk->num_columns(); i++) {
571598
if (!_parsed_columns[i]) {
572599
auto* column = chunk->get_column_raw_ptr_by_index(i);
@@ -577,6 +604,9 @@ Status JsonReader::_construct_row_without_jsonpath(simdjson::ondemand::object* r
577604
} else {
578605
column->append_datum(Datum((uint8_t)0));
579606
}
607+
} else if (auto it = _meta_col_by_index.find(i); it != _meta_col_by_index.end()) {
608+
// Fill from meta (NULL when the buffer carries none).
609+
RETURN_IF_ERROR(fill_stream_source_meta_column(it->second.kind, meta, column));
580610
} else {
581611
column->append_nulls(1);
582612
}
@@ -588,6 +618,15 @@ Status JsonReader::_construct_row_without_jsonpath(simdjson::ondemand::object* r
588618
Status JsonReader::_construct_row_with_jsonpath(simdjson::ondemand::object* row, Chunk* chunk) {
589619
size_t slot_size = _slot_descs.size();
590620
size_t jsonpath_size = _scanner->_json_paths.size();
621+
#if BE_TEST
622+
const StreamMessageMeta* meta = _scanner->_test_meta;
623+
#else
624+
const StreamMessageMeta* meta = stream_source_meta_of(_file_stream_buffer);
625+
#endif
626+
// Hidden metadata columns carry no jsonpath, so they are transparent to the positional
627+
// slot->jsonpath mapping: a jsonpath maps to the i-th non-metadata slot. This keeps payload columns
628+
// aligned even when a metadata column sits before jsonpath-backed columns.
629+
size_t meta_count = 0;
591630
for (size_t i = 0; i < slot_size; i++) {
592631
if (_slot_descs[i] == nullptr) {
593632
continue;
@@ -596,7 +635,18 @@ Status JsonReader::_construct_row_with_jsonpath(simdjson::ondemand::object* row,
596635

597636
// The columns in JsonReader's chunk are all in NullableColumn type;
598637
auto* column = down_cast<NullableColumn*>(chunk->get_column_raw_ptr_by_slot_id(_slot_descs[i]->id()));
599-
if (i >= jsonpath_size) {
638+
639+
// Hidden source-metadata slots are filled from the message meta, not the payload, before the
640+
// jsonpath extraction. Fill unconditionally (NULL when the buffer carries no meta) and always
641+
// advance meta_count, so a metadata slot stays transparent to the positional jsonpath mapping
642+
// regardless of whether meta is present.
643+
if (auto it = _meta_col_by_slot_id.find(_slot_descs[i]->id()); UNLIKELY(it != _meta_col_by_slot_id.end())) {
644+
RETURN_IF_ERROR(fill_stream_source_meta_column(it->second.kind, meta, column));
645+
meta_count++;
646+
continue;
647+
}
648+
size_t jp = i - meta_count;
649+
if (jp >= jsonpath_size) {
600650
if (strcmp(column_name, "__op") == 0) {
601651
// special treatment for __op column, fill default value '0' rather than null
602652
if (column->is_binary()) {
@@ -616,7 +666,7 @@ Status JsonReader::_construct_row_with_jsonpath(simdjson::ondemand::object* row,
616666
// simdjson's api is limited, which coult not convert ondemand::object to ondemand::value.
617667
// As a workaround, extract procedure is duplicated, for both ondemand::object and ondemand::value
618668
// TODO(mofei) make it more elegant
619-
if (_scanner->_json_paths[i].size() == 1 && _scanner->_json_paths[i][0].key == "$") {
669+
if (_scanner->_json_paths[jp].size() == 1 && _scanner->_json_paths[jp][0].key == "$") {
620670
// add_nullable_column may invoke a for-range iterating to the row.
621671
// If the for-range iterating is invoked after field access, or a second for-range iterating is invoked,
622672
// it would get an error "Objects and arrays can only be iterated when they are first encountered",
@@ -626,7 +676,7 @@ Status JsonReader::_construct_row_with_jsonpath(simdjson::ondemand::object* row,
626676
column, _slot_descs[i]->type(), _slot_descs[i]->col_name(), row, !_strict_mode));
627677
} else {
628678
simdjson::ondemand::value val;
629-
auto st = JsonFunctions::extract_from_object(*row, _scanner->_json_paths[i], &val);
679+
auto st = JsonFunctions::extract_from_object(*row, _scanner->_json_paths[jp], &val);
630680
if (st.ok()) {
631681
RETURN_IF_ERROR(_construct_column(val, column, _slot_descs[i]->type(), _slot_descs[i]->col_name()));
632682
} else if (st.is_not_found()) {

0 commit comments

Comments
 (0)