Skip to main content

kernel/event/record/
renewal.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What one keep-warm renewal cost, so a person can read off the history
7//! what keeping a prefix's cache alive spends.
8//!
9//! The request is absent for the reason `ModelCalled` carries no body: a
10//! renewal resends a prefix whose bytes the run already recorded, and the
11//! room address the line is written under says which one.
12
13use serde::{Deserialize, Serialize};
14
15use crate::budget::{Tokens, UsdMicros};
16use crate::error::AxError;
17use crate::model::{CacheCount, ModelUsage};
18
19/// `cache_renewed`: one renewal a worker sent, answered or refused.
20///
21/// A refusal is a line of its own rather than a note in the diagnostic
22/// log, because a provider that refuses renewals is a fact about what
23/// keep-warm costs, and the log is not the history.
24///
25/// `Refused` is tried first when reading: every field of `Answered` may
26/// be absent, so it would read any line.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(untagged)]
30pub enum CacheRenewed {
31    /// The provider refused the renewal; its refusal, unchanged.
32    Refused {
33        /// The failure the provider returned.
34        refused: AxError,
35    },
36    /// The provider answered the renewal.
37    Answered {
38        /// Four token counts, as the provider reported them. Absent when
39        /// it reported none, which is not the same fact as zero.
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        #[cfg_attr(
42            feature = "schema",
43            schemars(with = "Option<std::collections::BTreeMap<String, u64>>")
44        )]
45        usage: Option<ModelUsage>,
46        /// What the provider itself billed, when it returns a figure.
47        #[serde(default, skip_serializing_if = "Option::is_none")]
48        billed_usd_micros: Option<UsdMicros>,
49    },
50}
51
52/// What one renewal shows about the cache it was sent to keep.
53///
54/// The one reading of a `cache_renewed` line that a cost view counts by
55/// (`crates/kernel/spec/KeepWarm.lean` ยง8-74, storage D35).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum RenewalOutcome {
58    /// The provider reported no cache write: the renewal reached the
59    /// entry while it was alive.
60    Read,
61    /// The provider reported cache write tokens above zero: the entry
62    /// had expired, and the renewal paid the write price for the prefix
63    /// instead of the read price.
64    Late,
65    /// The provider answered without a cache write count.
66    Unreported,
67    /// The provider refused the renewal.
68    Refused,
69}
70
71impl CacheRenewed {
72    /// What this renewal shows about the cache it was sent to keep.
73    pub fn outcome(&self) -> RenewalOutcome {
74        match self {
75            CacheRenewed::Refused { .. } => RenewalOutcome::Refused,
76            CacheRenewed::Answered { usage, .. } => {
77                match usage.map(|usage| usage.cache_write_tokens) {
78                    None | Some(CacheCount::Unreported) => RenewalOutcome::Unreported,
79                    Some(CacheCount::Reported(written)) if written == Tokens::new(0) => {
80                        RenewalOutcome::Read
81                    }
82                    Some(CacheCount::Reported(_)) => RenewalOutcome::Late,
83                }
84            }
85        }
86    }
87}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used, reason = "test code")]
91mod tests {
92    use super::*;
93    use crate::event::Payload;
94
95    #[test]
96    fn a_refusal_reads_back_as_a_refusal_and_an_empty_answer_as_an_answer() {
97        let refused = CacheRenewed::Refused {
98            refused: AxError::failure(
99                crate::error::AxCode::Provider,
100                "renew a cached prefix",
101                "rate limited",
102            )
103            .with_recovery("renewal is skipped until the next real request"),
104        };
105        let answered = CacheRenewed::Answered {
106            usage: None,
107            billed_usd_micros: None,
108        };
109        for line in [refused, answered] {
110            assert_eq!(
111                Payload::of(&line).unwrap().read::<CacheRenewed>().unwrap(),
112                line
113            );
114        }
115    }
116
117    #[test]
118    fn a_renewal_that_wrote_the_cache_reads_as_late() {
119        let answered = |written: CacheCount| CacheRenewed::Answered {
120            usage: Some(ModelUsage {
121                cache_write_tokens: written,
122                ..ModelUsage::default()
123            }),
124            billed_usd_micros: None,
125        };
126        let lines = [
127            answered(CacheCount::Reported(Tokens::new(0))),
128            answered(CacheCount::Reported(Tokens::new(4096))),
129            answered(CacheCount::Unreported),
130            CacheRenewed::Answered {
131                usage: None,
132                billed_usd_micros: None,
133            },
134            CacheRenewed::Refused {
135                refused: AxError::failure(
136                    crate::error::AxCode::Provider,
137                    "renew a cached prefix",
138                    "rate limited",
139                )
140                .with_recovery("renewal is skipped until the next real request"),
141            },
142        ];
143        assert_eq!(
144            lines.iter().map(CacheRenewed::outcome).collect::<Vec<_>>(),
145            vec![
146                RenewalOutcome::Read,
147                RenewalOutcome::Late,
148                RenewalOutcome::Unreported,
149                RenewalOutcome::Unreported,
150                RenewalOutcome::Refused,
151            ]
152        );
153    }
154}