Skip to main content

ruststream_rdkafka/schema_registry/
cache.rs

1//! What a registry client remembers: the seam, its policy, and the default implementation.
2
3use std::collections::{HashMap, VecDeque};
4use std::fmt;
5use std::num::NonZeroUsize;
6use std::sync::{Arc, Mutex};
7use std::time::{Duration, Instant};
8
9use super::RegisteredSchema;
10
11/// Where a [`SchemaRegistry`](super::SchemaRegistry) keeps what it has resolved.
12///
13/// The trait is deliberately synchronous on every method. A [`Codec`](ruststream::codec::Codec)
14/// reads this cache while encoding or decoding a message, and a codec is synchronous, so an
15/// implementation that had to await here would have nowhere to do it and would end up blocking a
16/// runtime worker. Fetching is the other trait's job
17/// ([`RegistryClient`](super::RegistryClient), which is async); this one only remembers. An
18/// implementation backed by a remote store therefore belongs behind `RegistryClient`, not here.
19///
20/// Implementations are shared across threads and across clones of the client, so they take
21/// `&self` and do their own interior locking.
22///
23/// # Examples
24///
25/// A cache that remembers ids and forgets subjects, in the three methods the trait asks for,
26/// behind the registry a service consumes through:
27///
28/// ```
29/// use std::collections::HashMap;
30/// use std::sync::{Arc, Mutex};
31///
32/// use ruststream_rdkafka::prelude::*;
33/// use ruststream_rdkafka::schema_registry::{RegisteredSchema, SchemaCache, SchemaRegistry};
34/// # #[derive(serde::Deserialize)]
35/// # struct Order { id: u64 }
36/// # #[subscriber("orders")]
37/// # async fn place(order: &Order) -> HandlerOutcome {
38/// #     tracing::info!(order.id, "accepted");
39/// #     HandlerOutcome::ack()
40/// # }
41///
42/// #[derive(Default)]
43/// struct IdsOnly(Mutex<HashMap<u32, Arc<RegisteredSchema>>>);
44///
45/// impl SchemaCache for IdsOnly {
46///     fn schema(&self, id: u32) -> Option<Arc<RegisteredSchema>> {
47///         self.0.lock().expect("not poisoned").get(&id).cloned()
48///     }
49///
50///     fn subject(&self, _subject: &str) -> Option<Arc<RegisteredSchema>> {
51///         None
52///     }
53///
54///     fn store(&self, _subject: Option<&str>, schema: &Arc<RegisteredSchema>) {
55///         self.0
56///             .lock()
57///             .expect("not poisoned")
58///             .insert(schema.id(), Arc::clone(schema));
59///     }
60/// }
61///
62///
63/// fn app() -> RustStream {
64///     let registry =
65///         SchemaRegistry::new("http://localhost:8081").with_cache(Arc::new(IdsOnly::default()));
66///     let broker = KafkaBroker::new(["localhost:9092"])
67///         .default_group("orders-svc")
68///         .schema_registry(registry);
69///     RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(broker, |b| {
70///         b.include(place);
71///     })
72/// }
73/// # let _ = app;
74/// ```
75#[diagnostic::on_unimplemented(
76    message = "`{Self}` is not a schema cache",
77    note = "a cache answers `schema(id)` and `subject(name)` synchronously and takes new entries \
78            through `store`; fetching is `RegistryClient`'s job, so nothing here may await"
79)]
80pub trait SchemaCache: Send + Sync + 'static {
81    /// The schema remembered under `id`, if any.
82    ///
83    /// A schema id is immutable, so an entry here can only be absent, never wrong.
84    fn schema(&self, id: u32) -> Option<Arc<RegisteredSchema>>;
85
86    /// The schema remembered as `subject`'s current version, if any and still current.
87    ///
88    /// A subject's latest version moves, so an implementation that ages entries returns `None`
89    /// once its own policy says the answer is no longer trustworthy.
90    fn subject(&self, subject: &str) -> Option<Arc<RegisteredSchema>>;
91
92    /// Takes a freshly resolved schema, and the subject it was resolved through when it was one.
93    ///
94    /// `subject` is `None` for a lookup by id, which says nothing about any subject.
95    fn store(&self, subject: Option<&str>, schema: &Arc<RegisteredSchema>);
96
97    /// The ids this cache has dropped since the last call, so forms derived from them (a parsed
98    /// Avro schema, a compiled descriptor pool) can be dropped with them.
99    ///
100    /// The default reports none, which is right for a cache that never drops anything.
101    fn evicted_ids(&self) -> Vec<u32> {
102        Vec::new()
103    }
104}
105
106/// How the default cache ages and bounds what it holds.
107///
108/// The two halves of the registry's answer expire differently, and that is not a preference but
109/// a property of the service: **a schema id is immutable**. The registry assigns an id per
110/// distinct schema definition, by content - registering a new version of a subject mints a *new*
111/// id and leaves the old one resolving to the old schema for ever, and registering an identical
112/// schema under a different subject hands back the id it already had. A subject's *latest
113/// version*, by contrast, moves whenever someone registers one.
114///
115/// "Per registry" is really per **schema context**: a registry can carry several, and each numbers
116/// its ids from one, so the same id means different schemas in two of them. This was checked
117/// against a live registry - the same schema registered in the default context and in a named one
118/// both came back as id 1. It bounds what this cache is: keyed by id, it is correct for a client
119/// that stays inside one context, which is what a client addressing plain subject names does. An
120/// app that reaches across contexts through one client, by writing `:.context:subject` subject
121/// names, needs a client per context rather than one cache spanning them.
122///
123/// So an id-keyed entry can never go stale and needs no expiry - a TTL over it could only cause
124/// a refetch that returns the identical bytes - while a subject-keyed entry can, and is the only
125/// thing the `subject_ttl` of [`Cached`](Self::Cached) governs. Confluent's own clients scope
126/// their `latest.cache.ttl.sec` to exactly the latest-version caches for the same reason.
127///
128/// What ids still need is a bound. A consumer meets one id per writer version it is sent, which
129/// is small in a healthy topology and unbounded in a broken one (a producer registering a schema
130/// per message), so the cache is capped and evicts in insertion order. Evicting an id that is
131/// still in use costs one refetch and never a wrong answer, which is what makes plain insertion
132/// order enough here and a recency policy not worth its bookkeeping.
133///
134/// # Examples
135///
136/// A producer that notices a newly registered version of a subject within five minutes, without
137/// a restart:
138///
139/// ```
140/// use std::time::Duration;
141///
142/// use ruststream_rdkafka::prelude::*;
143/// use ruststream_rdkafka::{SchemaCachePolicy, SchemaFrame, SchemaRegistry};
144/// # #[derive(serde::Deserialize)]
145/// # struct Order { id: u64 }
146/// # #[derive(serde::Serialize, Outgoing)]
147/// # #[outgoing(name = "confirmations")]
148/// # struct Confirmation { id: u64 }
149/// # #[subscriber("orders", reply)]
150/// # async fn confirm(order: &Order) -> Confirmation {
151/// #     Confirmation { id: order.id }
152/// # }
153///
154/// fn app() -> impl App {
155///     let registry = SchemaRegistry::new("http://localhost:8081").cache_policy(
156///         SchemaCachePolicy::Cached {
157///             capacity: nonzero!(256usize),
158///             subject_ttl: Some(Duration::from_secs(300)),
159///         },
160///     );
161///     RustStream::new(AppInfo::new("orders", "0.1.0"))
162///         .publish_layer(SchemaFrame::new(registry))
163///         .with_broker(
164///             KafkaBroker::new(["localhost:9092"]).default_group("orders-svc"),
165///             |b| {
166///                 b.include(confirm);
167///             },
168///         )
169/// }
170/// # let _ = app;
171/// ```
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum SchemaCachePolicy {
175    /// Nothing is remembered: every lookup reaches the registry.
176    ///
177    /// This is a real configuration, not a zero TTL wearing a disguise - for a process that must
178    /// hold no schema state, or a test that wants every call observable. It has one consequence
179    /// worth knowing before choosing it: a synchronous codec reads the cache and cannot await a
180    /// miss, so [`AvroCodec::registry`](crate::avro::AvroCodec::registry) and
181    /// [`SchemaFramed`](super::SchemaFramed) cannot work under it. The transcoding path, whose
182    /// lookups are all `async`, works unchanged.
183    Disabled,
184    /// Ids are remembered up to `capacity`, and a subject's resolved version for `subject_ttl`.
185    Cached {
186        /// The most id-keyed schemas held at once; the oldest is dropped past it.
187        capacity: NonZeroUsize,
188        /// How long a subject's resolved version is trusted before the next `async` lookup
189        /// re-resolves it. `None` never re-resolves, which is Confluent's own default.
190        ///
191        /// This governs the `async` lookups only ([`warm`](super::SchemaRegistry::warm) and the
192        /// publish-side transcode). A codec's subject is resolved once, when the broker
193        /// connects, and stays put: its `encode` is synchronous and cannot re-resolve, and a
194        /// producer changing the schema it writes mid-process is a deliberate act, not something
195        /// to absorb silently between two messages.
196        subject_ttl: Option<Duration>,
197    },
198}
199
200impl SchemaCachePolicy {
201    /// The bound Confluent's own clients default to.
202    const DEFAULT_CAPACITY: usize = 1000;
203}
204
205impl Default for SchemaCachePolicy {
206    /// Ids kept up to 1000 (Confluent's own default bound), subjects never re-resolved
207    /// (Confluent's own default TTL).
208    fn default() -> Self {
209        Self::Cached {
210            capacity: NonZeroUsize::new(Self::DEFAULT_CAPACITY).expect("a non-zero constant"),
211            subject_ttl: None,
212        }
213    }
214}
215
216/// A subject's resolved id, with the moment it was resolved, so a TTL can be applied to it.
217struct SubjectEntry {
218    id: u32,
219    resolved: Instant,
220}
221
222/// The default [`SchemaCache`]: in-process maps governed by a [`SchemaCachePolicy`].
223pub struct MemorySchemaCache {
224    policy: SchemaCachePolicy,
225    by_id: Mutex<HashMap<u32, Arc<RegisteredSchema>>>,
226    /// The ids in `by_id`, in the order they were inserted, so the cap can drop the oldest.
227    id_order: Mutex<VecDeque<u32>>,
228    by_subject: Mutex<HashMap<String, SubjectEntry>>,
229    /// Called for every id this cache drops, so the client can forget its parsed form too.
230    on_evict: Mutex<Vec<u32>>,
231}
232
233impl fmt::Debug for MemorySchemaCache {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        f.debug_struct("MemorySchemaCache")
236            .field("policy", &self.policy)
237            .finish_non_exhaustive()
238    }
239}
240
241impl MemorySchemaCache {
242    /// A cache under `policy`.
243    #[must_use]
244    pub fn new(policy: SchemaCachePolicy) -> Self {
245        Self {
246            policy,
247            by_id: Mutex::new(HashMap::new()),
248            id_order: Mutex::new(VecDeque::new()),
249            by_subject: Mutex::new(HashMap::new()),
250            on_evict: Mutex::new(Vec::new()),
251        }
252    }
253
254    /// The policy this cache runs under.
255    #[must_use]
256    pub fn policy(&self) -> SchemaCachePolicy {
257        self.policy
258    }
259
260    /// The bound on id-keyed entries, or `None` when nothing is remembered at all.
261    fn capacity(&self) -> Option<NonZeroUsize> {
262        match self.policy {
263            SchemaCachePolicy::Disabled => None,
264            SchemaCachePolicy::Cached { capacity, .. } => Some(capacity),
265        }
266    }
267}
268
269impl SchemaCache for MemorySchemaCache {
270    fn schema(&self, id: u32) -> Option<Arc<RegisteredSchema>> {
271        self.by_id
272            .lock()
273            .expect("schema cache mutex poisoned")
274            .get(&id)
275            .cloned()
276    }
277
278    fn subject(&self, subject: &str) -> Option<Arc<RegisteredSchema>> {
279        let ttl = match self.policy {
280            SchemaCachePolicy::Disabled => return None,
281            SchemaCachePolicy::Cached { subject_ttl, .. } => subject_ttl,
282        };
283        // A subject's latest version moves, so an entry past its time is not an answer. The id it
284        // named stays valid for ever and stays in its own map; what expired is the claim that
285        // this subject still points at it. The guard is released before the id map is taken.
286        let by_subject = self
287            .by_subject
288            .lock()
289            .expect("subject cache mutex poisoned");
290        let fresh = by_subject.get(subject).and_then(|entry| {
291            ttl.is_none_or(|ttl| entry.resolved.elapsed() < ttl)
292                .then_some(entry.id)
293        });
294        drop(by_subject);
295        self.schema(fresh?)
296    }
297
298    fn store(&self, subject: Option<&str>, schema: &Arc<RegisteredSchema>) {
299        let Some(capacity) = self.capacity() else {
300            return;
301        };
302        // The two id maps are taken together and released before the subject map, so no two of
303        // these locks are ever held at once.
304        let mut by_id = self.by_id.lock().expect("schema cache mutex poisoned");
305        let mut order = self
306            .id_order
307            .lock()
308            .expect("schema cache order mutex poisoned");
309        if by_id.insert(schema.id(), Arc::clone(schema)).is_none() {
310            order.push_back(schema.id());
311        }
312        let mut evicted = Vec::new();
313        while order.len() > capacity.get() {
314            let Some(oldest) = order.pop_front() else {
315                break;
316            };
317            by_id.remove(&oldest);
318            evicted.push(oldest);
319        }
320        drop(order);
321        drop(by_id);
322        if !evicted.is_empty() {
323            self.on_evict
324                .lock()
325                .expect("evicted list mutex poisoned")
326                .extend(evicted);
327        }
328        if let Some(subject) = subject {
329            self.by_subject
330                .lock()
331                .expect("subject cache mutex poisoned")
332                .insert(
333                    subject.to_owned(),
334                    SubjectEntry {
335                        id: schema.id(),
336                        resolved: Instant::now(),
337                    },
338                );
339        }
340    }
341
342    fn evicted_ids(&self) -> Vec<u32> {
343        std::mem::take(&mut *self.on_evict.lock().expect("evicted list mutex poisoned"))
344    }
345}