1use std::collections::BTreeMap;
2use std::fs::{File, OpenOptions};
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::core::agents::{AgentRegistry, FileLock};
11use crate::core::ocla::types::{OclaError, OclaResult};
12
13const STORE_VERSION: u16 = 1;
14const MAX_ENTRIES_PER_SCOPE: usize = 1_000;
15const MAX_STORE_BYTES: u64 = 8 * 1024 * 1024;
16const MAX_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
17const MAX_IDENTIFIER_BYTES: usize = 512;
18pub(crate) const MAX_ERROR_BYTES: usize = 64 * 1024;
19const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
20
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields, rename_all = "snake_case")]
23pub enum DeadLetterDelivery {
24 LocalAgentBus,
25 RemoteHttp { endpoint_url: String },
26}
27
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DlqScope {
31 pub tenant_id: String,
32 pub project_id: String,
33}
34
35impl DlqScope {
36 pub fn new(tenant_id: impl Into<String>, project_id: impl Into<String>) -> OclaResult<Self> {
37 let scope = Self {
38 tenant_id: tenant_id.into(),
39 project_id: project_id.into(),
40 };
41 scope.validate()?;
42 Ok(scope)
43 }
44
45 fn validate(&self) -> OclaResult<()> {
46 validate_identifier("tenant_id", &self.tenant_id)?;
47 validate_identifier("project_id", &self.project_id)
48 }
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct DeadLetter {
54 pub id: String,
55 #[serde(default)]
56 pub peer_id: String,
57 #[serde(default)]
58 pub delivery_id: String,
59 pub tenant_id: String,
60 pub project_id: String,
61 pub delivery: DeadLetterDelivery,
62 pub original_message: String,
63 pub target_agent: String,
64 pub error: String,
65 pub attempts: u8,
66 pub first_failed_at: String,
67 pub last_failed_at: String,
68}
69
70impl DeadLetter {
71 fn validate(&self) -> OclaResult<()> {
72 validate_identifier("id", &self.id)?;
73 validate_identifier("peer_id", &self.peer_id)?;
74 validate_identifier("delivery_id", &self.delivery_id)?;
75 DlqScope::new(self.tenant_id.clone(), self.project_id.clone())?;
76 validate_identifier("target_agent", &self.target_agent)?;
77 if self.original_message.is_empty() || self.original_message.len() > MAX_MESSAGE_BYTES {
78 return Err(OclaError::InvalidRequest(
79 "dead letter message must be non-empty and bounded".into(),
80 ));
81 }
82 if self.error.is_empty() || self.error.len() > MAX_ERROR_BYTES {
83 return Err(OclaError::InvalidRequest(
84 "dead letter error must be non-empty and bounded".into(),
85 ));
86 }
87 if self.attempts == 0 {
88 return Err(OclaError::InvalidRequest(
89 "dead letter attempts must be greater than zero".into(),
90 ));
91 }
92 let first_failed_at = DateTime::parse_from_rfc3339(&self.first_failed_at)
93 .map_err(|_| OclaError::InvalidRequest("invalid first_failed_at".into()))?;
94 let last_failed_at = DateTime::parse_from_rfc3339(&self.last_failed_at)
95 .map_err(|_| OclaError::InvalidRequest("invalid last_failed_at".into()))?;
96 if last_failed_at < first_failed_at {
97 return Err(OclaError::InvalidRequest(
98 "last_failed_at must not precede first_failed_at".into(),
99 ));
100 }
101 if let DeadLetterDelivery::RemoteHttp { endpoint_url } = &self.delivery {
102 let url = reqwest::Url::parse(endpoint_url).map_err(|_| {
103 OclaError::InvalidRequest("invalid dead letter endpoint_url".into())
104 })?;
105 if !matches!(url.scheme(), "http" | "https")
106 || url.host_str().is_none()
107 || !url.username().is_empty()
108 || url.password().is_some()
109 {
110 return Err(OclaError::InvalidRequest(
111 "dead letter endpoint_url must be absolute HTTP(S)".into(),
112 ));
113 }
114 }
115 Ok(())
116 }
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120pub struct DlqStats {
121 pub total: usize,
122 pub max_scope_depth: usize,
123 pub oldest_age_seconds: u64,
124 pub by_target_agent: BTreeMap<String, usize>,
125}
126
127#[derive(Debug, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129struct DurableDlqV1 {
130 version: u16,
131 entries: Vec<DeadLetter>,
132}
133
134impl Default for DurableDlqV1 {
135 fn default() -> Self {
136 Self {
137 version: STORE_VERSION,
138 entries: Vec::new(),
139 }
140 }
141}
142
143#[derive(Clone)]
144enum QueueBackend {
145 Memory(Arc<Mutex<Vec<DeadLetter>>>),
146 Durable {
147 root: PathBuf,
148 process_lock: Arc<Mutex<()>>,
149 },
150 Unavailable(String),
151}
152
153#[derive(Clone)]
154pub struct DeadLetterQueue {
155 backend: QueueBackend,
156}
157
158pub struct DlqRetryGuard {
159 _lock: Option<FileLock>,
160}
161
162impl Default for DeadLetterQueue {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168impl DeadLetterQueue {
169 #[must_use]
170 pub fn new() -> Self {
171 Self {
172 backend: QueueBackend::Memory(Arc::new(Mutex::new(Vec::new()))),
173 }
174 }
175
176 pub fn persistent_default() -> std::io::Result<Self> {
177 let path = crate::core::data_dir::lean_ctx_data_dir()
178 .map_err(std::io::Error::other)?
179 .join("agents")
180 .join("a2a")
181 .join("dead-letters-v1.json");
182 Self::persistent(path)
183 }
184
185 pub fn persistent(path: impl AsRef<Path>) -> std::io::Result<Self> {
186 let path = path.as_ref();
187 let root = path.with_extension("d");
188 if path.exists() {
189 prepare_parent(&path)?;
190 prepare_store_directory(&root)?;
191 migrate_legacy_store(&path, &root)?;
192 } else {
193 for directory in [path.parent(), path.parent().and_then(Path::parent)]
194 .into_iter()
195 .flatten()
196 {
197 validate_directory(directory)?;
198 }
199 validate_directory(&root)?;
200 }
201 let queue = Self {
202 backend: QueueBackend::Durable {
203 root,
204 process_lock: Arc::new(Mutex::new(())),
205 },
206 };
207 Ok(queue)
208 }
209
210 #[must_use]
211 pub fn unavailable(error: impl Into<String>) -> Self {
212 Self {
213 backend: QueueBackend::Unavailable(error.into()),
214 }
215 }
216
217 pub fn enqueue(&self, letter: DeadLetter) -> OclaResult<()> {
218 letter.validate()?;
219 let scope = DlqScope::new(letter.tenant_id.clone(), letter.project_id.clone())?;
220 self.with_entries(Some(&scope), true, |entries| {
221 if let Some(existing) = entries.iter_mut().find(|entry| {
222 entry.peer_id == letter.peer_id
223 && entry.delivery_id == letter.delivery_id
224 && entry.tenant_id == letter.tenant_id
225 && entry.project_id == letter.project_id
226 }) {
227 existing.error = letter.error;
228 existing.attempts = existing.attempts.max(letter.attempts);
229 existing.last_failed_at = letter.last_failed_at;
230 return Ok(());
231 }
232 if entries
233 .iter()
234 .filter(|entry| {
235 entry.tenant_id == letter.tenant_id && entry.project_id == letter.project_id
236 })
237 .count()
238 >= MAX_ENTRIES_PER_SCOPE
239 && let Some(position) = entries.iter().position(|entry| {
240 entry.tenant_id == letter.tenant_id && entry.project_id == letter.project_id
241 })
242 {
243 entries.remove(position);
244 }
245 entries.push(letter);
246 while serialized_store_size(entries)? > MAX_STORE_BYTES {
247 if entries.len() == 1 {
248 return Err(OclaError::InvalidRequest(
249 "dead letter exceeds per-scope store size limit".into(),
250 ));
251 }
252 entries.remove(0);
253 }
254 Ok(())
255 })
256 }
257
258 pub fn dequeue(&self, scope: &DlqScope, id: &str) -> OclaResult<Option<DeadLetter>> {
259 scope.validate()?;
260 validate_identifier("id", id)?;
261 self.with_entries(Some(scope), true, |entries| {
262 let position = entries.iter().position(|letter| {
263 letter.id == id
264 && letter.tenant_id == scope.tenant_id
265 && letter.project_id == scope.project_id
266 });
267 Ok(position.map(|position| entries.remove(position)))
268 })
269 }
270
271 pub fn peek(&self, scope: &DlqScope) -> OclaResult<Vec<DeadLetter>> {
272 scope.validate()?;
273 self.with_entries(Some(scope), false, |entries| {
274 Ok(entries
275 .iter()
276 .filter(|letter| {
277 letter.tenant_id == scope.tenant_id && letter.project_id == scope.project_id
278 })
279 .cloned()
280 .collect())
281 })
282 }
283
284 pub fn get(&self, scope: &DlqScope, id: &str) -> OclaResult<Option<DeadLetter>> {
285 scope.validate()?;
286 validate_identifier("id", id)?;
287 self.with_entries(Some(scope), false, |entries| {
288 Ok(entries
289 .iter()
290 .find(|letter| {
291 letter.id == id
292 && letter.tenant_id == scope.tenant_id
293 && letter.project_id == scope.project_id
294 })
295 .cloned())
296 })
297 }
298
299 pub fn lock_retry(&self, scope: &DlqScope, id: &str) -> OclaResult<DlqRetryGuard> {
300 scope.validate()?;
301 validate_identifier("id", id)?;
302 match &self.backend {
303 QueueBackend::Durable { root, .. } => {
304 let path = scope_store_path(root, scope).with_extension("retry.lock");
308 let lock = FileLock::acquire_with_timeout(&path, LOCK_TIMEOUT)
309 .map_err(|error| store_error(&std::io::Error::other(error)))?;
310 Ok(DlqRetryGuard { _lock: Some(lock) })
311 }
312 QueueBackend::Memory(_) => Ok(DlqRetryGuard { _lock: None }),
313 QueueBackend::Unavailable(error) => Err(OclaError::InvalidRequest(format!(
314 "dead letter store unavailable: {error}"
315 ))),
316 }
317 }
318
319 pub fn retry(&self, scope: &DlqScope, id: &str) -> OclaResult<()> {
320 scope.validate()?;
321 validate_identifier("id", id)?;
322 let retry_error = self.with_entries(Some(scope), true, |entries| {
323 let position = entries
324 .iter()
325 .position(|letter| letter.id == id)
326 .ok_or_else(|| OclaError::InvalidRequest(format!("dead letter not found: {id}")))?;
327 if !matches!(
328 entries[position].delivery,
329 DeadLetterDelivery::LocalAgentBus
330 ) {
331 return Err(OclaError::InvalidRequest(
332 "remote dead letters require the configured authenticated transport".into(),
333 ));
334 }
335 match resend_local(&entries[position]) {
336 Ok(()) => {
337 entries.remove(position);
338 Ok(None)
339 }
340 Err(error) => {
341 entries[position].attempts = entries[position].attempts.saturating_add(1);
342 entries[position].last_failed_at = Utc::now().to_rfc3339();
343 Ok(Some(error))
344 }
345 }
346 })?;
347 retry_error.map_or(Ok(()), Err)
348 }
349
350 pub fn stats(&self, scope: Option<&DlqScope>) -> OclaResult<DlqStats> {
351 if let Some(scope) = scope {
352 scope.validate()?;
353 }
354 self.with_entries(scope, false, |entries| {
355 let now = Utc::now();
356 let mut oldest_age_seconds = 0;
357 let mut by_target_agent = BTreeMap::new();
358 let mut by_scope = BTreeMap::new();
359 let mut total = 0;
360 for letter in entries.iter().filter(|letter| {
361 scope.is_none_or(|scope| {
362 letter.tenant_id == scope.tenant_id && letter.project_id == scope.project_id
363 })
364 }) {
365 total += 1;
366 *by_scope
367 .entry((&letter.tenant_id, &letter.project_id))
368 .or_insert(0_usize) += 1;
369 *by_target_agent
370 .entry(letter.target_agent.clone())
371 .or_insert(0) += 1;
372 if let Ok(failed_at) = DateTime::parse_from_rfc3339(&letter.first_failed_at) {
373 let age = u64::try_from((now - failed_at.with_timezone(&Utc)).num_seconds())
374 .unwrap_or(0);
375 oldest_age_seconds = oldest_age_seconds.max(age);
376 }
377 }
378 Ok(DlqStats {
379 total,
380 max_scope_depth: by_scope.values().copied().max().unwrap_or(0),
381 oldest_age_seconds,
382 by_target_agent,
383 })
384 })
385 }
386
387 pub fn ensure_writable(&self, scope: &DlqScope) -> OclaResult<()> {
388 scope.validate()?;
389 match &self.backend {
390 QueueBackend::Memory(_) => Ok(()),
391 QueueBackend::Durable { root, process_lock } => {
392 let _process_guard = process_lock
393 .lock()
394 .unwrap_or_else(std::sync::PoisonError::into_inner);
395 prepare_store_directory(root).map_err(|error| store_error(&error))?;
396 let path = scope_store_path(root, scope);
397 let _lock =
398 FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
399 .map_err(|error| store_error(&std::io::Error::other(error)))?;
400 load_store(&path, Some(scope)).map_err(|error| store_error(&error))?;
401 probe_store_directory(root).map_err(|error| store_error(&error))
402 }
403 QueueBackend::Unavailable(error) => Err(OclaError::InvalidRequest(format!(
404 "dead letter store unavailable: {error}"
405 ))),
406 }
407 }
408
409 fn with_entries<R>(
410 &self,
411 scope: Option<&DlqScope>,
412 mutate: bool,
413 operation: impl FnOnce(&mut Vec<DeadLetter>) -> OclaResult<R>,
414 ) -> OclaResult<R> {
415 match &self.backend {
416 QueueBackend::Memory(entries) => {
417 let mut entries = entries
418 .lock()
419 .unwrap_or_else(std::sync::PoisonError::into_inner);
420 operation(&mut entries)
421 }
422 QueueBackend::Durable { root, process_lock } => {
423 let _process_guard = process_lock
424 .lock()
425 .unwrap_or_else(std::sync::PoisonError::into_inner);
426 if !root.exists() && !mutate {
427 return operation(&mut Vec::new());
428 }
429 prepare_store_directory(root).map_err(|error| store_error(&error))?;
430 if let Some(scope) = scope {
431 let path = scope_store_path(root, scope);
432 let _lock =
433 FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
434 .map_err(|error| store_error(&std::io::Error::other(error)))?;
435 let mut store =
436 load_store(&path, Some(scope)).map_err(|error| store_error(&error))?;
437 let result = operation(&mut store.entries)?;
438 if mutate {
439 save_store(&path, &store).map_err(|error| store_error(&error))?;
440 }
441 Ok(result)
442 } else {
443 if mutate {
444 return Err(OclaError::InvalidRequest(
445 "unscoped durable DLQ mutation is forbidden".into(),
446 ));
447 }
448 let mut entries =
449 load_all_scope_entries(root).map_err(|error| store_error(&error))?;
450 operation(&mut entries)
451 }
452 }
453 QueueBackend::Unavailable(error) => Err(OclaError::InvalidRequest(format!(
454 "dead letter store unavailable: {error}"
455 ))),
456 }
457 }
458}
459
460fn validate_identifier(label: &str, value: &str) -> OclaResult<()> {
461 if value.trim().is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.contains('\0') {
462 return Err(OclaError::InvalidRequest(format!("invalid {label}")));
463 }
464 Ok(())
465}
466
467fn store_error(error: &std::io::Error) -> OclaError {
468 OclaError::InvalidRequest(format!("dead letter store unavailable: {error}"))
469}
470
471fn resend_local(letter: &DeadLetter) -> OclaResult<()> {
472 AgentRegistry::mutate_locked(|registry| {
473 registry
474 .post_message(
475 "dead-letter-queue",
476 Some(&letter.target_agent),
477 "retry",
478 &letter.original_message,
479 )
480 .map(|_| ())
481 })
482 .and_then(|(_, result)| result)
483 .map_err(|error| OclaError::InvalidRequest(format!("dead letter retry failed: {error}")))
484}
485
486fn prepare_parent(path: &Path) -> std::io::Result<()> {
487 let parent = path.parent().ok_or_else(|| {
488 std::io::Error::new(std::io::ErrorKind::InvalidInput, "DLQ store has no parent")
489 })?;
490 #[cfg(unix)] let parent_existed = parent.exists();
492 for directory in [Some(parent), parent.parent()].into_iter().flatten() {
493 validate_directory(directory)?;
494 }
495 std::fs::create_dir_all(parent)?;
496 for directory in [Some(parent), parent.parent()].into_iter().flatten() {
497 validate_directory(directory)?;
498 }
499 #[cfg(unix)]
500 if !parent_existed {
501 use std::os::unix::fs::PermissionsExt;
502 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
503 }
504 Ok(())
505}
506
507fn validate_directory(path: &Path) -> std::io::Result<()> {
508 match std::fs::symlink_metadata(path) {
509 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
510 Err(std::io::Error::new(
511 std::io::ErrorKind::InvalidData,
512 "DLQ path components must be real directories",
513 ))
514 }
515 Ok(_) => Ok(()),
516 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
517 Err(error) => Err(error),
518 }
519}
520
521fn prepare_store_directory(root: &Path) -> std::io::Result<()> {
522 let parent = root.parent().ok_or_else(|| {
523 std::io::Error::new(std::io::ErrorKind::InvalidInput, "DLQ root has no parent")
524 })?;
525 validate_directory(parent)?;
526 validate_directory(root)?;
527 std::fs::create_dir_all(root)?;
528 validate_directory(root)?;
529 #[cfg(unix)]
530 {
531 use std::os::unix::fs::MetadataExt;
532 let metadata = std::fs::metadata(root)?;
533 if metadata.uid() != unsafe { libc::geteuid() } {
535 return Err(std::io::Error::new(
536 std::io::ErrorKind::PermissionDenied,
537 "DLQ store directory must be owned by the current user",
538 ));
539 }
540 crate::core::secure_fs::restrict_to_owner(root)?;
541 }
542 Ok(())
543}
544
545fn scope_store_path(root: &Path, scope: &DlqScope) -> PathBuf {
546 let mut material = Vec::with_capacity(scope.tenant_id.len() + scope.project_id.len() + 1);
547 material.extend_from_slice(scope.tenant_id.as_bytes());
548 material.push(0);
549 material.extend_from_slice(scope.project_id.as_bytes());
550 root.join(format!("{}.json", blake3::hash(&material).to_hex()))
551}
552
553fn serialized_store_size(entries: &[DeadLetter]) -> OclaResult<u64> {
554 serde_json::to_vec_pretty(&DurableDlqV1 {
555 version: STORE_VERSION,
556 entries: entries.to_vec(),
557 })
558 .map(|json| json.len() as u64)
559 .map_err(|error| OclaError::InvalidRequest(format!("failed to size dead letters: {error}")))
560}
561
562fn load_all_scope_entries(root: &Path) -> std::io::Result<Vec<DeadLetter>> {
563 let mut entries = Vec::new();
564 for item in std::fs::read_dir(root)? {
565 let item = item?;
566 let file_type = item.file_type()?;
567 let name = item.file_name();
568 let name = name.to_str().ok_or_else(|| {
569 std::io::Error::new(
570 std::io::ErrorKind::InvalidData,
571 "invalid DLQ store filename",
572 )
573 })?;
574 if item.path().extension() == Some(std::ffi::OsStr::new("lock"))
575 || name == "migration-v1.complete"
576 {
577 continue;
578 }
579 if file_type.is_file() && is_scope_store_temp_name(name) {
580 continue;
581 }
582 if file_type.is_symlink() || !file_type.is_file() || !is_scope_store_name(name) {
583 return Err(std::io::Error::new(
584 std::io::ErrorKind::InvalidData,
585 "DLQ root contains an unexpected entry",
586 ));
587 }
588 let store = load_store(&item.path(), None)?;
589 if let Some(first) = store.entries.first() {
590 let scope = DlqScope::new(first.tenant_id.clone(), first.project_id.clone())
591 .map_err(std::io::Error::other)?;
592 if scope_store_path(root, &scope) != item.path()
593 || store.entries.iter().any(|entry| {
594 entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
595 })
596 {
597 return Err(std::io::Error::new(
598 std::io::ErrorKind::InvalidData,
599 "DLQ scope store identity does not match its contents",
600 ));
601 }
602 }
603 entries.extend(store.entries);
604 }
605 Ok(entries)
606}
607
608fn is_scope_store_name(name: &str) -> bool {
609 let path = Path::new(name);
610 path.extension() == Some(std::ffi::OsStr::new("json"))
611 && path.file_stem().is_some_and(|stem| {
612 let bytes = stem.as_encoded_bytes();
613 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
614 })
615}
616
617fn is_scope_store_temp_name(name: &str) -> bool {
618 let Some((store_name, suffix)) = name.split_once(".tmp-") else {
619 return false;
620 };
621 !suffix.is_empty()
622 && suffix
623 .bytes()
624 .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
625 && store_name.len() == 64
626 && store_name.bytes().all(|byte| byte.is_ascii_hexdigit())
627}
628
629fn probe_store_directory(root: &Path) -> std::io::Result<()> {
630 let path = root.join(format!("writable-{}.lock", uuid::Uuid::new_v4()));
631 let result = (|| {
632 let mut options = OpenOptions::new();
633 options.write(true).create_new(true);
634 #[cfg(unix)]
635 {
636 use std::os::unix::fs::OpenOptionsExt;
637 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
638 }
639 #[cfg(windows)]
640 {
641 use std::os::windows::fs::OpenOptionsExt;
642 options.custom_flags(0x0020_0000);
643 }
644 options.open(&path)?.sync_all()?;
645 std::fs::remove_file(&path)
646 })();
647 if result.is_err() {
648 let _ = std::fs::remove_file(&path);
649 }
650 result
651}
652
653fn migrate_legacy_store(legacy_path: &Path, root: &Path) -> std::io::Result<()> {
654 if !legacy_path.exists() || root.join("migration-v1.complete").exists() {
655 return Ok(());
656 }
657 let _migration_lock =
658 FileLock::acquire_with_timeout(&root.join("migration-v1.lock"), LOCK_TIMEOUT)
659 .map_err(std::io::Error::other)?;
660 let marker = root.join("migration-v1.complete");
661 if marker.exists() {
662 return Ok(());
663 }
664 let legacy = load_store(legacy_path, None)?;
665 let mut grouped: BTreeMap<(String, String), Vec<DeadLetter>> = BTreeMap::new();
666 for letter in legacy.entries {
667 grouped
668 .entry((letter.tenant_id.clone(), letter.project_id.clone()))
669 .or_default()
670 .push(letter);
671 }
672 for ((tenant_id, project_id), letters) in grouped {
673 let scope = DlqScope::new(tenant_id, project_id).map_err(std::io::Error::other)?;
674 let path = scope_store_path(root, &scope);
675 let _scope_lock =
676 FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
677 .map_err(std::io::Error::other)?;
678 let mut current = load_store(&path, Some(&scope))?;
679 for letter in letters {
680 if let Some(existing) = current
681 .entries
682 .iter_mut()
683 .find(|entry| entry.id == letter.id)
684 {
685 if letter.last_failed_at > existing.last_failed_at {
686 *existing = letter;
687 }
688 } else {
689 current.entries.push(letter);
690 }
691 }
692 if current.entries.len() > MAX_ENTRIES_PER_SCOPE {
693 let excess = current.entries.len() - MAX_ENTRIES_PER_SCOPE;
694 current.entries.drain(..excess);
695 }
696 while serialized_store_size(¤t.entries).map_err(std::io::Error::other)?
697 > MAX_STORE_BYTES
698 {
699 if current.entries.is_empty() {
700 break;
701 }
702 current.entries.remove(0);
703 }
704 save_store(&path, ¤t)?;
705 }
706 write_marker(&marker)?;
707 let backup = legacy_path.with_extension("json.migrated-v1");
708 if !backup.exists() {
709 std::fs::rename(legacy_path, backup)?;
710 sync_parent(legacy_path)?;
711 }
712 Ok(())
713}
714
715fn write_marker(path: &Path) -> std::io::Result<()> {
716 let mut options = OpenOptions::new();
717 options.write(true).create_new(true);
718 #[cfg(unix)]
719 {
720 use std::os::unix::fs::OpenOptionsExt;
721 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
722 }
723 #[cfg(windows)]
724 {
725 use std::os::windows::fs::OpenOptionsExt;
726 options.custom_flags(0x0020_0000);
727 }
728 let mut file = options.open(path)?;
729 file.write_all(b"v1\n")?;
730 file.sync_all()?;
731 sync_parent(path)
732}
733
734fn load_store(path: &Path, expected_scope: Option<&DlqScope>) -> std::io::Result<DurableDlqV1> {
735 let metadata = match std::fs::symlink_metadata(path) {
736 Ok(metadata) => metadata,
737 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
738 return Ok(DurableDlqV1::default());
739 }
740 Err(error) => return Err(error),
741 };
742 if metadata.file_type().is_symlink() || !metadata.is_file() {
743 return Err(std::io::Error::new(
744 std::io::ErrorKind::InvalidData,
745 "DLQ store must be a regular file",
746 ));
747 }
748 if metadata.len() > MAX_STORE_BYTES {
749 return Err(std::io::Error::new(
750 std::io::ErrorKind::InvalidData,
751 "DLQ store exceeds size limit",
752 ));
753 }
754 let mut content = Vec::new();
755 open_read_nofollow(path)?
756 .take(MAX_STORE_BYTES + 1)
757 .read_to_end(&mut content)?;
758 if content.len() as u64 > MAX_STORE_BYTES {
759 return Err(std::io::Error::new(
760 std::io::ErrorKind::InvalidData,
761 "DLQ store exceeds size limit",
762 ));
763 }
764 let mut store: DurableDlqV1 =
765 serde_json::from_slice(&content).map_err(std::io::Error::other)?;
766 if store.version != STORE_VERSION || store.entries.len() > MAX_ENTRIES_PER_SCOPE {
767 return Err(std::io::Error::new(
768 std::io::ErrorKind::InvalidData,
769 "invalid DLQ store bounds or version",
770 ));
771 }
772 for entry in &mut store.entries {
773 if entry.peer_id.is_empty() {
776 entry.peer_id = "legacy".to_string();
777 }
778 if entry.delivery_id.is_empty() {
779 entry.delivery_id = entry.id.clone();
780 }
781 entry
782 .validate()
783 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
784 if expected_scope.is_some_and(|scope| {
785 entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
786 }) {
787 return Err(std::io::Error::new(
788 std::io::ErrorKind::InvalidData,
789 "DLQ scope store contains an entry from another scope",
790 ));
791 }
792 }
793 Ok(store)
794}
795
796fn save_store(path: &Path, store: &DurableDlqV1) -> std::io::Result<()> {
797 if let Ok(metadata) = std::fs::symlink_metadata(path)
798 && (metadata.file_type().is_symlink() || !metadata.is_file())
799 {
800 return Err(std::io::Error::new(
801 std::io::ErrorKind::InvalidData,
802 "DLQ store must be a regular file",
803 ));
804 }
805 let json = serde_json::to_vec_pretty(store).map_err(std::io::Error::other)?;
806 if json.len() as u64 > MAX_STORE_BYTES {
807 return Err(std::io::Error::new(
808 std::io::ErrorKind::InvalidData,
809 "DLQ store exceeds size limit",
810 ));
811 }
812 let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
813 let result = (|| {
814 let mut options = OpenOptions::new();
815 options.write(true).create_new(true);
816 #[cfg(unix)]
817 {
818 use std::os::unix::fs::OpenOptionsExt;
819 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
820 }
821 #[cfg(windows)]
822 {
823 use std::os::windows::fs::OpenOptionsExt;
824 options.custom_flags(0x0020_0000);
825 }
826 let mut file = options.open(&tmp)?;
827 file.write_all(&json)?;
828 file.sync_all()?;
829 drop(file);
830 atomic_replace(&tmp, path)?;
831 sync_parent(path)
832 })();
833 if result.is_err() {
834 let _ = std::fs::remove_file(&tmp);
835 }
836 result
837}
838
839fn open_read_nofollow(path: &Path) -> std::io::Result<File> {
840 let mut options = OpenOptions::new();
841 options.read(true);
842 #[cfg(unix)]
843 {
844 use std::os::unix::fs::OpenOptionsExt;
845 options.custom_flags(libc::O_NOFOLLOW);
846 }
847 #[cfg(windows)]
848 {
849 use std::os::windows::fs::OpenOptionsExt;
850 options.custom_flags(0x0020_0000);
851 }
852 options.open(path)
853}
854
855#[cfg(not(windows))]
856fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
857 std::fs::rename(source, destination)
858}
859
860#[cfg(windows)]
861fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
862 use std::os::windows::ffi::OsStrExt;
863 use windows_sys::Win32::Storage::FileSystem::{
864 MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
865 };
866
867 let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
868 let destination: Vec<u16> = destination
869 .as_os_str()
870 .encode_wide()
871 .chain(Some(0))
872 .collect();
873 let result = unsafe {
876 MoveFileExW(
877 source.as_ptr(),
878 destination.as_ptr(),
879 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
880 )
881 };
882 if result == 0 {
883 Err(std::io::Error::last_os_error())
884 } else {
885 Ok(())
886 }
887}
888
889fn sync_parent(path: &Path) -> std::io::Result<()> {
890 #[cfg(unix)]
891 if let Some(parent) = path.parent() {
892 File::open(parent)?.sync_all()?;
893 }
894 #[cfg(windows)]
899 let _ = path;
900 Ok(())
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906
907 fn scope(tenant: &str, project: &str) -> DlqScope {
908 DlqScope::new(tenant, project).unwrap()
909 }
910 fn letter(id: &str, tenant: &str, project: &str) -> DeadLetter {
911 DeadLetter {
912 id: id.into(),
913 peer_id: "legacy".into(),
914 delivery_id: id.into(),
915 tenant_id: tenant.into(),
916 project_id: project.into(),
917 delivery: DeadLetterDelivery::LocalAgentBus,
918 original_message: format!("message-{id}"),
919 target_agent: "agent-a".into(),
920 error: "delivery failed".into(),
921 attempts: 1,
922 first_failed_at: "2026-01-01T00:00:00Z".into(),
923 last_failed_at: "2026-01-01T00:00:00Z".into(),
924 }
925 }
926
927 #[test]
928 fn scoped_operations_do_not_cross_tenant_or_project() {
929 let queue = DeadLetterQueue::new();
930 queue
931 .enqueue(letter("one", "tenant-a", "project-a"))
932 .unwrap();
933 queue
934 .enqueue(letter("one", "tenant-b", "project-a"))
935 .unwrap();
936 assert_eq!(
937 queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
938 1
939 );
940 assert!(
941 queue
942 .dequeue(&scope("tenant-a", "project-b"), "one")
943 .unwrap()
944 .is_none()
945 );
946 assert_eq!(queue.stats(None).unwrap().total, 2);
947 }
948
949 #[test]
950 fn persistent_queue_survives_reopen_and_deduplicates() {
951 let dir = tempfile::tempdir().unwrap();
952 let path = dir.path().join("a2a").join("dead-letters-v1.json");
953 let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
954 queue
955 .enqueue(letter("one", "tenant-a", "project-a"))
956 .unwrap();
957 queue
958 .enqueue(letter("one", "tenant-a", "project-a"))
959 .unwrap();
960 drop(queue);
961 assert_eq!(
962 DeadLetterQueue::persistent(path)
963 .unwrap()
964 .peek(&scope("tenant-a", "project-a"))
965 .unwrap()
966 .len(),
967 1
968 );
969 }
970
971 #[test]
972 fn concurrent_persistent_enqueues_do_not_lose_entries() {
973 let dir = tempfile::tempdir().unwrap();
974 let path = dir.path().join("a2a").join("dead-letters-v1.json");
975 let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
976 let workers: Vec<_> = (0..8)
977 .map(|worker| {
978 let queue = queue.clone();
979 std::thread::spawn(move || {
980 for item in 0..25 {
981 queue
982 .enqueue(letter(
983 &format!("worker-{worker}-item-{item}"),
984 "tenant-a",
985 "project-a",
986 ))
987 .unwrap();
988 }
989 })
990 })
991 .collect();
992 for worker in workers {
993 worker.join().unwrap();
994 }
995 drop(queue);
996 assert_eq!(
997 DeadLetterQueue::persistent(path)
998 .unwrap()
999 .peek(&scope("tenant-a", "project-a"))
1000 .unwrap()
1001 .len(),
1002 200
1003 );
1004 }
1005
1006 #[test]
1007 fn corrupt_oversized_and_symlink_stores_fail_closed() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let corrupt = dir.path().join("corrupt.json");
1010 std::fs::write(&corrupt, b"not json").unwrap();
1011 assert!(DeadLetterQueue::persistent(corrupt).is_err());
1012 let oversized = dir.path().join("oversized.json");
1013 std::fs::write(&oversized, vec![b'x'; (MAX_STORE_BYTES + 1) as usize]).unwrap();
1014 assert!(DeadLetterQueue::persistent(oversized).is_err());
1015 #[cfg(unix)]
1016 {
1017 use std::os::unix::fs::symlink;
1018 let target = dir.path().join("target.json");
1019 std::fs::write(&target, b"{}").unwrap();
1020 let link = dir.path().join("link.json");
1021 symlink(&target, &link).unwrap();
1022 assert!(DeadLetterQueue::persistent(link).is_err());
1023 }
1024 }
1025
1026 #[test]
1027 fn remote_entries_cannot_be_retried_as_local_messages() {
1028 let queue = DeadLetterQueue::new();
1029 let mut item = letter("one", "tenant-a", "project-a");
1030 item.delivery = DeadLetterDelivery::RemoteHttp {
1031 endpoint_url: "https://agent.example".into(),
1032 };
1033 queue.enqueue(item).unwrap();
1034 assert!(queue.retry(&scope("tenant-a", "project-a"), "one").is_err());
1035 assert_eq!(queue.stats(None).unwrap().total, 1);
1036 }
1037
1038 #[test]
1039 fn invalid_attempts_time_order_and_endpoint_credentials_are_rejected() {
1040 let queue = DeadLetterQueue::new();
1041 let mut zero_attempts = letter("zero", "tenant-a", "project-a");
1042 zero_attempts.attempts = 0;
1043 assert!(queue.enqueue(zero_attempts).is_err());
1044
1045 let mut reversed = letter("reversed", "tenant-a", "project-a");
1046 reversed.last_failed_at = "2025-01-01T00:00:00Z".into();
1047 assert!(queue.enqueue(reversed).is_err());
1048
1049 let mut credential_url = letter("credentials", "tenant-a", "project-a");
1050 credential_url.delivery = DeadLetterDelivery::RemoteHttp {
1051 endpoint_url: "https://user:secret@agent.example".into(),
1052 };
1053 assert!(queue.enqueue(credential_url).is_err());
1054 assert_eq!(queue.stats(None).unwrap().total, 0);
1055 }
1056
1057 #[test]
1058 fn per_scope_capacity_never_evicts_another_scope() {
1059 let queue = DeadLetterQueue::new();
1060 queue
1061 .enqueue(letter("protected", "tenant-b", "project-b"))
1062 .unwrap();
1063 for index in 0..=MAX_ENTRIES_PER_SCOPE {
1064 queue
1065 .enqueue(letter(&format!("a-{index}"), "tenant-a", "project-a"))
1066 .unwrap();
1067 }
1068 let protected = queue.peek(&scope("tenant-b", "project-b")).unwrap();
1069 assert_eq!(protected.len(), 1);
1070 assert_eq!(protected[0].id, "protected");
1071 let tenant_a = queue.peek(&scope("tenant-a", "project-a")).unwrap();
1072 assert_eq!(tenant_a.len(), MAX_ENTRIES_PER_SCOPE);
1073 assert_eq!(tenant_a.first().unwrap().id, "a-1");
1074 }
1075
1076 #[test]
1077 fn durable_scopes_use_distinct_files_and_byte_eviction_is_local() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let legacy = dir.path().join("dead-letters-v1.json");
1080 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1081 let mut protected = letter("protected", "tenant-b", "project-b");
1082 protected.original_message = "b".repeat(MAX_MESSAGE_BYTES);
1083 queue.enqueue(protected).unwrap();
1084 for index in 0..5 {
1085 let mut item = letter(&format!("large-{index}"), "tenant-a", "project-a");
1086 item.original_message = "a".repeat(MAX_MESSAGE_BYTES);
1087 queue.enqueue(item).unwrap();
1088 }
1089
1090 let root = legacy.with_extension("d");
1091 let scope_a = scope("tenant-a", "project-a");
1092 let scope_b = scope("tenant-b", "project-b");
1093 assert_ne!(
1094 scope_store_path(&root, &scope_a),
1095 scope_store_path(&root, &scope_b)
1096 );
1097 assert_eq!(queue.peek(&scope_b).unwrap().len(), 1);
1098 let tenant_a = queue.peek(&scope_a).unwrap();
1099 assert!(!tenant_a.is_empty(), "`tenant_a` is empty");
1100 assert!(tenant_a.len() < 5);
1101 assert!(
1102 std::fs::metadata(scope_store_path(&root, &scope_a))
1103 .unwrap()
1104 .len()
1105 <= MAX_STORE_BYTES
1106 );
1107 }
1108
1109 #[test]
1110 fn compact_legacy_boundary_is_readable_and_next_enqueue_uses_writer_size() {
1111 let dir = tempfile::tempdir().unwrap();
1112 let legacy = dir.path().join("dead-letters-v1.json");
1113 let root = legacy.with_extension("d");
1114 let scope = scope("tenant-a", "project-a");
1115 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1116 prepare_store_directory(&root).unwrap();
1117
1118 let mut entries = (0..MAX_ENTRIES_PER_SCOPE)
1119 .map(|index| {
1120 let mut item = letter(&format!("boundary-{index}"), "tenant-a", "project-a");
1121 item.original_message = "x".repeat(8_200);
1122 item
1123 })
1124 .collect::<Vec<_>>();
1125 while serde_json::to_vec(&DurableDlqV1 {
1126 version: STORE_VERSION,
1127 entries: entries.clone(),
1128 })
1129 .unwrap()
1130 .len() as u64
1131 > MAX_STORE_BYTES
1132 {
1133 entries.remove(0);
1134 }
1135 let compact = serde_json::to_vec(&DurableDlqV1 {
1136 version: STORE_VERSION,
1137 entries: entries.clone(),
1138 })
1139 .unwrap();
1140 assert!(compact.len() as u64 <= MAX_STORE_BYTES);
1141 assert!(
1142 serde_json::to_vec_pretty(&DurableDlqV1 {
1143 version: STORE_VERSION,
1144 entries,
1145 })
1146 .unwrap()
1147 .len() as u64
1148 > MAX_STORE_BYTES
1149 );
1150 std::fs::write(scope_store_path(&root, &scope), compact).unwrap();
1151
1152 queue.ensure_writable(&scope).unwrap();
1153 queue
1154 .enqueue(letter("after-boundary", "tenant-a", "project-a"))
1155 .unwrap();
1156 let stored = std::fs::metadata(scope_store_path(&root, &scope)).unwrap();
1157 assert!(stored.len() <= MAX_STORE_BYTES);
1158 assert!(queue.get(&scope, "after-boundary").unwrap().is_some());
1159 }
1160
1161 #[test]
1162 fn aggregate_stats_ignore_regular_interrupted_write_artifacts() {
1163 let dir = tempfile::tempdir().unwrap();
1164 let legacy = dir.path().join("dead-letters-v1.json");
1165 let scope = scope("tenant-a", "project-a");
1166 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1167 queue
1168 .enqueue(letter("good", "tenant-a", "project-a"))
1169 .unwrap();
1170 let store = scope_store_path(&legacy.with_extension("d"), &scope);
1171 let tmp = store.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
1172 std::fs::write(&tmp, b"partial").unwrap();
1173
1174 assert_eq!(queue.stats(None).unwrap().total, 1);
1175 std::fs::remove_file(tmp).unwrap();
1176 }
1177
1178 #[cfg(unix)]
1179 #[test]
1180 fn persistent_store_repairs_preexisting_directory_permissions() {
1181 use std::os::unix::fs::PermissionsExt;
1182
1183 let dir = tempfile::tempdir().unwrap();
1184 let legacy = dir.path().join("dead-letters-v1.json");
1185 let root = legacy.with_extension("d");
1186 std::fs::create_dir(&root).unwrap();
1187 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap();
1188
1189 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1190 queue
1191 .enqueue(letter("private", "tenant-a", "project-a"))
1192 .unwrap();
1193 assert_eq!(
1194 std::fs::metadata(root).unwrap().permissions().mode() & 0o777,
1195 0o700
1196 );
1197 }
1198
1199 #[test]
1200 fn legacy_global_store_migrates_without_cross_scope_cohabitation() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let legacy = dir.path().join("dead-letters-v1.json");
1203 save_store(
1204 &legacy,
1205 &DurableDlqV1 {
1206 version: STORE_VERSION,
1207 entries: vec![
1208 letter("a", "tenant-a", "project-a"),
1209 letter("b", "tenant-b", "project-b"),
1210 ],
1211 },
1212 )
1213 .unwrap();
1214
1215 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1216 assert!(!legacy.exists());
1217 assert_eq!(
1218 queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
1219 1
1220 );
1221 assert_eq!(
1222 queue.peek(&scope("tenant-b", "project-b")).unwrap().len(),
1223 1
1224 );
1225 let stores = std::fs::read_dir(legacy.with_extension("d"))
1226 .unwrap()
1227 .filter_map(Result::ok)
1228 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
1229 .count();
1230 assert_eq!(stores, 2);
1231 }
1232
1233 #[test]
1234 fn corrupt_scope_does_not_block_another_scope_or_reopen() {
1235 let dir = tempfile::tempdir().unwrap();
1236 let legacy = dir.path().join("dead-letters-v1.json");
1237 let scope_a = scope("tenant-a", "project-a");
1238 let scope_b = scope("tenant-b", "project-b");
1239 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1240 queue
1241 .enqueue(letter("good", "tenant-a", "project-a"))
1242 .unwrap();
1243 queue
1244 .enqueue(letter("bad", "tenant-b", "project-b"))
1245 .unwrap();
1246 std::fs::write(
1247 scope_store_path(&legacy.with_extension("d"), &scope_b),
1248 b"not json",
1249 )
1250 .unwrap();
1251
1252 let reopened = DeadLetterQueue::persistent(legacy).unwrap();
1253 assert_eq!(reopened.peek(&scope_a).unwrap()[0].id, "good");
1254 assert!(reopened.peek(&scope_b).is_err());
1255 }
1256
1257 #[test]
1258 fn read_only_stats_does_not_create_store_directory() {
1259 let dir = tempfile::tempdir().unwrap();
1260 let legacy = dir.path().join("missing").join("dead-letters-v1.json");
1261 let root = legacy.with_extension("d");
1262 let queue = DeadLetterQueue::persistent(legacy).unwrap();
1263
1264 assert_eq!(queue.stats(None).unwrap().total, 0);
1265 assert!(!root.exists());
1266 }
1267
1268 #[test]
1269 fn aggregate_stats_report_worst_scope_depth() {
1270 let queue = DeadLetterQueue::new();
1271 queue
1272 .enqueue(letter("a1", "tenant-a", "project-a"))
1273 .unwrap();
1274 queue
1275 .enqueue(letter("a2", "tenant-a", "project-a"))
1276 .unwrap();
1277 queue
1278 .enqueue(letter("b1", "tenant-b", "project-b"))
1279 .unwrap();
1280
1281 let stats = queue.stats(None).unwrap();
1282 assert_eq!(stats.total, 3);
1283 assert_eq!(stats.max_scope_depth, 2);
1284 assert_eq!(
1285 queue
1286 .stats(Some(&scope("tenant-b", "project-b")))
1287 .unwrap()
1288 .max_scope_depth,
1289 1
1290 );
1291 }
1292
1293 #[test]
1294 fn successful_local_retry_atomically_persists_removal() {
1295 let _data = crate::core::data_dir::isolated_data_dir();
1298 let dir = tempfile::tempdir().unwrap();
1299 let legacy = dir.path().join("dead-letters-v1.json");
1300 let scope = scope("tenant-a", "project-a");
1301 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1302 let mut item = letter("retry", "tenant-a", "project-a");
1303 item.target_agent = "retry-target".into();
1304 queue.enqueue(item).unwrap();
1305
1306 queue.retry(&scope, "retry").unwrap();
1307 drop(queue);
1308 let stored = DeadLetterQueue::persistent(legacy)
1309 .unwrap()
1310 .peek(&scope)
1311 .unwrap();
1312 assert!(stored.is_empty(), "{stored:?}");
1313 }
1314
1315 #[test]
1316 fn retry_lock_artifacts_are_bounded_per_scope() {
1317 let dir = tempfile::tempdir().unwrap();
1318 let legacy = dir.path().join("dead-letters-v1.json");
1319 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1320 let scope_a = scope("tenant-a", "project-a");
1321 let scope_b = scope("tenant-b", "project-b");
1322 queue
1323 .enqueue(letter("first", "tenant-a", "project-a"))
1324 .unwrap();
1325 queue
1326 .enqueue(letter("third", "tenant-b", "project-b"))
1327 .unwrap();
1328
1329 drop(queue.lock_retry(&scope_a, "first").unwrap());
1330 drop(queue.lock_retry(&scope_a, "second").unwrap());
1331 drop(queue.lock_retry(&scope_b, "third").unwrap());
1332
1333 let retry_locks = std::fs::read_dir(legacy.with_extension("d"))
1334 .unwrap()
1335 .filter_map(Result::ok)
1336 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".retry.lock"))
1337 .count();
1338 assert_eq!(retry_locks, 2);
1339 }
1340}