Skip to content

Commit 3c8f677

Browse files
committed
Impl macro cache
1 parent d1d4768 commit 3c8f677

16 files changed

Lines changed: 105 additions & 334 deletions

File tree

.github/workflows/CI.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,15 @@ jobs:
145145
if: runner.os != 'Windows'
146146
run: |
147147
chmod +x libs/vespera-bridge/gradlew
148+
chmod +x libs/vespera-bridge-gradle-plugin/gradlew
148149
chmod +x examples/rust-jni-demo/java/gradlew
150+
- name: Publish vespera-bridge Gradle plugin to mavenLocal
151+
# demo-app's plugins block resolves kr.devfive.vespera-bridge from
152+
# mavenLocal (settings.gradle.kts pluginManagement) — the plugin is
153+
# not on the Gradle Plugin Portal.
154+
shell: bash
155+
working-directory: libs/vespera-bridge-gradle-plugin
156+
run: ./gradlew publishToMavenLocal --console=plain --no-daemon
149157
- name: Publish vespera-bridge to mavenLocal
150158
# demo-app resolves kr.devfive:vespera-bridge:1.0.0 from mavenLocal
151159
# (see examples/rust-jni-demo/java/demo-app/build.gradle.kts —

AGENTS.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ vespera/
3838
│ ├── vespera_inprocess/ # In-process dispatch (transport-agnostic)
3939
│ │ └── src/lib.rs # dispatch(), register_app(), dispatch_from_bytes()
4040
│ └── vespera_jni/ # JNI bridge (depends on vespera_inprocess)
41-
│ └── src/lib.rs # RUNTIME, jni_app! macro, JNI symbol export
41+
│ ├── src/jni_impl.rs # RUNTIME, jni_app! macro, JNI symbol export
42+
│ └── src/streaming_closures.rs # Streaming closure factories + JMethodID cache
4243
├── libs/
4344
│ └── vespera-bridge/ # Java library (com.devfive.vespera.bridge)
4445
│ ├── VesperaBridge.java # JNI native loader + dispatch
@@ -64,7 +65,7 @@ vespera/
6465
| Test new features | `examples/axum-example/` | Add route, run example |
6566
| In-process dispatch | `crates/vespera_inprocess/src/lib.rs` | RequestEnvelope → Router → ResponseEnvelope |
6667
| App factory (FFI pattern) | `crates/vespera_inprocess/src/lib.rs` | register_app(), dispatch_from_bytes() |
67-
| JNI integration | `crates/vespera_jni/src/lib.rs` | RUNTIME, jni_app! macro, JNI symbol export |
68+
| JNI integration | `crates/vespera_jni/src/jni_impl.rs` | RUNTIME, jni_app! macro, JNI symbol export |
6869
| Java bridge library | `libs/vespera-bridge/` | com.devfive.vespera.bridge package |
6970
| JNI demo (Rust) | `examples/rust-jni-demo/src/` | Routes + vespera::jni_app! |
7071
| JNI demo (Java) | `examples/rust-jni-demo/java/` | Spring Boot proxy app |
@@ -80,7 +81,8 @@ vespera/
8081
| `vespera_macro/src/openapi_generator.rs` | ~808 | OpenAPI doc assembly |
8182
| `vespera_macro/src/collector.rs` | ~707 | Filesystem route scanning |
8283
| `vespera_inprocess/src/lib.rs` | ~1184 | In-process dispatch + app factory + streaming + binary wire |
83-
| `vespera_jni/src/lib.rs` | ~795 | JNI RUNTIME + jni_app! macro + 7 JNI symbols (incl. direct-buffer path) |
84+
| `vespera_jni/src/jni_impl.rs` | ~833 | JNI RUNTIME + jni_app! macro + 7 JNI symbols (incl. direct-buffer path) |
85+
| `vespera_jni/src/streaming_closures.rs` | ~406 | Streaming closure factories (`make_pull_closure`, `make_push_closure`, `call_header_consumer`, `complete_future`) + `OnceLock<MethodCache>` caching `JMethodID`+`GlobalRef<JClass>` for `InputStream.read`, `OutputStream.write`, `Consumer.accept`, `CompletableFuture.complete``call_method_unchecked` on the hot path |
8486

