Skip to main content

rocketmq_runtime/
metadata_io.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Bounded, generation-aware metadata persistence.
16//!
17//! [`MetadataIoActor`] is the sole owner of admitted metadata snapshots. It
18//! coalesces queued generations for the same logical resource, executes
19//! synchronous filesystem work on a dedicated [`BlockingExecutor`] lane, and
20//! advances the durable generation only after the atomic replacement protocol
21//! completes.
22//!
23//! Acceptance reserves ownership and bytes; it is not a durability result.
24//! An observation timeout stops waiting, not an already-started filesystem
25//! write. Unconfirmed writes retain ordering and reconciliation obligations.
26//! Target ownership is process-local and does not replace an application's
27//! cross-process locking or format-specific recovery protocol.
28
29mod filesystem;
30
31use std::collections::HashMap;
32use std::collections::VecDeque;
33use std::fmt;
34use std::num::NonZeroUsize;
35use std::path::Path;
36use std::path::PathBuf;
37use std::sync::atomic::AtomicUsize;
38use std::sync::atomic::Ordering;
39use std::sync::Arc;
40use std::sync::Weak;
41use std::time::Duration;
42
43use futures::stream::FuturesUnordered;
44use futures::StreamExt;
45use parking_lot::Mutex;
46use tokio::sync::mpsc;
47use tokio::sync::oneshot;
48use tokio::sync::Notify;
49use tokio_util::sync::CancellationToken;
50
51use crate::metadata_target::MetadataTargetIdentity;
52use crate::metadata_target::MetadataTargetRegistration;
53use crate::metadata_target::MetadataTargetRegistrationOutcome;
54use crate::metadata_target::MetadataTargetRegistry;
55use crate::metadata_target::MetadataTargetRetirementOutcome;
56use crate::resource_budget::BudgetCapacity;
57use crate::resource_budget::BudgetLimit;
58use crate::resource_budget::FullPolicy;
59use crate::resource_budget::ResourceBudget;
60use crate::resource_budget::ResourcePermit;
61use crate::shutdown_deadline::ShutdownDeadline;
62use crate::BlockingExecutor;
63use crate::BlockingPoolPolicy;
64use crate::ChildServiceContext;
65use crate::RuntimeContractPolicy;
66use crate::RuntimeContractViolation;
67use crate::RuntimeError;
68use crate::RuntimeResult;
69
70/// An immutable absolute deadline for metadata admission and durability waits.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct MetadataDeadline {
73    at: tokio::time::Instant,
74}
75
76impl MetadataDeadline {
77    /// Freezes a relative timeout into one absolute Tokio deadline.
78    #[must_use]
79    pub fn after(timeout: Duration) -> Self {
80        let now = tokio::time::Instant::now();
81        Self {
82            at: now.checked_add(timeout).unwrap_or(now),
83        }
84    }
85
86    /// Uses an existing absolute Tokio deadline.
87    #[must_use]
88    pub const fn at(at: tokio::time::Instant) -> Self {
89        Self { at }
90    }
91
92    /// Returns the immutable absolute expiry instant.
93    #[must_use]
94    pub const fn instant(self) -> tokio::time::Instant {
95        self.at
96    }
97
98    /// Returns the remaining budget without extending the deadline.
99    #[must_use]
100    pub fn remaining(self) -> Duration {
101        self.at.saturating_duration_since(tokio::time::Instant::now())
102    }
103
104    /// Returns whether the absolute deadline has elapsed.
105    #[must_use]
106    pub fn is_expired(self) -> bool {
107        tokio::time::Instant::now() >= self.at
108    }
109}
110
111/// A monotonically increasing generation for one metadata resource.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
113pub struct MetadataGeneration(u64);
114
115impl MetadataGeneration {
116    /// Creates a generation identifier.
117    #[must_use]
118    pub const fn new(value: u64) -> Self {
119        Self(value)
120    }
121
122    /// Returns the underlying generation value.
123    #[must_use]
124    pub const fn get(self) -> u64 {
125        self.0
126    }
127}
128
129impl From<u64> for MetadataGeneration {
130    fn from(value: u64) -> Self {
131        Self::new(value)
132    }
133}
134
135/// The caller-visible completion boundary for a persistence request.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum MetadataDurability {
138    /// The immutable snapshot has been accepted by the bounded actor.
139    Accepted,
140    /// The snapshot generation, or a newer coalesced generation, is durable.
141    Durable(MetadataGeneration),
142}
143
144/// A filesystem step in the atomic replacement protocol.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum MetadataIoOperation {
147    /// Represents the create parent case.
148    CreateParent,
149    /// Represents the create temporary case.
150    CreateTemporary,
151    /// Represents the write temporary case.
152    WriteTemporary,
153    /// Represents the sync temporary case.
154    SyncTemporary,
155    /// Represents the replace target case.
156    ReplaceTarget,
157    /// Represents the sync parent case.
158    SyncParent,
159    /// Represents the remove temporary case.
160    RemoveTemporary,
161}
162
163impl fmt::Display for MetadataIoOperation {
164    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
165        let value = match self {
166            Self::CreateParent => "create parent directory",
167            Self::CreateTemporary => "create temporary file",
168            Self::WriteTemporary => "write temporary file",
169            Self::SyncTemporary => "sync temporary file",
170            Self::ReplaceTarget => "replace target file",
171            Self::SyncParent => "sync parent directory",
172            Self::RemoveTemporary => "remove temporary file",
173        };
174        formatter.write_str(value)
175    }
176}
177
178impl MetadataIoOperation {
179    /// Returns the closed runtime diagnostic operation for this filesystem step.
180    #[must_use]
181    pub const fn runtime_operation(self) -> crate::RuntimeOperation {
182        match self {
183            Self::CreateParent => crate::RuntimeOperation::MetadataCreateParent,
184            Self::CreateTemporary => crate::RuntimeOperation::MetadataCreateTemporary,
185            Self::WriteTemporary => crate::RuntimeOperation::MetadataWriteTemporary,
186            Self::SyncTemporary => crate::RuntimeOperation::MetadataSyncTemporary,
187            Self::ReplaceTarget => crate::RuntimeOperation::MetadataReplaceTarget,
188            Self::SyncParent => crate::RuntimeOperation::MetadataSyncParent,
189            Self::RemoveTemporary => crate::RuntimeOperation::MetadataRemoveTemporary,
190        }
191    }
192}
193
194fn metadata_io_failure(operation: MetadataIoOperation, _path: &Path, source: std::io::Error) -> RuntimeError {
195    RuntimeError::io(operation.runtime_operation(), source)
196}
197
198/// An immutable write request accepted by [`MetadataIoActor`].
199#[derive(Debug, Clone)]
200pub struct MetadataWriteRequest {
201    resource: Arc<str>,
202    generation: MetadataGeneration,
203    target: Arc<Path>,
204    bytes: Arc<[u8]>,
205    lane_deadline: Option<MetadataDeadline>,
206}
207
208impl MetadataWriteRequest {
209    /// Creates an immutable resource snapshot.
210    #[must_use]
211    pub fn new(
212        resource: impl Into<Arc<str>>,
213        generation: impl Into<MetadataGeneration>,
214        target: impl Into<PathBuf>,
215        bytes: impl Into<Vec<u8>>,
216    ) -> Self {
217        Self {
218            resource: resource.into(),
219            generation: generation.into(),
220            target: Arc::from(target.into()),
221            bytes: Arc::from(bytes.into()),
222            lane_deadline: None,
223        }
224    }
225
226    /// Bounds this request against the shared blocking lane.
227    ///
228    /// The value is combined with the lane's phase budgets, so it can only
229    /// tighten them and can never widen a shared lane limit. It is deliberately
230    /// separate from the durability deadline passed to
231    /// [`MetadataIoActor::submit`]: an admitted write keeps its ordering and
232    /// byte charge when the caller stops observing it, while a request whose
233    /// lane deadline has already elapsed is refused instead of started.
234    #[must_use]
235    pub fn with_lane_deadline(mut self, deadline: MetadataDeadline) -> Self {
236        self.lane_deadline = Some(deadline);
237        self
238    }
239
240    /// Returns the optional request-level lane deadline.
241    #[must_use]
242    pub const fn lane_deadline(&self) -> Option<MetadataDeadline> {
243        self.lane_deadline
244    }
245
246    /// Returns the logical resource identifier.
247    #[must_use]
248    pub fn resource(&self) -> &str {
249        &self.resource
250    }
251
252    /// Returns the immutable generation.
253    #[must_use]
254    pub const fn generation(&self) -> MetadataGeneration {
255        self.generation
256    }
257
258    /// Returns the target path.
259    #[must_use]
260    pub fn target(&self) -> &Path {
261        &self.target
262    }
263
264    /// Returns the retained snapshot size.
265    #[must_use]
266    pub fn len(&self) -> usize {
267        self.bytes.len()
268    }
269
270    /// Returns whether the immutable snapshot is empty.
271    #[must_use]
272    pub fn is_empty(&self) -> bool {
273        self.bytes.is_empty()
274    }
275}
276
277/// Filesystem abstraction used by the metadata actor.
278///
279/// Implementations must not publish partial target files. Production callers
280/// should use [`LocalMetadataFileSystem`]; the abstraction also permits
281/// deterministic fault injection without relying on a particular host
282/// filesystem.
283pub trait MetadataFileSystem: fmt::Debug + Send + Sync + 'static {
284    /// Atomically persists one immutable snapshot.
285    ///
286    /// # Errors
287    ///
288    /// Returns a typed error that identifies the failed durability step and
289    /// retains the original I/O source.
290    fn persist_atomic(&self, target: &Path, bytes: &[u8]) -> RuntimeResult<()>;
291}
292
293/// The production local filesystem implementation.
294#[derive(Debug, Default)]
295pub struct LocalMetadataFileSystem;
296
297impl MetadataFileSystem for LocalMetadataFileSystem {
298    fn persist_atomic(&self, target: &Path, bytes: &[u8]) -> RuntimeResult<()> {
299        filesystem::persist_atomic_local(target, bytes)
300    }
301}
302
303/// Metadata actor admission limits and legacy blocking-lane fields.
304///
305/// The actor owns its admission bounds, `max_pending_operations` and
306/// `max_pending_bytes`. The shared blocking lane policy owns lane capacity,
307/// phase timeouts, and the warn threshold, so the three `blocking_*` fields are
308/// accepted for source compatibility and never override the lane.
309/// [`MetadataIoActor::effective_profile`] reports the value that applies to each
310/// one.
311#[derive(Debug, Clone)]
312pub struct MetadataIoConfig {
313    /// The max pending operations value.
314    pub max_pending_operations: usize,
315    /// The max pending size in bytes.
316    pub max_pending_bytes: usize,
317    /// Legacy lane queue wait, retained for source compatibility.
318    ///
319    /// The shared blocking lane policy owns the queue wait in force, so this
320    /// value does not change admission. Use
321    /// [`MetadataWriteRequest::with_lane_deadline`] to tighten a single request.
322    pub blocking_queue_timeout: Duration,
323    /// Legacy lane execution budget, retained for source compatibility.
324    ///
325    /// The shared blocking lane policy owns the execution budget in force, so
326    /// this value does not bound a running write. It is still validated for
327    /// compatibility with existing configuration literals.
328    pub blocking_task_timeout: Duration,
329    /// Legacy lane warn threshold, retained for source compatibility.
330    ///
331    /// The warn threshold belongs to the shared blocking lane policy, so this
332    /// value is never emitted.
333    pub blocking_warn_after: Duration,
334}
335
336/// Metadata actor settings that passed deterministic admission validation.
337#[derive(Debug, Clone)]
338pub struct MetadataIoPlan {
339    config: MetadataIoConfig,
340    max_concurrent_writes: NonZeroUsize,
341}
342
343impl MetadataIoConfig {
344    /// Validates bounded metadata actor configuration before startup.
345    ///
346    /// The zero `blocking_task_timeout` check is retained for compatibility with
347    /// existing configuration literals, even though the field does not drive
348    /// execution.
349    ///
350    /// # Errors
351    ///
352    /// Returns a deterministic contract violation when an admission bound or
353    /// blocking duration is invalid. This method performs no I/O.
354    pub fn validate(&self) -> Result<(), RuntimeContractViolation> {
355        if self.max_pending_operations == 0 {
356            return Err(RuntimeContractViolation::InvalidMetadataConfiguration {
357                policy: RuntimeContractPolicy::MetadataMaxPendingOperationsPositive,
358            });
359        }
360        if self.max_pending_bytes == 0 {
361            return Err(RuntimeContractViolation::InvalidMetadataConfiguration {
362                policy: RuntimeContractPolicy::MetadataMaxPendingBytesPositive,
363            });
364        }
365        if self.blocking_task_timeout.is_zero() {
366            return Err(RuntimeContractViolation::InvalidMetadataConfiguration {
367                policy: RuntimeContractPolicy::MetadataBlockingTaskTimeoutPositive,
368            });
369        }
370        Ok(())
371    }
372
373    /// Creates a validated metadata actor startup plan.
374    ///
375    /// # Errors
376    ///
377    /// Returns a deterministic contract violation when an admission bound or
378    /// blocking duration is invalid.
379    pub fn into_plan(self) -> Result<MetadataIoPlan, RuntimeContractViolation> {
380        self.validate()?;
381        Ok(MetadataIoPlan {
382            config: self,
383            max_concurrent_writes: NonZeroUsize::MIN,
384        })
385    }
386}
387
388impl MetadataIoPlan {
389    /// Lets the actor write up to `writes` different resources at the same time.
390    ///
391    /// The default is one: resources are written one after another, so one
392    /// slow write delays every other resource of the actor. A larger value lets
393    /// independent resources proceed while a write is slow. Generations of one
394    /// resource are still written one at a time and in order, and its queued
395    /// generations still coalesce. The actor never runs more writes than the
396    /// shared metadata lane admits at once; [`MetadataIoActor::effective_profile`]
397    /// reports the value in force.
398    #[must_use]
399    pub fn with_max_concurrent_writes(mut self, writes: NonZeroUsize) -> Self {
400        self.max_concurrent_writes = writes;
401        self
402    }
403
404    /// Starts the validated actor with the production filesystem.
405    ///
406    /// # Errors
407    ///
408    /// Returns an operational lifecycle error when the owned coordinator
409    /// cannot start.
410    pub fn start(self, service_context: &ChildServiceContext) -> RuntimeResult<MetadataIoActor> {
411        MetadataIoActor::start_validated(service_context, self, Arc::new(LocalMetadataFileSystem))
412    }
413
414    /// Starts the validated actor with an injected filesystem implementation.
415    ///
416    /// # Errors
417    ///
418    /// Returns an operational lifecycle error when the owned coordinator
419    /// cannot start.
420    pub fn start_with_file_system(
421        self,
422        service_context: &ChildServiceContext,
423        file_system: Arc<dyn MetadataFileSystem>,
424    ) -> RuntimeResult<MetadataIoActor> {
425        MetadataIoActor::start_validated(service_context, self, file_system)
426    }
427}
428
429impl Default for MetadataIoConfig {
430    fn default() -> Self {
431        Self {
432            max_pending_operations: 1_024,
433            max_pending_bytes: 64 * 1024 * 1024,
434            blocking_queue_timeout: Duration::from_secs(5),
435            blocking_task_timeout: Duration::from_secs(30),
436            blocking_warn_after: Duration::from_secs(1),
437        }
438    }
439}
440
441/// Identifies the owner of one effective metadata limit.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum MetadataLimitSource {
444    /// The shared root blocking lane policy owns the value.
445    BlockingLanePolicy,
446    /// The metadata actor configuration owns the value.
447    ActorConfiguration,
448}
449
450/// One retained actor field together with the value that applies instead.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct MetadataIoLegacyBlockingField {
453    /// The configuration field name.
454    pub field: &'static str,
455    /// The value a caller configured.
456    pub configured: Duration,
457    /// The value that drives execution.
458    pub effective: Duration,
459    /// The owner of the effective value.
460    pub effective_source: MetadataLimitSource,
461}
462
463/// The shared blocking lane limits in force for the metadata actor.
464#[derive(Debug, Clone, PartialEq, Eq)]
465pub struct MetadataIoBlockingLaneProfile {
466    /// The lane name from the root blocking lane policy.
467    pub name: String,
468    /// The admitted concurrency ceiling.
469    pub max_concurrency: usize,
470    /// The admitted queue depth ceiling.
471    pub max_queue_depth: usize,
472    /// The queue wait budget.
473    pub queue_timeout: Duration,
474    /// The execution budget.
475    pub task_timeout: Duration,
476    /// The warn threshold for work that completed above it.
477    pub warn_after: Duration,
478}
479
480/// The admission limits the metadata actor owns.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct MetadataIoActorLimitsProfile {
483    /// The admitted pending operation ceiling.
484    pub max_pending_operations: usize,
485    /// The admitted retained byte ceiling.
486    pub max_pending_bytes: usize,
487    /// The waiter ceiling derived from `max_pending_operations`.
488    pub max_waiters: usize,
489    /// The number of different resources written at the same time: the
490    /// planned value, capped by the concurrency of the shared metadata lane.
491    pub max_concurrent_writes: usize,
492}
493
494/// A read-only view of the metadata I/O limits actually in force.
495///
496/// `blocking_lane` carries the shared lane limits owned by the root
497/// [`BlockingLanePolicies`](crate::BlockingLanePolicies), `actor_limits` carries
498/// the admission bounds the actor owns, and `legacy_blocking_fields` reports
499/// each retained configuration field beside the value that applies instead.
500///
501/// A request may tighten the lane phase budgets with
502/// [`MetadataWriteRequest::with_lane_deadline`]. The executor combines the
503/// request deadline with the lane budget by taking the earlier expiry, so no
504/// request can widen a shared lane limit.
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct MetadataIoEffectiveProfile {
507    /// The shared blocking lane limits in force.
508    pub blocking_lane: MetadataIoBlockingLaneProfile,
509    /// The admission limits owned by the actor.
510    pub actor_limits: MetadataIoActorLimitsProfile,
511    /// The retained configuration fields that do not override the lane.
512    pub legacy_blocking_fields: [MetadataIoLegacyBlockingField; 3],
513}
514
515/// The waiter ceiling derived from the admitted pending-operation bound.
516fn max_metadata_waiters(max_pending_operations: usize) -> usize {
517    max_pending_operations.saturating_mul(4).max(1)
518}
519
520/// Caps the planned write concurrency by the lane, so the actor never queues
521/// its own writes behind lane capacity it cannot use.
522fn effective_concurrent_writes(planned: NonZeroUsize, blocking_policy: &BlockingPoolPolicy) -> usize {
523    planned.get().min(blocking_policy.max_concurrency).max(1)
524}
525
526fn effective_profile(
527    config: &MetadataIoConfig,
528    blocking_policy: &BlockingPoolPolicy,
529    max_concurrent_writes: usize,
530) -> MetadataIoEffectiveProfile {
531    let legacy_blocking_field =
532        |field: &'static str, configured: Duration, effective: Duration| MetadataIoLegacyBlockingField {
533            field,
534            configured,
535            effective,
536            effective_source: MetadataLimitSource::BlockingLanePolicy,
537        };
538    MetadataIoEffectiveProfile {
539        blocking_lane: MetadataIoBlockingLaneProfile {
540            name: blocking_policy.name.clone(),
541            max_concurrency: blocking_policy.max_concurrency,
542            max_queue_depth: blocking_policy.max_queue_depth,
543            queue_timeout: blocking_policy.queue_timeout,
544            task_timeout: blocking_policy.task_timeout,
545            warn_after: blocking_policy.warn_after,
546        },
547        actor_limits: MetadataIoActorLimitsProfile {
548            max_pending_operations: config.max_pending_operations,
549            max_pending_bytes: config.max_pending_bytes,
550            max_waiters: max_metadata_waiters(config.max_pending_operations),
551            max_concurrent_writes,
552        },
553        legacy_blocking_fields: [
554            legacy_blocking_field(
555                "blocking_queue_timeout",
556                config.blocking_queue_timeout,
557                blocking_policy.queue_timeout,
558            ),
559            legacy_blocking_field(
560                "blocking_task_timeout",
561                config.blocking_task_timeout,
562                blocking_policy.task_timeout,
563            ),
564            legacy_blocking_field(
565                "blocking_warn_after",
566                config.blocking_warn_after,
567                blocking_policy.warn_after,
568            ),
569        ],
570    }
571}
572
573/// Reports retained blocking fields that a caller configured away from their
574/// defaults.
575///
576/// The notice is emitted once per actor start, so a non-default value cannot
577/// turn into per-operation logging. The lane policy always owns the value in
578/// force, which [`MetadataIoEffectiveProfile`] reports for every field.
579fn warn_legacy_blocking_fields(config: &MetadataIoConfig, blocking_policy: &BlockingPoolPolicy) {
580    let defaults = MetadataIoConfig::default();
581    let changed = [
582        (
583            "blocking_queue_timeout",
584            config.blocking_queue_timeout,
585            defaults.blocking_queue_timeout,
586        ),
587        (
588            "blocking_task_timeout",
589            config.blocking_task_timeout,
590            defaults.blocking_task_timeout,
591        ),
592        (
593            "blocking_warn_after",
594            config.blocking_warn_after,
595            defaults.blocking_warn_after,
596        ),
597    ]
598    .into_iter()
599    .filter(|(_field, configured, default)| configured != default)
600    .map(|(field, _configured, _default)| field)
601    .collect::<Vec<_>>();
602    if changed.is_empty() {
603        return;
604    }
605    tracing::warn!(
606        fields = ?changed,
607        lane = %blocking_policy.name,
608        "metadata blocking fields are retained for source compatibility and do not override the shared blocking lane policy"
609    );
610}
611
612/// A snapshot of one logical metadata resource.
613#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct MetadataIoResourceSnapshot {
615    /// The resource value.
616    pub resource: Arc<str>,
617    /// The target value.
618    pub target: Option<Arc<Path>>,
619    /// The durable generation value.
620    pub durable_generation: Option<MetadataGeneration>,
621    /// The in flight generation value.
622    pub in_flight_generation: Option<MetadataGeneration>,
623    /// The queued generation value.
624    pub queued_generation: Option<MetadataGeneration>,
625    /// The number of waiter entries.
626    pub waiter_count: usize,
627}
628
629/// A point-in-time actor snapshot.
630#[derive(Debug, Clone, PartialEq, Eq)]
631pub struct MetadataIoSnapshot {
632    /// Whether accepting.
633    pub accepting: bool,
634    /// The pending operations value.
635    pub pending_operations: usize,
636    /// The pending size in bytes.
637    pub pending_bytes: usize,
638    /// The max pending operations value.
639    pub max_pending_operations: usize,
640    /// The max pending size in bytes.
641    pub max_pending_bytes: usize,
642    /// The resources value.
643    pub resources: Vec<MetadataIoResourceSnapshot>,
644}
645
646/// Result of stopping admission and draining accepted work.
647#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct MetadataIoShutdownReport {
649    /// Whether timed out.
650    pub timed_out: bool,
651    /// The pending operations value.
652    pub pending_operations: usize,
653    /// The pending size in bytes.
654    pub pending_bytes: usize,
655    /// The unfinished value.
656    pub unfinished: Vec<MetadataIoResourceSnapshot>,
657}
658
659/// A receipt for an accepted immutable snapshot.
660#[derive(Debug)]
661pub struct MetadataIoReceipt {
662    generation: MetadataGeneration,
663    identity: MetadataTargetIdentity,
664    durable: oneshot::Receiver<RuntimeResult<MetadataIoCommitOutcome>>,
665}
666
667/// The normal admission result for one metadata write request.
668///
669/// A target conflict retains the original immutable request so the caller can
670/// retry it after reconciling the resource-to-target mapping.
671#[derive(Debug)]
672pub enum MetadataIoAdmissionOutcome {
673    /// The actor accepted the snapshot and returned its durability receipt.
674    Accepted(MetadataIoReceipt),
675    /// The resource already has pending work for a different target path.
676    TargetConflict(MetadataWriteRequest),
677}
678
679/// The normal durable-completion result for one metadata write request.
680#[derive(Debug)]
681pub enum MetadataIoDurabilityOutcome {
682    /// The accepted snapshot, or a coalesced newer snapshot, became durable.
683    Durable(MetadataGeneration),
684    /// The request was not admitted because its resource has a pending
685    /// snapshot for a different target path.
686    TargetConflict(MetadataWriteRequest),
687}
688
689/// The real persistence conclusion for an accepted metadata snapshot.
690#[derive(Debug, Clone)]
691pub enum MetadataIoCommitOutcome {
692    /// The snapshot or a newer coalesced snapshot completed the persistence
693    /// protocol and is durable.
694    Durable(MetadataGeneration),
695    /// The target file was not replaced.
696    FailedBeforeCommit(RuntimeError),
697    /// The target may have been replaced, but the durability protocol did not
698    /// complete. The caller must reconcile before publishing or retrying.
699    CommitOutcomeUnknown(RuntimeError),
700}
701
702/// Result of waiting for a committed metadata snapshot.
703#[derive(Debug)]
704pub enum MetadataIoCommitAdmissionOutcome {
705    /// The accepted snapshot reached a terminal persistence conclusion.
706    Completed(MetadataIoCommitOutcome),
707    /// The request was not admitted because the resource or target has a
708    /// different process-local writer binding.
709    TargetConflict(MetadataWriteRequest),
710}
711
712/// What a caller actually confirmed about one admitted metadata snapshot.
713///
714/// Unlike [`MetadataIoCommitOutcome`], this models the case where the caller
715/// stopped observing before the persistence protocol reached a terminal
716/// conclusion. An unobserved generation is not evidence that the write did not
717/// happen: the admitted snapshot keeps its ordering and byte charge, so it may
718/// still become durable, fail, or end unconfirmed after the caller's deadline.
719/// Business owners must treat that generation as unconfirmed rather than as a
720/// definite failure.
721///
722/// Every conclusion except a target conflict carries the observed generation,
723/// so a caller can record the change identity and later resolve it against
724/// [`MetadataIoActor::confirmed_durable_generation`].
725#[derive(Debug)]
726pub enum MetadataIoCommitObservation {
727    /// The persistence protocol reached a terminal conclusion within the
728    /// caller's deadline.
729    Settled {
730        /// The generation the caller observed.
731        generation: MetadataGeneration,
732        /// The conclusion the actor delivered.
733        outcome: MetadataIoCommitOutcome,
734    },
735    /// The caller stopped observing first. The generation identifies the
736    /// change whose durability was not confirmed.
737    Unobserved {
738        /// The generation whose durability was not confirmed.
739        generation: MetadataGeneration,
740    },
741    /// The request was not admitted because the resource is bound to a
742    /// different process-local target.
743    TargetConflict(MetadataWriteRequest),
744}
745
746impl MetadataIoCommitObservation {
747    /// Returns whether the caller must treat durable state as unconfirmed.
748    ///
749    /// True for an unobserved generation and for a replacement that the
750    /// durability protocol did not confirm. False for a confirmed durable
751    /// write, a failure before target replacement, and a target conflict.
752    #[must_use]
753    pub fn requires_reconciliation(&self) -> bool {
754        match self {
755            MetadataIoCommitObservation::Settled { outcome, .. } => {
756                matches!(outcome, MetadataIoCommitOutcome::CommitOutcomeUnknown(_))
757            }
758            MetadataIoCommitObservation::Unobserved { .. } => true,
759            MetadataIoCommitObservation::TargetConflict(_) => false,
760        }
761    }
762
763    /// Consumes the observation and returns the terminal conclusion, if any.
764    #[must_use]
765    pub fn settled(self) -> Option<MetadataIoCommitOutcome> {
766        match self {
767            MetadataIoCommitObservation::Settled { outcome, .. } => Some(outcome),
768            MetadataIoCommitObservation::Unobserved { .. } | MetadataIoCommitObservation::TargetConflict(_) => None,
769        }
770    }
771
772    /// Returns the observed generation, or `None` for a target conflict.
773    #[must_use]
774    pub const fn generation(&self) -> Option<MetadataGeneration> {
775        match self {
776            MetadataIoCommitObservation::Settled { generation, .. }
777            | MetadataIoCommitObservation::Unobserved { generation } => Some(*generation),
778            MetadataIoCommitObservation::TargetConflict(_) => None,
779        }
780    }
781
782    /// Returns the generation whose durability was not confirmed.
783    #[must_use]
784    pub const fn unobserved_generation(&self) -> Option<MetadataGeneration> {
785        match self {
786            MetadataIoCommitObservation::Unobserved { generation } => Some(*generation),
787            MetadataIoCommitObservation::Settled { .. } | MetadataIoCommitObservation::TargetConflict(_) => None,
788        }
789    }
790}
791
792/// The private receipt conclusion that keeps the coordinator-abort case
793/// distinct from a deadline expiry.
794///
795/// [`MetadataIoReceipt::wait_until_outcome`] reports a stopped coordinator as
796/// an unavailable context, while [`MetadataIoReceipt::observe_until`] folds it
797/// into an unobserved generation. Both mappings are derived from this single
798/// classification so the receipt channel is interpreted in exactly one place.
799enum ReceiptConclusion {
800    /// The receipt channel delivered the actor's result. That is either a
801    /// terminal persistence conclusion or the worker-stopped error, which the
802    /// actor only produces for a generation that never ran.
803    Delivered(RuntimeResult<MetadataIoCommitOutcome>),
804    /// The caller's absolute deadline elapsed first.
805    Expired,
806    /// The coordinator task was dropped without delivering a conclusion.
807    /// A blocking closure it submitted may still be running.
808    CoordinatorStopped,
809}
810
811impl MetadataIoReceipt {
812    /// Returns the history identity to pair with this receipt's generation.
813    ///
814    /// A retired history cannot prove durability in a later target identity.
815    pub fn target_identity(&self) -> MetadataTargetIdentity {
816        self.identity.clone()
817    }
818    /// Returns the accepted resource generation.
819    #[must_use]
820    pub const fn generation(&self) -> MetadataGeneration {
821        self.generation
822    }
823
824    /// Waits for this generation, or a newer coalesced generation, to become
825    /// durable without extending the caller's absolute deadline.
826    ///
827    /// Expiry abandons this observation. Accepted writes retain their ordering
828    /// and byte charge until actual completion, and may still become durable.
829    ///
830    /// # Errors
831    ///
832    /// Returns an operational runtime failure if persistence cannot complete.
833    pub async fn wait_until_outcome(self, deadline: MetadataDeadline) -> RuntimeResult<MetadataIoCommitOutcome> {
834        if deadline.is_expired() {
835            return Err(RuntimeError::timed_out(crate::RuntimeOperation::WaitForDurableMetadata));
836        }
837        match self.conclude(deadline).await {
838            ReceiptConclusion::Delivered(result) => result,
839            ReceiptConclusion::CoordinatorStopped => {
840                Err(RuntimeError::closed(crate::RuntimeOperation::MetadataWorkerStopped))
841            }
842            ReceiptConclusion::Expired => Err(RuntimeError::timed_out(crate::RuntimeOperation::WaitForDurableMetadata)),
843        }
844    }
845
846    /// Observes this generation without collapsing an observation timeout into
847    /// the persistence error channel.
848    ///
849    /// Expiry returns [`MetadataIoCommitObservation::Unobserved`] rather than
850    /// an error, because the accepted snapshot retains its ordering and byte
851    /// charge and may still become durable. A caller that needs a definite
852    /// answer must reconcile against the actor's confirmed durable generation
853    /// instead of treating the expiry as a failure.
854    ///
855    /// This method never returns [`MetadataIoCommitObservation::TargetConflict`]:
856    /// a receipt already identifies an admitted write. It retains only the
857    /// result and history identity, not write authority.
858    pub async fn observe_until(self, deadline: MetadataDeadline) -> MetadataIoCommitObservation {
859        let generation = self.generation;
860        match self.conclude(deadline).await {
861            ReceiptConclusion::Delivered(Ok(outcome)) => MetadataIoCommitObservation::Settled { generation, outcome },
862            // The actor delivered the worker-stopped error, which it only
863            // produces for a generation that never ran. That is a definite
864            // pre-commit failure rather than an unconfirmed replacement.
865            ReceiptConclusion::Delivered(Err(error)) => MetadataIoCommitObservation::Settled {
866                generation,
867                outcome: MetadataIoCommitOutcome::FailedBeforeCommit(error),
868            },
869            // A stopped coordinator and an elapsed deadline are both
870            // "no conclusion observed": a submitted closure may still be
871            // running, and the target may still change.
872            ReceiptConclusion::CoordinatorStopped | ReceiptConclusion::Expired => {
873                MetadataIoCommitObservation::Unobserved { generation }
874            }
875        }
876    }
877
878    /// Classifies the receipt channel exactly once for every caller.
879    async fn conclude(self, deadline: MetadataDeadline) -> ReceiptConclusion {
880        match tokio::time::timeout_at(deadline.instant(), self.durable).await {
881            Ok(Ok(result)) => ReceiptConclusion::Delivered(result),
882            Ok(Err(_closed)) => ReceiptConclusion::CoordinatorStopped,
883            Err(_elapsed) => ReceiptConclusion::Expired,
884        }
885    }
886
887    /// Waits for this generation, or a newer coalesced generation, to become
888    /// durable without extending the caller's absolute deadline.
889    ///
890    /// Expiry abandons this observation. Accepted writes retain their ordering
891    /// and byte charge until actual completion, and may still become durable.
892    ///
893    /// # Errors
894    ///
895    /// Returns an operational runtime failure if persistence cannot complete.
896    pub async fn wait_until(self, deadline: MetadataDeadline) -> RuntimeResult<MetadataGeneration> {
897        match self.wait_until_outcome(deadline).await? {
898            MetadataIoCommitOutcome::Durable(generation) => Ok(generation),
899            MetadataIoCommitOutcome::FailedBeforeCommit(source)
900            | MetadataIoCommitOutcome::CommitOutcomeUnknown(source) => Err(source),
901        }
902    }
903}
904
905/// A cloneable handle to the bounded metadata I/O owner.
906#[derive(Debug, Clone)]
907pub struct MetadataIoActor {
908    inner: Arc<ActorInner>,
909    sender: mpsc::Sender<Arc<str>>,
910}
911
912/// Read-only access that does not retain metadata admission or target ownership.
913#[derive(Debug, Clone)]
914pub struct MetadataIoObserver {
915    inner: Weak<ActorInner>,
916}
917
918impl MetadataIoObserver {
919    /// Reads bounded actor state, or returns `None` after its owners are released.
920    ///
921    /// The registry capacity bounds the resource scan. This call performs no I/O.
922    pub fn snapshot(&self) -> Option<MetadataIoSnapshot> {
923        self.inner.upgrade().map(|inner| snapshot(&inner))
924    }
925}
926
927#[derive(Debug)]
928struct ActorInner {
929    config: MetadataIoConfig,
930    blocking_policy: BlockingPoolPolicy,
931    max_concurrent_writes: usize,
932    /// Charges every retained snapshot to the owner's process budget.
933    retained_bytes: ResourceBudget,
934    targets: MetadataTargetRegistry,
935    waiter_count: Arc<AtomicUsize>,
936    state: Mutex<ActorState>,
937    shutdown: Notify,
938    worker_finished: Notify,
939}
940
941#[derive(Debug)]
942struct ActorState {
943    accepting: bool,
944    worker_finished: bool,
945    pending_operations: usize,
946    pending_bytes: usize,
947    resources: HashMap<Arc<str>, ResourceState>,
948}
949
950#[derive(Debug, Default)]
951struct ResourceState {
952    identity: Option<MetadataTargetIdentity>,
953    target: Option<Arc<Path>>,
954    target_registration: Option<MetadataTargetRegistration>,
955    durable_generation: Option<MetadataGeneration>,
956    in_flight: Option<WorkMeta>,
957    queued: Option<QueuedMetadataWrite>,
958    waiters: Vec<GenerationWaiter>,
959}
960
961#[derive(Debug)]
962struct QueuedMetadataWrite {
963    request: MetadataWriteRequest,
964    registration: MetadataTargetRegistration,
965    /// The snapshot's process-budget charge. Replacing a queued generation
966    /// releases it with the replaced snapshot.
967    charge: ResourcePermit,
968}
969
970#[derive(Debug)]
971struct WorkMeta {
972    generation: MetadataGeneration,
973    bytes: usize,
974}
975
976// Field order drops the actual payload before returning its byte reservation,
977// including when Tokio discards a closure before it starts.
978struct ChargedSnapshot {
979    bytes: Arc<[u8]>,
980    _charge: ResourcePermit,
981}
982
983#[derive(Debug)]
984struct GenerationWaiter {
985    generation: MetadataGeneration,
986    sender: oneshot::Sender<RuntimeResult<MetadataIoCommitOutcome>>,
987    _permit: WaiterPermit,
988}
989
990#[derive(Debug)]
991struct WaiterPermit {
992    waiter_count: Arc<AtomicUsize>,
993}
994
995impl Drop for WaiterPermit {
996    fn drop(&mut self) {
997        self.waiter_count.fetch_sub(1, Ordering::AcqRel);
998    }
999}
1000
1001impl ActorInner {
1002    fn reserve_waiter(&self, resource_waiters: usize) -> RuntimeResult<WaiterPermit> {
1003        let max_waiters = max_metadata_waiters(self.config.max_pending_operations);
1004        if resource_waiters >= max_waiters {
1005            return Err(RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataOperation));
1006        }
1007
1008        let mut current = self.waiter_count.load(Ordering::Acquire);
1009        loop {
1010            if current >= max_waiters {
1011                return Err(RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataOperation));
1012            }
1013            match self
1014                .waiter_count
1015                .compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Acquire)
1016            {
1017                Ok(_) => {
1018                    return Ok(WaiterPermit {
1019                        waiter_count: Arc::clone(&self.waiter_count),
1020                    });
1021                }
1022                Err(observed) => current = observed,
1023            }
1024        }
1025    }
1026}
1027
1028impl MetadataIoActor {
1029    fn start_validated(
1030        service_context: &ChildServiceContext,
1031        plan: MetadataIoPlan,
1032        file_system: Arc<dyn MetadataFileSystem>,
1033    ) -> RuntimeResult<Self> {
1034        let MetadataIoPlan {
1035            config,
1036            max_concurrent_writes,
1037        } = plan;
1038        let task_group = service_context.component("metadata-io").task_group().clone();
1039        let blocking = service_context.metadata_io().clone();
1040        let blocking_policy = blocking.policy().clone();
1041        warn_legacy_blocking_fields(&config, &blocking_policy);
1042        let max_concurrent_writes = effective_concurrent_writes(max_concurrent_writes, &blocking_policy);
1043        let retained_bytes = retained_bytes_budget(&service_context.process_budget())?;
1044        let targets = service_context.resources().metadata_targets();
1045        let (sender, receiver) = mpsc::channel(config.max_pending_operations);
1046        let inner = Arc::new(ActorInner {
1047            config,
1048            blocking_policy,
1049            max_concurrent_writes,
1050            retained_bytes,
1051            targets,
1052            waiter_count: Arc::new(AtomicUsize::new(0)),
1053            state: Mutex::new(ActorState {
1054                accepting: true,
1055                worker_finished: false,
1056                pending_operations: 0,
1057                pending_bytes: 0,
1058                resources: HashMap::new(),
1059            }),
1060            shutdown: Notify::new(),
1061            worker_finished: Notify::new(),
1062        });
1063        let actor = Self {
1064            inner: inner.clone(),
1065            sender,
1066        };
1067        let cancellation = task_group.cancellation_token();
1068        task_group
1069            .spawn_service(
1070                "metadata-io.coordinator",
1071                run_actor(inner, receiver, blocking, file_system, cancellation),
1072            )
1073            .map_err(startup_error)?;
1074        Ok(actor)
1075    }
1076
1077    /// Attempts to admit one immutable snapshot without waiting for disk durability.
1078    ///
1079    /// The call is deliberately non-blocking. Queue or byte saturation is
1080    /// returned immediately and the absolute deadline is checked before
1081    /// admission.
1082    ///
1083    /// Returns a normal [`MetadataIoAdmissionOutcome::TargetConflict`] when
1084    /// the same resource already has pending work for a different target.
1085    ///
1086    /// # Errors
1087    ///
1088    /// Returns a typed deadline, capacity, or lifecycle failure.
1089    pub fn submit(
1090        &self,
1091        request: MetadataWriteRequest,
1092        deadline: MetadataDeadline,
1093    ) -> RuntimeResult<MetadataIoAdmissionOutcome> {
1094        if deadline.is_expired() {
1095            return Err(RuntimeError::timed_out(crate::RuntimeOperation::AdmitMetadataSnapshot));
1096        }
1097
1098        let resource = request.resource.clone();
1099        let generation = request.generation;
1100        let (waiter_sender, durable) = oneshot::channel();
1101        let mut state = self.inner.state.lock();
1102        if !state.accepting {
1103            return Err(RuntimeError::closed(crate::RuntimeOperation::MetadataIoClosed));
1104        }
1105
1106        let Some(target_registration) = ensure_target_registration(&mut state, &self.inner.targets, &request)? else {
1107            return Ok(MetadataIoAdmissionOutcome::TargetConflict(request));
1108        };
1109
1110        let existing = state.resources.get(&resource);
1111        if let Some(existing) = existing {
1112            if let Some(durable_generation) = existing.durable_generation {
1113                if generation <= durable_generation {
1114                    let _ = waiter_sender.send(Ok(MetadataIoCommitOutcome::Durable(durable_generation)));
1115                    return Ok(MetadataIoAdmissionOutcome::Accepted(MetadataIoReceipt {
1116                        generation,
1117                        identity: target_registration.identity(),
1118                        durable,
1119                    }));
1120                }
1121            }
1122            if let Some(in_flight) = &existing.in_flight {
1123                if generation <= in_flight.generation {
1124                    let waiter_permit = self.inner.reserve_waiter(existing.waiters.len())?;
1125                    let Some(resource_state) = state.resources.get_mut(&resource) else {
1126                        return Err(RuntimeError::closed(crate::RuntimeOperation::MetadataWorkerStopped));
1127                    };
1128                    resource_state.waiters.push(GenerationWaiter {
1129                        generation,
1130                        sender: waiter_sender,
1131                        _permit: waiter_permit,
1132                    });
1133                    return Ok(MetadataIoAdmissionOutcome::Accepted(MetadataIoReceipt {
1134                        generation,
1135                        identity: target_registration.identity(),
1136                        durable,
1137                    }));
1138                }
1139            }
1140            if let Some(queued) = &existing.queued {
1141                if generation <= queued.request.generation {
1142                    let waiter_permit = self.inner.reserve_waiter(existing.waiters.len())?;
1143                    let Some(resource_state) = state.resources.get_mut(&resource) else {
1144                        return Err(RuntimeError::closed(crate::RuntimeOperation::MetadataWorkerStopped));
1145                    };
1146                    resource_state.waiters.push(GenerationWaiter {
1147                        generation,
1148                        sender: waiter_sender,
1149                        _permit: waiter_permit,
1150                    });
1151                    return Ok(MetadataIoAdmissionOutcome::Accepted(MetadataIoReceipt {
1152                        generation,
1153                        identity: target_registration.identity(),
1154                        durable,
1155                    }));
1156                }
1157            }
1158        }
1159
1160        let waiter_permit = self
1161            .inner
1162            .reserve_waiter(existing.map_or(0, |resource_state| resource_state.waiters.len()))?;
1163        let old_queued_bytes = existing
1164            .and_then(|resource_state| resource_state.queued.as_ref())
1165            .map_or(0, |queued| queued.request.len());
1166        let adds_operation = existing.is_none_or(|resource_state| resource_state.queued.is_none());
1167        let needs_queue_token =
1168            existing.is_none_or(|resource_state| resource_state.in_flight.is_none() && resource_state.queued.is_none());
1169        let next_operations = state.pending_operations + usize::from(adds_operation);
1170        if next_operations > self.inner.config.max_pending_operations {
1171            return Err(RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataOperation));
1172        }
1173        let retained_without_replaced = state.pending_bytes.saturating_sub(old_queued_bytes);
1174        let next_bytes = retained_without_replaced
1175            .checked_add(request.len())
1176            .ok_or_else(|| RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataBytes))?;
1177        if next_bytes > self.inner.config.max_pending_bytes {
1178            return Err(RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataBytes));
1179        }
1180        let queue_permit = if needs_queue_token {
1181            Some(
1182                self.sender
1183                    .try_reserve()
1184                    .map_err(|_error| RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataOperation))?,
1185            )
1186        } else {
1187            None
1188        };
1189
1190        let resource_state = state.resources.entry(resource.clone()).or_default();
1191        if let Some(queued) = &mut resource_state.queued {
1192            let new_bytes = request.len();
1193            // Only the growth is reserved, and a rejection leaves the old
1194            // generation intact. Shrink only after dropping the old payload.
1195            queued
1196                .charge
1197                .try_resize(new_bytes.max(queued.charge.bytes()))
1198                .map_err(|_rejection| RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataBytes))?;
1199            queued.request = request;
1200            queued.charge.shrink_to(new_bytes);
1201            queued.registration = target_registration.clone();
1202        } else {
1203            let charge = self
1204                .inner
1205                .retained_bytes
1206                .try_acquire_data(request.len())
1207                .map_err(|_rejection| RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataBytes))?;
1208            resource_state.queued = Some(QueuedMetadataWrite {
1209                request,
1210                registration: target_registration.clone(),
1211                charge,
1212            });
1213        }
1214        let identity = target_registration.identity();
1215        resource_state.target = resource_state
1216            .queued
1217            .as_ref()
1218            .map(|queued| queued.request.target.clone());
1219        resource_state.target_registration = Some(target_registration.clone());
1220        resource_state.waiters.push(GenerationWaiter {
1221            generation,
1222            sender: waiter_sender,
1223            _permit: waiter_permit,
1224        });
1225        state.pending_operations = next_operations;
1226        state.pending_bytes = next_bytes;
1227        drop(state);
1228        if let Some(permit) = queue_permit {
1229            permit.send(resource.clone());
1230        }
1231        Ok(MetadataIoAdmissionOutcome::Accepted(MetadataIoReceipt {
1232            generation,
1233            identity,
1234            durable,
1235        }))
1236    }
1237
1238    /// Assigns the next owner-lifetime generation and accepts an immutable
1239    /// snapshot without waiting for durability.
1240    ///
1241    /// Generation values are unique across every metadata actor that shares
1242    /// this runtime owner, so a generation stays a usable change identity
1243    /// after an actor is replaced. Gaps are allowed when admission rejects a
1244    /// request; they do not weaken ordering.
1245    ///
1246    /// # Errors
1247    ///
1248    /// Returns the same normal outcome and typed failures as [`Self::submit`].
1249    pub fn submit_next(
1250        &self,
1251        resource: impl Into<Arc<str>>,
1252        target: impl Into<PathBuf>,
1253        bytes: impl Into<Vec<u8>>,
1254        deadline: MetadataDeadline,
1255    ) -> RuntimeResult<MetadataIoAdmissionOutcome> {
1256        let generation = self.inner.targets.next_generation();
1257        self.submit(MetadataWriteRequest::new(resource, generation, target, bytes), deadline)
1258    }
1259
1260    /// Accepts a snapshot and waits for durable completion using the same
1261    /// absolute deadline.
1262    ///
1263    /// Returns [`MetadataIoDurabilityOutcome::TargetConflict`] without
1264    /// starting I/O when a pending request owns a different target path.
1265    ///
1266    /// # Errors
1267    ///
1268    /// Returns a typed admission, persistence, worker, or deadline failure.
1269    pub async fn submit_durable(
1270        &self,
1271        request: MetadataWriteRequest,
1272        deadline: MetadataDeadline,
1273    ) -> RuntimeResult<MetadataIoDurabilityOutcome> {
1274        match self.submit(request, deadline)? {
1275            MetadataIoAdmissionOutcome::Accepted(receipt) => receipt
1276                .wait_until(deadline)
1277                .await
1278                .map(MetadataIoDurabilityOutcome::Durable),
1279            MetadataIoAdmissionOutcome::TargetConflict(request) => {
1280                Ok(MetadataIoDurabilityOutcome::TargetConflict(request))
1281            }
1282        }
1283    }
1284
1285    /// Accepts a snapshot and returns its real persistence conclusion.
1286    ///
1287    /// This distinguishes a failure before target replacement from an
1288    /// unconfirmed replacement followed by a parent-directory sync failure.
1289    pub async fn submit_commit(
1290        &self,
1291        request: MetadataWriteRequest,
1292        deadline: MetadataDeadline,
1293    ) -> RuntimeResult<MetadataIoCommitAdmissionOutcome> {
1294        match self.submit(request, deadline)? {
1295            MetadataIoAdmissionOutcome::Accepted(receipt) => receipt
1296                .wait_until_outcome(deadline)
1297                .await
1298                .map(MetadataIoCommitAdmissionOutcome::Completed),
1299            MetadataIoAdmissionOutcome::TargetConflict(request) => {
1300                Ok(MetadataIoCommitAdmissionOutcome::TargetConflict(request))
1301            }
1302        }
1303    }
1304
1305    /// Assigns the next process-lifetime generation, accepts the immutable
1306    /// snapshot, and waits for durable completion using one absolute deadline.
1307    ///
1308    /// # Errors
1309    ///
1310    /// Returns the same normal outcome and typed failures as
1311    /// [`Self::submit_durable`].
1312    pub async fn submit_next_durable(
1313        &self,
1314        resource: impl Into<Arc<str>>,
1315        target: impl Into<PathBuf>,
1316        bytes: impl Into<Vec<u8>>,
1317        deadline: MetadataDeadline,
1318    ) -> RuntimeResult<MetadataIoDurabilityOutcome> {
1319        match self.submit_next(resource, target, bytes, deadline)? {
1320            MetadataIoAdmissionOutcome::Accepted(receipt) => receipt
1321                .wait_until(deadline)
1322                .await
1323                .map(MetadataIoDurabilityOutcome::Durable),
1324            MetadataIoAdmissionOutcome::TargetConflict(request) => {
1325                Ok(MetadataIoDurabilityOutcome::TargetConflict(request))
1326            }
1327        }
1328    }
1329
1330    /// Assigns the next owner-lifetime generation and returns its real
1331    /// persistence conclusion.
1332    pub async fn submit_next_commit(
1333        &self,
1334        resource: impl Into<Arc<str>>,
1335        target: impl Into<PathBuf>,
1336        bytes: impl Into<Vec<u8>>,
1337        deadline: MetadataDeadline,
1338    ) -> RuntimeResult<MetadataIoCommitAdmissionOutcome> {
1339        match self.submit_next(resource, target, bytes, deadline)? {
1340            MetadataIoAdmissionOutcome::Accepted(receipt) => receipt
1341                .wait_until_outcome(deadline)
1342                .await
1343                .map(MetadataIoCommitAdmissionOutcome::Completed),
1344            MetadataIoAdmissionOutcome::TargetConflict(request) => {
1345                Ok(MetadataIoCommitAdmissionOutcome::TargetConflict(request))
1346            }
1347        }
1348    }
1349
1350    /// Accepts a snapshot and reports what the caller actually confirmed about
1351    /// its persistence.
1352    ///
1353    /// An `Err` result means the request was never admitted, so the caller may
1354    /// safely discard its change. Every other case is expressed as a
1355    /// [`MetadataIoCommitObservation`], including an expiry that leaves the
1356    /// change unconfirmed.
1357    ///
1358    /// # Errors
1359    ///
1360    /// Returns a typed admission, capacity, or lifecycle failure.
1361    pub async fn submit_observed(
1362        &self,
1363        request: MetadataWriteRequest,
1364        deadline: MetadataDeadline,
1365    ) -> RuntimeResult<MetadataIoCommitObservation> {
1366        match self.submit(request, deadline)? {
1367            MetadataIoAdmissionOutcome::Accepted(receipt) => Ok(receipt.observe_until(deadline).await),
1368            MetadataIoAdmissionOutcome::TargetConflict(request) => {
1369                Ok(MetadataIoCommitObservation::TargetConflict(request))
1370            }
1371        }
1372    }
1373
1374    /// Assigns the next owner-lifetime generation and reports what the caller
1375    /// actually confirmed about its persistence.
1376    ///
1377    /// # Errors
1378    ///
1379    /// Returns the same normal outcome and typed failures as
1380    /// [`Self::submit_observed`].
1381    pub async fn submit_next_observed(
1382        &self,
1383        resource: impl Into<Arc<str>>,
1384        target: impl Into<PathBuf>,
1385        bytes: impl Into<Vec<u8>>,
1386        deadline: MetadataDeadline,
1387    ) -> RuntimeResult<MetadataIoCommitObservation> {
1388        match self.submit_next(resource, target, bytes, deadline)? {
1389            MetadataIoAdmissionOutcome::Accepted(receipt) => Ok(receipt.observe_until(deadline).await),
1390            MetadataIoAdmissionOutcome::TargetConflict(request) => {
1391                Ok(MetadataIoCommitObservation::TargetConflict(request))
1392            }
1393        }
1394    }
1395
1396    /// Returns the highest generation this actor confirmed durable for one
1397    /// logical resource.
1398    ///
1399    /// This is the evidence a caller needs to resolve an earlier
1400    /// [`MetadataIoCommitObservation::Unobserved`] conclusion without holding
1401    /// completion history: when the value reaches the unobserved generation,
1402    /// the change is durable after all. The value is seeded from the
1403    /// owner-scoped target registration, so it survives actor replacement for
1404    /// the same resource and target. After explicit retirement, compare
1405    /// [`Self::target_identity`] with the receipt's identity first: generations
1406    /// from a different history are not evidence about the old write.
1407    #[must_use]
1408    pub fn confirmed_durable_generation(&self, resource: &str) -> Option<MetadataGeneration> {
1409        let state = self.inner.state.lock();
1410        state
1411            .resources
1412            .get(resource)
1413            .and_then(|resource_state| resource_state.durable_generation)
1414    }
1415
1416    /// Stops new admission and drains accepted work until the absolute
1417    /// deadline.
1418    #[must_use]
1419    pub async fn shutdown_until(&self, deadline: MetadataDeadline) -> MetadataIoShutdownReport {
1420        self.stop_admission();
1421        loop {
1422            let finished_notification = self.inner.worker_finished.notified();
1423            tokio::pin!(finished_notification);
1424            finished_notification.as_mut().enable();
1425            let finished = self.inner.state.lock().worker_finished;
1426            if finished {
1427                return shutdown_report(&self.inner, false);
1428            }
1429            if deadline.is_expired() {
1430                return shutdown_report(&self.inner, true);
1431            }
1432            if tokio::time::timeout_at(deadline.instant(), finished_notification)
1433                .await
1434                .is_err()
1435            {
1436                return shutdown_report(&self.inner, true);
1437            }
1438        }
1439    }
1440
1441    /// Rejects future submissions while allowing accepted work to drain.
1442    pub fn stop_admission(&self) {
1443        stop_admission(&self.inner);
1444    }
1445
1446    /// Returns current queue, byte, generation, and waiter state.
1447    #[must_use]
1448    pub fn snapshot(&self) -> MetadataIoSnapshot {
1449        snapshot(&self.inner)
1450    }
1451
1452    /// Creates a diagnostic observer without retaining write authority.
1453    pub fn observer(&self) -> MetadataIoObserver {
1454        MetadataIoObserver {
1455            inner: Arc::downgrade(&self.inner),
1456        }
1457    }
1458
1459    /// Returns the target history associated with this actor's resource state.
1460    pub fn target_identity(&self, resource: &str) -> Option<MetadataTargetIdentity> {
1461        self.inner
1462            .state
1463            .lock()
1464            .resources
1465            .get(resource)
1466            .and_then(|state| state.identity.clone())
1467    }
1468
1469    /// Ends a closed, drained resource's durable history to reclaim registry capacity.
1470    ///
1471    /// This is an explicit identity boundary, not ordinary actor replacement.
1472    /// Future actors acquire a new identity and may bind a different resource
1473    /// to the path. Old actors cannot resume that history; retained read-only
1474    /// receipts keep their original result and identity. Compare generations
1475    /// only within the same identity.
1476    ///
1477    /// Unconfirmed replacements retain their fence. The runtime cannot verify
1478    /// business file formats and does not offer unconditional fence clearing.
1479    ///
1480    /// # Errors
1481    ///
1482    /// Returns a typed path normalization error without modifying the history.
1483    pub fn retire_target_for_new_identity(&self, resource: &str) -> RuntimeResult<MetadataTargetRetirementOutcome> {
1484        let mut state = self.inner.state.lock();
1485        if state.accepting {
1486            return Ok(MetadataTargetRetirementOutcome::AdmissionOpen);
1487        }
1488        if !state.worker_finished {
1489            return Ok(MetadataTargetRetirementOutcome::WorkInProgress);
1490        }
1491        let Some(resource_state) = state.resources.get_mut(resource) else {
1492            return Ok(MetadataTargetRetirementOutcome::NotFound);
1493        };
1494        if resource_state.in_flight.is_some() || resource_state.queued.is_some() || !resource_state.waiters.is_empty() {
1495            return Ok(MetadataTargetRetirementOutcome::WorkInProgress);
1496        }
1497        let (Some(target), Some(identity)) = (&resource_state.target, &resource_state.identity) else {
1498            return Ok(MetadataTargetRetirementOutcome::NotFound);
1499        };
1500        // A cached, idle registration has no pending work after coordinator completion.
1501        // Real blocking closures retain their own clone and still prevent retirement.
1502        resource_state.target_registration.take();
1503        let outcome = self
1504            .inner
1505            .targets
1506            .retire(target, resource, identity, resource_state.durable_generation)?;
1507        if outcome == MetadataTargetRetirementOutcome::Retired {
1508            state.resources.remove(resource);
1509        }
1510        Ok(outcome)
1511    }
1512
1513    /// Returns the metadata I/O limits that are actually in force.
1514    ///
1515    /// The view is read-only and derived from the validated configuration and
1516    /// the root blocking lane policy captured at startup, so it reports the
1517    /// values that drive execution rather than the values a caller requested.
1518    #[must_use]
1519    pub fn effective_profile(&self) -> MetadataIoEffectiveProfile {
1520        effective_profile(
1521            &self.inner.config,
1522            &self.inner.blocking_policy,
1523            self.inner.max_concurrent_writes,
1524        )
1525    }
1526}
1527
1528/// Derives the ledger node that charges retained snapshots to the process
1529/// budget.
1530///
1531/// The node copies its parent's limits instead of adding its own:
1532/// `max_pending_bytes` stays the actor's admission bound, while the process
1533/// budget bounds what every budgeted component retains together, including the
1534/// other metadata actors of the same owner.
1535fn retained_bytes_budget(process_budget: &ResourceBudget) -> RuntimeResult<ResourceBudget> {
1536    let parent = process_budget.limit();
1537    let limit = BudgetLimit {
1538        capacity: parent.capacity,
1539        control_reserve: BudgetCapacity::default(),
1540        max_age: parent.max_age,
1541        full_policy: FullPolicy::Reject,
1542    };
1543    process_budget.child("metadata-io", limit).map_err(|violation| {
1544        RuntimeError::configuration_failure(crate::RuntimeOperation::AdmitMetadataBytes, violation)
1545    })
1546}
1547
1548fn ensure_target_registration(
1549    state: &mut ActorState,
1550    registry: &MetadataTargetRegistry,
1551    request: &MetadataWriteRequest,
1552) -> RuntimeResult<Option<MetadataTargetRegistration>> {
1553    let resource = &request.resource;
1554    // Retired identities remain tombstones in older actors. Bound this cache
1555    // independently of the shared registry, whose slots can be reused.
1556    if !state.resources.contains_key(resource) && state.resources.len() >= registry.capacity() {
1557        return Err(RuntimeError::capacity(crate::RuntimeOperation::AdmitMetadataOperation));
1558    }
1559    let mut target_changed = false;
1560    if let Some(existing) = state.resources.get(resource) {
1561        if let Some(target) = &existing.target {
1562            target_changed = !registry.same_target(target.as_ref(), request.target.as_ref())?;
1563            if target_changed && (existing.in_flight.is_some() || existing.queued.is_some()) {
1564                return Ok(None);
1565            }
1566            if !target_changed {
1567                if let Some(registration) = &existing.target_registration {
1568                    // Reusing a cached registration skips the registry check
1569                    // below, so it has to consult the target's reconciliation
1570                    // fence explicitly. Without this, a queued generation would
1571                    // write over a target whose previous replacement never
1572                    // completed the durability protocol.
1573                    if registration.reconciliation_required() {
1574                        return Err(RuntimeError::context_unavailable(
1575                            crate::RuntimeOperation::MetadataResourceTarget,
1576                        ));
1577                    }
1578                    return Ok(Some(registration.clone()));
1579                }
1580            }
1581        }
1582    }
1583
1584    let expected = state
1585        .resources
1586        .get(resource)
1587        .filter(|_| !target_changed)
1588        .and_then(|state| state.identity.as_ref());
1589    let registration = match registry.register_with_identity(request.target.as_ref(), Arc::clone(resource), expected)? {
1590        MetadataTargetRegistrationOutcome::Registered(registration) => registration,
1591        MetadataTargetRegistrationOutcome::Conflict => return Ok(None),
1592        MetadataTargetRegistrationOutcome::ReconciliationRequired => {
1593            return Err(RuntimeError::context_unavailable(
1594                crate::RuntimeOperation::MetadataResourceTarget,
1595            ));
1596        }
1597    };
1598    let durable_generation = registration.durable_generation();
1599    let resource_state = state.resources.entry(Arc::clone(resource)).or_default();
1600    if target_changed {
1601        resource_state.target_registration = None;
1602        resource_state.durable_generation = None;
1603    }
1604    resource_state.target = Some(request.target.clone());
1605    resource_state.identity = Some(registration.identity());
1606    resource_state.target_registration = Some(registration.clone());
1607    if let Some(durable_generation) = durable_generation {
1608        resource_state.durable_generation = Some(
1609            resource_state
1610                .durable_generation
1611                .map_or(durable_generation, |current| current.max(durable_generation)),
1612        );
1613    }
1614    Ok(Some(registration))
1615}
1616
1617async fn run_actor(
1618    inner: Arc<ActorInner>,
1619    mut receiver: mpsc::Receiver<Arc<str>>,
1620    blocking: BlockingExecutor,
1621    file_system: Arc<dyn MetadataFileSystem>,
1622    cancellation: CancellationToken,
1623) {
1624    let mut cancellation_observed = false;
1625    let mut channel_closed = false;
1626    // A resource with work is in exactly one place: the channel, `ready`, or
1627    // `writes`. Writes of one resource therefore never overlap, and each keeps
1628    // its generation order however many resources are written at once.
1629    let mut ready: VecDeque<Arc<str>> = VecDeque::new();
1630    let mut writes = FuturesUnordered::new();
1631    loop {
1632        if !cancellation_observed && cancellation.is_cancelled() {
1633            cancellation_observed = true;
1634            stop_admission(&inner);
1635        }
1636        while writes.len() < inner.max_concurrent_writes {
1637            let Some(resource) = ready.pop_front() else {
1638                break;
1639            };
1640            writes.push(write_resource(&inner, &blocking, &file_system, resource));
1641        }
1642        if writes.is_empty() && should_finish(&inner) {
1643            break;
1644        }
1645        tokio::select! {
1646            biased;
1647            _ = cancellation.cancelled(), if !cancellation_observed => {
1648                cancellation_observed = true;
1649                stop_admission(&inner);
1650            }
1651            Some((resource, has_more)) = writes.next(), if !writes.is_empty() => {
1652                // Resources that arrived during the write go first, so a
1653                // resource that keeps receiving generations cannot starve them.
1654                while let Ok(pending) = receiver.try_recv() {
1655                    ready.push_back(pending);
1656                }
1657                if has_more {
1658                    ready.push_back(resource);
1659                }
1660            }
1661            _ = inner.shutdown.notified() => {}
1662            resource = receiver.recv(), if !channel_closed => {
1663                match resource {
1664                    Some(resource) => ready.push_back(resource),
1665                    None => {
1666                        channel_closed = true;
1667                        stop_admission(&inner);
1668                    }
1669                }
1670            }
1671        }
1672    }
1673    finish_worker(&inner);
1674}
1675
1676/// Writes the next generation of `resource` and reports whether another one
1677/// was queued meanwhile.
1678async fn write_resource(
1679    inner: &Arc<ActorInner>,
1680    blocking: &BlockingExecutor,
1681    file_system: &Arc<dyn MetadataFileSystem>,
1682    resource: Arc<str>,
1683) -> (Arc<str>, bool) {
1684    let has_more = process_resource(inner, blocking, file_system, resource.clone()).await;
1685    (resource, has_more)
1686}
1687
1688async fn process_resource(
1689    inner: &Arc<ActorInner>,
1690    blocking: &BlockingExecutor,
1691    file_system: &Arc<dyn MetadataFileSystem>,
1692    resource: Arc<str>,
1693) -> bool {
1694    let Some(QueuedMetadataWrite {
1695        request,
1696        registration,
1697        charge,
1698    }) = take_next_request(inner, &resource)
1699    else {
1700        return false;
1701    };
1702    let MetadataWriteRequest {
1703        target,
1704        bytes,
1705        generation,
1706        lane_deadline,
1707        ..
1708    } = request;
1709    // A request-level deadline is the only value that may tighten the lane
1710    // phase budgets. The durability deadline a caller passes to `submit` is
1711    // deliberately not reused here: an admitted write keeps its ordering and
1712    // byte charge after the caller stops observing it.
1713    let lane_deadline = lane_deadline.map(|deadline| ShutdownDeadline::at(deadline.instant().into_std()));
1714    let worker_file_system = file_system.clone();
1715    let completion_registration = registration.clone();
1716    let snapshot = ChargedSnapshot { bytes, _charge: charge };
1717    let result = match blocking
1718        .submit_io_until(format!("metadata-io:{resource}"), lane_deadline, move || {
1719            // Capture the whole owner, not individual fields: the byte charge
1720            // survives coordinator cancellation and leaves with the payload.
1721            let snapshot = snapshot;
1722            let result = worker_file_system.persist_atomic(&target, &snapshot.bytes);
1723            match &result {
1724                Ok(()) => {
1725                    let _ = completion_registration.record_durable(generation);
1726                }
1727                Err(error) if error.operation() == crate::RuntimeOperation::MetadataSyncParent => {
1728                    let _ = completion_registration.record_reconciliation_required();
1729                }
1730                Err(_) => {}
1731            }
1732            result
1733        })
1734        .await
1735    {
1736        // A caller/lane wait timeout cannot transfer write ownership: Tokio
1737        // may still be running this closure. Receipts have their own deadlines.
1738        Ok(mut task) => task.wait().await.and_then(|result| result),
1739        Err(source) => Err(source),
1740    };
1741    // A request's file mutation and durability accounting are complete once its blocking
1742    // closure returns, so this writer's target authority is released before the completion
1743    // is published. Publishing while the authority is still held would let a caller that
1744    // observes the confirmed generation race this release: the completion clears the
1745    // cached registration, so the follow-up write for this resource and target has to
1746    // re-register and would find the previous generation's registration still alive. A
1747    // queued generation keeps its own clone, so its authority outlives this release.
1748    drop(registration);
1749    finish_request(inner, &resource, generation, result)
1750}
1751
1752fn take_next_request(inner: &ActorInner, resource: &Arc<str>) -> Option<QueuedMetadataWrite> {
1753    let mut state = inner.state.lock();
1754    let resource_state = state.resources.get_mut(resource)?;
1755    let queued = resource_state.queued.take()?;
1756    resource_state.in_flight = Some(WorkMeta {
1757        generation: queued.request.generation,
1758        bytes: queued.request.len(),
1759    });
1760    Some(queued)
1761}
1762
1763fn finish_request(
1764    inner: &ActorInner,
1765    resource: &Arc<str>,
1766    generation: MetadataGeneration,
1767    result: RuntimeResult<()>,
1768) -> bool {
1769    let mut completed_waiters = Vec::new();
1770    let (has_queued, outcome) = {
1771        let mut state = inner.state.lock();
1772        let Some(mut resource_state) = state.resources.remove(resource) else {
1773            return false;
1774        };
1775        let Some(in_flight) = resource_state.in_flight.take() else {
1776            state.resources.insert(resource.clone(), resource_state);
1777            return false;
1778        };
1779        debug_assert_eq!(in_flight.generation, generation);
1780        state.pending_operations = state.pending_operations.saturating_sub(1);
1781        state.pending_bytes = state.pending_bytes.saturating_sub(in_flight.bytes);
1782        // The closure has already released its payload charge. Local pending
1783        // counters settle here; they may briefly lag the shared byte ledger.
1784
1785        let outcome = commit_outcome(generation, result);
1786        if matches!(outcome, MetadataIoCommitOutcome::Durable(_)) {
1787            resource_state.durable_generation = Some(
1788                resource_state
1789                    .durable_generation
1790                    .map_or(generation, |durable| durable.max(generation)),
1791            );
1792        }
1793        let mut retained = Vec::with_capacity(resource_state.waiters.len());
1794        for waiter in resource_state.waiters.drain(..) {
1795            if waiter.generation <= generation {
1796                completed_waiters.push(waiter);
1797            } else {
1798                retained.push(waiter);
1799            }
1800        }
1801        resource_state.waiters = retained;
1802        let has_queued = resource_state.queued.is_some();
1803        if !has_queued {
1804            resource_state.target_registration = None;
1805        }
1806        state.resources.insert(resource.clone(), resource_state);
1807        (has_queued, outcome)
1808    };
1809
1810    for waiter in completed_waiters {
1811        let _ = waiter.sender.send(Ok(outcome.clone()));
1812    }
1813    has_queued
1814}
1815
1816fn commit_outcome(generation: MetadataGeneration, result: RuntimeResult<()>) -> MetadataIoCommitOutcome {
1817    match result {
1818        Ok(()) => MetadataIoCommitOutcome::Durable(generation),
1819        Err(error) if error.operation() == crate::RuntimeOperation::MetadataSyncParent => {
1820            MetadataIoCommitOutcome::CommitOutcomeUnknown(error)
1821        }
1822        Err(error) => MetadataIoCommitOutcome::FailedBeforeCommit(error),
1823    }
1824}
1825
1826fn stop_admission(inner: &ActorInner) {
1827    let changed = {
1828        let mut state = inner.state.lock();
1829        let changed = state.accepting;
1830        state.accepting = false;
1831        changed
1832    };
1833    if changed {
1834        inner.shutdown.notify_one();
1835    }
1836}
1837
1838fn should_finish(inner: &ActorInner) -> bool {
1839    let state = inner.state.lock();
1840    !state.accepting && state.pending_operations == 0
1841}
1842
1843fn finish_worker(inner: &ActorInner) {
1844    let mut abandoned = Vec::new();
1845    {
1846        let mut state = inner.state.lock();
1847        state.accepting = false;
1848        state.worker_finished = true;
1849        for (resource, resource_state) in &mut state.resources {
1850            for waiter in resource_state.waiters.drain(..) {
1851                abandoned.push((resource.clone(), waiter));
1852            }
1853        }
1854    }
1855    for (_resource, waiter) in abandoned {
1856        let _ = waiter.sender.send(Err(RuntimeError::closed(
1857            crate::RuntimeOperation::MetadataWorkerStopped,
1858        )));
1859    }
1860    inner.worker_finished.notify_waiters();
1861}
1862
1863fn snapshot(inner: &ActorInner) -> MetadataIoSnapshot {
1864    let state = inner.state.lock();
1865    let mut resources = state
1866        .resources
1867        .iter()
1868        .map(|(resource, resource_state)| resource_snapshot(resource, resource_state))
1869        .collect::<Vec<_>>();
1870    resources.sort_by(|left, right| left.resource.cmp(&right.resource));
1871    MetadataIoSnapshot {
1872        accepting: state.accepting,
1873        pending_operations: state.pending_operations,
1874        pending_bytes: state.pending_bytes,
1875        max_pending_operations: inner.config.max_pending_operations,
1876        max_pending_bytes: inner.config.max_pending_bytes,
1877        resources,
1878    }
1879}
1880
1881fn shutdown_report(inner: &ActorInner, timed_out: bool) -> MetadataIoShutdownReport {
1882    let snapshot = snapshot(inner);
1883    let unfinished = snapshot
1884        .resources
1885        .into_iter()
1886        .filter(|resource| resource.in_flight_generation.is_some() || resource.queued_generation.is_some())
1887        .collect();
1888    MetadataIoShutdownReport {
1889        timed_out,
1890        pending_operations: snapshot.pending_operations,
1891        pending_bytes: snapshot.pending_bytes,
1892        unfinished,
1893    }
1894}
1895
1896fn resource_snapshot(resource: &Arc<str>, state: &ResourceState) -> MetadataIoResourceSnapshot {
1897    MetadataIoResourceSnapshot {
1898        resource: resource.clone(),
1899        target: state.target.clone(),
1900        durable_generation: state.durable_generation,
1901        in_flight_generation: state.in_flight.as_ref().map(|work| work.generation),
1902        queued_generation: state.queued.as_ref().map(|queued| queued.request.generation),
1903        waiter_count: state.waiters.len(),
1904    }
1905}
1906
1907fn startup_error(source: RuntimeError) -> RuntimeError {
1908    source
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913    use std::error::Error;
1914
1915    use super::*;
1916
1917    #[test]
1918    fn metadata_io_config_rejects_unbounded_zero_limits() {
1919        let zero_operations = MetadataIoConfig {
1920            max_pending_operations: 0,
1921            ..MetadataIoConfig::default()
1922        };
1923        assert_eq!(
1924            zero_operations.validate(),
1925            Err(RuntimeContractViolation::InvalidMetadataConfiguration {
1926                policy: RuntimeContractPolicy::MetadataMaxPendingOperationsPositive,
1927            })
1928        );
1929
1930        let zero_bytes = MetadataIoConfig {
1931            max_pending_bytes: 0,
1932            ..MetadataIoConfig::default()
1933        };
1934        assert_eq!(
1935            zero_bytes.validate(),
1936            Err(RuntimeContractViolation::InvalidMetadataConfiguration {
1937                policy: RuntimeContractPolicy::MetadataMaxPendingBytesPositive,
1938            })
1939        );
1940
1941        assert!(matches!(
1942            MetadataIoConfig {
1943                max_pending_operations: 0,
1944                ..MetadataIoConfig::default()
1945            }
1946            .into_plan(),
1947            Err(RuntimeContractViolation::InvalidMetadataConfiguration {
1948                policy: RuntimeContractPolicy::MetadataMaxPendingOperationsPositive,
1949            })
1950        ));
1951    }
1952
1953    #[test]
1954    fn metadata_io_error_retains_the_original_io_source() {
1955        let error = metadata_io_failure(
1956            MetadataIoOperation::WriteTemporary,
1957            Path::new("metadata.json"),
1958            std::io::Error::new(std::io::ErrorKind::WriteZero, "partial write"),
1959        );
1960        let source = error.source().expect("metadata error should retain its source");
1961        let source = source
1962            .downcast_ref::<std::io::Error>()
1963            .expect("metadata error source should remain a std::io::Error");
1964        assert_eq!(source.kind(), std::io::ErrorKind::WriteZero);
1965    }
1966
1967    #[test]
1968    fn commit_observation_requires_reconciliation_only_for_unconfirmed_outcomes() {
1969        let step_error = || {
1970            RuntimeError::io(
1971                crate::RuntimeOperation::MetadataSyncParent,
1972                std::io::Error::other("injected failure"),
1973            )
1974        };
1975
1976        let confirmed = [
1977            MetadataIoCommitObservation::Settled {
1978                generation: MetadataGeneration::new(7),
1979                outcome: MetadataIoCommitOutcome::Durable(MetadataGeneration::new(7)),
1980            },
1981            MetadataIoCommitObservation::Settled {
1982                generation: MetadataGeneration::new(7),
1983                outcome: MetadataIoCommitOutcome::FailedBeforeCommit(step_error()),
1984            },
1985        ];
1986        for observation in confirmed {
1987            assert!(
1988                !observation.requires_reconciliation(),
1989                "a settled conclusion cannot require reconciliation"
1990            );
1991            assert_eq!(observation.unobserved_generation(), None);
1992            assert_eq!(observation.generation(), Some(MetadataGeneration::new(7)));
1993        }
1994
1995        let unconfirmed = MetadataIoCommitObservation::Settled {
1996            generation: MetadataGeneration::new(7),
1997            outcome: MetadataIoCommitOutcome::CommitOutcomeUnknown(step_error()),
1998        };
1999        assert!(unconfirmed.requires_reconciliation());
2000        assert_eq!(unconfirmed.generation(), Some(MetadataGeneration::new(7)));
2001        assert!(unconfirmed.settled().is_some());
2002
2003        let unobserved = MetadataIoCommitObservation::Unobserved {
2004            generation: MetadataGeneration::new(9),
2005        };
2006        assert!(unobserved.requires_reconciliation());
2007        assert_eq!(unobserved.unobserved_generation(), Some(MetadataGeneration::new(9)));
2008        assert_eq!(unobserved.generation(), Some(MetadataGeneration::new(9)));
2009        assert!(unobserved.settled().is_none());
2010    }
2011
2012    #[test]
2013    fn target_conflict_observation_is_not_a_durability_conclusion() {
2014        let conflict = MetadataIoCommitObservation::TargetConflict(MetadataWriteRequest::new(
2015            "resource",
2016            MetadataGeneration::new(1),
2017            Path::new("metadata.json"),
2018            b"snapshot".to_vec(),
2019        ));
2020        assert!(!conflict.requires_reconciliation());
2021        assert_eq!(conflict.unobserved_generation(), None);
2022        assert_eq!(conflict.generation(), None);
2023        assert!(conflict.settled().is_none());
2024    }
2025}