Skip to content

Commit dc264c0

Browse files
committed
test(fixtures): multi_batch_finish for the shared SQL suite
The multi-batch streaming-finalize shape had no fixture in ANY SDK — every finalize fixture everywhere emits exactly one batch, which is the easy case: over HTTP a producer is strictly lock-step, so a single-batch flush completes inside its one turn and never needs a continuation. Two or more do, and that path turned out to be broken in two independent places (vgi-rust f5ce331, the DuckDB client 81760e5) precisely because nothing ran it. Emits one batch per input row the substream saw: the first carries that substream's total, the rest carry 0. The split makes the failure modes distinguishable — a wrong SUM means a batch's CONTENTS were lost, a wrong COUNT means a whole BATCH was, and a truncated flush still sums correctly so only the count betrays it. Both invariants hold at any substream fan-out. Mirrors vgi-python's MultiBatchFinishFunction; backs vgi/test/sql/integration/table_in_out/multi_batch_finalize.test, which now runs green against all five workers.
1 parent d4ecce8 commit dc264c0

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

vgi-example-worker/src/main/java/farm/query/vgi/example/Main.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
import farm.query.vgi.example.tableinout.FilterBySettingFunction;
8484
import farm.query.vgi.example.tableinout.SecretInOutFunction;
8585
import farm.query.vgi.example.tableinout.SlowCancellableInoutFunction;
86+
import farm.query.vgi.example.tableinout.MultiBatchFinishFunction;
8687
import farm.query.vgi.example.tableinout.SubstreamPartialSumFunction;
8788
import farm.query.vgi.example.tensor.UnnestTensorRowsFunction;
8889
import farm.query.vgi.example.tableinout.RepeatInputsFunction;
@@ -668,6 +669,7 @@ private static void registerTableInOuts(Worker w) {
668669
new UnnestTensorRowsFunction(),
669670
new SecretInOutFunction(),
670671
// Per-substream streaming finalize (parallel_finalize.test).
672+
new MultiBatchFinishFunction(),
671673
new SubstreamPartialSumFunction(),
672674
// Blended ("UNNEST-style") RowTransformFunctions — positional
673675
// args ARE the per-row input columns (blended.test,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright 2026 Query Farm LLC - https://query.farm
2+
3+
package farm.query.vgi.example.tableinout;
4+
5+
import farm.query.vgi.function.FunctionMetadata;
6+
import farm.query.vgi.function.FunctionSpec;
7+
import farm.query.vgi.internal.SchemaUtil;
8+
import farm.query.vgi.protocol.BindResponse;
9+
import farm.query.vgi.storage.BoundStorage;
10+
import farm.query.vgi.storage.FrameworkNs;
11+
import farm.query.vgi.storage.FunctionStorage;
12+
import farm.query.vgi.tableinout.TableInOutBindParams;
13+
import farm.query.vgi.tableinout.TableInOutExchangeState;
14+
import farm.query.vgi.tableinout.TableInOutFunction;
15+
import farm.query.vgi.tableinout.TableInOutInitParams;
16+
import farm.query.vgi.types.ScalarHelpers;
17+
import farm.query.vgi.types.Schemas;
18+
import farm.query.vgirpc.AnnotatedBatch;
19+
import farm.query.vgirpc.CallContext;
20+
import farm.query.vgirpc.OutputCollector;
21+
import farm.query.vgirpc.wire.Allocators;
22+
import org.apache.arrow.vector.BigIntVector;
23+
import org.apache.arrow.vector.FieldVector;
24+
import org.apache.arrow.vector.VectorSchemaRoot;
25+
import org.apache.arrow.vector.types.pojo.Field;
26+
import org.apache.arrow.vector.types.pojo.FieldType;
27+
import org.apache.arrow.vector.types.pojo.Schema;
28+
29+
import java.nio.ByteBuffer;
30+
import java.nio.ByteOrder;
31+
import java.util.ArrayList;
32+
import java.util.List;
33+
import java.util.UUID;
34+
import java.util.concurrent.ConcurrentHashMap;
35+
36+
/**
37+
* {@code multi_batch_finish(data TABLE)} — a streaming FINALIZE that emits
38+
* MANY batches.
39+
*
40+
* <p>Every other finalize fixture, in every SDK, emits exactly ONE batch — and
41+
* one batch is the easy case: over HTTP a producer is strictly lock-step, so a
42+
* single-batch flush completes inside its one turn and never needs a
43+
* continuation. Two or more do, and that path was broken in two independent
44+
* places (the Rust worker's flush producer and the DuckDB client's finalize
45+
* drain) for as long as no fixture emitted a second batch.
46+
*
47+
* <p>It emits one batch per input row the substream saw: the first carries that
48+
* substream's total, the rest carry 0. The split makes the two failure modes
49+
* tell themselves apart — a wrong {@code SUM} means a batch's CONTENTS were
50+
* lost or duplicated, a wrong {@code COUNT} means a whole BATCH was. The second
51+
* is what catches a truncated flush, because the rows that do arrive are
52+
* correct and only the count betrays the missing ones.
53+
*
54+
* <p>Both invariants hold at any substream fan-out, so the SQL test needs no
55+
* assumption about thread count. Mirrors vgi-python's
56+
* {@code MultiBatchFinishFunction}; backs
57+
* {@code vgi/test/sql/integration/table_in_out/multi_batch_finalize.test}.
58+
*/
59+
public final class MultiBatchFinishFunction implements TableInOutFunction {
60+
61+
private static final FunctionSpec SPEC = FunctionSpec.builder("multi_batch_finish")
62+
.metadata(FunctionMetadata.describe(
63+
"Streaming finalize that emits one batch per input row (multi-batch flush)")
64+
.withCategories("testing", "aggregation"))
65+
.table("data")
66+
.build();
67+
68+
@Override public FunctionSpec spec() { return SPEC; }
69+
70+
@Override public boolean hasFinalize() { return true; }
71+
72+
@Override public BindResponse onBind(TableInOutBindParams params) {
73+
Schema in = params.inputSchema();
74+
if (in == null || in.getFields().isEmpty()) {
75+
return BindResponse.forSchema(SchemaUtil.serializeSchema(new Schema(List.of())));
76+
}
77+
String name = in.getFields().get(0).getName();
78+
return BindResponse.forSchema(SchemaUtil.serializeSchema(new Schema(List.of(
79+
new Field(name, new FieldType(true, Schemas.INT64, null), null)))));
80+
}
81+
82+
/**
83+
* Live storage views for the exchanges in flight in THIS process, keyed by
84+
* a per-exchange id the state can serialize. Same mechanism as
85+
* {@link SubstreamPartialSumFunction}: a {@code BoundStorage} wraps a live
86+
* SQLite connection and cannot ride an HTTP continuation token, but the id
87+
* can, and the state re-resolves the view from here on the other side.
88+
*/
89+
private static final ConcurrentHashMap<String, BoundStorage> LIVE_STORAGE =
90+
new ConcurrentHashMap<>();
91+
92+
@Override public TableInOutExchangeState createExchange(TableInOutInitParams params) {
93+
String key = UUID.randomUUID().toString();
94+
LIVE_STORAGE.put(key, params.storage());
95+
return new State(key, params.outputSchema());
96+
}
97+
98+
/**
99+
* Emit one batch per input row this substream saw — the first carrying the
100+
* substream's total, the rest zero. An input-less substream emits nothing.
101+
*/
102+
@Override public List<VectorSchemaRoot> finish(TableInOutInitParams params) {
103+
long total = 0;
104+
long rows = 0;
105+
for (FunctionStorage.KV kv : params.storage().stateDrain(FrameworkNs.TIO_STATE)) {
106+
ByteBuffer buf = ByteBuffer.wrap(kv.value()).order(ByteOrder.LITTLE_ENDIAN);
107+
total += buf.getLong();
108+
rows += buf.getLong();
109+
}
110+
List<VectorSchemaRoot> out = new ArrayList<>();
111+
for (long i = 0; i < rows; i++) {
112+
VectorSchemaRoot root = VectorSchemaRoot.create(params.outputSchema(), Allocators.root());
113+
root.allocateNew();
114+
((BigIntVector) root.getVector(0)).setSafe(0, i == 0 ? total : 0L);
115+
root.setRowCount(1);
116+
out.add(root);
117+
}
118+
return out;
119+
}
120+
121+
/** Accumulate column-0 sums and the row count; persist both after every batch. */
122+
public static final class State extends TableInOutExchangeState {
123+
/** Key into {@link #LIVE_STORAGE}; survives the state token. */
124+
public String storageKey;
125+
/** Emit schema (Arrow schemas ride the token as IPC bytes). */
126+
public Schema outputSchema;
127+
/** Running sum of column 0 across the batches seen so far. */
128+
public long total;
129+
/** Running count of rows seen so far — this is the batch count finish() emits. */
130+
public long rows;
131+
/** Re-resolved on first use after a token round-trip. */
132+
private transient BoundStorage storageRef;
133+
134+
/** No-arg constructor for HTTP state-token deserialization. */
135+
public State() {}
136+
137+
State(String storageKey, Schema outputSchema) {
138+
this.storageKey = storageKey;
139+
this.outputSchema = outputSchema;
140+
}
141+
142+
private BoundStorage storage() {
143+
if (storageRef == null) {
144+
storageRef = LIVE_STORAGE.get(storageKey);
145+
if (storageRef == null) {
146+
throw new IllegalStateException(
147+
"multi_batch_finish: no live storage for exchange " + storageKey
148+
+ " (state resumed in a different worker process?)");
149+
}
150+
}
151+
return storageRef;
152+
}
153+
154+
@Override public void onInputBatch(AnnotatedBatch input, OutputCollector out, CallContext ctx) {
155+
VectorSchemaRoot in = input.root();
156+
FieldVector col = in.getVector(0);
157+
int n = in.getRowCount();
158+
for (int i = 0; i < n; i++) {
159+
if (!col.isNull(i)) total += ScalarHelpers.toLong(col, i);
160+
}
161+
rows += n;
162+
// Upsert (total, rows) keyed per worker process; finish() drains and
163+
// sums every entry.
164+
byte[] value = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
165+
.putLong(total).putLong(rows).array();
166+
storage().statePut(FrameworkNs.TIO_STATE,
167+
BoundStorage.packIntKey(ProcessHandle.current().pid()), value);
168+
VectorSchemaRoot empty = VectorSchemaRoot.create(outputSchema, Allocators.root());
169+
empty.setRowCount(0);
170+
out.emit(empty);
171+
}
172+
}
173+
}

0 commit comments

Comments
 (0)