Dispatch Modes & Wire Format

Binary Wire Format

Both request and response use the same length-prefixed layout:

bytes 0..4    : u32 BE = header_json byte length N
bytes 4..4+N  : UTF-8 JSON
                  (request)  { "v":1, "method", "path",
                               "query"?, "headers"? }
                  (response) { "v":1, "status", "headers",
                               "metadata", "validation_errors"? }
bytes 4+N..   : raw body bytes (UTF-8 text or binary —
                no encoding applied)

Key properties:

Dispatch Modes

VesperaBridge exposes seven native methods — all sharing the same wire format, the same registered router, and the same panic-safe catch_unwind discipline:

MethodModeJava returnMemory
dispatchBytes(byte[])syncbyte[] (header + body)full body in memory
dispatchAsync(CompletableFuture, byte[])asyncvoid (future completes)full body in memory
dispatchStreaming(byte[], OutputStream)sync, response-streamingbyte[] (header only)chunk-bounded response
dispatchFullStreaming(byte[], InputStream, OutputStream)sync, bidirectional streamingbyte[] (header only)chunk-bounded both ways
dispatchStreamingWithHeader(byte[], Consumer, OutputStream)sync, response-streamingvoid (header via callback)chunk-bounded response
dispatchFullStreamingWithHeader(byte[], Consumer, InputStream, OutputStream)sync, bidirectional streamingvoid (header via callback)chunk-bounded both ways
dispatchDirect(ByteBuffer, int, ByteBuffer)sync, direct buffersint (response length / overflow code)no Java heap arrays

Choosing a Mode

SmartDispatchModeResolver (Default since 0.2.0)

The autoconfigured default since vespera-bridge 0.2.0 picks the cheapest safe path per request. Measured on a GET /health round-trip through the real JNI boundary:

Request shapeModens / round-trip
Small/bodyless + idempotent (GET/HEAD/PUT/DELETE/OPTIONS, ≤ 256 KiB)DIRECT~2,200
Small (≤ 256 KiB Content-Length) + non-idempotent (POST/PATCH)SYNC~3,200
Large or unknown-length bodyBIDIRECTIONAL_STREAMING~24,100

Trade-offs:

Restore the pre-0.2.0 default (every request that may carry a body streams both ways, ~24 µs uniform):

vespera:
  bridge:
    dispatch-mode: bidirectional-streaming

Direct Buffer Dispatch

dispatchDirect(ByteBuffer in, int inLen, ByteBuffer out) eliminates the two JNI GetByteArrayRegion/SetByteArrayRegion copies that dispatchBytes pays. The response is streamed straight into the out buffer — no intermediate Vec. Measured at 1.4–3.4× per round-trip versus dispatchBytes depending on payload size.

Contract:

dispatchDirectPooled(byte[] wireRequest, boolean retryOnOverflow) wraps the raw call with per-thread reusable direct buffers (64 KiB initial, doubling up to vespera.direct.maxBufferBytes, default 4 MiB).

Direct API (Without the Proxy Controller)

import com.devfive.vespera.bridge.VesperaBridge;
import com.devfive.vespera.bridge.VesperaBridge.DecodedResponse;
 
// 1. Initialise once at startup
VesperaBridge.init("my_rust_lib");
 
// 2. Encode a request
byte[] wireRequest = VesperaBridge.encodeRequest(
    "POST",
    "/documents/validate",
    /* query */ null,
    Map.of("content-type", "application/json"),
    "{\"title\":\"\"}".getBytes(StandardCharsets.UTF_8));
 
// 3. Dispatch through Rust
byte[] wireResponse = VesperaBridge.dispatchBytes(wireRequest);
 
// 4. Decode
DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);
System.out.println(resp.status());               // 200
System.out.println(resp.headers());              // { "content-type": "application/json", … }
System.out.println(new String(resp.bodyBytes())); // copies the raw response body

0.2.0 breaking change: DecodedResponse.body() now returns a read-only java.nio.ByteBuffer (zero-copy view over the wire bytes). The owned byte[] materialisation moved to DecodedResponse.bodyBytes(). Callers that previously used body() as byte[] must switch to bodyBytes().

Async Dispatch

CompletableFuture<byte[]> future = VesperaBridge.dispatch(wireRequest);
 
future.thenAccept(wireResponse -> {
    DecodedResponse resp = VesperaBridge.decodeResponse(wireResponse);
    System.out.println("Status: " + resp.status());
});

The future is always completed with a valid wire response, even on Rust panics or JNI conversion failures. You will never see a dangling future.

Streaming Dispatch

byte[] wireRequest = VesperaBridge.encodeRequest(
    "GET", "/files/large.pdf", null, Map.of(), new byte[0]);
 
try (ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
    byte[] headerOnly = VesperaBridge.dispatchStreaming(wireRequest, sink);
    DecodedResponse meta = VesperaBridge.decodeResponse(headerOnly);
    System.out.println("Status: " + meta.status());
    System.out.println("Body size: " + sink.size());
}

Bidirectional Streaming

try (InputStream upload = Files.newInputStream(Path.of("huge.mp4"));
     OutputStream download = Files.newOutputStream(Path.of("transcoded.mp4"))) {
 
    byte[] wireHeader = VesperaBridge.encodeRequestHeader(
        "POST", "/transcode", null,
        Map.of("content-type", "video/mp4"));
 
    byte[] respHeader = VesperaBridge.dispatchFullStreaming(
        wireHeader, upload, download);
 
    DecodedResponse meta = VesperaBridge.decodeResponse(respHeader);
    System.out.println("Status: " + meta.status());
}

A 1 GiB upload paired with a 1 GiB download runs in low-single-digit MiB resident memory on each side. Backpressure is enforced naturally — if Axum reads slowly, InputStream.read() blocks on the bounded channel.

Contents
Edit this page
문의 및 의견 제출
contact@devfive.kr
Copyright © DEVFIVE. All Rights Reserved.
DEVFIVE