Interop Test Report — Progressive Rendering, all four runtimes¶
Live cross-runtime validation of the progressive streaming contract across the four Mercury Composable runtimes — the Java engine (mercury-composable), the Rust engine (mercury), and the Python (mercury-python) and Node.js (mercury-nodejs) function hosts — conducted 2026-08-30 (UTC) at the close of the streaming program's wrapper round. This report is a permanent record in the tradition of the Event over HTTP interop report: what was tested, the evidence, and the defects the round surfaced with their fixes.
The contract under test¶
One paradigm on all four runtimes: the caller provides a reply address; the callee
streams events to it until a terminal signal. Each segment is one event to the
caller's reply_to, marked with the reserved envelope header
x-event-stream: data | eof | exception. Across the Event-over-HTTP hop, the peer
answers the one POST with a Server-Sent Events response in the hybrid envelope-mode
dialect: envelope frames (the reserved SSE event name envelope, one base64-encoded
serialized envelope per frame) wherever envelope semantics matter — the head, the
terminals, and any segment that cannot round-trip as plain text — and raw SSE frames
for text tokens. The consuming client decodes the dialect and forwards each event to
the original reply address with the original correlation id, so a local stream and a
remote stream are indistinguishable to the consumer.
Delivered by: Java engine PRs
#299 (edge streaming),
#300 (SSE consumption),
#301 (envelope mode) —
ADR-0018/0019; Rust engine PRs
#216,
#217,
#218 — ADR-0015/0016; and the
matching wrapper round in mercury-python and mercury-nodejs (interceptor functions,
EventStreamWriter, streaming /api/event host, stream()/stream_to() client).
The live matrix¶
Ten producer/consumer combinations were driven live with the SHIPPED demo applications — no test shims. Producers pace their segments (300 ms in these drives), so progressive delivery is directly observable in the arrival timestamps; buffered delivery would show all segments arriving together.
| # | Consumer | Producer | Result |
|---|---|---|---|
| 1 | Node.js client | Python hello.tokens |
segments at ~11/312/612/916 ms, eof metadata {count, language: python} |
| 2 | Python client | Node.js hello.tokens |
segments at ~5/304/606/908 ms, eof metadata {count, language: node.js} |
| 3 | Python client | Java engine hello.sse |
segments at ~103/407/708/1012 ms, terminal eof |
| 4 | Node.js client | Java engine hello.sse |
segments at ~13/320/620/924 ms, terminal eof |
| 5 | Python client | Rust engine hello.sse |
segments at ~3/306/607 ms, terminal eof |
| 6 | Node.js client | Rust engine hello.sse |
segments at ~10/312/615 ms, terminal eof |
| 7 | Java engine edge (/api/hello/remote) |
Python hello.tokens |
SSE out the engine edge at ~195/493/793/1094 ms, terminal event: done with the Python eof metadata |
| 8 | Java engine edge | Node.js hello.tokens |
~217/516/817 ms, terminal metadata {count, language: node.js} |
| 9 | Rust engine edge | Python hello.tokens |
~20/323/624 ms, same shape |
| 10 | Rust engine edge | Node.js hello.tokens |
~19/320/622 ms, same shape |
Common observations across all ten:
- Progressive on the wire — arrival gaps match the producer's pacing; nothing buffers end-to-end (rows 7–10 traverse the full chain: HTTP edge → engine relay function → Event-over-HTTP → wrapper host → wrapper function and back).
- Correlation — the caller's correlation id is restored on every delivered
envelope (D7), and the engines' demo authentication (
authorization: demo) rides the per-target security headers unchanged. - Exact types — eof trailing metadata arrives as a real map, not text, on every path (the envelope-frame escape hatch).
- Explicit degradation, observed live — a missing token produced the engine's
401 and a private target its 403, both delivered as clean envelopes through the
buffered fallback; a caller without the
accept: text/event-streamopt-in receives the pinned406 Streaming function requires a caller that accepts text/event-stream.
Engine ⇄ engine and per-runtime coverage¶
Because the protocol signatures are identical across the four runtimes, each
repository's unit suite exercises the full protocol against its own application
instance (client consuming its own /api/event host in one process): Java
EventOverHttpStreamTest (14 cases), Rust event_over_http_stream.rs (14), Python
test_event_stream.py (17), Node.js event-stream.test.ts (17). Each suite also
carries misbehaving-peer fixtures — a raw first frame, a transport end without a
decoded terminal, trailing frames after the terminal — because a self-loop alone
cannot catch a deviation implemented identically in both halves of one runtime; the
live matrix above is the cross-implementation conformance check, and it passed with
zero shims.
Trace continuity (OpenTelemetry span / parent-span verification)¶
The drives were repeated with telemetry capture to verify distributed-trace continuity across the streaming hop. Findings, with the observed evidence:
- One trace id end to end, both directions. In the engine→wrapper drive, the
Java edge minted trace
f22af2e005f2445198563e2dc4f1ba54; every engine span carried it (the relay function, the HTTP client leg, each reply-lane segment delivery), the Python function received it (trace id and path ride inside the wire envelope), and the demo now echoes it in the eof trailing metadata - so the terminal frame rendered out the engine's own edge carries the same trace id the edge minted: continuity is self-documenting in the demo output. In the wrapper→engine drive, a Python caller supplied W3C trace id4bf92f3577b34da6a3ce929d0e0e4736with trace pathPY /stream-drive; the engine'sevent.api.auth,event.api.serviceandhello.ssespans all carried that id, and the function's span carried the caller's trace path. - Span parenting chains on the engines. The streaming target's span parents
onto the Event API service span - observed live:
event.api.service span_id=bfcddb32dd597ddb→hello.sse parent_span_id=bfcddb32dd597ddb. Outbound, the engines' relay leg sends the W3Ctraceparentheader carrying the sending function's span id (the trace-aware PostOffice stamps the span onto the outbound event), so a receiving ENGINE parents its spans onto the caller's - the same cross-engine parenting validated in the Event over HTTP interop report. - Wrapper executions are real spans (closed at this round - the wrappers
originally propagated the trace id but minted no spans, which broke the
lineage into disconnected segments at every wrapper hop). The function hosts
now implement the engines' exact span model: every traced execution mints a
16-hex span with the caller's span (from the inbound envelope) as its
parent; outbound calls and stream segments carry the current span onward;
and non-RPC executions emit the engines' distributed-trace dataset record on
the
distributed.tracinglog stream - the same{"trace": {...}, "annotations": {...}}shape the Java engine logs - so one log aggregation (or a stdout log-ingest agent forwarding to an observability dashboard) stitches the full span tree across all four runtimes. RPC round-trips emit no dataset, exactly like the engines: their metrics fold into the caller's view. - The connected tree, live-proven in all three directions. Engine→wrapper:
under one Java-edge trace, the relay function's span
8cbc0b4d4be75362became the Pythonhello.tokensspan's parent (span_id=7b90847f4b8b617a,parent_span_id=8cbc0b4d4be75362in the wrapper's own trace record), and the Java reply-lane delivery spans then parented onto the Python span - edge → engine function → wrapper function → engine deliveries, one unbroken chain. Wrapper→engine: a Python caller carrying external span00f067aa0ba902b7(the shape of a user-edge OpenTelemetry span) producedevent.api.authandevent.api.servicespans parented on it, withhello.sseparented on the service span. Wrapper⇄wrapper: a Node.js execution's record showed the Python caller's span as its parent. This is the lineage the AI SDLC requires - user → agent → MCP → tools in one tree. - One deliberate exception: plain-text token segments ride raw SSE frames,
which carry no envelope metadata (the zero-overhead token path), so their
engine-side delivery spans join the trace unparented; a stream's head and
terminal segments ride envelope frames and parent correctly. The engines'
HTTP client leg (
async.http.request) remains a sibling span - classic Event-over-HTTP parity. - Correlation id: the caller's cid rode every delivered envelope in every
drive (
cid-trace-driveon all four segments of the supplied-trace drive).
The check itself surfaced defect #5: the engine demo relay originally built its
forward event with the raw EventEmitter, which stamps no trace - the hop
continued cid but DROPPED the trace id. Fixed by using a trace-aware
PostOffice (its touch fill-stamps from/trace/span), and the wrapper demos'
hello.tokens now echo their received trace id in the eof metadata so the
continuity is visible in every future drive.
Business correlation-id continuity¶
The same verification was run for the business correlation-id (my_cid) - the
engine-managed envelope tag captured at an engine's HTTP edge from the
configured header (default X-Correlation-Id) and injected into every
receiving function's input headers as the read-only my_correlation_id view.
The wrapper demos' hello.tokens echo the view in the eof metadata alongside
the trace id, so every future drive self-documents both continuity dimensions.
Live results:
- Java engine edge → Python:
X-Correlation-Id: biz-e2w-001on the edge request came back in the terminal metadata rendered out the same edge - edge header →my_cidtag → relay function's injected header view → trace-aware PostOffice re-stamp → packed envelope over the hop → wrapper host injection → function echo. - Rust engine edge → Python:
biz-rust-005echoed identically. The Rust demo relay needed no change: the Rust engine has one PostOffice andapply_current_tracealways stamps trace and business correlation-id from the ambient context. - Python → Node.js and Node.js → Python (wrapper ⇄ wrapper): a caller-side
context (
trace_context(..., my_correlation_id=...)/runWithTrace({..., myCorrelationId})) producedbiz-w2w-002/biz-w2w-003echoes from the opposite wrapper - the tag crossed the hop and the receiving host injected the view. - Wrapper → engine: the wrappers stamp the identical tag bytes (the same
codec proven above), and the engines' pinned suites cover the receiving
half: an
/api/eventcaller carrying themy_cidtag reaches the target function aspo.getMyCorrelationId()(JavaEventHttpTest.eventOverHttpPropagatesTraceAndCorrelationId; Rust twin).
The check surfaced one parity gap in the (unreleased) wrapper round, fixed and
test-pinned in both wrappers: the wrapper clients inherited trace id/path and
the internal correlation id into outbound events but did not re-stamp the
business correlation-id as the my_cid tag, and local bus deliveries skipped
the header-view injection that the HTTP host performs. Both halves now mirror
the engines (PostOffice.touch / WorkerHandler parity): outbound events carry
the context's business correlation-id as the tag, local deliveries inject the
read-only view, and the trace context (get_trace() / getTrace()) exposes
it to handlers and relays.
By design (confirmed at this verification round): an engine's /api/event
ingress serves LOCAL routes only - it answers 404 for a route it does not
host, even when its own yaml.event.over.http map points that route at a
peer. The map is caller-side routing for the app's own outbound calls;
forwarding inbound calls onward would make every application an
Event-over-HTTP relay and open routing loops (the x-event-api wire marker
exists precisely to prevent such re-forwarding). Callers address the owning
peer directly; deliberate hop-through composition is an explicit relay
function - the demo hello.remote.relay is exactly that pattern.
Defects surfaced by the round (fixed and re-verified)¶
Honest engineering record — each was caught by the twin-building discipline before any release:
- Rust engine: the envelope-mode single-shot reply initially reached the caller
unwrapped (rendered as plain JSON instead of the classic packed-envelope wire);
fixed by wrapping at the one
SingleShotoutcome site — caught by the twin suite's first run. - Node.js host: a
Promise.raceagainst a queue waiter abandoned the losing waiter, which would steal and drop the next envelope; fixed by reusing one pending promise across keep-alive cycles (raceMsdocuments the rule) — caught in design review before the first test run. - Error-body contract: the in-band exception bodies converged on the standard
error key-values
type/status/messageacross all four runtimes (three Java sites and their twins were missing thetypekey or used a plain-text body). - Node.js: object error bodies would have stringified as
[object Object]; fixed with JSON rendering (errorText). - Java demo relay: the forward event was built with the raw (untraced) EventEmitter, silently dropping the distributed trace across the hop; fixed with a trace-aware PostOffice - found by the trace-continuity verification above.
Reproduce¶
Every row of the matrix uses shipped demo applications:
- Engine as producer: run the Java
lambda-example(port 8085) or Rusthello-world(8085; any port with-Drest.server.port=…); their publichello.ssestreams paced test messages. Consume from a wrapper withPostOffice.stream("hello.sse", …, endpoint="http://host:port/api/event")and the demoauthorization: demoheader. - Engine as consumer: run a wrapper demo app (python
mercury-serve examples/demo_app.py, port 8086; node demo, 8087), then the engine demo with its routing map (Java:-Dyaml.event.over.http=classpath:/event-over-http.yaml; Rust: ships enabled) and-Dpeer.demo.portas needed, and watchcurl -N -H 'accept: text/event-stream' 'http://127.0.0.1:8085/api/hello/remote?delay=300&count=3'render the wrapper's tokens progressively out the engine's edge. - Wrapper ⇄ wrapper: point either wrapper's
stream()at the other demo's/api/event.
Baselines at the time of the drives: Java engine main after PR #301, Rust engine main after PR #218, mercury-python and mercury-nodejs at the streaming feature round - shipped together as the v4.12.0 milestone release across all four repositories.
Appendix - telemetry and app context example (live capture)¶
One live request, captured end to end, showing how the telemetry stream and
the application log context connect the engine to the wrapper. Setup: the Java
lambda-example (port 8085) with its event-over-http map pointing
hello.tokens at the Python demo app (port 8086, -Dlog.format=compact).
The caller supplies a business correlation-id; the engine's HTTP edge mints
the trace:
curl -N -H 'accept: text/event-stream' -H 'X-Correlation-Id: biz-e2e-777' \
'http://127.0.0.1:8085/api/hello/remote?delay=100&count=1'
1. Java engine - the relay function's telemetry record (the root span of
trace 710dcb0b706e4b5b949be138d0992b0b, minted at the edge):
{
"level": "INFO",
"time": "2026-08-29 21:24:17.734",
"source": "org.platformlambda.core.services.Telemetry.handleEvent(Telemetry.java:81)",
"thread": 762,
"message": {
"trace": {
"path": "GET /api/hello/remote?delay=100&count=1",
"span_id": "afa2897918e3871b",
"service": "hello.remote.relay",
"success": true,
"origin": "20260830a0dcb550832b4683b4853c609778cd82",
"start": "2026-08-30T04:24:17.733Z",
"exec_time": 0.728,
"from": "http.request",
"id": "710dcb0b706e4b5b949be138d0992b0b",
"status": 200
}
}
}
2. The wire - the relay's trace-aware PostOffice stamps the outbound event
with the trace id and path, its own span id (afa2897918e3871b) and the
my_cid tag (biz-e2e-777); the whole envelope crosses POST /api/event,
with X-Trace-Id and the W3C traceparent on the HTTP headers.
3. Python wrapper - the function's own application log line. The handler
runs log.info("Streaming %d messages", count); the app-log-context feature
adds the context block. Note the join keys: the engine's trace id, the
business cid from the edge header, and this execution's spanId with the
relay's span as parentSpanId:
{"time": "2026-08-29 21:24:17.847", "level": "INFO", "logger": "mercury_user_app:93", "message": "Streaming 1 messages", "context": {"cid": "biz-e2e-777", "traceId": "710dcb0b706e4b5b949be138d0992b0b", "tracePath": "GET /api/hello/remote?delay=100&count=1", "spanId": "55ebd6464ffdf9c2", "parentSpanId": "afa2897918e3871b", "service": "hello.tokens", "timestamp": "2026-08-30T04:24:17.847Z"}}
4. Python wrapper - the telemetry record for the same execution (same
span_id as the app log line's spanId - the join key between the two
streams; from names the calling engine function):
{"time": "2026-08-29 21:24:17.949", "level": "INFO", "logger": "distributed.tracing:158", "message": {"trace": {"origin": "202608303cbae673831a4ab08cbb6f108fec0171", "id": "710dcb0b706e4b5b949be138d0992b0b", "path": "GET /api/hello/remote?delay=100&count=1", "service": "hello.tokens", "start": "2026-08-30T04:24:17.847Z", "success": true, "from": "hello.remote.relay", "exec_time": 101.673, "status": 200, "span_id": "55ebd6464ffdf9c2", "parent_span_id": "afa2897918e3871b"}}}
5. Java engine - a reply-lane delivery record. The wrapper's stream
segments carry its span, so the engine-side delivery span parents onto the
Python function (parent_span_id = 55ebd6464ffdf9c2):
{
"level": "INFO",
"time": "2026-08-29 21:24:17.853",
"source": "org.platformlambda.core.services.Telemetry.handleEvent(Telemetry.java:81)",
"thread": 779,
"message": {
"trace": {
"path": "GET /api/hello/remote?delay=100&count=1",
"parent_span_id": "55ebd6464ffdf9c2",
"span_id": "a50c19b7415406df",
"service": "async.http.response.stream.499",
"success": true,
"origin": "20260830a0dcb550832b4683b4853c609778cd82",
"start": "2026-08-30T04:24:17.851Z",
"exec_time": 1.202,
"from": "hello.tokens",
"id": "710dcb0b706e4b5b949be138d0992b0b",
"status": 200
}
}
}
6. The caller's view - the terminal SSE frame rendered back out the Java edge echoes both continuity dimensions:
event: done
data: {"trace_id":"710dcb0b706e4b5b949be138d0992b0b","count":1,"my_correlation_id":"biz-e2e-777","language":"python"}
Connectivity, in one paragraph. Every record above carries the one trace
id the engine minted at its HTTP edge, and the span ids chain across the
runtime boundary in both directions: the edge request → the relay function's
span (afa289...) → the Python execution's span (55ebd6..., parented on the
relay) → the engine's reply-lane delivery spans (parented on the Python span).
The business correlation-id from the X-Correlation-Id edge header rides the
my_cid envelope tag through every hop and surfaces as the cid in the
wrapper's app-log context and in the demo's terminal metadata. The wrapper's
application log line and its telemetry record share the same span_id, which
is what lets a log aggregation (or a stdout log-ingest agent) attach a
function's own log output to the exact span of the distributed trace - the
complete telemetry and app-context story from user to engine to wrapper and
back, assembled entirely from the four runtimes' stdout logs.