Skip to main content

ordinary_storage/stores/
assets.rs

1// Copyright (C) 2026 The Ordinary Authors.
2//
3// SPDX-License-Identifier: BSD-3-Clause
4
5use anyhow::bail;
6use bytes::{BufMut, Bytes, BytesMut};
7use hashbrown::HashSet;
8use ordinary_config::{AssetsLimits, CompressionAlgorithm};
9use parking_lot::Mutex;
10use saferlmdb::{
11    self as lmdb, Database, DatabaseOptions, Environment, ReadTransaction, WriteTransaction, put,
12};
13use std::fmt::{Display, Formatter};
14use std::sync::Arc;
15use tracing::{Level, instrument};
16
17pub struct PercentageDisplay(pub f64);
18
19impl Display for PercentageDisplay {
20    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
21        write!(f, "{:.2}%", self.0)
22    }
23}
24
25pub struct AssetStore {
26    limits: AssetsLimits,
27
28    env: Arc<Environment>,
29
30    /// stores static assets to be served by web server
31    assets_db: Arc<Database<'static>>,
32
33    log_sizes: bool,
34
35    store_size: Arc<Mutex<u64>>,
36}
37
38impl AssetStore {
39    pub fn new(
40        limits: AssetsLimits,
41        env: &Arc<Environment>,
42        log_sizes: bool,
43    ) -> anyhow::Result<Self> {
44        let asset_db = Arc::new(Database::open(
45            env.clone(),
46            Some("asset"),
47            &DatabaseOptions::new(lmdb::db::Flags::CREATE),
48        )?);
49
50        let mut store_size = 0;
51
52        let txn = ReadTransaction::new(env.clone())?;
53        let access = txn.access();
54
55        let mut asset_cursor = txn.cursor(asset_db.clone())?;
56
57        if let Ok((key, val)) = asset_cursor.first::<[u8], [u8]>(&access) {
58            store_size += key.len() as u64;
59            store_size += val.len() as u64;
60
61            while let Ok((key, val)) = asset_cursor.next::<[u8], [u8]>(&access) {
62                store_size += key.len() as u64;
63                store_size += val.len() as u64;
64            }
65        }
66
67        Ok(Self {
68            limits,
69            env: env.clone(),
70            assets_db: asset_db,
71            log_sizes,
72            // todo: read in on start up
73            store_size: Arc::new(Mutex::new(store_size)),
74        })
75    }
76
77    #[allow(clippy::cast_precision_loss)]
78    #[instrument(skip_all, err)]
79    pub fn put(
80        &self,
81        path: &str,
82        asset: &[u8],
83        compression: Option<(&CompressionAlgorithm, usize)>,
84    ) -> anyhow::Result<()> {
85        let mut key = BytesMut::with_capacity(path.len() + 1);
86        key.put(path.as_bytes());
87
88        if let Some(compression) = compression {
89            key.put_u8(compression.0.as_u8());
90        }
91
92        let size = (key.len() + asset.len()) as u64;
93
94        if size > self.limits.max_asset_size {
95            bail!("exceeds asset size limit");
96        }
97
98        let mut store_size = self.store_size.lock();
99
100        if *store_size + size > self.limits.max_store_size {
101            bail!("exceeds store size limit");
102        }
103
104        let mut overwrite_size = 0;
105
106        let txn = WriteTransaction::new(self.env.clone())?;
107
108        {
109            let mut access = txn.access();
110            if let Ok(result) = access.get::<[u8], [u8]>(&self.assets_db, key.as_ref()) {
111                overwrite_size = (key.len() + result.len()) as u64;
112            }
113
114            access.put(&self.assets_db, key.as_ref(), asset, &put::Flags::empty())?;
115        }
116
117        txn.commit()?;
118
119        *store_size -= overwrite_size;
120        *store_size += size;
121
122        if self.log_sizes {
123            let (reduction, source_size) = if let Some((_, pre_compression_size)) = compression {
124                (
125                    Some(tracing::field::display(PercentageDisplay(
126                        ((pre_compression_size as f64 - size as f64) / pre_compression_size as f64)
127                            * 100.0,
128                    ))),
129                    pre_compression_size as u64,
130                )
131            } else {
132                (None, size)
133            };
134
135            tracing::info!(
136                %path,
137                store.size = %bytesize::ByteSize(*store_size).display().si_short(),
138                asset.compression = %match compression {
139                    Some(c) => c.0.as_str(),
140                    None => "none"
141                },
142                asset.size.source = %bytesize::ByteSize(source_size).display().si_short(),
143                asset.size.compressed = compression.is_some().then_some(display(bytesize::ByteSize(size).display().si_short())),
144                asset.size.reduction = reduction,
145            );
146        } else {
147            tracing::info!(
148                %path,
149                compression = %match compression {
150                    Some(c) => c.0.as_str(),
151                    None => "none",
152                }
153            );
154        }
155
156        drop(store_size);
157
158        Ok(())
159    }
160
161    #[instrument(skip_all, err(level = Level::WARN))]
162    pub fn get(
163        &self,
164        path: &str,
165        compression: Option<&CompressionAlgorithm>,
166    ) -> anyhow::Result<Bytes> {
167        tracing::info!(
168            path,
169            compression = match compression {
170                Some(c) => c.as_str(),
171                None => "none",
172            }
173        );
174
175        let mut key = BytesMut::with_capacity(path.len() + 1);
176        key.put(path.as_bytes());
177
178        if let Some(compression) = compression {
179            key.put_u8(compression.as_u8());
180        }
181
182        let txn = ReadTransaction::new(self.env.clone())?;
183
184        let access = txn.access();
185        let result = access.get(&self.assets_db, key.as_ref())?;
186
187        Ok(Bytes::copy_from_slice(result))
188    }
189
190    #[instrument(skip_all, err)]
191    pub fn delete(
192        &self,
193        path: &str,
194        compression: Option<&CompressionAlgorithm>,
195    ) -> anyhow::Result<()> {
196        tracing::info!(
197            path,
198            compression = match compression {
199                Some(c) => c.as_str(),
200                None => "none",
201            }
202        );
203
204        let mut key = BytesMut::with_capacity(path.len() + 1);
205        key.put(path.as_bytes());
206
207        if let Some(compression) = compression {
208            key.put_u8(compression.as_u8());
209        }
210
211        let txn = WriteTransaction::new(self.env.clone())?;
212
213        {
214            let mut access = txn.access();
215            let result = access.get::<[u8], [u8]>(&self.assets_db, key.as_ref())?;
216
217            let mut store_size = self.store_size.lock();
218            *store_size -= (result.len() + key.len()) as u64;
219
220            drop(store_size);
221
222            access.del_key(&self.assets_db, key.as_ref())?;
223        }
224
225        txn.commit()?;
226
227        Ok(())
228    }
229
230    #[instrument(skip_all, err)]
231    pub fn delete_all(&self, skip_list: &Vec<String>) -> anyhow::Result<()> {
232        #[cfg(tracing_unstable)]
233        tracing::info!(skip = tracing::field::valuable(&skip_list));
234        #[cfg(not(tracing_unstable))]
235        tracing::info!(skip = tracing::field::debug(&skip_list));
236
237        let mut skip_set = HashSet::new();
238
239        for skip in skip_list {
240            let mut key = BytesMut::with_capacity(skip.len() + 1);
241            key.put(skip.as_bytes());
242
243            skip_set.insert(key.clone());
244
245            for compression in [
246                CompressionAlgorithm::Brotli,
247                CompressionAlgorithm::Deflate,
248                CompressionAlgorithm::Gzip,
249                CompressionAlgorithm::Zstd { level: 0 },
250            ] {
251                key.put_u8(compression.as_u8());
252                skip_set.insert(key.clone());
253
254                key.truncate(skip.len());
255            }
256        }
257
258        let mut delete_keys: Vec<(Bytes, usize)> = Vec::new();
259
260        {
261            let txn = ReadTransaction::new(self.env.clone())?;
262
263            let access = txn.access();
264
265            let mut cursor = txn.cursor(self.assets_db.clone())?;
266            let (key, result) = cursor.first::<[u8], [u8]>(&access)?;
267
268            if !skip_set.contains(key) {
269                delete_keys.push((Bytes::copy_from_slice(key), result.len() + key.len()));
270            }
271
272            while let Ok((key, result)) = cursor.next::<[u8], [u8]>(&access) {
273                if !skip_set.contains(key) {
274                    delete_keys.push((Bytes::copy_from_slice(key), result.len() + key.len()));
275                }
276            }
277        }
278
279        let mut store_size = self.store_size.lock();
280        let initial_size = *store_size;
281
282        let txn = WriteTransaction::new(self.env.clone())?;
283
284        {
285            let mut access = txn.access();
286
287            for (key, size) in delete_keys {
288                access.del_key(&self.assets_db, key.as_ref())?;
289                *store_size -= size as u64;
290            }
291        }
292
293        txn.commit()?;
294
295        if self.log_sizes {
296            tracing::info!(store.size = %bytesize::ByteSize(*store_size), store.size.reduced = %bytesize::ByteSize(*store_size - initial_size));
297        }
298
299        Ok(())
300    }
301}