1use super::*;
14use crate::idempotency::{IdempotencyRecord, IdempotencyStatus};
15use std::collections::HashMap;
16use std::io::{self, Write};
17
18const MAX_COMPLETIONS: usize = 64;
19const MAX_COMPLETION_BYTES: usize = 1024 * 1024;
20
21#[derive(Default)]
22pub(super) struct CompletionBuffer {
23 entries: HashMap<String, Completion>,
24}
25
26struct Completion {
27 request_hash: String,
28 response_body: Option<String>,
29 trace: Option<crate::TraceContext>,
30}
31
32impl RuntimeController {
33 pub fn recoverable_command_completions(&self) -> usize {
37 self.completions
38 .lock()
39 .entries
40 .values()
41 .filter(|entry| entry.response_body.is_some())
42 .count()
43 }
44
45 pub fn recover_pending_command(
52 &self,
53 command: &CommandEnvelope,
54 result: CommandResult,
55 ) -> RuntimeResult<CommandResult> {
56 let _activity = self
57 .admit_dispatch(command)
58 .map_err(|_| RuntimeError::CommandRejected)?;
59 let key = command
60 .idempotency_key
61 .as_deref()
62 .ok_or(RuntimeError::InvalidIdempotencyKey { reason: "missing" })?;
63 let request_hash = hash_command(command);
64 let body = encode_result(&result)?;
65 {
66 let store = self.idempotency.lock();
67 let mut completions = self.completions.lock();
68 let record = store
69 .get(key)?
70 .ok_or_else(|| completion_error("recover_completion", "reservation missing"))?;
71 check_hash(key, &record.request_hash, &request_hash)?;
72 if !matches!(record.status, IdempotencyStatus::Pending)
73 || completions.entries.contains_key(key)
74 {
75 return Err(RuntimeError::IdempotencyPending {
76 key: key.to_string(),
77 });
78 }
79 if completions.entries.len() >= MAX_COMPLETIONS {
80 return Err(completion_error(
81 "admit_completion",
82 "completion capacity exhausted",
83 ));
84 }
85 completions.entries.insert(
86 key.to_string(),
87 Completion {
88 request_hash,
89 response_body: Some(body),
90 trace: command.trace.clone(),
91 },
92 );
93 }
94 match self.reserve_idempotency(command)? {
96 Some(result) => result,
97 None => Err(completion_error(
98 "recover_completion",
99 "completion unavailable",
100 )),
101 }
102 }
103
104 pub(super) fn reserve_completion(
105 &self,
106 command: &CommandEnvelope,
107 ) -> RuntimeResult<Option<RuntimeResult<CommandResult>>> {
108 let Some(key) = command.idempotency_key.as_deref() else {
109 return Ok(None);
110 };
111 let request_hash = hash_command(command);
112 let mut store = self.idempotency.lock();
113 let mut completions = self.completions.lock();
114 if let Some(completion) = completions.entries.get(key) {
115 check_hash(key, &completion.request_hash, &request_hash)?;
116 let Some(body) = &completion.response_body else {
117 return Err(RuntimeError::IdempotencyPending {
118 key: key.to_string(),
119 });
120 };
121 let result = decode_result(body)?;
122 store.insert(resolved_record(key, request_hash, body.clone()))?;
123 let publication = CommandEnvelope {
124 command_name: command.command_name.clone(),
125 command_id: command.command_id.clone(),
126 app_id: command.app_id.clone(),
127 node_id: command.node_id.clone(),
128 issued_at_ms: command.issued_at_ms,
129 idempotency_key: command.idempotency_key.clone(),
130 payload: Vec::new(),
131 trace: completion.trace.clone(),
132 };
133 self.emit_events_and_audit(&publication, &Ok(result.clone()));
134 completions.entries.remove(key);
135 return Ok(Some(Ok(result)));
136 }
137 if let Some(record) = store.get(key)? {
138 check_hash(key, &record.request_hash, &request_hash)?;
139 match record.status {
140 IdempotencyStatus::Pending => {
141 return Err(RuntimeError::IdempotencyPending {
142 key: key.to_string(),
143 })
144 }
145 IdempotencyStatus::Resolved {
146 response_status,
147 response_body,
148 } => {
149 if response_status < 400 {
150 let result = if response_body.is_empty() {
151 CommandResult::accepted(Vec::new())
152 } else {
153 decode_result(&response_body)?
154 };
155 self.record_audit(command, &Ok(result.clone()));
156 return Ok(Some(Ok(result)));
157 }
158 store.remove(key)?;
159 }
160 }
161 }
162 if completions.entries.len() >= MAX_COMPLETIONS {
163 return Err(completion_error(
164 "admit_completion",
165 "completion capacity exhausted",
166 ));
167 }
168 store.insert(IdempotencyRecord {
169 key: key.to_string(),
170 request_hash: request_hash.clone(),
171 status: IdempotencyStatus::Pending,
172 created_at_ms: now_ms(),
173 })?;
174 completions.entries.insert(
175 key.to_string(),
176 Completion {
177 request_hash,
178 response_body: None,
179 trace: command.trace.clone(),
180 },
181 );
182 Ok(None)
183 }
184
185 pub fn post_dispatch(
193 &self,
194 command: &CommandEnvelope,
195 dispatch_result: &RuntimeResult<CommandResult>,
196 ) -> RuntimeResult<()> {
197 let Some(key) = command.idempotency_key.as_deref() else {
198 self.emit_events_and_audit(command, dispatch_result);
199 return Ok(());
200 };
201 let mut store = self.idempotency.lock();
202 let mut completions = self.completions.lock();
203 let request_hash = hash_command(command);
204 if let Some(completion) = completions.entries.get(key) {
205 check_hash(key, &completion.request_hash, &request_hash)?;
206 }
207 if let Ok(result) = dispatch_result {
208 let response_body = match encode_result(result) {
209 Ok(body) => body,
210 Err(error) => {
211 completions.entries.remove(key);
214 self.record_audit(command, &Err(error.clone()));
215 return Err(error);
216 }
217 };
218 if let Some(completion) = completions.entries.get_mut(key) {
219 check_hash(key, &completion.request_hash, &request_hash)?;
220 if completion
221 .response_body
222 .as_ref()
223 .is_some_and(|body| body != &response_body)
224 {
225 return Err(RuntimeError::IdempotencyConflict {
226 key: key.to_string(),
227 });
228 }
229 completion.response_body = Some(response_body.clone());
230 } else {
231 let record = store.get(key)?.ok_or_else(|| {
232 completion_error("finalize_completion", "reservation missing")
233 })?;
234 check_hash(key, &record.request_hash, &request_hash)?;
235 if let IdempotencyStatus::Resolved {
236 response_body: existing,
237 ..
238 } = record.status
239 {
240 if existing != response_body {
241 return Err(RuntimeError::IdempotencyConflict {
242 key: key.to_string(),
243 });
244 }
245 return Ok(());
246 }
247 return Err(RuntimeError::IdempotencyPending {
249 key: key.to_string(),
250 });
251 }
252 if let Err(error) = store.insert(resolved_record(key, request_hash, response_body)) {
253 self.record_audit(command, &Err(error.clone()));
254 return Err(error);
255 }
256 } else {
257 let record = store
258 .get(key)?
259 .ok_or_else(|| completion_error("finalize_completion", "reservation missing"))?;
260 check_hash(key, &record.request_hash, &request_hash)?;
261 if !matches!(record.status, IdempotencyStatus::Pending) {
262 return Err(RuntimeError::IdempotencyConflict {
263 key: key.to_string(),
264 });
265 }
266 store.remove(key)?;
267 }
268 self.emit_events_and_audit(command, dispatch_result);
269 completions.entries.remove(key);
270 Ok(())
271 }
272}
273
274fn check_hash(key: &str, stored: &str, expected: &str) -> RuntimeResult<()> {
275 if !stored.starts_with("command-v2:") {
276 return Err(completion_error(
277 "validate_request_domain",
278 "NO MORE SUPPORTED PLEASE UPDATE",
279 ));
280 }
281 if stored != expected {
282 return Err(RuntimeError::IdempotencyConflict {
283 key: key.to_string(),
284 });
285 }
286 Ok(())
287}
288
289fn resolved_record(key: &str, request_hash: String, response_body: String) -> IdempotencyRecord {
290 IdempotencyRecord {
291 key: key.to_string(),
292 request_hash,
293 status: IdempotencyStatus::Resolved {
294 response_status: 200,
295 response_body,
296 },
297 created_at_ms: now_ms(),
298 }
299}
300
301fn decode_result(body: &str) -> RuntimeResult<CommandResult> {
302 serde_json::from_str(body)
303 .map_err(|_| completion_error("deserialize_replay", "invalid completion result"))
304}
305
306fn completion_error(operation: &'static str, message: &str) -> RuntimeError {
307 RuntimeError::IdempotencyStoreIo {
308 operation,
309 message: message.to_string(),
310 }
311}
312
313struct BoundedResult(Vec<u8>);
314
315impl Write for BoundedResult {
316 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
317 if self.0.len().saturating_add(bytes.len()) > MAX_COMPLETION_BYTES {
318 return Err(io::Error::other("completion result limit exceeded"));
319 }
320 self.0.extend_from_slice(bytes);
321 Ok(bytes.len())
322 }
323 fn flush(&mut self) -> io::Result<()> {
324 Ok(())
325 }
326}
327
328fn encode_result(result: &CommandResult) -> RuntimeResult<String> {
329 let mut writer = BoundedResult(Vec::new());
330 serde_json::to_writer(&mut writer, result).map_err(|_| {
331 completion_error(
332 "serialize_response",
333 "completion result limit or encoding failure",
334 )
335 })?;
336 String::from_utf8(writer.0)
337 .map_err(|_| completion_error("serialize_response", "invalid completion encoding"))
338}
339
340fn hash_command(command: &CommandEnvelope) -> String {
343 use sha2::{Digest, Sha256};
344 let mut hasher = Sha256::new();
345 hasher.update(b"appcore.command.idempotency.v2\0");
346 for field in [
347 command.app_id.as_str().as_bytes(),
348 command.command_name.as_str().as_bytes(),
349 command.payload.as_slice(),
350 ] {
351 hasher.update((field.len() as u64).to_be_bytes());
352 hasher.update(field);
353 }
354 let result = hasher.finalize();
355 let mut hex = String::from("command-v2:");
356 for byte in result {
357 hex.push_str(&format!("{:02x}", byte));
358 }
359 hex
360}