Interop Test Report — Minimalist Kafka, Java ⇄ Rust¶
Live validation of the Rust minimalist-kafka port (gate K4 of
the port spec)
against the Java module, conducted 2026-09-21 on one real broker: the Rust kafka-demo on its own,
the Java and Rust engines chained in both directions, a consumer group with one member of each
engine, and a member killed hard while records were in flight.
This report is a permanent record. It documents what was tested, the evidence collected, and — in the interest of honest engineering — the defects the drive surfaced and how each was fixed or recorded. Everything here is reproducible from the shipped examples by following the Rust kafka-demo runbook and its Java twin.
Setup¶
| Piece | What ran |
|---|---|
| Broker | the Java repository's helpers/kafka-standalone 4.12.13 — Apache Kafka 4.3.1, one KRaft node on 127.0.0.1:9092, started fresh (it deletes its topics on restart) |
| Java app | examples/kafka-demo 4.12.13, unchanged: demo.inbound → kafka-demo-flow, demo.orders → the second-level routing rules; groups kafka-demo-group and kafka-demo-orders-group |
| Rust app | examples/kafka-demo (this port's twin, new at K4) on the K3 engine (mercury-minimalist-kafka, mercury #299) — the same topics, flows, rule list and the same group ids as the Java app, deliberately, so the two can share a group; the interop profile turns it into a relay between demo.interop.in, demo.inbound, demo.outbound and demo.interop.out |
| Helpers | Node 22 with kafkajs 2.2.4 — the five programs copied from the Java demo (create-topics, listen-outbound, listen-dlq, publish-inbound, publish-orders); seven topics with 10 partitions each |
| Protocol | classic rebalance protocol on both clients during the drive — both bundled templates left group.protocol unset at the time (the Java line was commented out); re-ruled the same evening, see the addendum |
Timestamps below are the machines' own: the Node programs and the Rust app print UTC (Z), the
Java app and the broker print local time (UTC−7).
Scenario 1 — direct routing, Rust alone¶
One message published to demo.inbound; the Rust adapter runs kafka-demo-flow; the flow publishes
to demo.outbound.
publisher [17:41:24.937Z] -> demo.inbound cid=d4782a35-… traceId=19f866087b99895b4a14ce03a7c2c4ba hello composable kafka
rust app 17:41:24.939Z INFO [kafka_demo] Received from KAFKA /demo.inbound (cid=d4782a35-…, traceId=19f866087b99895b4a14ce03a7c2c4ba, incoming span=6872b7906f8391ca): hello composable kafka
listener [17:41:24.948Z] <- demo.outbound[p7] cid=d4782a35-… traceId=19f866087b99895b4a14ce03a7c2c4ba span=9e29a3ce17d28bcb {"processedAt":"2026-09-21T17:41:24.939Z","processedBy":"kafka-demo","received":"hello composable kafka","traceId":"19f866087b99895b4a14ce03a7c2c4ba"}
One trace id at the publisher, in the app and at the listener; the incoming span is the publisher's,
the outbound span is a new one stamped by simple.kafka.notification. Eleven milliseconds end to end.
Scenario 2 — second-level routing, Rust alone¶
Four records on demo.orders, one per rule of the binding's flows list (serializer: 'json',
ttl: 15s):
| Command | Rule that fired | Evidence |
|---|---|---|
order {"item": "mobile-phone", "qty": 1} |
input.header.type(order) → flow://demo-order-flow |
app: Order event routed by rule type(order) … {"item":"mobile-phone","qty":1}; listener: "routedBy":"input.header.type(order)", "order":{"item":"mobile-phone","qty":1} — the decoded map went in and JSON bytes came out |
order-123 {"item": "laptop", "qty": 1} |
input.header.type(order-*) (wildcard) → the same flow |
"routedBy":"input.header.type(order-123)" |
refund {"order_id": "order-123"} |
input.body.event.kind(refund) → task://demo.refund.processor |
app: Refund routed by rule input.body.event.kind(refund) (cid=88c67ed0-…, traceId=b67abc94…): {"event":{"kind":"refund"},"order_id":"order-123"}; nothing on demo.outbound — a task target publishes nothing |
hello |
default → flow://demo-catch-all-flow |
app: Unmatched record caught by the default rule … raw bytes (not a JSON object/array); listener: "shape":"raw bytes (not a JSON object/array)","received":"hello","routedBy":"default" |
Every record kept its own publisher trace id through the app and onto the listener.
Scenario 3 — the failure path, Rust alone¶
order hello: the type=order header matches, the payload is not JSON, so the map-typed
demo.order.processor cannot digest it.
17:41:26.007Z WARN [minimalist_kafka::consumer] flow 'demo-order-flow' failed for a 'demo.orders' message (attempt 1/3); retrying - flow 'demo-order-flow' returned status 400
17:41:26.510Z WARN … (attempt 2/3); retrying …
17:41:27.012Z WARN … (attempt 3/3); retrying …
17:41:27.515Z WARN … failed for a 'demo.orders' message after 4 attempt(s); routing to Some("demo.orders.dlq") - flow 'demo-order-flow' returned status 400
dlq listener [17:41:27.528Z] <- demo.orders.dlq[p9] cid=ba56c312-…
origin: demo.orders
error: flow 'demo-order-flow' returned status 400
body: hello
Three retries at the configured 500 ms backoff, then the original record parked with
dlq.origin.topic and dlq.error, headers and body preserved, 1.5 s after publication. The
partition stayed live (the later scenarios ran on the same binding).
Scenario 4 — interop leg 1: Rust producer → Java flow adapter¶
The Rust relay (interop profile) consumes demo.interop.in, processes, and publishes to
demo.inbound — the Java app's inbound topic. The Java app processes and publishes to
demo.outbound, which the Rust relay consumes again and forwards to demo.interop.out. One trace
id, three hops, two engines:
publisher [17:42:39.987Z] -> demo.interop.in cid=e0b18dcf-… traceId=be9231bede94493dcf53662fed94151a across two engines
rust relay 17:42:39.987Z Received from KAFKA /demo.interop.in (cid=e0b18dcf-…, traceId=be9231be…, incoming span=47d6f645edfe6186): across two engines
java app 10:42:40.038 DemoProcessor - Received from demo.inbound (cid=e0b18dcf-…, traceId=be9231be…, incoming span=bd3138f4bce13eea): {"processedAt":"2026-09-21T17:42:39.987Z","processedBy":"kafka-demo","received":"across two engines","traceId":"be9231be…"}
java app 10:42:40.043 Telemetry - {trace={path=KAFKA /demo.inbound, parent_span_id=bd3138f4bce13eea, span_id=abb35e81d7085f1c, service=demo.processor, … id=be9231be…}}
java app 10:42:40.070 Telemetry - {trace={path=KAFKA /demo.inbound, parent_span_id=abb35e81d7085f1c, span_id=83e43d5ca8106df5, service=simple.kafka.notification, … id=be9231be…}}
listener [17:42:40.058Z] <- demo.outbound[p6] cid=e0b18dcf-… traceId=be9231bede94493dcf53662fed94151a span=83e43d5ca8106df5 {…Java's output…}
rust relay 17:42:40.058Z Received from KAFKA /demo.outbound (cid=e0b18dcf-…, traceId=be9231be…, incoming span=83e43d5ca8106df5): {…Java's output…}
listener [17:42:40.066Z] <- demo.interop.out[p4] cid=e0b18dcf-… traceId=be9231bede94493dcf53662fed94151a span=bcc6d2ddb4eeb4b1 {"processedBy":"kafka-demo","received":"{ \"received\": \"{\\\"received\\\":\\\"across two engines\\\",…}\", \"processedBy\": \"kafka-demo\" …}", "traceId":"be9231be…"}
The span chain is exact across the engine boundary in both directions: the Java demo.processor's
parent span (bd3138f4bce13eea) is the span the Rust notification stamped, and the Rust relay's
incoming span on demo.outbound (83e43d5ca8106df5) is the Java notification's span. The final
record nests the three hops' outputs. 79 ms end to end.
Scenario 5 — interop leg 2: Java producer → Rust flow adapter¶
The publisher writes to demo.inbound as in the plain demo; the Java app processes and publishes
to demo.outbound; the Rust relay consumes and forwards.
publisher [17:42:40.497Z] -> demo.inbound cid=44c92fd4-… traceId=97314d7cb7632d822787de512b4a0111 java first
java app 10:42:40.498 DemoProcessor - Received from demo.inbound (cid=44c92fd4-…, traceId=97314d7c…, incoming span=35b194c63185b1c6): java first
java app 10:42:40.500 Telemetry - {trace={… parent_span_id=ac30454f41980a44, span_id=bbf11dd82bad9169, service=simple.kafka.notification, … id=97314d7c…}}
rust relay 17:42:40.507Z Received from KAFKA /demo.outbound (cid=44c92fd4-…, traceId=97314d7c…, incoming span=bbf11dd82bad9169): {…Java's output…}
listener [17:42:40.514Z] <- demo.interop.out[p8] cid=44c92fd4-… traceId=97314d7cb7632d822787de512b4a0111 span=83bb59eb42f2caf5 {"processedBy":"kafka-demo","received":"{ … \"received\": \"java first\", \"processedBy\": \"kafka-demo\" }","traceId":"97314d7c…"}
Scenario 6 — a mixed-engine consumer group¶
The Rust app (plain profile) was started while the Java app was running, joining the same
kafka-demo-group on demo.inbound:
broker 10:44:40.302 Preparing to rebalance group kafka-demo-group … (reason: Adding new member rdkafka-3aa7b8bf-…)
broker 10:44:40.914 Stabilized group kafka-demo-group generation 4 with 2 members.
A burst of 20 records: 10 processed by the Java member, 10 by the Rust member, all 20 at the
listener within one second, 20 unique, none delivered twice. A Java client and a librdkafka client
share one classic group (the range assignor is common to both), so a consumer group can be
migrated between engines member by member.
Scenario 7 — a member killed hard: takeover and in-flight redelivery¶
7a — the timeout. The Rust member was killed with SIGKILL at 17:44:45; the coordinator
declared it dead 45 s later, the client default session.timeout.ms on both engines:
broker 10:45:30.677 Member rdkafka-3aa7b8bf-… in group kafka-demo-group has failed, removing it from the group.
broker 10:45:30.677 Preparing to rebalance group kafka-demo-group … (reason: removing member … on heartbeat expiration.)
7b — in flight at death. With the mixed group re-formed (generation 6, 2 members), four poison
orders (order poison-N) were published to demo.orders and the Rust member killed 0.7 s later —
mid-retry: its log ends at attempt 1/3 of one of them, nothing dead-lettered yet. After the
takeover the Java member retried and dead-lettered every one of them: the DLQ holds exactly four
records (17:46:57 to 17:47:02), each with the Java error text
java.lang.IllegalStateException: flow 'demo-order-flow' returned status 500 — the record the Rust
member had in flight was redelivered, not lost, and not duplicated on the DLQ.
7c — records published while the member is dead. 20 records to demo.inbound at 17:46:10.6:
listener [17:46:10.783Z] <- demo.outbound[p9] … the 10 records on the live Java member's partitions arrive at once …
listener [17:46:10.864Z] <- demo.outbound[p4]
broker 10:46:52.983 Member rdkafka-8a6b68d7-… in group kafka-demo-group has failed, removing it from the group.
broker 10:46:55.977 Stabilized group kafka-demo-group generation 7 with 1 members.
listener [17:46:56.121Z] <- demo.outbound[p6] … the 10 records on the dead member's partitions arrive after the takeover …
listener [17:46:56.200Z] <- demo.outbound[p0]
20 of 20 unique at the listener, none delivered twice; the second half waited 45.5 s for the coordinator to expire the dead member and hand its partitions to the Java member. That is the at-least-once contract as deployed: nothing is lost on a pod death, and the recovery time is the session timeout.
7d — the graceful counterpart. Stopping the Rust app with SIGINT drains each binding's
consumer after its in-flight record and leaves the group explicitly:
rust app Kafka flow consumer for topic 'demo.inbound' stopping / stopped (both bindings)
broker 10:42:25.634 Member rdkafka-6ed7af08-… has left group through explicit `LeaveGroup` request …
broker 10:42:25.636 Preparing to rebalance group kafka-demo-group … (reason: explicit `LeaveGroup` request …)
The rebalance is immediate, where the hard kill cost 45 s.
Findings¶
- A headless Rust application exited right after boot — fixed on this branch. The first live
run processed nothing: the app compiled its flows, started both consumers and exited 200 ms later,
while the broker held the six records the publishers had written.
AutoStart::runparked the process only when serving HTTP or websockets; a Kafka-consuming app with no REST server had nothing else holding it open — on the JVM the module's consumer threads are non-daemon, so the Java app never had the problem, and the Rust e2e never saw it because a test function keeps the runtime alive. Fix:Platform::keep_running(reason), declared once by the flow adapter when its consumers start and honoured byAutoStart::runexactly like HTTP serving (Kafka flow adapter (2 binding(s)) keeps the application running until it is stopped, thenApplication running - press Ctrl-C to stop). The adapter also registers its consumers' stop as a shutdown hook, which is what produced scenario 7d. SIGTERMis not handled by the Rust entry point — follow-up, platform-wide.AutoStart::runwaits on Ctrl-C (SIGINT) only. A Kubernetes pod stop sendsSIGTERM, so a rolling restart of a Rust consumer today looks like scenario 7c (partitions held until the 45 s session timeout) rather than 7d (immediateLeaveGroup). The Java process runs its shutdown hooks onSIGTERM. This is not Kafka-specific — every headless or REST Rust application is affected — and was recorded for platform-core rather than patched in the K4 change. Closed the same day (branchfix/sigterm-graceful-stop, the maintainer's direction):AutoStart::runnow stops onSIGTERMexactly as on Ctrl-C, and the flow adapter's shutdown hook waits — bounded by a 10 s grace — for every binding consumer to finish its in-flight record before the process goes on, so a pod stop takes the scenario 7d path: the record commits, the member leaves the group explicitly, the rebalance is immediate.- Presentation differences, not defects. (a) The dead-letter
dlq.errortext differs: the Rust engine reportsflow 'demo-order-flow' returned status 400(a typed function's input that cannot be deserialized is a 400), the Java enginejava.lang.IllegalStateException: flow 'demo-order-flow' returned status 500(a Java type mismatch is a 500). Both are the same contract — the original record parked with the failure named. (b) The Java demo's outbound JSON is pretty-printed (its mapper's default), the Rust demo's is compact; both are valid JSON and any consumer reads either. (c) The reuseddemo.processorlogged a fixed topic name; the Rust demo now logs the injected trace path (KAFKA /<topic>), which is what made the relay hops in scenarios 4 and 5 legible. - A new consumer group starts from
auto.offset.reset=earliest(the bundled template's default): when the relay'skafka-demo-interop-outgroup first joined, it consumed the four records already ondemo.outboundfrom scenarios 1 and 2 and forwarded them todemo.interop.out. Expected, and worth knowing when reading an interop listener.
Learnings, kept as the playbook¶
- Wait for the broker's word before publishing. A consumer's group join takes seconds; the
drive polled the broker log for
Stabilized group <id>before each publish. The kafkajs listeners (fromBeginning: false) likewise see nothing published before their assignment. - Distinguish engines by their logs, not by the payload: both demos stamp
processedBy: kafka-demo. The Rust log names the hop (Received from KAFKA /<topic>), the Java log is the DemoProcessor line plus the telemetry span path. - The wire contract that makes the trace continuous across engines is the record-header pair
cid+ W3Ctraceparent, with the notification stamping a fresh span per hop. The nestedreceivedfield on the interop listener shows the hop order for free. - Recovery time after a hard death is the session timeout, identical on both clients (45 s); a graceful stop rebalances at once. Finding 2 is what stands between a Rust pod and the graceful path in Kubernetes.
- Run the failure path against the real broker at least once: the mock cluster proves the logic, the live broker proves the timing (retry spacing, DLQ latency, takeover) that operators will actually see.
Addendum — group.protocol=auto re-ruled and proven live¶
The maintainer asked, after the drive, why the Rust port had not switched to the KIP-848 consumer
protocol on a broker that supports it. The evidence: the broker finalizes group.version=1; the Java
template shipped group.protocol commented out, so the Java demo had joined classic as well; the
Rust template set no key. The K3 delta (auto = classic, for want of a feature probe in librdkafka)
was replaced the same evening by an optimistic auto — start each binding's consumer with
consumer, and if the broker refuses it, rebuild the consumer once with classic — because librdkafka
reports the refusal as a fatal ConsumerGroupHeartbeat error (UNSUPPORTED_VERSION when the
coordinator has the protocol disabled, _UNSUPPORTED_FEATURE when the API is not advertised), and a
refused join never becomes a group member. Both engines now ship auto uncommented in their bundled
consumer templates (maintainer decision, 2026-09-21).
Proof A — the default template against the KIP-848 broker (kafka-standalone, group.version=1):
rust app group.protocol=auto - trying the KIP-848 consumer rebalance protocol first; a broker without it resolves the binding to classic at its first join
rust app Kafka flow adapter binding: topic 'demo.inbound' -> flow 'kafka-demo-flow' (consumer group 'kafka-demo-group', dlq-topic 'demo.inbound.dlq', protocol consumer (auto))
broker [GroupId kafka-demo-group] Member NzRWAus/TUaUSqJb63ZcWg joins the consumer group using the consumer protocol.
broker [GroupId kafka-demo-group] Bumped group epoch to 2 with metadata hash 2421248355301747518.
broker [GroupId kafka-demo-orders-group] Computed a new target assignment for epoch 2 with 'uniform' assignor in 3ms.
A published message was consumed; SIGTERM produced Member … left the consumer group — the
KIP-848 explicit leave.
Proof B — the same binary against a coordinator with the consumer protocol disabled
(the standalone broker started with group.coordinator.rebalance.protocols=classic through the
Spring Boot PropertiesLauncher and a loader.path override of server.properties):
rust app ERROR librdkafka: FATAL [thrd:main]: Fatal error: Broker: API version not supported: ConsumerGroupHeartbeat fatal error: Broker: API version not supported
rust app WARN group.protocol=auto resolved to classic for topic 'demo.inbound' - the broker does not support the consumer rebalance protocol (ConsumerGroupHeartbeat fatal error: Broker: API version not supported); rejoining with the classic protocol
broker Dynamic member with unknown member id joins group kafka-demo-group in Empty state. Created a new member id rdkafka-0b6d4827-…
Both bindings rejoined classic and consumed the published message. The client logs the refusal at
ERROR (twice per binding, librdkafka's own "Fatal error" and "Global error" lines) before the
adapter's WARN states the resolution — loud, but the outcome is the Java resolver's.
The flow adapter's own e2e now runs auto against MockCluster, which accepts the consumer protocol:
every binding logs protocol consumer (auto) and none falls back. Spec §7 item 7 records the
re-ruling.
Addendum — K5: sync-over-async over this transport, and the Schema Registry legs, both engines¶
Conducted 2026-09-21, after K5a (the Schema Registry codec, branch
feat/minimalist-kafka-schema-registry) and K5b (the facade tasks sync.prepare / sync.await /
soa.reply in mercury-sync-over-async, and the sync-over-async-demo mirrored from Java with its
facade and backend roles) were built. The same broker as above plus the Java repository's other two
helpers; the Java demo unchanged at 4.12.13.
Setup¶
| Piece | What ran |
|---|---|
| Broker | helpers/kafka-standalone 4.12.13 (Apache Kafka 4.3.1, KRaft, 127.0.0.1:9092), started fresh; the six demo topics with 10 partitions each created by the demo's create-topics.js |
| Redis | helpers/redis-standalone 4.12.13 on 6379 — the return route (request:{cid} / queue:{cid} keys, one return channel per pod) |
| Registry | helpers/schema-registry-standalone 4.12.13 on 8081, seeded from the demo's registry/1.json (subject sync-demo-json, a JSON Schema) and 2.json (subject sync-demo-avro, an Avro record) |
| Java app | examples/sync-over-async-demo 4.12.13, unchanged: profile facade (REST on :8500 here) with Confluent's own serializers behind simple.kafka.notification, profile backend |
| Rust app | examples/sync-over-async-demo (this port's mirror, new at K5b): profile facade (:8400, a second instance on :8401), profile backend; the Confluent legs through this engine's own codec |
| Protocol | every Rust binding joined with the KIP-848 consumer protocol — protocol consumer (auto) for all six bindings; the Java demo's bundled template is auto too and its clients joined the same groups |
Scenario 8 — Rust facade + Rust backend, the three legs¶
[raw-1] HTTP 200 trace=00000000000000000000000002e2d06d
{"processedAt":"2026-09-21T23:06:11.977Z","processedBy":"system-of-record","request":{"action":"create","order":"raw-1"},"status":"processed","traceId":"00000000000000000000000002e2d06d"}
[json-1] HTTP 200 trace=0000000000000000000000007993c3cf ... "traceId":"0000000000000000000000007993c3cf"
[avro-1] HTTP 200 trace=00000000000000000000000002395ee0
{"action":"create","processedBy":"system-of-record","status":"processed","traceId":"00000000000000000000000002395ee0"}
Every reply's traceId is the trace id of the traceparent the caller sent — REST edge → sync.prepare
→ Kafka → the backend flow → Kafka → soa.reply → the Redis return route → sync.await, one trace. The
Avro request reached the backend as {"action":"create","processedBy":"","status":"","traceId":""}:
the codec applied the record's defaults on the way out and dropped order (a closed-shape record), and
the flat reply came back the same way. Facade start-up:
Schema codec ready (registry=http://127.0.0.1:8081, cache=schema.registry, ttlMs=1800000, types=[JSON, AVRO], strictJson=false, csfle=unsupported, auth=none)
Kafka flow adapter binding: topic 'soa.response' -> flow 'soa-reply' (consumer group 'soa-reply-group', protocol consumer (auto))
Kafka flow adapter binding: topic 'json-topic-2' -> flow 'soa-reply-json' (consumer group 'soa-reply-json-group', schema decode on, protocol consumer (auto))
Kafka flow adapter binding: topic 'avro-topic-2' -> flow 'soa-reply-avro' (consumer group 'soa-reply-avro-group', schema decode on, protocol consumer (auto))
Return-route coordinator started for pod a5fab940c34a45cbb908dfca85febaed (redis 127.0.0.1:6379, ssl=false, channel svc-return:a5fab940c34a45cbb908dfca85febaed)
The first attempt answered 404 on every endpoint — Skip [POST] /api/sync-to-async - Service
http.flow.adapter not available, no bindings, no codec. The demo binary named no symbol of the flow
engine or the Kafka crate (it activates both by configuration alone), so the linker dropped their
inventories — exactly the caveat of spec §7 item 6, met again one crate further out. Two lines fixed it
(use event_script as _; use minimalist_kafka as _;); recorded as Finding 5.
Scenario 9 — the timeout path¶
The Rust backend stopped; a request with x-sync-timeout: 2000:
[timeout-1] HTTP 408 trace=0000000000000000000000007493789d (3 s wall clock, curl included)
{"message":"Timed out awaiting response for e425aa446692460f81e151d5f8c403b7","status":408,"type":"error"}
sync.await failed with 408, the flow's exception handler (sync.error.handler) passed it through and
aborted the pending entry. The request itself sat on soa.request; when the Java backend joined the group
20 s later (Scenario 10) it processed it — Processing request (cid=e425aa44…): {action=create,
order=timeout-1} — and its reply found no return route: the orphan contract (delivered: false), nothing
retried, nothing leaked.
Scenario 10 — Java backend, Rust facade: the frames cross both ways¶
The Rust facade's three legs against the Java backend, all 200 with continuous trace ids. On the request
legs Confluent's deserializers decoded this engine's frames; on the reply legs this engine's codec decoded
Confluent's serializers' frames:
java backend Processing request (cid=c5ea4622…): {action=create, order=raw-j1}
java backend Processing request (cid=fbe85bc8…): {action=create, order=json-j1}
java backend Processing Avro request (cid=8f4b6241…): {traceId=, action=create, processedBy=, status=}
The JSON leg carried order through (the schema is additionalProperties: true); the Avro leg arrived
with the defaults this engine's codec applied. Java logged AssociatedNameStrategy: Associations endpoint
not found (404), using fallback strategy per schema call — the mock registry lacks that endpoint;
Confluent falls back, harmless.
Scenario 11 — Java facade, Rust backend¶
The Java facade on :8500 (-Dspring.profiles.active=facade -Drest.server.port=8500) with the Rust
backend: raw, JSON Schema and Avro all 200; the Rust backend processed all three and the Java facade's
reply flows decoded this engine's frames — {trace={path=KAFKA /json-topic-2, … service=soa.reply.json,
success=true, … status=200}}.
Scenario 12 — a mixed consumer group on the request topic, and the return route across engines¶
Both backends in system-of-record-group; 20 raw requests through the Rust facade (:8400): 20 of 20
answered 200, split 9 processed by the Rust backend, 11 by the Java backend. The Java facade had
stayed a member of soa-reply-group from Scenario 11 — and consumed 8 of the 20 replies:
a8827fe5: rust=0, java=3 096e73b7: rust=0, java=3 48ffee4f: rust=0, java=3 3aa5975d: rust=0, java=3
5ab5360d: rust=0, java=3 b3ae1288: rust=0, java=3 b177c127: rust=0, java=3 122fa39d: rust=0, java=3
(per trace id: the facade whose log carries the KAFKA /soa.response → soa.reply span). Each of those
eight requests still returned 200 to the Rust facade that was waiting: the Java soa.reply handed the
reply to its coordinator, which published to the Rust pod's return channel, and the Rust sync.await
woke. The return route works across pods and across engines — the Redis keys and the channel name
are the wire contract, and both engines honour it.
Scenario 13 — two Rust facades¶
A second Rust facade on :8401 joined soa-reply-group (three members with the Java facade). Six requests
alternating between :8401 and :8400, all 200; three replies were consumed by the answering facade,
three by the Java facade and delivered cross-pod as in Scenario 12. In this sample no reply for a :8401
request landed on :8400 (ten partitions, three members, random placement of keyless records) — the
Rust-to-Rust hop is the same mechanism the Java-to-Rust hop already exercised.
Findings (K5)¶
Finding 5 — configuration-only activation needs the link line in every binary. The demo's Cargo.toml
declared mercury-event-script and mercury-minimalist-kafka, its YAML used both, and the binary shipped
without either: the Rust linker drops an rlib no symbol references, inventory entries included, so the REST
endpoints had no http.flow.adapter and no consumer started — the application answered 404 and looked
configured. The MVP e2e in the extension had the two use … as _; lines and was green; the binary did
not. Fixed in the demo (src/main.rs), stated in its README and the guide; spec §7 item 6 already
recorded the caveat for the Kafka crate, this drive shows it applies to every crate activated by
configuration alone (the flow engine too). Lesson: run the real binary against the real helpers before
calling a configuration-only feature done — the in-process test links what the binary may not.
Observed, not defects: the orphan reply after a facade timeout (Scenario 9) and Confluent's associations WARN against the mock registry (Scenario 10).
Verdict¶
The K5b gate holds: the Java RestFlowMvpTest analog (extensions/sync-over-async/tests/rest_flow_mvp.rs)
is green in Rust — REST → flow → mock cluster → flow → return route → 200 with the echoed request and a
continuous trace id, and 408 when the backend drops the request — and the live drive proves the facade
pattern on the Rust engine alone, in both cross-engine pairings including the Confluent JSON Schema and
Avro legs in both directions, with a mixed consumer group on the request topic and the return route
delivering across engines.