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}