lfsx_server/storage/s3/usage.rs
1use std::collections::HashMap;
2
3use futures_util::StreamExt;
4
5use super::{S3Store, SIZES_AT_ONCE, sizes};
6use crate::namespace::Namespace;
7
8// What a repository holds, which is the size index read back. It lives beside
9// the index rather than among the object operations, because the two only make
10// sense together: one writes the numbers and the other adds them up.
11
12impl S3Store {
13 // What the bucket holds for this repository, counted from its markers and
14 // the sizes recorded beside them. The markers are empty, so their own size
15 // says nothing, and the objects they claim live under names this prefix
16 // never reaches: the index is what closes that gap.
17 //
18 // The figure is still cached the way the local one is. A listing is cheap
19 // and a quota is checked on every negotiation, which is often enough that
20 // cheap is not the same as free.
21 pub async fn usage_of(&self, ns: &Namespace) -> (u64, u64) {
22 // One listing, which returns the markers and the size index together
23 // because they share the repository's prefix. In a bucket this server
24 // wrote, that is the whole measurement: no request is spent per object.
25 let keys = match self.keys.keys(&Self::own_prefix(ns)).await {
26 Ok(keys) => keys,
27 Err(error) => {
28 // A capacity figure that silently reads zero is worse than one
29 // that is missing, because it looks like an answer.
30 tracing::warn!(%error, "the object store could not be listed");
31 return (0, 0);
32 }
33 };
34
35 let mut indexed = HashMap::new();
36 let mut held = Vec::new();
37
38 for key in keys {
39 if let Some((oid, size)) = sizes::read(&key) {
40 indexed.insert(oid, size);
41 } else if let Some(oid) = key.rsplit('/').next()
42 && crate::storage::LocalStore::validate_oid(oid).is_ok()
43 {
44 held.push(oid.to_owned());
45 }
46 }
47
48 // Only what a marker claims is counted. An index entry whose marker has
49 // gone is inert rather than wrong, which is why a sweep that fails to
50 // tidy one costs an empty key and nothing else.
51 let objects = held.len() as u64;
52 let mut bytes = held.iter().filter_map(|oid| indexed.get(oid)).sum();
53
54 let unindexed: Vec<String> = held
55 .into_iter()
56 .filter(|oid| !indexed.contains_key(oid))
57 .collect();
58
59 if !unindexed.is_empty() {
60 bytes += self.measure_and_index(ns, unindexed).await;
61 }
62
63 (objects, bytes)
64 }
65
66 // The old way, for the objects the index does not cover, and it writes what
67 // it learns so it covers them next time.
68 //
69 // That is the whole migration. A bucket written before the index has markers
70 // and no sizes, and the first reading measures it exactly as this server
71 // always did and leaves the answer behind. There is nothing to run and no
72 // flag to set: it converges by being used.
73 async fn measure_and_index(&self, ns: &Namespace, oids: Vec<String>) -> u64 {
74 tracing::info!(
75 count = oids.len(),
76 "measuring objects the size index does not cover yet, and indexing them"
77 );
78
79 futures_util::stream::iter(oids)
80 .map(|oid| {
81 let store = &self;
82 async move {
83 let size = store.size_of(&oid).await.unwrap_or_default();
84
85 if let Err(error) = sizes::write(&store.keys, ns, &oid, size).await {
86 tracing::warn!(%error, oid, "an object could not be added to the size index");
87 }
88
89 size
90 }
91 })
92 .buffer_unordered(SIZES_AT_ONCE)
93 .fold(0, |held, size| async move { held + size })
94 .await
95 }
96}