1use std::{
4 collections::HashMap,
5 io,
6 sync::Arc,
7 time::{Duration, Instant},
8};
9
10use imagegen_bridge_core::{BridgeError, ErrorCode, ImageRequest, ImageResponse};
11use sha2::{Digest, Sha256};
12use tokio::sync::{Mutex, watch};
13use tokio_util::sync::CancellationToken;
14
15const MAX_SCOPE_BYTES: usize = 256;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct IdempotencyConfig {
20 pub max_entries: usize,
22 pub max_completed_bytes: usize,
24 pub completed_ttl: Duration,
26 pub in_flight_ttl: Duration,
28 pub unknown_ttl: Duration,
30}
31
32impl Default for IdempotencyConfig {
33 fn default() -> Self {
34 Self {
35 max_entries: 10_000,
36 max_completed_bytes: 256 * 1024 * 1024,
37 completed_ttl: Duration::from_secs(24 * 60 * 60),
38 in_flight_ttl: Duration::from_secs(31 * 60),
39 unknown_ttl: Duration::from_secs(24 * 60 * 60),
40 }
41 }
42}
43
44pub(crate) struct IdempotencyCoordinator {
45 inner: Mutex<IdempotencyState>,
46 config: IdempotencyConfig,
47}
48
49impl IdempotencyCoordinator {
50 pub(crate) fn new(config: IdempotencyConfig) -> Result<Self, BridgeError> {
51 if config.max_entries == 0
52 || config.max_completed_bytes == 0
53 || config.completed_ttl.is_zero()
54 || config.in_flight_ttl.is_zero()
55 || config.unknown_ttl.is_zero()
56 {
57 return Err(BridgeError::new(
58 ErrorCode::Configuration,
59 "idempotency limits and retention periods must be greater than zero",
60 ));
61 }
62 Ok(Self {
63 inner: Mutex::new(IdempotencyState::default()),
64 config,
65 })
66 }
67
68 pub(crate) async fn begin(
69 &self,
70 scope: &str,
71 key: &str,
72 request: &ImageRequest,
73 ) -> Result<IdempotencyAction, BridgeError> {
74 validate_scope(scope)?;
75 let fingerprint = request_fingerprint(request)?;
76 let record_key = RecordKey {
77 scope: scope.to_owned(),
78 key: key.to_owned(),
79 };
80 let now = Instant::now();
81 let mut state = self.inner.lock().await;
82 state.cleanup(now, self.config.in_flight_ttl);
83 state.access_clock = state.access_clock.saturating_add(1);
84 let access = state.access_clock;
85 if let Some(entry) = state.entries.get_mut(&record_key) {
86 if entry.fingerprint != fingerprint {
87 return Err(BridgeError::new(
88 ErrorCode::IdempotencyConflict,
89 "idempotency key was already used for a different request",
90 ));
91 }
92 entry.last_access = access;
93 let receiver = entry.sender.subscribe();
94 let outcome = receiver.borrow().clone();
95 return match outcome {
96 EntryOutcome::Completed(response) => {
97 Ok(IdempotencyAction::Cached(Box::new((*response).clone())))
98 }
99 EntryOutcome::Failed(error) | EntryOutcome::Unknown(error) => Err(error),
100 EntryOutcome::Pending => Ok(IdempotencyAction::Wait(receiver)),
101 };
102 }
103 state.make_capacity(self.config.max_entries)?;
104 let (sender, _receiver) = watch::channel(EntryOutcome::Pending);
105 state.entries.insert(
106 record_key.clone(),
107 Entry {
108 fingerprint,
109 created_at: now,
110 expires_at: None,
111 retained_bytes: 0,
112 uncertain: false,
113 last_access: access,
114 sender,
115 },
116 );
117 Ok(IdempotencyAction::Leader(IdempotencyToken {
118 record_key,
119 fingerprint,
120 }))
121 }
122
123 pub(crate) async fn complete(&self, token: IdempotencyToken, response: ImageResponse) {
124 let retained_bytes = response_weight(&response).unwrap_or(usize::MAX);
125 let mut state = self.inner.lock().await;
126 state.complete(&token, response, retained_bytes, &self.config);
127 }
128
129 pub(crate) async fn fail(&self, token: IdempotencyToken, error: BridgeError) {
130 let mut state = self.inner.lock().await;
131 if error
132 .details
133 .get("outcome")
134 .and_then(serde_json::Value::as_str)
135 == Some("unknown")
136 {
137 if let Some(entry) = state.entries.get_mut(&token.record_key)
138 && entry.fingerprint == token.fingerprint
139 {
140 entry.expires_at = Some(Instant::now() + self.config.unknown_ttl);
141 entry.uncertain = true;
142 entry.sender.send_replace(EntryOutcome::Unknown(
143 error.retryable(false).with_detail("outcome", "unknown"),
144 ));
145 }
146 } else if state
147 .entries
148 .get(&token.record_key)
149 .is_some_and(|entry| entry.fingerprint == token.fingerprint)
150 && let Some(entry) = state.remove(&token.record_key)
151 {
152 entry.sender.send_replace(EntryOutcome::Failed(error));
153 }
154 }
155}
156
157pub(crate) enum IdempotencyAction {
158 Leader(IdempotencyToken),
159 Cached(Box<ImageResponse>),
160 Wait(watch::Receiver<EntryOutcome>),
161}
162
163impl IdempotencyAction {
164 pub(crate) async fn wait(
165 mut receiver: watch::Receiver<EntryOutcome>,
166 deadline: Instant,
167 cancellation: &CancellationToken,
168 ) -> Result<ImageResponse, BridgeError> {
169 loop {
170 let outcome = receiver.borrow().clone();
171 match outcome {
172 EntryOutcome::Completed(response) => return Ok((*response).clone()),
173 EntryOutcome::Failed(error) | EntryOutcome::Unknown(error) => return Err(error),
174 EntryOutcome::Pending => {}
175 }
176 if deadline <= Instant::now() {
177 return Err(BridgeError::new(
178 ErrorCode::Timeout,
179 "request deadline elapsed while waiting for idempotent result",
180 ));
181 }
182 tokio::select! {
183 changed = receiver.changed() => {
184 if changed.is_err() {
185 return Err(BridgeError::new(
186 ErrorCode::Internal,
187 "idempotency leader ended without a result",
188 ));
189 }
190 }
191 () = cancellation.cancelled() => {
192 return Err(BridgeError::new(
193 ErrorCode::Cancelled,
194 "request was cancelled while waiting for idempotent result",
195 ));
196 }
197 () = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => {
198 return Err(BridgeError::new(
199 ErrorCode::Timeout,
200 "request deadline elapsed while waiting for idempotent result",
201 ));
202 }
203 }
204 }
205 }
206}
207
208#[derive(Clone)]
209pub(crate) enum EntryOutcome {
210 Pending,
211 Completed(Arc<ImageResponse>),
212 Failed(BridgeError),
213 Unknown(BridgeError),
214}
215
216pub(crate) struct IdempotencyToken {
217 record_key: RecordKey,
218 fingerprint: [u8; 32],
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Hash)]
222struct RecordKey {
223 scope: String,
224 key: String,
225}
226
227struct Entry {
228 fingerprint: [u8; 32],
229 created_at: Instant,
230 expires_at: Option<Instant>,
231 retained_bytes: usize,
232 uncertain: bool,
233 last_access: u64,
234 sender: watch::Sender<EntryOutcome>,
235}
236
237#[derive(Default)]
238struct IdempotencyState {
239 entries: HashMap<RecordKey, Entry>,
240 completed_bytes: usize,
241 access_clock: u64,
242}
243
244impl IdempotencyState {
245 fn cleanup(&mut self, now: Instant, in_flight_ttl: Duration) {
246 let stale: Vec<_> = self
247 .entries
248 .iter()
249 .filter_map(|(key, entry)| {
250 let expired = entry.expires_at.is_some_and(|expires| expires <= now)
251 || (entry.expires_at.is_none()
252 && now.saturating_duration_since(entry.created_at) >= in_flight_ttl);
253 expired.then(|| key.clone())
254 })
255 .collect();
256 for key in stale {
257 if let Some(entry) = self.remove(&key)
258 && entry.expires_at.is_none()
259 {
260 entry.sender.send_replace(EntryOutcome::Failed(
261 BridgeError::new(ErrorCode::Timeout, "idempotent operation expired")
262 .retryable(true),
263 ));
264 }
265 }
266 }
267
268 fn make_capacity(&mut self, maximum: usize) -> Result<(), BridgeError> {
269 if self.entries.len() < maximum {
270 return Ok(());
271 }
272 let oldest_completed = self
273 .entries
274 .iter()
275 .filter(|(_, entry)| entry.expires_at.is_some() && !entry.uncertain)
276 .min_by_key(|(_, entry)| entry.last_access)
277 .map(|(key, _)| key.clone());
278 if let Some(key) = oldest_completed {
279 self.remove(&key);
280 return Ok(());
281 }
282 Err(
283 BridgeError::new(ErrorCode::Overloaded, "idempotency capacity is exhausted")
284 .retryable(true),
285 )
286 }
287
288 fn complete(
289 &mut self,
290 token: &IdempotencyToken,
291 response: ImageResponse,
292 retained_bytes: usize,
293 config: &IdempotencyConfig,
294 ) {
295 if self
296 .entries
297 .get(&token.record_key)
298 .is_none_or(|entry| entry.fingerprint != token.fingerprint)
299 {
300 return;
301 }
302 if retained_bytes > config.max_completed_bytes
303 || self
304 .reserve_completed_bytes(
305 retained_bytes,
306 config.max_completed_bytes,
307 &token.record_key,
308 )
309 .is_err()
310 {
311 if let Some(entry) = self.entries.get_mut(&token.record_key) {
312 entry.expires_at = Some(Instant::now() + config.completed_ttl);
313 entry.uncertain = true;
314 entry.retained_bytes = 0;
315 entry.sender.send_replace(EntryOutcome::Unknown(
316 BridgeError::new(
317 ErrorCode::Overloaded,
318 "completed response is too large for idempotent replay",
319 )
320 .retryable(false)
321 .with_detail("outcome", "completed_unreplayable"),
322 ));
323 }
324 return;
325 }
326 self.access_clock = self.access_clock.saturating_add(1);
327 if let Some(entry) = self.entries.get_mut(&token.record_key) {
328 entry.expires_at = Some(Instant::now() + config.completed_ttl);
329 entry.uncertain = false;
330 entry.retained_bytes = retained_bytes;
331 entry.last_access = self.access_clock;
332 self.completed_bytes = self.completed_bytes.saturating_add(retained_bytes);
333 entry
334 .sender
335 .send_replace(EntryOutcome::Completed(Arc::new(response)));
336 }
337 }
338
339 fn reserve_completed_bytes(
340 &mut self,
341 incoming: usize,
342 maximum: usize,
343 exclude: &RecordKey,
344 ) -> Result<(), BridgeError> {
345 while self.completed_bytes.saturating_add(incoming) > maximum {
346 let victim = self
347 .entries
348 .iter()
349 .filter(|(key, entry)| {
350 *key != exclude && entry.retained_bytes > 0 && !entry.uncertain
351 })
352 .min_by_key(|(_, entry)| entry.last_access)
353 .map(|(key, _)| key.clone())
354 .ok_or_else(|| {
355 BridgeError::new(
356 ErrorCode::Overloaded,
357 "idempotency completed-response byte capacity is exhausted",
358 )
359 })?;
360 self.remove(&victim);
361 }
362 Ok(())
363 }
364
365 fn remove(&mut self, key: &RecordKey) -> Option<Entry> {
366 let entry = self.entries.remove(key)?;
367 self.completed_bytes = self.completed_bytes.saturating_sub(entry.retained_bytes);
368 Some(entry)
369 }
370}
371
372fn response_weight(response: &ImageResponse) -> Result<usize, BridgeError> {
373 struct Counter(usize);
374
375 impl io::Write for Counter {
376 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
377 self.0 = self.0.checked_add(bytes.len()).ok_or_else(|| {
378 io::Error::new(io::ErrorKind::FileTooLarge, "response weight overflowed")
379 })?;
380 Ok(bytes.len())
381 }
382
383 fn flush(&mut self) -> io::Result<()> {
384 Ok(())
385 }
386 }
387
388 let mut counter = Counter(256);
389 serde_json::to_writer(&mut counter, response).map_err(|_| {
390 BridgeError::new(
391 ErrorCode::Internal,
392 "could not measure idempotency response weight",
393 )
394 })?;
395 Ok(counter.0)
396}
397
398fn request_fingerprint(request: &ImageRequest) -> Result<[u8; 32], BridgeError> {
399 let mut request = request.clone();
400 request.idempotency_key = None;
401 request.timeout_ms = None;
402 let encoded = serde_json::to_vec(&request).map_err(|_| {
403 BridgeError::new(
404 ErrorCode::Internal,
405 "could not fingerprint the normalized request",
406 )
407 })?;
408 Ok(Sha256::digest(encoded).into())
409}
410
411fn validate_scope(scope: &str) -> Result<(), BridgeError> {
412 if scope.is_empty() || scope.len() > MAX_SCOPE_BYTES || scope.chars().any(char::is_control) {
413 Err(BridgeError::new(
414 ErrorCode::InvalidRequest,
415 "invalid idempotency scope",
416 ))
417 } else {
418 Ok(())
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 #![allow(clippy::manual_let_else, clippy::panic, clippy::unwrap_used)]
425
426 use imagegen_bridge_core::{GenerationParameters, Timings};
427
428 use super::*;
429
430 fn response(id: &str) -> ImageResponse {
431 ImageResponse {
432 id: id.to_owned(),
433 created: 0,
434 provider: "fake".to_owned(),
435 model: "fake".to_owned(),
436 requested: GenerationParameters::default(),
437 effective: GenerationParameters::default(),
438 normalizations: Vec::new(),
439 attempts: Vec::new(),
440 data: Vec::new(),
441 failures: Vec::new(),
442 revised_prompt: None,
443 usage: None,
444 session: None,
445 timings: Timings::default(),
446 warnings: Vec::new(),
447 }
448 }
449
450 #[tokio::test]
451 async fn replays_completed_response_and_rejects_conflicts() {
452 let coordinator = IdempotencyCoordinator::new(IdempotencyConfig::default()).unwrap();
453 let request = ImageRequest::generate("same");
454 let IdempotencyAction::Leader(token) =
455 coordinator.begin("tenant", "key", &request).await.unwrap()
456 else {
457 panic!("first caller must lead");
458 };
459 coordinator.complete(token, response("original")).await;
460 let IdempotencyAction::Cached(cached) =
461 coordinator.begin("tenant", "key", &request).await.unwrap()
462 else {
463 panic!("completed call must be cached");
464 };
465 assert_eq!(cached.id, "original");
466 let error = match coordinator
467 .begin("tenant", "key", &ImageRequest::generate("different"))
468 .await
469 {
470 Err(error) => error,
471 Ok(_) => panic!("a conflicting request must fail"),
472 };
473 assert_eq!(error.code, ErrorCode::IdempotencyConflict);
474 }
475
476 #[tokio::test]
477 async fn followers_receive_the_leader_result() {
478 let coordinator =
479 Arc::new(IdempotencyCoordinator::new(IdempotencyConfig::default()).unwrap());
480 let request = ImageRequest::generate("same");
481 let IdempotencyAction::Leader(token) =
482 coordinator.begin("tenant", "key", &request).await.unwrap()
483 else {
484 panic!("first caller must lead");
485 };
486 let IdempotencyAction::Wait(receiver) =
487 coordinator.begin("tenant", "key", &request).await.unwrap()
488 else {
489 panic!("second caller must wait");
490 };
491 let follower = tokio::spawn(async move {
492 IdempotencyAction::wait(
493 receiver,
494 Instant::now() + Duration::from_secs(5),
495 &CancellationToken::new(),
496 )
497 .await
498 });
499 coordinator.complete(token, response("leader")).await;
500 assert_eq!(follower.await.unwrap().unwrap().id, "leader");
501 }
502
503 #[tokio::test]
504 async fn unknown_outcome_is_retained_and_cannot_elect_a_second_leader() {
505 let coordinator = IdempotencyCoordinator::new(IdempotencyConfig::default()).unwrap();
506 let request = ImageRequest::generate("same");
507 let IdempotencyAction::Leader(token) = coordinator
508 .begin("tenant", "unknown", &request)
509 .await
510 .unwrap()
511 else {
512 panic!("first caller must lead");
513 };
514 coordinator
515 .fail(
516 token,
517 BridgeError::new(ErrorCode::Timeout, "provider result is uncertain")
518 .retryable(true)
519 .with_detail("outcome", "unknown"),
520 )
521 .await;
522
523 let repeated = match coordinator.begin("tenant", "unknown", &request).await {
524 Err(error) => error,
525 Ok(_) => panic!("unknown outcome must remain a tombstone"),
526 };
527 assert_eq!(repeated.code, ErrorCode::Timeout);
528 assert!(!repeated.retryable);
529 assert_eq!(repeated.details["outcome"], "unknown");
530
531 let conflict = match coordinator
532 .begin("tenant", "unknown", &ImageRequest::generate("different"))
533 .await
534 {
535 Err(error) => error,
536 Ok(_) => panic!("different request must still conflict"),
537 };
538 assert_eq!(conflict.code, ErrorCode::IdempotencyConflict);
539 }
540
541 #[tokio::test]
542 async fn completed_response_bytes_are_bounded_and_oversize_keeps_a_tombstone() {
543 let mut sample = response("sample");
544 sample.revised_prompt = Some("x".repeat(700));
545 let weight = response_weight(&sample).unwrap();
546 let coordinator = IdempotencyCoordinator::new(IdempotencyConfig {
547 max_entries: 10,
548 max_completed_bytes: weight + 64,
549 ..IdempotencyConfig::default()
550 })
551 .unwrap();
552 let request = ImageRequest::generate("same");
553
554 let IdempotencyAction::Leader(first) = coordinator
555 .begin("tenant", "first", &request)
556 .await
557 .unwrap()
558 else {
559 panic!("first caller must lead");
560 };
561 coordinator.complete(first, sample.clone()).await;
562 let IdempotencyAction::Leader(second) = coordinator
563 .begin("tenant", "second", &request)
564 .await
565 .unwrap()
566 else {
567 panic!("second caller must lead");
568 };
569 coordinator.complete(second, sample).await;
570 let state = coordinator.inner.lock().await;
571 assert!(state.completed_bytes <= coordinator.config.max_completed_bytes);
572 assert_eq!(
573 state
574 .entries
575 .values()
576 .filter(|entry| entry.retained_bytes > 0)
577 .count(),
578 1
579 );
580 drop(state);
581
582 let mut oversized = response("oversized");
583 oversized.revised_prompt = Some("y".repeat(weight * 2));
584 let IdempotencyAction::Leader(oversize_token) = coordinator
585 .begin("tenant", "oversized", &request)
586 .await
587 .unwrap()
588 else {
589 panic!("oversized caller must lead once");
590 };
591 coordinator.complete(oversize_token, oversized).await;
592 let error = match coordinator.begin("tenant", "oversized", &request).await {
593 Err(error) => error,
594 Ok(_) => panic!("unreplayable completion must retain a tombstone"),
595 };
596 assert_eq!(error.details["outcome"], "completed_unreplayable");
597 assert!(!error.retryable);
598 }
599}