Skip to main content

max / audiofiles

6.2 KB · 147 lines History Blame Raw
1 //! What storage cap to offer, and which one to propose.
2 //!
3 //! Policy rather than drawing, which is why it is here and not in either screen:
4 //! audiofiles has two of them - the shipped egui panel and the described one
5 //! under [`crate::quasi`] - and a cap the two disagreed about would be two
6 //! products. They disagreed before this module existed; both drew a logarithmic
7 //! slider from the floor to 10 TiB, and one of them had no bounds at all.
8 //!
9 //! # The cap answers a question the app can already answer
10 //!
11 //! `Database::synced_storage_stats` returns the exact size of what blob sync
12 //! would upload, locally and instantly. So the screen states the need, proposes
13 //! a cap with headroom, and shows what each option costs; it does not solicit a
14 //! number. See wiki `af-storage-cap`.
15 //!
16 //! # These are not tiers
17 //!
18 //! Pricing is continuous - `max($2.00, ceil(GiB) x 0.8c)` - so a named cap
19 //! commits us to no price and invents no product. It is a bookmark on a
20 //! continuum, and an exact figure is always accepted alongside. That is why
21 //! choosing this list is a UI decision and not a pricing one.
22
23 /// One gibibyte.
24 pub const GIB: i64 = 1024 * 1024 * 1024;
25
26 /// The caps offered by name, in GiB.
27 ///
28 /// The floor is the server's `MIN_CAP_BYTES` and the ceiling its
29 /// `MAX_CAP_BYTES`; the steps between roughly double, which is how a person
30 /// reads a size. Always filtered against the pricing the server actually sent
31 /// rather than trusted, so a floor that moves past a named size drops it
32 /// instead of offering a cap the route will refuse.
33 pub const OFFERED_GIB: &[i64] = &[250, 500, 1024, 2048, 5120, 10240];
34
35 /// How much room over today's need a proposed cap leaves: half again.
36 ///
37 /// Not double. A sample library grows, so a cap with no headroom is one the user
38 /// re-decides almost immediately; but headroom is bought by the gibibyte, and
39 /// proposing double is proposing to spend double. Half again is the smallest
40 /// margin that does not read as "you are already full".
41 ///
42 /// Proposing slightly low is cheap now: raising a cap takes effect immediately
43 /// rather than at the next renewal (MNW `752d1cac`).
44 const HEADROOM_NUMERATOR: i64 = 3;
45 const HEADROOM_DENOMINATOR: i64 = 2;
46
47 /// The threshold at which a cap is reported as filling, as a percentage.
48 ///
49 /// Ninety, and shared so the meter's colour and the sentence beside it can never
50 /// disagree about whether this is a problem.
51 pub const NEARLY_FULL_PERCENT: i64 = 90;
52
53 /// The named caps this pricing permits, smallest first.
54 pub fn offered(min_bytes: i64, max_bytes: i64) -> impl Iterator<Item = i64> {
55 OFFERED_GIB
56 .iter()
57 .map(|gib| gib * GIB)
58 .filter(move |bytes| *bytes >= min_bytes && *bytes <= max_bytes)
59 }
60
61 /// The cap to propose: the smallest named size covering the need with headroom.
62 ///
63 /// `need` of `None` is "cannot look" and `Some(0)` is "nothing is set to sync";
64 /// both propose the floor, which is the cheapest thing on offer rather than a
65 /// guess dressed as one. A need larger than every named size gets the largest,
66 /// so the proposal is never nothing.
67 pub fn proposed(need: Option<i64>, min_bytes: i64, max_bytes: i64) -> i64 {
68 let floor = offered(min_bytes, max_bytes).next().unwrap_or(min_bytes);
69 let Some(need) = need.filter(|bytes| *bytes > 0) else {
70 return floor;
71 };
72 let want = need
73 .saturating_mul(HEADROOM_NUMERATOR)
74 .saturating_div(HEADROOM_DENOMINATOR);
75 offered(min_bytes, max_bytes)
76 .find(|bytes| *bytes >= want)
77 .or_else(|| offered(min_bytes, max_bytes).last())
78 .unwrap_or(floor)
79 }
80
81 /// Whether a cap is close enough to full to say so.
82 pub fn nearly_full(used_bytes: i64, limit_bytes: i64) -> bool {
83 limit_bytes > 0
84 && used_bytes.saturating_mul(100) >= limit_bytes.saturating_mul(NEARLY_FULL_PERCENT)
85 }
86
87 #[cfg(test)]
88 mod tests {
89 use super::*;
90
91 const MIN: i64 = 250 * GIB;
92 const MAX: i64 = 10240 * GIB;
93
94 #[test]
95 fn a_need_gets_the_smallest_named_cap_that_covers_it_with_headroom() {
96 // 400 + half again = 600, and 512 is not on the list, so 1024.
97 assert_eq!(proposed(Some(400 * GIB), MIN, MAX), 1024 * GIB);
98 // 300 + half again = 450, which 500 covers.
99 assert_eq!(proposed(Some(300 * GIB), MIN, MAX), 500 * GIB);
100 }
101
102 #[test]
103 fn nothing_to_size_against_proposes_the_floor() {
104 // The two are different facts - cannot look, and nothing set to sync -
105 // and the screens say different things about them. The proposal is the
106 // same either way, because in both cases there is nothing to size to.
107 assert_eq!(proposed(None, MIN, MAX), MIN);
108 assert_eq!(proposed(Some(0), MIN, MAX), MIN);
109 }
110
111 #[test]
112 fn a_library_past_every_named_cap_gets_the_largest() {
113 assert_eq!(proposed(Some(9000 * GIB), MIN, MAX), 10240 * GIB);
114 // And does not overflow on a nonsense figure.
115 assert_eq!(proposed(Some(i64::MAX), MIN, MAX), 10240 * GIB);
116 }
117
118 #[test]
119 fn the_named_caps_are_filtered_against_what_the_server_sells() {
120 // A floor above a named size drops it rather than offering a cap the
121 // route refuses. This is the case that made the constant unsafe to
122 // trust: MIN_CAP_BYTES moved from 10 GiB to 250 on 2026-08-21.
123 let caps: Vec<i64> = offered(600 * GIB, 3000 * GIB).collect();
124 assert_eq!(caps, vec![1024 * GIB, 2048 * GIB]);
125 }
126
127 #[test]
128 fn a_pricing_range_containing_no_named_cap_still_proposes_something() {
129 // Degenerate, and it must not panic or propose zero: the floor is always
130 // an answer.
131 let odd = 77 * GIB;
132 assert_eq!(offered(odd, odd + 1).count(), 0);
133 assert_eq!(proposed(Some(10 * GIB), odd, odd + 1), odd);
134 }
135
136 #[test]
137 fn nearly_full_is_ninety_percent_and_does_not_overflow() {
138 assert!(!nearly_full(89 * GIB, 100 * GIB));
139 assert!(nearly_full(90 * GIB, 100 * GIB));
140 assert!(nearly_full(200 * GIB, 100 * GIB), "past full is still full");
141 assert!(!nearly_full(0, 0), "an unknown cap is not a full one");
142 // The old form multiplied both sides by 10 and 9; at these sizes the
143 // saturating form matters.
144 assert!(nearly_full(i64::MAX, 100 * GIB));
145 }
146 }
147