Skip to main content

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}