Skip to main content

shardline_server/
storage_migration.rs

1use std::{io::Cursor, path::PathBuf};
2
3use serde::Serialize;
4use shardline_index::{parse_xet_hash_hex, xet_hash_hex_string};
5use shardline_storage::{
6    ObjectBody, ObjectIntegrity, ObjectKey, ObjectPrefix, ObjectStore, PutOutcome,
7    S3ObjectStoreConfig,
8};
9use shardline_xet_core::merklehash::compute_data_hash;
10
11use crate::{
12    ServerError,
13    chunk_store::chunk_hash_from_chunk_object_key_if_present,
14    error::ObjectStoreError,
15    local_backend::chunk_hash,
16    object_store::{ServerObjectStore, read_full_object},
17    overflow::checked_add,
18    xet_adapter::{
19        shard_hash_from_object_key_if_present, validate_serialized_xorb,
20        xorb_hash_from_object_key_if_present,
21    },
22};
23
24/// Object-storage endpoint used by storage migration.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum StorageMigrationEndpoint {
27    /// Local Shardline state root. Object bytes live below `chunks/`.
28    LocalStateRoot(PathBuf),
29    /// S3-compatible object storage.
30    S3(S3ObjectStoreConfig),
31}
32
33impl StorageMigrationEndpoint {
34    const fn backend_name(&self) -> &'static str {
35        match self {
36            Self::LocalStateRoot(_root) => "local",
37            Self::S3(_config) => "s3",
38        }
39    }
40}
41
42/// Storage-migration runtime options.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct StorageMigrationOptions {
45    source: StorageMigrationEndpoint,
46    destination: StorageMigrationEndpoint,
47    prefix: String,
48    dry_run: bool,
49}
50
51impl StorageMigrationOptions {
52    /// Creates storage-migration options.
53    #[must_use]
54    pub const fn new(
55        source: StorageMigrationEndpoint,
56        destination: StorageMigrationEndpoint,
57    ) -> Self {
58        Self {
59            source,
60            destination,
61            prefix: String::new(),
62            dry_run: false,
63        }
64    }
65
66    /// Limits migration to objects under one object-key prefix.
67    #[must_use]
68    pub fn with_prefix(mut self, prefix: String) -> Self {
69        self.prefix = prefix;
70        self
71    }
72
73    /// Enables inventory-only mode.
74    #[must_use]
75    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
76        self.dry_run = dry_run;
77        self
78    }
79}
80
81/// Storage-migration summary.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
83pub struct StorageMigrationReport {
84    /// Source object-storage adapter name.
85    pub source_backend: String,
86    /// Destination object-storage adapter name.
87    pub destination_backend: String,
88    /// Object-key prefix scanned by the migration.
89    pub prefix: String,
90    /// Whether the run avoided writes.
91    pub dry_run: bool,
92    /// Number of source objects scanned.
93    pub scanned_objects: u64,
94    /// Number of destination writes that inserted a new object.
95    pub inserted_objects: u64,
96    /// Number of destination writes that found an identical object already present.
97    pub already_present_objects: u64,
98    /// Number of bytes copied into newly inserted destination objects.
99    pub copied_bytes: u64,
100    /// Number of source bytes inventoried.
101    pub scanned_bytes: u64,
102}
103
104impl StorageMigrationReport {
105    fn new(options: &StorageMigrationOptions) -> Self {
106        Self {
107            source_backend: options.source.backend_name().to_owned(),
108            destination_backend: options.destination.backend_name().to_owned(),
109            prefix: options.prefix.clone(),
110            dry_run: options.dry_run,
111            scanned_objects: 0,
112            inserted_objects: 0,
113            already_present_objects: 0,
114            copied_bytes: 0,
115            scanned_bytes: 0,
116        }
117    }
118}
119
120/// Copies immutable payload objects between object-storage adapters.
121///
122/// The command migrates object bytes only. Metadata, provider state, lifecycle state, and
123/// reconstruction rows remain in their configured metadata stores.
124///
125/// # Errors
126///
127/// Returns [`ServerError`] when inventory, reads, integrity checks, or destination writes
128/// fail.
129pub fn run_storage_migration(
130    options: &StorageMigrationOptions,
131) -> Result<StorageMigrationReport, ServerError> {
132    let source = endpoint_store(&options.source)?;
133    let destination = endpoint_store(&options.destination)?;
134    let prefix = ObjectPrefix::parse(&options.prefix)?;
135    let mut report = StorageMigrationReport::new(options);
136
137    crate::object_store::visit_object_prefix(&source, &prefix, |metadata| {
138        report.scanned_objects = checked_add(report.scanned_objects, 1)?;
139        report.scanned_bytes = checked_add(report.scanned_bytes, metadata.length())?;
140        if options.dry_run {
141            return Ok(());
142        }
143
144        let bytes = read_full_object(&source, metadata.key(), metadata.length())?;
145        let observed_length = u64::try_from(bytes.len())?;
146        validate_source_object_matches_content_addressed_key(metadata.key(), &bytes)?;
147        let integrity = ObjectIntegrity::new(chunk_hash(&bytes), observed_length);
148        let outcome =
149            destination.put_if_absent(metadata.key(), ObjectBody::from_vec(bytes), &integrity)?;
150        match outcome {
151            PutOutcome::Inserted => {
152                report.inserted_objects = checked_add(report.inserted_objects, 1)?;
153                report.copied_bytes = checked_add(report.copied_bytes, observed_length)?;
154            }
155            PutOutcome::AlreadyExists => {
156                report.already_present_objects = checked_add(report.already_present_objects, 1)?;
157            }
158        }
159
160        Ok(())
161    })?;
162
163    Ok(report)
164}
165
166fn validate_source_object_matches_content_addressed_key(
167    key: &ObjectKey,
168    bytes: &[u8],
169) -> Result<(), ServerError> {
170    if let Some(expected_hash) = chunk_hash_from_chunk_object_key_if_present(key)? {
171        let observed_hash = xet_hash_hex_string(chunk_hash(bytes));
172        ensure_observed_hash_matches_key(key, expected_hash, &observed_hash)?;
173    }
174
175    if let Some(expected_hash) = xorb_hash_from_object_key_if_present(key)? {
176        let expected_hash = parse_xet_hash_hex(expected_hash)?;
177        let mut cursor = Cursor::new(bytes);
178        validate_serialized_xorb(&mut cursor, expected_hash)?;
179    }
180
181    if let Some(expected_hash) = shard_hash_from_object_key_if_present(key)? {
182        let observed_hash = compute_data_hash(bytes).hex();
183        ensure_observed_hash_matches_key(key, expected_hash, &observed_hash)?;
184    }
185
186    Ok(())
187}
188
189fn ensure_observed_hash_matches_key(
190    key: &ObjectKey,
191    expected_hash: &str,
192    observed_hash: &str,
193) -> Result<(), ServerError> {
194    if observed_hash == expected_hash {
195        return Ok(());
196    }
197
198    Err(ServerError::ObjectStore(
199        ObjectStoreError::MigrationSourceHashMismatch {
200            key: key.as_str().to_owned(),
201            expected_hash: expected_hash.to_owned(),
202            observed_hash: observed_hash.to_owned(),
203        },
204    ))
205}
206
207fn endpoint_store(endpoint: &StorageMigrationEndpoint) -> Result<ServerObjectStore, ServerError> {
208    match endpoint {
209        StorageMigrationEndpoint::LocalStateRoot(root) => {
210            Ok(ServerObjectStore::local(root.join("chunks"))?)
211        }
212        StorageMigrationEndpoint::S3(config) => Ok(ServerObjectStore::s3(config.clone())?),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use std::fs;
219
220    use shardline_storage::{ObjectBody, ObjectIntegrity, ObjectKey, ObjectStore};
221
222    use super::{StorageMigrationEndpoint, StorageMigrationOptions, run_storage_migration};
223    use crate::{
224        chunk_store::chunk_object_key_for_computed_hash, local_backend::chunk_hash,
225        object_store::ServerObjectStore,
226    };
227
228    #[test]
229    fn storage_migration_copies_local_objects_idempotently() {
230        let source = tempfile::tempdir();
231        assert!(source.is_ok());
232        let Ok(source) = source else {
233            return;
234        };
235        let destination = tempfile::tempdir();
236        assert!(destination.is_ok());
237        let Ok(destination) = destination else {
238            return;
239        };
240        let source_store = ServerObjectStore::local(source.path().join("chunks"));
241        assert!(source_store.is_ok());
242        let Ok(source_store) = source_store else {
243            return;
244        };
245        let body = b"payload";
246        let key = chunk_object_key_for_computed_hash(chunk_hash(body)).map(|(_hash, key)| key);
247        assert!(key.is_ok());
248        let Ok(key) = key else {
249            return;
250        };
251        let integrity = ObjectIntegrity::new(chunk_hash(body), 7);
252        let put = source_store.put_if_absent(&key, ObjectBody::from_slice(body), &integrity);
253        assert!(put.is_ok());
254
255        let options = StorageMigrationOptions::new(
256            StorageMigrationEndpoint::LocalStateRoot(source.path().to_path_buf()),
257            StorageMigrationEndpoint::LocalStateRoot(destination.path().to_path_buf()),
258        );
259        let first = run_storage_migration(&options);
260        assert!(first.is_ok());
261        if let Ok(first) = first {
262            assert_eq!(first.scanned_objects, 1);
263            assert_eq!(first.inserted_objects, 1);
264            assert_eq!(first.already_present_objects, 0);
265            assert_eq!(first.copied_bytes, 7);
266        }
267
268        let second = run_storage_migration(&options);
269        assert!(second.is_ok());
270        if let Ok(second) = second {
271            assert_eq!(second.scanned_objects, 1);
272            assert_eq!(second.inserted_objects, 0);
273            assert_eq!(second.already_present_objects, 1);
274        }
275    }
276
277    #[test]
278    fn storage_migration_rejects_corrupt_source_chunk_key() {
279        let source = tempfile::tempdir();
280        assert!(source.is_ok());
281        let Ok(source) = source else {
282            return;
283        };
284        let destination = tempfile::tempdir();
285        assert!(destination.is_ok());
286        let Ok(destination) = destination else {
287            return;
288        };
289        let key =
290            ObjectKey::parse("aa/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
291        assert!(key.is_ok());
292        let Ok(key) = key else {
293            return;
294        };
295        let source_path = source.path().join("chunks").join(key.as_str());
296        let parent = source_path.parent();
297        assert!(parent.is_some());
298        let Some(parent) = parent else {
299            return;
300        };
301        let created = fs::create_dir_all(parent);
302        assert!(created.is_ok());
303        let written = fs::write(&source_path, b"corrupt-source-bytes");
304        assert!(written.is_ok());
305
306        let options = StorageMigrationOptions::new(
307            StorageMigrationEndpoint::LocalStateRoot(source.path().to_path_buf()),
308            StorageMigrationEndpoint::LocalStateRoot(destination.path().to_path_buf()),
309        );
310        let migrated = run_storage_migration(&options);
311
312        assert!(
313            migrated.is_err(),
314            "storage migration copied a source chunk whose bytes did not match its content-addressed key"
315        );
316        assert!(
317            !destination
318                .path()
319                .join("chunks")
320                .join(key.as_str())
321                .exists(),
322            "storage migration wrote corrupt source bytes into the destination under the original key"
323        );
324    }
325}