Skip to main content

oxide_batch/item_components/
multi_resource.rs

1//! Multi-resource reader/writer components (#150, `ITEM-MULTI-001`).
2//!
3//! Multiple physical resources (files, objects, ...) that together form one
4//! logical input or output must remain one ordered, restartable unit -- the
5//! same composition rule (Gate E) the rest of
6//! [`crate::item_components`] follows, applied to a resource set instead of
7//! a fixed, statically-declared delegate list.
8//!
9//! # Why this is not [`crate::item_components::CompositeReader`] plus a
10//! checkpoint
11//!
12//! [`crate::item_components::CompositeReader`] already has the right
13//! traversal shape -- an ordered sequence of delegates, advancing to the
14//! next only once the current exhausts -- but it is documented as
15//! in-memory-only and explicitly not restartable by itself, because its
16//! delegates are pre-constructed by the caller and each delegate's own
17//! [`crate::ItemStream`] (if any) is registered independently under a
18//! statically-declared [`crate::ComponentStreamIdentity`].
19//!
20//! A multi-resource component cannot use that shape: the resource count is
21//! often not known until runtime (a directory listing, an object-store
22//! prefix), so it cannot register one [`crate::ComponentStreamIdentity`] per
23//! resource against [`crate::ChunkComponentRevisions`], which requires a
24//! fixed, statically-declared namespace set. [`MultiResourceReader`] and
25//! [`MultiResourceWriter`] instead own exactly **one** namespace for the
26//! whole ordered resource set, and construct each resource's delegate
27//! on demand through a [`MultiResourceReaderOpener`]/
28//! [`MultiResourceWriterOpener`] -- never more than one resource open at a
29//! time (bounded, one-resource-at-a-time ownership): the current resource's
30//! delegate stream is always closed before the next one is opened.
31//!
32//! # Nested resource lifecycle
33//!
34//! A resource reaching its own boundary (a reader delegate exhausting, or a
35//! writer delegate rolling over) is a different event from the *enclosing*
36//! step attempt reaching its terminal outcome: the boundary can be, and
37//! usually is, reached before the chunk transaction in flight when it is
38//! discovered has committed. So the retiring delegate's stream is closed
39//! right there, with [`crate::StreamRuntimeOutcome::ResourceBoundary`] --
40//! never [`crate::StreamRuntimeOutcome::Committed`], which would falsely
41//! claim the outer step attempt itself had reached a durable, terminal
42//! commit. A close failure at a boundary is propagated as a read/write
43//! error rather than silently advancing to the next resource, so the
44//! checkpoint (produced by [`crate::ItemStream::update`], which runs only
45//! after a chunk's work -- including any resource transition -- has fully
46//! succeeded) never advances past a resource whose close failed. Every
47//! resource this module opens is closed **exactly once, ever** -- at its
48//! own boundary if it retires mid-attempt, or by this module's outer
49//! [`crate::ItemStream::close`] if it is still active when the step
50//! attempt's own terminal outcome is known -- never both, and never
51//! retried: [`crate::ItemStream::close`] carries no idempotency/atomicity
52//! guarantee, so a boundary close that fails poisons this reader/writer
53//! instance (every later call returns that same failure without touching
54//! any delegate again) rather than calling `close` a second time on a
55//! delegate that may already have performed an irreversible side effect.
56//! Recovery is a fresh step attempt: a new instance, opened from the
57//! durable checkpoint (which the failed close never advanced past), whose
58//! own resource-boundary close is a first attempt on a brand-new delegate,
59//! not a retry of the failed one.
60//!
61//! # Durable position
62//!
63//! The durable envelope this module's paired [`crate::ItemStream`] halves
64//! produce is exactly three things:
65//!
66//! - [`ResourceSetRevision`]: a content fingerprint over the ordered
67//!   resource identity sequence. A restart whose caller-supplied resource
68//!   set no longer matches this revision fails closed
69//!   ([`crate::FailureCategory::UnsupportedCapability`]) instead of silently
70//!   reinterpreting a stored resource index against a different physical
71//!   resource -- inserting or removing a resource ahead of the committed
72//!   index is exactly the case this guards.
73//! - the current resource's ordinal index.
74//! - the current resource's own delegate position, embedded verbatim,
75//!   including its namespace.
76//!
77//! That last point is what lets this module reuse the existing M6
78//! component-state contract exactly, rather than inventing a second state
79//! mechanism: every durable column [`crate::ComponentStateEnvelope`] carries
80//! (namespace, schema id/version, codec id/version, checksum
81//! algorithm/value, and the bounded payload) is a public accessor, and
82//! [`crate::ComponentStateEnvelope::from_durable`] reconstructs an envelope
83//! from exactly those columns. So the delegate reader/writer this module
84//! opens for the current resource -- typically the same
85//! `(component, stream, contract)` triple a first-party constructor like
86//! [`crate::item_components::delimited_reader`] already returns -- has its
87//! *own* candidate envelope captured by calling its `ItemStream::update`
88//! internally, and that envelope's durable columns are embedded as plain
89//! data inside this module's own envelope. Before that embedding happens,
90//! the delegate's reported namespace is checked against the identity this
91//! module's opener assigned it: a mismatch fails the update closed rather
92//! than let an invalid nested candidate become part of this module's own
93//! outer candidate, since the core runtime's own fail-closed namespace
94//! check has no visibility into state nested this way. On restart, the
95//! reverse happens: the embedded columns are used to reconstruct the
96//! delegate's envelope via `from_durable` (re-verifying its checksum) --
97//! but only after the *stored* namespace is re-checked against the same
98//! expected identity, so a corrupted or hand-crafted durable record cannot
99//! be silently normalized into a valid one either. Once reconstructed, the
100//! delegate's own `ItemStream::open` restores its position from it, unaware
101//! it is nested inside another component's state at all. No delegate state
102//! is hidden -- it is carried in full, just not under a second, separately
103//! registered namespace, because there is no separate registration to hide
104//! it from.
105//!
106//! Everything before the current resource is implicitly fully committed
107//! (this module never revisits a resource once it advances past it, exactly
108//! like [`crate::item_components::CompositeReader`]); everything after has
109//! not started. So the durable envelope only ever needs to describe the one
110//! current resource, never the whole set's progress.
111//!
112//! # Object storage
113//!
114//! [`crate::item_components::object_store`] provides the M6-basics
115//! provider-neutral object capability (get/put/stat/list) this module's
116//! opener traits can be implemented against; full S3/Azure/GCS certification
117//! remains M9 (`IO-OBJECT-001`).
118
119use std::fmt;
120use std::future::Future;
121use std::marker::PhantomData;
122use std::sync::Arc;
123
124use sha2::{Digest, Sha256};
125use tokio::sync::Mutex as AsyncMutex;
126
127use crate::{
128    ComponentStateEnvelope, ComponentStatePayload, ComponentStreamIdentity, ContentIdentity,
129    DefaultComponentCodec, ExternalStateReference, FailureCategory, ItemReader, ItemStream,
130    ItemWriter, ReadContext, ReadOutcome, ReaderError, RestartabilityDeclaration, StateCodecError,
131    StateLimits, StateSchemaId, StateSchemaVersion, StateSensitivity, StreamCloseContext,
132    StreamCloseError, StreamCloseOutcome, StreamOpenContext, StreamOpenError, StreamOpenOutcome,
133    StreamRuntimeOutcome, StreamStateContract, StreamUpdateContext, StreamUpdateError,
134    VersionedStateCodec, WriteContext, WriteOutcome, WriterError,
135};
136
137// ---------------------------------------------------------------------
138// Resource identity and resource-set revision
139// ---------------------------------------------------------------------
140
141const MAX_RESOURCE_IDENTITY_BYTES: usize = 1024;
142
143/// A stable, caller-declared identity for one physical resource within an
144/// ordered resource set.
145///
146/// Never derive this from filesystem iteration order, an object-store
147/// listing's provider-dependent order, or any other runtime-unstable source
148/// -- supply these in the exact logical order the resource set represents.
149/// A path or object key is an acceptable identity (this is diagnostic
150/// metadata, like [`crate::CodecId`], not covered by component-state
151/// payload sensitivity rules); a caller with a stricter sensitivity
152/// requirement supplies an opaque logical name instead.
153#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub struct ResourceIdentity(String);
155
156impl ResourceIdentity {
157    /// Validates a stable resource identity.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`MultiResourceConfigError`] when `value` is empty, exceeds
162    /// 1024 UTF-8 bytes, or contains a control character.
163    pub fn new(value: impl Into<String>) -> Result<Self, MultiResourceConfigError> {
164        let value = value.into();
165        if value.is_empty() {
166            return Err(MultiResourceConfigError::EmptyResourceIdentity);
167        }
168        if value.len() > MAX_RESOURCE_IDENTITY_BYTES {
169            return Err(MultiResourceConfigError::ResourceIdentityTooLong {
170                max_bytes: MAX_RESOURCE_IDENTITY_BYTES,
171            });
172        }
173        if value.chars().any(char::is_control) {
174            return Err(MultiResourceConfigError::MalformedResourceIdentity);
175        }
176        Ok(Self(value))
177    }
178
179    /// Borrows the validated identity.
180    #[must_use]
181    pub fn as_str(&self) -> &str {
182        &self.0
183    }
184}
185
186impl fmt::Debug for ResourceIdentity {
187    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
188        formatter
189            .debug_tuple("ResourceIdentity")
190            .field(&self.0)
191            .finish()
192    }
193}
194
195impl fmt::Display for ResourceIdentity {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        formatter.write_str(&self.0)
198    }
199}
200
201/// A content fingerprint over one ordered resource-identity sequence.
202///
203/// Two resource sets with the same identities in a different order, or a
204/// different resource count, produce different revisions. Safe to display:
205/// this is a hash of logical identities, never resource content.
206#[derive(Clone, Copy, Eq, Hash, PartialEq)]
207pub struct ResourceSetRevision([u8; 32]);
208
209impl ResourceSetRevision {
210    /// Computes the revision of an ordered resource-identity sequence.
211    #[must_use]
212    pub fn of<'a>(resources: impl IntoIterator<Item = &'a ResourceIdentity>) -> Self {
213        let mut hasher = Sha256::new();
214        hasher.update(b"oxide-batch.resource-set\0");
215        for resource in resources {
216            hasher.update(resource.as_str().as_bytes());
217            hasher.update([0u8]);
218        }
219        Self(hasher.finalize().into())
220    }
221
222    fn to_hex(self) -> String {
223        hex_encode(&self.0)
224    }
225
226    fn from_hex(hex: &str) -> Option<Self> {
227        let bytes = hex_decode(hex)?;
228        let array: [u8; 32] = bytes.try_into().ok()?;
229        Some(Self(array))
230    }
231}
232
233impl fmt::Debug for ResourceSetRevision {
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        write!(formatter, "ResourceSetRevision({})", self.to_hex())
236    }
237}
238
239/// An ordered, fingerprinted set of resources forming one logical
240/// multi-resource input or output.
241#[derive(Clone)]
242pub struct ResourceSet {
243    resources: Arc<[ResourceIdentity]>,
244    revision: ResourceSetRevision,
245}
246
247impl ResourceSet {
248    /// Builds a resource set from an explicit, caller-ordered sequence.
249    #[must_use]
250    pub fn new(resources: Vec<ResourceIdentity>) -> Self {
251        let revision = ResourceSetRevision::of(resources.iter());
252        Self {
253            resources: Arc::from(resources),
254            revision,
255        }
256    }
257
258    /// Borrows the ordered resource identities.
259    #[must_use]
260    pub fn resources(&self) -> &[ResourceIdentity] {
261        &self.resources
262    }
263
264    /// Returns this resource set's content revision.
265    #[must_use]
266    pub const fn revision(&self) -> ResourceSetRevision {
267        self.revision
268    }
269
270    /// Returns the number of resources in this set.
271    #[must_use]
272    pub fn len(&self) -> usize {
273        self.resources.len()
274    }
275
276    /// Returns whether this resource set has no resources.
277    #[must_use]
278    pub fn is_empty(&self) -> bool {
279        self.resources.is_empty()
280    }
281}
282
283/// A validation failure building a [`ResourceIdentity`] or [`ResourceSet`].
284#[derive(Clone, Copy, Debug, Eq, PartialEq)]
285#[non_exhaustive]
286pub enum MultiResourceConfigError {
287    /// A resource identity was empty.
288    EmptyResourceIdentity,
289    /// A resource identity exceeded its UTF-8 byte limit.
290    ResourceIdentityTooLong {
291        /// Maximum accepted UTF-8 bytes.
292        max_bytes: usize,
293    },
294    /// A resource identity contained a control character.
295    MalformedResourceIdentity,
296}
297
298impl fmt::Display for MultiResourceConfigError {
299    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
300        match self {
301            Self::EmptyResourceIdentity => {
302                formatter.write_str("resource identity must not be empty")
303            }
304            Self::ResourceIdentityTooLong { max_bytes } => {
305                write!(
306                    formatter,
307                    "resource identity exceeds {max_bytes} UTF-8 bytes"
308                )
309            }
310            Self::MalformedResourceIdentity => {
311                formatter.write_str("resource identity contains a control character")
312            }
313        }
314    }
315}
316
317impl std::error::Error for MultiResourceConfigError {}
318
319// ---------------------------------------------------------------------
320// Hex helpers (no base64 dependency in this crate; payloads here are tiny
321// position records, never item content, so the ~2x hex overhead is
322// immaterial against the default 64 KiB envelope bound)
323// ---------------------------------------------------------------------
324
325fn hex_encode(bytes: &[u8]) -> String {
326    use fmt::Write as _;
327    let mut out = String::with_capacity(bytes.len() * 2);
328    for byte in bytes {
329        let _ = write!(out, "{byte:02x}");
330    }
331    out
332}
333
334fn hex_decode(text: &str) -> Option<Vec<u8>> {
335    if !text.len().is_multiple_of(2) {
336        return None;
337    }
338    let mut out = Vec::with_capacity(text.len() / 2);
339    let bytes = text.as_bytes();
340    let mut i = 0;
341    while i < bytes.len() {
342        let hi = (bytes[i] as char).to_digit(16)?;
343        let lo = (bytes[i + 1] as char).to_digit(16)?;
344        out.push(u8::try_from((hi << 4) | lo).ok()?);
345        i += 2;
346    }
347    Some(out)
348}
349
350// ---------------------------------------------------------------------
351// Nested delegate envelope columns
352// ---------------------------------------------------------------------
353
354/// The durable columns of one delegate [`crate::ComponentStateEnvelope`],
355/// embedded verbatim inside this module's own envelope.
356///
357/// `namespace` is carried alongside the other columns specifically so a
358/// restore can verify -- not merely assume -- that the delegate which
359/// produced these columns actually reported the identity the
360/// multi-resource opener assigned it (see
361/// [`MultiResourceReaderStream::open`]/[`MultiResourceWriterStream::open`]'s
362/// namespace check). Reconstructing the delegate envelope with the
363/// caller-expected namespace regardless of what was actually stored would
364/// normalize a bad namespace into a good one and bypass the core runtime's
365/// fail-closed namespace-mismatch invariant.
366#[derive(Clone)]
367struct DelegateEnvelopeColumns {
368    namespace: String,
369    schema_id: String,
370    schema_version: u32,
371    codec_id: String,
372    codec_version: u32,
373    checksum_algorithm: u16,
374    checksum_algorithm_version: u16,
375    checksum: [u8; 32],
376    is_external: bool,
377    payload_inline_hex: Option<String>,
378    payload_external_content_id_hex: Option<String>,
379    payload_external_len: Option<u64>,
380}
381
382impl DelegateEnvelopeColumns {
383    fn from_envelope(envelope: &ComponentStateEnvelope) -> Result<Self, StateCodecError> {
384        let payload = envelope
385            .payload()
386            .map_err(|_| StateCodecError::InvalidPayload)?;
387        let (payload_inline_hex, payload_external_content_id_hex, payload_external_len) =
388            match payload {
389                ComponentStatePayload::Inline(bytes) => (Some(hex_encode(&bytes)), None, None),
390                ComponentStatePayload::External(reference) => (
391                    None,
392                    Some(hex_encode(reference.content_id().as_bytes())),
393                    Some(reference.encoded_len()),
394                ),
395            };
396        Ok(Self {
397            namespace: envelope.namespace().as_str().to_owned(),
398            schema_id: envelope.schema_id().as_str().to_owned(),
399            schema_version: envelope.schema_version().get(),
400            codec_id: envelope.codec_id().as_str().to_owned(),
401            codec_version: envelope.codec_version().get(),
402            checksum_algorithm: envelope.checksum_algorithm(),
403            checksum_algorithm_version: envelope.checksum_algorithm_version(),
404            checksum: envelope.checksum(),
405            is_external: envelope.is_external(),
406            payload_inline_hex,
407            payload_external_content_id_hex,
408            payload_external_len,
409        })
410    }
411
412    /// Returns whether this record's stored namespace exactly matches
413    /// `expected` -- the delegate identity the multi-resource opener
414    /// assigned. Callers must reject a mismatch fail-closed rather than
415    /// reconstruct the envelope anyway.
416    fn namespace_matches(&self, expected: &ComponentStreamIdentity) -> bool {
417        self.namespace == expected.as_str()
418    }
419
420    fn to_envelope(
421        &self,
422        namespace: ComponentStreamIdentity,
423        limits: StateLimits,
424    ) -> Option<ComponentStateEnvelope> {
425        let payload = if self.is_external {
426            let content_id_hex = self.payload_external_content_id_hex.as_deref()?;
427            let bytes = hex_decode(content_id_hex)?;
428            let array: [u8; 32] = bytes.try_into().ok()?;
429            let content_id = ContentIdentity::from_bytes(array);
430            let len = self.payload_external_len?;
431            ComponentStatePayload::External(ExternalStateReference::new(content_id, len))
432        } else {
433            let hex = self.payload_inline_hex.as_deref()?;
434            ComponentStatePayload::Inline(hex_decode(hex)?)
435        };
436        ComponentStateEnvelope::from_durable(
437            namespace,
438            &self.schema_id,
439            self.schema_version,
440            &self.codec_id,
441            self.codec_version,
442            self.checksum_algorithm,
443            self.checksum_algorithm_version,
444            self.checksum,
445            payload,
446            limits,
447        )
448        .ok()
449    }
450}
451
452// ---------------------------------------------------------------------
453// Outer multi-resource state
454// ---------------------------------------------------------------------
455
456#[derive(Clone)]
457struct MultiResourceState {
458    resource_set_revision: ResourceSetRevision,
459    resource_index: u32,
460    delegate: Option<DelegateEnvelopeColumns>,
461    /// Batches committed to the current resource so far. Durable so
462    /// [`RolloverPolicy::should_roll_over`] sees the true count across a
463    /// restart -- an in-memory-only counter would silently reset to `0` on
464    /// every restart and let a resource accumulate unboundedly many more
465    /// batches than its policy allows. Unused (always `0`) on the reader
466    /// path, which has no rollover decision to make.
467    resource_batches_written: u64,
468}
469
470fn delegate_columns_to_json(delegate: &DelegateEnvelopeColumns) -> serde_json::Value {
471    serde_json::json!({
472        "namespace": delegate.namespace,
473        "schema_id": delegate.schema_id,
474        "schema_version": delegate.schema_version,
475        "codec_id": delegate.codec_id,
476        "codec_version": delegate.codec_version,
477        "checksum_algorithm": delegate.checksum_algorithm,
478        "checksum_algorithm_version": delegate.checksum_algorithm_version,
479        "checksum": hex_encode(&delegate.checksum),
480        "is_external": delegate.is_external,
481        "payload_inline": delegate.payload_inline_hex,
482        "payload_external_content_id": delegate.payload_external_content_id_hex,
483        "payload_external_len": delegate.payload_external_len,
484    })
485}
486
487fn delegate_columns_from_json(value: &serde_json::Value) -> Option<DelegateEnvelopeColumns> {
488    let checksum_hex = value.get("checksum")?.as_str()?;
489    let checksum_bytes = hex_decode(checksum_hex)?;
490    let checksum: [u8; 32] = checksum_bytes.try_into().ok()?;
491    Some(DelegateEnvelopeColumns {
492        namespace: value.get("namespace")?.as_str()?.to_owned(),
493        schema_id: value.get("schema_id")?.as_str()?.to_owned(),
494        schema_version: u32::try_from(value.get("schema_version")?.as_u64()?).ok()?,
495        codec_id: value.get("codec_id")?.as_str()?.to_owned(),
496        codec_version: u32::try_from(value.get("codec_version")?.as_u64()?).ok()?,
497        checksum_algorithm: u16::try_from(value.get("checksum_algorithm")?.as_u64()?).ok()?,
498        checksum_algorithm_version: u16::try_from(
499            value.get("checksum_algorithm_version")?.as_u64()?,
500        )
501        .ok()?,
502        checksum,
503        is_external: value.get("is_external")?.as_bool()?,
504        payload_inline_hex: value
505            .get("payload_inline")
506            .and_then(serde_json::Value::as_str)
507            .map(str::to_owned),
508        payload_external_content_id_hex: value
509            .get("payload_external_content_id")
510            .and_then(serde_json::Value::as_str)
511            .map(str::to_owned),
512        payload_external_len: value
513            .get("payload_external_len")
514            .and_then(serde_json::Value::as_u64),
515    })
516}
517
518const MULTI_RESOURCE_SCHEMA: &str = "oxide-batch.multi-resource-position";
519const MULTI_RESOURCE_CODEC: &str = "oxide-batch.multi-resource-position-codec";
520
521#[derive(Clone, Copy)]
522struct MultiResourcePositionSchema;
523
524impl VersionedStateCodec<MultiResourceState> for MultiResourcePositionSchema {
525    fn schema_id(&self) -> &StateSchemaId {
526        static SCHEMA: std::sync::OnceLock<StateSchemaId> = std::sync::OnceLock::new();
527        #[allow(
528            clippy::unwrap_used,
529            reason = "fixed literal schema identity cannot fail validation"
530        )]
531        SCHEMA.get_or_init(|| StateSchemaId::new(MULTI_RESOURCE_SCHEMA).unwrap())
532    }
533
534    fn current_version(&self) -> StateSchemaVersion {
535        // Version 2 (#177): the embedded delegate envelope's `namespace`
536        // column became a required field, so a durable version-1 record
537        // (produced before #177, which never carried a delegate namespace)
538        // no longer satisfies this schema's shape. No upgrade edge is
539        // declared from 1 to 2 (`Self::upgrades` keeps `VersionedStateCodec`'s
540        // default, empty implementation): the shared upgrade-chain walk
541        // (`upgrade_schema_chain`, used by both `crate::state` and
542        // `crate::component_state`) therefore rejects a recorded version 1
543        // with `NoUpgradePath` before this codec's own `decode` ever runs,
544        // fail-closed by construction. A version-1 record's delegate
545        // namespace cannot be reconstructed after the fact -- there is
546        // nothing honest to backfill it with -- so this is deliberately a
547        // hard boundary, not a migration, proven by
548        // `stale_v1_delegate_record_without_a_namespace_fails_closed_on_restore`.
549        #[allow(
550            clippy::unwrap_used,
551            reason = "fixed literal schema version cannot fail validation"
552        )]
553        StateSchemaVersion::new(2).unwrap()
554    }
555
556    fn encode(&self, value: &MultiResourceState) -> Result<Vec<u8>, StateCodecError> {
557        let delegate = value.delegate.as_ref().map(delegate_columns_to_json);
558        serde_json::to_vec(&serde_json::json!({
559            "resource_set_revision": value.resource_set_revision.to_hex(),
560            "resource_index": value.resource_index,
561            "delegate": delegate,
562            "resource_batches_written": value.resource_batches_written,
563        }))
564        .map_err(|_| StateCodecError::InvalidPayload)
565    }
566
567    fn decode(&self, payload: &[u8]) -> Result<MultiResourceState, StateCodecError> {
568        let value: serde_json::Value =
569            serde_json::from_slice(payload).map_err(|_| StateCodecError::InvalidPayload)?;
570        let revision_hex = value
571            .get("resource_set_revision")
572            .and_then(serde_json::Value::as_str)
573            .ok_or(StateCodecError::InvalidPayload)?;
574        let resource_set_revision =
575            ResourceSetRevision::from_hex(revision_hex).ok_or(StateCodecError::InvalidPayload)?;
576        let resource_index = u32::try_from(
577            value
578                .get("resource_index")
579                .and_then(serde_json::Value::as_u64)
580                .ok_or(StateCodecError::InvalidPayload)?,
581        )
582        .map_err(|_| StateCodecError::InvalidPayload)?;
583        let delegate = match value.get("delegate") {
584            None | Some(serde_json::Value::Null) => None,
585            Some(delegate_value) => Some(
586                delegate_columns_from_json(delegate_value)
587                    .ok_or(StateCodecError::InvalidPayload)?,
588            ),
589        };
590        let resource_batches_written = value
591            .get("resource_batches_written")
592            .and_then(serde_json::Value::as_u64)
593            .unwrap_or(0);
594        Ok(MultiResourceState {
595            resource_set_revision,
596            resource_index,
597            delegate,
598            resource_batches_written,
599        })
600    }
601}
602
603fn multi_resource_position_codec(
604    restartability: RestartabilityDeclaration,
605) -> DefaultComponentCodec<MultiResourcePositionSchema> {
606    #[allow(
607        clippy::unwrap_used,
608        reason = "fixed literal identities cannot fail validation"
609    )]
610    DefaultComponentCodec::new(
611        MultiResourcePositionSchema,
612        crate::CodecId::new(MULTI_RESOURCE_CODEC).unwrap(),
613        crate::CodecVersion::new(1).unwrap(),
614        restartability,
615    )
616    .with_sensitivity(StateSensitivity::NonSensitive)
617}
618
619// ---------------------------------------------------------------------
620// Open errors
621// ---------------------------------------------------------------------
622
623/// A redacted failure opening one resource by ordinal.
624///
625/// Carries only the resource's ordinal position and a stable
626/// [`crate::FailureCategory`] -- never the underlying I/O error's payload,
627/// path, or message, per this crate's component-error redaction
628/// convention.
629#[derive(Clone, Copy, Debug, Eq, PartialEq)]
630pub struct MultiResourceOpenError {
631    resource_ordinal: u32,
632    category: FailureCategory,
633}
634
635impl MultiResourceOpenError {
636    /// Constructs a value-redacted [`FailureCategory::UserComponent`]
637    /// open failure for the resource at `resource_ordinal`.
638    #[must_use]
639    pub const fn new(resource_ordinal: u32) -> Self {
640        Self {
641            resource_ordinal,
642            category: FailureCategory::UserComponent,
643        }
644    }
645
646    /// Constructs an open failure that declares its own stable category.
647    #[must_use]
648    pub const fn with_category(resource_ordinal: u32, category: FailureCategory) -> Self {
649        Self {
650            resource_ordinal,
651            category,
652        }
653    }
654
655    /// Returns the zero-based ordinal of the resource that failed to open.
656    #[must_use]
657    pub const fn resource_ordinal(self) -> u32 {
658        self.resource_ordinal
659    }
660
661    /// Returns the stable category supplied by the opener.
662    #[must_use]
663    pub const fn category(self) -> FailureCategory {
664        self.category
665    }
666}
667
668impl fmt::Display for MultiResourceOpenError {
669    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
670        write!(
671            formatter,
672            "multi-resource open failed at resource ordinal {}",
673            self.resource_ordinal
674        )
675    }
676}
677
678impl std::error::Error for MultiResourceOpenError {}
679
680// ---------------------------------------------------------------------
681// Reader
682// ---------------------------------------------------------------------
683
684/// Opens one physical resource on demand for a [`MultiResourceReader`],
685/// producing a fresh delegate reader paired with its own
686/// [`crate::ItemStream`] half and state contract -- exactly the
687/// `(component, stream, contract)` triple every restartable first-party
688/// reader (e.g. [`crate::item_components::delimited_reader`]) already
689/// returns.
690///
691/// `delegate_identity` is the fixed namespace [`MultiResourceReader`]
692/// reuses for every resource's delegate envelope: it is never separately
693/// registered with the runtime (there is exactly one outer namespace for
694/// the whole resource set), so implementations should pass it straight
695/// through to whichever first-party constructor they call.
696pub trait MultiResourceReaderOpener<I>: Send + Sync {
697    /// The delegate reader this opener produces.
698    type Reader: ItemReader<I>;
699    /// The delegate reader's own [`crate::ItemStream`] half.
700    type Stream: ItemStream;
701
702    /// Opens `resource` fresh (its own delegate position starts at the
703    /// beginning; [`MultiResourceReader`] drives the delegate's own
704    /// `ItemStream::open` separately once this returns).
705    ///
706    /// # Errors
707    ///
708    /// Returns a redacted [`MultiResourceOpenError`] naming `resource`'s
709    /// ordinal.
710    fn open<'a>(
711        &'a self,
712        resource: &'a ResourceIdentity,
713        resource_ordinal: u32,
714        delegate_identity: &'a ComponentStreamIdentity,
715    ) -> impl Future<
716        Output = Result<(Self::Reader, Self::Stream, StreamStateContract), MultiResourceOpenError>,
717    > + Send
718    + 'a;
719}
720
721/// The shared half of a `(reader, stream)` pair: the delegate's own
722/// [`crate::ItemStream`] handle, current resource ordinal, and state
723/// contract.
724///
725/// Deliberately excludes the delegate *reader*: [`crate::ItemReader`] is
726/// `Send`-only (never `Sync`, by design -- it is always used exclusively),
727/// so a delegate reader can never live behind a lock also borrowed across
728/// an `.await` by [`MultiResourceReaderStream`]'s `&self` methods without
729/// making every future here non-`Send`. The delegate reader instead lives
730/// directly on [`MultiResourceReader`] itself, owned exclusively, and is
731/// kept in lock-step with this handle by updating both together, in the
732/// same critical section, at every resource transition.
733struct StreamHandle<S> {
734    index: u32,
735    stream: S,
736    /// `true` once this handle's `stream` has already been closed at a
737    /// resource boundary (see [`crate::StreamRuntimeOutcome::ResourceBoundary`]).
738    /// A handle is replaced (never mutated back to `false` in place) once
739    /// the transition to the next resource completes, so this flag exists
740    /// only to let [`MultiResourceReaderStream::close`] recognize -- and
741    /// skip -- a handle whose stream a resource-transition attempt already
742    /// closed but has not yet been able to replace (the transition's own
743    /// next-resource open failed and is pending retry). Without this, the
744    /// outer terminal close would close the same already-closed delegate a
745    /// second time.
746    retired: bool,
747}
748
749struct ReaderShared<I, O: MultiResourceReaderOpener<I>> {
750    opener: O,
751    resources: ResourceSet,
752    identity: ComponentStreamIdentity,
753    handle: AsyncMutex<Option<StreamHandle<O::Stream>>>,
754    /// A one-shot handoff slot: [`MultiResourceReaderStream::open`]
755    /// constructs the delegate reader for the attempt's resume resource
756    /// (position already restored via the delegate's own `ItemStream::open`)
757    /// and stashes it here; [`MultiResourceReader::read`] takes it on its
758    /// first call. A `std::sync::Mutex` is sufficient (never `tokio`'s):
759    /// both sides only ever lock it for a synchronous put/take, never
760    /// across an `.await`, so it does not need `O::Reader: Sync`.
761    pending_reader: std::sync::Mutex<Option<O::Reader>>,
762    /// Mirrors [`MultiResourceReader::poisoned`] for the paired stream's
763    /// `update`/`close`, which cannot see that local field directly. Safe
764    /// as a separate lock from `handle` (unlike the writer's unified
765    /// active/poisoned state): a reader is used exclusively (`&mut self`),
766    /// so there is no concurrent second `read` that could race a poisoning
767    /// transition the way concurrent `write` calls could -- `update`/
768    /// `close` only ever run in a disjoint framework phase from `read`.
769    poisoned: AsyncMutex<Option<FailureCategory>>,
770    limits: StateLimits,
771    _marker: PhantomData<fn() -> I>,
772}
773
774/// Reads an ordered set of physical resources as one logical, restartable
775/// input.
776///
777/// # Contract
778///
779/// - **Input/output**: `I`, same as every delegate the opener produces.
780/// - **State/checkpoint**: [`ResourceSetRevision`] plus the current
781///   resource's ordinal and embedded delegate position, through the paired
782///   [`MultiResourceReaderStream`]. A resource-set revision mismatch on
783///   restart fails closed rather than reinterpreting a stored index against
784///   a different physical resource.
785/// - **Ordering**: resources are traversed in [`ResourceSet`]'s declared
786///   order; within a resource, delegate order is preserved.
787/// - **Restartability**: the meet of this wrapper's own (always
788///   restartable, since the durable envelope is self-contained) and the
789///   opener's declared restartability, supplied explicitly at construction
790///   ([`multi_resource_reader`]'s `restartability` parameter) because a
791///   resource backend's stable-identity guarantee (e.g. an object store
792///   without version tokens) cannot be introspected from here.
793/// - **Thread safety**: used exclusively (`&mut self`) like every reader;
794///   internally synchronized with its paired stream via an async-aware
795///   lock, since the stream (`&self`) must observe the same active
796///   resource's position at a commit boundary.
797/// - **Bounded resource**: at most one resource's delegate open at a time;
798///   the current resource's delegate stream is always closed (with
799///   [`crate::StreamRuntimeOutcome::ResourceBoundary`]) before the next
800///   resource is opened, never overlapping.
801/// - **Close**: a resource exhausted mid-attempt is closed immediately,
802///   right there, rather than held open until the outer step attempt ends;
803///   the paired [`MultiResourceReaderStream::close`] closes whichever
804///   resource (if any) is still active when the step attempt reaches its
805///   own terminal outcome, and never re-closes a resource already closed at
806///   a boundary.
807/// - **Support tier**: first-party.
808pub struct MultiResourceReader<I, O: MultiResourceReaderOpener<I>> {
809    shared: Arc<ReaderShared<I, O>>,
810    /// Owned exclusively by this half; see [`StreamHandle`]'s docs for why
811    /// the delegate reader cannot live in the shared, lock-guarded state.
812    ///
813    /// `None` whenever [`Self::transitioning`] is `true` (the delegate that
814    /// hit `EndOfInput` has already been closed and must never be polled
815    /// again) or once the whole resource set is exhausted; `Some` while a
816    /// delegate is open and has not yet reached its own boundary.
817    reader: Option<O::Reader>,
818    /// Whether `pending_reader` has been consumed yet this attempt. The
819    /// handoff happens at most once, on the first `read` call; every later
820    /// `None` in `reader` is a mid-transition state the loop below resolves
821    /// itself, never a second reason to consult `pending_reader`.
822    started: bool,
823    /// `true` between closing an exhausted resource's delegate stream and
824    /// completing the transition to the next resource (or to fully
825    /// exhausted). Gates the loop below so a retried `read` -- after the
826    /// transition's own open step failed -- resumes the transition instead
827    /// of polling the already-closed delegate's `ItemReader` half again.
828    transitioning: bool,
829    /// Set once a resource-boundary `close` has been attempted and failed.
830    /// [`crate::ItemStream::close`] carries no idempotency/atomicity
831    /// guarantee: a delegate may perform an irreversible side effect (flush,
832    /// finalize, release) before returning an error, so a failed close is
833    /// never retried on the same delegate instance. Once poisoned, every
834    /// later `read` on this instance returns the same failure without
835    /// touching the delegate again; only a fresh step attempt -- a new
836    /// `MultiResourceReader` opened from the durable checkpoint, which
837    /// therefore never re-opens the poisoned instance -- may attempt this
838    /// resource's close again.
839    poisoned: Option<FailureCategory>,
840}
841
842impl<I, O> ItemReader<I> for MultiResourceReader<I, O>
843where
844    I: Send + 'static,
845    O: MultiResourceReaderOpener<I> + 'static,
846{
847    async fn read(&mut self, context: ReadContext<'_>) -> Result<ReadOutcome<I>, ReaderError> {
848        if let Some(category) = self.poisoned {
849            return Err(ReaderError::with_category(category));
850        }
851        if !self.started {
852            self.started = true;
853            let taken = self
854                .shared
855                .pending_reader
856                .lock()
857                .unwrap_or_else(std::sync::PoisonError::into_inner)
858                .take();
859            match taken {
860                Some(reader) => self.reader = Some(reader),
861                None => {
862                    // Nothing was ever handed off: either the resource set
863                    // is empty, or the restored position had already
864                    // exhausted every resource before this attempt began.
865                    return Ok(ReadOutcome::EndOfInput);
866                }
867            }
868        }
869        loop {
870            if !self.transitioning {
871                let Some(reader) = self.reader.as_mut() else {
872                    // Once `started`, `reader` is `None` and not
873                    // transitioning if and only if the terminal exhaustion
874                    // branch below has already run: every repeated call
875                    // after that must keep reporting `EndOfInput`, exactly
876                    // like `CompositeReader` once its delegate list is
877                    // exhausted, never error.
878                    return Ok(ReadOutcome::EndOfInput);
879                };
880                match reader.read(context).await? {
881                    ReadOutcome::Item(item) => return Ok(ReadOutcome::Item(item)),
882                    ReadOutcome::Stopped => return Ok(ReadOutcome::Stopped),
883                    ReadOutcome::EndOfInput => {
884                        // The resource this delegate reads is exhausted.
885                        // Close its stream *now*, before opening anything
886                        // else: `StreamRuntimeOutcome::ResourceBoundary`
887                        // (never `Committed` -- the enclosing step attempt
888                        // has not reached a terminal outcome yet) tells the
889                        // delegate its own local work is done without
890                        // falsely claiming the outer transaction committed.
891                        //
892                        // The close is attempted exactly once. `ItemStream`
893                        // carries no idempotency/atomicity guarantee, so a
894                        // failed close is never retried on this same
895                        // delegate instance -- see `Self::poisoned`'s docs.
896                        let mut guard = self.shared.handle.lock().await;
897                        let handle = guard.as_mut().ok_or_else(|| {
898                            ReaderError::with_category(FailureCategory::Invariant)
899                        })?;
900                        let closed = handle
901                            .stream
902                            .close(StreamCloseContext::new(
903                                context.stop_token(),
904                                StreamRuntimeOutcome::ResourceBoundary,
905                            ))
906                            .await;
907                        // Attempted (successfully or not): never closed
908                        // again, by this instance or by the outer terminal
909                        // close.
910                        handle.retired = true;
911                        drop(guard);
912                        self.reader = None;
913                        match closed {
914                            Ok(_) => self.transitioning = true,
915                            Err(error) => {
916                                let category = error.category();
917                                self.poisoned = Some(category);
918                                *self.shared.poisoned.lock().await = Some(category);
919                                return Err(ReaderError::with_category(category));
920                            }
921                        }
922                    }
923                }
924            }
925
926            // `transitioning`: the current resource's delegate stream is
927            // already closed. Complete the move to the next resource, or to
928            // fully exhausted, before this call returns or loops back to
929            // poll a (new) delegate reader.
930            let current_index = {
931                let guard = self.shared.handle.lock().await;
932                guard
933                    .as_ref()
934                    .ok_or_else(|| ReaderError::with_category(FailureCategory::Invariant))?
935                    .index
936            };
937            let next_index = current_index + 1;
938            if next_index as usize >= self.shared.resources.len() {
939                *self.shared.handle.lock().await = None;
940                self.transitioning = false;
941                return Ok(ReadOutcome::EndOfInput);
942            }
943            let next_resource = &self.shared.resources.resources()[next_index as usize];
944            // The freshly opened resource has no inherited state to
945            // validate (it was never touched before this attempt), so the
946            // contract returned here has nothing left to do. On failure,
947            // `transitioning` stays set and `reader` stays `None`: a
948            // retried `read` re-attempts this same open, never re-closes
949            // the already-retired previous delegate and never silently
950            // reports the whole reader exhausted.
951            let (new_reader, stream, _contract) = self
952                .shared
953                .opener
954                .open(next_resource, next_index, &self.shared.identity)
955                .await
956                .map_err(|error| ReaderError::with_category(error.category()))?;
957            stream
958                .open(StreamOpenContext::new(None, context.stop_token()))
959                .await
960                .map_err(|error| ReaderError::with_category(error.category()))?;
961            // Both halves of the pair are updated together, in one critical
962            // section, so `StreamHandle::index` never observably points at
963            // a resource `self.reader` has not yet transitioned to.
964            let mut guard = self.shared.handle.lock().await;
965            self.reader = Some(new_reader);
966            *guard = Some(StreamHandle {
967                index: next_index,
968                stream,
969                retired: false,
970            });
971            drop(guard);
972            self.transitioning = false;
973        }
974    }
975}
976
977/// The [`crate::ItemStream`] half of a [`MultiResourceReader`].
978pub struct MultiResourceReaderStream<I, O: MultiResourceReaderOpener<I>> {
979    shared: Arc<ReaderShared<I, O>>,
980}
981
982impl<I, O> ItemStream for MultiResourceReaderStream<I, O>
983where
984    I: Send + 'static,
985    O: MultiResourceReaderOpener<I> + 'static,
986{
987    async fn open(
988        &self,
989        context: StreamOpenContext<'_>,
990    ) -> Result<StreamOpenOutcome, StreamOpenError> {
991        let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
992        let restored = context.inherited_state().is_some();
993        let (resource_index, inherited_delegate) = match context.inherited_state() {
994            Some(envelope) => {
995                let state = envelope
996                    .decode::<MultiResourceState>(&codec)
997                    .map_err(|_| StreamOpenError::new())?;
998                if state.resource_set_revision != self.shared.resources.revision() {
999                    return Err(StreamOpenError::with_category(
1000                        FailureCategory::UnsupportedCapability,
1001                    ));
1002                }
1003                (state.resource_index, state.delegate)
1004            }
1005            None => (0, None),
1006        };
1007
1008        let resources_len = u32::try_from(self.shared.resources.len()).unwrap_or(u32::MAX);
1009        if resource_index > resources_len {
1010            return Err(StreamOpenError::with_category(FailureCategory::Invariant));
1011        }
1012        if resource_index == resources_len {
1013            *self.shared.handle.lock().await = None;
1014            *self
1015                .shared
1016                .pending_reader
1017                .lock()
1018                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1019            return Ok(if restored {
1020                StreamOpenOutcome::Restored
1021            } else {
1022                StreamOpenOutcome::Initial
1023            });
1024        }
1025
1026        let resource = &self.shared.resources.resources()[resource_index as usize];
1027        let (reader, stream, contract) = self
1028            .shared
1029            .opener
1030            .open(resource, resource_index, &self.shared.identity)
1031            .await
1032            .map_err(|error| StreamOpenError::with_category(error.category()))?;
1033
1034        match inherited_delegate {
1035            Some(columns) => {
1036                // Fail closed if the delegate that produced this durable
1037                // record did not actually report the namespace this opener
1038                // assigned it -- reconstructing the envelope under the
1039                // expected namespace regardless would normalize a bad
1040                // namespace into a good one (see
1041                // `DelegateEnvelopeColumns`'s docs).
1042                if !columns.namespace_matches(&self.shared.identity) {
1043                    return Err(StreamOpenError::with_category(FailureCategory::Invariant));
1044                }
1045                let inner = columns
1046                    .to_envelope(self.shared.identity.clone(), self.shared.limits)
1047                    .ok_or_else(StreamOpenError::new)?;
1048                let validated = contract
1049                    .validate_for_open(&inner)
1050                    .map_err(|_| StreamOpenError::new())?;
1051                stream
1052                    .open(StreamOpenContext::new(
1053                        Some(&validated),
1054                        context.stop_token(),
1055                    ))
1056                    .await
1057                    .map_err(|error| StreamOpenError::with_category(error.category()))?;
1058            }
1059            None => {
1060                stream
1061                    .open(StreamOpenContext::new(None, context.stop_token()))
1062                    .await
1063                    .map_err(|error| StreamOpenError::with_category(error.category()))?;
1064            }
1065        }
1066
1067        // Handed off to `MultiResourceReader::read` via `pending_reader`
1068        // (see its docs): the runtime always calls `ItemStream::open`
1069        // before the first `ItemReader::read` in an attempt, so the reader
1070        // is available by the time it is needed.
1071        *self
1072            .shared
1073            .pending_reader
1074            .lock()
1075            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(reader);
1076        *self.shared.handle.lock().await = Some(StreamHandle {
1077            index: resource_index,
1078            stream,
1079            retired: false,
1080        });
1081
1082        Ok(if restored {
1083            StreamOpenOutcome::Restored
1084        } else {
1085            StreamOpenOutcome::Initial
1086        })
1087    }
1088
1089    async fn update(
1090        &self,
1091        context: StreamUpdateContext<'_>,
1092    ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
1093        if let Some(category) = *self.shared.poisoned.lock().await {
1094            return Err(StreamUpdateError::with_category(category));
1095        }
1096        let guard = self.shared.handle.lock().await;
1097        let (resource_index, delegate) = match guard.as_ref() {
1098            Some(handle) => {
1099                let envelope = handle
1100                    .stream
1101                    .update(StreamUpdateContext::new(context.stop_token()))
1102                    .await
1103                    .map_err(|error| StreamUpdateError::with_category(error.category()))?;
1104                // Fail closed before this candidate can be embedded in the
1105                // outer candidate: the delegate must report exactly the
1106                // namespace this opener assigned it, never a substitute the
1107                // core runtime's own `ItemStream::update` fail-closed
1108                // namespace check would have rejected at the top level.
1109                if envelope.namespace() != &self.shared.identity {
1110                    return Err(StreamUpdateError::with_category(FailureCategory::Invariant));
1111                }
1112                let columns = DelegateEnvelopeColumns::from_envelope(&envelope)
1113                    .map_err(|_| StreamUpdateError::new())?;
1114                (handle.index, Some(columns))
1115            }
1116            None => (
1117                u32::try_from(self.shared.resources.len()).unwrap_or(u32::MAX),
1118                None,
1119            ),
1120        };
1121        drop(guard);
1122        let state = MultiResourceState {
1123            resource_set_revision: self.shared.resources.revision(),
1124            resource_index,
1125            delegate,
1126            resource_batches_written: 0,
1127        };
1128        let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
1129        ComponentStateEnvelope::encode(
1130            self.shared.identity.clone(),
1131            &state,
1132            &codec,
1133            self.shared.limits,
1134        )
1135        .map_err(|_| StreamUpdateError::new())
1136    }
1137
1138    async fn close(
1139        &self,
1140        context: StreamCloseContext<'_>,
1141    ) -> Result<StreamCloseOutcome, StreamCloseError> {
1142        let guard = self.shared.handle.lock().await;
1143        if let Some(handle) = guard.as_ref()
1144            && !handle.retired
1145        {
1146            handle
1147                .stream
1148                .close(StreamCloseContext::new(
1149                    context.stop_token(),
1150                    context.outcome(),
1151                ))
1152                .await
1153                .map_err(|error| StreamCloseError::with_category(error.category()))?;
1154        }
1155        Ok(StreamCloseOutcome::Closed)
1156    }
1157}
1158
1159/// Builds a `(reader, stream, contract)` triple over an ordered resource
1160/// set, namespaced under `identity`.
1161///
1162/// Register the stream with [`crate::ChunkStep::with_item_stream`] under
1163/// the same `identity`, and declare `identity` in the job's
1164/// [`crate::ChunkComponentRevisions`] via
1165/// [`crate::ChunkComponentRevisions::with_stream_revision`], exactly like
1166/// any other restartable reader.
1167///
1168/// `restartability` should be
1169/// [`RestartabilityDeclaration::NotRestartable`] when `opener` cannot
1170/// guarantee reopening the exact same resource content after a restart
1171/// (e.g. an object-store backend without stable version identity for its
1172/// listed objects).
1173pub fn multi_resource_reader<I, O>(
1174    resources: ResourceSet,
1175    opener: O,
1176    identity: ComponentStreamIdentity,
1177    restartability: RestartabilityDeclaration,
1178) -> (
1179    MultiResourceReader<I, O>,
1180    MultiResourceReaderStream<I, O>,
1181    StreamStateContract,
1182)
1183where
1184    I: Send + 'static,
1185    O: MultiResourceReaderOpener<I> + 'static,
1186{
1187    let shared = Arc::new(ReaderShared {
1188        opener,
1189        resources,
1190        identity,
1191        handle: AsyncMutex::new(None),
1192        pending_reader: std::sync::Mutex::new(None),
1193        poisoned: AsyncMutex::new(None),
1194        limits: StateLimits::default(),
1195        _marker: PhantomData,
1196    });
1197    let reader = MultiResourceReader {
1198        shared: Arc::clone(&shared),
1199        reader: None,
1200        started: false,
1201        transitioning: false,
1202        poisoned: None,
1203    };
1204    let stream = MultiResourceReaderStream { shared };
1205    let contract = StreamStateContract::new(multi_resource_position_codec(restartability));
1206    (reader, stream, contract)
1207}
1208
1209// ---------------------------------------------------------------------
1210// Writer
1211// ---------------------------------------------------------------------
1212
1213/// Opens one physical output resource on demand for a
1214/// [`MultiResourceWriter`], producing a fresh delegate writer paired with
1215/// its own [`crate::ItemStream`] half and state contract.
1216pub trait MultiResourceWriterOpener<O>: Send + Sync {
1217    /// The delegate writer this opener produces.
1218    type Writer: ItemWriter<O>;
1219    /// The delegate writer's own [`crate::ItemStream`] half.
1220    type Stream: ItemStream;
1221
1222    /// Opens `resource` fresh for output.
1223    ///
1224    /// # Errors
1225    ///
1226    /// Returns a redacted [`MultiResourceOpenError`] naming `resource`'s
1227    /// ordinal.
1228    fn open<'a>(
1229        &'a self,
1230        resource: &'a ResourceIdentity,
1231        resource_ordinal: u32,
1232        delegate_identity: &'a ComponentStreamIdentity,
1233    ) -> impl Future<
1234        Output = Result<(Self::Writer, Self::Stream, StreamStateContract), MultiResourceOpenError>,
1235    > + Send
1236    + 'a;
1237}
1238
1239/// Decides, before each [`ItemWriter::write`] batch, whether the current
1240/// output resource should roll over to the next one first.
1241///
1242/// A batch is never split across two resources: the rollover decision is
1243/// made once, before the batch is written, so one `write` call's items
1244/// always land entirely in one physical resource.
1245pub trait RolloverPolicy: Send + Sync {
1246    /// Returns whether the current resource should roll over before the
1247    /// next batch is written, given the number of batches already written
1248    /// to it.
1249    fn should_roll_over(&self, resource_batches_written: u64) -> bool;
1250}
1251
1252/// Rolls over after a fixed number of batches have been written to the
1253/// current resource.
1254#[derive(Clone, Copy, Debug)]
1255pub struct BatchCountRollover {
1256    max_batches_per_resource: u64,
1257}
1258
1259impl BatchCountRollover {
1260    /// Rolls over once `max_batches_per_resource` batches have been written
1261    /// to the current resource.
1262    #[must_use]
1263    pub const fn new(max_batches_per_resource: u64) -> Self {
1264        Self {
1265            max_batches_per_resource,
1266        }
1267    }
1268}
1269
1270impl RolloverPolicy for BatchCountRollover {
1271    fn should_roll_over(&self, resource_batches_written: u64) -> bool {
1272        resource_batches_written >= self.max_batches_per_resource
1273    }
1274}
1275
1276/// Never rolls over: exactly one output resource is used.
1277#[derive(Clone, Copy, Debug, Default)]
1278pub struct NoRollover;
1279
1280impl RolloverPolicy for NoRollover {
1281    fn should_roll_over(&self, _resource_batches_written: u64) -> bool {
1282        false
1283    }
1284}
1285
1286struct ActiveWriteResource<W, S> {
1287    index: u32,
1288    writer: W,
1289    stream: S,
1290    batches_written: u64,
1291    /// `true` once `stream` has already been closed at a resource boundary
1292    /// (see [`crate::StreamRuntimeOutcome::ResourceBoundary`]) but rollover
1293    /// has not yet been able to replace this entry with the next resource
1294    /// (the next resource's own open failed and is pending retry). Guards
1295    /// against closing the same delegate twice, both on a retried rollover
1296    /// and at the outer terminal close.
1297    retired: bool,
1298}
1299
1300/// The writer's state, one lock for both branches.
1301///
1302/// `Active` and `Poisoned` share [`WriterShared::active`]'s single
1303/// [`AsyncMutex`] rather than living behind two separate locks (an active
1304/// resource plus a standalone poison flag): deciding *whether* to poison and
1305/// committing that decision must be one atomic transition under one lock,
1306/// or a concurrent `write` could observe the pre-poisoning state (read the
1307/// flag before it flips) after the poisoning transition has already
1308/// logically happened, and roll over into a resource this writer is
1309/// supposed to have permanently stopped using.
1310enum WriterState<W, S> {
1311    /// A resource is open and receiving writes.
1312    Active(ActiveWriteResource<W, S>),
1313    /// A resource-boundary `close` failed. `ItemStream::close` carries no
1314    /// idempotency/atomicity guarantee, so this writer never attempts
1315    /// another close or write on any delegate again: every later `write`
1316    /// returns this same failure. The delegate that failed to close was
1317    /// already marked retired before this transition, so there is nothing
1318    /// left to close here or at the outer terminal close.
1319    Poisoned(FailureCategory),
1320}
1321
1322struct WriterShared<O, Opener: MultiResourceWriterOpener<O>, P> {
1323    opener: Opener,
1324    resources: ResourceSet,
1325    identity: ComponentStreamIdentity,
1326    rollover: P,
1327    /// `None` before the paired stream's `open` has run for the first
1328    /// time in this attempt.
1329    active: AsyncMutex<Option<WriterState<Opener::Writer, Opener::Stream>>>,
1330    limits: StateLimits,
1331    _marker: PhantomData<fn() -> O>,
1332}
1333
1334/// Writes a logical output partitioned across an ordered set of physical
1335/// resources.
1336///
1337/// # Contract
1338///
1339/// - **Input/output**: `O`, same as every delegate the opener produces.
1340/// - **State/checkpoint**: [`ResourceSetRevision`] plus the current output
1341///   resource's ordinal and embedded delegate position, through the paired
1342///   [`MultiResourceWriterStream`], following the same nested-envelope
1343///   scheme as [`MultiResourceReader`].
1344/// - **Ordering**: resources are filled in [`ResourceSet`]'s declared
1345///   order; one `write` batch never spans two resources.
1346/// - **Restartability**: supplied explicitly at construction, same as
1347///   [`multi_resource_reader`].
1348/// - **Thread safety**: `Send + Sync`; one async-aware lock guards the
1349///   active-or-poisoned state, the rollover decision, and the write
1350///   together as one atomic critical section (this crate's chunk runtime
1351///   never actually calls `write` concurrently on one writer instance; the
1352///   lock exists so this type's own invariants hold regardless). The
1353///   active resource and the poison flag deliberately share this single
1354///   lock rather than living behind two separate ones: two locks would let
1355///   a concurrent `write` read "not yet poisoned" before a poisoning
1356///   transition commits and then roll over into a resource this writer is
1357///   supposed to have permanently stopped using.
1358/// - **Transaction/delivery**: never claims a stronger mode than the
1359///   current delegate writer supports; a rollover happens between writer
1360///   calls, never inside one enlisted call.
1361/// - **Failure semantics**: a failure while writing to resource N does not
1362///   roll over to resource N+1 -- the same delegate is retried at the same
1363///   resource on the framework's own retry contract, exactly like an
1364///   undecorated writer.
1365/// - **Close**: rollover closes the outgoing resource's delegate stream
1366///   (with [`crate::StreamRuntimeOutcome::ResourceBoundary`], never
1367///   `Committed` -- the enclosing step attempt has not reached a terminal
1368///   outcome yet) before opening the next one, never leaving two resources'
1369///   delegates open at once; the paired [`MultiResourceWriterStream::close`]
1370///   closes whichever resource is still active at the step attempt's own
1371///   terminal outcome, and never re-closes a resource already closed by a
1372///   rollover.
1373/// - **Support tier**: first-party.
1374pub struct MultiResourceWriter<O, Opener: MultiResourceWriterOpener<O>, P> {
1375    shared: Arc<WriterShared<O, Opener, P>>,
1376}
1377
1378impl<O, Opener, P> ItemWriter<O> for MultiResourceWriter<O, Opener, P>
1379where
1380    O: Sync + 'static,
1381    Opener: MultiResourceWriterOpener<O> + 'static,
1382    P: RolloverPolicy + 'static,
1383{
1384    async fn write<'a>(
1385        &'a self,
1386        items: &'a [O],
1387        mut context: WriteContext<'a>,
1388    ) -> Result<WriteOutcome, WriterError> {
1389        let stop = context.stop_token();
1390        if stop.is_stop_requested() {
1391            return Ok(WriteOutcome::Stopped);
1392        }
1393        if self.shared.resources.is_empty() {
1394            return Err(WriterError::with_category(FailureCategory::Invariant));
1395        }
1396
1397        // One lock guards the poison check, the rollover decision, the
1398        // close/open transition, and the write itself: all of it is one
1399        // atomic critical section, so a concurrent `write` can never
1400        // observe a stale "not yet poisoned" read that a poisoning
1401        // transition has already superseded (see `WriterState`'s docs).
1402        let mut guard = self.shared.active.lock().await;
1403        let (should_roll_over, current_index) = match guard.as_ref() {
1404            Some(WriterState::Poisoned(category)) => {
1405                return Err(WriterError::with_category(*category));
1406            }
1407            Some(WriterState::Active(active)) => (
1408                self.shared
1409                    .rollover
1410                    .should_roll_over(active.batches_written),
1411                active.index,
1412            ),
1413            None => return Err(WriterError::with_category(FailureCategory::Invariant)),
1414        };
1415
1416        if should_roll_over {
1417            let next_index = current_index + 1;
1418            if (next_index as usize) < self.shared.resources.len() {
1419                // Close the outgoing resource's stream before opening the
1420                // next one: `StreamRuntimeOutcome::ResourceBoundary` (never
1421                // `Committed` -- the enclosing step attempt has not reached
1422                // a terminal outcome yet) tells the delegate its own local
1423                // work is done without falsely claiming the outer
1424                // transaction committed.
1425                //
1426                // The close is attempted exactly once. `ItemStream` carries
1427                // no idempotency/atomicity guarantee, so a failed close is
1428                // never retried on this same delegate instance -- see
1429                // `WriterState`'s docs.
1430                let mut boundary_close_failed = None;
1431                if let Some(WriterState::Active(active)) = guard.as_mut()
1432                    && !active.retired
1433                {
1434                    let closed = active
1435                        .stream
1436                        .close(StreamCloseContext::new(
1437                            stop,
1438                            StreamRuntimeOutcome::ResourceBoundary,
1439                        ))
1440                        .await;
1441                    // Attempted (successfully or not): never closed again,
1442                    // by this instance or by the outer terminal close.
1443                    active.retired = true;
1444                    if let Err(error) = closed {
1445                        boundary_close_failed = Some(error.category());
1446                    }
1447                }
1448                if let Some(category) = boundary_close_failed {
1449                    *guard = Some(WriterState::Poisoned(category));
1450                    return Err(WriterError::with_category(category));
1451                }
1452                let next_resource = &self.shared.resources.resources()[next_index as usize];
1453                // Fresh resource, no inherited state: the returned contract
1454                // has nothing left to validate.
1455                let (writer, stream, _contract) = self
1456                    .shared
1457                    .opener
1458                    .open(next_resource, next_index, &self.shared.identity)
1459                    .await
1460                    .map_err(|error| WriterError::with_category(error.category()))?;
1461                stream
1462                    .open(StreamOpenContext::new(None, stop))
1463                    .await
1464                    .map_err(|error| WriterError::with_category(error.category()))?;
1465                *guard = Some(WriterState::Active(ActiveWriteResource {
1466                    index: next_index,
1467                    writer,
1468                    stream,
1469                    batches_written: 0,
1470                    retired: false,
1471                }));
1472            }
1473            // No further resource to roll over into: keep writing to the
1474            // current (last) resource rather than failing -- the rollover
1475            // policy is a hint for splitting output, not a hard cap.
1476        }
1477
1478        let Some(WriterState::Active(active)) = guard.as_mut() else {
1479            return Err(WriterError::with_category(FailureCategory::Invariant));
1480        };
1481        // `WriteContext` holds `Option<&mut dyn BusinessTransaction>`, which
1482        // is invariant in its lifetime: the received `context` cannot be
1483        // passed to the delegate directly without forcing the delegate's own
1484        // lifetime to unify with `'a` (this call's, spanning the whole
1485        // outer future), which in turn would force `active`'s borrow -- and
1486        // therefore the mutex guard holding it -- to outlive this function.
1487        // Reconstructing a fresh, guard-scoped `WriteContext` (the same
1488        // technique `FanOutWriter` uses) avoids that.
1489        let enlisted = context.is_enlisted();
1490        let delegate_context = if enlisted {
1491            let transaction: &mut dyn crate::BusinessTransaction =
1492                context.transaction().ok_or_else(WriterError::new)?;
1493            WriteContext::enlisted(stop, transaction)
1494        } else {
1495            WriteContext::non_transactional(stop)
1496        };
1497        let outcome = active.writer.write(items, delegate_context).await?;
1498        if matches!(outcome, WriteOutcome::Written) {
1499            active.batches_written += 1;
1500        }
1501        Ok(outcome)
1502    }
1503}
1504
1505/// The [`crate::ItemStream`] half of a [`MultiResourceWriter`].
1506pub struct MultiResourceWriterStream<O, Opener: MultiResourceWriterOpener<O>, P> {
1507    shared: Arc<WriterShared<O, Opener, P>>,
1508}
1509
1510impl<O, Opener, P> ItemStream for MultiResourceWriterStream<O, Opener, P>
1511where
1512    O: Sync + 'static,
1513    Opener: MultiResourceWriterOpener<O> + 'static,
1514    P: RolloverPolicy + 'static,
1515{
1516    async fn open(
1517        &self,
1518        context: StreamOpenContext<'_>,
1519    ) -> Result<StreamOpenOutcome, StreamOpenError> {
1520        let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
1521        let restored = context.inherited_state().is_some();
1522        let (resource_index, inherited_delegate, resource_batches_written) =
1523            match context.inherited_state() {
1524                Some(envelope) => {
1525                    let state = envelope
1526                        .decode::<MultiResourceState>(&codec)
1527                        .map_err(|_| StreamOpenError::new())?;
1528                    if state.resource_set_revision != self.shared.resources.revision() {
1529                        return Err(StreamOpenError::with_category(
1530                            FailureCategory::UnsupportedCapability,
1531                        ));
1532                    }
1533                    (
1534                        state.resource_index,
1535                        state.delegate,
1536                        state.resource_batches_written,
1537                    )
1538                }
1539                None => (0, None, 0),
1540            };
1541
1542        let resources_len = u32::try_from(self.shared.resources.len()).unwrap_or(u32::MAX);
1543        if resource_index >= resources_len {
1544            return Err(StreamOpenError::with_category(FailureCategory::Invariant));
1545        }
1546
1547        let resource = &self.shared.resources.resources()[resource_index as usize];
1548        let (writer, stream, contract) = self
1549            .shared
1550            .opener
1551            .open(resource, resource_index, &self.shared.identity)
1552            .await
1553            .map_err(|error| StreamOpenError::with_category(error.category()))?;
1554
1555        match inherited_delegate {
1556            Some(columns) => {
1557                // Fail closed if the delegate that produced this durable
1558                // record did not actually report the namespace this opener
1559                // assigned it -- see `DelegateEnvelopeColumns`'s docs.
1560                if !columns.namespace_matches(&self.shared.identity) {
1561                    return Err(StreamOpenError::with_category(FailureCategory::Invariant));
1562                }
1563                let inner = columns
1564                    .to_envelope(self.shared.identity.clone(), self.shared.limits)
1565                    .ok_or_else(StreamOpenError::new)?;
1566                let validated = contract
1567                    .validate_for_open(&inner)
1568                    .map_err(|_| StreamOpenError::new())?;
1569                stream
1570                    .open(StreamOpenContext::new(
1571                        Some(&validated),
1572                        context.stop_token(),
1573                    ))
1574                    .await
1575                    .map_err(|error| StreamOpenError::with_category(error.category()))?;
1576            }
1577            None => {
1578                stream
1579                    .open(StreamOpenContext::new(None, context.stop_token()))
1580                    .await
1581                    .map_err(|error| StreamOpenError::with_category(error.category()))?;
1582            }
1583        }
1584
1585        *self.shared.active.lock().await = Some(WriterState::Active(ActiveWriteResource {
1586            index: resource_index,
1587            writer,
1588            stream,
1589            batches_written: resource_batches_written,
1590            retired: false,
1591        }));
1592
1593        Ok(if restored {
1594            StreamOpenOutcome::Restored
1595        } else {
1596            StreamOpenOutcome::Initial
1597        })
1598    }
1599
1600    async fn update(
1601        &self,
1602        context: StreamUpdateContext<'_>,
1603    ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
1604        let guard = self.shared.active.lock().await;
1605        let active = match guard.as_ref() {
1606            Some(WriterState::Active(active)) => active,
1607            Some(WriterState::Poisoned(category)) => {
1608                return Err(StreamUpdateError::with_category(*category));
1609            }
1610            None => return Err(StreamUpdateError::with_category(FailureCategory::Invariant)),
1611        };
1612        let envelope = active
1613            .stream
1614            .update(StreamUpdateContext::new(context.stop_token()))
1615            .await
1616            .map_err(|error| StreamUpdateError::with_category(error.category()))?;
1617        // Fail closed before this candidate can be embedded in the outer
1618        // candidate -- see the reader stream's `update` for the same check.
1619        if envelope.namespace() != &self.shared.identity {
1620            return Err(StreamUpdateError::with_category(FailureCategory::Invariant));
1621        }
1622        let columns = DelegateEnvelopeColumns::from_envelope(&envelope)
1623            .map_err(|_| StreamUpdateError::new())?;
1624        let state = MultiResourceState {
1625            resource_set_revision: self.shared.resources.revision(),
1626            resource_index: active.index,
1627            delegate: Some(columns),
1628            resource_batches_written: active.batches_written,
1629        };
1630        drop(guard);
1631        let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
1632        ComponentStateEnvelope::encode(
1633            self.shared.identity.clone(),
1634            &state,
1635            &codec,
1636            self.shared.limits,
1637        )
1638        .map_err(|_| StreamUpdateError::new())
1639    }
1640
1641    async fn close(
1642        &self,
1643        context: StreamCloseContext<'_>,
1644    ) -> Result<StreamCloseOutcome, StreamCloseError> {
1645        let guard = self.shared.active.lock().await;
1646        // `Poisoned` means a boundary close was already attempted (and
1647        // failed) on the delegate that was active at the time; nothing
1648        // further to close here, by construction.
1649        if let Some(WriterState::Active(active)) = guard.as_ref()
1650            && !active.retired
1651        {
1652            active
1653                .stream
1654                .close(StreamCloseContext::new(
1655                    context.stop_token(),
1656                    context.outcome(),
1657                ))
1658                .await
1659                .map_err(|error| StreamCloseError::with_category(error.category()))?;
1660        }
1661        Ok(StreamCloseOutcome::Closed)
1662    }
1663}
1664
1665/// The `(writer, stream, contract)` triple [`multi_resource_writer`]
1666/// returns.
1667pub type MultiResourceWriterTriple<O, Opener, P> = (
1668    MultiResourceWriter<O, Opener, P>,
1669    MultiResourceWriterStream<O, Opener, P>,
1670    StreamStateContract,
1671);
1672
1673/// Builds a `(writer, stream, contract)` triple over an ordered resource
1674/// set, namespaced under `identity`.
1675///
1676/// `resources` must be nonempty: a multi-resource writer always has a
1677/// current output resource to write to (unlike the reader, which can
1678/// legitimately describe zero input resources).
1679///
1680/// # Errors
1681///
1682/// Returns [`WriterConfigError::EmptyResourceSet`] when `resources` is
1683/// empty.
1684pub fn multi_resource_writer<O, Opener, P>(
1685    resources: ResourceSet,
1686    opener: Opener,
1687    identity: ComponentStreamIdentity,
1688    rollover: P,
1689    restartability: RestartabilityDeclaration,
1690) -> Result<MultiResourceWriterTriple<O, Opener, P>, WriterConfigError>
1691where
1692    O: Sync + 'static,
1693    Opener: MultiResourceWriterOpener<O> + 'static,
1694    P: RolloverPolicy + 'static,
1695{
1696    if resources.is_empty() {
1697        return Err(WriterConfigError::EmptyResourceSet);
1698    }
1699    let shared = Arc::new(WriterShared {
1700        opener,
1701        resources,
1702        identity,
1703        rollover,
1704        active: AsyncMutex::new(None),
1705        limits: StateLimits::default(),
1706        _marker: PhantomData,
1707    });
1708    let writer = MultiResourceWriter {
1709        shared: Arc::clone(&shared),
1710    };
1711    let stream = MultiResourceWriterStream { shared };
1712    let contract = StreamStateContract::new(multi_resource_position_codec(restartability));
1713    Ok((writer, stream, contract))
1714}
1715
1716/// A validation failure building a [`MultiResourceWriter`].
1717#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1718#[non_exhaustive]
1719pub enum WriterConfigError {
1720    /// A multi-resource writer requires at least one output resource.
1721    EmptyResourceSet,
1722}
1723
1724impl fmt::Display for WriterConfigError {
1725    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1726        match self {
1727            Self::EmptyResourceSet => {
1728                formatter.write_str("multi-resource writer requires a nonempty resource set")
1729            }
1730        }
1731    }
1732}
1733
1734impl std::error::Error for WriterConfigError {}
1735
1736#[cfg(test)]
1737mod tests {
1738    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1739
1740    use std::collections::{HashMap, HashSet};
1741
1742    use super::*;
1743    use crate::StopSource;
1744
1745    fn identity(name: &str) -> ComponentStreamIdentity {
1746        ComponentStreamIdentity::new(format!("oxide-batch.multi-resource-unit-test.{name}"))
1747            .expect("static identity is valid")
1748    }
1749
1750    fn resource_set(names: &[&str]) -> ResourceSet {
1751        ResourceSet::new(
1752            names
1753                .iter()
1754                .map(|name| ResourceIdentity::new((*name).to_owned()).unwrap())
1755                .collect(),
1756        )
1757    }
1758
1759    #[test]
1760    fn resource_identity_rejects_one_byte_past_its_ceiling() {
1761        assert!(ResourceIdentity::new("x".repeat(MAX_RESOURCE_IDENTITY_BYTES)).is_ok());
1762        let error = ResourceIdentity::new("x".repeat(MAX_RESOURCE_IDENTITY_BYTES + 1))
1763            .expect_err("one byte past the ceiling must be refused, not silently truncated");
1764        assert_eq!(
1765            error,
1766            MultiResourceConfigError::ResourceIdentityTooLong {
1767                max_bytes: MAX_RESOURCE_IDENTITY_BYTES
1768            }
1769        );
1770    }
1771
1772    #[test]
1773    fn resource_identity_rejects_empty_and_control_characters() {
1774        assert_eq!(
1775            ResourceIdentity::new(""),
1776            Err(MultiResourceConfigError::EmptyResourceIdentity)
1777        );
1778        assert_eq!(
1779            ResourceIdentity::new("a\nb"),
1780            Err(MultiResourceConfigError::MalformedResourceIdentity)
1781        );
1782    }
1783
1784    // -- a minimal in-memory `ItemReader`/`ItemStream` delegate pair, the
1785    // same shape `delimited_reader` returns, used only to exercise
1786    // `MultiResourceReader`/`MultiResourceReaderStream` without real I/O.
1787
1788    #[derive(Clone, Copy, Eq, PartialEq)]
1789    struct VecPosition(u64);
1790
1791    const VEC_READ_SCHEMA: &str = "oxide-batch.multi-resource-unit-test.vec-read-position";
1792    const VEC_READ_CODEC: &str = "oxide-batch.multi-resource-unit-test.vec-read-position-codec";
1793
1794    #[derive(Clone, Copy)]
1795    struct VecReadSchema;
1796
1797    impl VersionedStateCodec<VecPosition> for VecReadSchema {
1798        fn schema_id(&self) -> &StateSchemaId {
1799            static SCHEMA: std::sync::OnceLock<StateSchemaId> = std::sync::OnceLock::new();
1800            SCHEMA.get_or_init(|| StateSchemaId::new(VEC_READ_SCHEMA).unwrap())
1801        }
1802
1803        fn current_version(&self) -> StateSchemaVersion {
1804            StateSchemaVersion::new(1).unwrap()
1805        }
1806
1807        fn encode(&self, value: &VecPosition) -> Result<Vec<u8>, StateCodecError> {
1808            serde_json::to_vec(&serde_json::json!({ "ordinal": value.0 }))
1809                .map_err(|_| StateCodecError::InvalidPayload)
1810        }
1811
1812        fn decode(&self, payload: &[u8]) -> Result<VecPosition, StateCodecError> {
1813            let value: serde_json::Value =
1814                serde_json::from_slice(payload).map_err(|_| StateCodecError::InvalidPayload)?;
1815            let ordinal = value
1816                .get("ordinal")
1817                .and_then(serde_json::Value::as_u64)
1818                .ok_or(StateCodecError::InvalidPayload)?;
1819            Ok(VecPosition(ordinal))
1820        }
1821    }
1822
1823    fn vec_read_codec() -> DefaultComponentCodec<VecReadSchema> {
1824        DefaultComponentCodec::new(
1825            VecReadSchema,
1826            crate::CodecId::new(VEC_READ_CODEC).unwrap(),
1827            crate::CodecVersion::new(1).unwrap(),
1828            RestartabilityDeclaration::Restartable,
1829        )
1830        .with_sensitivity(StateSensitivity::NonSensitive)
1831    }
1832
1833    struct VecItemReader {
1834        items: Vec<u64>,
1835        ordinal: Arc<AsyncMutex<u64>>,
1836    }
1837
1838    impl ItemReader<u64> for VecItemReader {
1839        async fn read(
1840            &mut self,
1841            _context: ReadContext<'_>,
1842        ) -> Result<ReadOutcome<u64>, ReaderError> {
1843            let mut ordinal = self.ordinal.lock().await;
1844            let index = usize::try_from(*ordinal).unwrap_or(usize::MAX);
1845            match self.items.get(index) {
1846                Some(item) => {
1847                    let item = *item;
1848                    *ordinal += 1;
1849                    Ok(ReadOutcome::Item(item))
1850                }
1851                None => Ok(ReadOutcome::EndOfInput),
1852            }
1853        }
1854    }
1855
1856    struct VecItemReaderStream {
1857        ordinal: Arc<AsyncMutex<u64>>,
1858        namespace: ComponentStreamIdentity,
1859    }
1860
1861    impl ItemStream for VecItemReaderStream {
1862        async fn open(
1863            &self,
1864            context: StreamOpenContext<'_>,
1865        ) -> Result<StreamOpenOutcome, StreamOpenError> {
1866            let codec = vec_read_codec();
1867            if let Some(envelope) = context.inherited_state() {
1868                let restored = envelope
1869                    .decode::<VecPosition>(&codec)
1870                    .map_err(|_| StreamOpenError::new())?;
1871                *self.ordinal.lock().await = restored.0;
1872                Ok(StreamOpenOutcome::Restored)
1873            } else {
1874                *self.ordinal.lock().await = 0;
1875                Ok(StreamOpenOutcome::Initial)
1876            }
1877        }
1878
1879        async fn update(
1880            &self,
1881            _context: StreamUpdateContext<'_>,
1882        ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
1883            let codec = vec_read_codec();
1884            let ordinal = *self.ordinal.lock().await;
1885            ComponentStateEnvelope::encode(
1886                self.namespace.clone(),
1887                &VecPosition(ordinal),
1888                &codec,
1889                StateLimits::default(),
1890            )
1891            .map_err(|_| StreamUpdateError::new())
1892        }
1893
1894        async fn close(
1895            &self,
1896            _context: StreamCloseContext<'_>,
1897        ) -> Result<StreamCloseOutcome, StreamCloseError> {
1898            Ok(StreamCloseOutcome::Closed)
1899        }
1900    }
1901
1902    struct TestReaderOpener {
1903        data: HashMap<String, Vec<u64>>,
1904        fail: HashSet<String>,
1905    }
1906
1907    impl MultiResourceReaderOpener<u64> for TestReaderOpener {
1908        type Reader = VecItemReader;
1909        type Stream = VecItemReaderStream;
1910
1911        async fn open(
1912            &self,
1913            resource: &ResourceIdentity,
1914            resource_ordinal: u32,
1915            delegate_identity: &ComponentStreamIdentity,
1916        ) -> Result<(Self::Reader, Self::Stream, StreamStateContract), MultiResourceOpenError>
1917        {
1918            if self.fail.contains(resource.as_str()) {
1919                return Err(MultiResourceOpenError::new(resource_ordinal));
1920            }
1921            let items = self
1922                .data
1923                .get(resource.as_str())
1924                .cloned()
1925                .unwrap_or_default();
1926            let ordinal = Arc::new(AsyncMutex::new(0));
1927            let reader = VecItemReader {
1928                items,
1929                ordinal: Arc::clone(&ordinal),
1930            };
1931            let stream = VecItemReaderStream {
1932                ordinal,
1933                namespace: delegate_identity.clone(),
1934            };
1935            Ok((reader, stream, StreamStateContract::new(vec_read_codec())))
1936        }
1937    }
1938
1939    fn stop() -> (StopSource, crate::StopToken) {
1940        StopSource::new()
1941    }
1942
1943    fn read_context(stop: &crate::StopToken) -> ReadContext<'_> {
1944        ReadContext::new(stop)
1945    }
1946
1947    #[test]
1948    fn ordered_traversal_across_resources_reads_in_declared_order() {
1949        let mut data = HashMap::new();
1950        data.insert("a".to_owned(), vec![1, 2]);
1951        data.insert("b".to_owned(), vec![3]);
1952        data.insert("c".to_owned(), vec![4, 5]);
1953        let opener = TestReaderOpener {
1954            data,
1955            fail: HashSet::new(),
1956        };
1957        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
1958            resource_set(&["a", "b", "c"]),
1959            opener,
1960            identity("ordered"),
1961            RestartabilityDeclaration::Restartable,
1962        );
1963        let (_source, token) = stop();
1964        futures_executor::block_on(async {
1965            stream
1966                .open(StreamOpenContext::new(None, &token))
1967                .await
1968                .unwrap();
1969            let mut collected = Vec::new();
1970            loop {
1971                match reader.read(read_context(&token)).await.unwrap() {
1972                    ReadOutcome::Item(item) => collected.push(item),
1973                    ReadOutcome::EndOfInput => break,
1974                    ReadOutcome::Stopped => panic!("unexpected stop"),
1975                }
1976            }
1977            assert_eq!(collected, vec![1, 2, 3, 4, 5]);
1978        });
1979    }
1980
1981    #[test]
1982    fn empty_resource_set_reader_returns_end_of_input_immediately() {
1983        let opener = TestReaderOpener {
1984            data: HashMap::new(),
1985            fail: HashSet::new(),
1986        };
1987        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
1988            resource_set(&[]),
1989            opener,
1990            identity("empty"),
1991            RestartabilityDeclaration::Restartable,
1992        );
1993        let (_source, token) = stop();
1994        futures_executor::block_on(async {
1995            stream
1996                .open(StreamOpenContext::new(None, &token))
1997                .await
1998                .unwrap();
1999            assert_eq!(
2000                reader.read(read_context(&token)).await.unwrap(),
2001                ReadOutcome::EndOfInput
2002            );
2003        });
2004    }
2005
2006    #[test]
2007    fn restart_mid_resource_resumes_at_committed_position_not_resource_start() {
2008        let mut data = HashMap::new();
2009        data.insert("a".to_owned(), vec![1, 2]);
2010        data.insert("b".to_owned(), vec![10, 20, 30]);
2011        let opener = || TestReaderOpener {
2012            data: data.clone(),
2013            fail: HashSet::new(),
2014        };
2015        let resources = resource_set(&["a", "b"]);
2016        let (_source, token) = stop();
2017
2018        // Attempt 1: exhaust resource "a" and read one item from "b", then
2019        // commit (call `update`) -- simulating a chunk boundary -- before
2020        // "crashing" (the reader/stream pair is simply dropped).
2021        let committed_envelope = futures_executor::block_on(async {
2022            let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2023                resources.clone(),
2024                opener(),
2025                identity("restart"),
2026                RestartabilityDeclaration::Restartable,
2027            );
2028            stream
2029                .open(StreamOpenContext::new(None, &token))
2030                .await
2031                .unwrap();
2032            assert_eq!(
2033                reader.read(read_context(&token)).await.unwrap(),
2034                ReadOutcome::Item(1)
2035            );
2036            assert_eq!(
2037                reader.read(read_context(&token)).await.unwrap(),
2038                ReadOutcome::Item(2)
2039            );
2040            // Resource "a" exhausts and transitions to "b" on this call.
2041            assert_eq!(
2042                reader.read(read_context(&token)).await.unwrap(),
2043                ReadOutcome::Item(10)
2044            );
2045            stream
2046                .update(StreamUpdateContext::new(&token))
2047                .await
2048                .unwrap()
2049        });
2050
2051        // Attempt 2: fresh reader/stream pair over the same resource set,
2052        // restored from the committed envelope.
2053        futures_executor::block_on(async {
2054            let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2055                resources,
2056                opener(),
2057                identity("restart"),
2058                RestartabilityDeclaration::Restartable,
2059            );
2060            stream
2061                .open(StreamOpenContext::new(Some(&committed_envelope), &token))
2062                .await
2063                .unwrap();
2064            // Must resume with "b"'s next item (20), never replaying "a" or
2065            // restarting "b" from its own beginning (10).
2066            assert_eq!(
2067                reader.read(read_context(&token)).await.unwrap(),
2068                ReadOutcome::Item(20)
2069            );
2070            assert_eq!(
2071                reader.read(read_context(&token)).await.unwrap(),
2072                ReadOutcome::Item(30)
2073            );
2074            assert_eq!(
2075                reader.read(read_context(&token)).await.unwrap(),
2076                ReadOutcome::EndOfInput
2077            );
2078        });
2079    }
2080
2081    #[test]
2082    fn resource_set_revision_mismatch_on_restart_is_rejected() {
2083        let mut data = HashMap::new();
2084        data.insert("a".to_owned(), vec![1]);
2085        data.insert("b".to_owned(), vec![2]);
2086        let (_source, token) = stop();
2087
2088        let committed_envelope = futures_executor::block_on(async {
2089            let opener = TestReaderOpener {
2090                data: data.clone(),
2091                fail: HashSet::new(),
2092            };
2093            let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2094                resource_set(&["a", "b"]),
2095                opener,
2096                identity("revision"),
2097                RestartabilityDeclaration::Restartable,
2098            );
2099            stream
2100                .open(StreamOpenContext::new(None, &token))
2101                .await
2102                .unwrap();
2103            reader.read(read_context(&token)).await.unwrap();
2104            stream
2105                .update(StreamUpdateContext::new(&token))
2106                .await
2107                .unwrap()
2108        });
2109
2110        // Restart against a resource set with an inserted resource ahead of
2111        // the committed index -- the same physical index no longer names
2112        // the same resource, and this must fail closed rather than
2113        // silently reinterpreting it.
2114        futures_executor::block_on(async {
2115            let opener = TestReaderOpener {
2116                data,
2117                fail: HashSet::new(),
2118            };
2119            let (_reader, stream, _contract) = multi_resource_reader::<u64, _>(
2120                resource_set(&["z", "a", "b"]),
2121                opener,
2122                identity("revision"),
2123                RestartabilityDeclaration::Restartable,
2124            );
2125            let result = stream
2126                .open(StreamOpenContext::new(Some(&committed_envelope), &token))
2127                .await;
2128            assert!(
2129                result.is_err(),
2130                "changed resource set must be rejected, not silently resumed"
2131            );
2132        });
2133    }
2134
2135    #[test]
2136    fn resource_open_failure_does_not_advance_past_the_failure_point() {
2137        let mut data = HashMap::new();
2138        data.insert("a".to_owned(), vec![1]);
2139        data.insert("c".to_owned(), vec![3]);
2140        let mut fail = HashSet::new();
2141        fail.insert("b".to_owned());
2142        let opener = TestReaderOpener { data, fail };
2143        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2144            resource_set(&["a", "b", "c"]),
2145            opener,
2146            identity("open-failure"),
2147            RestartabilityDeclaration::Restartable,
2148        );
2149        let (_source, token) = stop();
2150        futures_executor::block_on(async {
2151            stream
2152                .open(StreamOpenContext::new(None, &token))
2153                .await
2154                .unwrap();
2155            assert_eq!(
2156                reader.read(read_context(&token)).await.unwrap(),
2157                ReadOutcome::Item(1)
2158            );
2159            // "a" exhausts, transition to "b" fails to open.
2160            let result = reader.read(read_context(&token)).await;
2161            assert!(
2162                result.is_err(),
2163                "resource b's open failure must surface, not be swallowed"
2164            );
2165            // A retried call must not have silently advanced to "c".
2166            let retried = reader.read(read_context(&token)).await;
2167            assert!(
2168                retried.is_err(),
2169                "a retry must fail at the same resource again, never skip to c"
2170            );
2171        });
2172    }
2173
2174    // -- Finding 1/3 (#177) regression evidence: recording delegates that
2175    // log every `open`/`update`/`close` call (and the `StreamRuntimeOutcome`
2176    // `close` receives) so tests can assert the exact nested lifecycle,
2177    // not only its externally observable read/write effects.
2178
2179    #[derive(Debug, Clone, PartialEq, Eq)]
2180    enum LifecycleEvent {
2181        Open(String),
2182        Update(String),
2183        Close(String, RecordedOutcome),
2184    }
2185
2186    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2187    enum RecordedOutcome {
2188        Committed,
2189        Failed,
2190        Stopped,
2191        Unknown,
2192        ResourceBoundary,
2193    }
2194
2195    impl From<StreamRuntimeOutcome> for RecordedOutcome {
2196        fn from(outcome: StreamRuntimeOutcome) -> Self {
2197            match outcome {
2198                StreamRuntimeOutcome::Committed => Self::Committed,
2199                StreamRuntimeOutcome::Failed => Self::Failed,
2200                StreamRuntimeOutcome::Stopped => Self::Stopped,
2201                StreamRuntimeOutcome::Unknown => Self::Unknown,
2202                StreamRuntimeOutcome::ResourceBoundary => Self::ResourceBoundary,
2203            }
2204        }
2205    }
2206
2207    type Log = Arc<std::sync::Mutex<Vec<LifecycleEvent>>>;
2208
2209    fn log_event(log: &Log, event: LifecycleEvent) {
2210        log.lock()
2211            .unwrap_or_else(std::sync::PoisonError::into_inner)
2212            .push(event);
2213    }
2214
2215    struct RecordingReaderStream {
2216        ordinal: Arc<AsyncMutex<u64>>,
2217        namespace: ComponentStreamIdentity,
2218        resource: String,
2219        log: Log,
2220        /// When set, closing this resource fails. `ItemStream::close` is
2221        /// only ever attempted once per instance (see the module's "Nested
2222        /// resource lifecycle" docs), so this is never cleared/retried by
2223        /// the wrapper -- a test that wants to prove recovery constructs a
2224        /// *second*, independent delegate instance instead, exactly as a
2225        /// fresh step attempt would.
2226        fail_close: Arc<std::sync::atomic::AtomicBool>,
2227    }
2228
2229    impl ItemStream for RecordingReaderStream {
2230        async fn open(
2231            &self,
2232            context: StreamOpenContext<'_>,
2233        ) -> Result<StreamOpenOutcome, StreamOpenError> {
2234            log_event(&self.log, LifecycleEvent::Open(self.resource.clone()));
2235            let codec = vec_read_codec();
2236            if let Some(envelope) = context.inherited_state() {
2237                let restored = envelope
2238                    .decode::<VecPosition>(&codec)
2239                    .map_err(|_| StreamOpenError::new())?;
2240                *self.ordinal.lock().await = restored.0;
2241                Ok(StreamOpenOutcome::Restored)
2242            } else {
2243                *self.ordinal.lock().await = 0;
2244                Ok(StreamOpenOutcome::Initial)
2245            }
2246        }
2247
2248        async fn update(
2249            &self,
2250            _context: StreamUpdateContext<'_>,
2251        ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
2252            log_event(&self.log, LifecycleEvent::Update(self.resource.clone()));
2253            let codec = vec_read_codec();
2254            let ordinal = *self.ordinal.lock().await;
2255            ComponentStateEnvelope::encode(
2256                self.namespace.clone(),
2257                &VecPosition(ordinal),
2258                &codec,
2259                StateLimits::default(),
2260            )
2261            .map_err(|_| StreamUpdateError::new())
2262        }
2263
2264        async fn close(
2265            &self,
2266            context: StreamCloseContext<'_>,
2267        ) -> Result<StreamCloseOutcome, StreamCloseError> {
2268            log_event(
2269                &self.log,
2270                LifecycleEvent::Close(self.resource.clone(), context.outcome().into()),
2271            );
2272            if self.fail_close.load(std::sync::atomic::Ordering::SeqCst) {
2273                return Err(StreamCloseError::new());
2274            }
2275            Ok(StreamCloseOutcome::Closed)
2276        }
2277    }
2278
2279    struct RecordingReaderOpener {
2280        data: HashMap<String, Vec<u64>>,
2281        log: Log,
2282        fail_open: HashSet<String>,
2283        fail_close: HashMap<String, Arc<std::sync::atomic::AtomicBool>>,
2284    }
2285
2286    impl MultiResourceReaderOpener<u64> for RecordingReaderOpener {
2287        type Reader = VecItemReader;
2288        type Stream = RecordingReaderStream;
2289
2290        async fn open(
2291            &self,
2292            resource: &ResourceIdentity,
2293            resource_ordinal: u32,
2294            delegate_identity: &ComponentStreamIdentity,
2295        ) -> Result<(Self::Reader, Self::Stream, StreamStateContract), MultiResourceOpenError>
2296        {
2297            if self.fail_open.contains(resource.as_str()) {
2298                return Err(MultiResourceOpenError::new(resource_ordinal));
2299            }
2300            let items = self
2301                .data
2302                .get(resource.as_str())
2303                .cloned()
2304                .unwrap_or_default();
2305            let ordinal = Arc::new(AsyncMutex::new(0));
2306            let reader = VecItemReader {
2307                items,
2308                ordinal: Arc::clone(&ordinal),
2309            };
2310            let fail_close = self
2311                .fail_close
2312                .get(resource.as_str())
2313                .cloned()
2314                .unwrap_or_else(|| Arc::new(std::sync::atomic::AtomicBool::new(false)));
2315            let stream = RecordingReaderStream {
2316                ordinal,
2317                namespace: delegate_identity.clone(),
2318                resource: resource.as_str().to_owned(),
2319                log: Arc::clone(&self.log),
2320                fail_close,
2321            };
2322            Ok((reader, stream, StreamStateContract::new(vec_read_codec())))
2323        }
2324    }
2325
2326    #[test]
2327    fn reader_closes_each_delegate_exactly_once_with_resource_boundary_outcome_in_order() {
2328        let mut data = HashMap::new();
2329        data.insert("a".to_owned(), vec![1, 2]);
2330        data.insert("b".to_owned(), vec![3]);
2331        let log: Log = Arc::new(std::sync::Mutex::new(Vec::new()));
2332        let opener = RecordingReaderOpener {
2333            data,
2334            log: Arc::clone(&log),
2335            fail_open: HashSet::new(),
2336            fail_close: HashMap::new(),
2337        };
2338        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2339            resource_set(&["a", "b"]),
2340            opener,
2341            identity("lifecycle-order"),
2342            RestartabilityDeclaration::Restartable,
2343        );
2344        let (_source, token) = stop();
2345        futures_executor::block_on(async {
2346            stream
2347                .open(StreamOpenContext::new(None, &token))
2348                .await
2349                .unwrap();
2350            loop {
2351                match reader.read(read_context(&token)).await.unwrap() {
2352                    ReadOutcome::Item(_) => {}
2353                    ReadOutcome::EndOfInput => break,
2354                    ReadOutcome::Stopped => panic!("unexpected stop"),
2355                }
2356            }
2357        });
2358        let events = log.lock().unwrap().clone();
2359        assert_eq!(
2360            events,
2361            vec![
2362                LifecycleEvent::Open("a".to_owned()),
2363                LifecycleEvent::Close("a".to_owned(), RecordedOutcome::ResourceBoundary),
2364                LifecycleEvent::Open("b".to_owned()),
2365                LifecycleEvent::Close("b".to_owned(), RecordedOutcome::ResourceBoundary),
2366            ],
2367            "the intermediate delegate (\"a\") and the final delegate (\"b\") must \
2368             each open exactly once and close exactly once, in order, with \
2369             `ResourceBoundary` -- never `Committed`, which would falsely \
2370             claim the enclosing step attempt itself had committed"
2371        );
2372    }
2373
2374    #[test]
2375    fn reader_resource_boundary_close_failure_poisons_the_reader_without_advancing_or_retrying() {
2376        let mut data = HashMap::new();
2377        data.insert("a".to_owned(), vec![1]);
2378        data.insert("b".to_owned(), vec![2]);
2379        let log: Log = Arc::new(std::sync::Mutex::new(Vec::new()));
2380        let mut fail_close = HashMap::new();
2381        fail_close.insert(
2382            "a".to_owned(),
2383            Arc::new(std::sync::atomic::AtomicBool::new(true)),
2384        );
2385        let opener = RecordingReaderOpener {
2386            data,
2387            log: Arc::clone(&log),
2388            fail_open: HashSet::new(),
2389            fail_close,
2390        };
2391        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2392            resource_set(&["a", "b"]),
2393            opener,
2394            identity("boundary-close-failure"),
2395            RestartabilityDeclaration::Restartable,
2396        );
2397        let (_source, token) = stop();
2398        futures_executor::block_on(async {
2399            stream
2400                .open(StreamOpenContext::new(None, &token))
2401                .await
2402                .unwrap();
2403            assert_eq!(
2404                reader.read(read_context(&token)).await.unwrap(),
2405                ReadOutcome::Item(1)
2406            );
2407            // The last checkpoint captured *before* the failing transition
2408            // must still describe resource index 0 with "a"'s own position.
2409            let envelope = stream
2410                .update(StreamUpdateContext::new(&token))
2411                .await
2412                .unwrap();
2413            let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
2414            let state = envelope.decode::<MultiResourceState>(&codec).unwrap();
2415            assert_eq!(
2416                state.resource_index, 0,
2417                "checkpoint advancement must not silently occur after a failed boundary close"
2418            );
2419            // "a" exhausts; its boundary close fails.
2420            let result = reader.read(read_context(&token)).await;
2421            assert!(result.is_err(), "a boundary close failure must surface");
2422            // `ItemStream::close` carries no idempotency/atomicity
2423            // guarantee, so this instance is now poisoned: every further
2424            // `read` returns the same failure without ever calling `close`
2425            // on "a" a second time or proceeding to "b" -- and `update` also
2426            // fails closed rather than exposing a phantom checkpoint for a
2427            // reader that can no longer make progress. A real recovery
2428            // requires a fresh step attempt (a new `MultiResourceReader`,
2429            // opened from the still-at-"a" checkpoint above), not a retry on
2430            // this instance.
2431            for _ in 0..3 {
2432                let retried = reader.read(read_context(&token)).await;
2433                assert!(
2434                    retried.is_err(),
2435                    "a poisoned instance must keep failing, never silently recover"
2436                );
2437            }
2438            let post_poison_update = stream.update(StreamUpdateContext::new(&token)).await;
2439            assert!(
2440                post_poison_update.is_err(),
2441                "update must fail closed once poisoned, not expose a stale checkpoint"
2442            );
2443        });
2444        let events = log.lock().unwrap().clone();
2445        assert_eq!(
2446            events,
2447            vec![
2448                LifecycleEvent::Open("a".to_owned()),
2449                LifecycleEvent::Update("a".to_owned()),
2450                LifecycleEvent::Close("a".to_owned(), RecordedOutcome::ResourceBoundary),
2451            ],
2452            "\"a\" must be closed exactly once, ever -- never retried after a \
2453             failure, and \"b\" must never be opened"
2454        );
2455    }
2456
2457    #[test]
2458    fn outer_terminal_close_does_not_double_close_a_reader_delegate_already_retired_at_a_boundary()
2459    {
2460        let mut data = HashMap::new();
2461        data.insert("a".to_owned(), vec![1]);
2462        let mut fail_open = HashSet::new();
2463        fail_open.insert("b".to_owned());
2464        let log: Log = Arc::new(std::sync::Mutex::new(Vec::new()));
2465        let opener = RecordingReaderOpener {
2466            data,
2467            log: Arc::clone(&log),
2468            fail_open,
2469            fail_close: HashMap::new(),
2470        };
2471        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2472            resource_set(&["a", "b"]),
2473            opener,
2474            identity("no-double-close"),
2475            RestartabilityDeclaration::Restartable,
2476        );
2477        let (_source, token) = stop();
2478        futures_executor::block_on(async {
2479            stream
2480                .open(StreamOpenContext::new(None, &token))
2481                .await
2482                .unwrap();
2483            assert_eq!(
2484                reader.read(read_context(&token)).await.unwrap(),
2485                ReadOutcome::Item(1)
2486            );
2487            // "a" exhausts and closes successfully; opening "b" then fails,
2488            // so the step attempt gives up and the outer terminal close
2489            // runs with the step's real (failed) outcome.
2490            let result = reader.read(read_context(&token)).await;
2491            assert!(result.is_err());
2492            stream
2493                .close(StreamCloseContext::new(
2494                    &token,
2495                    StreamRuntimeOutcome::Failed,
2496                ))
2497                .await
2498                .unwrap();
2499        });
2500        let events = log.lock().unwrap().clone();
2501        assert_eq!(
2502            events,
2503            vec![
2504                LifecycleEvent::Open("a".to_owned()),
2505                LifecycleEvent::Close("a".to_owned(), RecordedOutcome::ResourceBoundary),
2506            ],
2507            "\"a\" must be closed exactly once total -- the outer terminal \
2508             close must recognize it is already retired and skip it, not \
2509             close it a second time with the step's real outcome"
2510        );
2511    }
2512
2513    #[test]
2514    fn reader_update_fails_closed_when_delegate_reports_the_wrong_namespace() {
2515        struct WrongNamespaceStream {
2516            wrong_namespace: ComponentStreamIdentity,
2517        }
2518        impl ItemStream for WrongNamespaceStream {
2519            async fn open(
2520                &self,
2521                context: StreamOpenContext<'_>,
2522            ) -> Result<StreamOpenOutcome, StreamOpenError> {
2523                Ok(if context.inherited_state().is_some() {
2524                    StreamOpenOutcome::Restored
2525                } else {
2526                    StreamOpenOutcome::Initial
2527                })
2528            }
2529            async fn update(
2530                &self,
2531                _context: StreamUpdateContext<'_>,
2532            ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
2533                // A buggy/malicious delegate that ignores the identity the
2534                // multi-resource opener assigned it and reports a different
2535                // namespace of its own choosing.
2536                ComponentStateEnvelope::encode(
2537                    self.wrong_namespace.clone(),
2538                    &VecPosition(0),
2539                    &vec_read_codec(),
2540                    StateLimits::default(),
2541                )
2542                .map_err(|_| StreamUpdateError::new())
2543            }
2544            async fn close(
2545                &self,
2546                _context: StreamCloseContext<'_>,
2547            ) -> Result<StreamCloseOutcome, StreamCloseError> {
2548                Ok(StreamCloseOutcome::Closed)
2549            }
2550        }
2551
2552        struct WrongNamespaceOpener {
2553            wrong_namespace: ComponentStreamIdentity,
2554        }
2555        impl MultiResourceReaderOpener<u64> for WrongNamespaceOpener {
2556            type Reader = VecItemReader;
2557            type Stream = WrongNamespaceStream;
2558            async fn open(
2559                &self,
2560                _resource: &ResourceIdentity,
2561                _resource_ordinal: u32,
2562                _delegate_identity: &ComponentStreamIdentity,
2563            ) -> Result<(Self::Reader, Self::Stream, StreamStateContract), MultiResourceOpenError>
2564            {
2565                Ok((
2566                    VecItemReader {
2567                        items: vec![1],
2568                        ordinal: Arc::new(AsyncMutex::new(0)),
2569                    },
2570                    WrongNamespaceStream {
2571                        wrong_namespace: self.wrong_namespace.clone(),
2572                    },
2573                    StreamStateContract::new(vec_read_codec()),
2574                ))
2575            }
2576        }
2577
2578        let opener = WrongNamespaceOpener {
2579            wrong_namespace: identity("attacker-namespace"),
2580        };
2581        let (mut reader, stream, _contract) = multi_resource_reader::<u64, _>(
2582            resource_set(&["a"]),
2583            opener,
2584            identity("expected-namespace"),
2585            RestartabilityDeclaration::Restartable,
2586        );
2587        let (_source, token) = stop();
2588        futures_executor::block_on(async {
2589            stream
2590                .open(StreamOpenContext::new(None, &token))
2591                .await
2592                .unwrap();
2593            reader.read(read_context(&token)).await.unwrap();
2594            let result = stream.update(StreamUpdateContext::new(&token)).await;
2595            assert!(
2596                result.is_err(),
2597                "a nested delegate reporting the wrong namespace must fail \
2598                 the outer update closed, not be embedded into an outer \
2599                 candidate that could reach durable checkpoint state"
2600            );
2601        });
2602    }
2603
2604    #[test]
2605    fn reader_open_fails_closed_on_a_hand_crafted_mismatched_namespace_record() {
2606        // Bypasses the update-time check entirely (which already prevents a
2607        // wrong-namespace candidate from ever being committed) to prove
2608        // restore *independently* rejects a durable record whose embedded
2609        // delegate namespace does not match the expected identity -- e.g. if
2610        // such a record reached storage by some other means.
2611        let expected_identity = identity("restore-namespace-check");
2612        let wrong_identity = identity("restore-namespace-check-wrong");
2613        let delegate_envelope = ComponentStateEnvelope::encode(
2614            wrong_identity,
2615            &VecPosition(0),
2616            &vec_read_codec(),
2617            StateLimits::default(),
2618        )
2619        .unwrap();
2620        let columns = DelegateEnvelopeColumns::from_envelope(&delegate_envelope).unwrap();
2621        let resources = resource_set(&["a"]);
2622        let state = MultiResourceState {
2623            resource_set_revision: resources.revision(),
2624            resource_index: 0,
2625            delegate: Some(columns),
2626            resource_batches_written: 0,
2627        };
2628        let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
2629        let outer_envelope = ComponentStateEnvelope::encode(
2630            expected_identity.clone(),
2631            &state,
2632            &codec,
2633            StateLimits::default(),
2634        )
2635        .unwrap();
2636
2637        let mut data = HashMap::new();
2638        data.insert("a".to_owned(), vec![1]);
2639        let opener = TestReaderOpener {
2640            data,
2641            fail: HashSet::new(),
2642        };
2643        let (_reader, stream, _contract) = multi_resource_reader::<u64, _>(
2644            resources,
2645            opener,
2646            expected_identity,
2647            RestartabilityDeclaration::Restartable,
2648        );
2649        let (_source, token) = stop();
2650        let result = futures_executor::block_on(
2651            stream.open(StreamOpenContext::new(Some(&outer_envelope), &token)),
2652        );
2653        assert!(
2654            result.is_err(),
2655            "a stored delegate envelope under the wrong namespace must fail \
2656             closed on restore, never be silently reconstructed under the \
2657             expected identity"
2658        );
2659    }
2660
2661    /// Encodes the schema-1 shape #176 actually persisted: no `namespace`
2662    /// column on the embedded delegate. Used only to construct a realistic
2663    /// stale durable record for
2664    /// [`stale_v1_delegate_record_without_a_namespace_fails_closed_on_restore`]
2665    /// -- production code never encodes this shape again.
2666    #[derive(Clone, Copy)]
2667    struct LegacyV1MultiResourceSchema;
2668
2669    impl VersionedStateCodec<MultiResourceState> for LegacyV1MultiResourceSchema {
2670        fn schema_id(&self) -> &StateSchemaId {
2671            static SCHEMA: std::sync::OnceLock<StateSchemaId> = std::sync::OnceLock::new();
2672            SCHEMA.get_or_init(|| StateSchemaId::new(MULTI_RESOURCE_SCHEMA).unwrap())
2673        }
2674
2675        fn current_version(&self) -> StateSchemaVersion {
2676            StateSchemaVersion::new(1).unwrap()
2677        }
2678
2679        fn encode(&self, value: &MultiResourceState) -> Result<Vec<u8>, StateCodecError> {
2680            let delegate = value.delegate.as_ref().map(|delegate| {
2681                serde_json::json!({
2682                    "schema_id": delegate.schema_id,
2683                    "schema_version": delegate.schema_version,
2684                    "codec_id": delegate.codec_id,
2685                    "codec_version": delegate.codec_version,
2686                    "checksum_algorithm": delegate.checksum_algorithm,
2687                    "checksum_algorithm_version": delegate.checksum_algorithm_version,
2688                    "checksum": hex_encode(&delegate.checksum),
2689                    "is_external": delegate.is_external,
2690                    "payload_inline": delegate.payload_inline_hex,
2691                    "payload_external_content_id": delegate.payload_external_content_id_hex,
2692                    "payload_external_len": delegate.payload_external_len,
2693                })
2694            });
2695            serde_json::to_vec(&serde_json::json!({
2696                "resource_set_revision": value.resource_set_revision.to_hex(),
2697                "resource_index": value.resource_index,
2698                "delegate": delegate,
2699                "resource_batches_written": value.resource_batches_written,
2700            }))
2701            .map_err(|_| StateCodecError::InvalidPayload)
2702        }
2703
2704        fn decode(&self, _payload: &[u8]) -> Result<MultiResourceState, StateCodecError> {
2705            unreachable!(
2706                "only used to construct a stale v1 fixture; the current (v2) \
2707                 codec has no declared upgrade edge from v1, so the shared \
2708                 upgrade-chain walk rejects a recorded v1 envelope before \
2709                 any codec's `decode` runs on it"
2710            )
2711        }
2712    }
2713
2714    #[test]
2715    fn stale_v1_delegate_record_without_a_namespace_fails_closed_on_restore() {
2716        // #176 persisted schema version 1, whose delegate columns never
2717        // included a namespace. #177 made namespace a required column and
2718        // bumped the schema to version 2 without declaring an upgrade edge
2719        // from 1 -- there is nothing honest to backfill a pre-#177 record's
2720        // missing delegate namespace with, so this must be a hard,
2721        // fail-closed boundary, never a silent migration.
2722        let identity = identity("legacy-v1-fail-closed");
2723        let inner_envelope = ComponentStateEnvelope::encode(
2724            identity.clone(),
2725            &VecPosition(0),
2726            &vec_read_codec(),
2727            StateLimits::default(),
2728        )
2729        .unwrap();
2730        let columns = DelegateEnvelopeColumns::from_envelope(&inner_envelope).unwrap();
2731        let resources = resource_set(&["a"]);
2732        let state = MultiResourceState {
2733            resource_set_revision: resources.revision(),
2734            resource_index: 0,
2735            delegate: Some(columns),
2736            resource_batches_written: 0,
2737        };
2738        #[allow(clippy::unwrap_used)]
2739        let legacy_codec = DefaultComponentCodec::new(
2740            LegacyV1MultiResourceSchema,
2741            crate::CodecId::new(MULTI_RESOURCE_CODEC).unwrap(),
2742            crate::CodecVersion::new(1).unwrap(),
2743            RestartabilityDeclaration::Restartable,
2744        );
2745        let legacy_envelope = ComponentStateEnvelope::encode(
2746            identity.clone(),
2747            &state,
2748            &legacy_codec,
2749            StateLimits::default(),
2750        )
2751        .unwrap();
2752
2753        let mut data = HashMap::new();
2754        data.insert("a".to_owned(), vec![1]);
2755        let opener = TestReaderOpener {
2756            data,
2757            fail: HashSet::new(),
2758        };
2759        let (_reader, stream, _contract) = multi_resource_reader::<u64, _>(
2760            resources,
2761            opener,
2762            identity,
2763            RestartabilityDeclaration::Restartable,
2764        );
2765        let (_source, token) = stop();
2766        let result = futures_executor::block_on(
2767            stream.open(StreamOpenContext::new(Some(&legacy_envelope), &token)),
2768        );
2769        assert!(
2770            result.is_err(),
2771            "a version-1 durable record (pre-#177, no delegate namespace) \
2772             must fail closed on restore -- never silently accepted, and \
2773             never migrated by inventing a namespace it never carried"
2774        );
2775    }
2776
2777    // -- writer side: a minimal in-memory delegate mirroring the reader
2778    // fixture above.
2779
2780    struct VecItemWriter {
2781        resource: String,
2782        sink: Arc<std::sync::Mutex<HashMap<String, Vec<u64>>>>,
2783        committed_len: Arc<AsyncMutex<u64>>,
2784    }
2785
2786    impl ItemWriter<u64> for VecItemWriter {
2787        async fn write<'a>(
2788            &'a self,
2789            items: &'a [u64],
2790            context: WriteContext<'a>,
2791        ) -> Result<WriteOutcome, WriterError> {
2792            if context.stop_token().is_stop_requested() {
2793                return Ok(WriteOutcome::Stopped);
2794            }
2795            let mut committed = self.committed_len.lock().await;
2796            self.sink
2797                .lock()
2798                .unwrap_or_else(std::sync::PoisonError::into_inner)
2799                .entry(self.resource.clone())
2800                .or_default()
2801                .extend_from_slice(items);
2802            *committed += u64::try_from(items.len()).unwrap();
2803            Ok(WriteOutcome::Written)
2804        }
2805    }
2806
2807    struct VecItemWriterStream {
2808        committed_len: Arc<AsyncMutex<u64>>,
2809        namespace: ComponentStreamIdentity,
2810    }
2811
2812    impl ItemStream for VecItemWriterStream {
2813        async fn open(
2814            &self,
2815            context: StreamOpenContext<'_>,
2816        ) -> Result<StreamOpenOutcome, StreamOpenError> {
2817            let codec = vec_read_codec();
2818            if let Some(envelope) = context.inherited_state() {
2819                let restored = envelope
2820                    .decode::<VecPosition>(&codec)
2821                    .map_err(|_| StreamOpenError::new())?;
2822                *self.committed_len.lock().await = restored.0;
2823                Ok(StreamOpenOutcome::Restored)
2824            } else {
2825                *self.committed_len.lock().await = 0;
2826                Ok(StreamOpenOutcome::Initial)
2827            }
2828        }
2829
2830        async fn update(
2831            &self,
2832            _context: StreamUpdateContext<'_>,
2833        ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
2834            let codec = vec_read_codec();
2835            let committed = *self.committed_len.lock().await;
2836            ComponentStateEnvelope::encode(
2837                self.namespace.clone(),
2838                &VecPosition(committed),
2839                &codec,
2840                StateLimits::default(),
2841            )
2842            .map_err(|_| StreamUpdateError::new())
2843        }
2844
2845        async fn close(
2846            &self,
2847            _context: StreamCloseContext<'_>,
2848        ) -> Result<StreamCloseOutcome, StreamCloseError> {
2849            Ok(StreamCloseOutcome::Closed)
2850        }
2851    }
2852
2853    struct TestWriterOpener {
2854        sink: Arc<std::sync::Mutex<HashMap<String, Vec<u64>>>>,
2855    }
2856
2857    impl MultiResourceWriterOpener<u64> for TestWriterOpener {
2858        type Writer = VecItemWriter;
2859        type Stream = VecItemWriterStream;
2860
2861        async fn open(
2862            &self,
2863            resource: &ResourceIdentity,
2864            _resource_ordinal: u32,
2865            delegate_identity: &ComponentStreamIdentity,
2866        ) -> Result<(Self::Writer, Self::Stream, StreamStateContract), MultiResourceOpenError>
2867        {
2868            let committed_len = Arc::new(AsyncMutex::new(0));
2869            let writer = VecItemWriter {
2870                resource: resource.as_str().to_owned(),
2871                sink: Arc::clone(&self.sink),
2872                committed_len: Arc::clone(&committed_len),
2873            };
2874            let stream = VecItemWriterStream {
2875                committed_len,
2876                namespace: delegate_identity.clone(),
2877            };
2878            Ok((writer, stream, StreamStateContract::new(vec_read_codec())))
2879        }
2880    }
2881
2882    fn write_context(stop: &crate::StopToken) -> WriteContext<'_> {
2883        WriteContext::non_transactional(stop)
2884    }
2885
2886    #[test]
2887    fn rollover_writes_batches_to_successive_resources_in_order() {
2888        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
2889        let opener = TestWriterOpener {
2890            sink: Arc::clone(&sink),
2891        };
2892        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
2893            resource_set(&["a", "b", "c"]),
2894            opener,
2895            identity("rollover"),
2896            BatchCountRollover::new(1),
2897            RestartabilityDeclaration::Restartable,
2898        )
2899        .unwrap();
2900        let (_source, token) = stop();
2901        futures_executor::block_on(async {
2902            stream
2903                .open(StreamOpenContext::new(None, &token))
2904                .await
2905                .unwrap();
2906            writer.write(&[1, 2], write_context(&token)).await.unwrap();
2907            writer.write(&[3], write_context(&token)).await.unwrap();
2908            writer.write(&[4, 5], write_context(&token)).await.unwrap();
2909        });
2910        let sink = sink.lock().unwrap();
2911        assert_eq!(sink.get("a"), Some(&vec![1, 2]));
2912        assert_eq!(sink.get("b"), Some(&vec![3]));
2913        assert_eq!(sink.get("c"), Some(&vec![4, 5]));
2914    }
2915
2916    #[test]
2917    fn writer_restart_mid_resource_resumes_committed_position() {
2918        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
2919        let (_source, token) = stop();
2920
2921        let committed_envelope = futures_executor::block_on(async {
2922            let opener = TestWriterOpener {
2923                sink: Arc::clone(&sink),
2924            };
2925            let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
2926                resource_set(&["a", "b"]),
2927                opener,
2928                identity("writer-restart"),
2929                NoRollover,
2930                RestartabilityDeclaration::Restartable,
2931            )
2932            .unwrap();
2933            stream
2934                .open(StreamOpenContext::new(None, &token))
2935                .await
2936                .unwrap();
2937            writer.write(&[1, 2], write_context(&token)).await.unwrap();
2938            stream
2939                .update(StreamUpdateContext::new(&token))
2940                .await
2941                .unwrap()
2942        });
2943
2944        let second_attempt_envelope = futures_executor::block_on(async {
2945            let opener = TestWriterOpener {
2946                sink: Arc::clone(&sink),
2947            };
2948            let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
2949                resource_set(&["a", "b"]),
2950                opener,
2951                identity("writer-restart"),
2952                NoRollover,
2953                RestartabilityDeclaration::Restartable,
2954            )
2955            .unwrap();
2956            stream
2957                .open(StreamOpenContext::new(Some(&committed_envelope), &token))
2958                .await
2959                .unwrap();
2960            writer.write(&[3], write_context(&token)).await.unwrap();
2961            stream
2962                .update(StreamUpdateContext::new(&token))
2963                .await
2964                .unwrap()
2965        });
2966
2967        // The delegate's own committed-length position must have resumed
2968        // from the first attempt's committed value (2) and advanced by
2969        // exactly this attempt's one batch (1) to 3 -- never restarting
2970        // from 0, and never silently rolling over to resource "b".
2971        let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
2972        let state = second_attempt_envelope
2973            .decode::<MultiResourceState>(&codec)
2974            .unwrap();
2975        assert_eq!(
2976            state.resource_index, 0,
2977            "must still be resource \"a\", index 0"
2978        );
2979        let delegate = state
2980            .delegate
2981            .expect("a written resource always has delegate state");
2982        let inner = delegate
2983            .to_envelope(identity("writer-restart"), StateLimits::default())
2984            .expect("embedded delegate envelope must decode");
2985        let inner_position = inner.decode::<VecPosition>(&vec_read_codec()).unwrap();
2986        assert_eq!(
2987            inner_position.0, 3,
2988            "committed length must resume from 2 and advance by this attempt's batch, not restart from 0"
2989        );
2990
2991        // The fixture's writer appends unconditionally to a process-wide
2992        // sink regardless of restart (it does not reconcile against the
2993        // restored committed length the way a real backend like
2994        // `DelimitedWriter` truncates to it) -- that reconciliation is a
2995        // delegate responsibility, already covered by the delegate's own
2996        // tests, not something `MultiResourceWriter` re-implements. This
2997        // test's job is only the position round-trip asserted above.
2998        let sink = sink.lock().unwrap();
2999        assert_eq!(sink.get("a"), Some(&vec![1, 2, 3]));
3000    }
3001
3002    #[test]
3003    fn rollover_counter_survives_restart_and_still_caps_batches_per_resource() {
3004        // `BatchCountRollover::new(2)` must cap resource "a" at exactly two
3005        // committed batches, restart or not. Before this fix,
3006        // `batches_written` was an in-memory-only counter that reset to `0`
3007        // on every restart, so a crash-and-restart mid-resource silently let
3008        // more than `max_batches_per_resource` batches land in one resource
3009        // -- the durable envelope now carries the true count instead.
3010        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3011        let (_source, token) = stop();
3012
3013        let committed_envelope = futures_executor::block_on(async {
3014            let opener = TestWriterOpener {
3015                sink: Arc::clone(&sink),
3016            };
3017            let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3018                resource_set(&["a", "b"]),
3019                opener,
3020                identity("rollover-restart"),
3021                BatchCountRollover::new(2),
3022                RestartabilityDeclaration::Restartable,
3023            )
3024            .unwrap();
3025            stream
3026                .open(StreamOpenContext::new(None, &token))
3027                .await
3028                .unwrap();
3029            // First committed batch to resource "a"; batches_written -> 1.
3030            writer.write(&[1], write_context(&token)).await.unwrap();
3031            stream
3032                .update(StreamUpdateContext::new(&token))
3033                .await
3034                .unwrap()
3035        });
3036
3037        // Simulate a crash-and-restart: a fresh writer instance restores
3038        // from the committed envelope above.
3039        futures_executor::block_on(async {
3040            let opener = TestWriterOpener {
3041                sink: Arc::clone(&sink),
3042            };
3043            let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3044                resource_set(&["a", "b"]),
3045                opener,
3046                identity("rollover-restart"),
3047                BatchCountRollover::new(2),
3048                RestartabilityDeclaration::Restartable,
3049            )
3050            .unwrap();
3051            stream
3052                .open(StreamOpenContext::new(Some(&committed_envelope), &token))
3053                .await
3054                .unwrap();
3055            // If the restored count were wrongly reset to 0, this would be
3056            // read as the resource's 1st and 2nd post-restart batch and
3057            // both would land in "a", violating the cap of 2. With the
3058            // count correctly restored to 1, this single write is the
3059            // resource's 2nd batch (1 -> 2): still no rollover yet.
3060            writer.write(&[2], write_context(&token)).await.unwrap();
3061            // This next write is attempt number 3 for resource "a" with a
3062            // cap of 2: it must roll over to "b" first.
3063            writer.write(&[3], write_context(&token)).await.unwrap();
3064        });
3065
3066        let sink = sink.lock().unwrap();
3067        assert_eq!(
3068            sink.get("a"),
3069            Some(&vec![1, 2]),
3070            "resource \"a\" must never exceed its 2-batch cap across the restart"
3071        );
3072        assert_eq!(
3073            sink.get("b"),
3074            Some(&vec![3]),
3075            "the third batch must have rolled over into resource \"b\""
3076        );
3077    }
3078
3079    #[test]
3080    fn stale_resource_set_revision_is_rejected_on_writer_restart() {
3081        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3082        let (_source, token) = stop();
3083
3084        let committed_envelope = futures_executor::block_on(async {
3085            let opener = TestWriterOpener {
3086                sink: Arc::clone(&sink),
3087            };
3088            let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3089                resource_set(&["a", "b"]),
3090                opener,
3091                identity("writer-revision"),
3092                NoRollover,
3093                RestartabilityDeclaration::Restartable,
3094            )
3095            .unwrap();
3096            stream
3097                .open(StreamOpenContext::new(None, &token))
3098                .await
3099                .unwrap();
3100            writer.write(&[1], write_context(&token)).await.unwrap();
3101            stream
3102                .update(StreamUpdateContext::new(&token))
3103                .await
3104                .unwrap()
3105        });
3106
3107        futures_executor::block_on(async {
3108            let opener = TestWriterOpener {
3109                sink: Arc::clone(&sink),
3110            };
3111            let (_writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3112                resource_set(&["a", "b", "c"]),
3113                opener,
3114                identity("writer-revision"),
3115                NoRollover,
3116                RestartabilityDeclaration::Restartable,
3117            )
3118            .unwrap();
3119            let result = stream
3120                .open(StreamOpenContext::new(Some(&committed_envelope), &token))
3121                .await;
3122            assert!(
3123                result.is_err(),
3124                "changed resource set must be rejected on writer restart too"
3125            );
3126        });
3127    }
3128
3129    // -- Finding 1/3 (#177) regression evidence: writer-side recording
3130    // delegate mirroring the reader's above.
3131
3132    struct RecordingWriterStream {
3133        committed_len: Arc<AsyncMutex<u64>>,
3134        namespace: ComponentStreamIdentity,
3135        resource: String,
3136        log: Log,
3137        fail_close: Arc<std::sync::atomic::AtomicBool>,
3138    }
3139
3140    impl ItemStream for RecordingWriterStream {
3141        async fn open(
3142            &self,
3143            context: StreamOpenContext<'_>,
3144        ) -> Result<StreamOpenOutcome, StreamOpenError> {
3145            log_event(&self.log, LifecycleEvent::Open(self.resource.clone()));
3146            let codec = vec_read_codec();
3147            if let Some(envelope) = context.inherited_state() {
3148                let restored = envelope
3149                    .decode::<VecPosition>(&codec)
3150                    .map_err(|_| StreamOpenError::new())?;
3151                *self.committed_len.lock().await = restored.0;
3152                Ok(StreamOpenOutcome::Restored)
3153            } else {
3154                *self.committed_len.lock().await = 0;
3155                Ok(StreamOpenOutcome::Initial)
3156            }
3157        }
3158
3159        async fn update(
3160            &self,
3161            _context: StreamUpdateContext<'_>,
3162        ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
3163            log_event(&self.log, LifecycleEvent::Update(self.resource.clone()));
3164            let codec = vec_read_codec();
3165            let committed = *self.committed_len.lock().await;
3166            ComponentStateEnvelope::encode(
3167                self.namespace.clone(),
3168                &VecPosition(committed),
3169                &codec,
3170                StateLimits::default(),
3171            )
3172            .map_err(|_| StreamUpdateError::new())
3173        }
3174
3175        async fn close(
3176            &self,
3177            context: StreamCloseContext<'_>,
3178        ) -> Result<StreamCloseOutcome, StreamCloseError> {
3179            log_event(
3180                &self.log,
3181                LifecycleEvent::Close(self.resource.clone(), context.outcome().into()),
3182            );
3183            if self.fail_close.load(std::sync::atomic::Ordering::SeqCst) {
3184                return Err(StreamCloseError::new());
3185            }
3186            Ok(StreamCloseOutcome::Closed)
3187        }
3188    }
3189
3190    struct RecordingWriterOpener {
3191        sink: Arc<std::sync::Mutex<HashMap<String, Vec<u64>>>>,
3192        log: Log,
3193        fail_open: HashSet<String>,
3194        fail_close: HashMap<String, Arc<std::sync::atomic::AtomicBool>>,
3195    }
3196
3197    impl MultiResourceWriterOpener<u64> for RecordingWriterOpener {
3198        type Writer = VecItemWriter;
3199        type Stream = RecordingWriterStream;
3200
3201        async fn open(
3202            &self,
3203            resource: &ResourceIdentity,
3204            resource_ordinal: u32,
3205            delegate_identity: &ComponentStreamIdentity,
3206        ) -> Result<(Self::Writer, Self::Stream, StreamStateContract), MultiResourceOpenError>
3207        {
3208            if self.fail_open.contains(resource.as_str()) {
3209                return Err(MultiResourceOpenError::new(resource_ordinal));
3210            }
3211            let committed_len = Arc::new(AsyncMutex::new(0));
3212            let writer = VecItemWriter {
3213                resource: resource.as_str().to_owned(),
3214                sink: Arc::clone(&self.sink),
3215                committed_len: Arc::clone(&committed_len),
3216            };
3217            let fail_close = self
3218                .fail_close
3219                .get(resource.as_str())
3220                .cloned()
3221                .unwrap_or_else(|| Arc::new(std::sync::atomic::AtomicBool::new(false)));
3222            let stream = RecordingWriterStream {
3223                committed_len,
3224                namespace: delegate_identity.clone(),
3225                resource: resource.as_str().to_owned(),
3226                log: Arc::clone(&self.log),
3227                fail_close,
3228            };
3229            Ok((writer, stream, StreamStateContract::new(vec_read_codec())))
3230        }
3231    }
3232
3233    #[test]
3234    fn writer_rollover_closes_outgoing_delegate_exactly_once_with_resource_boundary_outcome() {
3235        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3236        let log: Log = Arc::new(std::sync::Mutex::new(Vec::new()));
3237        let opener = RecordingWriterOpener {
3238            sink: Arc::clone(&sink),
3239            log: Arc::clone(&log),
3240            fail_open: HashSet::new(),
3241            fail_close: HashMap::new(),
3242        };
3243        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3244            resource_set(&["a", "b"]),
3245            opener,
3246            identity("writer-lifecycle-order"),
3247            BatchCountRollover::new(1),
3248            RestartabilityDeclaration::Restartable,
3249        )
3250        .unwrap();
3251        let (_source, token) = stop();
3252        futures_executor::block_on(async {
3253            stream
3254                .open(StreamOpenContext::new(None, &token))
3255                .await
3256                .unwrap();
3257            writer.write(&[1], write_context(&token)).await.unwrap();
3258            writer.write(&[2], write_context(&token)).await.unwrap();
3259        });
3260        let events = log.lock().unwrap().clone();
3261        assert_eq!(
3262            events,
3263            vec![
3264                LifecycleEvent::Open("a".to_owned()),
3265                LifecycleEvent::Close("a".to_owned(), RecordedOutcome::ResourceBoundary),
3266                LifecycleEvent::Open("b".to_owned()),
3267            ],
3268            "rollover must close the outgoing delegate (\"a\") exactly once, \
3269             with `ResourceBoundary` -- never `Committed` -- before opening \
3270             the next resource (\"b\")"
3271        );
3272    }
3273
3274    #[test]
3275    fn writer_resource_boundary_close_failure_poisons_the_writer_without_rolling_over_or_retrying()
3276    {
3277        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3278        let log: Log = Arc::new(std::sync::Mutex::new(Vec::new()));
3279        let mut fail_close = HashMap::new();
3280        fail_close.insert(
3281            "a".to_owned(),
3282            Arc::new(std::sync::atomic::AtomicBool::new(true)),
3283        );
3284        let opener = RecordingWriterOpener {
3285            sink: Arc::clone(&sink),
3286            log: Arc::clone(&log),
3287            fail_open: HashSet::new(),
3288            fail_close,
3289        };
3290        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3291            resource_set(&["a", "b"]),
3292            opener,
3293            identity("writer-boundary-close-failure"),
3294            BatchCountRollover::new(1),
3295            RestartabilityDeclaration::Restartable,
3296        )
3297        .unwrap();
3298        let (_source, token) = stop();
3299        futures_executor::block_on(async {
3300            stream
3301                .open(StreamOpenContext::new(None, &token))
3302                .await
3303                .unwrap();
3304            writer.write(&[1], write_context(&token)).await.unwrap();
3305            // The last checkpoint captured *before* the failing write must
3306            // still describe "a".
3307            let envelope = stream
3308                .update(StreamUpdateContext::new(&token))
3309                .await
3310                .unwrap();
3311            let codec = multi_resource_position_codec(RestartabilityDeclaration::Restartable);
3312            let state = envelope.decode::<MultiResourceState>(&codec).unwrap();
3313            assert_eq!(
3314                state.resource_index, 0,
3315                "checkpoint advancement must not silently occur after a failed boundary close"
3316            );
3317            // Rollover to "b" is now due; closing "a" fails.
3318            let result = writer.write(&[2], write_context(&token)).await;
3319            assert!(result.is_err(), "a boundary close failure must surface");
3320            // `ItemStream::close` carries no idempotency/atomicity
3321            // guarantee, so this writer is now poisoned: every further
3322            // `write` returns the same failure without ever calling `close`
3323            // on "a" a second time or rolling over to "b" -- and `update`
3324            // also fails closed rather than exposing a phantom checkpoint
3325            // for a writer that can no longer make progress.
3326            for _ in 0..3 {
3327                let retried = writer.write(&[2], write_context(&token)).await;
3328                assert!(
3329                    retried.is_err(),
3330                    "a poisoned instance must keep failing, never silently recover"
3331                );
3332            }
3333            let post_poison_update = stream.update(StreamUpdateContext::new(&token)).await;
3334            assert!(
3335                post_poison_update.is_err(),
3336                "update must fail closed once poisoned, not expose a stale checkpoint"
3337            );
3338        });
3339        let events = log.lock().unwrap().clone();
3340        assert_eq!(
3341            events,
3342            vec![
3343                LifecycleEvent::Open("a".to_owned()),
3344                LifecycleEvent::Update("a".to_owned()),
3345                LifecycleEvent::Close("a".to_owned(), RecordedOutcome::ResourceBoundary),
3346            ],
3347            "\"a\" must be closed exactly once, ever -- never retried after a \
3348             failure, and \"b\" must never be opened"
3349        );
3350        let sink = sink.lock().unwrap();
3351        assert_eq!(
3352            sink.get("a"),
3353            Some(&vec![1]),
3354            "the item from the failed rollover attempt must never have \
3355             reached \"b\""
3356        );
3357        assert_eq!(
3358            sink.get("b"),
3359            None,
3360            "\"b\" must never receive any items once the writer is poisoned"
3361        );
3362    }
3363
3364    #[test]
3365    fn outer_terminal_close_does_not_double_close_a_writer_delegate_already_retired_at_a_boundary()
3366    {
3367        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3368        let log: Log = Arc::new(std::sync::Mutex::new(Vec::new()));
3369        let mut fail_open = HashSet::new();
3370        fail_open.insert("b".to_owned());
3371        let opener = RecordingWriterOpener {
3372            sink: Arc::clone(&sink),
3373            log: Arc::clone(&log),
3374            fail_open,
3375            fail_close: HashMap::new(),
3376        };
3377        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3378            resource_set(&["a", "b"]),
3379            opener,
3380            identity("writer-no-double-close"),
3381            BatchCountRollover::new(1),
3382            RestartabilityDeclaration::Restartable,
3383        )
3384        .unwrap();
3385        let (_source, token) = stop();
3386        futures_executor::block_on(async {
3387            stream
3388                .open(StreamOpenContext::new(None, &token))
3389                .await
3390                .unwrap();
3391            writer.write(&[1], write_context(&token)).await.unwrap();
3392            // Rollover is now due: "a" closes successfully, but opening "b"
3393            // then fails, so the step attempt gives up and the outer
3394            // terminal close runs with the step's real (failed) outcome.
3395            let result = writer.write(&[2], write_context(&token)).await;
3396            assert!(result.is_err());
3397            stream
3398                .close(StreamCloseContext::new(
3399                    &token,
3400                    StreamRuntimeOutcome::Failed,
3401                ))
3402                .await
3403                .unwrap();
3404        });
3405        let events = log.lock().unwrap().clone();
3406        assert_eq!(
3407            events,
3408            vec![
3409                LifecycleEvent::Open("a".to_owned()),
3410                LifecycleEvent::Close("a".to_owned(), RecordedOutcome::ResourceBoundary),
3411            ],
3412            "\"a\" must be closed exactly once total -- the outer terminal \
3413             close must recognize it is already retired and skip it, not \
3414             close it a second time with the step's real outcome"
3415        );
3416    }
3417
3418    #[test]
3419    fn concurrent_writes_never_race_the_poisoning_transition() {
3420        // Deterministic, non-flaky proof that `active`/poisoned state
3421        // sharing one lock (`WriterState`) actually prevents the race a
3422        // separate poison flag would allow: manually poll two `write`
3423        // futures with a no-op waker, so every interleaving point is
3424        // explicit rather than timing-dependent.
3425        struct BlockingCloseStream {
3426            namespace: ComponentStreamIdentity,
3427            proceed: Arc<tokio::sync::Notify>,
3428        }
3429        impl ItemStream for BlockingCloseStream {
3430            async fn open(
3431                &self,
3432                _context: StreamOpenContext<'_>,
3433            ) -> Result<StreamOpenOutcome, StreamOpenError> {
3434                Ok(StreamOpenOutcome::Initial)
3435            }
3436            async fn update(
3437                &self,
3438                _context: StreamUpdateContext<'_>,
3439            ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
3440                ComponentStateEnvelope::encode(
3441                    self.namespace.clone(),
3442                    &VecPosition(0),
3443                    &vec_read_codec(),
3444                    StateLimits::default(),
3445                )
3446                .map_err(|_| StreamUpdateError::new())
3447            }
3448            async fn close(
3449                &self,
3450                _context: StreamCloseContext<'_>,
3451            ) -> Result<StreamCloseOutcome, StreamCloseError> {
3452                // Blocks here, still holding the outer `active` lock across
3453                // this `.await` (the whole point being tested), until the
3454                // test explicitly releases it.
3455                self.proceed.notified().await;
3456                Err(StreamCloseError::new())
3457            }
3458        }
3459
3460        struct BlockingOpener {
3461            sink: Arc<std::sync::Mutex<HashMap<String, Vec<u64>>>>,
3462            proceed: Arc<tokio::sync::Notify>,
3463        }
3464        impl MultiResourceWriterOpener<u64> for BlockingOpener {
3465            type Writer = VecItemWriter;
3466            type Stream = BlockingCloseStream;
3467            async fn open(
3468                &self,
3469                resource: &ResourceIdentity,
3470                _resource_ordinal: u32,
3471                delegate_identity: &ComponentStreamIdentity,
3472            ) -> Result<(Self::Writer, Self::Stream, StreamStateContract), MultiResourceOpenError>
3473            {
3474                let writer = VecItemWriter {
3475                    resource: resource.as_str().to_owned(),
3476                    sink: Arc::clone(&self.sink),
3477                    committed_len: Arc::new(AsyncMutex::new(0)),
3478                };
3479                let stream = BlockingCloseStream {
3480                    namespace: delegate_identity.clone(),
3481                    proceed: Arc::clone(&self.proceed),
3482                };
3483                Ok((writer, stream, StreamStateContract::new(vec_read_codec())))
3484            }
3485        }
3486
3487        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3488        let proceed = Arc::new(tokio::sync::Notify::new());
3489        let opener = BlockingOpener {
3490            sink: Arc::clone(&sink),
3491            proceed: Arc::clone(&proceed),
3492        };
3493        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3494            resource_set(&["a", "b"]),
3495            opener,
3496            identity("concurrent-writer-race"),
3497            BatchCountRollover::new(1),
3498            RestartabilityDeclaration::Restartable,
3499        )
3500        .unwrap();
3501        let (_source, token) = stop();
3502
3503        futures_executor::block_on(async {
3504            stream
3505                .open(StreamOpenContext::new(None, &token))
3506                .await
3507                .unwrap();
3508            // Fills "a" to its one-batch rollover threshold.
3509            writer.write(&[1], write_context(&token)).await.unwrap();
3510        });
3511
3512        let waker = std::task::Waker::noop();
3513        let mut cx = std::task::Context::from_waker(waker);
3514
3515        let mut a_fut = std::pin::pin!(writer.write(&[2], write_context(&token)));
3516        // First poll: acquires the one shared lock, decides to roll over,
3517        // and calls `close`, which blocks on `proceed` -- still holding
3518        // that lock across the `.await`. Pending.
3519        assert!(matches!(
3520            a_fut.as_mut().poll(&mut cx),
3521            std::task::Poll::Pending
3522        ));
3523
3524        let mut b_fut = std::pin::pin!(writer.write(&[3], write_context(&token)));
3525        // A second, concurrent `write` call: with `active` and the poison
3526        // state unified under the one lock `a_fut` is still holding, this
3527        // must block on that same lock -- it must not be possible for it to
3528        // observe any intermediate state, poisoned or not, until `a_fut`'s
3529        // whole critical section has resolved.
3530        assert!(matches!(
3531            b_fut.as_mut().poll(&mut cx),
3532            std::task::Poll::Pending
3533        ));
3534
3535        // Let the blocked close actually fail now.
3536        proceed.notify_one();
3537        let a_result = loop {
3538            if let std::task::Poll::Ready(result) = a_fut.as_mut().poll(&mut cx) {
3539                break result;
3540            }
3541        };
3542        assert!(
3543            a_result.is_err(),
3544            "the closing write must surface the boundary close failure"
3545        );
3546
3547        // Only now can `b_fut` make progress -- and it must see the
3548        // already-poisoned state `a_fut` committed, never a stale
3549        // "not yet poisoned" read, and never roll over into "b".
3550        let b_result = loop {
3551            if let std::task::Poll::Ready(result) = b_fut.as_mut().poll(&mut cx) {
3552                break result;
3553            }
3554        };
3555        assert!(
3556            b_result.is_err(),
3557            "a concurrent write must never observe a stale pre-poisoning \
3558             state and must never roll over into \"b\" once the writer is \
3559             poisoned"
3560        );
3561
3562        let sink = sink.lock().unwrap();
3563        assert_eq!(
3564            sink.get("b"),
3565            None,
3566            "\"b\" must never receive any items once the writer is \
3567             poisoned, even under a concurrent write"
3568        );
3569    }
3570
3571    #[test]
3572    fn writer_update_fails_closed_when_delegate_reports_the_wrong_namespace() {
3573        struct WrongNamespaceWriterStream {
3574            wrong_namespace: ComponentStreamIdentity,
3575        }
3576        impl ItemStream for WrongNamespaceWriterStream {
3577            async fn open(
3578                &self,
3579                context: StreamOpenContext<'_>,
3580            ) -> Result<StreamOpenOutcome, StreamOpenError> {
3581                Ok(if context.inherited_state().is_some() {
3582                    StreamOpenOutcome::Restored
3583                } else {
3584                    StreamOpenOutcome::Initial
3585                })
3586            }
3587            async fn update(
3588                &self,
3589                _context: StreamUpdateContext<'_>,
3590            ) -> Result<ComponentStateEnvelope, StreamUpdateError> {
3591                ComponentStateEnvelope::encode(
3592                    self.wrong_namespace.clone(),
3593                    &VecPosition(0),
3594                    &vec_read_codec(),
3595                    StateLimits::default(),
3596                )
3597                .map_err(|_| StreamUpdateError::new())
3598            }
3599            async fn close(
3600                &self,
3601                _context: StreamCloseContext<'_>,
3602            ) -> Result<StreamCloseOutcome, StreamCloseError> {
3603                Ok(StreamCloseOutcome::Closed)
3604            }
3605        }
3606
3607        struct WrongNamespaceWriterOpener {
3608            sink: Arc<std::sync::Mutex<HashMap<String, Vec<u64>>>>,
3609            wrong_namespace: ComponentStreamIdentity,
3610        }
3611        impl MultiResourceWriterOpener<u64> for WrongNamespaceWriterOpener {
3612            type Writer = VecItemWriter;
3613            type Stream = WrongNamespaceWriterStream;
3614            async fn open(
3615                &self,
3616                resource: &ResourceIdentity,
3617                _resource_ordinal: u32,
3618                _delegate_identity: &ComponentStreamIdentity,
3619            ) -> Result<(Self::Writer, Self::Stream, StreamStateContract), MultiResourceOpenError>
3620            {
3621                Ok((
3622                    VecItemWriter {
3623                        resource: resource.as_str().to_owned(),
3624                        sink: Arc::clone(&self.sink),
3625                        committed_len: Arc::new(AsyncMutex::new(0)),
3626                    },
3627                    WrongNamespaceWriterStream {
3628                        wrong_namespace: self.wrong_namespace.clone(),
3629                    },
3630                    StreamStateContract::new(vec_read_codec()),
3631                ))
3632            }
3633        }
3634
3635        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3636        let opener = WrongNamespaceWriterOpener {
3637            sink,
3638            wrong_namespace: identity("attacker-namespace-writer"),
3639        };
3640        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3641            resource_set(&["a"]),
3642            opener,
3643            identity("expected-namespace-writer"),
3644            NoRollover,
3645            RestartabilityDeclaration::Restartable,
3646        )
3647        .unwrap();
3648        let (_source, token) = stop();
3649        futures_executor::block_on(async {
3650            stream
3651                .open(StreamOpenContext::new(None, &token))
3652                .await
3653                .unwrap();
3654            writer.write(&[1], write_context(&token)).await.unwrap();
3655            let result = stream.update(StreamUpdateContext::new(&token)).await;
3656            assert!(
3657                result.is_err(),
3658                "a nested delegate reporting the wrong namespace must fail \
3659                 the outer update closed, not be embedded into an outer \
3660                 candidate that could reach durable checkpoint state"
3661            );
3662        });
3663    }
3664
3665    // -- #146 residual composition audit (#150): both `PeekReader` and
3666    // `SynchronizedWriter` delegate 100% of restartability/ordering to
3667    // their inner component (peek.rs:41-49, sync.rs's own docs), so they
3668    // should compose over `MultiResourceReader`/`MultiResourceWriter` with
3669    // no special-casing needed. These two tests are that audit's evidence,
3670    // not a production-code gap fix.
3671
3672    #[test]
3673    fn peek_reader_over_multi_resource_reader_crosses_resource_boundary_without_corrupting_order() {
3674        use crate::item_components::PeekReader;
3675
3676        let mut data = HashMap::new();
3677        data.insert("a".to_owned(), vec![1, 2]);
3678        data.insert("b".to_owned(), vec![3, 4]);
3679        let opener = TestReaderOpener {
3680            data,
3681            fail: HashSet::new(),
3682        };
3683        let (reader, stream, _contract) = multi_resource_reader::<u64, _>(
3684            resource_set(&["a", "b"]),
3685            opener,
3686            identity("peek-composition"),
3687            RestartabilityDeclaration::Restartable,
3688        );
3689        let mut peeking = PeekReader::new(reader);
3690        let (_source, token) = stop();
3691        futures_executor::block_on(async {
3692            stream
3693                .open(StreamOpenContext::new(None, &token))
3694                .await
3695                .unwrap();
3696            let mut collected = Vec::new();
3697            loop {
3698                // Peek before every real read, exactly like the existing
3699                // `postgres_item_components_restart.rs` decorator-restart
3700                // evidence does for a single-resource reader -- proving
3701                // peeking never corrupts the *multi*-resource transition
3702                // in particular (crossing from "a" to "b" mid-peek).
3703                match peeking.peek(ReadContext::new(&token)).await.unwrap() {
3704                    crate::item_components::PeekOutcome::Item(_) => {}
3705                    crate::item_components::PeekOutcome::EndOfInput => break,
3706                    crate::item_components::PeekOutcome::Stopped => panic!("unexpected stop"),
3707                }
3708                match peeking.read(ReadContext::new(&token)).await.unwrap() {
3709                    ReadOutcome::Item(item) => collected.push(item),
3710                    ReadOutcome::EndOfInput => break,
3711                    ReadOutcome::Stopped => panic!("unexpected stop"),
3712                }
3713            }
3714            assert_eq!(collected, vec![1, 2, 3, 4]);
3715        });
3716    }
3717
3718    #[test]
3719    fn synchronized_writer_over_multi_resource_writer_preserves_rollover_order() {
3720        use crate::item_components::SynchronizedWriter;
3721
3722        let sink = Arc::new(std::sync::Mutex::new(HashMap::new()));
3723        let opener = TestWriterOpener {
3724            sink: Arc::clone(&sink),
3725        };
3726        let (writer, stream, _contract) = multi_resource_writer::<u64, _, _>(
3727            resource_set(&["a", "b"]),
3728            opener,
3729            identity("sync-composition"),
3730            BatchCountRollover::new(1),
3731            RestartabilityDeclaration::Restartable,
3732        )
3733        .unwrap();
3734        let synchronized = SynchronizedWriter::new(writer);
3735        let (_source, token) = stop();
3736        futures_executor::block_on(async {
3737            stream
3738                .open(StreamOpenContext::new(None, &token))
3739                .await
3740                .unwrap();
3741            synchronized
3742                .write(&[1], write_context(&token))
3743                .await
3744                .unwrap();
3745            synchronized
3746                .write(&[2], write_context(&token))
3747                .await
3748                .unwrap();
3749        });
3750        let sink = sink.lock().unwrap();
3751        assert_eq!(sink.get("a"), Some(&vec![1]));
3752        assert_eq!(sink.get("b"), Some(&vec![2]));
3753    }
3754}