Skip to content

Commit 090530d

Browse files
committed
Improve
1 parent ea28c80 commit 090530d

5 files changed

Lines changed: 214 additions & 55 deletions

File tree

crates/vespera_inprocess/tests/wire_contract.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::sync::Once;
1414
use axum::Router;
1515
use axum::http::{HeaderMap, HeaderName};
1616
use axum::response::{IntoResponse, Response};
17-
use axum::routing::get;
17+
use axum::routing::{get, post};
1818
use serde_json::Value;
1919
use tokio::runtime::Builder;
2020
use vespera_inprocess::{dispatch_from_bytes, error_wire, register_app};
@@ -31,10 +31,21 @@ async fn contract_headers() -> Response {
3131
(headers, "ok").into_response()
3232
}
3333

34+
/// Echo the raw request body back — used by the cross-language golden
35+
/// test so a matched `POST /users` proves the header/body split + routing
36+
/// on the exact bytes the Java encoder produces.
37+
async fn echo_body(body: axum::body::Bytes) -> axum::body::Bytes {
38+
body
39+
}
40+
3441
fn install_router() {
3542
static INIT: Once = Once::new();
3643
INIT.call_once(|| {
37-
register_app(|| Router::new().route("/contract", get(contract_headers)));
44+
register_app(|| {
45+
Router::new()
46+
.route("/contract", get(contract_headers))
47+
.route("/users", post(echo_body))
48+
});
3849
});
3950
}
4051

@@ -133,6 +144,51 @@ fn error_wire_bytes_are_locked() {
133144
);
134145
}
135146

