Skip to content

Refactor(rust-sdk): rewrite and align with java sdk#5271

Draft
daflyinbed wants to merge 19 commits into
apache:developfrom
daflyinbed:refactor/rust-sdk
Draft

Refactor(rust-sdk): rewrite and align with java sdk#5271
daflyinbed wants to merge 19 commits into
apache:developfrom
daflyinbed:refactor/rust-sdk

Conversation

@daflyinbed

Copy link
Copy Markdown

This is a rewrite of rust sdk. I wrote old rust sdk it 2 years ago and stuck in setup a environment to debug it.
There is some anti pattern to make rust sdk compitable with java sdk.

Todo:

  • review every file.
  • cleanup and insert asf license header.

daflyinbed added 14 commits July 2, 2026 13:14
…d broker response codes

- codec: to_event skips response attrs using canonical ProtocolKey constants
  (statuscode/responsemessage/time) instead of stale spellings that leaked
  them as cloudevents extensions
- codec: get_subject no longer falls back to source, matching Java
  EventMeshCloudEventUtils.getSubject (avoids default '/' being treated as topic)
- consumer: subscribe_webhook records local subscriptions only on broker success
- consumer: unsubscribe removes local subscriptions only on broker success
…-compose

Add an end-to-end test suite (tests/e2e/) for the Rust SDK that exercises
the gRPC producer/consumer against a live EventMesh runtime. The suite is
fully self-contained: docker-compose.yml and its docker/conf/ config files
move into the crate directory so the tests carry their own infrastructure.

- e2e feature gate keeps plain  server-free; tests auto-skip
  when neither Docker nor a server is reachable
- runtime.rs manages the container lifecycle from Rust (no shell script):
  OnceLock-guarded  + ctor::dtor teardown, with an
  EVENTMESH_E2E_EXTERNAL override to reuse an external server
- parallel-safe by design: each test uses a unique topic and consumer group
  (default cargo test parallelism, no --test-threads=1)
- harness creates topics via the admin HTTP API and warms subscriptions
  (standalone requires a subscriber before publishing)
- capability-aware: request/reply and unsubscribe-after-publish tolerate
  standalone broker limitations while asserting fully on RocketMQ
- covers publish, batch publish, one-way, stream subscribe+receive,
  unsubscribe, and request/reply round-trip
…hosts

- add :z to config bind mounts so containers can read them on
  SELinux-enforcing hosts (configs are labeled user_home_t, unreadable by
  the confined container process)
- shrink broker heap and cap MaxDirectMemorySize: runbroker.sh auto-sizes
  the heap from total host RAM (~3.8GB here) with -XX:+AlwaysPreTouch,
  which prevents the broker from starting on memory-constrained hosts
  (silent exit 253)
- drop broker-logs/broker-store named volumes: the rocketmq image doesn't
  ship /home/rocketmq/{logs,store}, so those volumes are created
  root-owned and the broker (uid 3000) can't write to them, crashing on
  store init
Exercise the SDK against a durable RocketMQ backend (namesrv + broker +
eventmesh) instead of the in-memory standalone store. Update the profile
selection in the lifecycle harness and the matching doc comments.
The EventMesh admin /topic endpoint parses the POST body with Netty's
HttpPostRequestDecoder (application/x-www-form-urlencoded / multipart),
not JSON. Sending {"name":...} yielded a blank name and the broker threw
"Topic name can not be blank", so the topic was never created. The
consumer then subscribed to a non-existent topic, its rebalance failed
("topic not exist"), and no messages were ever pulled/delivered.

Use reqwest's .form() so the name is sent correctly; this makes topic
creation actually succeed for both the standalone and rocketmq profiles.
The channel was built with .timeout(config.timeout) (default 5s), which is
a hard per-RPC cap applied to every call on the channel. That wrongly
terminated two cases:

- request_reply: the caller may pass a much longer round-trip timeout
  (e.g. 15s), but the channel killed the RPC at 5s with a transport
  TimeoutExpired before the reply arrived.
- subscribe_stream: the bidirectional subscription stream is long-lived,
  so a channel-wide request timeout is incorrect by construction.

Drop the channel-wide timeout and apply the config default only to the
short unary RPCs (publish / batch publish / one-way / webhook subscribe /
unsubscribe) via a small `timed` wrapper. request_reply keeps its
caller-supplied timeout and subscribe_stream stays unbounded.
…class wins

