Skip to content
566 linesCodeBlameRaw
1//! The cache of `actions/cache`: which entries each repository has, kept
2//! here, while the API keeps their bytes in R2 (the ACTIONS_CACHE bucket).
3//!
4//! - Entries are scoped by ref, as on GitHub (`scopes`): an entry belongs
5//! to the ref whose run saved it, and a run restores from its own ref,
6//! then its pull request's base branch, then the default branch. A pull
7//! request from outside the repository saves under `untrusted:<ref>`,
8//! which no other ref reads, so it can never plant an entry the default
9//! branch restores. g1t's own `actions/cache` and the toolkit's protocols
10//! follow the same rule (`Actions::cache_scope`).
11//! - An entry's version is the hash of its paths and compression, which
12//! the toolkit's client and g1t's runner both send: the same key saved
13//! for other paths is another entry.
14//! - A key is written once in its scope and version. In each scope in
15//! turn, a restore finds its key exactly, else the newest entry whose key
16//! starts with one of its restore keys.
17//! - An entry is at most `CACHE_MAX_ENTRY_BYTES`. A repository's entries
18//! hold at most `CACHE_REPO_QUOTA_BYTES` together: saving past it evicts
19//! the entries restored longest ago.
20//! - An entry not restored for `CACHE_UNUSED_DAYS`, or saved more than
21//! `CACHE_MAX_AGE_DAYS` ago, is deleted by the hourly sweep, which also
22//! writes down what each workspace holds, its artifacts included (they
23//! are in the same bucket), and reports this month's storage to billing
24//! (`note_pending`, source `cache`) at R2's price.
25//!
26//! The API calls these with the job's token, or its runtime token for the
27//! toolkit's protocols (runtime.rs), which is checked here. An entry the
28//! toolkit saves has the toolkit's version, and is found only by the same
29//! version; g1t's own `actions/cache` saves and finds entries without one.
30
31use g1t_contracts::FailureCode;
32use g1t_contracts::Outcome;
33use g1t_contracts::actions::{
34 CACHE_MAX_AGE_DAYS, CACHE_MAX_ENTRY_BYTES, CACHE_MICROS_PER_GB_MONTH, CACHE_REPO_QUOTA_BYTES, CACHE_UNUSED_DAYS,
35 CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, CacheUploadArgs,
36};
37use g1t_contracts::billing::NotePendingArgs;
38use g1t_contracts::new_id;
39use g1t_contracts::time::rfc3339;
40use g1t_kit::now_ms;
41use serde::Deserialize;
42use serde_json::Value;
43use worker::Result;
44
45use crate::{Actions, check, fail};
46
47const DAY_MS: u64 = 24 * 60 * 60 * 1000;
48/// An upload not finished after this long is given up.
49const PENDING_MS: u64 = 6 * 60 * 60 * 1000;
50/// A GB, as storage is billed.
51const GB: f64 = 1_000_000_000.0;
52
53#[derive(Debug, Deserialize)]
54struct EntryRow {
55 id: String,
56 key: String,
57 object: String,
58 size: f64,
59 created_at: String,
60}
61
62/// The longest key: GitHub's limit.
63const MAX_KEY_CHARS: usize = 512;
64
65/// Whether a key can be kept: 1 to 512 characters, no commas (GitHub's rule).
66pub(crate) fn valid_key(key: &str) -> bool {
67 !key.is_empty() && key.chars().count() <= MAX_KEY_CHARS && !key.contains(',')
68}
69
70/// Which of `entries` (id, size, newest use first) to evict so that the
71/// repository holds at most `quota` with `keep` (just saved) among them.
72/// The entry just saved is never evicted.
73pub(crate) fn to_evict(entries: &[(String, u64)], keep: &str, quota: u64) -> Vec<String> {
74 let mut total: u64 = entries.iter().map(|(_, size)| size).sum();
75 let mut out = Vec::new();
76 for (id, size) in entries.iter().rev() {
77 if total <= quota {
78 break;
79 }
80 if id == keep {
81 continue;
82 }
83 total = total.saturating_sub(*size);
84 out.push(id.clone());
85 }
86 out
87}
88
89/// Whether a repository holding `total` bytes must evict to stay within
90/// `quota`: what `to_evict` would take something from.
91pub(crate) fn over_quota(total: u64, quota: u64) -> bool {
92 total > quota
93}
94
95/// A month's GB-months from the bytes held each day so far: each day's
96/// bytes over 30 days.
97pub(crate) fn gb_months(days: &[u64]) -> f64 {
98 days.iter().map(|bytes| *bytes as f64).sum::<f64>() / GB / 30.0
99}
100
101/// What `gb_months` of cache cost g1t, in millionths of a dollar.
102pub(crate) fn storage_cost(gb_months: f64) -> i64 {
103 (gb_months * CACHE_MICROS_PER_GB_MONTH as f64).ceil() as i64
104}
105
106/// Where a job's cache entries are found and saved: its repository, and in
107/// it the refs it restores from, in order, and the one it saves to.
108pub(crate) struct CacheScope {
109 pub repo_id: String,
110 pub restore: Vec<String>,
111 pub save: String,
112}
113
114/// The scopes a run's jobs restore from, in order, and the one they save
115/// to: its own ref, then its pull request's base branch, then the default
116/// branch. A run that is not trusted (a pull request from outside) saves to
117/// a scope of its own that no other ref reads.
118pub(crate) fn scopes(git_ref: &str, base_ref: Option<&str>, default_branch: &str, trusted: bool) -> (Vec<String>, String) {
119 let own = if trusted { git_ref.to_owned() } else { format!("untrusted:{git_ref}") };
120 let mut restore = vec![own.clone()];
121 let base = base_ref.filter(|base| !base.is_empty()).map(|base| format!("refs/heads/{}", base.trim_start_matches("refs/heads/")));
122 for scope in base.into_iter().chain(std::iter::once(format!("refs/heads/{default_branch}"))) {
123 if !restore.contains(&scope) {
124 restore.push(scope);
125 }
126 }
127 (restore, own)
128}
129
130impl Actions {
131 /// Where a job's entries are found and saved (`scopes`), for g1t's own
132 /// `actions/cache` and the toolkit's protocols alike.
133 pub(crate) async fn cache_scope(&self, job: &crate::plan::JobRow) -> Result<CacheScope> {
134 let (restore, save) = match self.run_row(&job.run_id).await? {
135 Some(run) => {
136 let info = run.info();
137 scopes(&run.git_ref, info.base_ref.as_deref(), &info.default_branch, run.trusted != 0)
138 }
139 None => (Vec::new(), format!("untrusted:{}", job.run_id)),
140 };
141 Ok(CacheScope { repo_id: job.repo_id.clone(), restore, save })
142 }
143
144 /// A job by its own token or its runtime token (runtime.rs).
145 async fn cache_job(&self, job: &str, token: &str) -> Result<Outcome<crate::plan::JobRow>> {
146 self.job_for_credential(job, token).await
147 }
148
149 /// `cache_lookup`.
150 pub async fn cache_lookup(&self, a: CacheLookupArgs) -> Result<Outcome<Option<CacheHit>>> {
151 let job = check!(self.cache_job(&a.job, &a.token).await?);
152 let scope = self.cache_scope(&job).await?;
153 let now = now_ms();
154 let fresh = rfc3339(now.saturating_sub(CACHE_MAX_AGE_DAYS * DAY_MS));
155 // Found only by the same version: runners from before it was sent
156 // send none, and find only entries saved without one.
157 let version = a.version.clone().unwrap_or_default();
158 let mut found = None;
159 'scopes: for ref_scope in &scope.restore {
160 found = self
161 .db
162 .prepare(
163 "SELECT id, key, object, size, created_at FROM cache_entries
164 WHERE repo_id = ? AND scope = ? AND key = ? AND version = ? AND status = 'ready' AND created_at > ?",
165 )
166 .bind(&[
167 scope.repo_id.as_str().into(),
168 ref_scope.as_str().into(),
169 a.key.as_str().into(),
170 version.as_str().into(),
171 fresh.as_str().into(),
172 ])?
173 .first::<EntryRow>(None)
174 .await?;
175 if found.is_some() {
176 break;
177 }
178 for prefix in a.restore.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
179 found = self
180 .db
181 .prepare(
182 "SELECT id, key, object, size, created_at FROM cache_entries
183 WHERE repo_id = ?1 AND scope = ?5 AND version = ?4 AND status = 'ready' AND created_at > ?3
184 AND substr(key, 1, length(?2)) = ?2
185 ORDER BY created_at DESC LIMIT 1",
186 )
187 .bind(&[
188 scope.repo_id.as_str().into(),
189 prefix.into(),
190 fresh.as_str().into(),
191 version.as_str().into(),
192 ref_scope.as_str().into(),
193 ])?
194 .first::<EntryRow>(None)
195 .await?;
196 if found.is_some() {
197 break 'scopes;
198 }
199 }
200 }
201 let Some(entry) = found else { return Ok(Outcome::Ok(None)) };
202 self.db
203 .prepare("UPDATE cache_entries SET last_used_at = ? WHERE id = ?")
204 .bind(&[rfc3339(now).into(), entry.id.as_str().into()])?
205 .run()
206 .await?;
207 let blob = a.version.as_ref().and_then(|_| self.download_token("cache", &entry.id, &entry.object, crate::runtime::DOWNLOAD_SECONDS));
208 Ok(Outcome::Ok(Some(CacheHit { key: entry.key, object: entry.object, size: entry.size as u64, created_at: entry.created_at, blob })))
209 }
210
211 /// `cache_upload`.
212 pub async fn cache_upload(&self, a: CacheUploadArgs) -> Result<Outcome<CacheReservation>> {
213 let job = check!(self.cache_job(&a.job, &a.token).await?);
214 let scope = self.cache_scope(&job).await?;
215 #[derive(Deserialize)]
216 struct Pending {
217 id: String,
218 object: String,
219 number: f64,
220 upload: Option<String>,
221 }
222 let columns = "SELECT id, object, rowid AS number, upload FROM cache_entries";
223 let pending = match (a.number, a.key.as_deref()) {
224 (Some(number), _) => {
225 self.db
226 .prepare(format!("{columns} WHERE rowid = ? AND repo_id = ? AND status = 'pending'"))
227 .bind(&[(number as f64).into(), scope.repo_id.as_str().into()])?
228 .first::<Pending>(None)
229 .await?
230 }
231 (None, Some(key)) => {
232 self.db
233 .prepare(format!("{columns} WHERE repo_id = ? AND scope = ? AND key = ? AND version = ? AND status = 'pending'"))
234 .bind(&[scope.repo_id.as_str().into(), scope.save.as_str().into(), key.into(), a.version.clone().unwrap_or_default().into()])?
235 .first::<Pending>(None)
236 .await?
237 }
238 (None, None) => None,
239 };
240 let Some(pending) = pending else {
241 return Ok(fail(FailureCode::NotFound, "No upload of that entry is in progress."));
242 };
243 let blob = match &pending.upload {
244 Some(upload) => match self.upload_token("cache", &pending.id, &pending.object, upload) {
245 Some(blob) => Some(blob),
246 None => return Ok(fail(FailureCode::Invalid, "The toolkit's storage is not set up here: the actions service has no ACTIONS_KEY.")),
247 },
248 None => None,
249 };
250 Ok(Outcome::Ok(CacheReservation { id: pending.id, object: pending.object, number: pending.number as u64, upload: pending.upload, blob }))
251 }
252
253 /// `cache_reserve`.
254 pub async fn cache_reserve(&self, a: CacheReserveArgs) -> Result<Outcome<CacheReservation>> {
255 let job = check!(self.cache_job(&a.job, &a.token).await?);
256 let scope = self.cache_scope(&job).await?;
257 if !valid_key(&a.key) {
258 return Ok(fail(FailureCode::Invalid, format!("A cache key is 1 to {MAX_KEY_CHARS} characters, without commas.")));
259 }
260 if a.size > CACHE_MAX_ENTRY_BYTES {
261 return Ok(fail(
262 FailureCode::Invalid,
263 format!("It is {} MB; a cache entry is at most {} MB.", a.size / 1_048_576, CACHE_MAX_ENTRY_BYTES / 1_048_576),
264 ));
265 }
266 let now = now_ms();
267 let at = rfc3339(now);
268 let version = a.version.clone().unwrap_or_default();
269 // An upload left unfinished long ago no longer holds its key.
270 self.db
271 .prepare(
272 "UPDATE cache_entries SET status = 'expired'
273 WHERE repo_id = ? AND scope = ? AND key = ? AND version = ? AND status = 'pending' AND created_at < ?",
274 )
275 .bind(&[
276 scope.repo_id.as_str().into(),
277 scope.save.as_str().into(),
278 a.key.as_str().into(),
279 version.as_str().into(),
280 rfc3339(now.saturating_sub(PENDING_MS)).into(),
281 ])?
282 .run()
283 .await?;
284 let id = new_id("cache", now);
285 let object = format!("c/{}/{id}", scope.repo_id);
286 #[derive(Deserialize)]
287 struct Inserted {
288 number: f64,
289 }
290 // A key is written once in its scope and version, never into
291 // another ref's scope.
292 let inserted = self
293 .db
294 .prepare(
295 "INSERT INTO cache_entries (id, repo_id, namespace, key, object, size, status, created_at, last_used_at, version, scope)
296 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?7, ?8, ?9)
297 ON CONFLICT (repo_id, scope, key, version) DO UPDATE SET
298 id = ?1, object = ?5, size = ?6, status = 'pending', created_at = ?7, last_used_at = ?7, version = ?8, upload = NULL
299 WHERE cache_entries.status = 'expired'
300 RETURNING rowid AS number",
301 )
302 .bind(&[
303 id.as_str().into(),
304 scope.repo_id.as_str().into(),
305 job.namespace.as_str().into(),
306 a.key.as_str().into(),
307 object.as_str().into(),
308 (a.size as f64).into(),
309 at.as_str().into(),
310 version.as_str().into(),
311 scope.save.as_str().into(),
312 ])?
313 .first::<Inserted>(None)
314 .await?;
315 let Some(inserted) = inserted else {
316 return Ok(fail(FailureCode::Conflict, "That key is already cached."));
317 };
318 Ok(Outcome::Ok(CacheReservation { id, object, number: inserted.number as u64, upload: None, blob: None }))
319 }
320
321 /// `cache_commit`: the entry is ready; entries past the quota are
322 /// evicted, restored longest ago first.
323 pub async fn cache_commit(&self, a: CacheCommitArgs) -> Result<Outcome<CacheCommitted>> {
324 let job = check!(self.cache_job(&a.job, &a.token).await?);
325 if a.size > CACHE_MAX_ENTRY_BYTES {
326 return Ok(fail(FailureCode::Invalid, "That entry is larger than a cache entry may be."));
327 }
328 let ready = self
329 .db
330 .prepare("UPDATE cache_entries SET status = 'ready', size = ?, last_used_at = ? WHERE id = ? AND repo_id = ? AND status = 'pending' RETURNING id")
331 .bind(&[(a.size as f64).into(), rfc3339(now_ms()).into(), a.id.as_str().into(), job.repo_id.as_str().into()])?
332 .first::<Value>(None)
333 .await?;
334 if ready.is_none() {
335 return Ok(fail(FailureCode::NotFound, "No upload of that entry is in progress."));
336 }
337 // What the repository holds, summed: only past the quota are its
338 // entries listed to choose what to evict. A tool that saves an
339 // entry per compiled file (sccache) commits thousands of small
340 // entries a build, and listing them all on each was quadratic.
341 #[derive(Deserialize)]
342 struct Total {
343 bytes: Option<f64>,
344 }
345 let total = self
346 .db
347 .prepare("SELECT SUM(size) AS bytes FROM cache_entries WHERE repo_id = ? AND status = 'ready'")
348 .bind(&[job.repo_id.as_str().into()])?
349 .first::<Total>(None)
350 .await?
351 .and_then(|t| t.bytes)
352 .unwrap_or(0.0);
353 if !over_quota(total as u64, CACHE_REPO_QUOTA_BYTES) {
354 return Ok(Outcome::Ok(CacheCommitted { evicted: Vec::new() }));
355 }
356 #[derive(Deserialize)]
357 struct Held {
358 id: String,
359 object: String,
360 size: f64,
361 }
362 let held = self
363 .db
364 .prepare("SELECT id, object, size FROM cache_entries WHERE repo_id = ? AND status = 'ready' ORDER BY last_used_at DESC")
365 .bind(&[job.repo_id.as_str().into()])?
366 .all()
367 .await?
368 .results::<Held>()?;
369 let sizes: Vec<(String, u64)> = held.iter().map(|h| (h.id.clone(), h.size as u64)).collect();
370 let evict = to_evict(&sizes, &a.id, CACHE_REPO_QUOTA_BYTES);
371 let mut evicted = Vec::new();
372 for id in &evict {
373 if let Some(entry) = held.iter().find(|h| &h.id == id) {
374 self.db.prepare("DELETE FROM cache_entries WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
375 evicted.push(entry.object.clone());
376 }
377 }
378 Ok(Outcome::Ok(CacheCommitted { evicted }))
379 }
380
381 /// `cache_abort`.
382 pub async fn cache_abort(&self, a: CacheAbortArgs) -> Result<Outcome<bool>> {
383 let job = check!(self.cache_job(&a.job, &a.token).await?);
384 self.db
385 .prepare("DELETE FROM cache_entries WHERE id = ? AND repo_id = ? AND status = 'pending'")
386 .bind(&[a.id.as_str().into(), job.repo_id.as_str().into()])?
387 .run()
388 .await?;
389 Ok(Outcome::Ok(true))
390 }
391
392 /// Hourly: deletes entries unused for a week, saved too long ago, or
393 /// left unfinished, and their objects; then what each workspace's
394 /// cache holds today, and this month's storage, for billing.
395 pub async fn sweep_cache(&self, now: u64) -> Result<()> {
396 let at = |ms: u64| rfc3339(now.saturating_sub(ms));
397 self.db
398 .prepare(
399 "UPDATE cache_entries SET status = 'expired'
400 WHERE (status = 'ready' AND (last_used_at < ?1 OR created_at < ?2)) OR (status = 'pending' AND created_at < ?3)",
401 )
402 .bind(&[at(CACHE_UNUSED_DAYS * DAY_MS).into(), at(CACHE_MAX_AGE_DAYS * DAY_MS).into(), at(PENDING_MS).into()])?
403 .run()
404 .await?;
405 #[derive(Deserialize)]
406 struct Gone {
407 id: String,
408 object: String,
409 }
410 let gone = self
411 .db
412 .prepare("SELECT id, object FROM cache_entries WHERE status = 'expired' LIMIT 500")
413 .all()
414 .await?
415 .results::<Gone>()?;
416 if let Some(bucket) = &self.cache {
417 for chunk in gone.chunks(100) {
418 if let Err(error) = bucket.delete_multiple(chunk.iter().map(|g| g.object.as_str()).collect()).await {
419 worker::console_error!("actions: cache objects not deleted: {error}");
420 return Ok(());
421 }
422 for entry in chunk {
423 self.db.prepare("DELETE FROM cache_entries WHERE id = ?").bind(&[entry.id.as_str().into()])?.run().await?;
424 }
425 }
426 }
427 self.measure_cache(now).await
428 }
429
430 /// What each workspace's cache holds today, and this month's storage so
431 /// far reported to billing, which charges it to workspaces on the plan
432 /// once the month is over.
433 async fn measure_cache(&self, now: u64) -> Result<()> {
434 #[derive(Deserialize)]
435 struct Held {
436 namespace: String,
437 bytes: f64,
438 }
439 let today = rfc3339(now);
440 let (day, month) = (&today[..10], &today[..7]);
441 let held = self
442 .db
443 // Artifacts are kept in the same bucket, at the same price.
444 .prepare(
445 "SELECT namespace, SUM(size) AS bytes FROM (
446 SELECT namespace, size FROM cache_entries WHERE status = 'ready'
447 UNION ALL SELECT namespace, size FROM artifacts WHERE status = 'ready'
448 ) GROUP BY namespace",
449 )
450 .all()
451 .await?
452 .results::<Held>()?;
453 for workspace in &held {
454 self.db
455 .prepare(
456 "INSERT INTO cache_days (namespace, day, bytes) VALUES (?1, ?2, ?3)
457 ON CONFLICT (namespace, day) DO UPDATE SET bytes = max(bytes, ?3)",
458 )
459 .bind(&[workspace.namespace.as_str().into(), day.into(), workspace.bytes.into()])?
460 .run()
461 .await?;
462 }
463 #[derive(Deserialize)]
464 struct Day {
465 namespace: String,
466 bytes: f64,
467 }
468 let days = self
469 .db
470 .prepare("SELECT namespace, bytes FROM cache_days WHERE substr(day, 1, 7) = ?")
471 .bind(&[month.into()])?
472 .all()
473 .await?
474 .results::<Day>()?;
475 let mut by_workspace: std::collections::BTreeMap<String, Vec<u64>> = std::collections::BTreeMap::new();
476 for d in days {
477 by_workspace.entry(d.namespace).or_default().push(d.bytes as u64);
478 }
479 for (workspace, days) in by_workspace {
480 let months = gb_months(&days);
481 let cost = storage_cost(months);
482 if cost <= 0 {
483 continue;
484 }
485 let noted: Result<bool> = g1t_kit::call(
486 &self.billing,
487 "note_pending",
488 &NotePendingArgs { workspace: workspace.clone(), source: "cache".to_owned(), cost_micros: cost, detail: Some(format!("{months:.2} GB-months")) },
489 )
490 .await;
491 if let Err(error) = noted {
492 worker::console_error!("actions: cache storage not reported for {workspace}: {error}");
493 }
494 }
495 Ok(())
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn entries(sizes: &[(&str, u64)]) -> Vec<(String, u64)> {
504 sizes.iter().map(|(id, size)| ((*id).to_owned(), *size)).collect()
505 }
506
507 #[test]
508 fn a_run_restores_from_its_ref_then_its_base_then_the_default_branch() {
509 let (restore, save) = scopes("refs/heads/feature", None, "main", true);
510 assert_eq!(restore, ["refs/heads/feature", "refs/heads/main"]);
511 assert_eq!(save, "refs/heads/feature");
512 // A pull request: its own merge ref, the branch it merges into, the default.
513 let (restore, save) = scopes("refs/pull/7/merge", Some("release/1.x"), "main", true);
514 assert_eq!(restore, ["refs/pull/7/merge", "refs/heads/release/1.x", "refs/heads/main"]);
515 assert_eq!(save, "refs/pull/7/merge");
516 // The default branch reads only its own.
517 let (restore, _) = scopes("refs/heads/main", Some("main"), "main", true);
518 assert_eq!(restore, ["refs/heads/main"]);
519 // From outside: it saves where nothing else reads.
520 let (restore, save) = scopes("refs/pull/9/merge", Some("main"), "main", false);
521 assert_eq!(save, "untrusted:refs/pull/9/merge");
522 assert_eq!(restore, ["untrusted:refs/pull/9/merge", "refs/heads/main"]);
523 for trusted_ref in ["refs/heads/main", "refs/pull/9/merge", "refs/heads/feature"] {
524 let (others, _) = scopes(trusted_ref, Some("main"), "main", true);
525 assert!(!others.contains(&save), "{trusted_ref} must never read an untrusted entry");
526 }
527 }
528
529 #[test]
530 fn eviction_takes_the_entries_restored_longest_ago() {
531 // Newest use first.
532 let held = entries(&[("new", 4), ("b", 3), ("c", 3), ("old", 2)]);
533 assert_eq!(to_evict(&held, "new", 12), Vec::<String>::new());
534 assert_eq!(to_evict(&held, "new", 10), ["old"]);
535 assert_eq!(to_evict(&held, "new", 7), ["old", "c"]);
536 // The entry just saved stays, even when it alone is past the quota.
537 assert_eq!(to_evict(&entries(&[("big", 20), ("a", 1)]), "big", 10), ["a"]);
538 // Entries are listed only when the sum is over: at or under the
539 // quota, nothing would be evicted.
540 for (held, quota) in [(entries(&[("a", 4), ("b", 6)]), 10), (entries(&[("a", 1)]), 12)] {
541 let total = held.iter().map(|(_, size)| size).sum();
542 assert!(!over_quota(total, quota));
543 assert!(to_evict(&held, "a", quota).is_empty());
544 }
545 assert!(over_quota(11, 10));
546 }
547
548 #[test]
549 fn keys_are_checked() {
550 assert!(valid_key("cargo-Linux-abc123"));
551 assert!(!valid_key(""));
552 assert!(!valid_key("a,b"));
553 assert!(!valid_key(&"k".repeat(513)));
554 }
555
556 #[test]
557 fn storage_is_charged_by_the_gb_month_at_r2s_price() {
558 // 10 GB held for 30 days is 10 GB-months: $0.15.
559 let month = vec![10_000_000_000u64; 30];
560 assert!((gb_months(&month) - 10.0).abs() < 1e-9);
561 assert_eq!(storage_cost(gb_months(&month)), 150_000);
562 // A day of 1 GB: a thirtieth of a GB-month, rounded up.
563 assert_eq!(storage_cost(gb_months(&[1_000_000_000])), 500);
564 assert_eq!(storage_cost(0.0), 0);
565 }
566}