Skip to content

Commit cfe8031

Browse files
rustyconoverclaude
andcommitted
release: 0.24.0 — per-catalog attach options, and a gated ATTACH that answers
`attach/attach_options_required.test` was skipping here: upstream 1f1d7b1 split the `required` assertions out of the shared attach_options_echo.test behind VGI_ATTACH_OPTIONS_REQUIRED_WORKER, because the catalog they need existed only in the Python fixture. This grows the same catalog. - `Worker.ExtraCatalog` gains a 5th component, `attachOptions`: an auxiliary catalog advertises its own specs on its `catalog_catalogs()` row and enforces them at its own attach. A 4-arg convenience constructor keeps every existing call site source-compatible. Deliberately per-catalog rather than folded into `Worker.attachOptions(...)` — one worker serves a catalog that requires an option next to one that takes none, which is exactly what the test asserts. - Fixture: `AttachOptionsFixture.requiredSpecs()` (`api_key` required, `region` defaulting) as the `attach_options_required` catalog of the attach-options worker, mirroring vgi-python's `RequiredAttachOptions`. ci/run-integration.sh points the env var at that same worker on both lanes, so the test runs. - The refusal path had never been exercised in Java, and it HUNG. `AttachOptionRequirements` throws `RpcError("ValueError", …)`, whose `errorKind()` is null as documented, and vgirpc wrote that null into the error batch's metadata — throwing in the flatbuffer writer, so the batch never reached the wire and the client waited on a response that would never come. Fixed in vgirpc 0.21.1; pinned here (build.gradle.kts + VGI_RPC_JAVA_REF). Global functions needed no work: they shipped in 0.22.0 and both global_functions tests pass against an extension carrying vgi 21fdd4e. CI's red on them is the community extension lagging the consuming side — see CLAUDE.md. Local: launch 296 files / 268 pass / 28 skip / 0 fail (attach_options_required now among the passes); the two attach tests also verified under shm and over HTTP. `:vgi:test` green with a new ExtraCatalogAttachOptionsTest; javadoc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8a8dda6 commit cfe8031

10 files changed

Lines changed: 222 additions & 11 deletions

File tree

.github/workflows/integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ env:
3636
# The vgi-rpc-java commit built from source (composite include) for the worker's
3737
# HTTP features. Drop once a vgirpc release with these features is published and
3838
# pinned in vgi/build.gradle.kts.
39-
VGI_RPC_JAVA_REF: v0.21.0
39+
VGI_RPC_JAVA_REF: v0.21.1
4040

4141
jobs:
4242
integration:

CLAUDE.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ change to `~/Development/vgi-rpc-java/` doesn't show up, run
183183
## Releasing (Maven Central)
184184

185185
Published: **`farm.query:vgi`** (this repo) and **`farm.query:vgirpc`** /
186-
`vgirpc-oauth` (the sibling). Latest as of 2026-07-21: **vgi 0.20.0 → vgirpc
187-
0.17.0**. To cut a release: bump `version` in `build.gradle.kts`, push, then
186+
`vgirpc-oauth` (the sibling). Latest as of 2026-08-10: **vgi 0.24.0 → vgirpc
187+
0.21.1**. To cut a release: bump `version` in `build.gradle.kts`, push, then
188188
create a GitHub Release whose tag is the version (`v0.2.0` for `0.2.0`). The
189189
`release.yml` workflow (trigger: `release: published`) verifies tag == version,
190190
runs tests, and publishes. Both repos now set
@@ -288,6 +288,53 @@ older interfaces** (`TableFunction`, `TableInOutFunction`, etc.) — the
288288
`ScalarFn` style hasn't been extended to those because their richer
289289
lifecycle methods + per-execution state don't translate one-for-one.
290290

