Skip to content

Commit 95b2f0e

Browse files
rustyconoverclaude
andcommitted
feat(table-in-out): blended ("UNNEST-style") RowTransformFunction core (Phase B)
A blended table-in-out function's positional args ARE its per-row input columns (real typed args, no synthetic TABLE placeholder), so ONE registration serves every call shape: geo_encode(52.0, 13.0) -- literal -> one input row FROM t, geo_encode(t.x, t.y) -- columns -> streaming input LATERAL geo_encode(t.x, t.y) The signal is worker-advertised input_from_args (VgiFunctionInfo), parsed off the catalog function info. Registration enters the in_out_function branch on it even without a TABLE arg (positional_types are real value types). Bind builds the worker input schema from the DECLARED positional arg names (the worker reads columns by name) + the input-provided types, ignoring input_table_names (empty in the literal shape), and sets single_row_scan for the childless call. Critical fix — the literal shape is driven by PhysicalTableScan, which acts as a SOURCE: it re-invokes the callback with the SAME cardinality-1 input chunk and decides flow SOLELY on chunk.size() (discarding our returned OperatorResultType). The new single_row_scan branch in VgiTableInOutFunction writes the one synthesized input row once, CloseInputWriter()s so the worker reaches EOS, then drains to EOS inside the call (skipping empty-but-not-EOS batches), returning a 0-row chunk ONLY at true EOS — otherwise the query infinite-loops. Cancel-not-pool the scan-mode worker on EOS. Validated end to end: literal / column / LATERAL / projection / int->DOUBLE cast / 1->0 filter all work. No finalize (map-shaped; the worker side rejects a finalize override on a blended fn). Inline named args (f(x,y, opt:=v)) are NOT yet supported (a follow-up binder patch). Worker-side RowTransformFunction + fixture land in vgi-python. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 97e6b64 commit 95b2f0e

8 files changed

Lines changed: 221 additions & 17 deletions

File tree

src/generated/vgi_protocol_schemas.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// GENERATED by vgi.codegen.cpp_schemas. DO NOT EDIT BY HAND.
33
//
44
// Generator: vgi-gen-cpp-schemas v1
5-
// Content hash: 28aa7d751da6
5+
// Content hash: c025cbc6995f
66
//
77
// To regenerate:
88
// uv run --project ~/Development/vgi-python vgi-gen-cpp-schemas \
@@ -119,6 +119,7 @@ inline const std::shared_ptr<arrow::Schema> &FunctionInfoSchema() {
119119
arrow::field("source_order_dependent", arrow::boolean(), /*nullable=*/false),
120120
arrow::field("sink_order_dependent", arrow::boolean(), /*nullable=*/false),
121121
arrow::field("requires_input_batch_index", arrow::boolean(), /*nullable=*/false),
122+
arrow::field("input_from_args", arrow::boolean(), /*nullable=*/false),
122123
arrow::field("required_settings", arrow::list(arrow::utf8()), /*nullable=*/false),
123124
arrow::field("required_secrets", arrow::list(arrow::struct_({arrow::field("secret_type", arrow::utf8(), /*nullable=*/false), arrow::field("scope", arrow::utf8(), /*nullable=*/true), arrow::field("secret_name", arrow::utf8(), /*nullable=*/true)})), /*nullable=*/false),
124125
});

