Skip to main content

shardline_server/local_backend/
mod.rs

1use std::{num::NonZeroUsize, path::PathBuf};
2
3mod files;
4mod objects;
5pub(crate) mod records;
6mod xorbs;
7
8use shardline_index::{
9    FileChunkRecord, LocalIndexStore, LocalRecordStore, ReconstructionStore, RecordTraversal,
10};
11use shardline_storage::{ObjectPrefix, ObjectStore};
12
13use crate::{
14    ServerError, ServerFrontend,
15    config::default_upload_max_in_flight_chunks,
16    local_path::ensure_directory_path_components_are_not_symlinked,
17    model::ServerStatsResponse,
18    object_store::ServerObjectStore,
19    overflow::{checked_add, checked_increment},
20    validation::ensure_directory,
21};
22use records::read_record;
23
24/// Local filesystem backend for file chunk storage and reconstruction metadata.
25#[derive(Debug, Clone)]
26pub struct LocalBackend {
27    pub(super) public_base_url: String,
28    pub(super) chunk_size: NonZeroUsize,
29    pub(super) upload_max_in_flight_chunks: NonZeroUsize,
30    pub(super) server_frontends: Vec<ServerFrontend>,
31    pub(super) index_store: LocalIndexStore,
32    pub(super) record_store: LocalRecordStore,
33    pub(super) object_store: ServerObjectStore,
34}
35
36impl LocalBackend {
37    /// Creates a local backend and initializes its directory structure.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`ServerError`] when the local directories cannot be created.
42    pub async fn new(
43        root: PathBuf,
44        public_base_url: String,
45        chunk_size: NonZeroUsize,
46    ) -> Result<Self, ServerError> {
47        let object_store = ServerObjectStore::local(root.join("chunks"))?;
48        Self::new_with_object_store(root, public_base_url, chunk_size, object_store).await
49    }
50
51    /// Creates a local backend with explicit upload chunk parallelism.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`ServerError`] when the local directories cannot be created.
56    pub async fn new_with_upload_parallelism(
57        root: PathBuf,
58        public_base_url: String,
59        chunk_size: NonZeroUsize,
60        upload_max_in_flight_chunks: NonZeroUsize,
61    ) -> Result<Self, ServerError> {
62        let object_store = ServerObjectStore::local(root.join("chunks"))?;
63        Self::new_with_object_store_and_upload_parallelism(
64            root,
65            public_base_url,
66            chunk_size,
67            upload_max_in_flight_chunks,
68            object_store,
69        )
70        .await
71    }
72
73    pub(crate) async fn new_with_object_store(
74        root: PathBuf,
75        public_base_url: String,
76        chunk_size: NonZeroUsize,
77        object_store: ServerObjectStore,
78    ) -> Result<Self, ServerError> {
79        Self::new_with_object_store_and_upload_parallelism_with_frontends(
80            root,
81            public_base_url,
82            chunk_size,
83            default_upload_max_in_flight_chunks(),
84            object_store,
85            &[ServerFrontend::Xet],
86        )
87        .await
88    }
89
90    pub(crate) async fn new_with_object_store_and_upload_parallelism(
91        root: PathBuf,
92        public_base_url: String,
93        chunk_size: NonZeroUsize,
94        upload_max_in_flight_chunks: NonZeroUsize,
95        object_store: ServerObjectStore,
96    ) -> Result<Self, ServerError> {
97        Self::new_with_object_store_and_upload_parallelism_with_frontends(
98            root,
99            public_base_url,
100            chunk_size,
101            upload_max_in_flight_chunks,
102            object_store,
103            &[ServerFrontend::Xet],
104        )
105        .await
106    }
107
108    pub(crate) async fn new_with_object_store_and_upload_parallelism_with_frontends(
109        root: PathBuf,
110        public_base_url: String,
111        chunk_size: NonZeroUsize,
112        upload_max_in_flight_chunks: NonZeroUsize,
113        object_store: ServerObjectStore,
114        server_frontends: &[ServerFrontend],
115    ) -> Result<Self, ServerError> {
116        ensure_directory_path_components_are_not_symlinked(&root)?;
117        let backend = Self {
118            index_store: LocalIndexStore::open(root.clone()),
119            record_store: LocalRecordStore::open(root),
120            public_base_url,
121            chunk_size,
122            upload_max_in_flight_chunks,
123            server_frontends: server_frontends.to_vec(),
124            object_store,
125        };
126        Ok(backend)
127    }
128
129    /// Returns the public base URL used in generated download links.
130    #[must_use]
131    pub fn public_base_url(&self) -> &str {
132        &self.public_base_url
133    }
134
135    pub(crate) const fn object_backend_name(&self) -> &'static str {
136        self.object_store.backend_name()
137    }
138
139    /// Verifies that local storage paths remain reachable.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`ServerError`] when the local object store or metadata roots cannot be
144    /// traversed.
145    pub async fn ready(&self) -> Result<(), ServerError> {
146        let object_store = self.object_store();
147        if let Some(local_root) = object_store.local_root() {
148            ensure_directory(local_root).await?;
149        } else {
150            let probe_key = shardline_storage::ObjectKey::parse("health/probe")
151                .map_err(|_error| ServerError::InvalidContentHash)?;
152            let _object_store_reachable = object_store.metadata(&probe_key)?;
153        }
154        let _latest = RecordTraversal::list_latest_record_locators(&self.record_store).await?;
155        let _reconstructions =
156            ReconstructionStore::list_reconstruction_file_ids(&self.index_store)?;
157        Ok(())
158    }
159
160    /// Returns local backend storage stats.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`ServerError`] when local metadata cannot be traversed.
165    pub async fn stats(&self) -> Result<ServerStatsResponse, ServerError> {
166        let object_store = self.object_store();
167        let prefix = ObjectPrefix::parse("").map_err(|_error| ServerError::InvalidContentHash)?;
168        let mut chunks = 0_u64;
169        let mut chunk_bytes = 0_u64;
170        crate::object_store::visit_object_prefix(&object_store, &prefix, |metadata| {
171            let is_chunk =
172                crate::chunk_store::chunk_hash_from_chunk_object_key_if_present(metadata.key())?
173                    .is_some();
174            if is_chunk {
175                chunks = checked_increment(chunks)?;
176                chunk_bytes = checked_add(chunk_bytes, metadata.length())?;
177            }
178
179            Ok(())
180        })?;
181        let files = u64::try_from(
182            RecordTraversal::list_latest_record_locators(&self.record_store)
183                .await?
184                .len(),
185        )?;
186
187        Ok(ServerStatsResponse {
188            chunks,
189            chunk_bytes,
190            files,
191        })
192    }
193
194    pub(crate) fn object_store(&self) -> ServerObjectStore {
195        self.object_store.clone()
196    }
197
198    pub(super) async fn read_record(
199        &self,
200        file_id: &str,
201        content_hash: Option<&str>,
202        repository_scope: Option<&shardline_protocol::RepositoryScope>,
203    ) -> Result<shardline_index::FileRecord, ServerError> {
204        read_record(&self.record_store, file_id, content_hash, repository_scope).await
205    }
206}
207
208#[must_use]
209pub fn chunk_hash(bytes: &[u8]) -> shardline_protocol::ShardlineHash {
210    let digest = blake3::hash(bytes);
211    shardline_protocol::ShardlineHash::from_bytes(*digest.as_bytes())
212}
213
214pub(crate) fn content_hash(
215    total_bytes: u64,
216    chunk_size: u64,
217    chunks: &[FileChunkRecord],
218) -> String {
219    let mut hasher = blake3::Hasher::new();
220    hasher.update(&total_bytes.to_le_bytes());
221    hasher.update(&chunk_size.to_le_bytes());
222    for chunk in chunks {
223        hasher.update(chunk.hash.as_bytes());
224        hasher.update(&chunk.offset.to_le_bytes());
225        hasher.update(&chunk.length.to_le_bytes());
226    }
227    hasher.finalize().to_hex().to_string()
228}
229
230#[cfg(test)]
231mod tests;