platform_core/platform.rs
1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Rust port of the Java `Platform` registry + the **manager-worker dispatch**
18//! (`org.platformlambda.core.system.Platform` / `ServiceQueue` / `WorkerHandler`).
19//!
20//! Each route gets a **manager task** (the `ServiceQueue` analog) running the
21//! FIFO reactive back-pressure state machine, and `instances` **worker tasks**
22//! that *pull* work via ready signals:
23//!
24//! - a worker announces `Ready` (Java's `ready:<route>#<n>` bus signal), waits
25//! for one event, processes it, and announces `Ready` again — at most one
26//! in-flight event per worker;
27//! - the manager keeps a FIFO of ready workers. With no free worker it enters
28//! **buffering** mode and spills events into the per-route [`ElasticQueue`]
29//! (first [`MEMORY_BUFFER`](crate::util::elastic_queue::MEMORY_BUFFER) events
30//! in memory, overflow to segment files), draining one event per ready signal
31//! until the queue is empty — then the elastic queue closes and direct
32//! dispatch resumes;
33//! - the manager's inbound **mailbox is bounded**
34//! (`elastic.queue.dispatch.mailbox.size`, default 1024, min 20): when it
35//! fills, senders await — back-pressure, not drops (the Java vthread-dispatch
36//! mailbox behavior).
37//!
38//! Events are serialized (MsgPack) only when they cross into the elastic
39//! queue — a deliberate divergence from Java, where every bus message is
40//! already `byte[]`; in-process Rust moves the envelope for free, and the
41//! on-disk record format stays byte-identical to the Java store.
42//!
43//! `register` must be called within a Tokio runtime (managers/workers are
44//! spawned tasks) — the analog of the Java platform's Vert.x runtime
45//! requirement. The manager runs the spill I/O inline on its own task, exactly
46//! as Java runs it on the per-route dispatch virtual thread.
47
48use std::collections::{HashMap, HashSet, VecDeque};
49use std::sync::{Arc, Mutex, OnceLock, RwLock};
50use std::time::Instant;
51
52use tokio::sync::{mpsc, Notify};
53
54use crate::envelope::EventEnvelope;
55use crate::function::{AppError, ComposableFunction};
56use crate::trace::{self, TraceState};
57use crate::util::app_config_reader::AppConfigReader;
58use crate::util::elastic_queue::{ElasticQueue, MEMORY_BUFFER};
59
60const DISPATCH_MAILBOX_SIZE: &str = "elastic.queue.dispatch.mailbox.size";
61const DEFAULT_DISPATCH_MAILBOX_SIZE: usize = 1024;
62
63/// Everything entering a route's manager mailbox: an event to dispatch, or a
64/// worker's ready signal (Java sends both through the same route consumer).
65/// The envelope is boxed to keep the two variants close in size (clippy
66/// `large_enum_variant`).
67enum MailboxMessage {
68 Event(Box<EventEnvelope>),
69 Ready(usize),
70}
71
72struct RouteEntry {
73 /// Java ServiceDef.isPrivateFunction (see FunctionOptions::private).
74 private: bool,
75 mailbox: mpsc::Sender<MailboxMessage>,
76 stop: Arc<Notify>,
77 instances: usize,
78 function: Arc<dyn ComposableFunction>,
79}
80
81/// Reserved function route names for Event Script (Java `EventEmitter`
82/// parity): events to these routes are executed DIRECTLY on a fresh task,
83/// bypassing the manager-worker queue, so the engine behaves as part of the
84/// event core — orchestration never waits on its own bounded mailbox
85/// (liveness) and worker-instance count is irrelevant (unbounded concurrency,
86/// like Java's virtual-thread submit). Both functions are stateless event
87/// routers, so functional isolation is guaranteed.
88///
89/// Deliberately NOT exposed through registration options or the annotation
90/// macros (maintainer decision, 2026-07-16): application functions must stay
91/// on the reactive back-pressure path — this bypass is an engine privilege,
92/// not an API.
93const RESERVED_ENGINE_ROUTES: &[&str] = &[
94 "event.script.manager",
95 "task.executor",
96 // the RPC reply listener: replies must complete on the SENDER's runtime —
97 // the registered workers are the route's addressable identity, but a
98 // multi-runtime process (test binaries) cannot rely on their liveness
99 crate::inbox::TEMPORARY_INBOX,
100];
101
102type RouteRegistry = Arc<RwLock<HashMap<String, RouteEntry>>>;
103
104/// Route pools (prefix -> lane count): lifecycle metadata only, never
105/// consulted for routing (Java `Platform.poolRegistry`).
106type PoolRegistry = Arc<RwLock<HashMap<String, usize>>>;
107
108/// The service registry: route name → manager + worker pool. Cheap to clone.
109#[derive(Clone, Default)]
110pub struct Platform {
111 routes: RouteRegistry,
112 pools: PoolRegistry,
113 // serializes pool mutations (Java POOL_LOCK, a ReentrantLock there)
114 pool_mutations: Arc<Mutex<()>>,
115}
116
117/// Registration options (Java annotation analogs): `zero_traced` is
118/// `@ZeroTracing` (this route's executions are excluded from trace recording);
119/// Java `ServiceDef.MAX_INSTANCES`: the worker-count ceiling — registration
120/// clamps to `1..=1000` exactly like `setConcurrency`.
121const MAX_INSTANCES: usize = 1000;
122
123/// `interceptor` is `@EventInterceptor` (the function receives the raw
124/// envelope — `reply_to`/`cid` intact — and replies manually; the worker sends
125/// no auto-reply on success, though a failure still routes to `reply_to`).
126#[derive(Clone, Copy, Debug, Default)]
127pub struct FunctionOptions {
128 pub zero_traced: bool,
129 /// Java `isPrivate`: a private function is reachable only inside this
130 /// application instance — Event over HTTP rejects it with 403. `register`
131 /// creates PUBLIC functions (Java parity); use `register_private` or the
132 /// `#[preload]` macro (private by default, like Java `@PreLoad`).
133 pub private: bool,
134 pub interceptor: bool,
135}
136
137impl Platform {
138 pub fn new() -> Self {
139 let platform = Self::default();
140 // the RPC reply listener is an ESSENTIAL service present from birth
141 // on every platform — Java's EssentialServiceLoader registers it at
142 // the highest startup priority; isolated registries (tests) get it
143 // here so an RPC works before any lifecycle runs. Private,
144 // zero-tracing, 500 instances (Java parity).
145 let _ = platform.register_with_options(
146 crate::inbox::TEMPORARY_INBOX,
147 Arc::new(crate::inbox::TemporaryInbox),
148 500,
149 FunctionOptions {
150 zero_traced: true,
151 interceptor: false,
152 private: true,
153 },
154 );
155 platform
156 }
157
158 /// The process-wide platform (Java `Platform.getInstance()`), created on
159 /// first use. `Platform::new()` remains available for isolated registries
160 /// (tests); the lifecycle (`AppStarter`) uses this shared one.
161 pub fn get_instance() -> Platform {
162 static GLOBAL: OnceLock<Platform> = OnceLock::new();
163 GLOBAL.get_or_init(Platform::new).clone()
164 }
165
166 /// The application name (Java `platform.getName()`): `application.name`,
167 /// else `application` — Java's primary key and default. The Java
168 /// `spring.application.name` fallback is retired with the other Spring
169 /// names (maintainer decision, 2026-07-19; Spring is not ported).
170 pub fn name() -> String {
171 let config = AppConfigReader::get_instance();
172 config
173 .get_property("application.name")
174 .unwrap_or_else(|| "application".to_string())
175 }
176
177 /// This process's unique origin id (Java `platform.getOrigin()`,
178 /// simplified: a uuid per process; Java's optional appId derivation is not
179 /// ported until something needs it).
180 pub fn origin() -> &'static str {
181 static ORIGIN: OnceLock<String> = OnceLock::new();
182 ORIGIN.get_or_init(|| uuid::Uuid::new_v4().simple().to_string())
183 }
184
185 /// Register a function at a route with `instances` concurrent workers
186 /// (Java `platform.register(route, lambda, instances)`).
187 pub fn register(
188 &self,
189 route: &str,
190 function: Arc<dyn ComposableFunction>,
191 instances: usize,
192 ) -> Result<(), AppError> {
193 self.register_with_options(route, function, instances, FunctionOptions::default())
194 }
195
196 /// Register a PRIVATE function (Java `platform.registerPrivate`): callable
197 /// only inside this application instance — Event over HTTP rejects it
198 /// with 403. Engine internals register this way (increment 60).
199 pub fn register_private(
200 &self,
201 route: &str,
202 function: Arc<dyn ComposableFunction>,
203 instances: usize,
204 ) -> Result<(), AppError> {
205 self.register_with_options(
206 route,
207 function,
208 instances,
209 FunctionOptions {
210 private: true,
211 ..FunctionOptions::default()
212 },
213 )
214 }
215
216 /// Whether a registered route is private (Java `ServiceDef.isPrivateFunction`);
217 /// `None` when the route is not registered.
218 pub fn is_private(&self, route: &str) -> Option<bool> {
219 self.routes
220 .read()
221 .expect("route registry poisoned")
222 .get(route)
223 .map(|entry| entry.private)
224 }
225
226 /// Register with explicit options (increment E-3 extends the increment-10
227 /// zero-trace flag with the event-interceptor mode).
228 pub fn register_with_options(
229 &self,
230 route: &str,
231 function: Arc<dyn ComposableFunction>,
232 instances: usize,
233 options: FunctionOptions,
234 ) -> Result<(), AppError> {
235 validate_route(route)?;
236 // Java ServiceDef.setConcurrency: Math.max(1, Math.min(n, 1000)) —
237 // zero is accepted (→ 1) and the worker count is capped, silently
238 // (F10 parity fix, 2026-07-21; previously 0 was rejected and there
239 // was no ceiling)
240 let instances = instances.clamp(1, MAX_INSTANCES);
241 let (mailbox_tx, mailbox_rx) = mpsc::channel::<MailboxMessage>(dispatch_mailbox_size());
242 let stop = Arc::new(Notify::new());
243 {
244 let mut routes = self.routes.write().expect("route registry poisoned");
245 // Java Platform.register: an existing route is RELOADED — the old
246 // service is released and the new one takes its place (F10 parity
247 // fix; previously rejected with "already exists")
248 if let Some(previous) = routes.remove(route) {
249 log::warn!("Reloading LambdaFunction {route}");
250 previous.stop.notify_one();
251 }
252 routes.insert(
253 route.to_string(),
254 RouteEntry {
255 private: options.private,
256 mailbox: mailbox_tx.clone(),
257 stop: stop.clone(),
258 instances,
259 function: function.clone(),
260 },
261 );
262 }
263 // workers: capacity-1 event channels; each announces Ready through the
264 // shared mailbox, waits for one event, processes, repeats (1-based ids)
265 let mut worker_txs = Vec::with_capacity(instances);
266 for instance in 1..=instances {
267 let (worker_tx, worker_rx) = mpsc::channel::<EventEnvelope>(1);
268 worker_txs.push(worker_tx);
269 tokio::spawn(worker_loop(
270 route.to_string(),
271 instance,
272 function.clone(),
273 worker_rx,
274 mailbox_tx.clone(),
275 self.routes.clone(),
276 options,
277 ));
278 }
279 // the manager (ServiceQueue analog) owns the state machine + elastic queue
280 tokio::spawn(manager_loop(
281 route.to_string(),
282 mailbox_rx,
283 stop,
284 worker_txs,
285 ));
286 Ok(())
287 }
288
289 /// True when the route is registered locally (Java `hasRoute`).
290 pub fn has_route(&self, route: &str) -> bool {
291 self.routes
292 .read()
293 .expect("route registry poisoned")
294 .contains_key(route)
295 }
296
297 /// Release a route (Java `release`): signals the manager to stop; workers
298 /// exit as their channels close; the elastic queue is destroyed. Returns
299 /// whether the route existed.
300 pub fn release(&self, route: &str) -> bool {
301 let removed = self
302 .routes
303 .write()
304 .expect("route registry poisoned")
305 .remove(route);
306 match removed {
307 Some(entry) => {
308 entry.stop.notify_one();
309 true
310 }
311 None => false,
312 }
313 }
314
315 /// Register a route pool — a set of private singleton routes
316 /// `{prefix}.{n}` for n = 0 to count-1, at least 2 lanes (Java
317 /// `Platform.registerRoutePool`).
318 /// Each member runs with one worker, so it is a strict FIFO lane; a caller
319 /// may check out a lane for exclusive use to preserve event order while
320 /// other lanes serve concurrent traffic. One function instance is shared
321 /// across all members — it must be stateless, the same contract as a
322 /// multi-instance function.
323 ///
324 /// Registering an existing pool RELOADS it: the previous member set is
325 /// released first, with a warning in the application log. This API covers
326 /// registration only — lane checkout and return are the caller's concern.
327 /// Route pools are always private.
328 pub fn register_route_pool(
329 &self,
330 prefix: &str,
331 function: Arc<dyn ComposableFunction>,
332 count: usize,
333 ) -> Result<Vec<String>, AppError> {
334 if count < 2 {
335 return Err(AppError::new(400, "Route pool count must be at least 2"));
336 }
337 validate_route(&format!("{prefix}.0"))?;
338 let _mutation = self
339 .pool_mutations
340 .lock()
341 .expect("pool mutation lock poisoned");
342 let previous = self
343 .pools
344 .write()
345 .expect("pool registry poisoned")
346 .remove(prefix);
347 if let Some(previous) = previous {
348 log::warn!("Reloading route pool {prefix} ({previous} -> {count} lanes)");
349 self.release_pool_members(prefix, previous);
350 }
351 let mut members = Vec::with_capacity(count);
352 for n in 0..count {
353 let member = format!("{prefix}.{n}");
354 self.register_private(&member, function.clone(), 1)?;
355 members.push(member);
356 }
357 self.pools
358 .write()
359 .expect("pool registry poisoned")
360 .insert(prefix.to_string(), count);
361 log::info!("Route pool {prefix} with {count} instances started as async tasks");
362 Ok(members)
363 }
364
365 /// Release a route pool — removes all its members and the pool itself
366 /// (Java `Platform.releaseRoutePool`). Returns whether the pool existed.
367 pub fn release_route_pool(&self, prefix: &str) -> bool {
368 let _mutation = self
369 .pool_mutations
370 .lock()
371 .expect("pool mutation lock poisoned");
372 let count = self
373 .pools
374 .write()
375 .expect("pool registry poisoned")
376 .remove(prefix);
377 match count {
378 Some(count) => {
379 self.release_pool_members(prefix, count);
380 log::info!("Route pool {prefix} stopped");
381 true
382 }
383 None => false,
384 }
385 }
386
387 fn release_pool_members(&self, prefix: &str, count: usize) {
388 for n in 0..count {
389 self.release(&format!("{prefix}.{n}"));
390 }
391 }
392
393 /// Registered route names (sorted, for stable output).
394 pub fn routes(&self) -> Vec<String> {
395 let mut names: Vec<String> = self
396 .routes
397 .read()
398 .expect("route registry poisoned")
399 .keys()
400 .cloned()
401 .collect();
402 names.sort();
403 names
404 }
405
406 /// Worker count for a route, if registered.
407 pub fn instances(&self, route: &str) -> Option<usize> {
408 self.routes
409 .read()
410 .expect("route registry poisoned")
411 .get(route)
412 .map(|entry| entry.instances)
413 }
414
415 /// Crate-internal: deliver an event into a route's manager mailbox. Awaits
416 /// when the bounded mailbox is full — back-pressure, not drops.
417 ///
418 /// An `@origin`/`@instance` suffix on the route is tolerated and ignored
419 /// (Eric's ruling: the `route@origin` syntax is only meaningful under the
420 /// legacy Kafka service mesh — this port parses it away on inbound data,
421 /// e.g. envelopes from a Java peer, and never generates it).
422 pub(crate) async fn deliver(&self, route: &str, event: EventEnvelope) -> Result<(), AppError> {
423 let route = bare_route(route);
424 let mut event = event;
425 normalize_null_transport(&mut event);
426 // reserved engine routes run directly (see RESERVED_ENGINE_ROUTES)
427 if let Some(function) = reserved_route_function(&self.routes, route) {
428 spawn_direct(function, event);
429 return Ok(());
430 }
431 // clone the sender out of the lock before awaiting
432 let sender = self
433 .routes
434 .read()
435 .expect("route registry poisoned")
436 .get(route)
437 .map(|entry| entry.mailbox.clone());
438 let Some(sender) = sender else {
439 return Err(AppError::new(404, format!("Route {route} not found")));
440 };
441 sender
442 .send(MailboxMessage::Event(Box::new(event)))
443 .await
444 .map_err(|_| AppError::new(500, format!("Route {route} is closed")))
445 }
446}
447
448/// Deterministic null transport on EVERY hop (increment 58, the F2 decision —
449/// maintainer chose normalization over documentation): Java serializes every
450/// event-bus hop, so `Nil` map entries are stripped consistently when
451/// `serializer.null.transport=false`. The Rust fast path deliberately skips
452/// serialization (the documented performance divergence stays), so the strip
453/// is applied explicitly here — predicate-guarded: a body without Nil map
454/// entries costs one allocation-free read-only walk.
455fn normalize_null_transport(event: &mut EventEnvelope) {
456 if !crate::serializer::null_transport() && crate::serializer::has_nil_map_entry(event.body()) {
457 let stripped = crate::serializer::strip_nulls_always(event.body());
458 event.set_body_internal(stripped);
459 }
460}
461
462/// Resolve a reserved engine route to its registered function (None for
463/// normal routes — they take the manager-worker path).
464fn reserved_route_function(
465 registry: &RouteRegistry,
466 route: &str,
467) -> Option<Arc<dyn ComposableFunction>> {
468 if RESERVED_ENGINE_ROUTES.contains(&route) {
469 registry
470 .read()
471 .expect("route registry poisoned")
472 .get(route)
473 .map(|entry| entry.function.clone())
474 } else {
475 None
476 }
477}
478
479/// Direct execution (Java `EventEmitter.runTaskExecutor`): invoke the engine
480/// function on a fresh task with instance 1 — no queue, no trace bracket, no
481/// auto-reply; failures are logged (the engine functions manage their own
482/// replies and error routing).
483fn spawn_direct(function: Arc<dyn ComposableFunction>, event: EventEnvelope) {
484 tokio::spawn(async move {
485 let headers = event.headers().clone();
486 if let Err(e) = function.handle_event(headers, event, 1).await {
487 log::error!(
488 "Unable to execute event script - ({}) {}",
489 e.status(),
490 e.message()
491 );
492 }
493 });
494}
495
496/// Mailbox capacity (Java `ServiceQueue.dispatchMailboxSize`):
497/// `elastic.queue.dispatch.mailbox.size`, default 1024, floor `MEMORY_BUFFER`.
498fn dispatch_mailbox_size() -> usize {
499 let configured = AppConfigReader::get_instance()
500 .get_property_or(
501 DISPATCH_MAILBOX_SIZE,
502 &DEFAULT_DISPATCH_MAILBOX_SIZE.to_string(),
503 )
504 .parse::<usize>()
505 .unwrap_or(DEFAULT_DISPATCH_MAILBOX_SIZE);
506 let size = if configured > 0 {
507 configured
508 } else {
509 DEFAULT_DISPATCH_MAILBOX_SIZE
510 };
511 size.max(MEMORY_BUFFER as usize)
512}
513
514/// Validate a route name — port of Java `Utility.validServiceName` plus the
515/// at-least-one-dot rule: lowercase alphanumeric with `.` `-` `_`, no leading/
516/// trailing/consecutive dots. Crate-visible so the declarative
517/// Event-over-HTTP config loader applies the same rule (Java parity).
518pub(crate) fn validate_route(route: &str) -> Result<(), AppError> {
519 let valid_chars = !route.is_empty()
520 && route.bytes().all(|b| {
521 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_')
522 });
523 if !valid_chars {
524 return Err(AppError::new(
525 400,
526 format!("Invalid route '{route}' — use lowercase letters, digits, '.', '-', '_'"),
527 ));
528 }
529 if !route.contains('.')
530 || route.starts_with('.')
531 || route.ends_with('.')
532 || route.contains("..")
533 {
534 return Err(AppError::new(
535 400,
536 format!("Invalid route '{route}' — a route needs at least one '.' separator (e.g. 'v1.my.function')"),
537 ));
538 }
539 Ok(())
540}
541
542/// The route manager (Java `ServiceQueue.ServiceHandler` state machine):
543/// dispatch to a ready worker when one is free, otherwise buffer through the
544/// elastic queue; drain one buffered event per ready signal; close the elastic
545/// queue when it empties. `buffering` starts **true** (no worker has announced
546/// readiness yet — Java parity).
547async fn manager_loop(
548 route: String,
549 mut mailbox: mpsc::Receiver<MailboxMessage>,
550 stop: Arc<Notify>,
551 worker_txs: Vec<mpsc::Sender<EventEnvelope>>,
552 // workers are 1-based; worker_txs[n - 1] is worker #n
553) {
554 let mut elastic = ElasticQueue::new(&route);
555 let mut ready_fifo: VecDeque<usize> = VecDeque::new();
556 let mut ready_set: HashSet<usize> = HashSet::new();
557 let mut buffering = true;
558 loop {
559 let message = tokio::select! {
560 _ = stop.notified() => break,
561 received = mailbox.recv() => match received {
562 Some(message) => message,
563 None => break,
564 },
565 };
566 match message {
567 MailboxMessage::Ready(worker) => {
568 // guarantee a unique entry per worker (Java idx + fifo)
569 if ready_set.insert(worker) {
570 ready_fifo.push_back(worker);
571 }
572 if buffering {
573 match elastic.read() {
574 Ok(bytes) if bytes.is_empty() => {
575 // close elastic queue when all messages are cleared
576 buffering = false;
577 elastic.close();
578 }
579 Ok(bytes) => match EventEnvelope::from_bytes(&bytes) {
580 Ok(event) => {
581 // guaranteed: this ready signal just enqueued a worker
582 if let Some(next) = ready_fifo.pop_front() {
583 ready_set.remove(&next);
584 let _ = worker_txs[next - 1].send(event).await;
585 }
586 }
587 Err(e) => log::error!("{route} corrupted buffered event dropped - {e}"),
588 },
589 Err(e) => log::error!("{route} dispatch error - {e}"),
590 }
591 }
592 }
593 MailboxMessage::Event(event) => {
594 let event = *event;
595 if buffering {
596 // once the elastic queue is started, continue buffering
597 spill(&route, &mut elastic, &event);
598 } else if let Some(next) = ready_fifo.pop_front() {
599 // deliver the event to the next free worker
600 ready_set.remove(&next);
601 let _ = worker_txs[next - 1].send(event).await;
602 } else {
603 // no worker available — start buffering
604 buffering = true;
605 spill(&route, &mut elastic, &event);
606 }
607 }
608 }
609 }
610 // route released: workers exit via channel close; elastic queue cleaned up
611 drop(worker_txs);
612 elastic.destroy();
613}
614
615/// Serialize an event into the elastic queue (a spill I/O failure loses that
616/// event and is logged — the Java drainLoop catch-and-continue behavior).
617fn spill(route: &str, elastic: &mut ElasticQueue, event: &EventEnvelope) {
618 match event.to_bytes() {
619 Ok(bytes) => {
620 if let Err(e) = elastic.write(&bytes) {
621 log::error!("{route} dispatch error - {e}");
622 }
623 }
624 Err(e) => log::error!("{route} dispatch error - {e}"),
625 }
626}
627
628/// One worker (Java `WorkerHandler`): announce Ready → take one event →
629/// invoke the function → deliver the reply (if `reply_to`) with the request's
630/// correlation id → repeat. Exits when the route is released.
631async fn worker_loop(
632 route: String,
633 instance: usize,
634 function: Arc<dyn ComposableFunction>,
635 mut events: mpsc::Receiver<EventEnvelope>,
636 manager: mpsc::Sender<MailboxMessage>,
637 registry: RouteRegistry,
638 options: FunctionOptions,
639) {
640 // a route that is itself telemetry plumbing or the RPC reply listener
641 // (or listed in skip.rpc.tracing, or registered with @ZeroTracing
642 // semantics) never traces its own executions
643 let zero_traced = options.zero_traced || is_zero_traced(&route);
644 loop {
645 if manager.send(MailboxMessage::Ready(instance)).await.is_err() {
646 break; // manager gone — route released
647 }
648 let Some(event) = events.recv().await else {
649 break;
650 };
651 let started = Instant::now();
652 let mut event = event;
653 // the RPC marker (Java `event.getTag(RPC)`): RPC requests carry the
654 // reserved rpc tag; the reply address is just routing
655 let served_rpc = event.tag(crate::post_office::RPC_TAG).is_some();
656 // the reply listener receives its envelope PRISTINE: the envelope IS
657 // the payload the waiting caller gets back (annotations for the
658 // round_trip record, the request's correlation id for resolution) —
659 // Java executeFunction exempts TEMPORARY_INBOX from metadata handling
660 let is_reply_listener = route == crate::inbox::TEMPORARY_INBOX;
661 let (business_cid, headers) = if is_reply_listener {
662 (None, event.headers().clone())
663 } else {
664 // annotations belong to REPLY envelopes only — never leak a prior
665 // hop's annotations into a function's input (Java WorkerHandler)
666 event.clear_annotations_internal();
667 // Metadata contract (Java WorkerHandler parity): a user function
668 // receives a COPY of the envelope headers with read-only metadata
669 // INJECTED at delivery time; metadata is never transported in the
670 // event itself. The business correlation-id arrives on the
671 // engine-managed tag (a legacy pre-4.10.2 peer transported it as
672 // an envelope header — honor that value into the injected view),
673 // the engine-internal relay guard never reaches a user function's
674 // copy, and tags are engine-visible only.
675 let tag_cid = event
676 .tag(crate::post_office::BUSINESS_CID_TAG)
677 .map(str::to_string);
678 event.clear_tags_internal();
679 // the function's input header copy: engine-internal keys removed,
680 // the my_* read-only keys injected
681 let mut headers = event.headers().clone();
682 headers.remove(crate::automation::X_EVENT_API);
683 let legacy_cid = headers.remove(crate::automation::MY_CORRELATION_ID);
684 // the port's cid-slot convention is the last fallback: a direct
685 // bus caller may put the business id in the envelope cid
686 let business_cid = tag_cid
687 .or(legacy_cid)
688 .or_else(|| event.correlation_id().map(str::to_string));
689 headers.insert(MY_ROUTE.to_string(), route.clone());
690 if let Some(trace_id) = event.trace_id() {
691 headers.insert(MY_TRACE_ID.to_string(), trace_id.to_string());
692 }
693 if let Some(trace_path) = event.trace_path() {
694 headers.insert(MY_TRACE_PATH.to_string(), trace_path.to_string());
695 }
696 if let Some(cid) = &business_cid {
697 headers.insert(
698 crate::automation::MY_CORRELATION_ID.to_string(),
699 cid.clone(),
700 );
701 }
702 // The delivered envelope view is scrubbed of the same engine keys:
703 // a peer that transported my_* headers (e.g. a function that
704 // copied its injected input view onto an outgoing event) or an
705 // edge that merged them must never surface engine metadata as
706 // application data. Safe to mutate - each delivery owns its own
707 // envelope copy. Event interceptors are exempt: they relay raw
708 // envelopes and need transport fidelity (e.g. the x-event-api
709 // relay guard).
710 if !options.interceptor {
711 for key in ENGINE_METADATA_KEYS {
712 event.remove_header_internal(key);
713 }
714 }
715 (business_cid, headers)
716 };
717 let reply_to = event.reply_to().map(str::to_string);
718 let cid = event.correlation_id().map(str::to_string);
719 let event_from = event.from().map(str::to_string);
720 // trace bracket (Java WorkerHandler): a traced request carries trace id
721 // + path; this execution gets its own span, parented to the sender's
722 // span carried on the envelope. A ZERO-TRACED route still keeps the
723 // bracket when the incoming event is traced — Java gates only
724 // startTracing + sendTracingInfo on the flag, while the reply and any
725 // nested calls carry the trace onward unconditionally (F3 parity fix,
726 // 2026-07-21) — but the hop emits no telemetry and contributes no span.
727 // Deliberate minor divergence: the hop's own JSON log lines still
728 // resolve trace tokens (Java registers no log context here); log-only,
729 // nothing changes on the wire.
730 let trace_state = match (event.trace_id(), event.trace_path()) {
731 (Some(trace_id), Some(trace_path)) => {
732 // the business correlation-id resolved above (tag > legacy
733 // header > cid-slot convention) — the cid slot itself may
734 // carry an internal correlation id (the HTTP context id of a
735 // REST callback dispatch, or a flow task's composite id)
736 let mut state = TraceState::new(
737 &route,
738 trace_id,
739 trace_path,
740 event.span_id(),
741 business_cid.as_deref(),
742 );
743 state.zero_traced = zero_traced;
744 Some(state)
745 }
746 _ => None,
747 };
748 let (result, finished_state) =
749 trace::run_scoped(trace_state, function.handle_event(headers, event, instance)).await;
750 // execution-time metric, standardized to 3 decimal points at the source
751 // (Java `WorkerHandler.getExecTime` parity: clamp ≥ 0, round to 3 dp).
752 // Rounding here means every consumer — the reply envelope, the telemetry
753 // dataset, and the Playground traveler's "Executed … in T ms" narration —
754 // reports the same value rather than a raw full-precision float.
755 let elapsed_ms = started.elapsed().as_secs_f32() * 1000.0;
756 let elapsed_ms = (elapsed_ms.max(0.0) * 1000.0).round() / 1000.0;
757 // propagate the trace to the response so the next hop chains correctly;
758 // a zero-traced hop contributes no span of its own (Java parity)
759 let trace_triple = finished_state.as_ref().map(|s| {
760 (
761 s.trace_id.clone(),
762 s.trace_path.clone(),
763 (!s.zero_traced).then(|| s.span_id.clone()),
764 )
765 });
766 // trace annotations ride the REPLY (Java applyTraceContext): the RPC
767 // caller folds them into the round_trip record; a zero-traced hop
768 // attaches none (Java has no live TraceInfo there)
769 let reply_annotations: HashMap<String, rmpv::Value> = finished_state
770 .as_ref()
771 .filter(|s| !s.zero_traced)
772 .map(|s| {
773 s.annotations
774 .iter()
775 .filter_map(|(k, v)| {
776 rmpv::ext::to_value(v).ok().map(|value| (k.clone(), value))
777 })
778 .collect()
779 })
780 .unwrap_or_default();
781 // pre-compute the outcome for the telemetry record — the result is
782 // consumed by the reply delivery below (Java reads it from
783 // ProcessStatus after processEvent has already sent the response)
784 let outcome = match &result {
785 Ok(response) => (response.status(), !response.has_error(), None),
786 Err(e) => (e.status(), false, Some(e.message().to_string())),
787 };
788 // whether an RPC reply failed to reach its waiting caller (the Java
789 // ProcessStatus.isNotDelivered signal); an interceptor's success path
790 // attempts no delivery and is NOT a delivery failure (Java parity)
791 let mut not_delivered = false;
792 match (reply_to, result) {
793 // an event interceptor replies MANUALLY (Java @EventInterceptor):
794 // its successful return value is ignored and no auto-reply is sent
795 // — but a FAILURE still routes to reply_to (Java WorkerHandler:
796 // only the success reply is interceptor-guarded)
797 (Some(_), Ok(_)) if options.interceptor => {}
798 (Some(reply_route), result) => {
799 let mut response = match result {
800 Ok(envelope) => envelope,
801 Err(e) => EventEnvelope::new()
802 .set_status(e.status())
803 .set_raw_body(rmpv::Value::String(e.message().into())),
804 };
805 response.set_cid_internal(cid);
806 response.set_from_internal(&route);
807 response.set_to_internal(&reply_route);
808 response.set_exec_time_internal(elapsed_ms);
809 response.set_annotations_internal(reply_annotations.clone());
810 // exit-side sanitization, symmetric with the entry-side
811 // injection (Java copyResponseHeaders): the read-only my_*
812 // keys and engine-internal keys never leave a function as
813 // response headers, even if it accidentally copies its input
814 // headers onto the returned envelope
815 sanitize_response_headers(&mut response);
816 // the reply is a bus hop too — same deterministic null strip
817 normalize_null_transport(&mut response);
818 if let Some((trace_id, trace_path, span_id)) = trace_triple {
819 response.set_trace_internal(&trace_id, &trace_path);
820 match span_id {
821 Some(span_id) => response.set_span_id_internal(&span_id),
822 // a zero-traced hop owns no span — and must not leak
823 // a nested reply's span as its own (Java rebuilds the
824 // response envelope, so its reply never carries one)
825 None => response.clear_span_id_internal(),
826 }
827 }
828 // RPC replies route to the reserved temporary.inbox service
829 // like any other destination (Java parity) — no special path.
830 // A legacy '@origin' suffix is parsed away (never generated).
831 let reply_route = bare_route(&reply_route).to_string();
832 if let Some(function) = reserved_route_function(®istry, &reply_route) {
833 // replies to the reserved engine routes (task callbacks)
834 // take the same direct path as sends
835 spawn_direct(function, response);
836 } else {
837 // clone the reply mailbox out of the lock before awaiting
838 let sender = registry
839 .read()
840 .expect("route registry poisoned")
841 .get(&reply_route)
842 .map(|entry| entry.mailbox.clone());
843 if let Some(sender) = sender {
844 // route may already be released — drop silently
845 let _ = sender.send(MailboxMessage::Event(Box::new(response))).await;
846 } else {
847 // the reply had nowhere to go (Java ProcessStatus
848 // notDelivered) — the worker keeps its own record
849 not_delivered = true;
850 }
851 }
852 }
853 (None, Err(e)) => {
854 // fire-and-forget failure has nowhere to go — log it (Java parity)
855 log::warn!(
856 "Unhandled exception in {route}#{instance}: ({}) {}",
857 e.status(),
858 e.message()
859 );
860 }
861 (None, Ok(_)) => {} // fire-and-forget success: result discarded
862 }
863 // send the performance-metrics dataset to the telemetry sink — never
864 // for a zero-traced route, and never for an RPC-served execution whose
865 // reply reached the caller: the caller's round_trip record is THE
866 // record for this span (Java WorkerHandler.sendTracingInfo gate:
867 // `journaled || rpc == null || notDelivered`; journaling not ported)
868 if let Some(state) = finished_state.filter(|s| !s.zero_traced) {
869 if !served_rpc || not_delivered {
870 emit_telemetry(®istry, &route, event_from, &state, outcome, elapsed_ms).await;
871 }
872 }
873 }
874}
875
876/// Strip a legacy `@origin`/`@instance` suffix from a route reference —
877/// meaningful only under the Java Kafka service mesh, which is out of scope
878/// here: inbound values are parse-tolerant, outbound values never carry one.
879pub(crate) fn bare_route(route: &str) -> &str {
880 match route.find('@') {
881 Some(at) => &route[..at],
882 None => route,
883 }
884}
885
886/// Read-only metadata keys injected into a function's input header copy at
887/// delivery (Java `WorkerHandler` MY_ROUTE / MY_TRACE_ID / MY_TRACE_PATH).
888pub(crate) const MY_ROUTE: &str = "my_route";
889pub(crate) const MY_TRACE_ID: &str = "my_trace_id";
890pub(crate) const MY_TRACE_PATH: &str = "my_trace_path";
891
892/// The engine's reserved metadata keys: injected into a function's input
893/// header copy at delivery, scrubbed from the delivered envelope view at
894/// entry (non-interceptors), and filtered from a returned reply at exit —
895/// metadata is injected, never transported.
896pub(crate) const ENGINE_METADATA_KEYS: [&str; 5] = [
897 MY_ROUTE,
898 MY_TRACE_ID,
899 MY_TRACE_PATH,
900 crate::automation::MY_CORRELATION_ID,
901 crate::automation::X_EVENT_API,
902];
903
904/// Exit-side sanitization (Java `WorkerHandler.copyResponseHeaders`): the
905/// injected read-only metadata and engine-internal keys are filtered from a
906/// function's returned envelope before it becomes a reply.
907fn sanitize_response_headers(response: &mut EventEnvelope) {
908 for key in ENGINE_METADATA_KEYS {
909 response.remove_header_internal(key);
910 }
911}
912
913/// Whether a route's executions are excluded from trace recording: the
914/// telemetry plumbing and the RPC reply listener (Java `@ZeroTracing` +
915/// filter — exact names only, no prefixes), and any route listed in
916/// `skip.rpc.tracing` (default `async.http.request`).
917fn is_zero_traced(route: &str) -> bool {
918 if crate::telemetry::ZERO_TRACING_FILTER.contains(&route) {
919 return true;
920 }
921 in_skip_rpc_tracing_list(route)
922}
923
924/// Whether a route is listed in `skip.rpc.tracing` (default
925/// `async.http.request`) — shared by the worker's zero-trace resolution above
926/// and the caller-side RPC `round_trip` record (Java `InboxBase.getSkipTracing`
927/// reads the same key).
928pub(crate) fn in_skip_rpc_tracing_list(route: &str) -> bool {
929 AppConfigReader::get_instance()
930 .get_property_or("skip.rpc.tracing", "async.http.request")
931 .split([',', ' '])
932 .map(str::trim)
933 .any(|skipped| skipped == route)
934}
935
936/// Build the performance-metrics dataset for one traced execution and send it
937/// to the `distributed.tracing` sink (Java `WorkerHandler.sendTracingInfo` +
938/// `getMetrics`). Fire-and-forget; silently skipped when the sink is not
939/// registered on this platform.
940async fn emit_telemetry(
941 registry: &RouteRegistry,
942 route: &str,
943 from: Option<String>,
944 state: &TraceState,
945 outcome: (i32, bool, Option<String>),
946 elapsed_ms: f32,
947) {
948 let sender = registry
949 .read()
950 .expect("route registry poisoned")
951 .get(crate::telemetry::DISTRIBUTED_TRACING)
952 .map(|entry| entry.mailbox.clone());
953 let Some(sender) = sender else {
954 return; // no telemetry sink on this platform
955 };
956 let (status, success, exception) = outcome;
957 let mut metrics = serde_json::Map::new();
958 let mut put = |k: &str, v: serde_json::Value| {
959 metrics.insert(k.to_string(), v);
960 };
961 put("id", serde_json::Value::String(state.trace_id.clone()));
962 put("path", serde_json::Value::String(state.trace_path.clone()));
963 put("service", serde_json::Value::String(route.to_string()));
964 put("start", serde_json::Value::String(state.start_time.clone()));
965 put(
966 "origin",
967 serde_json::Value::String(Platform::origin().to_string()),
968 );
969 // round to 3 decimals in f64 so the JSON stays clean (f32 noise otherwise)
970 put(
971 "exec_time",
972 serde_json::Value::from(((elapsed_ms as f64) * 1000.0).round() / 1000.0),
973 );
974 put("status", serde_json::Value::from(status));
975 put("success", serde_json::Value::Bool(success));
976 if let Some(exception) = exception {
977 put("exception", serde_json::Value::String(exception));
978 }
979 if let Some(from) = from {
980 put("from", serde_json::Value::String(from));
981 }
982 put("span_id", serde_json::Value::String(state.span_id.clone()));
983 if let Some(parent) = &state.parent_span_id {
984 put("parent_span_id", serde_json::Value::String(parent.clone()));
985 }
986 let mut dataset = serde_json::Map::new();
987 dataset.insert("trace".to_string(), serde_json::Value::Object(metrics));
988 if !state.annotations.is_empty() {
989 dataset.insert(
990 "annotations".to_string(),
991 serde_json::Value::Object(state.annotations.clone().into_iter().collect()),
992 );
993 }
994 // the telemetry event itself carries NO trace fields (no recursion)
995 match EventEnvelope::new()
996 .set_to(crate::telemetry::DISTRIBUTED_TRACING)
997 .set_body(serde_json::Value::Object(dataset))
998 {
999 Ok(event) => {
1000 let _ = sender.send(MailboxMessage::Event(Box::new(event))).await;
1001 }
1002 Err(e) => log::error!("Unable to send to distributed.tracing - {e}"),
1003 }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1009
1010 #[test]
1011 fn route_validation_rules() {
1012 assert!(validate_route("v1.get.profile").is_ok());
1013 assert!(validate_route("hello.world-2_x").is_ok());
1014 assert!(validate_route("badroute").is_err()); // no dot
1015 assert!(validate_route("UPPER.case").is_err()); // uppercase
1016 assert!(validate_route(".leading.dot").is_err());
1017 assert!(validate_route("trailing.dot.").is_err());
1018 assert!(validate_route("double..dot").is_err());
1019 assert!(validate_route("").is_err());
1020 assert!(validate_route("with space.x").is_err());
1021 }
1022}