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