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