Skip to main content

appcore_core/controller/
completion.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: completion.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/10/13 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/10/13 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Bounded process-local recovery between command effects and receipt publication.
12
13use 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    /// Counts outcomes retained after a receipt failure and recoverable by retry.
34    ///
35    /// This excludes executing handlers and reservations restored after restart.
36    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    /// Finalizes a restarted pending reservation using application-owned evidence.
46    ///
47    /// The trusted caller must first reconcile the external effect and provide its
48    /// authoritative result. This method never invokes the handler or infers an
49    /// outcome. Already resolved, missing, mismatched and active reservations fail.
50    /// A receipt failure retains the supplied result for ordinary identical retry.
51    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        // Reservation is now completion-owned; racing dispatch can only finalize it.
95        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    /// Persists an outcome before emitting events, retaining failed completions.
186    ///
187    /// Call after a successful `pre_dispatch` reservation. Repeating finalization
188    /// for an already resolved result does not emit its events again. At most 64
189    /// active keyed completions of 1 MiB each are retained. Oversized results leave
190    /// the reservation pending for application reconciliation. Event delivery is
191    /// still bounded process-local publication, not a transactional durable outbox.
192    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                    // The handler completed but its oversized outcome cannot be retained.
212                    // Keep the durable reservation; release the local recovery capacity.
213                    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                // A restarted pending reservation requires explicit application evidence.
248                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
340// Key uniqueness is store-wide. App identity, command name and payload bind its
341// semantic operation; transport IDs, timestamps, trace and origin node do not.
342fn 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}