291+
## State of play (as of 2026-08-10, gated attach options)
292+
293+
**Global functions were already done** (0.22.0 — `Worker.registerGlobalFunctions` /
294+
`globalFunctionPrefix`, the two protocol-1.3.0 attach fields, the four
295+
`global_*` probes). Against a locally built extension carrying vgi `21fdd4e`,
296+
`global_functions/{basic,lifecycle}.test` both pass. **CI is red on them anyway,
297+
and it is not the worker:** the lane installs `vgi FROM community`, and the
298+
published build predates the consuming side — `vgi_global_functions()` doesn't
299+
exist and `ATTACH … (global_functions false)` is an unknown option. Verified by
300+
`FORCE INSTALL vgi FROM community` under the same `haybarn-v1.5.5-rc1` runner CI
301+
uses: `count(vgi_global_functions) = 0`. Nothing to do here until the community
302+
extension republishes; the same lag is what fails `attach_options_echo.test`'s
303+
`opt.required` read (upstream `1f1d7b1` has since moved those assertions out).
304+
305+
**Per-catalog attach options** (this release). `Worker.ExtraCatalog` gained a
306+
5th component, `attachOptions` — auxiliary catalogs advertise their own specs on
307+
their `catalog_catalogs()` row and enforce them at their own attach. A 4-arg
308+
convenience constructor keeps every existing call site compiling. The main
309+
catalog's `Worker.attachOptions(...)` is unchanged and unrelated: one worker can
310+
serve a catalog that requires an option next to one that takes none.
311+
- Fixture: `AttachOptionsFixture.REQUIRED_CATALOG_NAME` /`requiredSpecs()`
312+
(`api_key` required, `region` defaulting) registered as an extra catalog in
313+
Main's attach_options worker mode, mirroring vgi-python's
314+
`RequiredAttachOptions`. Drives `attach/attach_options_required.test`, which
315+
upstream gated behind `VGI_ATTACH_OPTIONS_REQUIRED_WORKER` when the shared
316+
file's required assertions broke every non-Python SDK. `ci/run-integration.sh`
317+
points that var at the same attach-options worker on both lanes, so the test
318+
runs rather than skipping.
319+
- **The refusal path had never been exercised, and it hung.**
320+
`AttachOptionRequirements` throws `RpcError("ValueError", …)` — whose
321+
`errorKind()` is null, as documented — and vgirpc's `Wire.errorMetadata` put
322+
that null in the batch metadata unconditionally. It threw in the flatbuffer
323+
key/value writer, so the error batch never reached the wire and the client
324+
waited forever. Fixed in **vgirpc 0.21.1** (`Wire` skips a null kind,
325+
`ErrorMetadataTest` pins it); this repo pins that version. Symptom to
326+
recognise: a worker-side `NullPointerException … Utf8Safe.computeEncodedLength`
327+
in `VGI_WORKER_STDERR`, with the client hung rather than erroring.
328+
- **Local:** launch 296 files / 268 pass / 28 skip / 0 fail; the two attach
329+
tests also verified under shm and against an HTTP worker. `:vgi:test` green
330+
(new `ExtraCatalogAttachOptionsTest`), `:vgi:javadoc` clean.
331+
332+
> **Local CI-lane repro is misleading.** `ci/run-integration.sh` against the
333+
> homebrew `haybarn-unittest` fails ~every test at ATTACH ("field count differs:
334+
> expected 14, actual 17") — that binary's cached community extension is far
335+
> older than CI's. Use `~/Development/vgi/build/release/test/unittest` for local
336+
> work; only the GitHub lane says anything real about the published extension.
337+
291338
## State of play (as of 2026-07-23, result-cache same-name)
292339

293340
**Cache-key schema disambiguation.** The extension fixed a result-cache-key bug:

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ plugins {
88

99
allprojects {
1010
group = "farm.query"
11-
version = "0.23.0"
11+
version = "0.24.0"
1212

1313
repositories {
1414
mavenCentral()

ci/run-integration.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ case "$TRANSPORT" in
121121
export VGI_VERSIONED_WORKER="launch:${HERE}/wrappers/vgi-worker-versioned"
122122
export VGI_VERSIONED_TABLES_WORKER="launch:${HERE}/wrappers/vgi-worker-versioned-tables"
123123
export VGI_ATTACH_OPTIONS_WORKER="launch:${HERE}/wrappers/vgi-worker-attach-options"
124+
# Same worker: it also serves the `attach_options_required` catalog, whose
125+
# gated ATTACH is asserted separately (upstream split those assertions out of
126+
# the shared attach_options_echo.test behind this env var).
127+
export VGI_ATTACH_OPTIONS_REQUIRED_WORKER="$VGI_ATTACH_OPTIONS_WORKER"
124128
# bad-enum fixture worker: serves the example catalog but advertises an
125129
# unrecognized null_handling enum for `double`, driving the C++ parser's
126130
# strict-enum rejection (bad_enum.test). Skipped over HTTP (like
@@ -167,6 +171,7 @@ case "$TRANSPORT" in
167171
# has an explicit "Pool / HTTP safety" section), so http serves it fine.
168172
ao_port="$(boot_http_worker "${HERE}/wrappers/vgi-worker-attach-options")"
169173
export VGI_ATTACH_OPTIONS_WORKER="http://localhost:${ao_port}"
174+
export VGI_ATTACH_OPTIONS_REQUIRED_WORKER="$VGI_ATTACH_OPTIONS_WORKER"
170175
echo "attach_options http worker on ${VGI_ATTACH_OPTIONS_WORKER}"
171176
# NB: VGI_TEST_DEDICATED_WORKER stays UNSET here. The buffering crash /
172177
# pool-recovery tests SIGKILL their worker mid-process, which is only safe on

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,18 @@ public static void main(String[] args) {
307307
.catalogName(catalogName)
308308
.attachOptions(farm.query.vgi.example.table.AttachOptionsFixture
309309
.declaredSpecs().toArray(new farm.query.vgi.AttachOptionSpec[0]))
310-
.registerTable(new farm.query.vgi.example.table.EchoAttachOptionsFunction());
310+
.registerTable(new farm.query.vgi.example.table.EchoAttachOptionsFunction())
311+
// Second catalog from the same worker, gated on an option the
312+
// caller must supply: it advertises that at discovery and
313+
// refuses the anonymous ATTACH, rather than attaching into
314+
// something that reads as an empty catalog. It carries no
315+
// functions of its own — the attach IS what's under test
316+
// (attach/attach_options_required.test).
317+
.registerExtraCatalog(new Worker.ExtraCatalog(
318+
farm.query.vgi.example.table.AttachOptionsFixture.REQUIRED_CATALOG_NAME,
319+
null, null,
320+
"Attach-options catalog gated on a required option",
321+
farm.query.vgi.example.table.AttachOptionsFixture.requiredSpecs()));
311322
runWorker(ao, args);
312323
return;
313324
}

vgi-example-worker/src/main/java/farm/query/vgi/example/table/AttachOptionsFixture.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ private AttachOptionsFixture() {}
3030

3131
public static final String CATALOG_NAME = "attach_options";
3232

33+
/**
34+
* A second catalog from the same worker, declaring one option the caller
35+
* must supply. Kept separate from {@link #CATALOG_NAME} so the
36+
* defaults-and-round-trip coverage there keeps attaching with no options at
37+
* all. Mirrors vgi-python's {@code REQUIRED_CATALOG_NAME}.
38+
*/
39+
public static final String REQUIRED_CATALOG_NAME = "attach_options_required";
40+
3341
private static final ArrowType DATE32 = new ArrowType.Date(DateUnit.DAY);
3442
private static final ArrowType TIME64_US = new ArrowType.Time(TimeUnit.MICROSECOND, 64);
3543
private static final ArrowType TIMESTAMP_US = new ArrowType.Timestamp(TimeUnit.MICROSECOND, null);
@@ -83,4 +91,17 @@ public static List<AttachOptionSpec> declaredSpecs() {
8391
Schemas.nullable("b", Schemas.UTF8)),
8492
Map.of("a", 1L, "b", "x")));
8593
}
94+
95+
/**
96+
* Options of the {@link #REQUIRED_CATALOG_NAME} catalog: one the caller has
97+
* to supply (no default to fall back on) next to one that defaults, so a
98+
* test can tell the two apart in the discovery listing.
99+
*
100+
* @return the gated catalog's declared option specs
101+
*/
102+
public static List<AttachOptionSpec> requiredSpecs() {
103+
return List.of(
104+
AttachOptionSpec.required("api_key", "API key", Schemas.UTF8),
105+
AttachOptionSpec.of("region", "Region", Schemas.UTF8, "us-east-1"));
106+
}
86107
}