src/include/vgi_catalog_metadata.hpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,14 @@ struct VgiFunctionInfo {
540540
// with sink_order_dependent.
541541
bool requires_input_batch_index = false;
542542

543+
// Blended ("UNNEST-style") table-in-out: the function's positional args ARE
544+
// its per-row input columns (real typed args, no synthetic TABLE placeholder),
545+
// so ONE registration serves f(52,13) (literal), FROM t, f(t.x,t.y) (columns),
546+
// and LATERAL f(t.x,t.y). The registration enters the in-out branch on this
547+
// flag even without a TABLE-typed arg, and bind builds the input schema from
548+
// the declared arg names + drives the literal single-row scan-mode.
549+
bool input_from_args = false;
550+
543551
// Settings required by this function (must be set before invocation)
544552
std::vector<std::string> required_settings;
545553

src/include/vgi_table_in_out_impl.hpp

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ struct VgiTableInOutBindData : public TableFunctionData {
112112
// own worker), but Execute must keep the substream connection open at input-EOS
113113
// so VgiTableInOutFinalize can reuse it (a no-finalize map releases it there).
114114
bool has_finalize = false;
115+
116+
// Blended ("UNNEST-style") table-in-out (Phase B). input_from_args: this is a
117+
// blended function (positional args are input columns). single_row_scan: this
118+
// particular bind is the childless call shape (literal f(52,13) or a
119+
// pure-varargs childless call) that DuckDB drives via PhysicalTableScan — a
120+
// SOURCE that re-feeds its cardinality-1 input chunk until the callback returns
121+
// a 0-row chunk. Execute uses the write-once -> CloseInputWriter -> drain-to-EOS
122+
// scan-mode branch for this shape. False for the streaming column/LATERAL shape
123+
// (PhysicalTableInOutFunction, which advances on NEED_MORE_INPUT).
124+
bool input_from_args = false;
125+
bool single_row_scan = false;
115126
};
116127

117128
// ============================================================================
@@ -219,6 +230,12 @@ struct VgiTableInOutLocalState : public ArrowScanLocalState {
219230
// finalize is independent per substream, so the "init once" latch must live on
220231
// the local state, not the global one (which the serial path uses).
221232
bool finalize_sent = false;
233+
234+
// Phase B (blended literal scan-mode): whether the single synthesized input row
235+
// has been written + the input writer closed. Single-thread-safe:
236+
// PhysicalTableScan::ParallelSource() is false for in-out functions, so the
237+
// literal scan runs on one local state.
238+
bool input_submitted = false;
222239
};
223240

224241
// ============================================================================
@@ -238,6 +255,16 @@ struct VgiTableInOutBindParams {
238255
// per-substream fan-out. Threaded to bind_data.parallel_safe.
239256
int32_t max_workers = 0;
240257

258+
// Blended ("UNNEST-style") table-in-out (Phase B): positional args ARE the
259+
// per-row input columns. When true, bind builds the worker input schema from
260+
// `positional_input_names` (the DECLARED arg names — the worker reads columns
261+
// by those names) + `input.input_table_types`, ignoring input_table_names
262+
// (empty in the literal shape), and sets single_row_scan for the childless
263+
// (literal / pure-varargs) call so Execute uses the write-once scan-mode.
264+
bool input_from_args = false;
265+
std::vector<std::string> positional_input_names;
266+
bool has_varargs = false;
267+
241268
// Routes through to bind_data so the OptimizerExtension can recognize a
242269
// LogicalGet of a buffered table function and rewrite it.
243270
bool table_buffering = false;

src/storage/vgi_table_function_set.cpp

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,16 @@ static unique_ptr<FunctionData> VgiCatalogTableInOutFunctionBind(ClientContext &
229229
// A3 serial opt-out: a worker that declares Meta.max_workers=1 keeps its
230230
// streaming table-in-out on a single shared worker (MaxThreads()=1).
231231
params.max_workers = vgi_info.function_info().max_workers.value_or(0);
232+
// Blended (Phase B): the DECLARED positional arg names are the worker's input
233+
// column names (it reads batch.column("<name>")). Re-parse the arguments schema
234+
// to recover them (+ varargs flag) so bind can build the input schema by name.
235+
params.input_from_args = vgi_info.function_info().input_from_args;
236+
if (params.input_from_args && vgi_info.function_info().arguments_schema) {
237+
auto blended_args =
238+
vgi::ParseFunctionArgumentSchema(context, vgi_info.function_info().arguments_schema);
239+
params.positional_input_names = blended_args.positional_names;
240+
params.has_varargs = blended_args.has_varargs;
241+
}
232242

233243
// Validate required settings
234244
const auto &required_settings = vgi_info.function_info().required_settings;
@@ -317,8 +327,21 @@ void VgiTableFunctionSet::LoadEntries(ClientContext &context, const std::lock_gu
317327
arg_types = vgi::ParseFunctionArgumentSchema(context, func_info.arguments_schema);
318328
}
319329

320-
// Check if this is a table-in-out function (has TABLE input argument)
321-
if (arg_types.HasTableInput()) {
330+
// Check if this is a table-in-out function: either it has a TABLE input
331+
// argument (classic), or it is a blended ("UNNEST-style") function whose
332+
// positional args ARE its per-row input columns (input_from_args). Both
333+
// register through the in_out_function path — a blended function's
334+
// positional_types are real value types (no TABLE marker), so it also
335+
// serves the literal f(52,13) and column FROM t, f(t.x,t.y) shapes.
336+
if (arg_types.HasTableInput() || func_info.input_from_args) {
337+
// A blended function must not also declare a finalize (map-shaped).
338+
// The worker's resolve_metadata already rejects this; assert as a
339+
// defense against a hand-rolled/old worker that advertises both.
340+
if (func_info.input_from_args && func_info.has_finalize) {
341+
throw InvalidInputException(
342+
"Function '%s' advertises input_from_args (blended) AND has_finalize; a blended "
343+
"RowTransformFunction is map-shaped and cannot have a finalize.", func_info.name);
344+
}
322345
// Create a table-in-out function
323346
// Table-in-out functions use LogicalType::TABLE in args and have an in_out_function
324347
TableFunction table_func(arg_types.positional_types, nullptr, VgiCatalogTableInOutFunctionBind,

src/vgi_catalog_api.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2645,6 +2645,11 @@ VgiFunctionInfo ParseFunctionInfo(const std::shared_ptr<arrow::RecordBatch> &bat
26452645
// so DuckDB threads source-position metadata to every Sink call.
26462646
info.requires_input_batch_index = row["requires_input_batch_index"].value_or(false);
26472647

2648+
// input_from_args — optional bool (defaults to false for older workers). A
2649+
// blended ("UNNEST-style") table-in-out whose positional args ARE its per-row
2650+
// input columns.
2651+
info.input_from_args = row["input_from_args"].value_or(false);
2652+
26482653
// Required settings for this function (list of strings)
26492654
info.required_settings = row["required_settings"].value_or(std::vector<std::string> {});
26502655

src/vgi_table_in_out_impl.cpp

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ unique_ptr<FunctionData> VgiTableInOutBindData::Copy() const {
7777
copy->requires_input_batch_index = requires_input_batch_index;
7878
copy->parallel_safe = parallel_safe;
7979
copy->has_finalize = has_finalize;
80+
copy->input_from_args = input_from_args;
81+
copy->single_row_scan = single_row_scan;
8082
return copy;
8183
}
8284

@@ -165,30 +167,75 @@ unique_ptr<FunctionData> VgiTableInOutBind(ClientContext &context, TableFunction
165167
// case is honored, matching the pure-table path's use of the same field.)
166168
bind_data->parallel_safe = (params.max_workers != 1);
167169

168-
// Build arguments from the regular (non-TABLE) inputs
169-
// input.inputs contains positional arguments, but TABLE arguments are represented as NULL
170-
// We skip NULL values since they represent the TABLE input (not a scalar argument)
171-
// Named arguments are in input.named_parameters
170+
bind_data->input_from_args = params.input_from_args;
171+
172+
// Build arguments from the regular (non-TABLE) inputs.
173+
// input.inputs contains positional arguments, but TABLE arguments are represented as NULL.
174+
// We skip NULL values since they represent the TABLE input (not a scalar argument).
175+
// Named arguments are in input.named_parameters.
176+
//
177+
// BLENDED (input_from_args): the positional inputs ARE the per-row input
178+
// columns (delivered by DuckDB's synthesized input chunk), NOT bind arguments —
179+
// skip ALL of them. Only named args survive as bind-time scalars.
172180
vector<Value> positional_args;
173-
for (auto &val : input.inputs) {
174-
// Skip NULL values - these represent TABLE arguments
175-
if (val.IsNull()) {
176-
continue;
181+
if (!params.input_from_args) {
182+
for (auto &val : input.inputs) {
183+
// Skip NULL values - these represent TABLE arguments
184+
if (val.IsNull()) {
185+
continue;
186+
}
187+
positional_args.push_back(val);
177188
}
178-
positional_args.push_back(val);
179189
}
180190
vector<std::pair<string, Value>> named_args;
181191
for (auto &[name, value] : input.named_parameters) {
182192
named_args.emplace_back(name, value);
183193
}
184194
bind_data->arguments = BuildArgumentsFromValues(context, positional_args, named_args);
185195

186-
// Build the input schema from the table input types/names
187-
bind_data->input_schema = BuildArrowSchemaFromDuckDB(context, input.input_table_types, input.input_table_names);
196+
// Build the input schema.
197+
//
198+
// BLENDED: use the DECLARED positional arg names (the worker reads columns by
199+
// those names) with the input-provided types. IGNORE input.input_table_names —
200+
// it is empty in the literal shape (f(52,13) -> ["",""]) and would otherwise
201+
// name the worker's input columns after the referenced columns in the column
202+
// shape. For a pure-VARARGS blended function the declared names don't cover the
203+
// N runtime columns, so fall back to generated col0..colN-1.
204+
if (params.input_from_args) {
205+
vector<string> in_names;
206+
const idx_t n_cols = input.input_table_types.size();
207+
if (!params.has_varargs && params.positional_input_names.size() == n_cols) {
208+
for (auto &nm : params.positional_input_names) {
209+
in_names.push_back(nm);
210+
}
211+
} else {
212+
for (idx_t i = 0; i < n_cols; i++) {
213+
in_names.push_back("col" + std::to_string(i));
214+
}
215+
}
216+
bind_data->input_schema = BuildArrowSchemaFromDuckDB(context, input.input_table_types, in_names);
217+
// Childless call shape (literal f(52,13) or a pure-varargs childless call)
218+
// -> PhysicalTableScan, which needs the write-once scan-mode in Execute.
219+
// Robust signal: all synthesized input names are empty (the column/LATERAL
220+
// shape gives non-empty names and a streaming child). Do NOT key on
221+
// !input.inputs.empty() (false-negatives the zero-positional childless call).
222+
bool all_names_empty = true;
223+
for (auto &nm : input.input_table_names) {
224+
if (!nm.empty()) {
225+
all_names_empty = false;
226+
break;
227+
}
228+
}
229+
bind_data->single_row_scan = all_names_empty;
230+
} else {
231+
bind_data->input_schema = BuildArrowSchemaFromDuckDB(context, input.input_table_types, input.input_table_names);
232+
}
188233

189234
VGI_LOG(context, "table_in_out.bind",
190235
{{"worker_path", bind_data->worker_path()},
191236
{"function_name", bind_data->function_name},
237+
{"input_from_args", params.input_from_args ? "true" : "false"},
238+
{"single_row_scan", bind_data->single_row_scan ? "true" : "false"},
192239
{"input_columns", std::to_string(input.input_table_types.size())}});
193240

194241
// Create the connection and perform bind
@@ -631,6 +678,72 @@ OperatorResultType VgiTableInOutFunction(ExecutionContext &context, TableFunctio
631678
}
632679
IFunctionConnection &conn = parallel ? *local_state.connection : *global_state.connection;
633680

681+
// ------------------------------------------------------------------------
682+
// Phase B — blended LITERAL scan-mode (single_row_scan).
683+
// ------------------------------------------------------------------------
684+
// DuckDB drives the childless call shape (f(52,13) / pure-varargs childless)
685+
// through PhysicalTableScan, which acts as a SOURCE: it re-invokes this
686+
// callback with the SAME cardinality-1 input chunk and decides flow SOLELY on
687+
// `chunk.size()` (it DISCARDS our returned OperatorResultType). So we must
688+
// return a 0-row chunk ONLY at true end-of-stream, and never mid-stream.
689+
if (bind_data.single_row_scan) {
690+
// 1) Drain a large (1->N) output batch that overflowed STANDARD_VECTOR_SIZE.
691+
if (HasRemainingBatchData(local_state)) {
692+
idx_t rows_copied =
693+
ProduceOutputFromBatch(local_state, bind_data.arrow_table, output, bind_data.projection_pushdown);
694+
VGI_LOG(client_context, "table_in_out.scan_output",
695+
{{"conn", conn.GetConnIdHex()}, {"function_name", bind_data.function_name},
696+
{"output_rows", std::to_string(rows_copied)}});
697+
return OperatorResultType::HAVE_MORE_OUTPUT;
698+
}
699+
// 2) First call: write the single synthesized input row ONCE, then close the
700+
// input writer so the worker can reach EOS (no deadlock).
701+
if (!local_state.input_submitted) {
702+
auto input_batch = DataChunkToArrow(client_context, input, bind_data.input_schema);
703+
VGI_LOG(client_context, "table_in_out.scan_write_input",
704+
{{"conn", conn.GetConnIdHex()}, {"function_name", bind_data.function_name},
705+
{"input_rows", std::to_string(input_batch->num_rows())}});
706+
conn.WriteInputBatch(input_batch);
707+
conn.CloseInputWriter();
708+
local_state.input_submitted = true;
709+
}
710+
// 3) Drain to EOS INSIDE this call: skip empty-but-not-EOS batches (worker
711+
// heartbeat / 1->0). Return a >0-row chunk on the first non-empty batch;
712+
// return a 0-row chunk (SetCardinality(0)) only on nullptr (true EOS).
713+
while (true) {
714+
std::shared_ptr<arrow::RecordBatch> output_batch;
715+
try {
716+
output_batch = conn.ReadDataBatch();
717+
} catch (const IOException &) {
718+
// Worker may exit right after input EOS (e.g. empty 1->0). Treat as
719+
// clean end-of-stream, mirroring the scalar path's post-close read.
720+
output_batch = nullptr;
721+
}
722+
if (!output_batch) {
723+
output.SetCardinality(0);
724+
// Cancel-not-pool: the "no-finalize in-out worker returns to a clean
725+
// accept-loop after input-EOS" transition is unproven for scan-mode,
726+
// so drop the connection instead of pooling it.
727+
if (parallel) {
728+
local_state.connection.reset();
729+
} else {
730+
global_state.stream_finished = true;
731+
}
732+
return OperatorResultType::FINISHED;
733+
}
734+
if (output_batch->num_rows() == 0) {
735+
continue; // empty-but-not-EOS: keep reading, never return 0 rows mid-stream
736+
}
737+
LoadBatchIntoScanState(local_state, output_batch);
738+
idx_t rows_copied =
739+
ProduceOutputFromBatch(local_state, bind_data.arrow_table, output, bind_data.projection_pushdown);
740+
VGI_LOG(client_context, "table_in_out.scan_output",
741+
{{"conn", conn.GetConnIdHex()}, {"function_name", bind_data.function_name},
742+
{"output_rows", std::to_string(rows_copied)}});
743+
return OperatorResultType::HAVE_MORE_OUTPUT;
744+
}
745+
}
746+
634747
// Continue producing rows from a batch that exceeded STANDARD_VECTOR_SIZE
635748
if (HasRemainingBatchData(local_state)) {
636749
idx_t rows_copied = ProduceOutputFromBatch(local_state, bind_data.arrow_table, output, bind_data.projection_pushdown);

test/landing/fixtures/describe.expected.json

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"attach_options": [],
55
"counts": {
6-
"functions": 184,
6+
"functions": 185,
77
"schemas": 2,
88
"tables": 59,
99
"views": 3
@@ -1820,6 +1820,30 @@
18201820
"name": "generator_exception",
18211821
"type": "table"
18221822
},
1823+
{
1824+
"args": [
1825+
{
1826+
"desc": "Latitude input column",
1827+
"name": "latitude",
1828+
"type": "double"
1829+
},
1830+
{
1831+
"desc": "Longitude input column",
1832+
"name": "longitude",
1833+
"type": "double"
1834+
},
1835+
{
1836+
"default": "4",
1837+
"desc": "Rounding precision",
1838+
"name": "precision",
1839+
"named": true,
1840+
"type": "int64"
1841+
}
1842+
],
1843+
"doc": "Blended per-row geo encoder (lat, lon -> geohash)",
1844+
"name": "geo_encode",
1845+
"type": "table"
1846+
},
18231847
{
18241848
"args": [
18251849
{

test/sql/integration/table/function_registration.test

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ ATTACH 'example' AS example (TYPE vgi, LOCATION '${VGI_TEST_WORKER}');
7272
# 1 parallel-streaming-finalize fixture (substream_partial_sum, a streaming
7373
# TableInOutFunction whose per-substream finalize emits this substream's partial
7474
# sum — see table_in_out/parallel_finalize.test)
75+
# 1 blended ("UNNEST-style") fixture (geo_encode, a RowTransformFunction whose
76+
# positional args ARE its per-row input columns — one registration serves
77+
# literal / column / LATERAL; see table_in_out/blended.test)
7578
# 12 result-cache fixtures (cacheable_numbers, cache_nonce, cache_no_store,
7679
# cache_scoped_txn, cache_big, cache_multicol, cache_revalidatable, cache_whoami,
7780
# cache_versioned_scan, cache_projection, cache_poison, cache_external_fail —
@@ -90,13 +93,13 @@ ATTACH 'example' AS example (TYPE vgi, LOCATION '${VGI_TEST_WORKER}');
9093
# filter_bytes keys distinct entries, backs cache/filter_pushdown_keys.test, ALSO a data
9194
# Table (surfaces as a table); cache_partitioned — single-value partition_values through
9295
# the spill blob, backs cache/spill_partition_values.test)
93-
# = 122
96+
# = 123
9497
query I
9598
SELECT COUNT(*)
9699
FROM duckdb_functions()
97100
WHERE database_name = 'example' AND function_type = 'table';
98101
----
99-
122
102+
123
100103

101104
# The 34 pure table functions (no TABLE parameter type)
102105
# Note: make_series appears 5 times (5 overloads), make_pairs appears 3 times,

0 commit comments

Comments
 (0)