platform_core/post_office.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 `PostOffice` (`org.platformlambda.core.system.PostOffice`)
18//! — the inter-function messaging client.
19//!
20//! Two core patterns: [`send`](PostOffice::send) (fire-and-forget) and
21//! [`request`](PostOffice::request) (RPC). RPC works exactly like Java's
22//! `TemporaryInbox` design (see [`crate::inbox`]): the request carries
23//! `reply_to = temporary.inbox` — the ONE reserved reply-listener route —
24//! plus a unique correlation id keyed into a pending-request registry; the
25//! reply routes to that service like any other event and completes the
26//! caller's oneshot; the caller awaits with a timeout (→ status **408** on
27//! expiry). No per-request route or prefix is claimed, so the `inbox.*`
28//! namespace belongs to applications.
29
30use std::collections::HashMap;
31use std::time::Duration;
32
33use crate::automation::event_api;
34use crate::envelope::EventEnvelope;
35use crate::function::AppError;
36use crate::platform::Platform;
37use crate::trace;
38
39/// Engine-managed envelope tag carrying the business correlation-id across
40/// touch points and Event-over-HTTP hops (Java `EventEmitter.BUSINESS_CID_TAG`).
41/// Metadata is never transported as envelope headers — the worker injects the
42/// `my_correlation_id` key into the function's input header copy from this
43/// tag at delivery.
44pub const BUSINESS_CID_TAG: &str = "my_cid";
45
46/// Engine-managed envelope tag marking an RPC request (Java
47/// `EventEmitter.RPC`): the worker suppresses its own telemetry record for a
48/// delivered RPC-served execution — the caller's `round_trip` record is THE
49/// record for the span. The reply address is just routing.
50pub(crate) const RPC_TAG: &str = "rpc";
51
52/// Stamp the current trace context onto an outbound event — the mirror of
53/// Java `PostOffice.touch()`: trace id and path are filled **only when the
54/// event has none of its own** (an explicitly supplied trace identity always
55/// wins — F8 parity fix, 2026-07-21); the span id is stamped unconditionally
56/// so the receiver knows its parent span — except inside a zero-traced hop,
57/// which owns no span (Java: no live TraceInfo there); `from` and the
58/// business correlation-id follow the request when absent.
59/// No-op outside a trace bracket. Crate-visible so the programmatic
60/// Event-over-HTTP client stamps the wire envelope the same way (Java's
61/// trace-aware `po.request(..., endpoint, rpc)` touches the event first).
62pub(crate) fn apply_current_trace(mut event: EventEnvelope) -> EventEnvelope {
63 let snapshot = trace::with_current(|state| {
64 (
65 state.route.clone(),
66 state.trace_id.clone(),
67 state.trace_path.clone(),
68 state.span_id.clone(),
69 state.cid.clone(),
70 state.zero_traced,
71 )
72 });
73 if let Some((route, trace_id, trace_path, span_id, cid, zero_traced)) = snapshot {
74 // Java touch(): each trace field fills independently, if-absent
75 let effective_id = event.trace_id().unwrap_or(&trace_id).to_string();
76 let effective_path = event.trace_path().unwrap_or(&trace_path).to_string();
77 event = event.set_trace(&effective_id, &effective_path);
78 if !zero_traced {
79 event = event.set_span_id(&span_id);
80 }
81 if event.from().is_none() {
82 event = event.set_from(&route);
83 }
84 // the business correlation-id rides an engine-managed envelope tag —
85 // never an envelope header or the cid slot (which stays free for
86 // internal correlation); the receiving worker injects it into the
87 // function's input header copy at delivery (Java touch() parity)
88 if event.tag(BUSINESS_CID_TAG).is_none() {
89 if let Some(cid) = cid {
90 event = event.add_tag(BUSINESS_CID_TAG, &cid);
91 }
92 }
93 }
94 event
95}
96
97/// Pending scheduled deliveries (Java `EventEmitter` future events): timer id
98/// → abort handle. Entries remove themselves on firing.
99fn scheduled_events(
100) -> &'static std::sync::Mutex<std::collections::HashMap<String, tokio::task::AbortHandle>> {
101 static TIMERS: std::sync::OnceLock<
102 std::sync::Mutex<std::collections::HashMap<String, tokio::task::AbortHandle>>,
103 > = std::sync::OnceLock::new();
104 TIMERS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
105}
106
107/// The messaging client. Cheap to clone; holds a handle to the [`Platform`].
108#[derive(Clone)]
109pub struct PostOffice {
110 platform: Platform,
111}
112
113impl PostOffice {
114 pub fn new(platform: &Platform) -> Self {
115 PostOffice {
116 platform: platform.clone(),
117 }
118 }
119
120 /// Fire-and-forget delivery to `event.to` (Java `po.send`).
121 /// Errors: 400 when `to` is missing, 404 when the route is not registered.
122 /// Awaits when the route's bounded manager mailbox is full — reactive
123 /// back-pressure, not drops.
124 ///
125 /// When called from inside a traced function, the platform propagates the
126 /// trace automatically: the outbound event carries the current trace
127 /// id/path, this function's span id (the receiver's parent span), the
128 /// sender route, and the business correlation-id when the event has none.
129 ///
130 /// A route declared in `yaml.event.over.http` forwards transparently to
131 /// the peer's `/api/event` instead of the local bus (Java
132 /// `EventEmitter.send` declarative hook) — user code cannot tell a remote
133 /// route from a local one. The `x-event-api` envelope header marks an
134 /// event that already crossed the wire, so it is never re-forwarded.
135 pub async fn send(&self, event: EventEnvelope) -> Result<(), AppError> {
136 self.dispatch(apply_current_trace(event)).await
137 }
138
139 /// Send WITHOUT stamping the current trace — the stream writer's data
140 /// segments: a stream is traced at its head and its tail, never per token
141 /// (one span per token would flood a tracing backend). Same routing as
142 /// [`send`](Self::send) — a mapped route still forwards over Event-over-HTTP.
143 /// Crate-visible: user code has no untraced send.
144 pub(crate) async fn send_untraced(&self, event: EventEnvelope) -> Result<(), AppError> {
145 self.dispatch(event).await
146 }
147
148 async fn dispatch(&self, event: EventEnvelope) -> Result<(), AppError> {
149 let Some(route) = event.to().map(str::to_string) else {
150 return Err(AppError::new(400, "Missing routing path ('to')"));
151 };
152 if event.header(event_api::X_EVENT_API).is_none() {
153 if let Some(entry) = event_api::get_event_http_target(&route) {
154 return event_api::send_with_event_http(&self.platform, event, &route, entry);
155 }
156 }
157 self.platform.deliver(&route, event).await
158 }
159
160 /// Schedule a future one-time delivery (Java `po.sendLater(event, time)`):
161 /// the event is sent after `delay`; the returned timer id cancels it via
162 /// [`cancel_future_event`](Self::cancel_future_event). The timer rides an
163 /// abortable tokio task (map-don't-mirror; increment E-3 — built for the
164 /// event-script flow TTL watcher).
165 pub fn send_later(&self, event: EventEnvelope, delay: std::time::Duration) -> String {
166 // capture the sender/trace/correlation context NOW (Java sendLater
167 // wraps the event in touch() before the timer) — the spawned timer
168 // task does not inherit the task-local trace bracket (F7 parity fix)
169 let event = apply_current_trace(event);
170 let timer_id = uuid::Uuid::new_v4().simple().to_string();
171 let platform = self.platform.clone();
172 let id_for_task = timer_id.clone();
173 let handle = tokio::spawn(async move {
174 tokio::time::sleep(delay).await;
175 scheduled_events()
176 .lock()
177 .expect("timer registry")
178 .remove(&id_for_task);
179 if let Some(route) = event.to().map(str::to_string) {
180 // deliver through send() so a scheduled event honors the
181 // declarative Event-over-HTTP hook exactly like a direct one
182 // (the timer task has no trace bracket, so the trace context
183 // captured above at schedule time is untouched)
184 if let Err(e) = PostOffice::new(&platform).send(event).await {
185 log::warn!(
186 "Unable to deliver scheduled event to {route} - {}",
187 e.message()
188 );
189 }
190 }
191 });
192 scheduled_events()
193 .lock()
194 .expect("timer registry")
195 .insert(timer_id.clone(), handle.abort_handle());
196 timer_id
197 }
198
199 /// Cancel a scheduled delivery (Java `po.cancelFutureEvent(id)`).
200 /// Returns whether the timer was still pending.
201 pub fn cancel_future_event(&self, timer_id: &str) -> bool {
202 match scheduled_events()
203 .lock()
204 .expect("timer registry")
205 .remove(timer_id)
206 {
207 Some(handle) => {
208 handle.abort();
209 true
210 }
211 None => false,
212 }
213 }
214
215 // ---- trace-aware conveniences (Java PostOffice business APIs) ----
216
217 /// The business correlation-id of the current traced request
218 /// (Java `getMyCorrelationId`). `None` outside a trace or when the
219 /// incoming event carried none.
220 pub fn my_correlation_id(&self) -> Option<String> {
221 trace::with_current(|state| state.cid.clone()).flatten()
222 }
223
224 /// The current trace id (Java `getTraceId` on the trace-aware PostOffice).
225 pub fn my_trace_id(&self) -> Option<String> {
226 trace::with_current(|state| state.trace_id.clone())
227 }
228
229 /// The current trace path.
230 pub fn my_trace_path(&self) -> Option<String> {
231 trace::with_current(|state| state.trace_path.clone())
232 }
233
234 /// The current hop's own span id (Java `getTrace().spanId`) — what a
235 /// function stamps as the parent of the NEXT hop when it carries the
236 /// trace context across a non-event boundary (e.g. a Kafka record's
237 /// W3C `traceparent` header). `None` outside a trace or in a
238 /// zero-traced hop, which owns no span.
239 pub fn my_span_id(&self) -> Option<String> {
240 trace::with_current(|state| {
241 // a zero-traced hop mints no span into the chain (same guard as
242 // the send path's unconditional span stamp)
243 (!state.zero_traced).then(|| state.span_id.clone())
244 })
245 .flatten()
246 }
247
248 /// Attach business context to the **distributed-trace dataset** that flows
249 /// to the telemetry sink (Java `annotateTrace`). Silent no-op outside a
250 /// trace.
251 pub fn annotate_trace(&self, key: &str, value: impl serde::Serialize) -> &Self {
252 if let Ok(value) = serde_json::to_value(value) {
253 trace::with_current_mut(|state| {
254 state.annotations.insert(key.to_string(), value);
255 });
256 }
257 self
258 }
259
260 /// Attach business context to the **application log** stream only (Java
261 /// `updateContext`) — appears in the `context` block of every subsequent
262 /// structured log line of this request. A `null` value removes the key.
263 /// The reserved keys — the trace-context names in both spellings (`cid`,
264 /// `traceId` / `trace_id`, `tracePath` / `trace_path`, `spanId` / `span_id`,
265 /// `parentSpanId` / `parent_span_id`, `service`, `utc`, `timestamp`) — are
266 /// rejected with a 400; outside a trace the call is a silent no-op. A key
267 /// the template also emits never shadows the template: the template wins.
268 pub fn update_context(&self, key: &str, value: impl serde::Serialize) -> Result<(), AppError> {
269 if crate::trace::RESERVED_OUTPUT_KEYS.contains(&key) {
270 return Err(AppError::new(
271 400,
272 format!("'{key}' is a reserved log context key"),
273 ));
274 }
275 let value = serde_json::to_value(value)
276 .map_err(|e| AppError::new(400, format!("unable to serialize context value: {e}")))?;
277 trace::with_current_mut(|state| {
278 if value.is_null() {
279 state.custom_log_keys.remove(key);
280 } else {
281 state.custom_log_keys.insert(key.to_string(), value);
282 }
283 });
284 Ok(())
285 }
286
287 /// RPC (Java `po.request(event, timeout)`): deliver the event and await the
288 /// reply through a temporary inbox. Timeout → status **408**.
289 ///
290 /// A route declared in `yaml.event.over.http` forwards transparently as an
291 /// Event-over-HTTP RPC and returns the peer's reply (Java
292 /// `EventEmitter.asyncRequest`/`eRequest` declarative hook); the
293 /// `x-event-api` recursion guard applies as in [`send`](Self::send).
294 pub async fn request(
295 &self,
296 event: EventEnvelope,
297 timeout: Duration,
298 ) -> Result<EventEnvelope, AppError> {
299 // propagate the trace context first, so a business correlation-id
300 // riding the current trace wins over a minted one
301 let event = apply_current_trace(event);
302 if event.header(event_api::X_EVENT_API).is_none() {
303 if let Some(entry) = event.to().and_then(event_api::get_event_http_target) {
304 let forward = event.set_header(event_api::X_EVENT_API, "request");
305 return event_api::event_over_http_with_headers(
306 self,
307 &entry.target,
308 forward,
309 timeout,
310 true,
311 &entry.headers,
312 )
313 .await;
314 }
315 }
316 self.request_direct(event, timeout).await
317 }
318
319 /// The local inbox-based RPC without the declarative Event-over-HTTP hook
320 /// — used by the framework's own internal calls (notably the HTTP client
321 /// leg of an Event-over-HTTP forward, which must never consult the
322 /// declarative registry itself).
323 pub(crate) async fn request_direct(
324 &self,
325 event: EventEnvelope,
326 timeout: Duration,
327 ) -> Result<EventEnvelope, AppError> {
328 // one pending entry in the correlation-id-keyed registry (Java
329 // TemporaryInbox + InboxBase.getHolder): the reply routes to the
330 // reserved temporary.inbox service like any other event — no
331 // per-request route is created, so the inbox.* namespace stays free
332 // for applications
333 let (inbox_cid, rx) = crate::inbox::open();
334 // the caller's original correlation id is restored on the reply
335 // (Java AsyncInbox.originalCid)
336 let original_cid = event.correlation_id().map(str::to_string);
337 // the port's cid-slot convention: a direct caller's explicit
338 // correlation id is business context — carry it on the engine tag so
339 // the callee's injected my_correlation_id still matches, now that the
340 // slot itself carries the inbox resolution id (an already-stamped tag
341 // wins, e.g. from a traced bracket or the REST/flow engines)
342 let mut event = event;
343 if event.tag(BUSINESS_CID_TAG).is_none() {
344 if let Some(cid) = &original_cid {
345 event = event.add_tag(BUSINESS_CID_TAG, cid);
346 }
347 }
348 // capture the RPC trace identity before the send consumes the event
349 // (Java AsyncInbox constructor: to, from, traceId, tracePath, and the
350 // caller's span riding the outbound request — the callee's parent)
351 let rpc_trace = RpcTraceCapture::of(&event);
352 let begin = std::time::Instant::now();
353 let event = event
354 .set_reply_to(crate::inbox::TEMPORARY_INBOX)
355 .set_correlation_id(&inbox_cid)
356 // the RPC marker (Java event.addTag(RPC, timeout)) — the worker
357 // reads it to suppress its own record for a delivered RPC
358 .add_tag(RPC_TAG, &timeout.as_millis().to_string());
359 if let Err(e) = self.send(event).await {
360 crate::inbox::close(&inbox_cid);
361 return Err(e);
362 }
363 let outcome = tokio::time::timeout(timeout, rx).await;
364 match outcome {
365 Ok(Ok(response)) => {
366 // the requester measures the full request/response cycle
367 // (Java AsyncInbox.saveResponse: reply.setRoundTrip(diff)),
368 // standardized to 3 decimal points like exec_time
369 let diff = begin.elapsed().as_secs_f32() * 1000.0;
370 let diff = (diff.max(0.0) * 1000.0).round() / 1000.0;
371 let mut response = response.set_round_trip(diff);
372 // restore the caller's correlation id (Java parity: the
373 // inbox id was only the resolution key)
374 if original_cid.is_some() {
375 response.set_cid_internal(original_cid);
376 }
377 // the reply's annotations belong to the trace record, never
378 // to the caller (Java saveResponse: fold + clearAnnotations)
379 let annotations = response.annotations().clone();
380 let response = response.clear_annotations();
381 self.record_rpc_trace(&rpc_trace, &response, annotations);
382 Ok(response)
383 }
384 Ok(Err(_)) => Err(AppError::new(500, "Reply channel closed unexpectedly")),
385 Err(_) => {
386 crate::inbox::close(&inbox_cid);
387 Err(AppError::new(
388 408,
389 format!("Request timeout for {} ms", timeout.as_millis()),
390 ))
391 }
392 }
393 }
394
395 /// Emit the caller-side RPC trace record — the dataset carrying
396 /// `round_trip` — to the `distributed.tracing` sink (Java
397 /// `InboxBase.recordTrace`, invoked from `AsyncInbox.saveResponse`).
398 /// For an RPC-served execution this is **the single record for the span**:
399 /// the worker suppresses its own record when the reply reaches the caller
400 /// (Java `WorkerHandler.sendTracingInfo` gate), so exec_time, round_trip,
401 /// span lineage and the callee's annotations all report here, once.
402 ///
403 /// Emitted only for a **traced** RPC (the outbound event carried a trace
404 /// id and path) whose target service is not in `skip.rpc.tracing`.
405 /// Span lineage mirrors the Java fixes (commits `04e5618f` + `140640d8`):
406 /// `parent_span_id` = the caller's span captured from the outbound
407 /// request, unconditionally; `span_id` = the callee's own span carried on
408 /// the reply, adopted **only from a direct responder** (the reply's `from`
409 /// equals the requested route — Java `InboxBase.spanIdFromResponder`). A
410 /// RELAYED reply (e.g. a flow answering on behalf of the manager route)
411 /// carries the span of a different function that reports its own record —
412 /// adopting it would misattribute and duplicate that span.
413 fn record_rpc_trace(
414 &self,
415 rpc: &RpcTraceCapture,
416 reply: &EventEnvelope,
417 annotations: HashMap<String, rmpv::Value>,
418 ) {
419 let (Some(to), Some(trace_id), Some(trace_path)) =
420 (&rpc.to, &rpc.trace_id, &rpc.trace_path)
421 else {
422 return; // not a traced RPC
423 };
424 let service = trim_origin(to).to_string();
425 if crate::platform::in_skip_rpc_tracing_list(&service) {
426 return;
427 }
428 if !self
429 .platform
430 .has_route(crate::telemetry::DISTRIBUTED_TRACING)
431 {
432 return; // no telemetry sink on this platform
433 }
434 let mut metrics = serde_json::Map::new();
435 let mut put = |k: &str, v: serde_json::Value| {
436 metrics.insert(k.to_string(), v);
437 };
438 put(
439 "origin",
440 serde_json::Value::String(Platform::origin().to_string()),
441 );
442 put("id", serde_json::Value::String(trace_id.clone()));
443 put("service", serde_json::Value::String(service));
444 if let Some(from) = &rpc.from {
445 put(
446 "from",
447 serde_json::Value::String(trim_origin(from).to_string()),
448 );
449 }
450 // span lineage of the RPC (omitted when unavailable, Java parity):
451 // the reply's span id counts only when it comes from the DIRECT
452 // responder (Java spanIdFromResponder); the parent is unconditional
453 if let Some(span_id) = span_id_from_responder(to, reply) {
454 put("span_id", serde_json::Value::String(span_id.to_string()));
455 }
456 if let Some(parent) = &rpc.parent_span {
457 put("parent_span_id", serde_json::Value::String(parent.clone()));
458 }
459 if let Some(exec_time) = reply.exec_time() {
460 put(
461 "exec_time",
462 serde_json::Value::from(((exec_time as f64) * 1000.0).round() / 1000.0),
463 );
464 }
465 if let Some(round_trip) = reply.round_trip() {
466 put(
467 "round_trip",
468 serde_json::Value::from(((round_trip as f64) * 1000.0).round() / 1000.0),
469 );
470 }
471 put("start", serde_json::Value::String(rpc.start.clone()));
472 put("path", serde_json::Value::String(trace_path.clone()));
473 let status = reply.status();
474 put("status", serde_json::Value::from(status));
475 if status >= 400 {
476 put("success", serde_json::Value::Bool(false));
477 // data privacy (Java parity): only a recognized plain error
478 // message is shown; any structured error body is masked
479 let message = match reply.body() {
480 rmpv::Value::String(s) => s.as_str().unwrap_or("***").to_string(),
481 _ => "***".to_string(),
482 };
483 put("exception", serde_json::Value::String(message));
484 } else {
485 put("success", serde_json::Value::Bool(true));
486 }
487 let mut dataset = serde_json::Map::new();
488 dataset.insert("trace".to_string(), serde_json::Value::Object(metrics));
489 // the callee's annotations, carried on the reply, report with THIS
490 // record — the callee's own record was suppressed (Java recordTrace)
491 if !annotations.is_empty() {
492 let folded: serde_json::Map<String, serde_json::Value> = annotations
493 .into_iter()
494 .filter_map(|(k, v)| serde_json::to_value(&v).ok().map(|value| (k, value)))
495 .collect();
496 if !folded.is_empty() {
497 dataset.insert("annotations".to_string(), serde_json::Value::Object(folded));
498 }
499 }
500 // fire-and-forget like Java's EventEmitter.send — never delays the
501 // caller's RPC completion; delivery failures are logged only
502 let platform = self.platform.clone();
503 tokio::spawn(async move {
504 match EventEnvelope::new()
505 .set_to(crate::telemetry::DISTRIBUTED_TRACING)
506 .set_body(serde_json::Value::Object(dataset))
507 {
508 Ok(event) => {
509 if let Err(e) = platform
510 .deliver(crate::telemetry::DISTRIBUTED_TRACING, event)
511 .await
512 {
513 log::error!("Unable to send to distributed.tracing - {}", e.message());
514 }
515 }
516 Err(e) => log::error!("Unable to send to distributed.tracing - {}", e.message()),
517 }
518 });
519 }
520}
521
522/// The RPC trace identity captured when the request is sent (Java
523/// `AsyncInbox`'s constructor fields + `InboxMetadata`).
524struct RpcTraceCapture {
525 to: Option<String>,
526 from: Option<String>,
527 trace_id: Option<String>,
528 trace_path: Option<String>,
529 /// The caller's span riding the outbound request — the callee's parent.
530 parent_span: Option<String>,
531 /// ISO-8601 UTC time the RPC began.
532 start: String,
533}
534
535impl RpcTraceCapture {
536 fn of(event: &EventEnvelope) -> Self {
537 RpcTraceCapture {
538 to: event.to().map(str::to_string),
539 from: event.from().map(str::to_string),
540 trace_id: event.trace_id().map(str::to_string),
541 trace_path: event.trace_path().map(str::to_string),
542 parent_span: event.span_id().map(str::to_string),
543 start: trace::iso8601_utc_now(),
544 }
545 }
546}
547
548/// Trim an `@origin` suffix from a route (Java `InboxBase.trimOrigin`).
549fn trim_origin(route: &str) -> &str {
550 match route.find('@') {
551 Some(at) => &route[..at],
552 None => route,
553 }
554}
555
556/// The reply's span id, adopted only when the reply comes from the DIRECT
557/// responder — its `from` equals the requested route (Java
558/// `InboxBase.spanIdFromResponder`, commit `140640d8`). A relayed reply
559/// (another function answering on behalf of the requested route) carries a
560/// span that its own record already reports.
561fn span_id_from_responder<'a>(to: &str, reply: &'a EventEnvelope) -> Option<&'a str> {
562 match reply.from() {
563 Some(from) if trim_origin(to) == from => reply.span_id(),
564 _ => None,
565 }
566}