|
| 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