147+
/// **Cross-language golden (request direction)** — dispatches the
148+
/// byte-identical wire frame the Java encoder produces and asserts the
149+
/// Rust parser accepts it and routes correctly.
150+
///
151+
/// The header JSON + body below are byte-identical to the Java side's
152+
/// shared golden (`VesperaWireTest.CANONICAL_REQUEST_HEADER_JSON` /
153+
/// `CANONICAL_REQUEST_BODY`). Java asserts its encoder emits exactly
154+
/// these bytes; this test asserts Rust parses exactly these bytes and
155+
/// routes `POST /users` with the body intact. Together they lock the two
156+
/// independent hand-rolled wire implementations against silent drift: a
157+
/// change to either side's field order / structure / framing breaks its
158+
/// own golden assertion.
159+
#[test]
160+
fn cross_language_request_golden_routes() {
161+
install_router();
162+
let runtime = Builder::new_current_thread()
163+
.enable_all()
164+
.build()
165+
.expect("tokio runtime");
166+
167+
// Byte-identical to the Java cross-language golden — do NOT edit one
168+
// side without the other (see VesperaWireTest).
169+
let header_json =
170+
br#"{"v":1,"method":"POST","path":"/users","query":"page=1","headers":{"content-type":"application/json"}}"#;
171+
let body = br#"{"x":1}"#;
172+
let mut wire = Vec::with_capacity(4 + header_json.len() + body.len());
173+
wire.extend_from_slice(&u32::try_from(header_json.len()).unwrap().to_be_bytes());
174+
wire.extend_from_slice(header_json);
175+
wire.extend_from_slice(body);
176+
177+
let resp = dispatch_from_bytes(wire, &runtime);
178+
let (header, resp_body) = split_wire(&resp);
179+
180+
// Body round-trip proves the parser split header/body at the exact
181+
// offset and routed the echo handler; 200 proves `POST /users` matched.
182+
assert_eq!(
183+
resp_body, body,
184+
"cross-language request golden: echo body must round-trip (header/body split + routing)"
185+
);
186+
assert!(
187+
header.contains(r#""status":200"#),
188+
"cross-language request golden: POST /users must route 200 — got: {header}"
189+
);
190+
}
191+
136192
/// Golden: 422 hoisting shape — `validation_errors` appears as the
137193
/// LAST field, after `metadata`, with `path`/`message` entry order.
138194
#[test]

libs/vespera-bridge/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,45 @@ end-to-end:
394394
`DispatchMode.BIDIRECTIONAL_STREAMING` is safe for virtual threads
395395
and handles all payload sizes without pooling.
396396

397+
### Synchronous dispatch runtime semantics (background tasks)
398+
399+
`SYNC` and `DIRECT` drive each request on a **per-calling-thread
400+
current-thread Tokio runtime** (one runtime per Java request thread,
401+
created lazily, blocking pool capped at 4 threads). The request future and
402+
any task the handler `.await`s run to completion normally. The one caveat:
403+
a handler that **detaches** background work with `tokio::spawn(...)` and
404+
returns *without awaiting it* makes progress on that detached task only
405+
while a later request reuses the same Java thread's runtime in a
406+
`block_on` — there are no dedicated worker threads on this path.
407+
408+
If your handlers fire-and-forget background tasks, route them through a
409+
mode backed by the shared multi-threaded runtime instead:
410+
411+
- `dispatchAsync` / the `CompletableFuture` API, or
412+
- `vespera.bridge.dispatch-mode=bidirectional-streaming`
413+
414+
(both use the shared `RUNTIME`, whose worker count is tunable via
415+
`vespera.runtime.workerThreads` / `VESPERA_RUNTIME_WORKERS`). Handlers that
416+
only `.await` their own work are unaffected.
417+
418+
### Operational sizing (threads & off-heap)
419+
420+
The `DIRECT` fast path keeps per-platform-thread pooled direct
421+
`ByteBuffer`s (64 KiB → `vespera.direct.maxBufferBytes`, default 4 MiB),
422+
and `SYNC`/`DIRECT` keep one current-thread runtime per Java request
423+
thread. On a large servlet pool (e.g. 200 Tomcat threads) the steady-state
424+
cost is therefore bounded but not negligible:
425+
426+
- **Off-heap**: up to `poolThreads × maxBufferBytes` retained direct
427+
memory once each thread has served a large response (adaptive shrink
428+
reclaims it after idle dispatches). Lower `vespera.direct.maxBufferBytes`
429+
if off-heap pressure matters more than the DIRECT copy savings.
430+
- **Threads**: worst case `poolThreads × 4` Tokio blocking threads if many
431+
handlers use `spawn_blocking` concurrently (the multipart temp-file
432+
extractor does). Cap Rust's shared async runtime with
433+
`vespera.runtime.workerThreads` when the JVM's own pools compete for the
434+
same cores, or when a container CPU limit is below the host CPU count.
435+
397436
## Direct API (without the proxy controller)
398437

399438
For custom integrations bypassing Spring:

libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaProxyController.java

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -329,18 +329,7 @@ private static void dispatchSync(
329329
*/
330330
private static void writeWireResponse(byte[] wire, HttpServletResponse response)
331331
throws IOException {
332-
if (wire == null || wire.length < 4) {
333-
throw new IllegalArgumentException(
334-
"wire response too short: "
335-
+ (wire == null ? "null" : wire.length + " bytes"));
336-
}
337-
int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16)
338-
| ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF);
339-
if (headerLen < 0 || (long) 4 + headerLen > wire.length) {
340-
throw new IllegalArgumentException(
341-
"wire header_len " + headerLen
342-
+ " overflows response (" + wire.length + " bytes)");
343-
}
332+
int headerLen = VesperaWireCodec.readHeaderLength(wire);
344333
int[] statusHolder = {500};
345334
WireHeaderReader.apply(
346335
ByteBuffer.wrap(wire), 4, headerLen,
@@ -517,25 +506,13 @@ private void dispatchDirectMode(
517506
* already apply.
518507
*/
519508
static int readValidatedHeaderLen(ByteBuffer wire) {
520-
int limit = wire.limit();
521-
if (limit < 4) {
522-
throw new IllegalArgumentException("wire response too short: " + limit + " bytes");
523-
}
524-
// Decode the u32 BE length prefix from absolute bytes (order-independent)
525-
// instead of wire.getInt(0), which honours the buffer's CURRENT byte
526-
// order — a LITTLE_ENDIAN view (e.g. a caller that called order(...) on
527-
// the buffer, or a future change) would misparse the big-endian wire
528-
// prefix. Matches the manual big-endian decode the heap byte[] paths
529-
// (writeWireResponse / buildResponseEntityFromWire) already use.
530-
int headerLen = ((wire.get(0) & 0xFF) << 24)
531-
| ((wire.get(1) & 0xFF) << 16)
532-
| ((wire.get(2) & 0xFF) << 8)
533-
| (wire.get(3) & 0xFF);
534-
if (headerLen < 0 || (long) 4 + headerLen > limit) {
535-
throw new IllegalArgumentException(
536-
"wire header_len " + headerLen + " overflows response (" + limit + " bytes)");
537-
}
538-
return headerLen;
509+
// Delegates to the single source of truth in VesperaWireCodec so the
510+
// u32 BE prefix decode + bounds contract stays byte-identical across
511+
// every wire-frame split site (heap byte[] and direct ByteBuffer). The
512+
// helper decodes from absolute bytes (order-independent) — never
513+
// wire.getInt(0), which honours the buffer's CURRENT byte order — so a
514+
// LITTLE_ENDIAN view can never misparse the big-endian wire prefix.
515+
return VesperaWireCodec.readHeaderLength(wire);
539516
}
540517

541518
// Package-private so tests can verify DIRECT header/body-length behavior
@@ -805,16 +782,7 @@ private static void applyDecodedHeader(byte[] headerBytes,
805782
* response executor instead of the native completion thread.
806783
*/
807784
private static ResponseEntity<?> buildResponseEntityFromWire(byte[] wire) {
808-
if (wire == null || wire.length < 4) {
809-
throw new IllegalArgumentException(
810-
"wire response too short: " + (wire == null ? "null" : wire.length + " bytes"));
811-
}
812-
int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16)
813-
| ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF);
814-
if (headerLen < 0 || (long) 4 + headerLen > wire.length) {
815-
throw new IllegalArgumentException(
816-
"wire header_len " + headerLen + " overflows response (" + wire.length + " bytes)");
817-
}
785+
int headerLen = VesperaWireCodec.readHeaderLength(wire);
818786
HttpHeaders httpHeaders = new HttpHeaders();
819787
int[] statusHolder = {500};
820788
WireHeaderReader.apply(

libs/vespera-bridge/src/main/java/com/devfive/vespera/bridge/VesperaWireCodec.java

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,63 @@ static int wireTotalLength(int headerLen, int bodyLen) {
251251
return (int) total;
252252
}
253253

254+
/**
255+
* Decode and validate the u32 BE header-length prefix at bytes {@code 0..4}
256+
* of a heap wire frame — the <strong>single source of truth</strong> for
257+
* the frame split shared by {@link #decodeResponse} and the
258+
* {@link VesperaProxyController} write/build paths, so the bounds contract
259+
* can never drift between the (previously duplicated) call sites.
260+
*
261+
* <p>The prefix is read from absolute bytes (big-endian, order-independent),
262+
* never {@code ByteBuffer.getInt} which honours the buffer's current byte
263+
* order.
264+
*
265+
* @return the header JSON length {@code N} (so the body is {@code wire[4+N..]})
266+
* @throws IllegalArgumentException if {@code wire} is shorter than the
267+
* 4-byte prefix, or the decoded length is negative or overflows the frame
268+
*/
269+
static int readHeaderLength(byte[] wire) {
270+
if (wire == null || wire.length < 4) {
271+
throw new IllegalArgumentException(
272+
"wire response too short: "
273+
+ (wire == null ? "null" : wire.length + " bytes"));
274+
}
275+
int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16)
276+
| ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF);
277+
if (headerLen < 0 || 4L + headerLen > wire.length) {
278+
throw new IllegalArgumentException(
279+
"wire header_len " + headerLen
280+
+ " overflows response (" + wire.length + " bytes)");
281+
}
282+
return headerLen;
283+
}
284+
285+
/**
286+
* {@link ByteBuffer} sibling of {@link #readHeaderLength(byte[])} — decodes
287+
* the u32 BE header-length prefix from absolute bytes {@code 0..4} of
288+
* {@code wire} (honouring neither the buffer's position nor its byte order),
289+
* validating against {@code wire.limit()}.
290+
*
291+
* @return the header JSON length {@code N}
292+
* @throws IllegalArgumentException if the buffer is shorter than the 4-byte
293+
* prefix, or the decoded length is negative or overflows the limit
294+
*/
295+
static int readHeaderLength(ByteBuffer wire) {
296+
int limit = wire.limit();
297+
if (limit < 4) {
298+
throw new IllegalArgumentException("wire response too short: " + limit + " bytes");
299+
}
300+
int headerLen = ((wire.get(0) & 0xFF) << 24)
301+
| ((wire.get(1) & 0xFF) << 16)
302+
| ((wire.get(2) & 0xFF) << 8)
303+
| (wire.get(3) & 0xFF);
304+
if (headerLen < 0 || 4L + headerLen > limit) {
305+
throw new IllegalArgumentException(
306+
"wire header_len " + headerLen + " overflows response (" + limit + " bytes)");
307+
}
308+
return headerLen;
309+
}
310+
254311
/** Internal: write {@code [u32 BE len | headerJson[0..headerLen] | body]} at position 0. */
255312
static int assembleInto(byte[] headerJson, int headerLen, byte[] body, ByteBuffer target) {
256313
int total = wireTotalLength(headerLen, body.length);
@@ -499,18 +556,7 @@ private static void writeJsonString(ExposedByteArrayOutputStream out, String s)
499556
* @throws IllegalArgumentException if the wire bytes are malformed
500557
*/
501558
static DecodedResponse decodeResponse(byte[] wire) {
502-
if (wire == null || wire.length < 4) {
503-
throw new IllegalArgumentException(
504-
"wire response too short: "
505-
+ (wire == null ? "null" : wire.length + " bytes"));
506-
}
507-
int headerLen = ((wire[0] & 0xFF) << 24) | ((wire[1] & 0xFF) << 16)
508-
| ((wire[2] & 0xFF) << 8) | (wire[3] & 0xFF);
509-
if (headerLen < 0 || (long) 4 + headerLen > wire.length) {
510-
throw new IllegalArgumentException(
511-
"wire header_len " + headerLen
512-
+ " overflows response (" + wire.length + " bytes)");
513-
}
559+
int headerLen = readHeaderLength(wire);
514560
// Manual decode via the allocation-lean WireHeaderReader tokenizer
515561
// (the same parser the DIRECT / streaming header callbacks use)
516562
// instead of a Jackson JsonParser — drops the per-response parser +

libs/vespera-bridge/src/test/java/com/devfive/vespera/bridge/VesperaWireTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,56 @@ void encodeRequest_includes_query_and_headers_when_present() throws Exception {
9292
assertEquals("{\"x\":1}", new String(body, StandardCharsets.UTF_8));
9393
}
9494

95+
/**
96+
* Canonical request header JSON — the <strong>shared cross-language
97+
* golden</strong> that locks the Java encoder against the Rust
98+
* {@code serde_json}/hand-rolled parser. The Rust counterpart
99+
* ({@code crates/vespera_inprocess/tests/wire_contract.rs ::
100+
* cross_language_request_golden_routes}) dispatches the byte-identical
101+
* frame and asserts it routes, so the two independent hand-rolled wire
102+
* implementations cannot silently drift: a change to either side's field
103+
* order / escaping / structure breaks its own golden assertion.
104+
*
105+
* <p>Field order is fixed by {@code VesperaWireCodec.fillHeaderJson}:
106+
* {@code v, method, path, query?, headers?, app?}.
107+
*/
108+
static final String CANONICAL_REQUEST_HEADER_JSON =
109+
"{\"v\":1,\"method\":\"POST\",\"path\":\"/users\",\"query\":\"page=1\","
110+
+ "\"headers\":{\"content-type\":\"application/json\"}}";
111+
112+
/** Canonical request body paired with {@link #CANONICAL_REQUEST_HEADER_JSON}. */
113+
static final byte[] CANONICAL_REQUEST_BODY = "{\"x\":1}".getBytes(StandardCharsets.UTF_8);
114+
115+
@Test
116+
void crossLanguage_request_golden_bytes_are_locked() {
117+
Map<String, String> headers = new LinkedHashMap<>();
118+
headers.put("content-type", "application/json");
119+
120+
byte[] wire = VesperaBridge.encodeRequest(
121+
"POST", "/users", "page=1", headers, CANONICAL_REQUEST_BODY);
122+
123+
byte[] expectedHeader =
124+
CANONICAL_REQUEST_HEADER_JSON.getBytes(StandardCharsets.UTF_8);
125+
126+
// Length prefix == exact canonical header byte length (big-endian).
127+
int headerLen = ByteBuffer.wrap(wire).order(ByteOrder.BIG_ENDIAN).getInt();
128+
assertEquals(expectedHeader.length, headerLen,
129+
"encoded header length drifted from the cross-language golden");
130+
131+
// Header JSON bytes are byte-identical to the shared golden (locks the
132+
// Java encoder's field order + structure the Rust parser is asserted
133+
// to accept verbatim in wire_contract.rs).
134+
byte[] headerJson = new byte[headerLen];
135+
System.arraycopy(wire, 4, headerJson, 0, headerLen);
136+
assertArrayEquals(expectedHeader, headerJson,
137+
"request header JSON drifted from the cross-language golden — WIRE FORMAT BREAK");
138+
139+
// Body follows the header verbatim.
140+
byte[] body = new byte[wire.length - 4 - headerLen];
141+
System.arraycopy(wire, 4 + headerLen, body, 0, body.length);
142+
assertArrayEquals(CANONICAL_REQUEST_BODY, body, "request body must follow header verbatim");
143+
}
144+
95145
@Test
96146
void encodeRequestRejectsNullMethodAndPathWithFieldName() {
97147
NullPointerException method = assertThrows(

0 commit comments

Comments
 (0)