1use std::collections::{BTreeMap, HashMap, HashSet};
85use std::sync::{Arc, Mutex, PoisonError};
86use std::time::Duration;
87
88use serde::{Deserialize, Serialize};
89use serde_json::value::RawValue;
90use sha2::{Digest, Sha256};
91
92use crate::completion::CacheCost;
93
94pub type Clock = Arc<dyn Fn() -> u64 + Send + Sync>;
96
97fn system_now() -> u64 {
102 #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
103 {
104 std::time::SystemTime::now()
105 .duration_since(std::time::UNIX_EPOCH)
106 .map_or(0, |elapsed| elapsed.as_secs())
107 }
108 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
109 {
110 0
111 }
112}
113
114const MAX_TTL_SECS: u64 = 81 * 60;
117
118const EXPIRY_MARGIN_SECS: u64 = 5;
120
121const MIN_IDLE_SECS: u64 = 60;
124
125#[derive(Clone, Copy, Debug, PartialEq)]
131pub struct AutoCache {
132 pub ttl: Duration,
134 pub implicit_floor: u64,
136 pub implicit_ceiling: u64,
140 pub min_tokens: u64,
142 pub min_gain: u64,
144 pub cached_ratio: f64,
146 pub storage_ratio_per_hour: f64,
148}
149
150impl Default for AutoCache {
151 fn default() -> Self {
152 Self {
153 ttl: Duration::from_secs(60 * 60),
154 implicit_floor: 4_096,
155 implicit_ceiling: 16_000,
156 min_tokens: 1_024,
157 min_gain: 256,
158 cached_ratio: 0.1,
159 storage_ratio_per_hour: 0.5 / 0.75,
160 }
161 }
162}
163
164impl AutoCache {
165 pub fn for_spec(spec: &crate::catalog::ModelSpec) -> Self {
171 Self::priced(spec.pricing.as_ref())
172 }
173
174 fn priced(pricing: Option<&crate::catalog::Pricing>) -> Self {
178 let ratio = pricing
179 .and_then(|pricing| Some((pricing.cache_read?, pricing.input)))
180 .filter(|(read, input)| *read >= 0.0 && *input > 0.0)
181 .map(|(read, input)| read / input);
182 let default = Self::default();
183 Self {
184 cached_ratio: ratio.unwrap_or(default.cached_ratio),
185 ..default
186 }
187 }
188}
189
190#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Lease {
193 pub name: String,
195 pub digest: String,
199 pub covers: usize,
201 pub tokens: u64,
203 pub expires_at: u64,
205 pub model: String,
207}
208
209#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211pub enum CacheEvent {
212 Created {
214 name: String,
216 tokens: u64,
218 estimated: u64,
220 covers: usize,
222 ttl_secs: u64,
224 at: u64,
226 },
227 Retired {
229 name: String,
231 at: u64,
233 },
234 Lost {
236 name: String,
238 at: u64,
240 },
241 Extended {
243 name: String,
245 expires_at: u64,
247 at: u64,
249 },
250 CreateFailed {
252 status: Option<u16>,
254 message: String,
256 at: u64,
258 },
259}
260
261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
263pub struct CreatedCache {
264 pub name: String,
266 pub tokens: u64,
268 pub at: u64,
270}
271
272#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
276pub struct CacheReport {
277 pub created: Vec<CreatedCache>,
279 pub reads: BTreeMap<String, u64>,
281 pub retired: Vec<String>,
284 pub lost: Vec<String>,
286 pub token_hours: f64,
289 pub live: Vec<Lease>,
291}
292
293impl From<&CacheReport> for CacheCost {
297 fn from(report: &CacheReport) -> Self {
298 Self {
299 cache_writes: report.created.iter().map(|created| created.tokens).sum(),
300 storage_token_hours: report.token_hours,
301 ..Self::default()
302 }
303 }
304}
305
306#[derive(Clone, Debug, Default)]
307struct Line {
308 premium: f64,
311 implicit: Option<f64>,
313 rolled_at: u64,
314 last_call: Option<u64>,
315 longest_gap: u64,
316 failed_at: Option<u64>,
319}
320
321#[derive(Clone, Debug)]
322struct Life {
323 tokens: u64,
324 created: u64,
325 expires_at: u64,
326 ended: Option<u64>,
327 last_read: u64,
328 line_gap: u64,
329}
330
331#[derive(Default)]
332pub(crate) struct Book {
333 pub(crate) leases: HashMap<String, Lease>,
334 lives: BTreeMap<String, Life>,
335 lineages: HashMap<String, HashSet<String>>,
337 lines: HashMap<String, Line>,
338 events: Vec<CacheEvent>,
339 report: CacheReport,
340 calibration: Option<f64>,
343}
344
345#[derive(Clone)]
349pub struct CacheBook {
350 inner: Arc<Mutex<Book>>,
351 pub(crate) create: Arc<futures::lock::Mutex<()>>,
353 policy: AutoCache,
354 clock: Clock,
355 pub(crate) display_prefix: Arc<str>,
356}
357
358impl std::fmt::Debug for CacheBook {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 f.debug_struct("CacheBook")
361 .field("policy", &self.policy)
362 .field("display_prefix", &self.display_prefix)
363 .finish_non_exhaustive()
364 }
365}
366
367impl CacheBook {
368 pub fn new(policy: AutoCache) -> Self {
370 Self {
371 inner: Arc::default(),
372 create: Arc::default(),
373 policy,
374 clock: Arc::new(system_now),
375 display_prefix: Arc::from("rig-cache-"),
376 }
377 }
378
379 pub fn with_clock(mut self, clock: impl Fn() -> u64 + Send + Sync + 'static) -> Self {
381 self.clock = Arc::new(clock);
382 self
383 }
384
385 pub fn with_display_prefix(mut self, prefix: &str) -> Self {
388 self.display_prefix = Arc::from(prefix);
389 self
390 }
391
392 pub fn policy(&self) -> AutoCache {
394 self.policy
395 }
396
397 pub(crate) fn book(&self) -> std::sync::MutexGuard<'_, Book> {
398 self.inner.lock().unwrap_or_else(PoisonError::into_inner)
399 }
400
401 pub(crate) fn now(&self) -> u64 {
402 (self.clock)()
403 }
404
405 pub fn events(&self) -> Vec<CacheEvent> {
407 self.book().events.clone()
408 }
409
410 pub fn leases(&self) -> Vec<Lease> {
412 let mut leases: Vec<Lease> = self.book().leases.values().cloned().collect();
413 leases.sort_by(|a, b| a.name.cmp(&b.name));
414 leases
415 }
416
417 pub fn restore(&self, leases: Vec<Lease>) {
419 let now = self.now();
420 let mut book = self.book();
421 for lease in leases {
422 book.lives.entry(lease.name.clone()).or_insert(Life {
423 tokens: lease.tokens,
424 created: now,
425 expires_at: lease.expires_at,
426 ended: None,
427 last_read: now,
428 line_gap: 0,
429 });
430 book.leases.insert(lease.digest.clone(), lease);
431 }
432 }
433
434 pub fn report(&self) -> CacheReport {
436 let now = self.now();
437 let book = self.book();
438 let mut report = book.report.clone();
439 report.token_hours = book
440 .lives
441 .values()
442 .map(|life| {
443 let end = life.ended.unwrap_or(now).min(life.expires_at);
444 life.tokens as f64 * end.saturating_sub(life.created) as f64 / 3600.0
445 })
446 .sum();
447 report.live = book.leases.values().cloned().collect();
448 report.live.sort_by(|a, b| a.name.cmp(&b.name));
449 report
450 }
451
452 pub(crate) fn retired(&self, lease: &Lease) {
453 let now = self.now();
454 let mut book = self.book();
455 book.leases.remove(&lease.digest);
456 end_life(&mut book, &lease.name, now);
457 book.events.push(CacheEvent::Retired {
458 name: lease.name.clone(),
459 at: now,
460 });
461 book.report.retired.push(lease.name.clone());
462 tracing::info!(target: "gemini.cache.retired", name = %lease.name);
463 }
464
465 pub(crate) fn lost(&self, lease: &Lease) {
466 let now = self.now();
467 let mut book = self.book();
468 book.leases.remove(&lease.digest);
469 end_life(&mut book, &lease.name, now);
470 book.events.push(CacheEvent::Lost {
471 name: lease.name.clone(),
472 at: now,
473 });
474 book.report.lost.push(lease.name.clone());
475 tracing::info!(target: "gemini.cache.lost", name = %lease.name);
476 }
477}
478
479pub(crate) fn end_life(book: &mut Book, name: &str, now: u64) {
480 if let Some(life) = book.lives.get_mut(name)
481 && life.ended.is_none()
482 {
483 life.ended = Some(now);
484 }
485}
486
487pub(crate) fn short_digest(digest: &str) -> &str {
488 digest.get(..40).unwrap_or(digest)
489}
490
491const PREFIX_KEYS: [&str; 3] = ["systemInstruction", "tools", "toolConfig"];
495
496pub(crate) struct Parsed {
498 pub(crate) rest: Vec<(String, Box<RawValue>)>,
500 pub(crate) prefix: [Option<Box<RawValue>>; 3],
502 pub(crate) contents: Vec<Box<RawValue>>,
503 pub(crate) has_cached_content: bool,
504}
505
506struct Members(Vec<(String, Box<RawValue>)>);
509
510impl<'de> Deserialize<'de> for Members {
511 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512 struct Visitor;
513 impl<'de> serde::de::Visitor<'de> for Visitor {
514 type Value = Members;
515 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 formatter.write_str("a JSON object")
517 }
518 fn visit_map<A: serde::de::MapAccess<'de>>(
519 self,
520 mut map: A,
521 ) -> Result<Members, A::Error> {
522 let mut members: Vec<(String, Box<RawValue>)> = Vec::new();
523 while let Some((key, value)) = map.next_entry::<String, Box<RawValue>>()? {
524 match members.iter_mut().find(|(held, _)| *held == key) {
525 Some((_, held)) => *held = value,
526 None => members.push((key, value)),
527 }
528 }
529 Ok(Members(members))
530 }
531 }
532 deserializer.deserialize_map(Visitor)
533 }
534}
535
536pub(crate) fn parse(bytes: &[u8]) -> Option<Parsed> {
537 let Members(members) = serde_json::from_slice(bytes).ok()?;
538 let mut parsed = Parsed {
539 rest: Vec::new(),
540 prefix: [None, None, None],
541 contents: Vec::new(),
542 has_cached_content: false,
543 };
544 for (key, value) in members {
545 if key == "contents" {
546 parsed.contents = serde_json::from_str(value.get()).ok()?;
547 } else if let Some(slot) = PREFIX_KEYS.iter().position(|prefix| *prefix == key) {
548 if value.get() != "null"
549 && let Some(field) = parsed.prefix.get_mut(slot)
550 {
551 *field = Some(value);
552 }
553 } else {
554 if key == "cachedContent" && value.get() != "null" {
555 parsed.has_cached_content = true;
556 }
557 parsed.rest.push((key, value));
558 }
559 }
560 Some(parsed)
561}
562
563fn hex(bytes: &[u8]) -> String {
564 use std::fmt::Write;
565 bytes.iter().fold(String::new(), |mut out, byte| {
566 let _ = write!(out, "{byte:02x}");
567 out
568 })
569}
570
571pub(crate) struct Digested {
573 pub(crate) d: Vec<String>,
575 pub(crate) prefix: u64,
577 pub(crate) contents: Vec<u64>,
579}
580
581pub(crate) fn digests(model: &str, parsed: &Parsed) -> Digested {
582 let mut hasher = Sha256::new();
583 hasher.update(model.as_bytes());
584 let mut prefix = 0;
585 for field in &parsed.prefix {
586 hasher.update([0u8]);
587 if let Some(raw) = field {
588 prefix += digest(&mut hasher, raw.get());
589 }
590 }
591 let mut state = hasher.finalize().to_vec();
592 let mut d = vec![hex(&state)];
593 let mut contents = Vec::with_capacity(parsed.contents.len());
594 for content in &parsed.contents {
595 let mut hasher = Sha256::new();
596 hasher.update(&state);
597 contents.push(digest(&mut hasher, content.get()));
598 state = hasher.finalize().to_vec();
599 d.push(hex(&state));
600 }
601 Digested {
602 d,
603 prefix,
604 contents,
605 }
606}
607
608fn digest(hasher: &mut Sha256, fragment: &str) -> u64 {
614 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(fragment) else {
615 hasher.update(fragment.as_bytes());
616 return (fragment.len() / 4) as u64;
617 };
618 hasher.update(crate::json_utils::to_canonical_string(&value).as_bytes());
619 fn scrub(value: &mut serde_json::Value) {
620 match value {
621 serde_json::Value::Object(map) => {
622 map.shift_remove("thoughtSignature");
623 map.values_mut().for_each(scrub);
624 }
625 serde_json::Value::Array(items) => items.iter_mut().for_each(scrub),
626 _ => {}
627 }
628 }
629 scrub(&mut value);
630 (value.to_string().len() / 4) as u64
631}
632
633pub(crate) fn is_user_text(raw: &RawValue) -> bool {
635 #[derive(Deserialize)]
636 struct Content {
637 role: Option<String>,
638 #[serde(default)]
639 parts: Vec<serde_json::Map<String, serde_json::Value>>,
640 }
641 serde_json::from_str::<Content>(raw.get()).is_ok_and(|content| {
642 content.role.as_deref() == Some("user")
643 && content.parts.iter().any(|part| part.contains_key("text"))
644 && !content
645 .parts
646 .iter()
647 .any(|part| part.contains_key("functionResponse"))
648 })
649}
650
651pub(crate) fn stripped(parsed: &Parsed, lease: &Lease) -> Option<Vec<u8>> {
653 let mut out: Vec<(String, &RawValue)> = Vec::new();
654 let name = serde_json::value::to_raw_value(&lease.name).ok()?;
655 let contents = serde_json::value::to_raw_value(&parsed.contents.get(lease.covers..)?).ok()?;
656 out.push(("cachedContent".to_owned(), &name));
657 out.push(("contents".to_owned(), &contents));
658 for (key, value) in &parsed.rest {
659 if key != "cachedContent" {
660 out.push((key.clone(), value));
661 }
662 }
663 let mut bytes = b"{".to_vec();
664 for (index, (key, value)) in out.iter().enumerate() {
665 if index > 0 {
666 bytes.push(b',');
667 }
668 bytes.extend(serde_json::to_vec(key).ok()?);
669 bytes.push(b':');
670 bytes.extend(value.get().as_bytes());
671 }
672 bytes.push(b'}');
673 Some(bytes)
674}
675
676pub(crate) fn cache_body(
679 model: &str,
680 parsed: &Parsed,
681 covers: usize,
682 display_name: &str,
683 ttl_secs: u64,
684) -> Option<Vec<u8>> {
685 let mut bytes = b"{".to_vec();
686 let mut field = |key: &str, value: &str| {
687 if bytes.len() > 1 {
688 bytes.push(b',');
689 }
690 bytes.extend(format!("\"{key}\":").as_bytes());
691 bytes.extend(value.as_bytes());
692 };
693 field(
694 "model",
695 &serde_json::to_string(&format!("models/{model}")).ok()?,
696 );
697 field("displayName", &serde_json::to_string(display_name).ok()?);
698 field("ttl", &format!("\"{ttl_secs}s\""));
699 for (key, value) in PREFIX_KEYS.iter().zip(&parsed.prefix) {
700 if let Some(value) = value {
701 field(key, value.get());
702 }
703 }
704 if covers > 0 {
705 let contents = serde_json::value::to_raw_value(&parsed.contents.get(..covers)?).ok()?;
706 field("contents", contents.get());
707 }
708 bytes.push(b'}');
709 Some(bytes)
710}
711
712pub(crate) struct Plan {
717 pub(crate) read: Option<Lease>,
718 pub(crate) create: Option<Create>,
719 pub(crate) retire: Vec<Lease>,
720 pub(crate) extend: Option<(Lease, u64)>,
721 pub(crate) line: String,
722 pub(crate) coverable: u64,
723}
724
725pub(crate) struct Create {
726 pub(crate) covers: usize,
727 pub(crate) digest: String,
728 pub(crate) ttl_secs: u64,
729 estimate: u64,
731 expected: u64,
733 pub(crate) replaces: Option<Lease>,
734}
735
736impl CacheBook {
737 pub(crate) fn plan(&self, model: &str, digested: &Digested, roll_allowed: bool) -> Plan {
738 let p = self.policy;
739 let now = self.now();
740 let n = digested.contents.len();
741 let at = |k: usize| digested.d.get(k).map_or("", String::as_str);
743 let (prefix_estimate, content_estimates) = (digested.prefix, &digested.contents);
744 let mut book = self.book();
745 let calibration = book.calibration.unwrap_or(1.0);
746
747 if n >= 1 {
750 let first = at(1).to_owned();
751 book.lineages
752 .entry(at(0).to_owned())
753 .or_default()
754 .insert(first);
755 }
756 let shared = book.lineages.get(at(0)).is_some_and(|seen| seen.len() >= 2);
757
758 let idle: Vec<Lease> = book
760 .leases
761 .values()
762 .filter(|lease| lease.covers > 0 && lease.model == model)
763 .filter(|lease| {
764 book.lives.get(&lease.name).is_some_and(|life| {
765 now.saturating_sub(life.last_read)
766 > MIN_IDLE_SECS.max(life.line_gap.saturating_mul(3))
767 })
768 })
769 .cloned()
770 .collect();
771
772 let read = (0..n.max(1))
774 .rev()
775 .find_map(|k| {
776 book.leases
777 .get(at(k))
778 .filter(|lease| lease.model == model)
779 .filter(|lease| lease.expires_at > now + EXPIRY_MARGIN_SECS)
780 .cloned()
781 })
782 .filter(|lease| !idle.iter().any(|gone| gone.name == lease.name));
783 let covered = read.as_ref().map_or(0, |lease| lease.covers);
784 let tail: u64 = content_estimates
785 .get(covered..n.saturating_sub(1).max(covered))
786 .unwrap_or_default()
787 .iter()
788 .sum();
789 let coverable = match &read {
790 Some(lease) => lease.tokens + (tail as f64 * calibration) as u64,
791 None => ((prefix_estimate + tail) as f64 * calibration) as u64,
792 };
793
794 let line_key = read
796 .as_ref()
797 .filter(|lease| lease.covers > 0)
798 .map_or_else(|| at(1.min(n)).to_owned(), |lease| lease.digest.clone());
799 let prefix_leased = book.leases.contains_key(at(0));
800 let line = book.lines.entry(line_key.clone()).or_insert_with(|| Line {
801 rolled_at: now,
802 ..Line::default()
803 });
804 if let Some(last) = line.last_call {
805 line.longest_gap = line.longest_gap.max(now.saturating_sub(last));
806 }
807 line.last_call = Some(now);
808 let longest_gap = line.longest_gap;
809 let ttl_secs = p
810 .ttl
811 .as_secs()
812 .max(longest_gap.saturating_mul(2).min(MAX_TTL_SECS))
813 .max(1);
814
815 let mut plan = Plan {
816 read: read.clone(),
817 create: None,
818 retire: idle,
819 extend: None,
820 line: line_key,
821 coverable,
822 };
823
824 let implicit_serving = line.implicit.is_none_or(|ratio| ratio >= 0.5);
826 if coverable >= p.implicit_ceiling && implicit_serving {
827 plan.read = None;
828 return plan;
829 }
830
831 let failed_below = line.failed_at.is_some_and(|at| coverable < at + p.min_gain);
832 if read.is_none()
833 && shared
834 && !prefix_leased
835 && (prefix_estimate as f64 * calibration) as u64 >= p.min_tokens
836 && !failed_below
837 {
838 plan.create = Some(Create {
839 covers: 0,
840 digest: at(0).to_owned(),
841 ttl_secs,
842 estimate: prefix_estimate,
843 expected: (prefix_estimate as f64 * calibration) as u64,
844 replaces: None,
845 });
846 } else if n >= 2 && roll_allowed && !failed_below {
847 let cached = read.as_ref().map_or(0, |lease| lease.tokens);
848 let gain = coverable.saturating_sub(cached);
849 let held_hours = now.saturating_sub(line.rolled_at).max(60) as f64 / 3600.0;
850 let cost = coverable as f64 * (1.0 + p.storage_ratio_per_hour * held_hours);
851 if gain >= p.min_gain && coverable >= p.min_tokens && line.premium >= cost {
852 plan.create = Some(Create {
853 covers: n - 1,
854 digest: at(n - 1).to_owned(),
855 ttl_secs,
856 estimate: prefix_estimate
857 + content_estimates
858 .get(..n - 1)
859 .unwrap_or_default()
860 .iter()
861 .sum::<u64>(),
862 expected: coverable,
863 replaces: read.clone().filter(|lease| lease.covers > 0),
864 });
865 }
866 }
867
868 if plan.create.is_none()
870 && let Some(lease) = &read
871 && longest_gap > 0
872 && lease.expires_at.saturating_sub(now) < longest_gap.saturating_mul(3) / 2
873 {
874 plan.extend = Some((lease.clone(), ttl_secs));
875 }
876 plan
877 }
878
879 pub(crate) fn observe(&self, line: &str, read: Option<&str>, coverable: u64, cached: u64) {
881 let p = self.policy;
882 let mut book = self.book();
883 if let Some(name) = read {
884 *book.report.reads.entry(name.to_owned()).or_default() += 1;
885 }
886 let entry = book.lines.entry(line.to_owned()).or_default();
887 let mut missed = coverable.saturating_sub(cached) as f64;
888 if read.is_none() && coverable >= p.implicit_floor {
889 let ratio = cached as f64 / coverable.max(1) as f64;
890 let implicit = entry
891 .implicit
892 .map_or(ratio, |ewma| 0.5 * ewma + 0.5 * ratio);
893 entry.implicit = Some(implicit);
894 if implicit >= 0.5 {
895 missed = 0.0;
896 }
897 }
898 entry.premium += missed * (1.0 - p.cached_ratio);
899 }
900
901 pub(crate) fn created(
902 &self,
903 from_line: &str,
904 create: &Create,
905 name: String,
906 tokens: u64,
907 model: &str,
908 ) -> Lease {
909 let now = self.now();
910 let lease = Lease {
911 name: name.clone(),
912 digest: create.digest.clone(),
913 covers: create.covers,
914 tokens,
915 expires_at: now + create.ttl_secs,
916 model: model.to_owned(),
917 };
918 let mut book = self.book();
919 if create.estimate > 0 && tokens > 0 {
920 let ratio = tokens as f64 / create.estimate as f64;
921 book.calibration = Some(book.calibration.map_or(ratio, |c| 0.5 * c + 0.5 * ratio));
922 }
923 let previous = book.lines.get(from_line).cloned().unwrap_or_default();
924 if create.covers > 0 {
925 book.lines.insert(
926 lease.digest.clone(),
927 Line {
928 premium: 0.0,
929 implicit: previous.implicit,
930 rolled_at: now,
931 last_call: previous.last_call,
932 longest_gap: previous.longest_gap,
933 failed_at: None,
934 },
935 );
936 }
937 book.lives.insert(
938 name.clone(),
939 Life {
940 tokens,
941 created: now,
942 expires_at: lease.expires_at,
943 ended: None,
944 last_read: now,
945 line_gap: previous.longest_gap,
946 },
947 );
948 book.events.push(CacheEvent::Created {
949 name: name.clone(),
950 tokens,
951 estimated: create.expected,
952 covers: create.covers,
953 ttl_secs: create.ttl_secs,
954 at: now,
955 });
956 book.report.created.push(CreatedCache {
957 name,
958 tokens,
959 at: now,
960 });
961 book.leases.insert(lease.digest.clone(), lease.clone());
962 tracing::info!(target: "gemini.cache.created", name = %lease.name, tokens, covers = create.covers);
963 lease
964 }
965
966 pub(crate) fn create_failed(
967 &self,
968 line: &str,
969 coverable: u64,
970 status: Option<u16>,
971 message: String,
972 ) {
973 let now = self.now();
974 let mut book = self.book();
975 if let Some(entry) = book.lines.get_mut(line) {
976 entry.failed_at = Some(coverable);
977 }
978 book.events.push(CacheEvent::CreateFailed {
979 status,
980 message,
981 at: now,
982 });
983 }
984
985 pub(crate) fn extended(&self, lease: &Lease, ttl_secs: u64) {
986 let now = self.now();
987 let expires_at = now + ttl_secs;
988 let mut book = self.book();
989 if let Some(held) = book.leases.get_mut(&lease.digest) {
990 held.expires_at = expires_at;
991 }
992 if let Some(life) = book.lives.get_mut(&lease.name) {
993 life.expires_at = expires_at;
994 }
995 book.events.push(CacheEvent::Extended {
996 name: lease.name.clone(),
997 expires_at,
998 at: now,
999 });
1000 tracing::info!(target: "gemini.cache.extended", name = %lease.name, expires_at);
1001 }
1002
1003 pub(crate) fn touched(&self, lease: &Lease) {
1004 let now = self.now();
1005 let mut book = self.book();
1006 let gap = book
1007 .lines
1008 .get(&lease.digest)
1009 .map_or(0, |line| line.longest_gap);
1010 if let Some(life) = book.lives.get_mut(&lease.name) {
1011 life.last_read = now;
1012 life.line_gap = life.line_gap.max(gap);
1013 }
1014 }
1015}
1016
1017#[cfg(test)]
1018mod tests;