Skip to main content

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(&registry, &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(&registry, &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}