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 listed in skip.rpc.tracing, or registered with @ZeroTracing
711 // semantics) never traces its own executions
712 let zero_traced = options.zero_traced || is_zero_traced(&route);
713 loop {
714 if manager.send(MailboxMessage::Ready(instance)).await.is_err() {
715 break; // manager gone — route released
716 }
717 let Some(event) = events.recv().await else {
718 break;
719 };
720 let started = Instant::now();
721 let mut event = event;
722 // the RPC marker (Java `event.getTag(RPC)`): RPC requests carry the
723 // reserved rpc tag; the reply address is just routing
724 let served_rpc = event.tag(crate::post_office::RPC_TAG).is_some();
725 // the reply listener receives its envelope PRISTINE: the envelope IS
726 // the payload the waiting caller gets back (annotations for the
727 // round_trip record, the request's correlation id for resolution) —
728 // Java executeFunction exempts TEMPORARY_INBOX from metadata handling
729 let is_reply_listener = route == crate::inbox::TEMPORARY_INBOX;
730 let (business_cid, headers) = if is_reply_listener {
731 (None, event.headers().clone())
732 } else {
733 // annotations belong to REPLY envelopes only — never leak a prior
734 // hop's annotations into a function's input (Java WorkerHandler)
735 event.clear_annotations_internal();
736 // Metadata contract (Java WorkerHandler parity): a user function
737 // receives a COPY of the envelope headers with read-only metadata
738 // INJECTED at delivery time; metadata is never transported in the
739 // event itself. The business correlation-id arrives on the
740 // engine-managed tag (a legacy pre-4.10.2 peer transported it as
741 // an envelope header — honor that value into the injected view),
742 // the engine-internal relay guard never reaches a user function's
743 // copy, and tags are engine-visible only.
744 let tag_cid = event
745 .tag(crate::post_office::BUSINESS_CID_TAG)
746 .map(str::to_string);
747 event.clear_tags_internal();
748 // the function's input header copy: engine-internal keys removed,
749 // the my_* read-only keys injected
750 let mut headers = event.headers().clone();
751 headers.remove(crate::automation::X_EVENT_API);
752 let legacy_cid = headers.remove(crate::automation::MY_CORRELATION_ID);
753 // the port's cid-slot convention is the last fallback: a direct
754 // bus caller may put the business id in the envelope cid
755 let business_cid = tag_cid
756 .or(legacy_cid)
757 .or_else(|| event.correlation_id().map(str::to_string));
758 headers.insert(MY_ROUTE.to_string(), route.clone());
759 if let Some(trace_id) = event.trace_id() {
760 headers.insert(MY_TRACE_ID.to_string(), trace_id.to_string());
761 }
762 if let Some(trace_path) = event.trace_path() {
763 headers.insert(MY_TRACE_PATH.to_string(), trace_path.to_string());
764 }
765 if let Some(cid) = &business_cid {
766 headers.insert(
767 crate::automation::MY_CORRELATION_ID.to_string(),
768 cid.clone(),
769 );
770 }
771 // The delivered envelope view is scrubbed of the same engine keys:
772 // a peer that transported my_* headers (e.g. a function that
773 // copied its injected input view onto an outgoing event) or an
774 // edge that merged them must never surface engine metadata as
775 // application data. Safe to mutate - each delivery owns its own
776 // envelope copy. Event interceptors are exempt: they relay raw
777 // envelopes and need transport fidelity (e.g. the x-event-api
778 // relay guard).
779 if !options.interceptor {
780 for key in ENGINE_METADATA_KEYS {
781 event.remove_header_internal(key);
782 }
783 }
784 (business_cid, headers)
785 };
786 let reply_to = event.reply_to().map(str::to_string);
787 let cid = event.correlation_id().map(str::to_string);
788 let event_from = event.from().map(str::to_string);
789 // trace bracket (Java WorkerHandler): a traced request carries trace id
790 // + path; this execution gets its own span, parented to the sender's
791 // span carried on the envelope. A ZERO-TRACED route still keeps the
792 // bracket when the incoming event is traced — Java gates only
793 // startTracing + sendTracingInfo on the flag, while the reply and any
794 // nested calls carry the trace onward unconditionally (F3 parity fix,
795 // 2026-07-21) — but the hop emits no telemetry and contributes no span.
796 // Deliberate minor divergence: the hop's own JSON log lines still
797 // resolve trace tokens (Java registers no log context here); log-only,
798 // nothing changes on the wire.
799 let trace_state = match (event.trace_id(), event.trace_path()) {
800 (Some(trace_id), Some(trace_path)) => {
801 // the business correlation-id resolved above (tag > legacy
802 // header > cid-slot convention) — the cid slot itself may
803 // carry an internal correlation id (the HTTP context id of a
804 // REST callback dispatch, or a flow task's composite id)
805 let mut state = TraceState::new(
806 &route,
807 trace_id,
808 trace_path,
809 event.span_id(),
810 business_cid.as_deref(),
811 );
812 state.zero_traced = zero_traced;
813 Some(state)
814 }
815 _ => None,
816 };
817 let (result, finished_state) =
818 trace::run_scoped(trace_state, function.handle_event(headers, event, instance)).await;
819 // execution-time metric, standardized to 3 decimal points at the source
820 // (Java `WorkerHandler.getExecTime` parity: clamp ≥ 0, round to 3 dp).
821 // Rounding here means every consumer — the reply envelope, the telemetry
822 // dataset, and the Playground traveler's "Executed … in T ms" narration —
823 // reports the same value rather than a raw full-precision float.
824 let elapsed_ms = started.elapsed().as_secs_f32() * 1000.0;
825 let elapsed_ms = (elapsed_ms.max(0.0) * 1000.0).round() / 1000.0;
826 // propagate the trace to the response so the next hop chains correctly;
827 // a zero-traced hop contributes no span of its own (Java parity)
828 let trace_triple = finished_state.as_ref().map(|s| {
829 (
830 s.trace_id.clone(),
831 s.trace_path.clone(),
832 (!s.zero_traced).then(|| s.span_id.clone()),
833 )
834 });
835 // trace annotations ride the REPLY (Java applyTraceContext): the RPC
836 // caller folds them into the round_trip record; a zero-traced hop
837 // attaches none (Java has no live TraceInfo there)
838 let reply_annotations: HashMap<String, rmpv::Value> = finished_state
839 .as_ref()
840 .filter(|s| !s.zero_traced)
841 .map(|s| {
842 s.annotations
843 .iter()
844 .filter_map(|(k, v)| {
845 rmpv::ext::to_value(v).ok().map(|value| (k.clone(), value))
846 })
847 .collect()
848 })
849 .unwrap_or_default();
850 // pre-compute the outcome for the telemetry record — the result is
851 // consumed by the reply delivery below (Java reads it from
852 // ProcessStatus after processEvent has already sent the response)
853 let outcome = match &result {
854 Ok(response) => (response.status(), !response.has_error(), None),
855 Err(e) => (e.status(), false, Some(e.message().to_string())),
856 };
857 // whether an RPC reply failed to reach its waiting caller (the Java
858 // ProcessStatus.isNotDelivered signal); an interceptor's success path
859 // attempts no delivery and is NOT a delivery failure (Java parity)
860 let mut not_delivered = false;
861 match (reply_to, result) {
862 // an event interceptor replies MANUALLY (Java @EventInterceptor):
863 // its successful return value is ignored and no auto-reply is sent
864 // — but a FAILURE still routes to reply_to (Java WorkerHandler:
865 // only the success reply is interceptor-guarded)
866 (Some(_), Ok(_)) if options.interceptor => {}
867 (Some(reply_route), result) => {
868 let mut response = match result {
869 Ok(envelope) => envelope,
870 Err(e) => EventEnvelope::new()
871 .set_status(e.status())
872 .set_raw_body(rmpv::Value::String(e.message().into())),
873 };
874 response.set_cid_internal(cid);
875 response.set_from_internal(&route);
876 response.set_to_internal(&reply_route);
877 response.set_exec_time_internal(elapsed_ms);
878 response.set_annotations_internal(reply_annotations.clone());
879 // exit-side sanitization, symmetric with the entry-side
880 // injection (Java copyResponseHeaders): the read-only my_*
881 // keys and engine-internal keys never leave a function as
882 // response headers, even if it accidentally copies its input
883 // headers onto the returned envelope
884 sanitize_response_headers(&mut response);
885 // the reply is a bus hop too — same deterministic null strip
886 normalize_null_transport(&mut response);
887 if let Some((trace_id, trace_path, span_id)) = trace_triple {
888 response.set_trace_internal(&trace_id, &trace_path);
889 match span_id {
890 Some(span_id) => response.set_span_id_internal(&span_id),
891 // a zero-traced hop owns no span — and must not leak
892 // a nested reply's span as its own (Java rebuilds the
893 // response envelope, so its reply never carries one)
894 None => response.clear_span_id_internal(),
895 }
896 }
897 // RPC replies route to the reserved temporary.inbox service
898 // like any other destination (Java parity) — no special path.
899 // A legacy '@origin' suffix is parsed away (never generated).
900 let reply_route = bare_route(&reply_route).to_string();
901 if let Some(function) = reserved_route_function(®istry, &reply_route) {
902 // replies to the reserved engine routes (task callbacks)
903 // take the same direct path as sends
904 spawn_direct(function, response);
905 } else {
906 // clone the reply mailbox out of the lock before awaiting
907 let sender = registry
908 .read()
909 .expect("route registry poisoned")
910 .get(&reply_route)
911 .map(|entry| entry.mailbox.clone());
912 if let Some(sender) = sender {
913 // route may already be released — drop silently
914 let _ = sender.send(MailboxMessage::Event(Box::new(response))).await;
915 } else {
916 // the reply had nowhere to go (Java ProcessStatus
917 // notDelivered) — the worker keeps its own record
918 not_delivered = true;
919 }
920 }
921 }
922 (None, Err(e)) => {
923 // fire-and-forget failure has nowhere to go — log it (Java parity)
924 log::warn!(
925 "Unhandled exception in {route}#{instance}: ({}) {}",
926 e.status(),
927 e.message()
928 );
929 }
930 (None, Ok(_)) => {} // fire-and-forget success: result discarded
931 }
932 // send the performance-metrics dataset to the telemetry sink — never
933 // for a zero-traced route, and never for an RPC-served execution whose
934 // reply reached the caller: the caller's round_trip record is THE
935 // record for this span (Java WorkerHandler.sendTracingInfo gate:
936 // `journaled || rpc == null || notDelivered`; journaling not ported)
937 if let Some(state) = finished_state.filter(|s| !s.zero_traced) {
938 if !served_rpc || not_delivered {
939 emit_telemetry(®istry, &route, event_from, &state, outcome, elapsed_ms).await;
940 }
941 }
942 }
943}
944
945/// Strip a legacy `@origin`/`@instance` suffix from a route reference —
946/// meaningful only under the Java Kafka service mesh, which is out of scope
947/// here: inbound values are parse-tolerant, outbound values never carry one.
948pub(crate) fn bare_route(route: &str) -> &str {
949 match route.find('@') {
950 Some(at) => &route[..at],
951 None => route,
952 }
953}
954
955/// Read-only metadata keys injected into a function's input header copy at
956/// delivery (Java `WorkerHandler` MY_ROUTE / MY_TRACE_ID / MY_TRACE_PATH).
957pub(crate) const MY_ROUTE: &str = "my_route";
958pub(crate) const MY_TRACE_ID: &str = "my_trace_id";
959pub(crate) const MY_TRACE_PATH: &str = "my_trace_path";
960
961/// The engine's reserved metadata keys: injected into a function's input
962/// header copy at delivery, scrubbed from the delivered envelope view at
963/// entry (non-interceptors), and filtered from a returned reply at exit —
964/// metadata is injected, never transported.
965pub(crate) const ENGINE_METADATA_KEYS: [&str; 5] = [
966 MY_ROUTE,
967 MY_TRACE_ID,
968 MY_TRACE_PATH,
969 crate::automation::MY_CORRELATION_ID,
970 crate::automation::X_EVENT_API,
971];
972
973/// Exit-side sanitization (Java `WorkerHandler.copyResponseHeaders`): the
974/// injected read-only metadata and engine-internal keys are filtered from a
975/// function's returned envelope before it becomes a reply.
976fn sanitize_response_headers(response: &mut EventEnvelope) {
977 for key in ENGINE_METADATA_KEYS {
978 response.remove_header_internal(key);
979 }
980}
981
982/// Whether a route's executions are excluded from trace recording: the
983/// telemetry plumbing and the RPC reply listener (Java `@ZeroTracing` +
984/// filter — exact names only, no prefixes), and any route listed in
985/// `skip.rpc.tracing` (default `async.http.request`).
986fn is_zero_traced(route: &str) -> bool {
987 if crate::telemetry::ZERO_TRACING_FILTER.contains(&route) {
988 return true;
989 }
990 in_skip_rpc_tracing_list(route)
991}
992
993/// Whether a route is listed in `skip.rpc.tracing` (default
994/// `async.http.request`) — shared by the worker's zero-trace resolution above
995/// and the caller-side RPC `round_trip` record (Java `InboxBase.getSkipTracing`
996/// reads the same key).
997pub(crate) fn in_skip_rpc_tracing_list(route: &str) -> bool {
998 AppConfigReader::get_instance()
999 .get_property_or("skip.rpc.tracing", "async.http.request")
1000 .split([',', ' '])
1001 .map(str::trim)
1002 .any(|skipped| skipped == route)
1003}
1004
1005/// Build the performance-metrics dataset for one traced execution and send it
1006/// to the `distributed.tracing` sink (Java `WorkerHandler.sendTracingInfo` +
1007/// `getMetrics`). Fire-and-forget; silently skipped when the sink is not
1008/// registered on this platform.
1009async fn emit_telemetry(
1010 registry: &RouteRegistry,
1011 route: &str,
1012 from: Option<String>,
1013 state: &TraceState,
1014 outcome: (i32, bool, Option<String>),
1015 elapsed_ms: f32,
1016) {
1017 let sender = registry
1018 .read()
1019 .expect("route registry poisoned")
1020 .get(crate::telemetry::DISTRIBUTED_TRACING)
1021 .map(|entry| entry.mailbox.clone());
1022 let Some(sender) = sender else {
1023 return; // no telemetry sink on this platform
1024 };
1025 let (status, success, exception) = outcome;
1026 let mut metrics = serde_json::Map::new();
1027 let mut put = |k: &str, v: serde_json::Value| {
1028 metrics.insert(k.to_string(), v);
1029 };
1030 put("id", serde_json::Value::String(state.trace_id.clone()));
1031 put("path", serde_json::Value::String(state.trace_path.clone()));
1032 put("service", serde_json::Value::String(route.to_string()));
1033 put("start", serde_json::Value::String(state.start_time.clone()));
1034 put(
1035 "origin",
1036 serde_json::Value::String(Platform::origin().to_string()),
1037 );
1038 // round to 3 decimals in f64 so the JSON stays clean (f32 noise otherwise)
1039 put(
1040 "exec_time",
1041 serde_json::Value::from(((elapsed_ms as f64) * 1000.0).round() / 1000.0),
1042 );
1043 put("status", serde_json::Value::from(status));
1044 put("success", serde_json::Value::Bool(success));
1045 if let Some(exception) = exception {
1046 put("exception", serde_json::Value::String(exception));
1047 }
1048 if let Some(from) = from {
1049 put("from", serde_json::Value::String(from));
1050 }
1051 put("span_id", serde_json::Value::String(state.span_id.clone()));
1052 if let Some(parent) = &state.parent_span_id {
1053 put("parent_span_id", serde_json::Value::String(parent.clone()));
1054 }
1055 let mut dataset = serde_json::Map::new();
1056 dataset.insert("trace".to_string(), serde_json::Value::Object(metrics));
1057 if !state.annotations.is_empty() {
1058 dataset.insert(
1059 "annotations".to_string(),
1060 serde_json::Value::Object(state.annotations.clone().into_iter().collect()),
1061 );
1062 }
1063 // the telemetry event itself carries NO trace fields (no recursion)
1064 match EventEnvelope::new()
1065 .set_to(crate::telemetry::DISTRIBUTED_TRACING)
1066 .set_body(serde_json::Value::Object(dataset))
1067 {
1068 Ok(event) => {
1069 let _ = sender.send(MailboxMessage::Event(Box::new(event))).await;
1070 }
1071 Err(e) => log::error!("Unable to send to distributed.tracing - {e}"),
1072 }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078
1079 /// A component that runs background work declares it once; the
1080 /// standalone entry point reads the flag to stay alive until Ctrl-C or SIGTERM.
1081 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1082 async fn keep_running_is_declared_once_and_read_by_the_entry_point() {
1083 let platform = Platform::new();
1084 platform.keep_running("test component");
1085 platform.keep_running("test component again");
1086 assert!(Platform::is_kept_running());
1087 }
1088
1089 /// Java `Platform.onShutdown` contract: hooks run once, newest first, and
1090 /// one hook's failure never stops the others.
1091 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1092 async fn shutdown_hooks_run_newest_first_once_and_isolated() {
1093 let order = Arc::new(std::sync::Mutex::new(Vec::new()));
1094 let platform = Platform::new();
1095 for tag in ["first", "second", "third"] {
1096 let order = order.clone();
1097 platform.on_shutdown(move || order.lock().unwrap().push(tag));
1098 }
1099 let boom = order.clone();
1100 platform.on_shutdown(move || {
1101 boom.lock().unwrap().push("boom");
1102 panic!("a hook that fails must not stop the rest");
1103 });
1104 Platform::run_shutdown_hooks();
1105 assert_eq!(
1106 vec!["boom", "third", "second", "first"],
1107 *order.lock().unwrap()
1108 );
1109 // a second run finds nothing to do
1110 Platform::run_shutdown_hooks();
1111 assert_eq!(4, order.lock().unwrap().len());
1112 }
1113
1114 #[test]
1115 fn route_validation_rules() {
1116 assert!(validate_route("v1.get.profile").is_ok());
1117 assert!(validate_route("hello.world-2_x").is_ok());
1118 assert!(validate_route("badroute").is_err()); // no dot
1119 assert!(validate_route("UPPER.case").is_err()); // uppercase
1120 assert!(validate_route(".leading.dot").is_err());
1121 assert!(validate_route("trailing.dot.").is_err());
1122 assert!(validate_route("double..dot").is_err());
1123 assert!(validate_route("").is_err());
1124 assert!(validate_route("with space.x").is_err());
1125 }
1126}