1use super::identity::validate_digest;
10use crate::execution_identity::{ExecutionIdentityV1, ExecutionResultReceiptV1};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use thiserror::Error;
17use tokio::io::AsyncWriteExt;
18use tokio::sync::Mutex;
19
20pub const EVALUATION_DISPATCH_LEDGER_SCHEMA_V1: &str = "a3s.code.evaluation-dispatch-ledger.v1";
21pub const EVALUATION_DISPATCH_LEDGER_DEFAULT_MAX_RECORDS: usize = 4096;
22pub const EVALUATION_DISPATCH_LEDGER_MAX_BYTES: usize = 16 * 1024 * 1024;
23pub const EVALUATION_DISPATCH_MIN_LEASE_MS: u64 = 60 * 1000;
24pub const EVALUATION_DISPATCH_LEASE_GRACE_MS: u64 = 30 * 1000;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum EvaluationDispatchClaimOutcome {
28 Claimed { attempt: u32 },
29 Completed,
30 Busy { lease_expires_at_ms: u64 },
31 Conflict,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Error)]
35pub enum EvaluationDispatchLedgerError {
36 #[error("evaluation dispatch ledger field `{0}` is invalid")]
37 InvalidField(&'static str),
38 #[error("evaluation dispatch ledger schema is unsupported")]
39 UnsupportedSchema,
40 #[error("evaluation dispatch ledger claim conflicts with an existing identity")]
41 Conflict,
42 #[error("evaluation dispatch ledger storage failed: {0}")]
43 Storage(String),
44 #[error("evaluation dispatch ledger is corrupt: {0}")]
45 Corrupt(String),
46 #[error("evaluation dispatch ledger exceeds its configured size limit")]
47 SizeLimit,
48}
49
50#[async_trait]
51pub trait EvaluationDispatchLedger: Send + Sync {
52 async fn claim(
53 &self,
54 dispatch_id: &str,
55 request_digest: &str,
56 owner_id: &str,
57 now_ms: u64,
58 lease_ms: u64,
59 ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError>;
60
61 async fn claim_with_identity(
65 &self,
66 dispatch_id: &str,
67 request_digest: &str,
68 identity: &ExecutionIdentityV1,
69 owner_id: &str,
70 now_ms: u64,
71 lease_ms: u64,
72 ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
73 identity
74 .validate()
75 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
76 self.claim(dispatch_id, request_digest, owner_id, now_ms, lease_ms)
77 .await
78 }
79
80 async fn renew(
81 &self,
82 dispatch_id: &str,
83 request_digest: &str,
84 owner_id: &str,
85 now_ms: u64,
86 lease_ms: u64,
87 ) -> Result<bool, EvaluationDispatchLedgerError>;
88
89 async fn renew_with_identity(
92 &self,
93 dispatch_id: &str,
94 request_digest: &str,
95 identity: &ExecutionIdentityV1,
96 owner_id: &str,
97 now_ms: u64,
98 lease_ms: u64,
99 ) -> Result<bool, EvaluationDispatchLedgerError> {
100 identity
101 .validate()
102 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
103 self.renew(dispatch_id, request_digest, owner_id, now_ms, lease_ms)
104 .await
105 }
106
107 async fn complete(
108 &self,
109 dispatch_id: &str,
110 request_digest: &str,
111 owner_id: &str,
112 completed_at_ms: u64,
113 ) -> Result<(), EvaluationDispatchLedgerError>;
114
115 async fn complete_with_receipt(
117 &self,
118 dispatch_id: &str,
119 request_digest: &str,
120 identity: &ExecutionIdentityV1,
121 owner_id: &str,
122 receipt: &ExecutionResultReceiptV1,
123 completed_at_ms: u64,
124 ) -> Result<(), EvaluationDispatchLedgerError> {
125 identity
126 .validate()
127 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
128 receipt
129 .validate()
130 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("result_receipt"))?;
131 if &receipt.identity != identity {
132 return Err(EvaluationDispatchLedgerError::Conflict);
133 }
134 self.complete(dispatch_id, request_digest, owner_id, completed_at_ms)
135 .await
136 }
137
138 async fn release(
139 &self,
140 dispatch_id: &str,
141 request_digest: &str,
142 owner_id: &str,
143 ) -> Result<(), EvaluationDispatchLedgerError>;
144
145 async fn release_with_identity(
148 &self,
149 dispatch_id: &str,
150 request_digest: &str,
151 identity: &ExecutionIdentityV1,
152 owner_id: &str,
153 ) -> Result<(), EvaluationDispatchLedgerError> {
154 identity
155 .validate()
156 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
157 self.release(dispatch_id, request_digest, owner_id).await
158 }
159
160 async fn completed_receipt(
162 &self,
163 _dispatch_id: &str,
164 ) -> Result<Option<ExecutionResultReceiptV1>, EvaluationDispatchLedgerError> {
165 Ok(None)
166 }
167
168 async fn prune_completed(&self, before_ms: u64)
169 -> Result<usize, EvaluationDispatchLedgerError>;
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174enum DispatchClaimStatus {
175 Pending,
176 Completed,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181struct DispatchClaimRecord {
182 request_digest: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 execution_identity: Option<ExecutionIdentityV1>,
185 status: DispatchClaimStatus,
186 owner_id: String,
187 lease_expires_at_ms: u64,
188 attempts: u32,
189 created_at_ms: u64,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 completed_at_ms: Option<u64>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 result_receipt: Option<ExecutionResultReceiptV1>,
194}
195
196#[derive(Debug, Default, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198struct DispatchLedgerFileV1 {
199 schema: String,
200 records: BTreeMap<String, DispatchClaimRecord>,
201}
202
203#[derive(Debug, Default)]
204pub struct MemoryEvaluationDispatchLedger {
205 records: Mutex<BTreeMap<String, DispatchClaimRecord>>,
206}
207
208impl MemoryEvaluationDispatchLedger {
209 pub fn new() -> Self {
210 Self::default()
211 }
212}
213
214#[async_trait]
215impl EvaluationDispatchLedger for MemoryEvaluationDispatchLedger {
216 async fn claim(
217 &self,
218 dispatch_id: &str,
219 request_digest: &str,
220 owner_id: &str,
221 now_ms: u64,
222 lease_ms: u64,
223 ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
224 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
225 let mut records = self.records.lock().await;
226 claim_record(
227 &mut records,
228 dispatch_id,
229 request_digest,
230 None,
231 owner_id,
232 now_ms,
233 lease_ms,
234 )
235 }
236
237 async fn claim_with_identity(
238 &self,
239 dispatch_id: &str,
240 request_digest: &str,
241 identity: &ExecutionIdentityV1,
242 owner_id: &str,
243 now_ms: u64,
244 lease_ms: u64,
245 ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
246 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
247 identity
248 .validate()
249 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
250 let mut records = self.records.lock().await;
251 claim_record(
252 &mut records,
253 dispatch_id,
254 request_digest,
255 Some(identity),
256 owner_id,
257 now_ms,
258 lease_ms,
259 )
260 }
261
262 async fn renew(
263 &self,
264 dispatch_id: &str,
265 request_digest: &str,
266 owner_id: &str,
267 now_ms: u64,
268 lease_ms: u64,
269 ) -> Result<bool, EvaluationDispatchLedgerError> {
270 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
271 let mut records = self.records.lock().await;
272 Ok(renew_record(
273 &mut records,
274 dispatch_id,
275 request_digest,
276 None,
277 owner_id,
278 now_ms,
279 lease_ms,
280 ))
281 }
282
283 async fn renew_with_identity(
284 &self,
285 dispatch_id: &str,
286 request_digest: &str,
287 identity: &ExecutionIdentityV1,
288 owner_id: &str,
289 now_ms: u64,
290 lease_ms: u64,
291 ) -> Result<bool, EvaluationDispatchLedgerError> {
292 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
293 identity
294 .validate()
295 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
296 let mut records = self.records.lock().await;
297 Ok(renew_record(
298 &mut records,
299 dispatch_id,
300 request_digest,
301 Some(identity),
302 owner_id,
303 now_ms,
304 lease_ms,
305 ))
306 }
307
308 async fn complete(
309 &self,
310 dispatch_id: &str,
311 request_digest: &str,
312 owner_id: &str,
313 completed_at_ms: u64,
314 ) -> Result<(), EvaluationDispatchLedgerError> {
315 validate_identity_args(dispatch_id, request_digest, owner_id)?;
316 let mut records = self.records.lock().await;
317 complete_record(
318 &mut records,
319 dispatch_id,
320 request_digest,
321 None,
322 owner_id,
323 None,
324 completed_at_ms,
325 )
326 }
327
328 async fn complete_with_receipt(
329 &self,
330 dispatch_id: &str,
331 request_digest: &str,
332 identity: &ExecutionIdentityV1,
333 owner_id: &str,
334 receipt: &ExecutionResultReceiptV1,
335 completed_at_ms: u64,
336 ) -> Result<(), EvaluationDispatchLedgerError> {
337 validate_identity_args(dispatch_id, request_digest, owner_id)?;
338 identity
339 .validate()
340 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
341 receipt
342 .validate()
343 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("result_receipt"))?;
344 if &receipt.identity != identity {
345 return Err(EvaluationDispatchLedgerError::Conflict);
346 }
347 let mut records = self.records.lock().await;
348 complete_record(
349 &mut records,
350 dispatch_id,
351 request_digest,
352 Some(identity),
353 owner_id,
354 Some(receipt),
355 completed_at_ms,
356 )
357 }
358
359 async fn release(
360 &self,
361 dispatch_id: &str,
362 request_digest: &str,
363 owner_id: &str,
364 ) -> Result<(), EvaluationDispatchLedgerError> {
365 validate_identity_args(dispatch_id, request_digest, owner_id)?;
366 let mut records = self.records.lock().await;
367 release_record(&mut records, dispatch_id, request_digest, None, owner_id)
368 }
369
370 async fn release_with_identity(
371 &self,
372 dispatch_id: &str,
373 request_digest: &str,
374 identity: &ExecutionIdentityV1,
375 owner_id: &str,
376 ) -> Result<(), EvaluationDispatchLedgerError> {
377 validate_identity_args(dispatch_id, request_digest, owner_id)?;
378 identity
379 .validate()
380 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
381 let mut records = self.records.lock().await;
382 release_record(
383 &mut records,
384 dispatch_id,
385 request_digest,
386 Some(identity),
387 owner_id,
388 )
389 }
390
391 async fn completed_receipt(
392 &self,
393 dispatch_id: &str,
394 ) -> Result<Option<ExecutionResultReceiptV1>, EvaluationDispatchLedgerError> {
395 let records = self.records.lock().await;
396 Ok(records
397 .get(dispatch_id)
398 .and_then(|record| record.result_receipt.clone()))
399 }
400
401 async fn prune_completed(
402 &self,
403 before_ms: u64,
404 ) -> Result<usize, EvaluationDispatchLedgerError> {
405 let mut records = self.records.lock().await;
406 Ok(prune_records(&mut records, before_ms))
407 }
408}
409
410#[derive(Debug, Clone)]
411pub struct FileEvaluationDispatchLedger {
412 root: PathBuf,
413 max_records: usize,
414 process_lock: Arc<Mutex<()>>,
415}
416
417impl FileEvaluationDispatchLedger {
418 pub fn new(root: impl Into<PathBuf>) -> Self {
419 Self {
420 root: root.into(),
421 max_records: EVALUATION_DISPATCH_LEDGER_DEFAULT_MAX_RECORDS,
422 process_lock: Arc::new(Mutex::new(())),
423 }
424 }
425
426 pub fn with_max_records(
427 root: impl Into<PathBuf>,
428 max_records: usize,
429 ) -> Result<Self, EvaluationDispatchLedgerError> {
430 if max_records == 0 {
431 return Err(EvaluationDispatchLedgerError::InvalidField("max_records"));
432 }
433 Ok(Self {
434 root: root.into(),
435 max_records,
436 process_lock: Arc::new(Mutex::new(())),
437 })
438 }
439
440 pub async fn open(root: impl Into<PathBuf>) -> Result<Self, EvaluationDispatchLedgerError> {
442 let ledger = Self::new(root);
443 ledger.validate_store().await?;
444 Ok(ledger)
445 }
446
447 pub async fn open_with_max_records(
448 root: impl Into<PathBuf>,
449 max_records: usize,
450 ) -> Result<Self, EvaluationDispatchLedgerError> {
451 let ledger = Self::with_max_records(root, max_records)?;
452 ledger.validate_store().await?;
453 Ok(ledger)
454 }
455
456 pub fn root(&self) -> &Path {
457 &self.root
458 }
459
460 pub fn max_records(&self) -> usize {
461 self.max_records
462 }
463
464 pub async fn validate_store(&self) -> Result<usize, EvaluationDispatchLedgerError> {
465 Ok(self.read_records().await?.len())
466 }
467
468 fn data_path(&self) -> PathBuf {
469 self.root.join("evaluation-dispatch-ledger.json")
470 }
471
472 fn lock_path(&self) -> PathBuf {
473 self.root.join(".evaluation-dispatch-ledger.lock")
474 }
475
476 async fn acquire_file_lock(&self) -> Result<std::fs::File, EvaluationDispatchLedgerError> {
477 tokio::fs::create_dir_all(&self.root)
478 .await
479 .map_err(|error| storage_error("create dispatch ledger directory", error))?;
480 let path = self.lock_path();
481 tokio::task::spawn_blocking(move || {
482 use fs2::FileExt;
483 let file = std::fs::OpenOptions::new()
484 .create(true)
485 .truncate(false)
486 .read(true)
487 .write(true)
488 .open(&path)
489 .map_err(|error| storage_error("open dispatch ledger lock", error))?;
490 file.lock_exclusive()
491 .map_err(|error| storage_error("lock dispatch ledger", error))?;
492 Ok(file)
493 })
494 .await
495 .map_err(|error| {
496 EvaluationDispatchLedgerError::Storage(format!("lock task failed: {error}"))
497 })?
498 }
499
500 async fn read_records(
501 &self,
502 ) -> Result<BTreeMap<String, DispatchClaimRecord>, EvaluationDispatchLedgerError> {
503 read_records_from_path(&self.data_path(), self.max_records).await
504 }
505
506 async fn mutate<T>(
507 &self,
508 mutation: impl FnOnce(
509 &mut BTreeMap<String, DispatchClaimRecord>,
510 ) -> Result<T, EvaluationDispatchLedgerError>,
511 ) -> Result<T, EvaluationDispatchLedgerError> {
512 let _process_guard = self.process_lock.lock().await;
513 let _file_guard = self.acquire_file_lock().await?;
514 let mut records = self.read_records().await?;
515 let value = mutation(&mut records)?;
516 enforce_retention(&mut records, self.max_records)?;
517 self.publish_records(&records).await?;
518 Ok(value)
519 }
520
521 async fn publish_records(
522 &self,
523 records: &BTreeMap<String, DispatchClaimRecord>,
524 ) -> Result<(), EvaluationDispatchLedgerError> {
525 let bytes = serde_json::to_vec(&DispatchLedgerFileV1 {
526 schema: EVALUATION_DISPATCH_LEDGER_SCHEMA_V1.to_string(),
527 records: records.clone(),
528 })
529 .map_err(|error| {
530 EvaluationDispatchLedgerError::Storage(format!("encode ledger: {error}"))
531 })?;
532 if bytes.len() > EVALUATION_DISPATCH_LEDGER_MAX_BYTES {
533 return Err(EvaluationDispatchLedgerError::SizeLimit);
534 }
535 tokio::fs::create_dir_all(&self.root)
536 .await
537 .map_err(|error| storage_error("create dispatch ledger directory", error))?;
538 let path = self.data_path();
539 let temp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
540 let result = async {
541 let mut file = tokio::fs::File::create(&temp)
542 .await
543 .map_err(|error| storage_error("create dispatch ledger generation", error))?;
544 file.write_all(&bytes)
545 .await
546 .map_err(|error| storage_error("write dispatch ledger generation", error))?;
547 file.sync_all()
548 .await
549 .map_err(|error| storage_error("sync dispatch ledger generation", error))?;
550 drop(file);
551 let temp_copy = temp.clone();
552 let path_copy = path.clone();
553 tokio::task::spawn_blocking(move || {
554 tempfile::TempPath::try_from_path(temp_copy)
555 .map_err(|error| storage_error("prepare dispatch ledger replacement", error))?
556 .persist(path_copy)
557 .map_err(|error| {
558 storage_error("publish dispatch ledger generation", error.error)
559 })
560 })
561 .await
562 .map_err(|error| {
563 EvaluationDispatchLedgerError::Storage(format!("publish task failed: {error}"))
564 })??;
565 Ok::<(), EvaluationDispatchLedgerError>(())
566 }
567 .await;
568 if result.is_err() {
569 let _ = tokio::fs::remove_file(&temp).await;
570 }
571 result
572 }
573}
574
575#[async_trait]
576impl EvaluationDispatchLedger for FileEvaluationDispatchLedger {
577 async fn claim(
578 &self,
579 dispatch_id: &str,
580 request_digest: &str,
581 owner_id: &str,
582 now_ms: u64,
583 lease_ms: u64,
584 ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
585 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
586 self.mutate(|records| {
587 claim_record(
588 records,
589 dispatch_id,
590 request_digest,
591 None,
592 owner_id,
593 now_ms,
594 lease_ms,
595 )
596 })
597 .await
598 }
599
600 async fn renew(
601 &self,
602 dispatch_id: &str,
603 request_digest: &str,
604 owner_id: &str,
605 now_ms: u64,
606 lease_ms: u64,
607 ) -> Result<bool, EvaluationDispatchLedgerError> {
608 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
609 self.mutate(|records| {
610 Ok(renew_record(
611 records,
612 dispatch_id,
613 request_digest,
614 None,
615 owner_id,
616 now_ms,
617 lease_ms,
618 ))
619 })
620 .await
621 }
622
623 async fn complete(
624 &self,
625 dispatch_id: &str,
626 request_digest: &str,
627 owner_id: &str,
628 completed_at_ms: u64,
629 ) -> Result<(), EvaluationDispatchLedgerError> {
630 validate_identity_args(dispatch_id, request_digest, owner_id)?;
631 self.mutate(|records| {
632 complete_record(
633 records,
634 dispatch_id,
635 request_digest,
636 None,
637 owner_id,
638 None,
639 completed_at_ms,
640 )
641 })
642 .await
643 }
644
645 async fn release(
646 &self,
647 dispatch_id: &str,
648 request_digest: &str,
649 owner_id: &str,
650 ) -> Result<(), EvaluationDispatchLedgerError> {
651 validate_identity_args(dispatch_id, request_digest, owner_id)?;
652 self.mutate(|records| release_record(records, dispatch_id, request_digest, None, owner_id))
653 .await
654 }
655
656 async fn claim_with_identity(
657 &self,
658 dispatch_id: &str,
659 request_digest: &str,
660 identity: &ExecutionIdentityV1,
661 owner_id: &str,
662 now_ms: u64,
663 lease_ms: u64,
664 ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
665 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
666 identity
667 .validate()
668 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
669 self.mutate(|records| {
670 claim_record(
671 records,
672 dispatch_id,
673 request_digest,
674 Some(identity),
675 owner_id,
676 now_ms,
677 lease_ms,
678 )
679 })
680 .await
681 }
682
683 async fn renew_with_identity(
684 &self,
685 dispatch_id: &str,
686 request_digest: &str,
687 identity: &ExecutionIdentityV1,
688 owner_id: &str,
689 now_ms: u64,
690 lease_ms: u64,
691 ) -> Result<bool, EvaluationDispatchLedgerError> {
692 validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
693 identity
694 .validate()
695 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
696 self.mutate(|records| {
697 Ok(renew_record(
698 records,
699 dispatch_id,
700 request_digest,
701 Some(identity),
702 owner_id,
703 now_ms,
704 lease_ms,
705 ))
706 })
707 .await
708 }
709
710 async fn complete_with_receipt(
711 &self,
712 dispatch_id: &str,
713 request_digest: &str,
714 identity: &ExecutionIdentityV1,
715 owner_id: &str,
716 receipt: &ExecutionResultReceiptV1,
717 completed_at_ms: u64,
718 ) -> Result<(), EvaluationDispatchLedgerError> {
719 validate_identity_args(dispatch_id, request_digest, owner_id)?;
720 identity
721 .validate()
722 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
723 receipt
724 .validate()
725 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("result_receipt"))?;
726 if &receipt.identity != identity {
727 return Err(EvaluationDispatchLedgerError::Conflict);
728 }
729 self.mutate(|records| {
730 complete_record(
731 records,
732 dispatch_id,
733 request_digest,
734 Some(identity),
735 owner_id,
736 Some(receipt),
737 completed_at_ms,
738 )
739 })
740 .await
741 }
742
743 async fn release_with_identity(
744 &self,
745 dispatch_id: &str,
746 request_digest: &str,
747 identity: &ExecutionIdentityV1,
748 owner_id: &str,
749 ) -> Result<(), EvaluationDispatchLedgerError> {
750 validate_identity_args(dispatch_id, request_digest, owner_id)?;
751 identity
752 .validate()
753 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
754 self.mutate(|records| {
755 release_record(
756 records,
757 dispatch_id,
758 request_digest,
759 Some(identity),
760 owner_id,
761 )
762 })
763 .await
764 }
765
766 async fn completed_receipt(
767 &self,
768 dispatch_id: &str,
769 ) -> Result<Option<ExecutionResultReceiptV1>, EvaluationDispatchLedgerError> {
770 Ok(self
771 .read_records()
772 .await?
773 .get(dispatch_id)
774 .and_then(|record| record.result_receipt.clone()))
775 }
776
777 async fn prune_completed(
778 &self,
779 before_ms: u64,
780 ) -> Result<usize, EvaluationDispatchLedgerError> {
781 self.mutate(|records| Ok(prune_records(records, before_ms)))
782 .await
783 }
784}
785
786fn validate_claim_args(
787 dispatch_id: &str,
788 request_digest: &str,
789 owner_id: &str,
790 now_ms: u64,
791 lease_ms: u64,
792) -> Result<(), EvaluationDispatchLedgerError> {
793 validate_identity_args(dispatch_id, request_digest, owner_id)?;
794 if now_ms == 0 {
795 return Err(EvaluationDispatchLedgerError::InvalidField("now_ms"));
796 }
797 if lease_ms == 0 {
798 return Err(EvaluationDispatchLedgerError::InvalidField("lease_ms"));
799 }
800 Ok(())
801}
802
803fn validate_identity_args(
804 dispatch_id: &str,
805 request_digest: &str,
806 owner_id: &str,
807) -> Result<(), EvaluationDispatchLedgerError> {
808 validate_text("dispatch_id", dispatch_id)?;
809 validate_text("owner_id", owner_id)?;
810 validate_digest(request_digest)
811 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("request_digest"))
812}
813
814fn validate_text(field: &'static str, value: &str) -> Result<(), EvaluationDispatchLedgerError> {
815 if value.is_empty() || value.len() > 512 || value.contains('\0') || value.lines().count() != 1 {
816 return Err(EvaluationDispatchLedgerError::InvalidField(field));
817 }
818 Ok(())
819}
820
821fn claim_record(
822 records: &mut BTreeMap<String, DispatchClaimRecord>,
823 dispatch_id: &str,
824 request_digest: &str,
825 identity: Option<&ExecutionIdentityV1>,
826 owner_id: &str,
827 now_ms: u64,
828 lease_ms: u64,
829) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
830 let lease_expires_at_ms = now_ms.saturating_add(lease_ms.max(1));
831 match records.get_mut(dispatch_id) {
832 None => {
833 records.insert(
834 dispatch_id.to_string(),
835 DispatchClaimRecord {
836 request_digest: request_digest.to_string(),
837 execution_identity: identity.cloned(),
838 status: DispatchClaimStatus::Pending,
839 owner_id: owner_id.to_string(),
840 lease_expires_at_ms,
841 attempts: 1,
842 created_at_ms: now_ms,
843 completed_at_ms: None,
844 result_receipt: None,
845 },
846 );
847 Ok(EvaluationDispatchClaimOutcome::Claimed { attempt: 1 })
848 }
849 Some(record) if record.request_digest != request_digest => {
850 Ok(EvaluationDispatchClaimOutcome::Conflict)
851 }
852 Some(record)
853 if identity.is_some_and(|identity| {
854 record
855 .execution_identity
856 .as_ref()
857 .is_some_and(|record_identity| record_identity != identity)
858 }) =>
859 {
860 Ok(EvaluationDispatchClaimOutcome::Conflict)
861 }
862 Some(record) if record.status == DispatchClaimStatus::Completed => {
863 Ok(EvaluationDispatchClaimOutcome::Completed)
864 }
865 Some(record) if record.lease_expires_at_ms > now_ms && record.owner_id != owner_id => {
866 Ok(EvaluationDispatchClaimOutcome::Busy {
867 lease_expires_at_ms: record.lease_expires_at_ms,
868 })
869 }
870 Some(record) => {
871 if record.execution_identity.is_none() {
872 record.execution_identity = identity.cloned();
873 }
874 record.owner_id = owner_id.to_string();
875 record.lease_expires_at_ms = lease_expires_at_ms;
876 record.attempts = record.attempts.saturating_add(1);
877 Ok(EvaluationDispatchClaimOutcome::Claimed {
878 attempt: record.attempts,
879 })
880 }
881 }
882}
883
884fn renew_record(
885 records: &mut BTreeMap<String, DispatchClaimRecord>,
886 dispatch_id: &str,
887 request_digest: &str,
888 identity: Option<&ExecutionIdentityV1>,
889 owner_id: &str,
890 now_ms: u64,
891 lease_ms: u64,
892) -> bool {
893 let Some(record) = records.get_mut(dispatch_id) else {
894 return false;
895 };
896 if record.request_digest != request_digest
897 || identity.is_some_and(|identity| {
898 record
899 .execution_identity
900 .as_ref()
901 .is_some_and(|record_identity| record_identity != identity)
902 })
903 || record.status != DispatchClaimStatus::Pending
904 || record.owner_id != owner_id
905 || record.lease_expires_at_ms <= now_ms
906 {
907 return false;
908 }
909 record.lease_expires_at_ms = now_ms.saturating_add(lease_ms.max(1));
910 true
911}
912
913fn complete_record(
914 records: &mut BTreeMap<String, DispatchClaimRecord>,
915 dispatch_id: &str,
916 request_digest: &str,
917 identity: Option<&ExecutionIdentityV1>,
918 owner_id: &str,
919 result_receipt: Option<&ExecutionResultReceiptV1>,
920 completed_at_ms: u64,
921) -> Result<(), EvaluationDispatchLedgerError> {
922 let record = records
923 .get_mut(dispatch_id)
924 .ok_or(EvaluationDispatchLedgerError::Conflict)?;
925 if record.request_digest != request_digest {
926 return Err(EvaluationDispatchLedgerError::Conflict);
927 }
928 if identity.is_some_and(|identity| {
929 record
930 .execution_identity
931 .as_ref()
932 .is_some_and(|record_identity| record_identity != identity)
933 }) {
934 return Err(EvaluationDispatchLedgerError::Conflict);
935 }
936 if record.status == DispatchClaimStatus::Completed {
937 if let (Some(expected), Some(actual)) = (record.result_receipt.as_ref(), result_receipt) {
938 if expected != actual {
939 return Err(EvaluationDispatchLedgerError::Conflict);
940 }
941 }
942 return Ok(());
943 }
944 if record.owner_id != owner_id {
945 return Err(EvaluationDispatchLedgerError::Conflict);
946 }
947 if completed_at_ms < record.created_at_ms {
948 return Err(EvaluationDispatchLedgerError::InvalidField(
949 "completed_at_ms",
950 ));
951 }
952 if record.lease_expires_at_ms <= completed_at_ms {
956 return Err(EvaluationDispatchLedgerError::Conflict);
957 }
958 if let Some(identity) = identity {
959 record.execution_identity = Some(identity.clone());
960 }
961 record.result_receipt = result_receipt.cloned();
962 record.status = DispatchClaimStatus::Completed;
963 record.lease_expires_at_ms = 0;
964 record.completed_at_ms = Some(completed_at_ms);
965 Ok(())
966}
967
968fn release_record(
969 records: &mut BTreeMap<String, DispatchClaimRecord>,
970 dispatch_id: &str,
971 request_digest: &str,
972 identity: Option<&ExecutionIdentityV1>,
973 owner_id: &str,
974) -> Result<(), EvaluationDispatchLedgerError> {
975 let Some(record) = records.get_mut(dispatch_id) else {
976 return Ok(());
977 };
978 if record.request_digest != request_digest
979 || identity.is_some_and(|identity| {
980 record
981 .execution_identity
982 .as_ref()
983 .is_some_and(|record_identity| record_identity != identity)
984 })
985 || record.status == DispatchClaimStatus::Completed
986 {
987 return Ok(());
988 }
989 if record.owner_id == owner_id {
990 record.owner_id.clear();
991 record.lease_expires_at_ms = 0;
992 }
993 Ok(())
994}
995
996fn prune_records(records: &mut BTreeMap<String, DispatchClaimRecord>, before_ms: u64) -> usize {
997 let before = records.len();
998 records.retain(|_, record| {
999 record.status != DispatchClaimStatus::Completed
1000 || record.completed_at_ms.unwrap_or(u64::MAX) >= before_ms
1001 });
1002 before - records.len()
1003}
1004
1005fn enforce_retention(
1006 records: &mut BTreeMap<String, DispatchClaimRecord>,
1007 max_records: usize,
1008) -> Result<(), EvaluationDispatchLedgerError> {
1009 while records.len() > max_records {
1010 let candidate = records
1011 .iter()
1012 .filter(|(_, record)| record.status == DispatchClaimStatus::Completed)
1013 .min_by_key(|(id, record)| (record.completed_at_ms.unwrap_or(u64::MAX), id.as_str()))
1014 .map(|(id, _)| id.clone());
1015 let Some(candidate) = candidate else {
1016 return Err(EvaluationDispatchLedgerError::SizeLimit);
1017 };
1018 records.remove(&candidate);
1019 }
1020 Ok(())
1021}
1022
1023async fn read_records_from_path(
1024 path: &Path,
1025 max_records: usize,
1026) -> Result<BTreeMap<String, DispatchClaimRecord>, EvaluationDispatchLedgerError> {
1027 let bytes = match crate::bounded_io::read_file_bounded_async(
1028 path,
1029 EVALUATION_DISPATCH_LEDGER_MAX_BYTES,
1030 )
1031 .await
1032 {
1033 Ok(bytes) => bytes,
1034 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1035 Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {
1036 return Err(EvaluationDispatchLedgerError::SizeLimit)
1037 }
1038 Err(error) => return Err(storage_error("read dispatch ledger", error)),
1039 };
1040 let file: DispatchLedgerFileV1 = serde_json::from_slice(&bytes).map_err(|error| {
1041 EvaluationDispatchLedgerError::Corrupt(format!("decode ledger: {error}"))
1042 })?;
1043 if file.schema != EVALUATION_DISPATCH_LEDGER_SCHEMA_V1 {
1044 return Err(EvaluationDispatchLedgerError::UnsupportedSchema);
1045 }
1046 if file.records.len() > max_records {
1047 return Err(EvaluationDispatchLedgerError::Corrupt(format!(
1048 "{} records exceed configured retention limit {}",
1049 file.records.len(),
1050 max_records
1051 )));
1052 }
1053 for (dispatch_id, record) in &file.records {
1054 validate_text("dispatch_id", dispatch_id)?;
1055 validate_digest(&record.request_digest)
1056 .map_err(|_| EvaluationDispatchLedgerError::InvalidField("request_digest"))?;
1057 if let Some(identity) = &record.execution_identity {
1058 identity.validate().map_err(|_| {
1059 EvaluationDispatchLedgerError::Corrupt("execution identity is invalid".to_string())
1060 })?;
1061 }
1062 if let Some(receipt) = &record.result_receipt {
1063 receipt.validate().map_err(|_| {
1064 EvaluationDispatchLedgerError::Corrupt("result receipt is invalid".to_string())
1065 })?;
1066 if record
1067 .execution_identity
1068 .as_ref()
1069 .is_some_and(|identity| identity != &receipt.identity)
1070 {
1071 return Err(EvaluationDispatchLedgerError::Corrupt(
1072 "result receipt identity does not match claim".to_string(),
1073 ));
1074 }
1075 }
1076 if !record.owner_id.is_empty() {
1077 validate_text("owner_id", &record.owner_id)?;
1078 }
1079 if record.created_at_ms == 0
1080 || record.attempts == 0
1081 || record
1082 .completed_at_ms
1083 .is_some_and(|completed| completed < record.created_at_ms)
1084 {
1085 return Err(EvaluationDispatchLedgerError::Corrupt(
1086 "claim record timestamps or attempts are invalid".to_string(),
1087 ));
1088 }
1089 match record.status {
1090 DispatchClaimStatus::Pending
1091 if record.completed_at_ms.is_some()
1092 || record.result_receipt.is_some()
1093 || (record.owner_id.is_empty() && record.lease_expires_at_ms != 0)
1094 || (!record.owner_id.is_empty() && record.lease_expires_at_ms == 0) =>
1095 {
1096 return Err(EvaluationDispatchLedgerError::Corrupt(
1097 "pending claim fields are inconsistent".to_string(),
1098 ));
1099 }
1100 DispatchClaimStatus::Completed
1101 if record.completed_at_ms.is_none()
1102 || record.lease_expires_at_ms != 0
1103 || record.owner_id.is_empty() =>
1104 {
1105 return Err(EvaluationDispatchLedgerError::Corrupt(
1106 "completed claim fields are inconsistent".to_string(),
1107 ));
1108 }
1109 _ => {}
1110 }
1111 }
1112 Ok(file.records)
1113}
1114
1115fn storage_error(operation: &str, error: impl std::fmt::Display) -> EvaluationDispatchLedgerError {
1116 EvaluationDispatchLedgerError::Storage(format!("{operation}: {error}"))
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121 use super::*;
1122 use crate::execution_identity::{ExecutionResultOutcomeV1, EXECUTION_RESULT_RECEIPT_SCHEMA_V1};
1123
1124 fn identity(domain: &str) -> ExecutionIdentityV1 {
1125 ExecutionIdentityV1::derive(domain, &serde_json::json!({"request": "bounded"})).unwrap()
1126 }
1127
1128 fn receipt(identity: ExecutionIdentityV1) -> ExecutionResultReceiptV1 {
1129 ExecutionResultReceiptV1 {
1130 schema: EXECUTION_RESULT_RECEIPT_SCHEMA_V1.to_string(),
1131 identity,
1132 evidence_digest:
1133 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1134 .to_string(),
1135 outcome: ExecutionResultOutcomeV1::Succeeded,
1136 result_digest: Some(
1137 "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1138 .to_string(),
1139 ),
1140 result_bytes: 1,
1141 }
1142 }
1143
1144 #[test]
1145 fn legacy_claim_record_deserializes_without_identity_or_receipt() {
1146 let value = serde_json::json!({
1147 "request_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1148 "status": "pending",
1149 "owner_id": "owner-1",
1150 "lease_expires_at_ms": 101,
1151 "attempts": 1,
1152 "created_at_ms": 1
1153 });
1154 let record: DispatchClaimRecord = serde_json::from_value(value).unwrap();
1155 assert!(record.execution_identity.is_none());
1156 assert!(record.result_receipt.is_none());
1157 }
1158
1159 #[tokio::test]
1160 async fn identity_fences_claim_renewal_and_terminal_receipt() {
1161 let ledger = MemoryEvaluationDispatchLedger::new();
1162 let canonical = identity("a3s.test.identity");
1163 let wrong = identity("a3s.test.other");
1164 let request_digest =
1165 "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1166
1167 assert!(matches!(
1168 ledger
1169 .claim_with_identity("dispatch-1", request_digest, &canonical, "owner-1", 1, 100)
1170 .await
1171 .unwrap(),
1172 EvaluationDispatchClaimOutcome::Claimed { attempt: 1 }
1173 ));
1174 assert!(!ledger
1175 .renew_with_identity("dispatch-1", request_digest, &wrong, "owner-1", 2, 100)
1176 .await
1177 .unwrap());
1178 assert!(ledger
1179 .renew_with_identity("dispatch-1", request_digest, &canonical, "owner-1", 2, 100)
1180 .await
1181 .unwrap());
1182
1183 let result = receipt(canonical.clone());
1184 ledger
1185 .complete_with_receipt(
1186 "dispatch-1",
1187 request_digest,
1188 &canonical,
1189 "owner-1",
1190 &result,
1191 3,
1192 )
1193 .await
1194 .unwrap();
1195 assert_eq!(
1196 ledger.completed_receipt("dispatch-1").await.unwrap(),
1197 Some(result)
1198 );
1199 assert!(matches!(
1200 ledger
1201 .complete_with_receipt(
1202 "dispatch-1",
1203 request_digest,
1204 &wrong,
1205 "owner-1",
1206 &receipt(wrong.clone()),
1207 4,
1208 )
1209 .await,
1210 Err(EvaluationDispatchLedgerError::Conflict)
1211 ));
1212 }
1213}