vgi/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ plugins {
55

66
dependencies {
77
// farm.query is the published group (see ../build.gradle.kts allprojects).
8-
api("farm.query:vgirpc:0.21.0")
8+
api("farm.query:vgirpc:0.21.1")
99
implementation("org.slf4j:slf4j-api:2.0.16")
1010
// Cross-process aggregate state store. DuckDB spawns multiple worker
1111
// subprocesses for parallel aggregation; SQLite's file locking gives us

vgi/src/main/java/farm/query/vgi/Worker.java

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ public final class Worker {
4343
* <p>1.3.0 added {@code global_functions} / {@code global_function_prefix}
4444
* to the {@code catalog_attach} result (positions 14/15, before
4545
* {@code resolved_data_version}): functions a worker asks the client to
46-
* publish into its global namespace. This port advertises none (empty
47-
* list, empty prefix), but must carry the fields — the extension matches
48-
* the response schema exactly. */
46+
* publish into its global namespace — see {@link #registerGlobalFunctions}
47+
* and {@link #globalFunctionPrefix(String)}. A worker that opts out still
48+
* carries the fields (empty list, empty prefix): the extension matches the
49+
* response schema exactly. */
4950
public static final String VGI_PROTOCOL_VERSION = "1.3.0";
5051

5152
private String catalogName = "vgi";
@@ -404,9 +405,33 @@ public Worker schemaTags(String schema, Map<String, String> tags) {
404405
* @param implementationVersion the advertised/resolved implementation version
405406
* @param dataVersion the advertised {@code data_version_spec} and resolved data version
406407
* @param schemaComment the comment on the catalog's single {@code main} schema
408+
* @param attachOptions ATTACH-time options this catalog alone declares, advertised on its
409+
* {@code catalog_catalogs()} row and enforced at its attach. Separate from
410+
* {@link #attachOptions(AttachOptionSpec...)}, which is the main catalog's:
411+
* a worker may serve one catalog that requires an option and another that
412+
* takes none.
407413
*/
408414
public record ExtraCatalog(String name, String implementationVersion, String dataVersion,
409-
String schemaComment) {}
415+
String schemaComment, List<AttachOptionSpec> attachOptions) {
416+
417+
/** Defensive copy; a null option list reads as none declared. */
418+
public ExtraCatalog {
419+
attachOptions = attachOptions == null ? List.of() : List.copyOf(attachOptions);
420+
}
421+
422+
/**
423+
* An auxiliary catalog declaring no attach options of its own.
424+
*
425+
* @param name the catalog name used in {@code ATTACH '<name>' ...}
426+
* @param implementationVersion the advertised/resolved implementation version
427+
* @param dataVersion the advertised {@code data_version_spec} and resolved data version
428+
* @param schemaComment the comment on the catalog's single {@code main} schema
429+
*/
430+
public ExtraCatalog(String name, String implementationVersion, String dataVersion,
431+
String schemaComment) {
432+
this(name, implementationVersion, dataVersion, schemaComment, List.of());
433+
}
434+
}
410435

411436
private final Map<String, ExtraCatalog> extraCatalogs = new LinkedHashMap<>();
412437

vgi/src/main/java/farm/query/vgi/internal/VgiServiceImpl.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -987,9 +987,13 @@ public ItemsResponse catalog_catalogs() {
987987
worker.dataVersionSpec(), attachOptionBytes,
988988
worker.releases(), worker.sourceUrl()));
989989
for (Worker.ExtraCatalog extra : worker.extraCatalogs().values()) {
990+
List<byte[]> extraOptionBytes = new ArrayList<>();
991+
for (farm.query.vgi.AttachOptionSpec spec : extra.attachOptions()) {
992+
extraOptionBytes.add(AttachOptionSpecSerializer.serialize(spec));
993+
}
990994
items.add(CatalogInfoSerializer.serialize(
991995
extra.name(), extra.implementationVersion(), extra.dataVersion(),
992-
List.of(), List.of(), null));
996+
extraOptionBytes, List.of(), null));
993997
}
994998
return new ItemsResponse(items);
995999
}
@@ -1082,6 +1086,10 @@ public void catalog_transaction_rollback(byte[] attach_opaque_data, byte[] trans
10821086
public CatalogAttachResult catalog_attach(CatalogAttachRequest request, CallContext ctx) {
10831087
Worker.ExtraCatalog extra = worker.extraCatalogs().get(request.name());
10841088
if (extra != null) {
1089+
// Enforced against THIS catalog's specs, not the main catalog's: the
1090+
// same worker may serve one catalog that requires an option and
1091+
// another that takes none.
1092+
AttachOptionRequirements.validate(request.name(), extra.attachOptions(), request.options());
10851093
// MetaWorker-style auxiliary catalog: a random per-ATTACH opaque id
10861094
// is the storage scope isolating this session's state; the client
10871095
// persists and resends it, so it also survives worker restarts.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright 2026 Query Farm LLC - https://query.farm
2+
3+
package farm.query.vgi.internal;
4+
5+
import farm.query.vgi.AttachOptionSpec;
6+
import farm.query.vgi.Worker;
7+
import farm.query.vgi.protocol.CatalogAttachRequest;
8+
import farm.query.vgi.protocol.ItemsResponse;
9+
import farm.query.vgi.types.Schemas;
10+
import farm.query.vgirpc.RpcError;
11+
import farm.query.vgirpc.wire.Allocators;
12+
import org.apache.arrow.vector.VarCharVector;
13+
import org.apache.arrow.vector.VectorSchemaRoot;
14+
import org.apache.arrow.vector.complex.ListVector;
15+
import org.apache.arrow.vector.types.pojo.Schema;
16+
import org.apache.arrow.vector.util.Text;
17+
import org.junit.jupiter.api.Test;
18+
19+
import java.util.List;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertThrows;
23+
import static org.junit.jupiter.api.Assertions.assertTrue;
24+
25+
/**
26+
* ATTACH options declared by an auxiliary catalog rather than the worker's main
27+
* one. A worker may serve a catalog that requires an option next to one that
28+
* takes none, so both the discovery listing and the refusal are per-catalog.
29+
*/
30+
class ExtraCatalogAttachOptionsTest {
31+
32+
private static final String GATED = "gated";
33+
34+
private static Worker worker() {
35+
return Worker.builder()
36+
.catalogName("host")
37+
.registerExtraCatalog(new Worker.ExtraCatalog(GATED, null, null, "gated catalog",
38+
List.of(
39+
AttachOptionSpec.required("api_key", "API key", Schemas.UTF8),
40+
AttachOptionSpec.of("region", "Region", Schemas.UTF8, "us-east-1"))));
41+
}
42+
43+
private static VgiServiceImpl service(Worker w) {
44+
return new VgiServiceImpl(w, w.scalars(), w.tables(), w.tableInOuts(), w.aggregates());
45+
}
46+
47+
/** A one-row batch whose column names are the supplied option keys. */
48+
private static byte[] options(String name, String value) {
49+
Schema schema = Schemas.of(Schemas.nullable(name, Schemas.UTF8));
50+
try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, Allocators.root())) {
51+
root.allocateNew();
52+
((VarCharVector) root.getVector(name)).setSafe(0, new Text(value));
53+
root.setRowCount(1);
54+
return BatchUtil.writeSingleBatch(root);
55+
}
56+
}
57+
58+
/** Count of {@code attach_option_specs} on a serialized CatalogInfo row. */
59+
private static int declaredOptionCount(byte[] catalogInfo) {
60+
return BatchUtil.withReadBatch(catalogInfo, Allocators.root(),
61+
root -> ((ListVector) root.getVector("attach_option_specs")).getObject(0).size());
62+
}
63+
64+
@Test
65+
void auxiliaryCatalogAdvertisesItsOwnOptionsAtDiscovery() {
66+
ItemsResponse catalogs = service(worker()).catalog_catalogs();
67+
assertEquals(2, catalogs.items().size());
68+
assertEquals(0, declaredOptionCount(catalogs.items().get(0)), "main catalog declares none");
69+
assertEquals(2, declaredOptionCount(catalogs.items().get(1)));
70+
}
71+
72+
@Test
73+
void omittingARequiredOptionFailsTheAttach() {
74+
RpcError e = assertThrows(RpcError.class, () -> service(worker())
75+
.catalog_attach(new CatalogAttachRequest(GATED, null, null, null), null));
76+
assertTrue(e.errorMessage().contains("required option 'api_key'"), e.errorMessage());
77+
}
78+
79+
@Test
80+
void supplyingItAttachesNormally() {
81+
var result = service(worker()).catalog_attach(
82+
new CatalogAttachRequest(GATED, options("api_key", "secret"), null, null), null);
83+
assertTrue(result.attach_opaque_data().length > 0);
84+
}
85+
86+
/** The gate belongs to the catalog that declared it: the host catalog
87+
* declares no options and still attaches with none supplied. */
88+
@Test
89+
void theHostCatalogIsUnaffected() {
90+
var result = service(worker()).catalog_attach(
91+
new CatalogAttachRequest("host", null, null, null), null);
92+
assertTrue(result.attach_opaque_data().length > 0);
93+
}
94+
}

0 commit comments

Comments
 (0)