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 use std::os::unix::fs::PermissionsExt;
533 let metadata = std::fs::metadata(root)?;
534 if metadata.uid() != unsafe { libc::geteuid() } {
536 return Err(std::io::Error::new(
537 std::io::ErrorKind::PermissionDenied,
538 "DLQ store directory must be owned by the current user",
539 ));
540 }
541 std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700))?;
542 }
543 Ok(())
544}
545
546fn scope_store_path(root: &Path, scope: &DlqScope) -> PathBuf {
547 let mut material = Vec::with_capacity(scope.tenant_id.len() + scope.project_id.len() + 1);
548 material.extend_from_slice(scope.tenant_id.as_bytes());
549 material.push(0);
550 material.extend_from_slice(scope.project_id.as_bytes());
551 root.join(format!("{}.json", blake3::hash(&material).to_hex()))
552}
553
554fn serialized_store_size(entries: &[DeadLetter]) -> OclaResult<u64> {
555 serde_json::to_vec_pretty(&DurableDlqV1 {
556 version: STORE_VERSION,
557 entries: entries.to_vec(),
558 })
559 .map(|json| json.len() as u64)
560 .map_err(|error| OclaError::InvalidRequest(format!("failed to size dead letters: {error}")))
561}
562
563fn load_all_scope_entries(root: &Path) -> std::io::Result<Vec<DeadLetter>> {
564 let mut entries = Vec::new();
565 for item in std::fs::read_dir(root)? {
566 let item = item?;
567 let file_type = item.file_type()?;
568 let name = item.file_name();
569 let name = name.to_str().ok_or_else(|| {
570 std::io::Error::new(
571 std::io::ErrorKind::InvalidData,
572 "invalid DLQ store filename",
573 )
574 })?;
575 if item.path().extension() == Some(std::ffi::OsStr::new("lock"))
576 || name == "migration-v1.complete"
577 {
578 continue;
579 }
580 if file_type.is_file() && is_scope_store_temp_name(name) {
581 continue;
582 }
583 if file_type.is_symlink() || !file_type.is_file() || !is_scope_store_name(name) {
584 return Err(std::io::Error::new(
585 std::io::ErrorKind::InvalidData,
586 "DLQ root contains an unexpected entry",
587 ));
588 }
589 let store = load_store(&item.path(), None)?;
590 if let Some(first) = store.entries.first() {
591 let scope = DlqScope::new(first.tenant_id.clone(), first.project_id.clone())
592 .map_err(std::io::Error::other)?;
593 if scope_store_path(root, &scope) != item.path()
594 || store.entries.iter().any(|entry| {
595 entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
596 })
597 {
598 return Err(std::io::Error::new(
599 std::io::ErrorKind::InvalidData,
600 "DLQ scope store identity does not match its contents",
601 ));
602 }
603 }
604 entries.extend(store.entries);
605 }
606 Ok(entries)
607}
608
609fn is_scope_store_name(name: &str) -> bool {
610 let path = Path::new(name);
611 path.extension() == Some(std::ffi::OsStr::new("json"))
612 && path.file_stem().is_some_and(|stem| {
613 let bytes = stem.as_encoded_bytes();
614 bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
615 })
616}
617
618fn is_scope_store_temp_name(name: &str) -> bool {
619 let Some((store_name, suffix)) = name.split_once(".tmp-") else {
620 return false;
621 };
622 !suffix.is_empty()
623 && suffix
624 .bytes()
625 .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
626 && store_name.len() == 64
627 && store_name.bytes().all(|byte| byte.is_ascii_hexdigit())
628}
629
630fn probe_store_directory(root: &Path) -> std::io::Result<()> {
631 let path = root.join(format!("writable-{}.lock", uuid::Uuid::new_v4()));
632 let result = (|| {
633 let mut options = OpenOptions::new();
634 options.write(true).create_new(true);
635 #[cfg(unix)]
636 {
637 use std::os::unix::fs::OpenOptionsExt;
638 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
639 }
640 #[cfg(windows)]
641 {
642 use std::os::windows::fs::OpenOptionsExt;
643 options.custom_flags(0x0020_0000);
644 }
645 options.open(&path)?.sync_all()?;
646 std::fs::remove_file(&path)
647 })();
648 if result.is_err() {
649 let _ = std::fs::remove_file(&path);
650 }
651 result
652}
653
654fn migrate_legacy_store(legacy_path: &Path, root: &Path) -> std::io::Result<()> {
655 if !legacy_path.exists() || root.join("migration-v1.complete").exists() {
656 return Ok(());
657 }
658 let _migration_lock =
659 FileLock::acquire_with_timeout(&root.join("migration-v1.lock"), LOCK_TIMEOUT)
660 .map_err(std::io::Error::other)?;
661 let marker = root.join("migration-v1.complete");
662 if marker.exists() {
663 return Ok(());
664 }
665 let legacy = load_store(legacy_path, None)?;
666 let mut grouped: BTreeMap<(String, String), Vec<DeadLetter>> = BTreeMap::new();
667 for letter in legacy.entries {
668 grouped
669 .entry((letter.tenant_id.clone(), letter.project_id.clone()))
670 .or_default()
671 .push(letter);
672 }
673 for ((tenant_id, project_id), letters) in grouped {
674 let scope = DlqScope::new(tenant_id, project_id).map_err(std::io::Error::other)?;
675 let path = scope_store_path(root, &scope);
676 let _scope_lock =
677 FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
678 .map_err(std::io::Error::other)?;
679 let mut current = load_store(&path, Some(&scope))?;
680 for letter in letters {
681 if let Some(existing) = current
682 .entries
683 .iter_mut()
684 .find(|entry| entry.id == letter.id)
685 {
686 if letter.last_failed_at > existing.last_failed_at {
687 *existing = letter;
688 }
689 } else {
690 current.entries.push(letter);
691 }
692 }
693 if current.entries.len() > MAX_ENTRIES_PER_SCOPE {
694 let excess = current.entries.len() - MAX_ENTRIES_PER_SCOPE;
695 current.entries.drain(..excess);
696 }
697 while serialized_store_size(¤t.entries).map_err(std::io::Error::other)?
698 > MAX_STORE_BYTES
699 {
700 if current.entries.is_empty() {
701 break;
702 }
703 current.entries.remove(0);
704 }
705 save_store(&path, ¤t)?;
706 }
707 write_marker(&marker)?;
708 let backup = legacy_path.with_extension("json.migrated-v1");
709 if !backup.exists() {
710 std::fs::rename(legacy_path, backup)?;
711 sync_parent(legacy_path)?;
712 }
713 Ok(())
714}
715
716fn write_marker(path: &Path) -> std::io::Result<()> {
717 let mut options = OpenOptions::new();
718 options.write(true).create_new(true);
719 #[cfg(unix)]
720 {
721 use std::os::unix::fs::OpenOptionsExt;
722 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
723 }
724 #[cfg(windows)]
725 {
726 use std::os::windows::fs::OpenOptionsExt;
727 options.custom_flags(0x0020_0000);
728 }
729 let mut file = options.open(path)?;
730 file.write_all(b"v1\n")?;
731 file.sync_all()?;
732 sync_parent(path)
733}
734
735fn load_store(path: &Path, expected_scope: Option<&DlqScope>) -> std::io::Result<DurableDlqV1> {
736 let metadata = match std::fs::symlink_metadata(path) {
737 Ok(metadata) => metadata,
738 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
739 return Ok(DurableDlqV1::default());
740 }
741 Err(error) => return Err(error),
742 };
743 if metadata.file_type().is_symlink() || !metadata.is_file() {
744 return Err(std::io::Error::new(
745 std::io::ErrorKind::InvalidData,
746 "DLQ store must be a regular file",
747 ));
748 }
749 if metadata.len() > MAX_STORE_BYTES {
750 return Err(std::io::Error::new(
751 std::io::ErrorKind::InvalidData,
752 "DLQ store exceeds size limit",
753 ));
754 }
755 let mut content = Vec::new();
756 open_read_nofollow(path)?
757 .take(MAX_STORE_BYTES + 1)
758 .read_to_end(&mut content)?;
759 if content.len() as u64 > MAX_STORE_BYTES {
760 return Err(std::io::Error::new(
761 std::io::ErrorKind::InvalidData,
762 "DLQ store exceeds size limit",
763 ));
764 }
765 let mut store: DurableDlqV1 =
766 serde_json::from_slice(&content).map_err(std::io::Error::other)?;
767 if store.version != STORE_VERSION || store.entries.len() > MAX_ENTRIES_PER_SCOPE {
768 return Err(std::io::Error::new(
769 std::io::ErrorKind::InvalidData,
770 "invalid DLQ store bounds or version",
771 ));
772 }
773 for entry in &mut store.entries {
774 if entry.peer_id.is_empty() {
777 entry.peer_id = "legacy".to_string();
778 }
779 if entry.delivery_id.is_empty() {
780 entry.delivery_id = entry.id.clone();
781 }
782 entry
783 .validate()
784 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
785 if expected_scope.is_some_and(|scope| {
786 entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
787 }) {
788 return Err(std::io::Error::new(
789 std::io::ErrorKind::InvalidData,
790 "DLQ scope store contains an entry from another scope",
791 ));
792 }
793 }
794 Ok(store)
795}
796
797fn save_store(path: &Path, store: &DurableDlqV1) -> std::io::Result<()> {
798 if let Ok(metadata) = std::fs::symlink_metadata(path)
799 && (metadata.file_type().is_symlink() || !metadata.is_file())
800 {
801 return Err(std::io::Error::new(
802 std::io::ErrorKind::InvalidData,
803 "DLQ store must be a regular file",
804 ));
805 }
806 let json = serde_json::to_vec_pretty(store).map_err(std::io::Error::other)?;
807 if json.len() as u64 > MAX_STORE_BYTES {
808 return Err(std::io::Error::new(
809 std::io::ErrorKind::InvalidData,
810 "DLQ store exceeds size limit",
811 ));
812 }
813 let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
814 let result = (|| {
815 let mut options = OpenOptions::new();
816 options.write(true).create_new(true);
817 #[cfg(unix)]
818 {
819 use std::os::unix::fs::OpenOptionsExt;
820 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
821 }
822 #[cfg(windows)]
823 {
824 use std::os::windows::fs::OpenOptionsExt;
825 options.custom_flags(0x0020_0000);
826 }
827 let mut file = options.open(&tmp)?;
828 file.write_all(&json)?;
829 file.sync_all()?;
830 drop(file);
831 atomic_replace(&tmp, path)?;
832 sync_parent(path)
833 })();
834 if result.is_err() {
835 let _ = std::fs::remove_file(&tmp);
836 }
837 result
838}
839
840fn open_read_nofollow(path: &Path) -> std::io::Result<File> {
841 let mut options = OpenOptions::new();
842 options.read(true);
843 #[cfg(unix)]
844 {
845 use std::os::unix::fs::OpenOptionsExt;
846 options.custom_flags(libc::O_NOFOLLOW);
847 }
848 #[cfg(windows)]
849 {
850 use std::os::windows::fs::OpenOptionsExt;
851 options.custom_flags(0x0020_0000);
852 }
853 options.open(path)
854}
855
856#[cfg(not(windows))]
857fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
858 std::fs::rename(source, destination)
859}
860
861#[cfg(windows)]
862fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
863 use std::os::windows::ffi::OsStrExt;
864 use windows_sys::Win32::Storage::FileSystem::{
865 MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
866 };
867
868 let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
869 let destination: Vec<u16> = destination
870 .as_os_str()
871 .encode_wide()
872 .chain(Some(0))
873 .collect();
874 let result = unsafe {
877 MoveFileExW(
878 source.as_ptr(),
879 destination.as_ptr(),
880 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
881 )
882 };
883 if result == 0 {
884 Err(std::io::Error::last_os_error())
885 } else {
886 Ok(())
887 }
888}
889
890fn sync_parent(path: &Path) -> std::io::Result<()> {
891 #[cfg(unix)]
892 if let Some(parent) = path.parent() {
893 File::open(parent)?.sync_all()?;
894 }
895 #[cfg(windows)]
900 let _ = path;
901 Ok(())
902}
903
904#[cfg(test)]
905mod tests {
906 use super::*;
907
908 fn scope(tenant: &str, project: &str) -> DlqScope {
909 DlqScope::new(tenant, project).unwrap()
910 }
911 fn letter(id: &str, tenant: &str, project: &str) -> DeadLetter {
912 DeadLetter {
913 id: id.into(),
914 peer_id: "legacy".into(),
915 delivery_id: id.into(),
916 tenant_id: tenant.into(),
917 project_id: project.into(),
918 delivery: DeadLetterDelivery::LocalAgentBus,
919 original_message: format!("message-{id}"),
920 target_agent: "agent-a".into(),
921 error: "delivery failed".into(),
922 attempts: 1,
923 first_failed_at: "2026-01-01T00:00:00Z".into(),
924 last_failed_at: "2026-01-01T00:00:00Z".into(),
925 }
926 }
927
928 #[test]
929 fn scoped_operations_do_not_cross_tenant_or_project() {
930 let queue = DeadLetterQueue::new();
931 queue
932 .enqueue(letter("one", "tenant-a", "project-a"))
933 .unwrap();
934 queue
935 .enqueue(letter("one", "tenant-b", "project-a"))
936 .unwrap();
937 assert_eq!(
938 queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
939 1
940 );
941 assert!(
942 queue
943 .dequeue(&scope("tenant-a", "project-b"), "one")
944 .unwrap()
945 .is_none()
946 );
947 assert_eq!(queue.stats(None).unwrap().total, 2);
948 }
949
950 #[test]
951 fn persistent_queue_survives_reopen_and_deduplicates() {
952 let dir = tempfile::tempdir().unwrap();
953 let path = dir.path().join("a2a").join("dead-letters-v1.json");
954 let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
955 queue
956 .enqueue(letter("one", "tenant-a", "project-a"))
957 .unwrap();
958 queue
959 .enqueue(letter("one", "tenant-a", "project-a"))
960 .unwrap();
961 drop(queue);
962 assert_eq!(
963 DeadLetterQueue::persistent(path)
964 .unwrap()
965 .peek(&scope("tenant-a", "project-a"))
966 .unwrap()
967 .len(),
968 1
969 );
970 }
971
972 #[test]
973 fn concurrent_persistent_enqueues_do_not_lose_entries() {
974 let dir = tempfile::tempdir().unwrap();
975 let path = dir.path().join("a2a").join("dead-letters-v1.json");
976 let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
977 let workers: Vec<_> = (0..8)
978 .map(|worker| {
979 let queue = queue.clone();
980 std::thread::spawn(move || {
981 for item in 0..25 {
982 queue
983 .enqueue(letter(
984 &format!("worker-{worker}-item-{item}"),
985 "tenant-a",
986 "project-a",
987 ))
988 .unwrap();
989 }
990 })
991 })
992 .collect();
993 for worker in workers {
994 worker.join().unwrap();
995 }
996 drop(queue);
997 assert_eq!(
998 DeadLetterQueue::persistent(path)
999 .unwrap()
1000 .peek(&scope("tenant-a", "project-a"))
1001 .unwrap()
1002 .len(),
1003 200
1004 );
1005 }
1006
1007 #[test]
1008 fn corrupt_oversized_and_symlink_stores_fail_closed() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let corrupt = dir.path().join("corrupt.json");
1011 std::fs::write(&corrupt, b"not json").unwrap();
1012 assert!(DeadLetterQueue::persistent(corrupt).is_err());
1013 let oversized = dir.path().join("oversized.json");
1014 std::fs::write(&oversized, vec![b'x'; (MAX_STORE_BYTES + 1) as usize]).unwrap();
1015 assert!(DeadLetterQueue::persistent(oversized).is_err());
1016 #[cfg(unix)]
1017 {
1018 use std::os::unix::fs::symlink;
1019 let target = dir.path().join("target.json");
1020 std::fs::write(&target, b"{}").unwrap();
1021 let link = dir.path().join("link.json");
1022 symlink(&target, &link).unwrap();
1023 assert!(DeadLetterQueue::persistent(link).is_err());
1024 }
1025 }
1026
1027 #[test]
1028 fn remote_entries_cannot_be_retried_as_local_messages() {
1029 let queue = DeadLetterQueue::new();
1030 let mut item = letter("one", "tenant-a", "project-a");
1031 item.delivery = DeadLetterDelivery::RemoteHttp {
1032 endpoint_url: "https://agent.example".into(),
1033 };
1034 queue.enqueue(item).unwrap();
1035 assert!(queue.retry(&scope("tenant-a", "project-a"), "one").is_err());
1036 assert_eq!(queue.stats(None).unwrap().total, 1);
1037 }
1038
1039 #[test]
1040 fn invalid_attempts_time_order_and_endpoint_credentials_are_rejected() {
1041 let queue = DeadLetterQueue::new();
1042 let mut zero_attempts = letter("zero", "tenant-a", "project-a");
1043 zero_attempts.attempts = 0;
1044 assert!(queue.enqueue(zero_attempts).is_err());
1045
1046 let mut reversed = letter("reversed", "tenant-a", "project-a");
1047 reversed.last_failed_at = "2025-01-01T00:00:00Z".into();
1048 assert!(queue.enqueue(reversed).is_err());
1049
1050 let mut credential_url = letter("credentials", "tenant-a", "project-a");
1051 credential_url.delivery = DeadLetterDelivery::RemoteHttp {
1052 endpoint_url: "https://user:secret@agent.example".into(),
1053 };
1054 assert!(queue.enqueue(credential_url).is_err());
1055 assert_eq!(queue.stats(None).unwrap().total, 0);
1056 }
1057
1058 #[test]
1059 fn per_scope_capacity_never_evicts_another_scope() {
1060 let queue = DeadLetterQueue::new();
1061 queue
1062 .enqueue(letter("protected", "tenant-b", "project-b"))
1063 .unwrap();
1064 for index in 0..=MAX_ENTRIES_PER_SCOPE {
1065 queue
1066 .enqueue(letter(&format!("a-{index}"), "tenant-a", "project-a"))
1067 .unwrap();
1068 }
1069 let protected = queue.peek(&scope("tenant-b", "project-b")).unwrap();
1070 assert_eq!(protected.len(), 1);
1071 assert_eq!(protected[0].id, "protected");
1072 let tenant_a = queue.peek(&scope("tenant-a", "project-a")).unwrap();
1073 assert_eq!(tenant_a.len(), MAX_ENTRIES_PER_SCOPE);
1074 assert_eq!(tenant_a.first().unwrap().id, "a-1");
1075 }
1076
1077 #[test]
1078 fn durable_scopes_use_distinct_files_and_byte_eviction_is_local() {
1079 let dir = tempfile::tempdir().unwrap();
1080 let legacy = dir.path().join("dead-letters-v1.json");
1081 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1082 let mut protected = letter("protected", "tenant-b", "project-b");
1083 protected.original_message = "b".repeat(MAX_MESSAGE_BYTES);
1084 queue.enqueue(protected).unwrap();
1085 for index in 0..5 {
1086 let mut item = letter(&format!("large-{index}"), "tenant-a", "project-a");
1087 item.original_message = "a".repeat(MAX_MESSAGE_BYTES);
1088 queue.enqueue(item).unwrap();
1089 }
1090
1091 let root = legacy.with_extension("d");
1092 let scope_a = scope("tenant-a", "project-a");
1093 let scope_b = scope("tenant-b", "project-b");
1094 assert_ne!(
1095 scope_store_path(&root, &scope_a),
1096 scope_store_path(&root, &scope_b)
1097 );
1098 assert_eq!(queue.peek(&scope_b).unwrap().len(), 1);
1099 let tenant_a = queue.peek(&scope_a).unwrap();
1100 assert!(!tenant_a.is_empty(), "`tenant_a` is empty");
1101 assert!(tenant_a.len() < 5);
1102 assert!(
1103 std::fs::metadata(scope_store_path(&root, &scope_a))
1104 .unwrap()
1105 .len()
1106 <= MAX_STORE_BYTES
1107 );
1108 }
1109
1110 #[test]
1111 fn compact_legacy_boundary_is_readable_and_next_enqueue_uses_writer_size() {
1112 let dir = tempfile::tempdir().unwrap();
1113 let legacy = dir.path().join("dead-letters-v1.json");
1114 let root = legacy.with_extension("d");
1115 let scope = scope("tenant-a", "project-a");
1116 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1117 prepare_store_directory(&root).unwrap();
1118
1119 let mut entries = (0..MAX_ENTRIES_PER_SCOPE)
1120 .map(|index| {
1121 let mut item = letter(&format!("boundary-{index}"), "tenant-a", "project-a");
1122 item.original_message = "x".repeat(8_200);
1123 item
1124 })
1125 .collect::<Vec<_>>();
1126 while serde_json::to_vec(&DurableDlqV1 {
1127 version: STORE_VERSION,
1128 entries: entries.clone(),
1129 })
1130 .unwrap()
1131 .len() as u64
1132 > MAX_STORE_BYTES
1133 {
1134 entries.remove(0);
1135 }
1136 let compact = serde_json::to_vec(&DurableDlqV1 {
1137 version: STORE_VERSION,
1138 entries: entries.clone(),
1139 })
1140 .unwrap();
1141 assert!(compact.len() as u64 <= MAX_STORE_BYTES);
1142 assert!(
1143 serde_json::to_vec_pretty(&DurableDlqV1 {
1144 version: STORE_VERSION,
1145 entries,
1146 })
1147 .unwrap()
1148 .len() as u64
1149 > MAX_STORE_BYTES
1150 );
1151 std::fs::write(scope_store_path(&root, &scope), compact).unwrap();
1152
1153 queue.ensure_writable(&scope).unwrap();
1154 queue
1155 .enqueue(letter("after-boundary", "tenant-a", "project-a"))
1156 .unwrap();
1157 let stored = std::fs::metadata(scope_store_path(&root, &scope)).unwrap();
1158 assert!(stored.len() <= MAX_STORE_BYTES);
1159 assert!(queue.get(&scope, "after-boundary").unwrap().is_some());
1160 }
1161
1162 #[test]
1163 fn aggregate_stats_ignore_regular_interrupted_write_artifacts() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let legacy = dir.path().join("dead-letters-v1.json");
1166 let scope = scope("tenant-a", "project-a");
1167 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1168 queue
1169 .enqueue(letter("good", "tenant-a", "project-a"))
1170 .unwrap();
1171 let store = scope_store_path(&legacy.with_extension("d"), &scope);
1172 let tmp = store.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
1173 std::fs::write(&tmp, b"partial").unwrap();
1174
1175 assert_eq!(queue.stats(None).unwrap().total, 1);
1176 std::fs::remove_file(tmp).unwrap();
1177 }
1178
1179 #[cfg(unix)]
1180 #[test]
1181 fn persistent_store_repairs_preexisting_directory_permissions() {
1182 use std::os::unix::fs::PermissionsExt;
1183
1184 let dir = tempfile::tempdir().unwrap();
1185 let legacy = dir.path().join("dead-letters-v1.json");
1186 let root = legacy.with_extension("d");
1187 std::fs::create_dir(&root).unwrap();
1188 std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap();
1189
1190 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1191 queue
1192 .enqueue(letter("private", "tenant-a", "project-a"))
1193 .unwrap();
1194 assert_eq!(
1195 std::fs::metadata(root).unwrap().permissions().mode() & 0o777,
1196 0o700
1197 );
1198 }
1199
1200 #[test]
1201 fn legacy_global_store_migrates_without_cross_scope_cohabitation() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let legacy = dir.path().join("dead-letters-v1.json");
1204 save_store(
1205 &legacy,
1206 &DurableDlqV1 {
1207 version: STORE_VERSION,
1208 entries: vec![
1209 letter("a", "tenant-a", "project-a"),
1210 letter("b", "tenant-b", "project-b"),
1211 ],
1212 },
1213 )
1214 .unwrap();
1215
1216 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1217 assert!(!legacy.exists());
1218 assert_eq!(
1219 queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
1220 1
1221 );
1222 assert_eq!(
1223 queue.peek(&scope("tenant-b", "project-b")).unwrap().len(),
1224 1
1225 );
1226 let stores = std::fs::read_dir(legacy.with_extension("d"))
1227 .unwrap()
1228 .filter_map(Result::ok)
1229 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
1230 .count();
1231 assert_eq!(stores, 2);
1232 }
1233
1234 #[test]
1235 fn corrupt_scope_does_not_block_another_scope_or_reopen() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let legacy = dir.path().join("dead-letters-v1.json");
1238 let scope_a = scope("tenant-a", "project-a");
1239 let scope_b = scope("tenant-b", "project-b");
1240 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1241 queue
1242 .enqueue(letter("good", "tenant-a", "project-a"))
1243 .unwrap();
1244 queue
1245 .enqueue(letter("bad", "tenant-b", "project-b"))
1246 .unwrap();
1247 std::fs::write(
1248 scope_store_path(&legacy.with_extension("d"), &scope_b),
1249 b"not json",
1250 )
1251 .unwrap();
1252
1253 let reopened = DeadLetterQueue::persistent(legacy).unwrap();
1254 assert_eq!(reopened.peek(&scope_a).unwrap()[0].id, "good");
1255 assert!(reopened.peek(&scope_b).is_err());
1256 }
1257
1258 #[test]
1259 fn read_only_stats_does_not_create_store_directory() {
1260 let dir = tempfile::tempdir().unwrap();
1261 let legacy = dir.path().join("missing").join("dead-letters-v1.json");
1262 let root = legacy.with_extension("d");
1263 let queue = DeadLetterQueue::persistent(legacy).unwrap();
1264
1265 assert_eq!(queue.stats(None).unwrap().total, 0);
1266 assert!(!root.exists());
1267 }
1268
1269 #[test]
1270 fn aggregate_stats_report_worst_scope_depth() {
1271 let queue = DeadLetterQueue::new();
1272 queue
1273 .enqueue(letter("a1", "tenant-a", "project-a"))
1274 .unwrap();
1275 queue
1276 .enqueue(letter("a2", "tenant-a", "project-a"))
1277 .unwrap();
1278 queue
1279 .enqueue(letter("b1", "tenant-b", "project-b"))
1280 .unwrap();
1281
1282 let stats = queue.stats(None).unwrap();
1283 assert_eq!(stats.total, 3);
1284 assert_eq!(stats.max_scope_depth, 2);
1285 assert_eq!(
1286 queue
1287 .stats(Some(&scope("tenant-b", "project-b")))
1288 .unwrap()
1289 .max_scope_depth,
1290 1
1291 );
1292 }
1293
1294 #[test]
1295 fn successful_local_retry_atomically_persists_removal() {
1296 let _data = crate::core::data_dir::isolated_data_dir();
1299 let dir = tempfile::tempdir().unwrap();
1300 let legacy = dir.path().join("dead-letters-v1.json");
1301 let scope = scope("tenant-a", "project-a");
1302 let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1303 let mut item = letter("retry", "tenant-a", "project-a");
1304 item.target_agent = "retry-target".into();
1305 queue.enqueue(item).unwrap();
1306
1307 queue.retry(&scope, "retry").unwrap();
1308 drop(queue);
1309 let stored = DeadLetterQueue::persistent(legacy)
1310 .unwrap()
1311 .peek(&scope)
1312 .unwrap();
1313 assert!(stored.is_empty(), "{stored:?}");
1314 }
1315
1316 #[test]
1317 fn retry_lock_artifacts_are_bounded_per_scope() {
1318 let dir = tempfile::tempdir().unwrap();
1319 let legacy = dir.path().join("dead-letters-v1.json");
1320 let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1321 let scope_a = scope("tenant-a", "project-a");
1322 let scope_b = scope("tenant-b", "project-b");
1323 queue
1324 .enqueue(letter("first", "tenant-a", "project-a"))
1325 .unwrap();
1326 queue
1327 .enqueue(letter("third", "tenant-b", "project-b"))
1328 .unwrap();
1329
1330 drop(queue.lock_retry(&scope_a, "first").unwrap());
1331 drop(queue.lock_retry(&scope_a, "second").unwrap());
1332 drop(queue.lock_retry(&scope_b, "third").unwrap());
1333
1334 let retry_locks = std::fs::read_dir(legacy.with_extension("d"))
1335 .unwrap()
1336 .filter_map(Result::ok)
1337 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".retry.lock"))
1338 .count();
1339 assert_eq!(retry_locks, 2);
1340 }
1341}