The published apache/eventmesh image ships rocketmq-*.jar in lib/ (system
classpath). With them there, parent-first classloading always resolves
rocketmq-client's own ConsumeMessageConcurrentlyService over EventMesh's
shadow copy that lives in the storage plugin, so the consume listener
throws ClassCastException and silently drops every message before it can
be delivered over gRPC (apache#5213).

The plugin classloader (JarExtensionClassLoader) sorts its URL list
alphabetically, so once the rocketmq jars sit next to
eventmesh-storage-rocketmq.jar under plugin/storage/rocketmq/, the
EventMesh shadow class loads first and consumption works. Override the
eventmesh-rocketmq container command to move the jars there before
start.sh runs, so the e2e suite works against the unmodified image.
… request

Mirror the Java SDK's SubStreamHandler.buildReplyMessage so the consumer's
stream reply carries what the broker needs to match it back to the pending
request:

- build_reply now merges the incoming request's CloudEvent attributes into
  the reply (reply attrs win, request fills the gaps), propagating
  correlation99id / reply99to99client that RocketMQ's RequestFutureHolder
  pairs on. Previously the reply dropped these and the producer always
  timed out with CODE 10006.
- mark_as_reply no longer clears the reply data. EventMesh's
  ReplyMessageProcessor runs ServiceUtils.validateCloudEventData, which for
  text content requires non-empty textData; an empty body failed validation
  so producer.reply() was never invoked. The Java SDK does not strip the
  reply payload.

Also tighten the e2e request_reply skip so only a genuine 'not supported'
(standalone) reply is skipped; a request timeout (10006) now surfaces as a
real failure instead of being masked.

Verified: cargo test --features e2e passes 7/7; server logs show the full
round-trip (request -> consumer reply ~17ms -> producer receives reply ~34ms).
The background heartbeat task spawned in GrpcConsumer::new was never
cancellable — no Drop, no shutdown signal, JoinHandle discarded — so every
dropped consumer leaked a permanently-running task that kept sending
heartbeats against the broker.

Introduce a CancellationToken shared by the heartbeat loop and the stream
receive loop, with three shutdown entry points:
- StreamServe::with_graceful_shutdown(signal) — bind an external trigger
  (Ctrl-C, oneshot) that cancels the shared token
- GrpcConsumer::shutdown() — imperative, awaitable heartbeat exit
- Drop — unconditional safety net (cancel + abort)

subscribe_stream is now synchronous and returns a StreamServe driver
(impl IntoFuture). The gRPC stream is opened lazily on the first poll,
mirroring axum's Server::serve: a single .await drives subscription and
delivery, with optional .with_graceful_shutdown() chained before it.

Also: mark_as_reply now forces datacontenttype=application/json to match
the Java SubStreamHandler.buildReplyMessage, and the unused
SubscriptionReply type is removed.
* feat(rust-sdk): tcp

* fix(rust sdk): resolve TCP hostnames, sync unsubscribe state, harden e2e RR

Address three PR review findings in the TCP transport:

- connection.rs: pass "host:port" to TcpStream::connect instead of
  pre-parsing into SocketAddr, so DNS names like "localhost" (the
  default server_addr) resolve. Previously the default config and any
  hostname-based deployment failed with InvalidArgument.

- consumer.rs: clear the entire local subscriptions map on a successful
  unsubscribe. The runtime's UnSubscribeProcessor ignores the request
  body and drops all session topics (matching Java's bodyless
  MessageUtils.unsubscribe()), so retaining un-passed topics left the
  SDK believing they were still subscribed. Added a loopback unit test
  verifying subscribe A+B then unsubscribe([A]) yields empty local state.

- tcp_request_reply.rs: only skip the RR assertion for an externally
  provided server (Mode::External, the sole standalone-capable path);
  panic on Mode::Started where the harness-launched rocketmq broker
  must support request/reply. Stops timeouts, codec regressions, bad
  ACKs, and connection failures from being silently swallowed.

* fix(rust sdk): preserve request metadata in TCP replies

Merge the inbound request's wire properties into the listener's reply
before serializing RESPONSE_TO_SERVER, mirroring the gRPC consumer's
build_reply merge. A hand-built reply previously dropped the request's
correlation extensions (RocketMQ reply-to / correlation-id), so the
broker could not match the reply and TcpProducer::request_reply timed
out.

* fix(rust sdk): make TCP header seq optional for server-initiated frames

The Java runtime sends SERVER_GOODBYE_REQUEST and REDIRECT_TO_CLIENT with
seq = null (omitted by JsonUtils). A required seq field made serde reject
those valid frames before handle_inbound could send SERVER_GOODBYE_RESPONSE,
so clients dropped the connection with a codec error during graceful
shutdown/rebalance. Change Header.seq to Option<String> and adjust
correlation/ACK helpers accordingly.

* fix(rust sdk): handle REDIRECT_TO_CLIENT and preserve redirect target fields

The REDIRECT_TO_CLIENT feature was wired into the codec dispatch but
broken in two places, leaving TCP consumers unable to follow a rebalance:

1. RedirectInfo only had a defaulted `redirect_to` field, which does not
   match the Java wire shape (`RedirectInfo{ip,port}`). Serde silently
   discarded the target address when decoding a redirect frame, so even a
   handler could not reconnect. Align the struct with
   org.apache.eventmesh.common.protocol.tcp.RedirectInfo (ip: String,
   port: u16).

2. Command::RedirectToClient fell through to the default branch in
   handle_inbound and was silently ignored. The runtime then closes the
   session unconditionally after a 30s grace period
   (EventMeshTcp2Client.redirectClient2NewEventMesh +
   closeSessionIfTimeout), so delivery froze until the forced disconnect.
   handle_inbound now returns bool: the new RedirectToClient arm logs the
   advertised ip/port and signals the receive loop to stop so the caller
   can reconnect immediately, while ServerGoodbyeRequest and the default
   arm keep the previous loop semantics.
TCP auto-reconnect: the background I/O task now re-establishes the TCP
connection + HELLO handshake after I/O errors or server-side close, with
exponential backoff (1s–30s) and configurable retry cap (default infinite).
Consumers automatically replay all subscriptions + LISTEN_REQUEST on
reconnect via an internal notification channel. Mirrors the Java SDK's
heartbeat-driven reconnect but with backoff and prompt pending-request
cleanup.

  - ReconnectConfig in config/tcp.rs (enabled, max_retries, backoff range)
  - connection.rs refactored: establish() + outer reconnect loop +
    inner io_loop()
  - consumer.rs receive loop: select! on reconnect events, re-subscribe
    all topics + re-LISTEN

TCP CloudEvents: native cloudevents::Event interop over TCP, using
protocoltype=cloudevents and application/cloudevents+json body format
(matching the Java runtime's codec path). The producer gains
publish_cloud_event / broadcast_cloud_event / request_reply_cloud_event.
Inbound CloudEvents are transparently converted to EventMeshMessage so
existing listeners handle them without changes.

  - message.rs: build_cloud_event_package, parse_cloud_event,
    cloud_event_to_message, message_to_cloud_event (behind cloud_events)
  - producer.rs: three new inherent methods
  - consumer.rs: handle_inbound detects cloudevents protocol and converts
  - examples/tcp/producer_cloud_events.rs
  - tests/e2e/tcp_cloud_events.rs (publish + broadcast)

75 unit tests pass; clippy -D warnings clean; e2e compiles.
…tatus codes

- publish_one_way now calls the dedicated PublisherService/publishOneWay
  RPC instead of the two-way publish, honoring fire-and-forget semantics
- publish_cloud_event and request_reply check is_success() before
  returning Ok, mirroring publish/publish_batch so ? no longer treats
  broker-rejected (ACL/validation/send-failure) responses as success

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Welcome to the Apache EventMesh community!!
This is your first PR in our project. We're very excited to have you onboard contributing. Your contributions are greatly appreciated!

Please make sure that the changes are covered by tests.
We will be here shortly.
Let us know if you need any help!

Want to get closer to the community?

WeChat Assistant WeChat Public Account Slack
Join Slack Chat

Mailing Lists:

Name Description Subscribe Unsubscribe Archive
Users User support and questions mailing list Subscribe Unsubscribe Mail Archives
Development Development related discussions Subscribe Unsubscribe Mail Archives
Commits All commits to repositories Subscribe Unsubscribe Mail Archives
Issues Issues or PRs comments and reviews Subscribe Unsubscribe Mail Archives

@qqeasonchen

Copy link
Copy Markdown
Contributor

@daflyinbed please create an issue associate with this pr,and fix the pr title, you can accord to other prs. thx

wentongshi and others added 4 commits July 8, 2026 14:41
…ion lifecycle

Remove the Subscriber trait and split subscribe/unsubscribe (RPC) from
the receive-loop driver (connection lifecycle). Each transport now exposes
transport-specific consumer types with a background receive loop spawned
at construction time:

- GrpcStreamConsumer: eager-open bidi stream + spawned driver + heartbeat.
  subscribe() sends over the active stream; unsubscribe() is a unary RPC.
- GrpcWebhookConsumer: lightweight RPC-only client (no listener, no driver).
- TcpConsumer: connect() does TCP+HELLO+LISTEN+spawn driver in one step.
  subscribe/unsubscribe via conn.io() on the already-open connection.
- HttpConsumer: subscribe_webhook/unsubscribe as inherent methods;
  wait_for_shutdown added for clean exit.

Shutdown signal is optional, passed at construction. wait_for_shutdown()
blocks until the signal fires or the connection closes (clean exit); drop
does best-effort cancel+abort.

Deletes: Subscriber trait, StreamServe/ListenServe public types,
with_graceful_shutdown on drivers, TCP listen()/add_subscription().
- docker-compose.yml: default both EventMesh services to the locally-built
  eventmesh:jdk11-test image (IMAGE override still honored)
- runtime.rs: open perms on the bind-mounted docker/conf/ dir + files before
  . Containers run as a different uid (rocketmq = 3000)
  and on hosts with a restrictive ACL (other::---) the broker couldn't read
  its own config and crashed on boot with FileNotFoundException
- subscribe.rs / tcp_subscribe.rs: the unsubscribe assertions only treated a
  timeout as success, but the server tears down the stream on unsubscribe so
  the channel returns Ok(None). Accept Ok(None) too; only a real delivered
  message (Ok(Some)) is a leak
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants