use apache_avro::Schema;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Debug)]
pub(crate) struct CompiledSchema {
pub(crate) id: u32,
pub(crate) schema: Result<Schema, String>,
}
impl CompiledSchema {
pub(crate) fn compile(id: u32, json: &str) -> Self {
let schema = match std::panic::catch_unwind(|| Schema::parse_str(json)) {
Ok(Ok(schema)) => Ok(schema),
Ok(Err(e)) => Err(format!("schema {id} is not usable: {e}")),
Err(_panic) => Err(format!("schema {id} is not usable: the parser panicked")),
};
CompiledSchema { id, schema }
}
pub(crate) fn unusable_reason(&self) -> Option<String> {
match &self.schema {
Ok(_) => None,
Err(reason) => Some(reason.clone()),
}
}
}
#[derive(Clone, Debug)]
pub(crate) enum Lookup {
Ready(Arc<CompiledSchema>),
Failed(String),
Missing,
}
#[derive(Clone, Debug)]
enum Entry {
Ready(Arc<CompiledSchema>),
Failed {
reason: String,
at: Instant,
},
}
#[derive(Clone, Debug)]
pub(crate) struct CacheSnapshot(Arc<HashMap<u32, Entry>>);
#[derive(Debug)]
pub(crate) struct SchemaCache {
entries: RwLock<Arc<HashMap<u32, Entry>>>,
negative_ttl: Duration,
}
impl SchemaCache {
pub(crate) fn new(negative_ttl: Duration) -> Self {
SchemaCache {
entries: RwLock::new(Arc::new(HashMap::new())),
negative_ttl,
}
}
pub(crate) fn empty_snapshot() -> CacheSnapshot {
CacheSnapshot(Arc::new(HashMap::new()))
}
fn snapshot(&self) -> CacheSnapshot {
CacheSnapshot(Arc::clone(&self.entries.read().expect("schema cache lock")))
}
pub(crate) fn get(&self, id: u32) -> Lookup {
let entries = self.entries.read().expect("schema cache lock");
Self::eval(&entries, id, self.negative_ttl).unwrap_or(Lookup::Missing)
}
pub(crate) fn lookup(&self, memo: &mut CacheSnapshot, id: u32) -> Lookup {
if let Some(ready @ Lookup::Ready(_)) = Self::eval(&memo.0, id, self.negative_ttl) {
return ready;
}
*memo = self.snapshot();
Self::eval(&memo.0, id, self.negative_ttl).unwrap_or(Lookup::Missing)
}
fn eval(map: &HashMap<u32, Entry>, id: u32, ttl: Duration) -> Option<Lookup> {
match map.get(&id) {
Some(Entry::Ready(schema)) => Some(Lookup::Ready(Arc::clone(schema))),
Some(Entry::Failed { reason, at }) => {
if at.elapsed() >= ttl {
None
} else {
Some(Lookup::Failed(reason.clone()))
}
}
None => None,
}
}
pub(crate) fn insert_ready(&self, schema: CompiledSchema) {
let mut guard = self.entries.write().expect("schema cache lock");
let mut map = (**guard).clone();
map.insert(schema.id, Entry::Ready(Arc::new(schema)));
*guard = Arc::new(map);
}
pub(crate) fn insert_failed(&self, id: u32, reason: String) {
let mut guard = self.entries.write().expect("schema cache lock");
if matches!(guard.get(&id), Some(Entry::Ready(_))) {
return;
}
let mut map = (**guard).clone();
map.insert(
id,
Entry::Failed {
reason,
at: Instant::now(),
},
);
*guard = Arc::new(map);
}
}
#[cfg(test)]
mod tests {
use super::*;
const SCHEMA_JSON: &str =
r#"{"type":"record","name":"T","fields":[{"name":"a","type":"long"}]}"#;
const PARSER_PANICS_JSON: &str =
r#"{"type":"record","name":"my-record","fields":[{"name":"a","type":"long"}]}"#;
fn compiled(id: u32) -> CompiledSchema {
CompiledSchema::compile(id, SCHEMA_JSON)
}
#[test]
fn well_formed_schema_compiles() {
let entry = compiled(4);
assert!(entry.schema.is_ok());
assert!(entry.unusable_reason().is_none());
}
#[test]
fn a_parser_panic_becomes_an_ordinary_failure() {
let entry = CompiledSchema::compile(6, PARSER_PANICS_JSON);
assert!(
matches!(&entry.schema, Err(reason) if reason.contains("panicked")),
"{:?}",
entry.schema.as_ref().err()
);
assert!(entry.unusable_reason().is_some());
}
#[test]
fn an_unparseable_schema_is_unusable() {
let entry = CompiledSchema::compile(8, "not a schema");
let reason = entry.unusable_reason().expect("junk JSON is not a schema");
assert!(reason.contains("schema 8 is not usable"), "{reason}");
}
#[test]
fn ready_and_missing() {
let cache = SchemaCache::new(Duration::from_secs(30));
assert!(matches!(cache.get(1), Lookup::Missing));
cache.insert_ready(compiled(1));
assert!(matches!(cache.get(1), Lookup::Ready(s) if s.id == 1));
}
#[test]
fn negative_entries_expire() {
let cache = SchemaCache::new(Duration::ZERO);
cache.insert_failed(9, "unknown id".into());
assert!(matches!(cache.get(9), Lookup::Missing));
let cache = SchemaCache::new(Duration::from_secs(600));
cache.insert_failed(9, "unknown id".into());
assert!(matches!(cache.get(9), Lookup::Failed(r) if r.contains("unknown id")));
}
#[test]
fn ready_is_never_downgraded_to_failed() {
let cache = SchemaCache::new(Duration::from_secs(600));
cache.insert_ready(compiled(5));
cache.insert_failed(5, "registry returned 500".into());
assert!(
matches!(cache.get(5), Lookup::Ready(s) if s.id == 5),
"a late failure must not clobber a cached Ready schema"
);
}
#[test]
fn memo_serves_ready_and_refreshes_on_non_ready() {
let cache = SchemaCache::new(Duration::from_secs(600));
let mut memo = SchemaCache::empty_snapshot();
assert!(matches!(cache.lookup(&mut memo, 1), Lookup::Missing));
cache.insert_ready(compiled(1));
assert!(matches!(cache.lookup(&mut memo, 1), Lookup::Ready(s) if s.id == 1));
assert!(matches!(cache.lookup(&mut memo, 1), Lookup::Ready(_)));
cache.insert_failed(2, "unknown".into());
assert!(matches!(cache.lookup(&mut memo, 2), Lookup::Failed(_)));
cache.insert_ready(compiled(2));
assert!(matches!(cache.lookup(&mut memo, 2), Lookup::Ready(_)));
}
}