Skip to main content

riffdb_client_rust/
maintenance.rs

1//! Immutable offline-maintenance submissions for uncertainty-safe retry.
2
3use std::fmt;
4
5use riffdb_proto::v1;
6use riffdb_types::{
7    BackupNameV1, ContractMigrationOperationId, MigrationBundleHash, OfflineMaintenanceOperationId,
8    OfflineMaintenanceReplacementConfirmation, RequestId,
9};
10
11const MAX_CANDIDATE_BUNDLE_BYTES: usize = 15 * 1_024 * 1_024;
12const MAX_MIGRATION_BUNDLE_BYTES: usize = 16 * 1_024 * 1_024;
13
14/// A safe local failure while constructing immutable migration input.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum ContractMigrationSubmissionError {
17    /// The candidate bundle is empty or exceeds the public request limit.
18    InvalidCandidateBundleSize,
19    /// The migration bundle is empty or exceeds the public request limit.
20    InvalidMigrationBundleSize,
21}
22
23impl fmt::Display for ContractMigrationSubmissionError {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        formatter.write_str(match self {
26            Self::InvalidCandidateBundleSize => "candidate bundle size is outside public limits",
27            Self::InvalidMigrationBundleSize => "migration bundle size is outside public limits",
28        })
29    }
30}
31
32impl std::error::Error for ContractMigrationSubmissionError {}
33
34#[derive(Clone)]
35struct ContractMigrationArtifacts {
36    candidate_bundle: Vec<u8>,
37    migration_bundle: Vec<u8>,
38}
39
40impl ContractMigrationArtifacts {
41    fn new(
42        candidate_bundle: Vec<u8>,
43        migration_bundle: Vec<u8>,
44    ) -> Result<Self, ContractMigrationSubmissionError> {
45        if candidate_bundle.is_empty() || candidate_bundle.len() > MAX_CANDIDATE_BUNDLE_BYTES {
46            return Err(ContractMigrationSubmissionError::InvalidCandidateBundleSize);
47        }
48        if migration_bundle.is_empty() || migration_bundle.len() > MAX_MIGRATION_BUNDLE_BYTES {
49            return Err(ContractMigrationSubmissionError::InvalidMigrationBundleSize);
50        }
51        Ok(Self {
52            candidate_bundle,
53            migration_bundle,
54        })
55    }
56}
57
58/// One immutable read-only migration check with caller-stable identity.
59#[derive(Clone)]
60pub struct CheckContractMigration {
61    operation_id: ContractMigrationOperationId,
62    artifacts: ContractMigrationArtifacts,
63}
64
65impl CheckContractMigration {
66    /// Checks public artifact bounds and retains exact canonical bytes for retries.
67    pub fn new(
68        operation_id: ContractMigrationOperationId,
69        candidate_bundle: Vec<u8>,
70        migration_bundle: Vec<u8>,
71    ) -> Result<Self, ContractMigrationSubmissionError> {
72        Ok(Self {
73            operation_id,
74            artifacts: ContractMigrationArtifacts::new(candidate_bundle, migration_bundle)?,
75        })
76    }
77
78    /// Returns the caller-stable migration operation identity.
79    #[must_use]
80    pub const fn operation_id(&self) -> ContractMigrationOperationId {
81        self.operation_id
82    }
83
84    pub(crate) fn request(&self, request_id: RequestId) -> v1::CheckContractMigrationRequest {
85        v1::CheckContractMigrationRequest {
86            request_id: request_id.into_bytes().to_vec(),
87            operation_id: self.operation_id.into_bytes().to_vec(),
88            candidate_bundle: self.artifacts.candidate_bundle.clone(),
89            migration_bundle: self.artifacts.migration_bundle.clone(),
90        }
91    }
92}
93
94impl fmt::Debug for CheckContractMigration {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter.write_str("CheckContractMigration([REDACTED_ARTIFACTS])")
97    }
98}
99
100/// One immutable confirmed migration apply with caller-stable identity.
101#[derive(Clone)]
102pub struct ApplyContractMigration {
103    operation_id: ContractMigrationOperationId,
104    artifacts: ContractMigrationArtifacts,
105    confirmed_migration_hash: MigrationBundleHash,
106}
107
108impl ApplyContractMigration {
109    /// Checks public artifact bounds and retains exact confirmed input for retries.
110    pub fn new(
111        operation_id: ContractMigrationOperationId,
112        candidate_bundle: Vec<u8>,
113        migration_bundle: Vec<u8>,
114        confirmed_migration_hash: MigrationBundleHash,
115    ) -> Result<Self, ContractMigrationSubmissionError> {
116        Ok(Self {
117            operation_id,
118            artifacts: ContractMigrationArtifacts::new(candidate_bundle, migration_bundle)?,
119            confirmed_migration_hash,
120        })
121    }
122
123    /// Returns the caller-stable migration operation identity.
124    #[must_use]
125    pub const fn operation_id(&self) -> ContractMigrationOperationId {
126        self.operation_id
127    }
128
129    /// Returns the exact operator-confirmed migration bundle identity.
130    #[must_use]
131    pub const fn confirmed_migration_hash(&self) -> MigrationBundleHash {
132        self.confirmed_migration_hash
133    }
134
135    pub(crate) fn request(&self, request_id: RequestId) -> v1::ApplyContractMigrationRequest {
136        v1::ApplyContractMigrationRequest {
137            request_id: request_id.into_bytes().to_vec(),
138            operation_id: self.operation_id.into_bytes().to_vec(),
139            candidate_bundle: self.artifacts.candidate_bundle.clone(),
140            migration_bundle: self.artifacts.migration_bundle.clone(),
141            confirmation: v1::ContractMigrationApplyConfirmation::AllowApplyContractMigration
142                as i32,
143            confirmed_migration_hash: self.confirmed_migration_hash.into_bytes().to_vec(),
144        }
145    }
146}
147
148impl fmt::Debug for ApplyContractMigration {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        formatter.write_str("ApplyContractMigration([REDACTED_ARTIFACTS])")
151    }
152}
153
154/// One checked immutable offline-backup creation.
155pub struct CreateOfflineBackup {
156    operation_id: OfflineMaintenanceOperationId,
157    backup_name: BackupNameV1,
158}
159
160impl CreateOfflineBackup {
161    /// Creates a backup submission with caller-stable semantic identity.
162    #[must_use]
163    pub const fn new(
164        operation_id: OfflineMaintenanceOperationId,
165        backup_name: BackupNameV1,
166    ) -> Self {
167        Self {
168            operation_id,
169            backup_name,
170        }
171    }
172
173    /// Returns the caller-stable maintenance operation ID.
174    #[must_use]
175    pub const fn operation_id(&self) -> OfflineMaintenanceOperationId {
176        self.operation_id
177    }
178
179    /// Borrows the checked backup name.
180    #[must_use]
181    pub const fn backup_name(&self) -> &BackupNameV1 {
182        &self.backup_name
183    }
184
185    pub(crate) fn request(&self, request_id: RequestId) -> v1::CreateOfflineBackupRequest {
186        v1::CreateOfflineBackupRequest {
187            request_id: request_id.into_bytes().to_vec(),
188            operation_id: self.operation_id.into_bytes().to_vec(),
189            backup_name: self.backup_name.as_str().to_owned(),
190        }
191    }
192}
193
194impl fmt::Debug for CreateOfflineBackup {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter
197            .debug_struct("CreateOfflineBackup")
198            .field("operation_id", &self.operation_id)
199            .field("backup_name", &self.backup_name)
200            .finish()
201    }
202}
203
204/// One checked immutable offline-backup retirement.
205pub struct RetireOfflineBackup {
206    operation_id: OfflineMaintenanceOperationId,
207    backup_name: BackupNameV1,
208}
209
210impl RetireOfflineBackup {
211    /// Creates a retirement submission with caller-stable semantic identity.
212    #[must_use]
213    pub const fn new(
214        operation_id: OfflineMaintenanceOperationId,
215        backup_name: BackupNameV1,
216    ) -> Self {
217        Self {
218            operation_id,
219            backup_name,
220        }
221    }
222
223    /// Returns the caller-stable maintenance operation ID.
224    #[must_use]
225    pub const fn operation_id(&self) -> OfflineMaintenanceOperationId {
226        self.operation_id
227    }
228
229    /// Borrows the checked backup name.
230    #[must_use]
231    pub const fn backup_name(&self) -> &BackupNameV1 {
232        &self.backup_name
233    }
234
235    pub(crate) fn request(&self, request_id: RequestId) -> v1::RetireOfflineBackupRequest {
236        v1::RetireOfflineBackupRequest {
237            request_id: request_id.into_bytes().to_vec(),
238            operation_id: self.operation_id.into_bytes().to_vec(),
239            backup_name: self.backup_name.as_str().to_owned(),
240        }
241    }
242}
243
244impl fmt::Debug for RetireOfflineBackup {
245    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246        formatter
247            .debug_struct("RetireOfflineBackup")
248            .field("operation_id", &self.operation_id)
249            .field("backup_name", &self.backup_name)
250            .finish()
251    }
252}
253
254/// One selector-free offline storage scrub (ADR-0203). Its only input is the
255/// caller-stable operation ID: there is no target, scope, or validation choice.
256pub struct StartOfflineStorageScrub {
257    operation_id: OfflineMaintenanceOperationId,
258}
259
260impl StartOfflineStorageScrub {
261    /// Creates a scrub submission with a caller-stable operation identity.
262    #[must_use]
263    pub const fn new(operation_id: OfflineMaintenanceOperationId) -> Self {
264        Self { operation_id }
265    }
266
267    /// Returns the caller-stable maintenance operation ID.
268    #[must_use]
269    pub const fn operation_id(&self) -> OfflineMaintenanceOperationId {
270        self.operation_id
271    }
272
273    pub(crate) fn request(&self, request_id: RequestId) -> v1::StartOfflineStorageScrubRequest {
274        v1::StartOfflineStorageScrubRequest {
275            request_id: request_id.into_bytes().to_vec(),
276            operation_id: self.operation_id.into_bytes().to_vec(),
277        }
278    }
279}
280
281impl fmt::Debug for StartOfflineStorageScrub {
282    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
283        formatter
284            .debug_struct("StartOfflineStorageScrub")
285            .field("operation_id", &self.operation_id)
286            .finish()
287    }
288}
289
290/// One checked immutable offline-backup restore.
291pub struct RestoreOfflineBackup {
292    operation_id: OfflineMaintenanceOperationId,
293    backup_name: BackupNameV1,
294    confirmation: OfflineMaintenanceReplacementConfirmation,
295}
296
297impl RestoreOfflineBackup {
298    /// Creates a restore submission with caller-stable semantic identity.
299    #[must_use]
300    pub const fn new(
301        operation_id: OfflineMaintenanceOperationId,
302        backup_name: BackupNameV1,
303        confirmation: OfflineMaintenanceReplacementConfirmation,
304    ) -> Self {
305        Self {
306            operation_id,
307            backup_name,
308            confirmation,
309        }
310    }
311
312    /// Returns the caller-stable maintenance operation ID.
313    #[must_use]
314    pub const fn operation_id(&self) -> OfflineMaintenanceOperationId {
315        self.operation_id
316    }
317
318    /// Borrows the checked backup name.
319    #[must_use]
320    pub const fn backup_name(&self) -> &BackupNameV1 {
321        &self.backup_name
322    }
323
324    /// Returns the exact destructive-replacement confirmation.
325    #[must_use]
326    pub const fn confirmation(&self) -> OfflineMaintenanceReplacementConfirmation {
327        self.confirmation
328    }
329
330    pub(crate) fn request(&self, request_id: RequestId) -> v1::RestoreOfflineBackupRequest {
331        let replacement_confirmation = match self.confirmation {
332            OfflineMaintenanceReplacementConfirmation::NotProvided => {
333                v1::OfflineMaintenanceReplacementConfirmation::Unspecified
334            }
335            OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget => {
336                v1::OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget
337            }
338        };
339        v1::RestoreOfflineBackupRequest {
340            request_id: request_id.into_bytes().to_vec(),
341            operation_id: self.operation_id.into_bytes().to_vec(),
342            backup_name: self.backup_name.as_str().to_owned(),
343            replacement_confirmation: replacement_confirmation as i32,
344        }
345    }
346}
347
348impl fmt::Debug for RestoreOfflineBackup {
349    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
350        formatter
351            .debug_struct("RestoreOfflineBackup")
352            .field("operation_id", &self.operation_id)
353            .field("backup_name", &self.backup_name)
354            .field("confirmation", &self.confirmation)
355            .finish()
356    }
357}
358
359/// One immutable archive restore whose archive selection is frozen by its durable receipt.
360pub struct RestoreArchivedBackup {
361    operation_id: OfflineMaintenanceOperationId,
362    backup_name: BackupNameV1,
363    archive_name: riffdb_types::ArchiveNameV1,
364    stop: riffdb_types::ArchiveRestoreStopV1,
365    confirmation: OfflineMaintenanceReplacementConfirmation,
366}
367
368impl RestoreArchivedBackup {
369    /// Retains checked input and the caller-stable identity for every retry.
370    #[must_use]
371    pub const fn new(
372        operation_id: OfflineMaintenanceOperationId,
373        backup_name: BackupNameV1,
374        archive_name: riffdb_types::ArchiveNameV1,
375        stop: riffdb_types::ArchiveRestoreStopV1,
376        confirmation: OfflineMaintenanceReplacementConfirmation,
377    ) -> Self {
378        Self {
379            operation_id,
380            backup_name,
381            archive_name,
382            stop,
383            confirmation,
384        }
385    }
386
387    /// Returns the stable maintenance operation identity.
388    #[must_use]
389    pub const fn operation_id(&self) -> OfflineMaintenanceOperationId {
390        self.operation_id
391    }
392
393    /// Borrows the checked backup name.
394    #[must_use]
395    pub const fn backup_name(&self) -> &BackupNameV1 {
396        &self.backup_name
397    }
398
399    /// Borrows the configured archive name.
400    #[must_use]
401    pub const fn archive_name(&self) -> &riffdb_types::ArchiveNameV1 {
402        &self.archive_name
403    }
404
405    /// Returns the exact requested application stop.
406    #[must_use]
407    pub const fn stop(&self) -> riffdb_types::ArchiveRestoreStopV1 {
408        self.stop
409    }
410
411    /// Returns the explicit replacement confirmation.
412    #[must_use]
413    pub const fn confirmation(&self) -> OfflineMaintenanceReplacementConfirmation {
414        self.confirmation
415    }
416
417    pub(crate) fn request(&self, request_id: RequestId) -> v1::RestoreArchivedBackupRequest {
418        v1::RestoreArchivedBackupRequest {
419            request_id: request_id.into_bytes().to_vec(),
420            operation_id: self.operation_id.into_bytes().to_vec(),
421            backup_name: self.backup_name.as_str().to_owned(),
422            archive_name: self.archive_name.as_str().to_owned(),
423            stop_at_sequence: match self.stop {
424                riffdb_types::ArchiveRestoreStopV1::LastArchived => None,
425                riffdb_types::ArchiveRestoreStopV1::AtApplicationSequence(sequence) => {
426                    Some(sequence.get())
427                }
428            },
429            replacement_confirmation: match self.confirmation {
430                OfflineMaintenanceReplacementConfirmation::NotProvided => {
431                    v1::OfflineMaintenanceReplacementConfirmation::Unspecified as i32
432                }
433                OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget => {
434                    v1::OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget as i32
435                }
436            },
437        }
438    }
439}
440
441impl fmt::Debug for RestoreArchivedBackup {
442    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
443        formatter.write_str("RestoreArchivedBackup([REDACTED])")
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    fn request_id(timestamp: u64) -> RequestId {
452        RequestId::from_unix_milliseconds_and_random(timestamp, [1; 10]).expect("request ID")
453    }
454
455    fn operation_id() -> OfflineMaintenanceOperationId {
456        OfflineMaintenanceOperationId::from_unix_milliseconds_and_random(1, [2; 10])
457            .expect("operation ID")
458    }
459
460    #[test]
461    // req: REP-007, AFC-007
462    fn archive_restore_retry_keeps_every_semantic_input_and_redacts_debug() {
463        let restore = RestoreArchivedBackup::new(
464            operation_id(),
465            BackupNameV1::new("backup").unwrap(),
466            riffdb_types::ArchiveNameV1::new("private-archive").unwrap(),
467            riffdb_types::ArchiveRestoreStopV1::AtApplicationSequence(
468                riffdb_types::CommitSequence::new(7).unwrap(),
469            ),
470            OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget,
471        );
472        let first = restore.request(request_id(1));
473        let mut second = restore.request(request_id(2));
474        assert_ne!(first.request_id, second.request_id);
475        second.request_id = first.request_id.clone();
476        assert_eq!(first, second);
477        assert_eq!(first.stop_at_sequence, Some(7));
478        assert!(!format!("{restore:?}").contains("private-archive"));
479        let last = RestoreArchivedBackup::new(
480            operation_id(),
481            BackupNameV1::new("backup").unwrap(),
482            riffdb_types::ArchiveNameV1::new("private-archive").unwrap(),
483            riffdb_types::ArchiveRestoreStopV1::LastArchived,
484            OfflineMaintenanceReplacementConfirmation::NotProvided,
485        );
486        assert_eq!(last.request(request_id(3)).stop_at_sequence, None);
487    }
488
489    fn migration_operation_id() -> ContractMigrationOperationId {
490        ContractMigrationOperationId::from_unix_milliseconds_and_random(1, [3; 10])
491            .expect("operation ID")
492    }
493
494    #[test]
495    fn retries_change_only_the_transport_request_id() {
496        let create =
497            CreateOfflineBackup::new(operation_id(), BackupNameV1::new("stable").expect("name"));
498        let first = create.request(request_id(2));
499        let second = create.request(request_id(3));
500        assert_ne!(first.request_id, second.request_id);
501        assert_eq!(first.operation_id, second.operation_id);
502        assert_eq!(first.backup_name, second.backup_name);
503
504        let retire =
505            RetireOfflineBackup::new(operation_id(), BackupNameV1::new("stable").expect("name"));
506        let first = retire.request(request_id(6));
507        let second = retire.request(request_id(7));
508        assert_ne!(first.request_id, second.request_id);
509        assert_eq!(first.operation_id, second.operation_id);
510        assert_eq!(first.backup_name, second.backup_name);
511
512        let restore = RestoreOfflineBackup::new(
513            operation_id(),
514            BackupNameV1::new("stable").expect("name"),
515            OfflineMaintenanceReplacementConfirmation::AllowReplaceNonemptyTarget,
516        );
517        let first = restore.request(request_id(4));
518        let second = restore.request(request_id(5));
519        assert_ne!(first.request_id, second.request_id);
520        assert_eq!(first.operation_id, second.operation_id);
521        assert_eq!(first.backup_name, second.backup_name);
522        assert_eq!(
523            first.replacement_confirmation,
524            second.replacement_confirmation
525        );
526    }
527
528    #[test]
529    fn migration_retries_change_only_transport_identity() {
530        let check =
531            CheckContractMigration::new(migration_operation_id(), vec![1], vec![2]).expect("check");
532        let first = check.request(request_id(2));
533        let second = check.request(request_id(3));
534        assert_ne!(first.request_id, second.request_id);
535        assert_eq!(first.operation_id, second.operation_id);
536        assert_eq!(first.candidate_bundle, second.candidate_bundle);
537        assert_eq!(first.migration_bundle, second.migration_bundle);
538
539        let apply = ApplyContractMigration::new(
540            migration_operation_id(),
541            vec![1],
542            vec![2],
543            MigrationBundleHash::from_bytes([9; 32]),
544        )
545        .expect("apply");
546        let first = apply.request(request_id(4));
547        let second = apply.request(request_id(5));
548        assert_ne!(first.request_id, second.request_id);
549        assert_eq!(first.operation_id, second.operation_id);
550        assert_eq!(first.candidate_bundle, second.candidate_bundle);
551        assert_eq!(first.migration_bundle, second.migration_bundle);
552        assert_eq!(
553            first.confirmed_migration_hash,
554            second.confirmed_migration_hash
555        );
556    }
557}