1use 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
137const MAX_RESOURCE_IDENTITY_BYTES: usize = 1024;
142
143#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub struct ResourceIdentity(String);
155
156impl ResourceIdentity {
157 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 #[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#[derive(Clone, Copy, Eq, Hash, PartialEq)]
207pub struct ResourceSetRevision([u8; 32]);
208
209impl ResourceSetRevision {
210 #[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#[derive(Clone)]
242pub struct ResourceSet {
243 resources: Arc<[ResourceIdentity]>,
244 revision: ResourceSetRevision,
245}
246
247impl ResourceSet {
248 #[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 #[must_use]
260 pub fn resources(&self) -> &[ResourceIdentity] {
261 &self.resources
262 }
263
264 #[must_use]
266 pub const fn revision(&self) -> ResourceSetRevision {
267 self.revision
268 }
269
270 #[must_use]
272 pub fn len(&self) -> usize {
273 self.resources.len()
274 }
275
276 #[must_use]
278 pub fn is_empty(&self) -> bool {
279 self.resources.is_empty()
280 }
281}
282
283#[derive(Clone, Copy, Debug, Eq, PartialEq)]
285#[non_exhaustive]
286pub enum MultiResourceConfigError {
287 EmptyResourceIdentity,
289 ResourceIdentityTooLong {
291 max_bytes: usize,
293 },
294 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
319fn 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#[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 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#[derive(Clone)]
457struct MultiResourceState {
458 resource_set_revision: ResourceSetRevision,
459 resource_index: u32,
460 delegate: Option<DelegateEnvelopeColumns>,
461 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
630pub struct MultiResourceOpenError {
631 resource_ordinal: u32,
632 category: FailureCategory,
633}
634
635impl MultiResourceOpenError {
636 #[must_use]
639 pub const fn new(resource_ordinal: u32) -> Self {
640 Self {
641 resource_ordinal,
642 category: FailureCategory::UserComponent,
643 }
644 }
645
646 #[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 #[must_use]
657 pub const fn resource_ordinal(self) -> u32 {
658 self.resource_ordinal
659 }
660
661 #[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
680pub trait MultiResourceReaderOpener<I>: Send + Sync {
697 type Reader: ItemReader<I>;
699 type Stream: ItemStream;
701
702 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
721struct StreamHandle<S> {
734 index: u32,
735 stream: S,
736 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 pending_reader: std::sync::Mutex<Option<O::Reader>>,
762 poisoned: AsyncMutex<Option<FailureCategory>>,
770 limits: StateLimits,
771 _marker: PhantomData<fn() -> I>,
772}
773
774pub struct MultiResourceReader<I, O: MultiResourceReaderOpener<I>> {
809 shared: Arc<ReaderShared<I, O>>,
810 reader: Option<O::Reader>,
818 started: bool,
823 transitioning: bool,
829 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 return Ok(ReadOutcome::EndOfInput);
866 }
867 }
868 }
869 loop {
870 if !self.transitioning {
871 let Some(reader) = self.reader.as_mut() else {
872 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 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 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 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 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 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
977pub 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 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 *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 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
1159pub 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
1209pub trait MultiResourceWriterOpener<O>: Send + Sync {
1217 type Writer: ItemWriter<O>;
1219 type Stream: ItemStream;
1221
1222 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
1239pub trait RolloverPolicy: Send + Sync {
1246 fn should_roll_over(&self, resource_batches_written: u64) -> bool;
1250}
1251
1252#[derive(Clone, Copy, Debug)]
1255pub struct BatchCountRollover {
1256 max_batches_per_resource: u64,
1257}
1258
1259impl BatchCountRollover {
1260 #[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#[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 retired: bool,
1298}
1299
1300enum WriterState<W, S> {
1311 Active(ActiveWriteResource<W, S>),
1313 Poisoned(FailureCategory),
1320}
1321
1322struct WriterShared<O, Opener: MultiResourceWriterOpener<O>, P> {
1323 opener: Opener,
1324 resources: ResourceSet,
1325 identity: ComponentStreamIdentity,
1326 rollover: P,
1327 active: AsyncMutex<Option<WriterState<Opener::Writer, Opener::Stream>>>,
1330 limits: StateLimits,
1331 _marker: PhantomData<fn() -> O>,
1332}
1333
1334pub 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 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 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 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 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 }
1477
1478 let Some(WriterState::Active(active)) = guard.as_mut() else {
1479 return Err(WriterError::with_category(FailureCategory::Invariant));
1480 };
1481 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
1505pub 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 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 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 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
1665pub type MultiResourceWriterTriple<O, Opener, P> = (
1668 MultiResourceWriter<O, Opener, P>,
1669 MultiResourceWriterStream<O, Opener, P>,
1670 StreamStateContract,
1671);
1672
1673pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1718#[non_exhaustive]
1719pub enum WriterConfigError {
1720 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 #[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 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 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 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 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 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 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 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 #[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 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 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 let result = reader.read(read_context(&token)).await;
2421 assert!(result.is_err(), "a boundary close failure must surface");
2422 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 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 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 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 #[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 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 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 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 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 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 writer.write(&[1], write_context(&token)).await.unwrap();
3031 stream
3032 .update(StreamUpdateContext::new(&token))
3033 .await
3034 .unwrap()
3035 });
3036
3037 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 writer.write(&[2], write_context(&token)).await.unwrap();
3061 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 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 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 let result = writer.write(&[2], write_context(&token)).await;
3319 assert!(result.is_err(), "a boundary close failure must surface");
3320 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 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 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 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 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 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 assert!(matches!(
3531 b_fut.as_mut().poll(&mut cx),
3532 std::task::Poll::Pending
3533 ));
3534
3535 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 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 #[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 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}