8587
## CRATE DEPENDENCY GRAPH
8688

@@ -182,9 +184,9 @@ bytes 4+N.. : raw body bytes (UTF-8 text or binary —
182184
| `Java_...dispatchFullStreamingWithHeader` | `void dispatchFullStreamingWithHeader(byte[], Consumer<byte[]>, InputStream, OutputStream)` | sync bidirectional streaming, header callback | chunk-bounded both directions |
183185
| `Java_...dispatchDirect0` | `int dispatchDirect(ByteBuffer, int, ByteBuffer)` (public validated wrapper over the private native) | sync, direct buffers | full body, zero Java heap arrays |
184186

185-
All share the same wire format, registered router, and panic-safe `catch_unwind` discipline. The direct-buffer path (`dispatchDirect` + pooled `dispatchDirectPooled`, per-thread 64 KiB→4 MiB buffers via `vespera.direct.maxBufferBytes`) removes the two JNI region copies of `dispatchBytes`; on response overflow it returns `-(requiredSize)` and a retry **re-runs the handler**, so the Java side only auto-retries idempotent requests (`BufferTooSmallException` otherwise). Spring opt-in via `vespera.bridge.dispatch-mode=smart` (`SmartDispatchModeResolver`: small/bodyless idempotent → `DIRECT` ~2.2µs, small non-idempotent → `SYNC` ~3.2µs, else streaming ~24µs); the autoconfigured default remains `BIDIRECTIONAL_STREAMING`, with provably bodyless requests (CL:0, or GET/HEAD/OPTIONS without CL/TE) downgraded to response-only `STREAMING` (~3x, 24.1→7.7µs). `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a worker thread via `attach_current_thread`. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls chunks (default 64 KiB) from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded channel, default 16 slots) so 1 GiB uploads run in `O(chunk_size)` RAM.
187+
All share the same wire format, registered router, and panic-safe `catch_unwind` discipline. **`DecodedResponse` (vespera-bridge 1.0.0, BREAKING):** `body()` now returns a read-only `java.nio.ByteBuffer` (zero-copy view over the wire bytes); `bodyBytes()` materialises an owned `byte[]` copy on demand — callers that previously used `body()` as `byte[]` must switch to `bodyBytes()`. The direct-buffer path (`dispatchDirect` + pooled `dispatchDirectPooled`, per-thread 64 KiB→4 MiB buffers via `vespera.direct.maxBufferBytes`) removes the two JNI region copies of `dispatchBytes`; on response overflow it returns `-(requiredSize)` and a retry **re-runs the handler**, so the Java side only auto-retries idempotent requests (`BufferTooSmallException` otherwise). Spring opt-in via `vespera.bridge.dispatch-mode=smart` (`SmartDispatchModeResolver`: small/bodyless idempotent → `DIRECT` ~2.2µs, small non-idempotent → `SYNC` ~3.2µs, else streaming ~24µs); the autoconfigured default remains `BIDIRECTIONAL_STREAMING`, with provably bodyless requests (CL:0, or GET/HEAD/OPTIONS without CL/TE) downgraded to response-only `STREAMING` (~3x, 24.1→7.7µs). `dispatchAsync` spawns the dispatch on Rust's shared Tokio runtime via `tokio::spawn` (panic → `JoinError` → `error_wire(500)`) and completes the `CompletableFuture` from a daemon-attached cached Tokio worker thread (`with_async_daemon_env` in `jni_impl.rs`: raw `AttachCurrentThreadAsDaemon` + TLS env cache + per-completion local frame + unconditional pending-exception cleanup) — ~1.3µs/op faster than scoped attach per completion. `dispatchStreaming` drains the response body chunk-by-chunk via `http_body::Body::frame()` and writes each chunk to the Java `OutputStream`. `dispatchFullStreaming` adds request-side streaming: a `tokio::task::spawn_blocking` thread pulls chunks (default 64 KiB) from `InputStream.read(byte[])` and feeds them into axum via an `mpsc::channel`-backed `http_body::Body`, giving natural backpressure (bounded channel, default 16 slots) so 1 GiB uploads run in `O(chunk_size)` RAM.
186188

187-
**Streaming tuning (process-fixed after first dispatch):** chunk size via system property `vespera.streaming.chunkBytes` / env `VESPERA_STREAMING_CHUNK_BYTES` (default 64 KiB, clamped 4 KiB–8 MiB); channel capacity via `vespera.streaming.channelCapacity` / `VESPERA_STREAMING_CHANNEL_CAPACITY` (default 16, clamped 1–1024). Rust-side setters: `vespera_inprocess::set_streaming_chunk_bytes` / `set_streaming_channel_capacity` (precedence: setter > env > default). The shared Tokio runtime's worker count is tunable the same way: `vespera.runtime.workerThreads` / `VESPERA_RUNTIME_WORKERS` (default: logical CPUs, clamped 1–1024) — cap it when JVM thread pools compete for the same cores. `_default`-app dispatch resolves through a lock-free `OnceLock<Router>` fast path; named apps go through the `RwLock<HashMap>`. The response wire header serializes straight from `http::HeaderMap` (zero per-header allocation) and request wire headers deserialize borrowing from the input buffer (`Cow`) — the wire byte layout is locked by `crates/vespera_inprocess/tests/wire_contract.rs`.
189+
**Streaming tuning (process-fixed after first dispatch):** chunk size via system property `vespera.streaming.chunkBytes` / env `VESPERA_STREAMING_CHUNK_BYTES` (default 64 KiB, clamped 4 KiB–8 MiB); channel capacity via `vespera.streaming.channelCapacity` / `VESPERA_STREAMING_CHANNEL_CAPACITY` (default 16, clamped 1–1024). Java API: `VesperaBridge.configureStreaming(chunkBytes, channelCapacity)` — pending-config pattern (call before `init()`; values stored pending and applied right after native load, before any dispatch; programmatic > sysprops > env > defaults). Rust-side setters: `vespera_inprocess::set_streaming_chunk_bytes` / `set_streaming_channel_capacity` (precedence: setter > env > default). The shared Tokio runtime's worker count is tunable the same way: `vespera.runtime.workerThreads` / `VESPERA_RUNTIME_WORKERS` (default: logical CPUs, clamped 1–1024) — cap it when JVM thread pools compete for the same cores. `_default`-app dispatch resolves through a lock-free `OnceLock<Router>` fast path; named apps go through the `RwLock<HashMap>`. The response wire header serializes straight from `http::HeaderMap` (zero per-header allocation) and request wire headers deserialize borrowing from the input buffer (`Cow`) — the wire byte layout is locked by `crates/vespera_inprocess/tests/wire_contract.rs`.
188190

189191
### Rust Public API (vespera_inprocess)
190192

@@ -332,7 +334,7 @@ props only.
332334
| Concern | Location |
333335
|---|---|
334336
| Macro integration tests | `crates/vespera_macro/tests/` (+ `insta` snapshots) |
335-
| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` |
337+
| Validated/422 contract | `crates/vespera/tests/validated_extractor.rs`, `crates/vespera/tests/jni_validation.rs` | Envelope built via `#[derive(Serialize)]` structs (not `serde_json::json!`); exact bytes locked by `insta::assert_snapshot!` in `validated_extractor.rs` |
336338
| Core unit tests | `crates/vespera_core/src/**` inline `#[cfg(test)]` |
337339
| JNI end-to-end | `examples/rust-jni-demo` (Rust + Java + Gradle) |
338340
| Front tests | `apps/front/src/__tests__/` (`bun test` + `bun-test-env-dom`) |
@@ -354,7 +356,7 @@ props only.
354356
- **No direct axum dep in examples**: Use `vespera::axum` re-export
355357
- **No direct vespera_jni/vespera_inprocess dep**: Use `vespera` features
356358
- **Java package**: `com.devfive.vespera.bridge` (fixed for JNI symbol stability)
357-
- **Java build**: Gradle (Kotlin DSL), published to GitHub Packages
359+
- **Java build**: Gradle (Kotlin DSL), published to Maven Central (`kr.devfive:vespera-bridge`, `kr.devfive:vespera-bridge-gradle-plugin`) via changepacks → `./gradlew publishToMavenCentral` (vanniktech maven-publish + GPG in-memory signing)
358360

359361
## ANTI-PATTERNS (THIS PROJECT)
360362

@@ -392,6 +394,9 @@ java -jar demo-app/build/libs/demo-app-0.1.0.jar
392394

393395
# Check generated OpenAPI
394396
cat examples/axum-example/openapi.json
397+
398+
# CI: jni-e2e job (3-OS matrix: ubuntu/windows/macos) runs demo-app E2E tests
399+
# including StreamingClosureStressTest — see .github/workflows/CI.yml
395400
```
396401

397402
## NOTES
@@ -402,3 +407,5 @@ cat examples/axum-example/openapi.json
402407
- Generic types in schemas require `#[derive(Schema)]` on all type params
403408
- JNI native library can be bundled inside the fat JAR for single-file deployment
404409
- `VesperaBridge.init()` auto-extracts bundled native lib to temp, falls back to system path
410+
- JNI dispatch perf benchmarks: `libs/vespera-bridge/docs/jni-before-after-2026-06-11.md` (note: root `/docs` is gitignored)
411+
- `vespera_macro` file_cache: per-macro-invocation epoch caching of `fs::metadata` (`bump_epoch` called at every file-cache-reaching entry point — `vespera!`, `schema_type!`, `schema!`, `export_app!`, `#[derive(Schema)]`); `collector.rs` clone-optimized

crates/vespera/tests/validated_extractor.rs

Lines changed: 25 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,31 @@ fn router() -> Router {
2929
Router::new().route("/posts", post(create_post))
3030
}
3131

32+
fn post_json_request(uri: &str, body: impl Into<Body>) -> Request<Body> {
33+
Request::builder()
34+
.method("POST")
35+
.uri(uri)
36+
.header("content-type", "application/json")
37+
.body(body.into())
38+
.unwrap()
39+
}
40+
3241
async fn body_to_string(body: Body) -> String {
3342
let bytes = ::axum::body::to_bytes(body, usize::MAX).await.unwrap();
3443
String::from_utf8(bytes.to_vec()).unwrap()
3544
}
3645

46+
fn assert_json_content_type(headers: &::axum::http::HeaderMap) {
47+
assert_eq!(
48+
headers.get("content-type").map(|v| v.to_str().unwrap()),
49+
Some("application/json"),
50+
);
51+
}
52+
3753
#[tokio::test]
3854
async fn valid_payload_returns_200() {
3955
let app = router();
40-
let req = Request::builder()
41-
.method("POST")
42-
.uri("/posts")
43-
.header("content-type", "application/json")
44-
.body(Body::from(r#"{"title":"My Post","content":"hello world"}"#))
45-
.unwrap();
56+
let req = post_json_request("/posts", r#"{"title":"My Post","content":"hello world"}"#);
4657

4758
let res = app.oneshot(req).await.unwrap();
4859
assert_eq!(res.status(), 200);
@@ -52,21 +63,11 @@ async fn valid_payload_returns_200() {
5263
#[tokio::test]
5364
async fn short_title_returns_422_with_path_keyed_envelope() {
5465
let app = router();
55-
let req = Request::builder()
56-
.method("POST")
57-
.uri("/posts")
58-
.header("content-type", "application/json")
59-
.body(Body::from(r#"{"title":"X","content":"ok"}"#))
60-
.unwrap();
66+
let req = post_json_request("/posts", r#"{"title":"X","content":"ok"}"#);
6167

6268
let res = app.oneshot(req).await.unwrap();
6369
assert_eq!(res.status(), 422);
64-
assert_eq!(
65-
res.headers()
66-
.get("content-type")
67-
.map(|v| v.to_str().unwrap()),
68-
Some("application/json"),
69-
);
70+
assert_json_content_type(res.headers());
7071

7172
let body: ::serde_json::Value =
7273
::serde_json::from_str(&body_to_string(res.into_body()).await).unwrap();
@@ -84,12 +85,7 @@ async fn short_title_returns_422_with_path_keyed_envelope() {
8485
#[tokio::test]
8586
async fn empty_content_returns_422() {
8687
let app = router();
87-
let req = Request::builder()
88-
.method("POST")
89-
.uri("/posts")
90-
.header("content-type", "application/json")
91-
.body(Body::from(r#"{"title":"Valid title","content":""}"#))
92-
.unwrap();
88+
let req = post_json_request("/posts", r#"{"title":"Valid title","content":""}"#);
9389

9490
let res = app.oneshot(req).await.unwrap();
9591
assert_eq!(res.status(), 422);
@@ -103,12 +99,7 @@ async fn empty_content_returns_422() {
10399
#[tokio::test]
104100
async fn multiple_violations_all_appear_in_envelope() {
105101
let app = router();
106-
let req = Request::builder()
107-
.method("POST")
108-
.uri("/posts")
109-
.header("content-type", "application/json")
110-
.body(Body::from(r#"{"title":"X","content":""}"#))
111-
.unwrap();
102+
let req = post_json_request("/posts", r#"{"title":"X","content":""}"#);
112103

113104
let res = app.oneshot(req).await.unwrap();
114105
assert_eq!(res.status(), 422);
@@ -127,12 +118,7 @@ async fn malformed_json_propagates_400_not_422() {
127118
// `Validated<T>` must forward that rejection unchanged rather than
128119
// synthesizing a 422 from a non-existent garde report.
129120
let app = router();
130-
let req = Request::builder()
131-
.method("POST")
132-
.uri("/posts")
133-
.header("content-type", "application/json")
134-
.body(Body::from("not json"))
135-
.unwrap();
121+
let req = post_json_request("/posts", "not json");
136122

137123
let res = app.oneshot(req).await.unwrap();
138124
// Axum's Json extractor returns 400 (or 415 depending on cause) —
@@ -219,12 +205,7 @@ async fn dispatch(app: Router, payload: ::serde_json::Value) -> (u16, ::serde_js
219205
let res = app.oneshot(req).await.unwrap();
220206
let status = res.status().as_u16();
221207
if status == 422 {
222-
assert_eq!(
223-
res.headers()
224-
.get("content-type")
225-
.map(|v| v.to_str().unwrap()),
226-
Some("application/json"),
227-
);
208+
assert_json_content_type(res.headers());
228209
}
229210
let body: ::serde_json::Value = ::serde_json::from_str(&body_to_string(res.into_body()).await)
230211
.unwrap_or(::serde_json::Value::Null);
@@ -312,12 +293,7 @@ async fn rule_range_minimum_violation_returns_422() {
312293
"ok"
313294
}
314295
let app = Router::new().route("/n", post(handler));
315-
let req = Request::builder()
316-
.method("POST")
317-
.uri("/n")
318-
.header("content-type", "application/json")
319-
.body(Body::from(r#"{"age":-1}"#))
320-
.unwrap();
296+
let req = post_json_request("/n", r#"{"age":-1}"#);
321297
let res = app.oneshot(req).await.unwrap();
322298
assert_eq!(res.status(), 422);
323299
let body: ::serde_json::Value =
@@ -410,24 +386,15 @@ async fn multiple_per_rule_violations_all_appear_in_envelope() {
410386
#[tokio::test]
411387
async fn byte_snapshot_422_envelope_multi_error() {
412388
let app = router();
413-
// Trigger 2 validation errors: title too short + content empty
414-
let req = Request::builder()
415-
.method("POST")
416-
.uri("/posts")
417-
.header("content-type", "application/json")
418-
.body(Body::from(r#"{"title":"X","content":""}"#))
419-
.unwrap();
389+
let req = post_json_request("/posts", r#"{"title":"X","content":""}"#);
420390

421391
let res = app.oneshot(req).await.unwrap();
422392
assert_eq!(res.status(), 422);
423393

424-
// Read full response body as bytes and convert to string
425394
let body_bytes = ::axum::body::to_bytes(res.into_body(), usize::MAX)
426395
.await
427396
.unwrap();
428397
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
429398

430-
// Snapshot the exact serialized bytes (as UTF-8 JSON string)
431-
// This locks the envelope shape: {"errors":[{"path":"...","message":"..."}]}
432399
insta::assert_snapshot!("validated_422_envelope_multi_error", body_str);
433400
}

0 commit comments

Comments
 (0)