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