use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
pub const MANIFEST_SCHEMA_VERSION: u32 = 1;
pub const COHERENCE_MANIFEST_MARKER: &str = "[autumn:cache-coherence] ";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReadKind {
Cached,
Fragment,
ReadThrough,
}
impl ReadKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cached => "cached",
Self::Fragment => "fragment",
Self::ReadThrough => "read_through",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DependencyProvenance {
Declared,
Derived,
Undetermined,
}
impl DependencyProvenance {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Declared => "declared",
Self::Derived => "derived",
Self::Undetermined => "undetermined",
}
}
}
pub struct CachedReadDescriptor {
pub id: &'static str,
pub kind: ReadKind,
pub reads: &'static [fn() -> &'static str],
pub provenance: DependencyProvenance,
pub acknowledged_stale: Option<&'static str>,
pub location: &'static str,
}
inventory::collect!(CachedReadDescriptor);
pub struct MutationDescriptor {
pub repository: &'static str,
pub method: &'static str,
pub model: fn() -> &'static str,
pub table: &'static str,
pub invalidates: &'static [&'static str],
pub acknowledged_stale: Option<&'static str>,
pub location: &'static str,
pub cascades_into: Option<(&'static str, bool)>,
}
inventory::collect!(MutationDescriptor);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachedRead {
pub id: String,
pub kind: ReadKind,
pub reads: Vec<String>,
pub provenance: DependencyProvenance,
pub acknowledged_stale: Option<String>,
pub location: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mutation {
pub repository: String,
pub method: String,
pub model: String,
pub table: String,
pub invalidates: Vec<String>,
pub acknowledged_stale: Option<String>,
pub location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cascades_into: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub cascade_recurses: bool,
}
impl Mutation {
#[must_use]
pub fn qualified_name(&self) -> String {
format!("{}::{}", self.repository, self.method)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StalenessFinding {
pub read: String,
pub read_location: String,
pub mutation: String,
pub mutation_location: String,
pub model: String,
}
#[must_use]
pub fn model_key(name: &str) -> &str {
let name = normalize_model_path(name);
name.rsplit("::").next().unwrap_or(name).trim()
}
fn normalize_model_path(name: &str) -> &str {
let name = name.trim().trim_start_matches(['&', '*']).trim();
let name = name.strip_prefix("mut ").unwrap_or(name).trim();
name.split_once('<')
.map_or(name, |(head, _)| head)
.trim_end()
}
#[must_use]
pub fn models_match(a: &str, b: &str) -> bool {
let (a, b) = (normalize_model_path(a), normalize_model_path(b));
if a.contains("::") && b.contains("::") {
return a == b;
}
model_key(a) == model_key(b)
}
#[must_use]
pub fn close_cascades(mutations: &[Mutation]) -> Vec<Mutation> {
let mut closed = mutations.to_vec();
let key = |m: &Mutation, model: &str| {
(
m.repository.clone(),
m.method.clone(),
model.to_string(),
m.location.clone(),
)
};
let mut seen: std::collections::HashSet<(String, String, String, String)> =
mutations.iter().map(|m| key(m, &m.model)).collect();
let mut by_repo: std::collections::HashMap<&str, Vec<&Mutation>> =
std::collections::HashMap::new();
for m in mutations.iter().filter(|m| m.cascades_into.is_some()) {
by_repo.entry(m.repository.as_str()).or_default().push(m);
}
for root in mutations.iter().filter(|m| m.cascades_into.is_some()) {
let mut visited = std::collections::HashSet::new();
let mut queue: Vec<&str> = root
.cascades_into
.as_deref()
.filter(|_| root.cascade_recurses)
.into_iter()
.collect();
while let Some(repo) = queue.pop() {
if !visited.insert(repo) {
continue;
}
for hop in by_repo.get(repo).into_iter().flatten() {
if hop.cascade_recurses {
queue.extend(hop.cascades_into.as_deref());
}
if seen.insert(key(root, &hop.model)) {
closed.push(Mutation {
model: hop.model.clone(),
table: String::new(),
cascades_into: None,
..root.clone()
});
}
}
}
}
closed
}
#[must_use]
pub fn check(reads: &[CachedRead], mutations: &[Mutation]) -> Vec<StalenessFinding> {
let mut findings = Vec::new();
for read in reads {
if read.provenance == DependencyProvenance::Undetermined
|| read.acknowledged_stale.is_some()
{
continue;
}
for mutation in mutations {
if mutation.acknowledged_stale.is_some() {
continue;
}
if !read
.reads
.iter()
.any(|dep| models_match(dep, &mutation.model))
{
continue;
}
if mutation.invalidates.iter().any(|id| id == &read.id) {
continue;
}
findings.push(StalenessFinding {
read: read.id.clone(),
read_location: read.location.clone(),
mutation: mutation.qualified_name(),
mutation_location: mutation.location.clone(),
model: mutation.model.clone(),
});
}
}
findings
.sort_by(|a, b| (&a.read, &a.mutation, &a.model).cmp(&(&b.read, &b.mutation, &b.model)));
findings
}
#[must_use]
pub fn duplicate_read_ids(reads: &[CachedRead]) -> Vec<String> {
let mut ids: Vec<&str> = reads.iter().map(|r| r.id.as_str()).collect();
ids.sort_unstable();
let mut duplicates: Vec<String> = ids
.windows(2)
.filter(|pair| pair[0] == pair[1])
.map(|pair| pair[0].to_string())
.collect();
duplicates.dedup();
duplicates
}
#[must_use]
pub fn undetermined_reads(reads: &[CachedRead]) -> Vec<&CachedRead> {
reads
.iter()
.filter(|r| {
r.provenance == DependencyProvenance::Undetermined && r.acknowledged_stale.is_none()
})
.collect()
}
#[must_use]
pub const fn gate_failed(manifest: &CoherenceManifest, strict: bool) -> bool {
!manifest.violations.is_empty() || (strict && !manifest.undetermined_reads.is_empty())
}
#[must_use]
pub fn audit_exit_code(manifest: &CoherenceManifest, strict: bool) -> i32 {
i32::from(gate_failed(manifest, strict))
}
#[must_use]
pub fn format_diagnostic(findings: &[StalenessFinding]) -> String {
if findings.is_empty() {
return String::new();
}
let mut groups: Vec<(&StalenessFinding, Vec<&StalenessFinding>)> = Vec::new();
for finding in findings {
match groups
.iter_mut()
.find(|(head, _)| head.read == finding.read && head.model == finding.model)
{
Some((_, members)) => members.push(finding),
None => groups.push((finding, vec![finding])),
}
}
let stale_reads: usize = {
let mut reads: Vec<&str> = groups.iter().map(|(head, _)| head.read.as_str()).collect();
reads.sort_unstable();
reads.dedup();
reads.len()
};
let mut out = format!(
"error: {stale_reads} cached read{} can be left stale by a repository write\n\n",
if stale_reads == 1 { "" } else { "s" }
);
for (head, members) in &groups {
let writes: Vec<&str> = members.iter().map(|f| f.mutation.as_str()).collect();
let mut sites: Vec<&str> = members
.iter()
.map(|f| f.mutation_location.as_str())
.collect();
sites.dedup();
let _ = writeln!(
out,
" the cached read {read} is derived from {model},\n \
which {count} repository write{plural} mutate{verb} without invalidating it:\n \
{writes}\n \
read at {read_loc}\n \
written at {sites}\n \
fix: add invalidates({read}) to the repository, or\n \
acknowledge the staleness with #[acknowledge_stale(reason = \"…\")].\n",
read = head.read,
model = head.model,
count = writes.len(),
plural = if writes.len() == 1 { "" } else { "s" },
verb = if writes.len() == 1 { "s" } else { "" },
writes = writes.join(", "),
read_loc = head.read_location,
sites = sites.join(", "),
);
}
out
}
#[must_use]
pub fn format_summary(reads: &[CachedRead], mutations: &[Mutation]) -> String {
CoherenceManifest::build(reads, mutations).summary()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dimension<T> {
pub provenance: String,
pub source: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub runtime_caveat: String,
pub entries: Vec<T>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestRead {
pub id: String,
pub kind: String,
pub reads: Vec<String>,
pub provenance: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub acknowledged_stale: Option<String>,
pub location: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestMutation {
pub name: String,
pub model: String,
pub table: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub acknowledged_stale: Option<String>,
pub location: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestInvalidation {
pub mutation: String,
pub read: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UndeterminedRead {
pub id: String,
pub location: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExcludedDimension {
pub dimension: String,
pub eventual_provenance: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dimensions {
pub cached_reads: Dimension<ManifestRead>,
pub mutations: Dimension<ManifestMutation>,
pub invalidations: Dimension<ManifestInvalidation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoherenceManifest {
pub schema_version: u32,
pub dimensions: Dimensions,
pub violations: Vec<StalenessFinding>,
pub undetermined_reads: Vec<UndeterminedRead>,
#[serde(default)]
pub duplicate_read_ids: Vec<String>,
pub excluded: Vec<ExcludedDimension>,
}
impl CoherenceManifest {
#[must_use]
pub fn build(reads: &[CachedRead], mutations: &[Mutation]) -> Self {
let mutations = &close_cascades(mutations);
Self {
schema_version: MANIFEST_SCHEMA_VERSION,
dimensions: Dimensions {
cached_reads: cached_reads_dimension(reads),
mutations: mutations_dimension(mutations),
invalidations: invalidations_dimension(mutations),
},
violations: check(reads, mutations),
undetermined_reads: undetermined_dimension(reads),
duplicate_read_ids: duplicate_read_ids(reads),
excluded: excluded_dimensions(),
}
}
#[must_use]
pub fn summary(&self) -> String {
let reads = &self.dimensions.cached_reads.entries;
let count = |class: &str| reads.iter().filter(|e| e.provenance == class).count();
let declared = count("declared");
let derived = count("derived");
let undetermined = reads.len() - declared - derived;
let acknowledged = reads
.iter()
.filter(|e| e.acknowledged_stale.is_some())
.count()
+ self
.dimensions
.mutations
.entries
.iter()
.filter(|e| e.acknowledged_stale.is_some())
.count();
format!(
"{} cached reads ({declared} declared, {derived} derived, {undetermined} \
undetermined), {} repository mutations, {acknowledged} acknowledged-stale",
reads.len(),
self.dimensions.mutations.entries.len(),
)
}
#[must_use]
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("cache-coherence manifest is always serializable")
}
}
fn cached_reads_dimension(reads: &[CachedRead]) -> Dimension<ManifestRead> {
let mut entries: Vec<ManifestRead> = reads
.iter()
.map(|r| {
let mut models: Vec<String> = r.reads.clone();
models.sort();
models.dedup();
ManifestRead {
id: r.id.clone(),
kind: r.kind.as_str().to_string(),
reads: models,
provenance: r.provenance.as_str().to_string(),
acknowledged_stale: r.acknowledged_stale.clone(),
location: r.location.clone(),
}
})
.collect();
entries.sort_by(|a, b| (&a.id, &a.location).cmp(&(&b.id, &b.location)));
Dimension {
provenance: "provable".to_string(),
source: "macro:#[cached] / declare_cached_read!".to_string(),
runtime_caveat: "each entry's EXISTENCE is proven — a `#[cached]` function registers \
itself — but its dependency set is only as strong as the entry's own \
`provenance` field says: `declared` is trusted verbatim (a `reads(...)` \
naming the wrong model audits clean), `derived` is what a syntactic \
analysis of the function could see, and `undetermined` is nothing at \
all. A `declare_cached_read!` entry is hand-written throughout, \
including the fact that the call site exists; an undeclared \
`cache_fragment` / `get_or_compute` call is invisible here — see the \
undeclared_cache_call_sites entry in `excluded`."
.to_string(),
entries,
}
}
fn mutations_dimension(mutations: &[Mutation]) -> Dimension<ManifestMutation> {
let mut entries: Vec<ManifestMutation> = mutations
.iter()
.map(|m| ManifestMutation {
name: m.qualified_name(),
model: m.model.clone(),
table: m.table.clone(),
acknowledged_stale: m.acknowledged_stale.clone(),
location: m.location.clone(),
})
.collect();
entries.sort_by(|a, b| {
(&a.name, &a.model, &a.table, &a.location).cmp(&(&b.name, &b.model, &b.table, &b.location))
});
Dimension {
provenance: "provable".to_string(),
source: "macro:#[repository]".to_string(),
runtime_caveat: "every entry is proven, but the SET is not exhaustive: only \
`#[repository]` write methods are here. Nothing proves an app's writes \
all go through one — see the writes_outside_repository entry in \
`excluded` — and a write that reaches another model's table through a \
`counter_cache` is registered under its own model, not the counter's."
.to_string(),
entries,
}
}
fn invalidations_dimension(mutations: &[Mutation]) -> Dimension<ManifestInvalidation> {
let mut entries: Vec<ManifestInvalidation> = mutations
.iter()
.flat_map(|m| {
m.invalidates.iter().map(move |read| ManifestInvalidation {
mutation: m.qualified_name(),
read: read.clone(),
})
})
.collect();
entries.sort_by(|a, b| (&a.mutation, &a.read).cmp(&(&b.mutation, &b.read)));
Dimension {
provenance: "provable".to_string(),
source: "macro:#[repository(..., invalidates(...))]".to_string(),
runtime_caveat: "the edge's target is proven — `invalidates(path)` resolves to the \
`#[cached]` function's own generated id constant, so rustc rejects a \
path that names anything else. Each generated write method calls the \
invalidator after its own transaction commits (#3056), and a durable \
commit-hook row retries it when `commit_hooks` is on. Not proven: that \
the backend sweep succeeds (a failure is logged and counted in \
`autumn_cache_invalidation_failures_total`). Also not proven on a \
backend with no shared fill epoch: that no fill on another replica \
writes an old value back. `RedisCache` has a shared epoch. See \
docs/guide/cache-coherence.md."
.to_string(),
entries,
}
}
fn undetermined_dimension(reads: &[CachedRead]) -> Vec<UndeterminedRead> {
let mut entries: Vec<UndeterminedRead> = undetermined_reads(reads)
.into_iter()
.map(|r| UndeterminedRead {
id: r.id.clone(),
location: r.location.clone(),
})
.collect();
entries.sort_by(|a, b| (&a.id, &a.location).cmp(&(&b.id, &b.location)));
entries
}
fn excluded_dimensions() -> Vec<ExcludedDimension> {
vec![
ExcludedDimension {
dimension: "row_and_column_scoped_dependencies".to_string(),
eventual_provenance: "provable".to_string(),
reason: "model/table granularity is what this slice proves; a read derived from one \
column of one row is currently treated as depending on the whole model, \
which over-approximates (a false failure, never a false pass)"
.to_string(),
},
ExcludedDimension {
dimension: "invalidation_call_sites".to_string(),
eventual_provenance: "provable".to_string(),
reason: "generated write methods call the declared invalidator after they commit \
(#3056); what that does not prove is carried as the invalidations \
dimension's runtime_caveat"
.to_string(),
},
ExcludedDimension {
dimension: "writes_outside_repository".to_string(),
eventual_provenance: "provable".to_string(),
reason: "only `#[repository]` write methods are in the mutated set. A hand-rolled \
repository, a raw diesel `update`/`insert`/`delete` against the handle, a \
migration, or a job that writes directly is invisible here, so a cached \
read those dirty audits clean. Declare such a write's effect by routing it \
through a `#[repository]`, or acknowledge the read"
.to_string(),
},
ExcludedDimension {
dimension: "undeclared_cache_call_sites".to_string(),
eventual_provenance: "provable".to_string(),
reason: "`cache_fragment` and `get_or_compute` are plain function calls with a \
runtime key — there is no annotated item for a macro to find, so a call \
site that does not opt in with `declare_cached_read!` is not in this \
manifest at all. A clean audit says nothing about it. Opting in is also \
not enough on its own: the call site must key its entries under the \
declared id, or `invalidate_namespace` matches nothing and reports \
success. `cache_fragment_in` and `make_cache_key` are the namespaced \
forms; the bare `cache_fragment` keys under `fragment:` and is not \
reachable by a namespace sweep"
.to_string(),
},
ExcludedDimension {
dimension: "counter_cache_and_cascade_writes".to_string(),
eventual_provenance: "provable".to_string(),
reason: "both are declared on the MODEL, which `#[repository]` cannot see. A \
`#[belongs_to(Parent, counter_cache)]` column means a child's write \
updates the parent's counter, registered under the child's model only; a \
`#[has_many(Child, dependent = destroy)]` means the parent's delete writes \
the child's table with no descriptor for it. A cascade declared on the \
REPOSITORY — `#[repository(..., dependent(...))]` — IS registered against \
the child model it deletes or nullifies. So is each model further \
down a chain of `destroy` cascades. The audit links the chain by \
repository trait name. `on_delete = restrict` is excluded because it \
only probes and never writes"
.to_string(),
},
ExcludedDimension {
dimension: "http_response_cache".to_string(),
eventual_provenance: "provable".to_string(),
reason: "`CacheResponseLayer` caches whole HTTP responses keyed by URI, with no \
annotated item naming what the response was derived from. It has no \
ReadKind and no declaration form in this slice; a response cache over \
mutable data is not covered by this gate"
.to_string(),
},
ExcludedDimension {
dimension: "cross_service_coherence".to_string(),
eventual_provenance: "runtime-only".to_string(),
reason: "a second service's writes are not in this binary's dependency graph; \
single-app scope by design"
.to_string(),
},
ExcludedDimension {
dimension: "ttl_expiry".to_string(),
eventual_provenance: "declared".to_string(),
reason: "time-based expiry is orthogonal to derived-data coherence: a TTL bounds how \
long a value stays stale, it does not stop it becoming stale"
.to_string(),
},
]
}
#[macro_export]
macro_rules! declare_cached_read {
(
id = $id:expr,
kind = $kind:ident,
reads = [$first:path $(, $rest:path)* $(,)?] $(,)?
) => {
$crate::declare_cached_read!(
id = $id,
kind = $kind,
reads = [$first $(, $rest)*],
acknowledged_stale = ::core::option::Option::None,
);
};
(
id = $id:expr,
kind = $kind:ident,
reads = [$first:path $(, $rest:path)* $(,)?],
acknowledge_stale = $reason:literal $(,)?
) => {
const _: () = assert!(
!$crate::cache::coherence::reason_is_blank($reason),
"acknowledge_stale requires a non-blank reason",
);
$crate::declare_cached_read!(
id = $id,
kind = $kind,
reads = [$first $(, $rest)*],
acknowledged_stale = ::core::option::Option::Some($reason),
);
};
(
id = $id:expr,
kind = $kind:ident,
reads = [$($model:path),+ $(,)?],
acknowledged_stale = $ack:expr $(,)?
) => {
$crate::reexports::inventory::submit! {
$crate::cache::coherence::CachedReadDescriptor {
id: $id,
kind: $crate::cache::coherence::ReadKind::$kind,
reads: &[$(|| ::core::any::type_name::<$model>()),*],
provenance: $crate::cache::coherence::DependencyProvenance::Declared,
acknowledged_stale: $ack,
location: concat!(file!(), ":", line!()),
}
}
};
}
const fn decode_utf8(bytes: &[u8], i: usize) -> (u32, usize) {
let first = bytes[i];
if first < 0x80 {
(first as u32, 1)
} else if first < 0xE0 {
(
(((first as u32) & 0x1F) << 6) | ((bytes[i + 1] as u32) & 0x3F),
2,
)
} else if first < 0xF0 {
(
(((first as u32) & 0x0F) << 12)
| (((bytes[i + 1] as u32) & 0x3F) << 6)
| ((bytes[i + 2] as u32) & 0x3F),
3,
)
} else {
(
(((first as u32) & 0x07) << 18)
| (((bytes[i + 1] as u32) & 0x3F) << 12)
| (((bytes[i + 2] as u32) & 0x3F) << 6)
| ((bytes[i + 3] as u32) & 0x3F),
4,
)
}
}
#[must_use]
pub const fn reason_is_blank(reason: &str) -> bool {
let bytes = reason.as_bytes();
let mut i = 0;
while i < bytes.len() {
let (code_point, width) = decode_utf8(bytes, i);
match char::from_u32(code_point) {
Some(c) if c.is_whitespace() => {}
_ => return false,
}
i += width;
}
true
}
#[derive(Default)]
struct Namespace {
stores: Vec<std::sync::Arc<dyn super::Cache>>,
epoch: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
static NAMESPACES: std::sync::RwLock<Option<std::collections::HashMap<String, Namespace>>> =
std::sync::RwLock::new(None);
pub fn register_namespace_store(
namespace: &'static str,
store: std::sync::Arc<dyn super::Cache>,
) -> std::sync::Arc<std::sync::atomic::AtomicU64> {
let mut guard = NAMESPACES
.write()
.expect("cache namespace store lock poisoned");
let entry = guard
.get_or_insert_with(std::collections::HashMap::new)
.entry(namespace.to_string())
.or_default();
entry.stores.push(store);
let epoch = std::sync::Arc::clone(&entry.epoch);
drop(guard);
epoch
}
#[must_use]
pub fn namespace_epoch(namespace: &str) -> std::sync::Arc<std::sync::atomic::AtomicU64> {
{
let guard = NAMESPACES
.read()
.expect("cache namespace store lock poisoned");
if let Some(epoch) = guard
.as_ref()
.and_then(|map| map.get(namespace))
.map(|entry| std::sync::Arc::clone(&entry.epoch))
{
return epoch;
}
}
let mut guard = NAMESPACES
.write()
.expect("cache namespace store lock poisoned");
let entry = guard
.get_or_insert_with(std::collections::HashMap::new)
.entry(namespace.to_string())
.or_default();
let epoch = std::sync::Arc::clone(&entry.epoch);
drop(guard);
epoch
}
#[must_use]
pub fn fill_is_still_current(epoch: &std::sync::atomic::AtomicU64, sampled: u64) -> bool {
epoch.load(std::sync::atomic::Ordering::Acquire) == sampled
}
static FILL_FENCE: std::sync::RwLock<()> = std::sync::RwLock::new(());
pub fn with_fill_fence<R>(
epoch: &std::sync::atomic::AtomicU64,
sampled: u64,
insert: impl FnOnce() -> R,
) -> Option<R> {
let guard = FILL_FENCE
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let result = fill_is_still_current(epoch, sampled).then(insert);
drop(guard);
result
}
#[must_use = "a `false` means stale entries can still be served"]
pub fn invalidate_namespace(namespace: &str) -> bool {
if let Some(answer) = super::replayed_namespace_removal(namespace) {
return answer;
}
let removal = super::NamespaceRemoval::reserve();
let mut complete = true;
for store in fence_and_collect_stores(namespace) {
let swept = store.invalidate_namespace(namespace);
store.clear();
complete &= registered_store_complete(&*store, swept, namespace);
}
let global_failure = super::global_cache().and_then(|global| {
if super::NamespaceRemoval::call_installed(|| global.invalidate_namespace(namespace)) {
return None;
}
let reason = "the backend cannot drop a namespace, or its sweep failed";
report_incomplete_invalidation(namespace, reason);
Some(reason.to_owned())
});
finish_removal(removal, namespace, complete, global_failure)
}
fn finish_removal(
removal: super::NamespaceRemoval,
namespace: &str,
stores_complete: bool,
global_failure: Option<String>,
) -> bool {
let failure = global_failure.or_else(|| {
(!stores_complete).then(|| "a registered store could not drop the namespace".to_owned())
});
let complete = failure.is_none();
removal.record(namespace, failure.as_deref());
complete
}
fn registered_store_complete(store: &dyn super::Cache, swept: bool, namespace: &str) -> bool {
if swept || !store.shares_fill_epoch() {
return true;
}
report_incomplete_invalidation(
namespace,
"a registered shared store could not raise its fill epoch",
);
false
}
#[must_use = "a `false` means stale entries can still be served"]
pub async fn invalidate_namespace_async(namespace: &str) -> bool {
if let Some(answer) = super::replayed_namespace_removal(namespace) {
return answer;
}
let removal = super::NamespaceRemoval::reserve();
let stores = if let Some(stores) = try_fence_and_collect_stores(namespace) {
stores
} else {
let owned = namespace.to_owned();
match crate::time::spawn_blocking(move || fence_and_collect_stores(&owned)).await {
Ok(stores) => stores,
Err(join) => match join.try_into_panic() {
Ok(panic) => std::panic::resume_unwind(panic),
Err(_cancelled) => {
let reason = "the runtime is shutting down";
report_incomplete_invalidation(namespace, reason);
return finish_removal(removal, namespace, false, Some(reason.to_owned()));
}
},
}
};
let mut complete = true;
for store in stores {
let swept = store.invalidate_namespace_async(namespace).await.is_ok();
store.clear();
complete &= registered_store_complete(&*store, swept, namespace);
}
let mut global_failure = None;
if let Some(global) = super::global_cache() {
let sweep = super::NamespaceRemoval::call_installed(|| {
global.invalidate_namespace_async(namespace)
});
if let Err(error) = sweep.await {
report_incomplete_invalidation(namespace, error.reason());
global_failure = Some(error.reason().to_owned());
}
}
finish_removal(removal, namespace, complete, global_failure)
}
#[doc(hidden)]
#[must_use = "call `run().await` after the write body"]
pub struct InvalidateAfterWrite {
make: fn() -> super::CacheFuture<'static, bool>,
pending: bool,
}
impl InvalidateAfterWrite {
pub const fn new(make: fn() -> super::CacheFuture<'static, bool>) -> Self {
Self {
make,
pending: true,
}
}
pub const fn committed(&mut self) {
self.pending = true;
}
pub async fn flush(&mut self) -> bool {
if !self.pending {
return true;
}
self.pending = false;
let Ok(handle) = tokio::runtime::Handle::try_current() else {
report_skipped_invalidation("the write ran outside a Tokio runtime");
return false;
};
#[cfg(feature = "reporting")]
let invalidation = crate::capsule::boundary::carry_scopes((self.make)());
#[cfg(not(feature = "reporting"))]
let invalidation = (self.make)();
#[cfg(feature = "reporting")]
let detached = crate::capsule::boundary::DetachGuard::arm();
let complete = handle.spawn(invalidation).await.unwrap_or_else(|_| {
report_skipped_invalidation("the invalidation task panicked or was cancelled");
false
});
#[cfg(feature = "reporting")]
detached.disarm();
complete
}
pub async fn run(mut self) -> bool {
self.flush().await
}
pub fn disarm(mut self) {
self.pending = false;
}
}
impl Drop for InvalidateAfterWrite {
fn drop(&mut self) {
if !self.pending {
return;
}
self.pending = false;
if let Ok(handle) = tokio::runtime::Handle::try_current() {
#[cfg(feature = "reporting")]
let invalidation = {
crate::capsule::boundary::note_detached_work();
crate::capsule::boundary::carry_detached((self.make)())
};
#[cfg(not(feature = "reporting"))]
let invalidation = (self.make)();
handle.spawn(invalidation);
} else {
report_skipped_invalidation("the write ended outside a Tokio runtime");
}
}
}
fn report_skipped_invalidation(reason: &str) {
tracing::warn!(
reason,
"a repository write's declared cache invalidation did not complete; stale entries \
can be served until their TTL"
);
super::record_invalidation_failure();
}
fn report_incomplete_invalidation(namespace: &str, reason: &str) {
tracing::warn!(
namespace,
reason,
"cache namespace invalidation incomplete; stale entries can be served until their TTL"
);
super::record_invalidation_failure();
}
fn fence_and_collect_stores(namespace: &str) -> Vec<std::sync::Arc<dyn super::Cache>> {
let fence = FILL_FENCE
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
bump_and_collect_stores(namespace, fence)
}
fn try_fence_and_collect_stores(namespace: &str) -> Option<Vec<std::sync::Arc<dyn super::Cache>>> {
let fence = match FILL_FENCE.try_write() {
Ok(fence) => fence,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
Some(bump_and_collect_stores(namespace, fence))
}
fn bump_and_collect_stores(
namespace: &str,
fence: std::sync::RwLockWriteGuard<'_, ()>,
) -> Vec<std::sync::Arc<dyn super::Cache>> {
let guard = NAMESPACES
.read()
.expect("cache namespace store lock poisoned");
let stores = guard
.as_ref()
.and_then(|map| map.get(namespace))
.map_or_else(Vec::new, |entry| {
entry
.epoch
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
entry.stores.clone()
});
drop(guard);
drop(fence);
stores
}
#[must_use]
pub fn registered_reads() -> Vec<CachedRead> {
inventory::iter::<CachedReadDescriptor>()
.map(|d| CachedRead {
id: d.id.to_string(),
kind: d.kind,
reads: d.reads.iter().map(|f| f().to_string()).collect(),
provenance: d.provenance,
acknowledged_stale: d.acknowledged_stale.map(ToString::to_string),
location: d.location.to_string(),
})
.collect()
}
#[must_use]
pub fn registered_mutations() -> Vec<Mutation> {
let direct: Vec<Mutation> = inventory::iter::<MutationDescriptor>()
.map(|d| Mutation {
repository: d.repository.to_string(),
method: d.method.to_string(),
model: (d.model)().to_string(),
table: d.table.to_string(),
invalidates: d.invalidates.iter().map(|s| (*s).to_string()).collect(),
acknowledged_stale: d.acknowledged_stale.map(ToString::to_string),
location: d.location.to_string(),
cascades_into: d.cascades_into.map(|(name, _)| name.to_string()),
cascade_recurses: d.cascades_into.is_some_and(|(_, recurses)| recurses),
})
.collect();
close_cascades(&direct)
}
#[must_use]
pub fn audit() -> CoherenceManifest {
CoherenceManifest::build(®istered_reads(), ®istered_mutations())
}
#[must_use]
pub fn is_dump_mode() -> bool {
std::env::var("AUTUMN_DUMP_CACHE_COHERENCE").as_deref() == Ok("1")
}
pub fn print_manifest_dump(manifest: &CoherenceManifest) {
println!(
"{COHERENCE_MANIFEST_MARKER}{}",
serde_json::to_string(manifest).expect("cache-coherence manifest is always serializable")
);
}
#[must_use]
pub fn parse_manifest_dump(stdout: &str) -> Option<CoherenceManifest> {
let line = stdout
.lines()
.find_map(|l| l.strip_prefix(COHERENCE_MANIFEST_MARKER))?;
serde_json::from_str(line).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn read(id: &str, models: &[&str]) -> CachedRead {
CachedRead {
id: id.to_string(),
kind: ReadKind::Cached,
reads: models.iter().map(|m| (*m).to_string()).collect(),
provenance: DependencyProvenance::Declared,
acknowledged_stale: None,
location: "src/views.rs:10".to_string(),
}
}
fn mutation(repo: &str, method: &str, model: &str) -> Mutation {
Mutation {
repository: repo.to_string(),
method: method.to_string(),
model: model.to_string(),
table: "posts".to_string(),
invalidates: Vec::new(),
acknowledged_stale: None,
location: "src/repositories.rs:20".to_string(),
cascades_into: None,
cascade_recurses: false,
}
}
fn cascade(repo: &str, method: &str, model: &str, into: &str) -> Mutation {
Mutation {
cascades_into: Some(into.to_string()),
cascade_recurses: true,
..mutation(repo, method, model)
}
}
fn post_comment_like() -> Vec<Mutation> {
vec![
mutation("PostRepository", "delete_by_id", "app::Post"),
cascade(
"PostRepository",
"delete_by_id",
"app::Comment",
"CommentRepository",
),
mutation("CommentRepository", "delete_by_id", "app::Comment"),
cascade(
"CommentRepository",
"delete_by_id",
"app::Like",
"LikeRepository",
),
mutation("LikeRepository", "delete_by_id", "app::Like"),
]
}
fn models_of(mutations: &[Mutation], repo: &str, method: &str) -> Vec<String> {
let mut models: Vec<String> = mutations
.iter()
.filter(|m| m.repository == repo && m.method == method)
.map(|m| m.model.clone())
.collect();
models.sort();
models
}
#[test]
fn a_nested_cascade_mutates_the_grandchild_model() {
let closed = close_cascades(&post_comment_like());
assert_eq!(
models_of(&closed, "PostRepository", "delete_by_id"),
["app::Comment", "app::Like", "app::Post"]
);
}
#[test]
fn a_grandchild_read_without_a_root_edge_is_flagged() {
let reads = vec![read("app::likes", &["app::Like"])];
let mut muts = post_comment_like();
for m in muts
.iter_mut()
.filter(|m| m.repository == "CommentRepository")
{
m.invalidates = vec!["app::likes".to_string()];
}
let findings = check(&reads, &close_cascades(&muts));
assert!(
findings
.iter()
.any(|f| f.mutation == "PostRepository::delete_by_id"),
"{findings:?}"
);
}
#[test]
fn a_root_edge_covers_the_grandchild_read() {
let reads = vec![read("app::likes", &["app::Like"])];
let mut muts = post_comment_like();
for m in muts.iter_mut().filter(|m| m.repository == "PostRepository") {
m.invalidates = vec!["app::likes".to_string()];
}
let closed = close_cascades(&muts);
let like_writes = closed
.iter()
.filter(|m| m.repository == "PostRepository" && m.model == "app::Like")
.count();
assert_eq!(like_writes, 1, "{closed:?}");
assert!(
check(&reads, &closed)
.iter()
.all(|f| f.mutation != "PostRepository::delete_by_id"),
"{closed:?}"
);
}
#[test]
fn the_root_acknowledgement_carries_to_the_grandchild() {
let mut muts = post_comment_like();
muts[1].acknowledged_stale = Some("reason".to_string());
let closed = close_cascades(&muts);
assert!(
closed
.iter()
.filter(|m| m.repository == "PostRepository" && m.model == "app::Like")
.all(|m| m.acknowledged_stale.is_some())
);
}
#[test]
fn a_restrict_step_adds_no_mutation() {
let mut muts = post_comment_like();
muts.retain(|m| m.cascades_into.as_deref() != Some("LikeRepository"));
let closed = close_cascades(&muts);
assert_eq!(
models_of(&closed, "PostRepository", "delete_by_id"),
["app::Comment", "app::Post"]
);
}
#[test]
fn a_nullify_step_is_written_but_not_followed() {
let mut muts = post_comment_like();
muts[3].cascade_recurses = false;
muts.push(cascade(
"LikeRepository",
"delete_by_id",
"app::Reaction",
"ReactionRepository",
));
let closed = close_cascades(&muts);
assert_eq!(
models_of(&closed, "PostRepository", "delete_by_id"),
["app::Comment", "app::Like", "app::Post"]
);
}
#[test]
fn a_cascade_link_round_trips_through_json() {
let m = cascade(
"PostRepository",
"delete_by_id",
"app::Comment",
"CommentRepository",
);
let back: Mutation = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back, m);
let plain = serde_json::to_string(&mutation("A", "save", "app::A")).unwrap();
assert!(!plain.contains("cascade"), "{plain}");
}
#[test]
fn a_cascade_cycle_terminates() {
let muts = vec![
cascade("ARepository", "delete_by_id", "app::B", "BRepository"),
cascade("BRepository", "delete_by_id", "app::A", "ARepository"),
cascade("CRepository", "delete_by_id", "app::C", "CRepository"),
];
let closed = close_cascades(&muts);
assert_eq!(
models_of(&closed, "ARepository", "delete_by_id"),
["app::A", "app::B"]
);
assert_eq!(
models_of(&closed, "CRepository", "delete_by_id"),
["app::C"]
);
}
#[test]
fn two_same_named_roots_keep_their_own_edges() {
let mut a = post_comment_like();
let mut b = post_comment_like();
for m in &mut b {
m.location = "src/other.rs:1".to_string();
}
a.extend(b);
let like_rows = close_cascades(&a)
.iter()
.filter(|m| m.repository == "PostRepository" && m.model == "app::Like")
.count();
assert_eq!(like_rows, 2);
}
#[test]
fn a_child_may_share_its_parents_trait_name() {
let muts = vec![
cascade(
"CommentRepository",
"delete_by_id",
"app::Comment",
"CommentRepository",
),
Mutation {
location: "src/blog.rs:1".to_string(),
..cascade(
"CommentRepository",
"delete_by_id",
"app::Like",
"LikeRepository",
)
},
];
let closed = close_cascades(&muts);
let root_like = closed
.iter()
.filter(|m| m.model == "app::Like" && m.location == "src/repositories.rs:20")
.count();
assert_eq!(root_like, 1, "{closed:?}");
}
#[test]
fn closing_twice_changes_nothing() {
let once = close_cascades(&post_comment_like());
assert_eq!(close_cascades(&once), once);
}
#[test]
fn the_manifest_lists_the_closed_mutations() {
let manifest = CoherenceManifest::build(&[], &post_comment_like());
assert!(
manifest
.dimensions
.mutations
.entries
.iter()
.any(|m| m.name == "PostRepository::delete_by_id" && m.model == "app::Like")
);
}
#[test]
fn intersecting_model_without_invalidation_is_a_violation() {
let reads = vec![read("blog::views::recent_posts", &["blog::models::Post"])];
let muts = vec![mutation("PostRepository", "save", "blog::models::Post")];
let findings = check(&reads, &muts);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].read, "blog::views::recent_posts");
assert_eq!(findings[0].mutation, "PostRepository::save");
assert_eq!(findings[0].model, "blog::models::Post");
}
#[test]
fn disjoint_models_are_coherent() {
let reads = vec![read("blog::views::recent_posts", &["blog::models::Post"])];
let muts = vec![mutation("TagRepository", "save", "blog::models::Tag")];
assert!(check(&reads, &muts).is_empty());
}
#[test]
fn declared_invalidation_discharges_the_obligation() {
let reads = vec![read("blog::views::recent_posts", &["blog::models::Post"])];
let mut m = mutation("PostRepository", "save", "blog::models::Post");
m.invalidates = vec!["blog::views::recent_posts".to_string()];
assert!(check(&reads, &[m]).is_empty());
}
#[test]
fn invalidation_of_a_different_read_does_not_discharge() {
let reads = vec![read("blog::views::recent_posts", &["blog::models::Post"])];
let mut m = mutation("PostRepository", "save", "blog::models::Post");
m.invalidates = vec!["blog::views::post_count".to_string()];
assert_eq!(check(&reads, &[m]).len(), 1);
}
#[test]
fn acknowledged_stale_read_is_exempt() {
let mut r = read("blog::views::recent_posts", &["blog::models::Post"]);
r.acknowledged_stale = Some("ttl of 5s is short enough".to_string());
let muts = vec![mutation("PostRepository", "save", "blog::models::Post")];
assert!(check(&[r], &muts).is_empty());
}
#[test]
fn acknowledged_stale_mutation_is_exempt() {
let reads = vec![read("blog::views::recent_posts", &["blog::models::Post"])];
let mut m = mutation("PostRepository", "save", "blog::models::Post");
m.acknowledged_stale = Some("import-only path".to_string());
assert!(check(&reads, &[m]).is_empty());
}
#[test]
fn undetermined_reads_never_fail_the_default_gate() {
let mut r = read("blog::views::mystery", &[]);
r.provenance = DependencyProvenance::Undetermined;
let muts = vec![mutation("PostRepository", "save", "blog::models::Post")];
assert!(check(&[r], &muts).is_empty());
}
#[test]
fn undetermined_reads_are_reported_separately() {
let mut r = read("blog::views::mystery", &[]);
r.provenance = DependencyProvenance::Undetermined;
let ok = read("blog::views::recent_posts", &["blog::models::Post"]);
let all = [r, ok];
let undetermined = undetermined_reads(&all);
assert_eq!(undetermined.len(), 1);
assert_eq!(undetermined[0].id, "blog::views::mystery");
}
#[test]
fn an_acknowledged_read_is_not_a_strict_undetermined_failure() {
let mut r = read("blog::views::mystery", &[]);
r.provenance = DependencyProvenance::Undetermined;
r.acknowledged_stale = Some("ttl of 5s is short enough".to_string());
assert!(
undetermined_reads(std::slice::from_ref(&r)).is_empty(),
"an acknowledged read must not be reported as undetermined"
);
let manifest = CoherenceManifest::build(std::slice::from_ref(&r), &[]);
assert!(!gate_failed(&manifest, true), "--strict must stay green");
assert_eq!(
manifest.dimensions.cached_reads.entries[0]
.acknowledged_stale
.as_deref(),
Some("ttl of 5s is short enough")
);
assert!(manifest.summary().contains("1 acknowledged-stale"));
}
#[test]
fn one_read_over_two_models_reports_one_finding_per_mutation() {
let reads = vec![read("blog::views::feed", &["Post", "Comment"])];
let muts = vec![
mutation("PostRepository", "save", "blog::models::Post"),
mutation("CommentRepository", "save", "blog::models::Comment"),
];
assert_eq!(check(&reads, &muts).len(), 2);
}
#[test]
fn a_model_named_twice_by_one_read_still_yields_one_finding() {
let reads = vec![read("blog::views::feed", &["Post", "Post"])];
let muts = vec![mutation("PostRepository", "save", "blog::models::Post")];
assert_eq!(check(&reads, &muts).len(), 1);
}
#[test]
fn findings_are_stable_ordered() {
let reads = vec![read("z::read", &["Post"]), read("a::read", &["Post"])];
let muts = vec![
mutation("PostRepository", "update", "Post"),
mutation("PostRepository", "delete_by_id", "Post"),
];
let findings = check(&reads, &muts);
let keys: Vec<_> = findings
.iter()
.map(|f| format!("{}|{}", f.read, f.mutation))
.collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(
keys, sorted,
"findings must come back deterministically ordered"
);
}
#[test]
fn model_key_ignores_generic_arguments_and_references() {
assert_eq!(model_key("&blog::models::Post"), "Post");
assert_eq!(
model_key("blog::models::Wrapper<blog::models::Post>"),
"Wrapper"
);
assert_eq!(model_key(" Post "), "Post");
assert_eq!(model_key("&mut blog::Post"), "Post");
assert_eq!(model_key("*const blog::Post"), "Post");
}
#[test]
fn a_derived_bare_ident_matches_a_qualified_type_name() {
let reads = vec![read("blog::views::recent_posts", &["Post"])];
let muts = vec![mutation("PostRepository", "save", "blog::models::Post")];
assert_eq!(check(&reads, &muts).len(), 1);
}
#[test]
fn two_qualified_same_named_models_are_kept_apart() {
assert!(!models_match("plugin::models::User", "app::models::User"));
assert!(models_match("app::models::User", "app::models::User"));
let reads = vec![read("app::views::roster", &["app::models::User"])];
let muts = vec![mutation("UserRepository", "save", "plugin::models::User")];
assert!(
check(&reads, &muts).is_empty(),
"a different module's same-named model must not be flagged"
);
}
#[test]
fn a_bare_ident_still_over_approximates_across_modules() {
assert!(models_match("User", "plugin::models::User"));
}
#[test]
fn diagnostic_names_read_mutation_and_shared_model() {
let reads = vec![read("blog::views::recent_posts", &["blog::models::Post"])];
let muts = vec![mutation("PostRepository", "save", "blog::models::Post")];
let text = format_diagnostic(&check(&reads, &muts));
assert!(text.contains("blog::views::recent_posts"), "{text}");
assert!(text.contains("PostRepository::save"), "{text}");
assert!(text.contains("blog::models::Post"), "{text}");
assert!(text.contains("src/views.rs:10"), "{text}");
assert!(text.contains("src/repositories.rs:20"), "{text}");
assert!(text.contains("invalidates("), "must name the fix: {text}");
}
#[test]
fn diagnostic_groups_one_missing_edge_into_one_block() {
let reads = vec![read("blog::views::recent_posts", &["Post"])];
let muts: Vec<Mutation> = [
"save",
"update",
"delete_by_id",
"save_many",
"update_many",
"delete_many",
]
.iter()
.map(|m| mutation("PostRepository", m, "Post"))
.collect();
let findings = check(&reads, &muts);
assert_eq!(findings.len(), 6, "the manifest still carries every pair");
let text = format_diagnostic(&findings);
assert_eq!(
text.matches("fix: add invalidates(").count(),
1,
"one edit, one fix line: {text}"
);
assert!(text.contains("1 cached read can be left stale"), "{text}");
for method in ["save", "update", "delete_by_id", "delete_many"] {
assert!(
text.contains(&format!("PostRepository::{method}")),
"every uncovered write is still named: {text}"
);
}
}
#[test]
fn diagnostic_separates_two_reads_over_the_same_model() {
let reads = vec![read("a::read", &["Post"]), read("b::read", &["Post"])];
let muts = vec![mutation("PostRepository", "save", "Post")];
let text = format_diagnostic(&check(&reads, &muts));
assert_eq!(text.matches("fix: add invalidates(").count(), 2, "{text}");
assert!(text.contains("2 cached reads can be left stale"), "{text}");
}
#[test]
fn diagnostic_is_empty_when_nothing_is_wrong() {
assert!(format_diagnostic(&[]).is_empty());
}
#[test]
fn summary_counts_each_provenance_class_and_every_acknowledgement() {
let mut undetermined = read("u", &[]);
undetermined.provenance = DependencyProvenance::Undetermined;
let mut derived = read("d", &["Post"]);
derived.provenance = DependencyProvenance::Derived;
let mut declared = read("c", &["Post"]);
declared.acknowledged_stale = Some("deliberate".to_string());
let mut m = mutation("PostRepository", "save", "Post");
m.acknowledged_stale = Some("seed only".to_string());
let text = format_summary(&[undetermined, derived, declared], &[m]);
assert!(text.contains("3 cached reads"), "{text}");
assert!(text.contains("1 declared"), "{text}");
assert!(text.contains("1 derived"), "{text}");
assert!(text.contains("1 undetermined"), "{text}");
assert!(text.contains("1 repository mutations"), "{text}");
assert!(text.contains("2 acknowledged-stale"), "{text}");
}
#[test]
fn manifest_is_provenance_tagged_and_stable_ordered() {
let reads = vec![read("z::read", &["Post"]), read("a::read", &["Post"])];
let muts = vec![mutation("PostRepository", "save", "Post")];
let manifest = CoherenceManifest::build(&reads, &muts);
assert_eq!(manifest.schema_version, MANIFEST_SCHEMA_VERSION);
assert_eq!(manifest.dimensions.cached_reads.provenance, "provable");
assert_eq!(manifest.dimensions.mutations.provenance, "provable");
assert_eq!(manifest.dimensions.invalidations.provenance, "provable");
assert!(
!manifest.dimensions.invalidations.runtime_caveat.is_empty(),
"a `declared` dimension must carry its caveat"
);
let ids: Vec<_> = manifest
.dimensions
.cached_reads
.entries
.iter()
.map(|e| e.id.as_str())
.collect();
assert_eq!(ids, vec!["a::read", "z::read"]);
assert_eq!(manifest.violations.len(), 2);
}
#[test]
fn manifest_carries_every_entry_through_json() {
let mut r = read("blog::views::recent_posts", &["blog::models::Post"]);
r.acknowledged_stale = Some("deliberate".to_string());
let mut m = mutation("PostRepository", "save", "blog::models::Post");
m.invalidates = vec!["blog::views::recent_posts".to_string()];
let manifest = CoherenceManifest::build(&[r], &[m]);
let json: serde_json::Value = serde_json::from_str(&manifest.to_json()).unwrap();
assert_eq!(json["schema_version"], MANIFEST_SCHEMA_VERSION);
let entry = &json["dimensions"]["cached_reads"]["entries"][0];
assert_eq!(entry["id"], "blog::views::recent_posts");
assert_eq!(entry["kind"], "cached");
assert_eq!(entry["provenance"], "declared");
assert_eq!(entry["reads"][0], "blog::models::Post");
assert_eq!(entry["acknowledged_stale"], "deliberate");
assert_eq!(entry["location"], "src/views.rs:10");
let write = &json["dimensions"]["mutations"]["entries"][0];
assert_eq!(write["name"], "PostRepository::save");
assert_eq!(write["model"], "blog::models::Post");
assert_eq!(write["table"], "posts");
let edge = &json["dimensions"]["invalidations"]["entries"][0];
assert_eq!(edge["mutation"], "PostRepository::save");
assert_eq!(edge["read"], "blog::views::recent_posts");
}
#[test]
fn manifest_omits_an_absent_runtime_caveat_rather_than_emitting_an_empty_one() {
let json: serde_json::Value =
serde_json::from_str(&CoherenceManifest::build(&[], &[]).to_json()).unwrap();
let bare: Dimension<ManifestRead> = Dimension {
provenance: "provable".to_string(),
source: "test".to_string(),
runtime_caveat: String::new(),
entries: Vec::new(),
};
let bare_json: serde_json::Value = serde_json::to_value(&bare).unwrap();
assert!(bare_json["runtime_caveat"].is_null());
assert!(json["dimensions"]["invalidations"]["runtime_caveat"].is_string());
}
#[test]
fn manifest_undetermined_entries_carry_their_location() {
let mut r = read("blog::views::mystery", &[]);
r.provenance = DependencyProvenance::Undetermined;
let manifest = CoherenceManifest::build(&[r], &[]);
assert_eq!(manifest.undetermined_reads.len(), 1);
assert_eq!(manifest.undetermined_reads[0].id, "blog::views::mystery");
assert_eq!(manifest.undetermined_reads[0].location, "src/views.rs:10");
}
#[test]
fn excluded_dimensions_name_the_holes_this_slice_leaves() {
let named: Vec<&str> = excluded_dimensions()
.iter()
.map(|d| d.dimension.as_str())
.collect::<Vec<_>>()
.iter()
.map(|s| Box::leak(s.to_string().into_boxed_str()) as &str)
.collect();
for expected in [
"row_and_column_scoped_dependencies",
"invalidation_call_sites",
"writes_outside_repository",
"undeclared_cache_call_sites",
"counter_cache_and_cascade_writes",
"http_response_cache",
"cross_service_coherence",
"ttl_expiry",
] {
assert!(named.contains(&expected), "missing {expected} in {named:?}");
}
assert!(
excluded_dimensions().iter().all(|d| !d.reason.is_empty()),
"an exclusion without a reason is not an exclusion"
);
}
#[test]
fn round_trips_through_the_dump_protocol() {
let reads = vec![read("blog::views::recent_posts", &["Post"])];
let muts = vec![mutation("PostRepository", "save", "Post")];
let manifest = CoherenceManifest::build(&reads, &muts);
let dump = format!(
"some unrelated startup logging\n{COHERENCE_MANIFEST_MARKER}{}\n",
serde_json::to_string(&manifest).unwrap()
);
let parsed = parse_manifest_dump(&dump).expect("marker line must be found");
assert_eq!(parsed.violations.len(), 1);
assert_eq!(parsed.summary(), manifest.summary());
}
#[test]
fn a_dump_without_the_marker_is_not_mistaken_for_an_empty_manifest() {
assert!(parse_manifest_dump("no marker here\n").is_none());
}
#[test]
fn a_corrupt_manifest_is_not_mistaken_for_an_empty_one() {
let dump = format!("{COHERENCE_MANIFEST_MARKER}{{\"schema_version\": \n");
assert!(parse_manifest_dump(&dump).is_none());
}
#[test]
fn exit_code_is_nonzero_iff_a_violation_exists() {
let reads = vec![read("r", &["Post"])];
let muts = vec![mutation("PostRepository", "save", "Post")];
assert_eq!(
audit_exit_code(&CoherenceManifest::build(&reads, &muts), false),
1
);
assert_eq!(
audit_exit_code(&CoherenceManifest::build(&[], &[]), false),
0
);
}
#[test]
fn strict_mode_fails_on_undetermined_reads() {
let mut r = read("blog::views::mystery", &[]);
r.provenance = DependencyProvenance::Undetermined;
let manifest = CoherenceManifest::build(std::slice::from_ref(&r), &[]);
assert!(!gate_failed(&manifest, false));
assert!(gate_failed(&manifest, true));
}
#[cfg(all(feature = "cache-moka", feature = "reporting"))]
#[test]
fn a_local_namespace_invalidation_is_on_the_capsule_seam() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
let store = std::sync::Arc::new(super::super::MokaCache::new(16, None));
register_namespace_store("tests::seam_ns", store.clone());
let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
"coherence".to_owned(),
std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
));
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
assert!(rt.block_on(crate::capsule::capture::with_capture_scope(
std::sync::Arc::clone(&scope),
invalidate_namespace_async("tests::seam_ns"),
)));
let recorded = scope.effects_snapshot();
assert_eq!(recorded.cache.len(), 1, "{:?}", recorded.cache);
super::super::insert(&*store, "tests::seam_ns:1", 1_i32);
let tape = std::sync::Arc::new(crate::capsule::ReplayEffects::new(recorded));
assert!(rt.block_on(crate::capsule::with_effect_tape(
std::sync::Arc::clone(&tape),
invalidate_namespace_async("tests::seam_ns"),
)));
assert!(tape.finish().is_empty(), "{:?}", tape.finish());
assert_eq!(
super::super::get::<i32>(&*store, "tests::seam_ns:1"),
Some(1)
);
let empty = std::sync::Arc::new(crate::capsule::ReplayEffects::new(
crate::capsule::CapsuleEffects::default(),
));
let _ = rt.block_on(crate::capsule::with_effect_tape(
std::sync::Arc::clone(&empty),
invalidate_namespace_async("tests::seam_ns"),
));
assert!(!empty.divergences().is_empty());
}
#[cfg(all(feature = "cache-moka", feature = "reporting"))]
#[test]
fn a_failed_registered_store_is_in_the_recorded_namespace_removal() {
struct SharedFailingStore;
impl super::super::Cache for SharedFailingStore {
fn get_value(
&self,
_key: &str,
) -> Option<std::sync::Arc<dyn std::any::Any + Send + Sync>> {
None
}
fn insert_value(
&self,
_key: &str,
_value: std::sync::Arc<dyn std::any::Any + Send + Sync>,
) {
}
fn invalidate(&self, _key: &str) {}
fn clear(&self) {}
fn invalidate_namespace(&self, _namespace: &str) -> bool {
false
}
fn shares_fill_epoch(&self) -> bool {
true
}
}
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::set_global_cache(std::sync::Arc::new(super::super::MokaCache::new(16, None)));
register_namespace_store(
"tests::seam_mixed_ns",
std::sync::Arc::new(SharedFailingStore),
);
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
for sync in [true, false] {
let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
"coherence".to_owned(),
std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
));
let run = || async move {
if sync {
invalidate_namespace("tests::seam_mixed_ns")
} else {
invalidate_namespace_async("tests::seam_mixed_ns").await
}
};
assert!(!rt.block_on(crate::capsule::capture::with_capture_scope(
std::sync::Arc::clone(&scope),
run(),
)));
let recorded = scope.effects_snapshot();
assert_eq!(recorded.cache.len(), 1, "sync={sync}: {:?}", recorded.cache);
let tape = std::sync::Arc::new(crate::capsule::ReplayEffects::new(recorded));
assert!(
!rt.block_on(crate::capsule::with_effect_tape(
std::sync::Arc::clone(&tape),
run(),
)),
"sync={sync}: the replay returns the recorded `false`"
);
assert!(tape.finish().is_empty(), "{:?}", tape.finish());
}
super::super::clear_global_cache();
}
#[cfg(feature = "cache-moka")]
#[test]
fn invalidating_a_namespace_clears_only_that_reads_store() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
let a = std::sync::Arc::new(super::super::MokaCache::new(16, None));
let b = std::sync::Arc::new(super::super::MokaCache::new(16, None));
register_namespace_store("tests::ns_a", a.clone());
register_namespace_store("tests::ns_b", b.clone());
super::super::insert(&*a, "tests::ns_a:1", 1_i32);
super::super::insert(&*b, "tests::ns_b:1", 2_i32);
assert!(invalidate_namespace("tests::ns_a"));
assert_eq!(super::super::get::<i32>(&*a, "tests::ns_a:1"), None);
assert_eq!(super::super::get::<i32>(&*b, "tests::ns_b:1"), Some(2));
}
#[cfg(feature = "cache-moka")]
#[test]
fn invalidating_an_unregistered_namespace_is_complete_and_harmless() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
assert!(invalidate_namespace("tests::never_registered"));
}
#[cfg(feature = "cache-moka")]
#[test]
fn a_shared_backend_is_asked_to_drop_the_namespace_too() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let global = std::sync::Arc::new(super::super::MokaCache::new(64, None));
super::super::set_global_cache(global.clone());
super::super::insert(&*global, "tests::shared_ns:aaa", 1_i32);
super::super::insert(&*global, "tests::shared_ns:bbb", 2_i32);
super::super::insert(&*global, "tests::other_ns:aaa", 3_i32);
assert!(
invalidate_namespace("tests::shared_ns"),
"a backend that can drop a namespace reports a complete invalidation"
);
assert_eq!(
super::super::get::<i32>(&*global, "tests::shared_ns:aaa"),
None
);
assert_eq!(
super::super::get::<i32>(&*global, "tests::shared_ns:bbb"),
None
);
assert_eq!(
super::super::get::<i32>(&*global, "tests::other_ns:aaa"),
Some(3),
"a neighbouring namespace must survive"
);
super::super::clear_global_cache();
}
#[test]
fn a_backend_that_cannot_enumerate_reports_an_incomplete_invalidation() {
struct OpaqueBackend;
impl super::super::Cache for OpaqueBackend {
fn get_value(
&self,
_key: &str,
) -> Option<std::sync::Arc<dyn std::any::Any + Send + Sync>> {
None
}
fn insert_value(
&self,
_key: &str,
_value: std::sync::Arc<dyn std::any::Any + Send + Sync>,
) {
}
fn invalidate(&self, _key: &str) {}
fn clear(&self) {}
}
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::set_global_cache(std::sync::Arc::new(OpaqueBackend));
assert!(!invalidate_namespace("tests::opaque_ns"));
super::super::clear_global_cache();
}
#[test]
fn a_blank_acknowledgement_reason_is_blank_by_the_same_rule_everywhere() {
assert!(reason_is_blank(""));
assert!(reason_is_blank(" "));
assert!(reason_is_blank("\t\n "));
assert!(!reason_is_blank("5s lag is fine"));
assert!(!reason_is_blank(" x "));
}
#[test]
fn unicode_whitespace_is_a_blank_reason_too() {
for blank in [
"\u{2003}",
"\u{00a0}",
"\u{3000}",
"\u{2028}",
" \u{2003}\t",
] {
assert!(
reason_is_blank(blank),
"{blank:?} is whitespace to `trim`, so it must be blank here too"
);
assert!(blank.trim().is_empty(), "the premise: {blank:?}");
}
for reason in ["\u{2003}ok", "日本語", "→ stale is fine"] {
assert!(!reason_is_blank(reason), "{reason:?}");
assert!(!reason.trim().is_empty(), "the premise: {reason:?}");
}
}
#[test]
fn an_identity_claimed_twice_is_reported() {
let reads = vec![
read("app::Stats::count", &["Post"]),
read("app::Stats::count", &["Post"]),
read("app::views::feed", &["Post"]),
];
assert_eq!(duplicate_read_ids(&reads), vec!["app::Stats::count"]);
assert!(
CoherenceManifest::build(&reads, &[])
.duplicate_read_ids
.contains(&"app::Stats::count".to_string())
);
}
#[test]
fn distinct_identities_are_not_reported_as_duplicates() {
let reads = vec![read("a", &["Post"]), read("b", &["Post"])];
assert!(duplicate_read_ids(&reads).is_empty());
assert!(
CoherenceManifest::build(&reads, &[])
.duplicate_read_ids
.is_empty()
);
}
#[cfg(feature = "cache-moka")]
#[test]
fn every_store_sharing_a_namespace_is_cleared() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
let first = std::sync::Arc::new(super::super::MokaCache::new(16, None));
let second = std::sync::Arc::new(super::super::MokaCache::new(16, None));
register_namespace_store("tests::shared_identity", first.clone());
register_namespace_store("tests::shared_identity", second.clone());
super::super::insert(&*first, "tests::shared_identity:1", 1_i32);
super::super::insert(&*second, "tests::shared_identity:2", 2_i32);
assert!(invalidate_namespace("tests::shared_identity"));
assert_eq!(
super::super::get::<i32>(&*first, "tests::shared_identity:1"),
None
);
assert_eq!(
super::super::get::<i32>(&*second, "tests::shared_identity:2"),
None,
"the second registration must not be left stale"
);
}
#[cfg(feature = "cache-moka")]
#[test]
fn an_invalidation_fences_a_fill_that_was_already_in_flight() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
let store = std::sync::Arc::new(super::super::MokaCache::new(16, None));
let epoch = register_namespace_store("tests::fenced_ns", store);
let sampled = epoch.load(std::sync::atomic::Ordering::Acquire);
assert!(fill_is_still_current(&epoch, sampled));
assert!(invalidate_namespace("tests::fenced_ns"));
assert!(
!fill_is_still_current(&epoch, sampled),
"an in-flight fill must be fenced out by the invalidation"
);
let after = epoch.load(std::sync::atomic::Ordering::Acquire);
assert!(fill_is_still_current(&epoch, after));
}
#[test]
fn the_fill_fence_inserts_when_current_and_skips_when_invalidated() {
let epoch = std::sync::atomic::AtomicU64::new(0);
let sampled = epoch.load(std::sync::atomic::Ordering::Acquire);
assert_eq!(
with_fill_fence(&epoch, sampled, || "inserted"),
Some("inserted")
);
epoch.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
assert_eq!(
with_fill_fence(&epoch, sampled, || "inserted"),
None,
"a fill whose epoch moved must not run its insert at all"
);
}
#[cfg(feature = "cache-moka")]
#[test]
fn an_invalidation_cannot_land_between_the_fence_check_and_the_insert() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
let store = std::sync::Arc::new(super::super::MokaCache::new(16, None));
let epoch = register_namespace_store("tests::atomic_fill_ns", store);
let sampled = epoch.load(std::sync::atomic::Ordering::Acquire);
let inserted = with_fill_fence(&epoch, sampled, || {
let racer = std::thread::spawn(|| invalidate_namespace("tests::atomic_fill_ns"));
std::thread::sleep(std::time::Duration::from_millis(100));
let observed = epoch.load(std::sync::atomic::Ordering::Acquire);
(racer, observed)
});
let (racer, observed) = inserted.expect("the fill was current, so it must have run");
assert_eq!(
observed, sampled,
"the epoch moved while the insert was running: the check and the \
insert are not atomic, so this value would land after the clear"
);
assert!(racer.join().expect("invalidator thread panicked"));
assert!(
!fill_is_still_current(&epoch, sampled),
"the invalidation must land once the fill releases the fence"
);
}
#[test]
fn a_backend_whose_sweep_failed_reports_an_incomplete_invalidation() {
struct FailingSweep;
impl super::super::Cache for FailingSweep {
fn get_value(
&self,
_key: &str,
) -> Option<std::sync::Arc<dyn std::any::Any + Send + Sync>> {
None
}
fn insert_value(
&self,
_key: &str,
_value: std::sync::Arc<dyn std::any::Any + Send + Sync>,
) {
}
fn invalidate(&self, _key: &str) {}
fn clear(&self) {}
fn invalidate_namespace(&self, _namespace: &str) -> bool {
false
}
}
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::set_global_cache(std::sync::Arc::new(FailingSweep));
assert!(!invalidate_namespace("tests::failed_sweep_ns"));
super::super::clear_global_cache();
}
struct RefusingBackend;
impl super::super::Cache for RefusingBackend {
fn get_value(&self, _key: &str) -> Option<std::sync::Arc<dyn std::any::Any + Send + Sync>> {
None
}
fn insert_value(
&self,
_key: &str,
_value: std::sync::Arc<dyn std::any::Any + Send + Sync>,
) {
}
fn invalidate(&self, _key: &str) {}
fn clear(&self) {}
fn invalidate_namespace(&self, _namespace: &str) -> bool {
false
}
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(future)
}
#[test]
fn async_invalidation_with_no_backend_is_complete() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
assert!(block_on(invalidate_namespace_async(
"tests::async_no_backend"
)));
}
#[test]
fn async_invalidation_bumps_the_epoch_first() {
let epoch = namespace_epoch("tests::async_epoch");
let before = epoch.load(std::sync::atomic::Ordering::Acquire);
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::clear_global_cache();
assert!(block_on(invalidate_namespace_async("tests::async_epoch")));
assert!(
epoch.load(std::sync::atomic::Ordering::Acquire) > before,
"a fill already in flight must be fenced out"
);
}
#[test]
fn a_failed_async_invalidation_is_reported_and_counted() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::set_global_cache(std::sync::Arc::new(RefusingBackend));
let before = super::super::invalidation_failures_total();
let complete = block_on(invalidate_namespace_async("tests::async_refused"));
let after = super::super::invalidation_failures_total();
super::super::clear_global_cache();
assert!(!complete, "a refused sweep must not report success");
assert!(after > before, "the failure must be counted");
}
#[test]
fn a_failed_sync_invalidation_is_counted_too() {
let _guard = super::super::GLOBAL_CACHE_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
super::super::set_global_cache(std::sync::Arc::new(RefusingBackend));
let before = super::super::invalidation_failures_total();
let complete = invalidate_namespace("tests::sync_refused");
let after = super::super::invalidation_failures_total();
super::super::clear_global_cache();
assert!(!complete);
assert!(after > before, "the failure must be counted");
}
struct DeclaredModelA;
struct DeclaredModelB;
crate::declare_cached_read! {
id = "autumn_web::cache::coherence::tests::declared_fragment",
kind = Fragment,
reads = [DeclaredModelA, DeclaredModelB],
}
crate::declare_cached_read! {
id = "autumn_web::cache::coherence::tests::declared_read_through",
kind = ReadThrough,
reads = [DeclaredModelA],
acknowledge_stale = "the sidebar tolerates a stale count",
}
#[test]
fn a_manually_declared_read_reaches_the_manifest() {
let declared = registered_reads()
.into_iter()
.find(|r| r.id.ends_with("tests::declared_fragment"))
.expect("declare_cached_read! must register the read");
assert_eq!(declared.kind, ReadKind::Fragment);
assert_eq!(declared.provenance, DependencyProvenance::Declared);
assert_eq!(declared.reads.len(), 2);
assert!(
declared
.reads
.iter()
.any(|m| model_key(m) == "DeclaredModelA"),
"{:?}",
declared.reads
);
assert!(declared.location.contains("coherence.rs"));
}
#[test]
fn a_manually_declared_read_can_acknowledge_staleness() {
let declared = registered_reads()
.into_iter()
.find(|r| r.id.ends_with("tests::declared_read_through"))
.expect("declare_cached_read! must register the read");
assert_eq!(declared.kind, ReadKind::ReadThrough);
assert_eq!(
declared.acknowledged_stale.as_deref(),
Some("the sidebar tolerates a stale count")
);
}
#[test]
fn read_kind_spellings_are_stable() {
assert_eq!(ReadKind::Cached.as_str(), "cached");
assert_eq!(ReadKind::Fragment.as_str(), "fragment");
assert_eq!(ReadKind::ReadThrough.as_str(), "read_through");
assert_eq!(DependencyProvenance::Declared.as_str(), "declared");
assert_eq!(DependencyProvenance::Derived.as_str(), "derived");
assert_eq!(DependencyProvenance::Undetermined.as_str(), "undetermined");
}
static GUARD_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn count_guard_run() -> crate::cache::CacheFuture<'static, bool> {
Box::pin(async {
GUARD_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
true
})
}
fn guard_runs() -> usize {
GUARD_RUNS.load(std::sync::atomic::Ordering::SeqCst)
}
static SAW_SCOPE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg(feature = "reporting")]
fn note_scope() -> crate::cache::CacheFuture<'static, bool> {
Box::pin(async {
SAW_SCOPE.store(
crate::capsule::current_scope().is_some(),
std::sync::atomic::Ordering::SeqCst,
);
true
})
}
#[cfg(feature = "reporting")]
#[tokio::test]
async fn a_flushed_invalidation_keeps_the_capture_scope() {
let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
"coherence".to_owned(),
std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
));
crate::capsule::capture::with_capture_scope(std::sync::Arc::clone(&scope), async {
assert!(InvalidateAfterWrite::new(note_scope).run().await);
})
.await;
assert!(SAW_SCOPE.load(std::sync::atomic::Ordering::SeqCst));
assert!(
!scope.is_truncated(),
"an awaited invalidation is not detached"
);
}
#[cfg(feature = "reporting")]
fn never_ends() -> crate::cache::CacheFuture<'static, bool> {
Box::pin(std::future::pending())
}
#[cfg(feature = "reporting")]
#[tokio::test]
async fn a_flush_dropped_while_waiting_marks_the_capsule_incomplete() {
let scope = std::sync::Arc::new(crate::capsule::CaptureScope::new(
"coherence".to_owned(),
std::sync::Arc::new(crate::capsule::CaptureSettings::default()),
std::sync::Arc::new(crate::log::filter::ParameterFilter::default()),
));
crate::capsule::capture::with_capture_scope(std::sync::Arc::clone(&scope), async {
let mut guard = InvalidateAfterWrite::new(never_ends);
let waited =
tokio::time::timeout(std::time::Duration::from_millis(10), guard.flush()).await;
assert!(waited.is_err(), "the invalidation never ends");
})
.await;
assert!(scope.is_truncated(), "the invalidation runs detached");
}
#[cfg(feature = "reporting")]
#[tokio::test]
async fn a_replay_flush_dropped_while_waiting_is_a_divergence() {
let tape = std::sync::Arc::new(crate::capsule::ReplayEffects::new(
crate::capsule::CapsuleEffects::default(),
));
crate::capsule::with_effect_tape(std::sync::Arc::clone(&tape), async {
let mut guard = InvalidateAfterWrite::new(never_ends);
let waited =
tokio::time::timeout(std::time::Duration::from_millis(10), guard.flush()).await;
assert!(waited.is_err(), "the invalidation never ends");
})
.await;
let divergences = tape.divergences();
assert_eq!(divergences.len(), 1, "{divergences:?}");
assert_eq!(
divergences[0].seam,
crate::capsule::effects::EffectSeam::Detached
);
}
#[tokio::test]
async fn the_after_write_guard_invalidates_once_per_commit() {
let before = guard_runs();
let mut guard = InvalidateAfterWrite::new(count_guard_run);
guard.committed();
assert!(guard.flush().await);
assert!(guard.flush().await, "a second flush has nothing to do");
assert!(guard.run().await);
assert_eq!(guard_runs(), before + 1);
let mut guard = InvalidateAfterWrite::new(count_guard_run);
assert!(guard.flush().await);
guard.committed();
assert!(guard.run().await);
assert_eq!(guard_runs(), before + 3);
assert!(InvalidateAfterWrite::new(count_guard_run).run().await);
assert_eq!(guard_runs(), before + 4);
InvalidateAfterWrite::new(count_guard_run).disarm();
tokio::task::yield_now().await;
assert_eq!(guard_runs(), before + 4);
drop(InvalidateAfterWrite::new(count_guard_run));
for _ in 0..100 {
if guard_runs() == before + 5 {
break;
}
tokio::task::yield_now().await;
}
assert_eq!(guard_runs(), before + 5);
let mut guard = InvalidateAfterWrite::new(count_guard_run);
assert!(guard.flush().await);
drop(guard);
tokio::task::yield_now().await;
assert_eq!(guard_runs(), before + 6);
}
}