Guidance for Claude Code when working in this repository.
vgi-rpc-java is a Java 21 port of vgi-rpc (the Python reference lives at ~/Development/vgi-rpc). vgi-rpc is a transport-agnostic RPC framework built on Apache Arrow IPC: services are defined as Java interfaces, Arrow schemas are derived from method signatures / record component types, and calls flow over pipe, unix-socket, raw TCP, or HTTP transports as sequential Arrow IPC streams.
When the Python and Java implementations disagree, Python is the reference. Wire format, metadata keys, error semantics, and stream-state token layout must match byte-for-byte so the two can interoperate (the conformance suite runs a Python driver against the Java worker).
# Build everything (uses Gradle wrapper, toolchain pins to JDK 21)
./gradlew build
# Compile only
./gradlew compileJava
# JUnit tests (Arrow memory needs --add-opens java.base/java.nio — already set in root build.gradle.kts)
./gradlew test
# Single module
./gradlew :vgirpc:test
# Assemble runnable distributions for workers
./gradlew installDist
# Python-driven conformance suite (builds first, then runs pytest against all transports)
./run_tests.sh
./run_tests.sh pipe # single transport
./run_tests.sh "echo_point" # pytest -k filter
./run_tests.sh --no-build … # skip gradle rebuild
# Inspect a single failing conformance test
./inspect.sh <test_id>
# Conformance suite under JaCoCo (one .exec per spawned worker, merged report)
./run_tests.sh --coverage # → vgirpc/build/reports/jacoco/jacocoConformanceReport/
# Combined coverage: JUnit lane + conformance lane (the honest "adequacy" number)
./gradlew :vgirpc:test :vgirpc:java22Test # JUnit + FFM exec data
./run_tests.sh --coverage # conformance exec data
./gradlew :vgirpc:jacocoMergedReport # → .../jacocoMergedReport/run_tests.sh requires JAVA_HOME=/opt/homebrew/opt/openjdk@21 (set inside the script) and the Python venv at ~/Development/vgi-rpc/.venv. Full pytest output is written to /tmp/pytest_java.txt.
Before pushing: ./gradlew build must pass, and ./run_tests.sh must pass for the transports that apply to the change.
vgirpc— core library. Wire protocol, transports, HTTP server/client (Jetty 12), schema derivation, marshalling, external-location support, shared-memory segment primitive.vgirpc-oauth— optional OAuth/JWT bits (JWKS validation, PKCE, signed cookies). Split out so core users don't pullnimbus-jose-jwt(~500 KB).vgirpc-s3— S3ExternalStoragebackend.vgirpc-gcs— Google Cloud StorageExternalStoragebackend.conformance— the conformance service definition (ConformanceService,AllTypes,Point,BoundingBox,RichHeader, etc.) shared between the Java worker and the Python driver.conformance-worker— runnable entry point (Main) that servesConformanceServiceover pipe / unix / tcp / HTTP based on CLI args (--unix <path>,--tcp [HOST:]PORT,--http). Packaged viainstallDist.benchmark+benchmark-worker— equivalent pair for the benchmark service.
Package root: farm.query.vgirpc
RpcServer— dispatches unary + streaming calls, owns server identity, handles__describe__. Call sites useWire.writeZeroBatch(writer, schema, meta)for log/error/tick batches — don't inline theVectorSchemaRoot.create + allocateNew + setRowCount(0) + writeBatchsequence again.RpcConnection— client-sidejava.lang.reflect.Proxyfactory. Turns a typed interface into an RPC proxy over anRpcTransport.ClientStreamSession— client side of a streaming exchange; buffers params, writes ticks / input batches, reads output batches.CallContext+AuthContext+AuthScope— request-scoped context injected into method implementations via an optionalCallContext ctxparameter (the parameter is NOT declared on the service interface, it's detected reflectively at dispatch time).AuthScopeis the thread-local bridge for HTTP auth.RpcMethodInfo/MethodType/ServiceIntrospector— reflective introspection of a service interface. Pulls method type (UNARY/STREAM), params schema, result schema, auth requirements.Stream<S>/StreamState/ProducerState/ExchangeState— streaming primitives. A streaming method returnsStream<S extends StreamState>; the state'sprocess(input, out, ctx)is called once per tick.OutputCollector— per-tick output buffer. Collects zero or one data batch plus any log/error zero-row batches.Introspect/AnnotatedBatch/RpcError/VersionError— protocol types.
-
wire/—IpcStreamReader,IpcStreamWriter,Metadata(allvgi_rpc.*metadata key constants),Allocators(sharedBufferAllocatorroot),Wire(higher-level helpers:requestMetadata,validateRequestVersion,requireMethodName,writeErrorStream,writeZeroBatch,errorMetadata,classify,errorFromMetadata,messageFromMetadata),MapToList(Arrow map↔list-of-struct coercion). -
transport/—RpcTransportinterface,StdioTransport,SubprocessTransport,UnixSocketTransport,TcpSocketTransport(raw Arrow-IPC framing over a bare TCP socket — the network analog ofUnixSocketTransport; no auth/TLS, loopback-default, trusted networks only). -
http/— Jetty-based HTTP transport.HttpServer,HttpPreHandler,HttpStreamHandler(stateless streaming: state travels in a signedStateTokenin custom metadata),StateSerializer,StateToken,Authenticator,AuthException,TokenExpiredException.Unauthorized responses. Every 401 follows
docs/unauthorized-spec.mdin the Python repo.AuthReasonis the closed set of codes; the reason is read off theAuthExceptionsubtype (MissingCredentials→missing_credential,InvalidCredentials→invalid_credential,AuthFailure→ whatever it declares, defaulting tounauthorized) — never guessed from message text.HttpServer.writeUnauthorizedrendersVGI-Auth-Reason,Cache-Control: no-store, and the JSON envelope{error, reason, detail, proxy_hint?}; this port always answers JSON, which §4.2 permits. The proxy note (VGI-Auth-Proxy-Required: true+proxy_hint) comes from server configuration only —Config.proxyProofRequiredcontributesVGI-Proxy-Proofin require mode,Config.proxyAuthHeadersstates headers for a custom authenticator — so it is identical on every 401 and discloses nothing. Cross-language conformance group:TestUnauthorized. CORS.CorsPolicy(package-private, applied fromRouterServlet.serviceso the grant rides every answer, not just the preflight). Strictly opt-in:Config.corsOriginsempty ⇒ not oneAccess-Control-*header, which is itself a conformance contract (TestCorsOffMode). A single"*"allows all — safe only because credentials here are header-borne and the server never setsAccess-Control-Allow-Credentials; anything else is matched case-insensitively againstOrigin, echoed back, and paired withVary: Origin.Access-Control-Allow-Headersechoes the preflight'sAccess-Control-Request-Headers(same answer Go/Rust/Python give), falling back to the request-side surface.Access-Control-Expose-Headersis built byHttpServer.corsExposeHeaders()from the same conditions asapplyCapabilityHeaders— whatever this server advertises, it exposes. Adding aVGI-*/X-VGI-*response header means adding it to both: an advertised-but-unexposed header is invisible to a browser and to nothing else, so every non-CORS test passes right through the omission. Cross-language conformance group:TestCors. Token introspection.TokenIntrospection+TokenResolver+TokenIdentitybackPOST {prefix}/__introspect_token__, which resolves an opaque bearer credential to a principal for a fronting proxy. Off unlessConfig.tokenIntrospection(resolver, principals)is called, and a disabled worker still answers404 {"error":"not_enabled"}— a caller classifies401/403/404as definitive and everything else as transient, so an unrouted path (which would dispatch a JSON body into the Arrow reader and 500) means retrying forever against a worker that will never support the feature. The response is a closed set ofprincipal/token_name/ttl_seconds; aclaimsfield would let a worker choose its caller's tenant routing, row scope and policy branch. The introspector allowlist has no permissive default (authentication and introspection are different capabilities), JWS-shaped subjects are refused without reaching the resolver, unknown/expired/malformed are byte-identical rejections, and the credential is SHA-256 digested rather than logged. It is deliberately not implemented by replaying the credential through the server's ownAuthenticator— seeTokenResolverfor the four ways that breaks. Advertised viaVGI-Token-Introspection: true. Conformance groups:TestTokenIntrospection(needs the--introspectworker) andTestTokenIntrospectionOffMode(ungated). Definitive vs transient.AuthUnavailableExceptionmeans "I could not find out whether the credential is bad" and sits outside theAuthExceptionhierarchy on purpose: everyAuthExceptionsubtype renders as a 401 andAuthenticator.chaincatches it to mean "not my credential, try the next", so an outage raised as one emerges as a 401 from the end of the chain — turning a sidecar restart into a fleet-wide re-login storm and poisoning callers' negative caches. Unchecked, so it propagates toRouterServlet.service, which renders503+Retry-After. -
http/auth/— shared authenticator implementations (bearer, mTLS/XFCC). JWT/OAuth lives in thevgirpc-oauthmodule to keep core deps lean. -
marshal/—Marshalling(row↔VectorSchemaRoot, type casting, parameter adaptation),RecordCodec(Java record ↔ row map). -
schema/—SchemaDerivation(Java type → Arrow schema),ArrowSerializableRecord,ArrowField,ArrowFieldType,Nullable,EnumDictionaryRegistry,StreamHeader. -
external/—ExternalStorage,ExternalLocationConfig,Externalizer(large batch → pointer batch),LocationResolver,ExternalFetcher. -
shm/—ShmSegmentfor zero-copy batch transfer between co-located processes. -
log/—Level,Message. Log messages are serialized as zero-row batches withvgi_rpc.log_level/vgi_rpc.log_message/vgi_rpc.log_extrametadata.
- Multiple IPC streams sequential on the same pipe; one request stream and one response stream per call.
- Every request batch carries
vgi_rpc.request_versionin custom metadata (Wire.requestMetadata) — server validates viaWire.validateRequestVersionand rejects mismatches withVersionError. - Unary: client sends params batch → server replies with zero or more log batches + one result/error batch.
- Stream: initial params exchange, then lockstep ticks (producer) or input batches (exchange) → server replies with log batches + one output batch per tick, until EOS.
- HTTP mapping:
POST /vgi/{method}(unary),POST /vgi/{method}/init(stream init),POST /vgi/{method}/exchange(stream exchange). Streaming state is stateless server-side:StateToken(HMAC-signed) rides in Arrow custom metadata between calls. - Errors become zero-row batches with
Level.EXCEPTIONlog metadata; the transport stays clean for the next call.Wire.errorFromMetadata/Wire.messageFromMetadatareconstruct on the client side.
- Java 21,
--release 21,-Xlint:all,-serial,-processing,-parameters(parameter names matter — the framework uses them to bind kwargs). - Prefer records for data classes (
AllTypes,Point,RichHeaderare records). - Prefer sealed types and pattern matching where they simplify dispatch.
- Try-with-resources for every
VectorSchemaRoot,IpcStreamWriter/Reader, and socket. - All
VectorSchemaRoots allocate fromAllocators.root()unless a sub-allocator is explicitly needed; closing them returns memory. - Metadata keys live in
wire/Metadata.java— never hard-code the string"vgi_rpc.*"elsewhere. - Zero-row control batches (log, error, tick, pointer) go through
Wire.writeZeroBatch— don't re-inline the allocate/setRowCount/writeBatch sequence. - Keep the wire path byte-compatible with Python. Before changing metadata keys, stream-state layout, or batch framing, check the Python implementation at
~/Development/vgi-rpc/vgi_rpc/.
- JUnit 5 for Java-side unit tests (
*Test.javaundersrc/test/java). Arrow memory needs--add-opens=java.base/java.nio=ALL-UNNAMED— already wired in the rootbuild.gradle.kts. - Conformance is driven from Python via
tests/test_java_conformance.pyand the othertests/test_java_*.pyfiles. These spawn the Java worker (built via./gradlew installDist) over the transport under test. The./run_tests.sh/./inspect.shentry points stay at the repo root. - The conformance driver expects
conformance-workerto printPORT:<port>on stdout when launched with--http(auto-port selection, matches the Python reference).
The reference's large_payload category exists because an unbuffered writer maps write() onto one write(2), and above INT_MAX on macOS a pipe short-writes exactly INT_MAX with no error (the peer then deadlocks) while a socket returns EINVAL. large_payload.echo_binary_4mib passes here on pipe/unix/tcp/http. large_payload.echo_binary_over_int32_max cannot pass and never will: it echoes 2**31 + 1 bytes, and a Java array caps at Integer.MAX_VALUE elements, so no byte[] can hold the value. The bytes do arrive — the worker reads all 2,147,483,649 of them off the wire — and Marshalling.requireRepresentableOnJvm then refuses them by name rather than letting Arrow's long→int length truncation surface as NegativeArraySizeException: -2147483647. Exclude it (vgi-rpc-test --filter '!large_payload.echo_binary_over_int32_max') rather than trying to fix it; lifting the ceiling means a non-array Java representation for large_binary, which is a protocol-visible API decision, not a bug fix.
The write side is fine at every size this port can reach: 2,147,483,640 bytes (Integer.MAX_VALUE - 7) round-trips clean over pipe, unix and tcp on macOS. Nothing on that path hands the kernel more than a 64 KiB buffer — see the IpcStreamWriter(OutputStream) javadoc for why, and IpcStreamWriterChunkingTest for the assertion that keeps it that way.
This port tracks vgi-rpc-python for wire compatibility. Two surfaces matter:
-
__describe__—Introspect.DESCRIBE_VERSION = "4".DESCRIBE_SCHEMAis the slim 8-column form:name,method_type,has_return,params_schema_ipc,result_schema_ipc,has_header,header_schema_ipc,is_exchange. Python-flavoured columns (doc,param_types_json,param_defaults_json,param_docs_json) are off the wire — the Protocol interface is the source of truth for human-readable type info. The response's custom metadata carriesvgi_rpc.protocol_hashviaIntrospect.computeProtocolHash, byte-identical to the Python algorithm.RpcServer.protocolHash()exposes it;RpcServer.setProtocolVersion(...)sets the optional human label. Within-port stable; cross-port byte equality is not guaranteed (Arrow IPC schema bytes differ across libraries). -
Access log —
AccessLogHook(AccessLogHook.java) implementsDispatchHookand writes one JSONL record per dispatch. The record conforms tovgi_rpc/access_log.schema.jsonin the Python repo and validates undervgi-rpc-test --access-log <path>.DispatchInfocarriesprotocol,protocolHash,protocolVersion,remoteAddr,requestData,streamId,cancelled,httpStatus,claims. Install viaRpcServer.setDispatchHook(new AccessLogHook(out, serverVersion)), orAccessLogHook.builder(out)for the spec's optional behaviours:sampleRate(deterministic per call, keyed onstream_idthenrequest_id, errors never sampled, out-of-range rejected at construction),asyncQueueSize(bounded, non-blocking, drops reported asdropped_records),logPayloads(false)(⇒truncated: "payload_omitted", which is not the size-driventrue),claimRedactor(ClaimRedactor.byKeyName()by default,ClaimRedactor.none()to opt out; a redactor that throws fails closed), andtraceCorrelator(TraceCorrelator.openTelemetry()readsSpan.current()reflectively so OTel stays off the core classpath —trace_id/span_idare emitted both or neither, and only when they are well-formed W3C hex).Egress accounting (§4.8) can't all be measured in the hook: response compression runs after the handler returns.
AccessLogScopeis the per-request thread-local that closes that gap —RouterServlet.serviceopens one,readBodystampsrequest_bytes(pre-decompression),writeArrowResponsestampsresponse_bytes(post-compression),Externalizer.maybeExternalizecountsexternalized_bytesat the single upload choke point, and the scope emits the parked records on close. Transports that install no scope (pipe / unix / TCP) keep logging inline. These are distinct from §4.6'sinput_bytes/output_bytes(logical Arrow buffers), which this port does not yet populate at all.HTTP stream turns (§1: "one record per
initand one perexchange/producecontinuation") are emitted byHttpStreamHandler, notRpcServer— HTTP streams never reachserveOne, so for a long time they produced no records at all while unary calls logged fine, which is backwards: streams are where the bytes are.beginTurn/StreamTurnfire the sameDispatchHookper HTTP request, after the point the turn is a genuine dispatch (a malformed body or an unopenable cursor is refused earlier and logs nothing, matching the reference). Thestream_idis minted at/init— beforemintInitTokens, so a producer that finishes in one turn still gets one — and travels in theCallToken, which is how every continuation's record joins the init's without any server-side state.DispatchInfo.requestState/responseStatecarry the decrypted cursor (§4.4): the wire token is opaque AEAD, and a log a reader cannot decode without the server's token key is not an audit trail. Java's state blob is CBOR (StateSerializer), not the Arrow IPC the spec names — the schema only constrains it to base64, and plaintext-not-ciphertext is the property that matters.X-VGI-RPC-Errorand thestatusfield both need to know a call failed, and neither can learn it from control flow: every error path serializes the exception into the response body and then returns normally.CallOutcome(a thread-local opened byRouterServlet.servicefor HTTP and byserveOnefor pipe/unix/TCP, nesting inertly when both apply) is set at the one choke point every error passes through —Wire.errorMetadata— so a new error path cannot forget to raise it.writeArrowResponsereads it to set the header (never unconditionally: a flag on every response is the same outage as no flag), andAccessLogHookreads it when the dispatcher reported no exception.The two readers ask different questions, so there are two predicates.
AccessLogHookwantsfailed()— did this call fail at all.writeArrowResponsewantsshouldFlagResponse()— may a client read this body as an error instead of as the reply it asked for. They diverge on exactly one path: a producer/initwhoseproduceraises. Every other error path discards the body it had built and answers a single self-contained error stream, but that one appends the EXCEPTION batch to a body that is a sequence of IPC streams (the declared stream header, then the producer's stream). A client that reads the flag as "switch to the unary error reader" stops at the header stream's EOS, never reaches the batch, and reports a generic transport failure with the worker's message discarded — which is what the DuckDB extension'sReadUnaryResponseFromBufferbranch does. SowriteProducerRun's catch callsCallOutcome.suppressResponseFlag(), matching the reference, whose producer loop writes the error batch and deliberately leaves_current_response_statusat 200 (init-method raises and cap overshoots, which replace the body, still set it). The suppression touches only the header — the access log still saysstatus: "error". 0.19.0 shipped the blanketfailed()rule and turned fourstatement errorsqllogictest files into silent skips (ignore_error_messagesmatches "HTTP"). Regression:http/InBandStreamErrorHeaderTest, which also pins the two directions that must keep the flag; the shared suite'sTestErrorHeaderposts unary bodies only and cannot see this.It is read a second time, at
AccessLogScope.close, because dispatch returning is not the moment the outcome is settled.max_response_bytesis enforced after the body exists (HttpServer.writeResponseCapError, unary and/exchange; producer/initis soft-capped and must stayok), so an overshoot discards the body, answers an EXCEPTION batch, and lands afteronDispatchEndcomputedstatus: "ok".AccessLogHook.restatepromotes the parked record —ok→erroronly, since a record that already named a failure named the cause and the overshoot it tripped on the way out is a consequence. This works becauseRouterServlet.serviceopensCallOutcomeoutsideAccessLogScope, so the error is still readable when the scope emits; keep that nesting order. Covered byTestHttpResponseCapAccessLog.max_externalized_response_bytesis the other cap, and the one with no escape valve.max_response_bytesgoverns the wire and is soft for producer/init; bytes already uploaded to external storage cannot be un-uploaded, so this one is hard on every method type, producers included. It shipped advertised-but-unenforced — the configured value was read only to emitVGI-Max-Externalized-Response-Bytesand add it to the CORS expose list, and a worker capped at 512 bytes uploaded 200,336 and answered success.ExternalResponseBudgetis the per-request scope that closes it:RouterServlet.doPostopens one (there, because it is the one place that knows the method name the refusal must name), andExternalizer.maybeExternalizecharges against it viareserve()immediately beforeExternalStorage.upload— the pre-flight is the half that matters, since enforcing after the upload has already spent the egress. Refused bytes are never charged, so the cap is a cap and not a wall: a smaller payload still travels the external channel. The refusal is a dedicated type,ExternalizedResponseCapExceededException, because every upload site wrapsmaybeExternalizein a catch-all that falls back to inline delivery on upload failure; a refusal is not a failure and each of those rethrows it ahead of the fallback. The scope also remembers that it tripped, andHttpServerreads that back after dispatch (writeExternalizedCapErrorIfViolated, before the wire-cap check on both the unary and stream paths) so the contract survives a future catch-all that swallows the exception. Conformance group:TestExternalizedResponseCap.Enforcing it required first having something to enforce: HTTP stream responses did not externalize at all — only
RpcServer.writeResult(unary) andRpcServer.flushEntries(pipe family) did — so an HTTP worker advertising a threshold applied it to neither producer nor exchange output.HttpStreamHandler.writeStreamBatchroutes stream data batches through the externalizer, passing the stream's declared output schema: an externalised payload is a standalone IPC stream and declares its own schema, where an inline batch rides a stream whose schema was declared once up front, so a collector root differing only in field nullability is invisible inline and a schema mismatch once externalised (Externalizer.maybeExternalize's four-arg overload). Dictionary-encoded batches stay inline — the uploader cannot carry dictionaries.What the schema cannot check — sampling determinism, drop reporting, fail-closed redaction,
payload_omittedvstrue,response_bytesbeing the compressed size — is covered byAccessLogHookTestandhttp/AccessLogEgressTest.
The conformance worker accepts --access-log <path> (Main.java parses it) plus --access-log-sample <rate>, --access-log-async, --access-log-queue-size <n> and --access-log-no-payloads (so vgi-rpc-test --access-log can validate the optional record shapes, not just the default one), --access-log-debug (accepted and ignored — see below), --http-auth (reject-all authenticator that honours the X-Conformance-Auth-Reason fixture header, backing TestHealth + TestUnauthorized), --no-call-state-cache (disables the per-process call-state cache so every stream continuation takes the miss path, backing TestColdCallStateCache), --cors-origin <origin> (repeatable; implies --http and grants that origin browser access, backing TestCors — the default worker stays CORS-free for TestCorsOffMode), and --introspect (implies --http plus principal-header auth, and enables token introspection with the fixed conformance introspector/subject/JWS-trap constants, backing TestTokenIntrospection — the default worker stays introspection-free for TestTokenIntrospectionOffMode).
Verifying the access log. vgi-rpc-test --access-log <path> --require-request-data is run by the launcher conformance lane in CI (.github/workflows/ci.yml), unfiltered so the zero-parameter methods — which send an empty schema and no row — stay in the sample. --require-request-data is the part that matters: without it request_data is only checked when present, so a worker that never emits it passes vacuously. This port logs payloads by default, which is why it caught a request_data bug the DEBUG-gated ports logged past; --access-log-debug exists only so the porting guide's canonical command line runs here unmodified, and must stay a no-op rather than becoming the inverse of --access-log-no-payloads.
That lane is a pipe run, where a whole stream call is one dispatch and one record — it says nothing about HTTP, where a stream is a chain of requests. The http lane therefore also runs TestHttpStreamAccessLog (tests/test_java_conformance.py), which drives producer / exchange / failing streams against a worker started with --access-log and asserts the records exist and have the right shape before validating them. Presence is the assertion that matters: the schema validator reported PASS over a log with zero stream records for as long as the bug existed. The correlation half — X-Request-ID on the response equalling request_id in the log — is the shared suite's TestRequestId, gated on the conformance_http_access_log fixture in the same file.
- Check the Python reference at
~/Development/vgi-rpc/vgi_rpc/— behavior there is authoritative. - Check
~/Development/vgi-rpc/CLAUDE.mdfor the higher-level architectural summary. - Run
./run_tests.sh <keyword>to see whether the conformance suite already exercises the behavior you're changing.