greentic_runner_host/runner/
remote_dispatch.rs1use anyhow::Result;
6use async_nats::HeaderMap;
7use async_trait::async_trait;
8use greentic_types::{
9 DispatchError, DispatchMode, RuntimeDispatchRequest, RuntimeDispatchResponse, request_topic,
10 response_topic,
11};
12use serde_json::Value;
13
14pub struct RemoteDispatch {
16 pub tenant: String,
17 pub env: String,
18 pub runtime: String,
19 pub target: String,
20 pub operation: String,
21 pub mode: DispatchMode,
22 pub correlation_id: String,
24 pub input: Value,
25 pub deadline_ms: Option<u64>,
26}
27
28#[derive(Debug)]
30pub enum RemoteDispatchAction {
31 AwaitingResponse { correlation_id: String },
32 Dispatched,
33}
34
35pub struct BuiltRequest {
37 pub subject: String,
38 pub headers: HeaderMap,
39 pub body: Vec<u8>,
40 pub action: RemoteDispatchAction,
41}
42
43pub fn build_request(req: &RemoteDispatch) -> Result<BuiltRequest> {
46 let body_struct = RuntimeDispatchRequest {
47 target: req.target.clone(),
48 operation: req.operation.clone(),
49 mode: req.mode,
50 input: req.input.clone(),
51 deadline_ms: req.deadline_ms,
52 };
53 let body = serde_json::to_vec(&body_struct)?;
54
55 let mut headers = HeaderMap::new();
56 headers.insert("Greentic-Correlation-Id", req.correlation_id.as_str());
57 headers.insert("Greentic-Tenant", req.tenant.as_str());
58 headers.insert("Greentic-Env", req.env.as_str());
59 headers.insert("Greentic-Idempotency-Key", req.correlation_id.as_str());
60
61 let action = match req.mode {
62 DispatchMode::Await => RemoteDispatchAction::AwaitingResponse {
63 correlation_id: req.correlation_id.clone(),
64 },
65 DispatchMode::FireAndForget => RemoteDispatchAction::Dispatched,
66 };
67
68 Ok(BuiltRequest {
69 subject: request_topic(&req.runtime),
70 headers,
71 body,
72 action,
73 })
74}
75
76pub fn build_timeout_response_message(
93 runtime: &str,
94 correlation_id: &str,
95 tenant: &str,
96 env: &str,
97 deadline_ms: u64,
98) -> (String, HeaderMap, Vec<u8>) {
99 let subject = response_topic(runtime);
100
101 let mut headers = HeaderMap::new();
102 headers.insert("Greentic-Correlation-Id", correlation_id);
103 headers.insert("Greentic-Tenant", tenant);
104 headers.insert("Greentic-Env", env);
105
106 let response = RuntimeDispatchResponse {
107 ok: false,
108 output: Value::Null,
109 events: vec![],
110 error: Some(DispatchError {
111 code: "timeout".into(),
112 message: format!("no response within {deadline_ms}ms"),
113 }),
114 };
115 let body = serde_json::to_vec(&response).unwrap_or_default();
117
118 (subject, headers, body)
119}
120
121#[async_trait]
125pub trait RemoteDispatchHandler: Send + Sync {
126 async fn dispatch(&self, request: RemoteDispatch) -> Result<RemoteDispatchAction>;
127}
128
129pub struct NatsDispatcher {
131 client: async_nats::Client,
132}
133
134impl NatsDispatcher {
135 pub fn new(client: async_nats::Client) -> Self {
136 Self { client }
137 }
138}
139
140#[async_trait]
141impl RemoteDispatchHandler for NatsDispatcher {
142 async fn dispatch(&self, request: RemoteDispatch) -> Result<RemoteDispatchAction> {
143 let maybe_timeout = match (request.mode, request.deadline_ms) {
146 (DispatchMode::Await, Some(deadline_ms)) => Some((
147 deadline_ms,
148 request.runtime.clone(),
149 request.correlation_id.clone(),
150 request.tenant.clone(),
151 request.env.clone(),
152 )),
153 _ => None,
154 };
155
156 let built = build_request(&request)?;
157 self.client
158 .publish_with_headers(built.subject, built.headers, built.body.into())
159 .await?;
160
161 if let Some((deadline_ms, runtime, correlation_id, tenant, env)) = maybe_timeout {
163 let timeout_client = self.client.clone();
164 tokio::spawn(async move {
165 tokio::time::sleep(tokio::time::Duration::from_millis(deadline_ms)).await;
166 let (subject, headers, body) = build_timeout_response_message(
167 &runtime,
168 &correlation_id,
169 &tenant,
170 &env,
171 deadline_ms,
172 );
173 if let Err(publish_error) = timeout_client
174 .publish_with_headers(subject, headers, body.into())
175 .await
176 {
177 tracing::warn!(
178 %publish_error,
179 %correlation_id,
180 deadline_ms,
181 "failed to publish timeout response for awaited dispatch"
182 );
183 }
184 });
185 }
186
187 Ok(built.action)
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use greentic_types::{DispatchMode, RuntimeDispatchRequest, RuntimeDispatchResponse};
195 use serde_json::json;
196
197 fn sample(mode: DispatchMode) -> RemoteDispatch {
198 RemoteDispatch {
199 tenant: "t1".into(),
200 env: "default".into(),
201 runtime: "sorla".into(),
202 target: "dep-1".into(),
203 operation: "create".into(),
204 mode,
205 correlation_id: "t1:web:chan:conv:user::pack=p".into(), input: json!({"a": 1}),
207 deadline_ms: Some(1000),
208 }
209 }
210
211 #[test]
212 fn build_request_targets_request_topic_with_headers_and_body() {
213 let built = build_request(&sample(DispatchMode::Await)).unwrap();
214 assert_eq!(built.subject, "greentic.sorla.request.v1");
215 assert_eq!(
216 built
217 .headers
218 .get("Greentic-Correlation-Id")
219 .map(|v| v.as_str()),
220 Some("t1:web:chan:conv:user::pack=p")
221 );
222 assert_eq!(
223 built.headers.get("Greentic-Tenant").map(|v| v.as_str()),
224 Some("t1")
225 );
226 let body: RuntimeDispatchRequest = serde_json::from_slice(&built.body).unwrap();
227 assert_eq!(body.operation, "create");
228 assert_eq!(body.mode, DispatchMode::Await);
229 assert!(matches!(
230 built.action,
231 RemoteDispatchAction::AwaitingResponse { .. }
232 ));
233 }
234
235 #[test]
236 fn fire_and_forget_action_is_dispatched() {
237 let built = build_request(&sample(DispatchMode::FireAndForget)).unwrap();
238 assert!(matches!(built.action, RemoteDispatchAction::Dispatched));
239 }
240
241 #[test]
242 fn build_request_targets_telco_x_request_topic() {
243 let mut dispatch = sample(DispatchMode::Await);
247 dispatch.runtime = "telco-x".into();
248 let built = build_request(&dispatch).unwrap();
249 assert_eq!(built.subject, "greentic.telco-x.request.v1");
250 }
251
252 #[test]
253 fn build_timeout_response_message_telco_x_response_topic() {
254 let (subject, _headers, _body) =
255 build_timeout_response_message("telco-x", "corr-1", "t1", "default", 500);
256 assert_eq!(subject, "greentic.telco-x.response.v1");
257 }
258
259 #[test]
260 fn build_timeout_response_message_uses_response_topic() {
261 let (subject, _headers, _body) =
262 build_timeout_response_message("sorla", "corr-1", "t1", "default", 500);
263 assert_eq!(subject, "greentic.sorla.response.v1");
264 }
265
266 #[test]
267 fn build_timeout_response_message_echoes_correlation_header() {
268 let (_subject, headers, _body) =
269 build_timeout_response_message("sorla", "my-corr-id", "t1", "default", 1000);
270 assert_eq!(
271 headers.get("Greentic-Correlation-Id").map(|v| v.as_str()),
272 Some("my-corr-id")
273 );
274 }
275
276 #[test]
277 fn build_timeout_response_message_body_deserializes_to_timeout_error() {
278 let (_subject, _headers, body) =
279 build_timeout_response_message("sorla", "corr-1", "t1", "default", 200);
280 let response: RuntimeDispatchResponse = serde_json::from_slice(&body).unwrap();
281 assert!(!response.ok, "ok must be false");
282 assert_eq!(response.output, serde_json::Value::Null);
283 let error = response.error.expect("error must be present");
284 assert_eq!(error.code, "timeout");
285 assert!(
286 error.message.contains("200ms"),
287 "message must mention deadline: {:?}",
288 error.message
289 );
290 }
291}