1use crate::envelope::EnvelopeCodecError;
6use crate::{
7 ChangeSeq, CheckpointId, ChecksumAlgorithm, CommitId, ContentId, ContentRef, ContentStoreId,
8 ManifestNo, NamespaceId, SubjectId, UploadId,
9};
10use crate::{WriterEpoch, WriterId};
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Deserializer, Serialize};
13use std::num::NonZeroU64;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum ControlObjectKind {
21 Hint,
23 CheckpointRecord,
25 UploadSession,
27 ContentStore,
29}
30
31impl ControlObjectKind {
32 pub const ALL: [Self; 4] = [
34 Self::Hint,
35 Self::CheckpointRecord,
36 Self::UploadSession,
37 Self::ContentStore,
38 ];
39
40 pub const fn format_version(self) -> u32 {
47 match self {
48 Self::Hint => 1,
49 Self::CheckpointRecord => 1,
50 Self::UploadSession => 1,
51 Self::ContentStore => 1,
52 }
53 }
54
55 pub const fn as_str(self) -> &'static str {
57 match self {
58 Self::Hint => "hint",
59 Self::CheckpointRecord => "checkpoint_record",
60 Self::UploadSession => "upload_session",
61 Self::ContentStore => "content_store",
62 }
63 }
64
65 pub fn parse(value: &str) -> Option<Self> {
67 Self::ALL.into_iter().find(|kind| kind.as_str() == value)
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct ContentStoreState {
75 pub content_store_id: ContentStoreId,
77 pub created_at_ms: u64,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct HintState {
85 pub namespace_id: NamespaceId,
87 pub manifest_no: ManifestNo,
89 pub wal_no: crate::WalNo,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct ManifestRef {
102 pub owner_namespace_id: NamespaceId,
104 pub manifest_no: ManifestNo,
106 pub manifest_head_seq: ChangeSeq,
108 pub manifest_payload_checksum: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
115pub enum CheckpointOwner {
116 User {
120 name: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 expires_at_ms: Option<u64>,
125 },
126 Fork {
128 target_namespace_id: NamespaceId,
130 },
131 Snapshot {
133 name: String,
135 expires_at_ms: u64,
137 },
138}
139
140impl CheckpointOwner {
141 pub fn expires_at_ms(&self) -> Option<u64> {
143 match self {
144 Self::User { expires_at_ms, .. } => *expires_at_ms,
145 Self::Fork { .. } => None,
146 Self::Snapshot { expires_at_ms, .. } => Some(*expires_at_ms),
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(deny_unknown_fields)]
154pub struct CheckpointRecordState {
155 pub namespace_id: NamespaceId,
157 pub pin_id: CheckpointId,
159 pub manifest_no: ManifestNo,
161 pub manifest_head_seq: ChangeSeq,
163 pub manifest_payload_checksum: String,
165 pub head_commit_id: CommitId,
167 pub created_at_ms: u64,
169 pub owner: CheckpointOwner,
171}
172
173impl CheckpointRecordState {
174 pub fn manifest(&self) -> ManifestRef {
176 ManifestRef {
177 owner_namespace_id: self.namespace_id.clone(),
178 manifest_no: self.pin_id.manifest_no(),
179 manifest_head_seq: self.manifest_head_seq,
180 manifest_payload_checksum: self.manifest_payload_checksum.clone(),
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct WriterBlock {
191 pub writer_id: WriterId,
193 pub acquired_at_ms: u64,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct AcquiredWriter {
202 pub writer_id: WriterId,
204 pub writer_epoch: WriterEpoch,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
214pub enum NamespaceStatus {
215 Active {},
220 Deleted {
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 reclaim_after_ms: Option<u64>,
226 },
227}
228
229impl NamespaceStatus {
230 pub const fn is_deleted(&self) -> bool {
232 matches!(self, Self::Deleted { .. })
233 }
234 pub const fn reclaim_after_ms(&self) -> Option<u64> {
236 match self {
237 Self::Deleted { reclaim_after_ms } => *reclaim_after_ms,
238 Self::Active {} => None,
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(deny_unknown_fields)]
246pub struct ForkBasis {
247 pub manifest: ManifestRef,
251 pub source_checkpoint_id: CheckpointId,
253}
254
255const GENESIS_COMMIT_ID: &str = "c_00000000000000000000000000000000";
256
257pub fn genesis_commit_id() -> CommitId {
260 CommitId::parse(GENESIS_COMMIT_ID).expect("genesis commit id is valid")
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum ProxiedStaging {
266 Idle,
268 Claimed,
270 Staged(ContentRef),
272}
273
274impl Serialize for ProxiedStaging {
275 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
276 where
277 S: serde::Serializer,
278 {
279 #[derive(Serialize)]
280 #[serde(tag = "kind", rename_all = "snake_case")]
281 enum Shape<'a> {
282 Idle {},
283 Claimed {},
284 Staged { content_ref: &'a ContentRef },
285 }
286
287 match self {
288 Self::Idle => Shape::Idle {}.serialize(serializer),
289 Self::Claimed => Shape::Claimed {}.serialize(serializer),
290 Self::Staged(content_ref) => Shape::Staged { content_ref }.serialize(serializer),
291 }
292 }
293}
294
295impl<'de> Deserialize<'de> for ProxiedStaging {
296 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297 where
298 D: Deserializer<'de>,
299 {
300 StrictProxiedStaging::deserialize(deserializer).map(Into::into)
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
306#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
307pub enum UploadSessionMode {
308 ServiceProxied {
311 staging: ProxiedStaging,
313 },
314 DirectPut {
316 checksum_algorithm: ChecksumAlgorithm,
318 },
319 DirectMultipart {
326 provider_upload_id: String,
331 part_size_bytes: NonZeroU64,
337 checksum_algorithm: ChecksumAlgorithm,
340 },
341}
342
343impl UploadSessionMode {
344 pub fn checksum_algorithm(&self) -> Option<ChecksumAlgorithm> {
346 match self {
347 Self::ServiceProxied { .. } => None,
348 Self::DirectPut { checksum_algorithm }
349 | Self::DirectMultipart {
350 checksum_algorithm, ..
351 } => Some(*checksum_algorithm),
352 }
353 }
354
355 fn content_ref(&self) -> Option<&ContentRef> {
357 match self {
358 Self::ServiceProxied {
359 staging: ProxiedStaging::Staged(content_ref),
360 } => Some(content_ref),
361 Self::ServiceProxied { .. } | Self::DirectPut { .. } | Self::DirectMultipart { .. } => {
362 None
363 }
364 }
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
374pub enum UploadSessionRecordStatus {
375 Open {
377 expires_at_ms: u64,
381 },
382 Completed {
385 completed_at_ms: u64,
388 content_ref: ContentRef,
390 },
391 Aborted {
393 aborted_at_ms: u64,
396 },
397}
398
399impl UploadSessionRecordStatus {
400 fn content_ref(&self) -> Option<&ContentRef> {
402 match self {
403 Self::Open { .. } => None,
404 Self::Completed { content_ref, .. } => Some(content_ref),
405 Self::Aborted { .. } => None,
406 }
407 }
408}
409
410impl std::fmt::Display for UploadSessionRecordStatus {
411 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 let status = match self {
413 Self::Open { .. } => "open",
414 Self::Completed { .. } => "completed",
415 Self::Aborted { .. } => "aborted",
416 };
417 formatter.write_str(status)
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
428pub struct UploadSessionState {
429 pub namespace_id: NamespaceId,
431 pub upload_id: UploadId,
433 pub content_id: ContentId,
440 pub created_at_ms: u64,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub subject_id: Option<SubjectId>,
445 pub mode: UploadSessionMode,
447 pub status: UploadSessionRecordStatus,
450}
451
452impl UploadSessionState {
453 fn validate(&self) -> Result<(), String> {
454 if !matches!(self.status, UploadSessionRecordStatus::Open { .. })
455 && self.mode.content_ref().is_some()
456 {
457 return Err(format!(
458 "upload session `{}` is {} but still holds a staged content reference",
459 self.upload_id, self.status
460 ));
461 }
462 for content_ref in self
463 .mode
464 .content_ref()
465 .into_iter()
466 .chain(self.status.content_ref())
467 {
468 content_ref.validate().map_err(|error| {
469 format!(
470 "upload session `{}` holds an invalid content ref: {error}",
471 self.upload_id
472 )
473 })?;
474 if content_ref.content_id != self.content_id {
475 return Err(format!(
476 "upload session `{}` owns content `{}` but holds a reference to `{}`",
477 self.upload_id, self.content_id, content_ref.content_id
478 ));
479 }
480 }
481 if let (
482 Some(checksum_algorithm),
483 UploadSessionRecordStatus::Completed { content_ref, .. },
484 ) = (self.mode.checksum_algorithm(), &self.status)
485 {
486 if content_ref.checksum.algorithm != checksum_algorithm {
487 return Err(format!(
488 "upload session `{}` requires `{checksum_algorithm}` but its completed \
489 content uses `{}`",
490 self.upload_id, content_ref.checksum.algorithm
491 ));
492 }
493 }
494 Ok(())
495 }
496}
497
498#[derive(Deserialize)]
499#[serde(deny_unknown_fields)]
500struct StrictUploadSessionState {
501 namespace_id: NamespaceId,
502 upload_id: UploadId,
503 content_id: ContentId,
504 created_at_ms: u64,
505 #[serde(default)]
506 subject_id: Option<SubjectId>,
507 mode: StrictUploadSessionMode,
508 status: StrictUploadSessionRecordStatus,
509}
510
511#[derive(Deserialize)]
513#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
514enum StrictUploadSessionMode {
515 ServiceProxied {
516 staging: StrictProxiedStaging,
517 },
518 DirectPut {
519 checksum_algorithm: ChecksumAlgorithm,
520 },
521 DirectMultipart {
522 provider_upload_id: String,
523 part_size_bytes: NonZeroU64,
524 checksum_algorithm: ChecksumAlgorithm,
525 },
526}
527
528impl From<StrictUploadSessionMode> for UploadSessionMode {
529 fn from(mode: StrictUploadSessionMode) -> Self {
530 match mode {
531 StrictUploadSessionMode::ServiceProxied { staging } => Self::ServiceProxied {
532 staging: staging.into(),
533 },
534 StrictUploadSessionMode::DirectPut { checksum_algorithm } => {
535 Self::DirectPut { checksum_algorithm }
536 }
537 StrictUploadSessionMode::DirectMultipart {
538 provider_upload_id,
539 part_size_bytes,
540 checksum_algorithm,
541 } => Self::DirectMultipart {
542 provider_upload_id,
543 part_size_bytes,
544 checksum_algorithm,
545 },
546 }
547 }
548}
549
550#[derive(Deserialize)]
551#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
552enum StrictProxiedStaging {
553 Idle {},
554 Claimed {},
555 Staged { content_ref: ContentRef },
556}
557
558impl From<StrictProxiedStaging> for ProxiedStaging {
559 fn from(staging: StrictProxiedStaging) -> Self {
560 match staging {
561 StrictProxiedStaging::Idle {} => Self::Idle,
562 StrictProxiedStaging::Claimed {} => Self::Claimed,
563 StrictProxiedStaging::Staged { content_ref } => Self::Staged(content_ref),
564 }
565 }
566}
567
568#[derive(Deserialize)]
569#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
570enum StrictUploadSessionRecordStatus {
571 Open {
572 expires_at_ms: u64,
573 },
574 Completed {
575 completed_at_ms: u64,
576 content_ref: ContentRef,
577 },
578 Aborted {
579 aborted_at_ms: u64,
580 },
581}
582
583impl From<StrictUploadSessionRecordStatus> for UploadSessionRecordStatus {
584 fn from(status: StrictUploadSessionRecordStatus) -> Self {
585 match status {
586 StrictUploadSessionRecordStatus::Open { expires_at_ms } => Self::Open { expires_at_ms },
587 StrictUploadSessionRecordStatus::Completed {
588 completed_at_ms,
589 content_ref,
590 } => Self::Completed {
591 completed_at_ms,
592 content_ref,
593 },
594 StrictUploadSessionRecordStatus::Aborted { aborted_at_ms } => {
595 Self::Aborted { aborted_at_ms }
596 }
597 }
598 }
599}
600
601impl<'de> Deserialize<'de> for UploadSessionState {
602 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
606 where
607 D: Deserializer<'de>,
608 {
609 let record = StrictUploadSessionState::deserialize(deserializer)?;
610 let session = Self {
611 namespace_id: record.namespace_id,
612 upload_id: record.upload_id,
613 content_id: record.content_id,
614 created_at_ms: record.created_at_ms,
615 subject_id: record.subject_id,
616 mode: record.mode.into(),
617 status: record.status.into(),
618 };
619 session.validate().map_err(serde::de::Error::custom)?;
620 Ok(session)
621 }
622}
623
624pub type ControlObjectEnvelope<T> = crate::envelope::VerifiedEnvelope<T>;
626
627pub fn encode_control_state<T: Serialize>(
629 kind: ControlObjectKind,
630 state: &T,
631) -> Result<Vec<u8>, EnvelopeCodecError> {
632 crate::envelope::encode_json_envelope(kind.as_str(), kind.format_version(), state)
633 .map(crate::envelope::EncodedEnvelope::into_bytes)
634}
635
636pub fn decode_control_object<T>(
642 bytes: &[u8],
643 expected_kind: ControlObjectKind,
644) -> Result<ControlObjectEnvelope<T>, EnvelopeCodecError>
645where
646 T: DeserializeOwned,
647{
648 let decoded = crate::envelope::decode_json_envelope(
649 bytes,
650 expected_kind.format_version(),
651 |found| match ControlObjectKind::parse(found) {
654 None => Err(EnvelopeCodecError::UnknownKind {
655 found: found.to_owned(),
656 }),
657 Some(kind) if kind != expected_kind => Err(EnvelopeCodecError::KindMismatch {
658 expected: expected_kind.as_str().to_owned(),
659 found: found.to_owned(),
660 }),
661 Some(_) => Ok(()),
662 },
663 )?;
664
665 Ok(decoded)
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::{Checksum, ContentRefKind};
672
673 #[test]
674 fn completed_proxied_session_rejects_conflicting_staged_size() {
675 let content_ref = ContentRef {
676 kind: ContentRefKind::BlobV1,
677 owner_namespace_id: crate::NamespaceId::parse("demo").expect("namespace id"),
678 content_id: ContentId::parse("con_0123456789abcdef0123456789abcdef")
679 .expect("content id"),
680 size_bytes: 5,
681 checksum: Checksum::sha256(b"hello"),
682 };
683 let mut staged = content_ref.clone();
684 staged.size_bytes += 1;
685 let session = UploadSessionState {
686 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
687 upload_id: UploadId::parse("upl_0123456789abcdef0123456789abcdef").expect("upload id"),
688 content_id: content_ref.content_id.clone(),
689 created_at_ms: 1_000,
690 subject_id: None,
691 mode: UploadSessionMode::ServiceProxied {
692 staging: ProxiedStaging::Staged(staged),
693 },
694 status: UploadSessionRecordStatus::Completed {
695 completed_at_ms: 2_000,
696 content_ref,
697 },
698 };
699
700 let error = session.validate().expect_err("conflicting staged size");
701 assert_eq!(
702 error,
703 format!(
704 "upload session `{}` is completed but still holds a staged content reference",
705 session.upload_id
706 )
707 );
708 }
709
710 #[test]
711 fn terminal_upload_modes_reject_only_retained_staged_references() {
712 let content_ref = ContentRef {
713 kind: ContentRefKind::BlobV1,
714 owner_namespace_id: crate::NamespaceId::parse("demo").expect("namespace id"),
715 content_id: ContentId::parse("con_0123456789abcdef0123456789abcdef")
716 .expect("content id"),
717 size_bytes: 5,
718 checksum: Checksum::sha256(b"hello"),
719 };
720 let modes = [
721 UploadSessionMode::ServiceProxied {
722 staging: ProxiedStaging::Idle,
723 },
724 UploadSessionMode::ServiceProxied {
725 staging: ProxiedStaging::Claimed,
726 },
727 UploadSessionMode::ServiceProxied {
728 staging: ProxiedStaging::Staged(content_ref.clone()),
729 },
730 UploadSessionMode::DirectPut {
731 checksum_algorithm: ChecksumAlgorithm::Sha256,
732 },
733 UploadSessionMode::DirectMultipart {
734 provider_upload_id: "provider-upload".to_owned(),
735 part_size_bytes: NonZeroU64::new(8 * 1024 * 1024).expect("part size"),
736 checksum_algorithm: ChecksumAlgorithm::Sha256,
737 },
738 ];
739 for mode in modes {
740 for status in [
741 UploadSessionRecordStatus::Completed {
742 completed_at_ms: 2_000,
743 content_ref: content_ref.clone(),
744 },
745 UploadSessionRecordStatus::Aborted {
746 aborted_at_ms: 2_000,
747 },
748 ] {
749 let session = UploadSessionState {
750 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
751 upload_id: UploadId::parse("upl_0123456789abcdef0123456789abcdef")
752 .expect("upload id"),
753 content_id: content_ref.content_id.clone(),
754 created_at_ms: 1_000,
755 subject_id: None,
756 mode: mode.clone(),
757 status,
758 };
759 let encoded = serde_json::to_value(&session).expect("encode session");
760 let decoded = serde_json::from_value::<UploadSessionState>(encoded);
761 if mode.content_ref().is_some() {
762 let error = decoded
763 .expect_err("terminal staging is corrupt")
764 .to_string();
765 assert!(error.contains(session.upload_id.as_str()));
766 assert!(error.contains("still holds a staged content reference"));
767 } else {
768 assert_eq!(decoded.expect("valid terminal session"), session);
769 }
770 }
771 }
772 }
773
774 #[test]
775 fn control_object_kind_strings_round_trip_and_match_serde() {
776 for kind in ControlObjectKind::ALL {
777 assert_eq!(ControlObjectKind::parse(kind.as_str()), Some(kind));
778 let serialized = serde_json::to_value(kind).expect("serialize kind");
779 assert_eq!(serialized, serde_json::Value::from(kind.as_str()));
780 }
781 assert_eq!(ControlObjectKind::parse("not_a_kind"), None);
782 }
783}