LCOV - code coverage report
Current view: top level - antares-sql/src/store/mem - mod.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 98.7 % 1818 1794
Test Date: 2026-11-02 13:21:27 Functions: 64.5 % 822 530

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! In-memory store — v0 storage backend.
       3              : //!
       4              : //! DELIBERATE DEVIATION (recorded in docs/adr/): the target backend is
       5              : //! Postgres; the suite-green loop uses an in-memory backend first. The store
       6              : //! API is shaped like the Postgres store traits (tenant first parameter
       7              : //! everywhere) so the sqlx implementation can land behind the same seam.
       8              : //!
       9              : //! Documents are held in the *internal expanded form* produced by
      10              : //! `antares_jsonld::expand` (IRI keys, instance arrays), with server-managed
      11              : //! timestamps embedded (`createdAt`/`modifiedAt` at entity level and inside
      12              : //! each attribute instance) — output layers strip them unless sysAttrs.
      13              : 
      14              : use ::redb::{Database, Durability, ReadableDatabase, ReadableTableMetadata};
      15              : use antares_model::{NgsiError, TenantId};
      16              : use antares_store::{context_row_owner, context_row_visible, filter, ChangeHook, Kind};
      17              : 
      18              : mod redb;
      19              : use self::redb::{
      20              :     key_bytes, split_key, table_for, Shadow, Table, FORMAT_VERSION, T_ENTITIES, T_JSONLD_CONTEXTS,
      21              :     T_META, T_TEMPORAL_ENTITIES,
      22              : };
      23              : use serde_json::Value;
      24              : use std::collections::{BTreeMap, HashMap};
      25              : use std::path::Path;
      26              : use std::sync::RwLock;
      27              : 
      28              : /// How many `Cached` @context entries the broker keeps (5.13.1). The bound is
      29              : /// on the CACHE only: it exists because one entry is stored per distinct
      30              : /// external @context URL a request references, which is client-controlled,
      31              : /// while the working set of real @contexts a deployment uses is small. Every
      32              : /// entry holds a whole @context body, so the count is the memory bound too.
      33              : pub const MAX_CACHED_CONTEXTS: usize = 1_000;
      34              : 
      35              : /// The `Cached` entry ids to drop so at most `MAX_CACHED_CONTEXTS` remain,
      36              : /// oldest `createdAt` first (5.13.1 stamps every stored entry with one; an
      37              : /// entry without a stamp sorts oldest and goes first). Other kinds are never
      38              : /// candidates.
      39            4 : fn oldest_cached(contexts: &BTreeMap<String, Value>) -> Vec<String> {
      40            4 :     let mut cached: Vec<(&str, &str)> = contexts
      41            4 :         .iter()
      42         4006 :         .filter(|(_, d)| d.get("kind").and_then(Value::as_str) == Some("Cached"))
      43         4002 :         .map(|(id, d)| {
      44         4002 :             (
      45         4002 :                 id.as_str(),
      46         4002 :                 d.get("createdAt").and_then(Value::as_str).unwrap_or(""),
      47         4002 :             )
      48         4002 :         })
      49            4 :         .collect();
      50            4 :     if cached.len() <= MAX_CACHED_CONTEXTS {
      51            2 :         return Vec::new();
      52            2 :     }
      53        20780 :     cached.sort_unstable_by(|(a_id, a_ts), (b_id, b_ts)| (a_ts, a_id).cmp(&(b_ts, b_id)));
      54            2 :     cached
      55            2 :         .iter()
      56            2 :         .take(cached.len().saturating_sub(MAX_CACHED_CONTEXTS))
      57            2 :         .map(|(id, _)| (*id).to_owned())
      58            2 :         .collect()
      59            4 : }
      60              : 
      61              : /// The attribute names of an internal entity doc: every member but the
      62              : /// entity's own (`ENTITY_META_KEYS`), collected so the doc can be changed
      63              : /// while they are walked.
      64         6157 : fn attribute_names(obj: &serde_json::Map<String, Value>) -> Vec<String> {
      65         6157 :     obj.keys()
      66        27159 :         .filter(|k| !antares_model::ENTITY_META_KEYS.contains(&k.as_str()))
      67         6157 :         .cloned()
      68         6157 :         .collect()
      69         6157 : }
      70              : 
      71              : /// Drop attribute instances whose `expiresAt` passed (4.22) from an internal
      72              : /// entity or temporal doc; an attribute whose last instance expired is
      73              : /// removed entirely. Returns whether the doc changed.
      74         6115 : pub(crate) fn prune_expired_instances(doc: &mut Value, now: &str) -> bool {
      75         6115 :     let Some(obj) = doc.as_object_mut() else {
      76            0 :         return false;
      77              :     };
      78         6115 :     let mut changed = false;
      79         6125 :     for k in attribute_names(obj) {
      80         2527 :         let Some(arr) = obj.get_mut(&k).and_then(Value::as_array_mut) else {
      81            0 :             continue;
      82              :         };
      83         2527 :         let before = arr.len();
      84              :         // 4.6.3 leaves the seconds-fraction separator open, so the two
      85              :         // stamps have to be compared as instants and not as bytes — the rule
      86              :         // lives in `filter::expired_at`, which the read boundary uses too.
      87         2895 :         arr.retain(|inst| !filter::expired_at(inst, now));
      88         2527 :         if arr.len() != before {
      89           67 :             changed = true;
      90           67 :             if arr.is_empty() {
      91           44 :                 obj.remove(&k);
      92           44 :             }
      93         2460 :         }
      94              :     }
      95         6115 :     changed
      96         6115 : }
      97              : 
      98              : /// Whether an internal doc's types include `type_iri` (retention).
      99          104 : fn typed(doc: &Value, type_iri: &str) -> bool {
     100          104 :     match doc.get("type") {
     101           92 :         Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some(type_iri)),
     102           12 :         Some(Value::String(t)) => t == type_iri,
     103            0 :         _ => false,
     104              :     }
     105          104 : }
     106              : 
     107              : /// Whether `scope` reaches the temporal document `id`.
     108           76 : fn in_scope(id: &str, doc: &Value, scope: antares_store::HistoryScope<'_>) -> bool {
     109              :     use antares_store::HistoryScope;
     110           76 :     match scope {
     111           16 :         HistoryScope::Entity(one) => id == one,
     112              :         HistoryScope::Type {
     113           52 :             type_iri,
     114           52 :             except_ids,
     115           52 :         } => typed(doc, type_iri) && !except_ids.iter().any(|e| e == id),
     116              :         HistoryScope::Rest {
     117            8 :             except_types,
     118            8 :             except_ids,
     119            8 :         } => !except_types.iter().any(|t| typed(doc, t)) && !except_ids.iter().any(|e| e == id),
     120              :     }
     121           76 : }
     122              : 
     123              : /// The instant an attribute instance is kept by, the same fallback the
     124              : /// Postgres arm's `observed_at` column takes.
     125          136 : fn instance_instant(inst: &Value) -> Option<&str> {
     126          136 :     ["observedAt", "modifiedAt", "deletedAt", "createdAt"]
     127          136 :         .iter()
     128          156 :         .find_map(|k| inst.get(*k).and_then(Value::as_str))
     129          136 : }
     130              : 
     131              : /// `a` before `b`, compared as instants (4.6.3 allows a comma fraction), by
     132              : /// bytes when either does not parse.
     133         9876 : pub(crate) fn earlier(a: &str, b: &str) -> bool {
     134         9876 :     let instant =
     135        19752 :         |s: &str| chrono::DateTime::parse_from_rfc3339(&filter::canonical_datetime(s)).ok();
     136         9876 :     match (instant(a), instant(b)) {
     137         9876 :         (Some(x), Some(y)) => x < y,
     138            0 :         _ => a < b,
     139              :     }
     140         9876 : }
     141              : 
     142              : /// The 4.22 "now" the write paths judge `expiresAt` against — the same UTC-Z
     143              : /// millisecond form the read boundary uses.
     144        31094 : fn now_stamp() -> String {
     145        31094 :     chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
     146        31094 : }
     147              : 
     148              : const ALL_KINDS: [Kind; 10] = [
     149              :     Kind::Entity,
     150              :     Kind::Subscription,
     151              :     Kind::Registration,
     152              :     Kind::CSourceSubscription,
     153              :     Kind::Temporal,
     154              :     Kind::Snapshot,
     155              :     Kind::EntityMap,
     156              :     Kind::DistSub,
     157              :     Kind::DeadLetter,
     158              :     Kind::View,
     159              : ];
     160              : 
     161              : /// Run `f` off the tokio worker pool when called from a multi-thread runtime:
     162              : /// a per-commit fsync must never stall an async worker. Outside a
     163              : /// runtime (unit tests, startup) it just runs inline.
     164              : ///
     165              : /// `durable` is what decides. A per-document write blocks only where it
     166              : /// commits, so it passes `shadow.is_some()`: in `memory` mode that write is
     167              : /// a lock and a map insert, and handing a worker's queue to another thread —
     168              : /// a handoff, and past the pool's live threads a spawn — costs more than the
     169              : /// write and costs more the more cores there are to hand work between. The
     170              : /// paths that hold the write section for a whole SCAN (the 4.22 sweep, the
     171              : /// two purges) pass `true` whatever the mode: what makes them long there is
     172              : /// the scan, not the commit.
     173       470863 : fn on_blocking<T>(durable: bool, f: impl FnOnce() -> T) -> T {
     174              :     // wasm32: single-threaded, no tokio runtime — always inline.
     175              :     #[cfg(not(target_arch = "wasm32"))]
     176       470863 :     if durable {
     177        14874 :         if let Ok(h) = tokio::runtime::Handle::try_current() {
     178        14868 :             if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
     179        14230 :                 return tokio::task::block_in_place(f);
     180          638 :             }
     181            6 :         }
     182       455989 :     }
     183              :     #[cfg(target_arch = "wasm32")]
     184              :     let _ = durable;
     185       456633 :     f()
     186       470863 : }
     187              : 
     188              : /// One write [`Store::stage`] queued: table, key, encoded doc (`None` =
     189              : /// delete).
     190              : type Staged = (Table, Vec<u8>, Option<Vec<u8>>);
     191              : 
     192              : #[derive(Default)]
     193              : pub struct Store {
     194              :     inner: RwLock<Inner>,
     195              :     hook: RwLock<Option<ChangeHook>>,
     196              :     /// Set when this instance serves only the temporal seam: it never holds
     197              :     /// the entities, so the append guard must not look for them here.
     198              :     pub temporal_only: bool,
     199              :     /// Most instances one Attribute's history may hold here; `0` = no cap
     200              :     /// (`ANTARES_MEM_MAX_INSTANCES`). An Entity's whole history is ONE
     201              :     /// document, cloned, compared and (in `file` mode) rewritten and fsynced
     202              :     /// whole on every append under the single write lock, so an unbounded
     203              :     /// one makes each write slower than the last, for every Tenant.
     204              :     pub max_instances: usize,
     205              :     /// `file` mode durability shadow; `None` = pure in-memory (`memory` mode).
     206              :     shadow: Option<Shadow>,
     207              :     /// Writers currently queued behind the single write-critical section
     208              :     /// (redb has ONE writer, so fsync commits serialize here). Exported via
     209              :     /// /q/health; the group-commit lever only gets built if a benchmark shows
     210              :     /// this depth sustained at the measured ~3.1k writes/s ceiling.
     211              :     write_waiters: std::sync::atomic::AtomicUsize,
     212              :     write_waiters_peak: std::sync::atomic::AtomicUsize,
     213              :     /// Change hooks fire in COMMIT order. The hook runs after the
     214              :     /// write-critical section (it may write other kinds through the store,
     215              :     /// so running it inside would deadlock), which lets a later commit's
     216              :     /// hook overtake an earlier one — the consumer would record stale state
     217              :     /// as newest. Entity writes therefore hold this from before the commit
     218              :     /// until the emit is done. Only the local single-process path needs it:
     219              :     /// across processes the transactional outbox is the ordered channel.
     220              :     emit_order: tokio::sync::Mutex<()>,
     221              :     /// Entity changes emitted, the version each carries to the hook: counted
     222              :     /// under `emit_order`, so in commit order.
     223              :     emitted: std::sync::atomic::AtomicI64,
     224              : }
     225              : 
     226              : #[derive(Default)]
     227              : struct Inner {
     228              :     /// tenant → id → internal entity doc (BTreeMap: deterministic list order).
     229              :     entities: HashMap<String, BTreeMap<String, Value>>,
     230              :     subscriptions: HashMap<String, BTreeMap<String, Value>>,
     231              :     registrations: HashMap<String, BTreeMap<String, Value>>,
     232              :     csource_subscriptions: HashMap<String, BTreeMap<String, Value>>,
     233              :     snapshots: HashMap<String, BTreeMap<String, Value>>,
     234              :     entity_map_docs: HashMap<String, BTreeMap<String, Value>>,
     235              :     dist_subs: HashMap<String, BTreeMap<String, Value>>,
     236              :     dead_letters: HashMap<String, BTreeMap<String, Value>>,
     237              :     views: HashMap<String, BTreeMap<String, Value>>,
     238              :     /// tenant → entity id → temporal doc (attr IRI → instance array).
     239              :     temporal: HashMap<String, BTreeMap<String, Value>>,
     240              :     /// hosted/cached @context documents, shared across tenants by design.
     241              :     contexts: BTreeMap<String, Value>,
     242              : }
     243              : 
     244              : impl Store {
     245              :     /// Open (or create) the `file`-mode store: redb at `dir/antares.redb`,
     246              :     /// format-checked, in-memory maps rebuilt from the file.
     247              :     /// Any open/format/decode error refuses to start — never silently serve
     248              :     /// partial data.
     249           70 :     pub fn open_file(dir: &Path) -> Result<Self, String> {
     250           70 :         std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
     251           70 :         let path = dir.join("antares.redb");
     252           70 :         let db = Database::create(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
     253           68 :         Self::from_database(db, &path.display().to_string())
     254           70 :     }
     255              : 
     256              :     /// The target-independent half of `open_file`: format check +
     257              :     /// boot rebuild over an already-constructed redb `Database`. The
     258              :     /// browser build calls this with an OPFS-backed database — same shadow,
     259              :     /// same commit-before-ack, different `StorageBackend`.
     260           72 :     pub fn from_database(db: Database, label: &str) -> Result<Self, String> {
     261              :         // Format marker. Absent marker + existing data = a file this
     262              :         // binary cannot vouch for; refuse rather than guess.
     263           72 :         let stored_format = {
     264           72 :             let rt = db.begin_read().map_err(|e| e.to_string())?;
     265           72 :             match rt.open_table(T_META) {
     266           36 :                 Ok(t) => t
     267           36 :                     .get("format")
     268           36 :                     .map_err(|e| e.to_string())?
     269           36 :                     .map(|v| v.value().to_owned()),
     270           36 :                 Err(::redb::TableError::TableDoesNotExist(_)) => None,
     271            0 :                 Err(e) => return Err(e.to_string()),
     272              :             }
     273              :         };
     274           72 :         match stored_format.as_deref() {
     275           36 :             Some(FORMAT_VERSION) => {}
     276            2 :             Some(other) => {
     277            2 :                 return Err(format!(
     278            2 :                     "data file {label} has format {other}, this binary supports {FORMAT_VERSION} — \
     279            2 :                      refusing to start"
     280            2 :                 ));
     281              :             }
     282              :             None => {
     283           36 :                 let rt = db.begin_read().map_err(|e| e.to_string())?;
     284          324 :                 for kind in ALL_KINDS {
     285              :                     // Absent = nothing stored under that kind. Any other
     286              :                     // error is a table this binary cannot read, and reading
     287              :                     // it as empty would let the guard stamp its marker onto
     288              :                     // a file it is about to refuse.
     289          324 :                     let t = match rt.open_table(table_for(kind)) {
     290            2 :                         Ok(t) => t,
     291          320 :                         Err(::redb::TableError::TableDoesNotExist(_)) => continue,
     292            2 :                         Err(e) => return Err(e.to_string()),
     293              :                     };
     294            2 :                     if t.len().map_err(|e| e.to_string())? > 0 {
     295            2 :                         return Err(format!(
     296            2 :                             "data file {label} holds data but no format marker — refusing to start"
     297            2 :                         ));
     298            0 :                     }
     299              :                 }
     300           32 :                 drop(rt);
     301           32 :                 let mut tx = db.begin_write().map_err(|e| e.to_string())?;
     302           32 :                 tx.set_durability(Durability::Immediate)
     303           32 :                     .map_err(|e| e.to_string())?;
     304              :                 {
     305           32 :                     let mut t = tx.open_table(T_META).map_err(|e| e.to_string())?;
     306           32 :                     t.insert("format", FORMAT_VERSION)
     307           32 :                         .map_err(|e| e.to_string())?;
     308              :                 }
     309           32 :                 tx.commit().map_err(|e| e.to_string())?;
     310              :             }
     311              :         }
     312              : 
     313              :         // Boot rebuild — scan every table into the in-memory maps.
     314           66 :         let mut inner = Inner::default();
     315           66 :         let rt = db.begin_read().map_err(|e| e.to_string())?;
     316          660 :         for kind in ALL_KINDS {
     317          660 :             let table = match rt.open_table(table_for(kind)) {
     318           82 :                 Ok(t) => t,
     319          578 :                 Err(::redb::TableError::TableDoesNotExist(_)) => continue,
     320            0 :                 Err(e) => return Err(e.to_string()),
     321              :             };
     322           82 :             let map = match kind {
     323           24 :                 Kind::Entity => &mut inner.entities,
     324            6 :                 Kind::Subscription => &mut inner.subscriptions,
     325            6 :                 Kind::Registration => &mut inner.registrations,
     326            4 :                 Kind::CSourceSubscription => &mut inner.csource_subscriptions,
     327           14 :                 Kind::Temporal => &mut inner.temporal,
     328            6 :                 Kind::Snapshot => &mut inner.snapshots,
     329            6 :                 Kind::EntityMap => &mut inner.entity_map_docs,
     330            6 :                 Kind::DistSub => &mut inner.dist_subs,
     331            6 :                 Kind::DeadLetter => &mut inner.dead_letters,
     332            4 :                 Kind::View => &mut inner.views,
     333              :             };
     334         1710 :             for row in ::redb::ReadableTable::iter(&table).map_err(|e| e.to_string())? {
     335         1710 :                 let (k, v) = row.map_err(|e| e.to_string())?;
     336         1710 :                 let (tenant, id) = split_key(k.value())
     337         1710 :                     .ok_or_else(|| format!("undecodable key in table {kind:?}"))?;
     338         1710 :                 let doc: Value = serde_json::from_slice(v.value())
     339         1710 :                     .map_err(|e| format!("undecodable value for {tenant}/{id}: {e}"))?;
     340         1710 :                 map.entry(tenant).or_default().insert(id, doc);
     341              :             }
     342              :         }
     343              :         // Same rule as the kind loop above: absent is empty, unreadable is a
     344              :         // refusal. Hosted and ImplicitlyCreated @contexts (5.13.1) are a
     345              :         // Tenant's own documents, so starting without this table is serving
     346              :         // partial data, which `open_file` promises never to do.
     347           66 :         let contexts = match rt.open_table(T_JSONLD_CONTEXTS) {
     348            4 :             Ok(t) => Some(t),
     349           60 :             Err(::redb::TableError::TableDoesNotExist(_)) => None,
     350            2 :             Err(e) => return Err(e.to_string()),
     351              :         };
     352           64 :         if let Some(t) = contexts {
     353            4 :             for row in ::redb::ReadableTable::iter(&t).map_err(|e| e.to_string())? {
     354            2 :                 let (k, v) = row.map_err(|e| e.to_string())?;
     355            2 :                 let id = String::from_utf8(k.value().to_vec())
     356            2 :                     .map_err(|e| format!("undecodable context id: {e}"))?;
     357            2 :                 let doc: Value = serde_json::from_slice(v.value())
     358            2 :                     .map_err(|e| format!("undecodable context {id}: {e}"))?;
     359            2 :                 inner.contexts.insert(id, doc);
     360              :             }
     361           60 :         }
     362           64 :         drop(rt);
     363              : 
     364           64 :         Ok(Self {
     365           64 :             inner: RwLock::new(inner),
     366           64 :             hook: RwLock::new(None),
     367           64 :             emit_order: tokio::sync::Mutex::new(()),
     368           64 :             emitted: Default::default(),
     369           64 :             shadow: Some(Shadow { db }),
     370           64 :             temporal_only: false,
     371           64 :             max_instances: 0,
     372           64 :             write_waiters: Default::default(),
     373           64 :             write_waiters_peak: Default::default(),
     374           64 :         })
     375           72 :     }
     376              : 
     377              :     /// Write-through for one doc (must be called inside the write-critical
     378              :     /// section so redb order equals memory order).
     379       450387 :     fn persist(&self, table: Table, key: &[u8], doc: Option<&Value>) {
     380       450387 :         if let Some(shadow) = &self.shadow {
     381              :             // None means DELETE the key, so a document that will not encode
     382              :             // must skip the write rather than collapse into one.
     383        12381 :             let bytes = match doc {
     384         9670 :                 Some(d) => match serde_json::to_vec(d) {
     385         9670 :                     Ok(b) => Some(b),
     386            0 :                     Err(_) => return,
     387              :                 },
     388         2711 :                 None => None,
     389              :             };
     390        12381 :             shadow.write(table, key, bytes.as_deref());
     391       438006 :         }
     392       450387 :     }
     393              : 
     394              :     /// Queue the write-through of one doc (`None` = delete) for
     395              :     /// [`Self::commit_staged`]; nothing in `memory` mode. A document that
     396              :     /// will not encode is skipped, as [`Self::persist`] skips it.
     397         4442 :     fn stage(&self, staged: &mut Vec<Staged>, table: Table, key: Vec<u8>, doc: Option<&Value>) {
     398         4442 :         if self.shadow.is_none() {
     399         2626 :             return;
     400         1816 :         }
     401         1816 :         let bytes = match doc {
     402          122 :             Some(d) => match serde_json::to_vec(d) {
     403          122 :                 Ok(b) => Some(b),
     404            0 :                 Err(_) => return,
     405              :             },
     406         1694 :             None => None,
     407              :         };
     408         1816 :         staged.push((table, key, bytes));
     409         4442 :     }
     410              : 
     411              :     /// Commit what [`Self::stage`] queued in one redb transaction (must be
     412              :     /// called inside the write-critical section, as `persist`): a
     413              :     /// multi-document change is durable whole, at the cost of one fsync.
     414         3680 :     fn commit_staged(&self, staged: &[Staged]) {
     415         3680 :         if let Some(shadow) = &self.shadow {
     416          632 :             shadow.write_all(
     417          632 :                 staged
     418          632 :                     .iter()
     419         2258 :                     .map(|(t, k, v)| (*t, k.as_slice(), v.as_deref())),
     420              :             );
     421         3048 :         }
     422         3680 :     }
     423              : 
     424              :     /// Acquire the write-critical section, counting queued writers.
     425              :     ///
     426              :     /// Poison recovery (`into_inner`) is deliberate: a panic inside a caller's
     427              :     /// mutate closure unwinds one request; poisoning would turn it into a
     428              :     /// whole-process brick (every later store call panicking until restart).
     429              :     /// Consistency holds because mutations work on a clone and swap last.
     430       470863 :     fn write_inner(&self) -> std::sync::RwLockWriteGuard<'_, Inner> {
     431              :         use std::sync::atomic::Ordering;
     432       470863 :         let depth = self
     433       470863 :             .write_waiters
     434       470863 :             .fetch_add(1, Ordering::Relaxed)
     435       470863 :             .saturating_add(1);
     436       470863 :         self.write_waiters_peak.fetch_max(depth, Ordering::Relaxed);
     437       470863 :         let guard = self
     438       470863 :             .inner
     439       470863 :             .write()
     440       470863 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     441       470863 :         self.write_waiters.fetch_sub(1, Ordering::Relaxed);
     442       470863 :         guard
     443       470863 :     }
     444              : 
     445              :     /// Whether writes reach a durability shadow (`file` mode, and the
     446              :     /// browser build over OPFS) rather than living only in memory.
     447          242 :     pub fn shadowed(&self) -> bool {
     448          242 :         self.shadow.is_some()
     449          242 :     }
     450              : 
     451              :     /// (Currently queued writers, peak since start). The peak going
     452              :     /// nowhere near sustained depth is the evidence that the group-commit
     453              :     /// lever stays unbuilt.
     454            8 :     pub fn commit_queue(&self) -> (usize, usize) {
     455              :         use std::sync::atomic::Ordering;
     456            8 :         (
     457            8 :             self.write_waiters.load(Ordering::Relaxed),
     458            8 :             self.write_waiters_peak.load(Ordering::Relaxed),
     459            8 :         )
     460            8 :     }
     461              : 
     462         1264 :     pub fn set_change_hook(&self, h: ChangeHook) {
     463         1264 :         *self
     464         1264 :             .hook
     465         1264 :             .write()
     466         1264 :             .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(h);
     467         1264 :     }
     468              : 
     469              :     /// Held from before an entity commit until its emit returns (see
     470              :     /// `emit_order`). Not for other kinds: their writes emit nothing, and a
     471              :     /// hook that writes them re-enters the store.
     472       182894 :     async fn emit_ordered(&self) -> tokio::sync::MutexGuard<'_, ()> {
     473       182894 :         self.emit_order.lock().await
     474       182894 :     }
     475              : 
     476       178306 :     async fn emit(&self, tenant: &TenantId, before: Option<Value>, after: Option<Value>) {
     477              :         // cloned out of the lock first: a read guard cannot be held across
     478              :         // the await the hook now costs.
     479       178306 :         let hook = self
     480       178306 :             .hook
     481       178306 :             .read()
     482       178306 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
     483       178306 :             .clone();
     484       178306 :         if let Some(h) = hook {
     485       136790 :             let version = antares_store::ChangeVersion {
     486       136790 :                 incarnation: String::new(),
     487       136790 :                 version: self
     488       136790 :                     .emitted
     489       136790 :                     .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
     490       136790 :                     .saturating_add(1),
     491       136790 :             };
     492       136790 :             h(tenant, before, after, version).await;
     493        41516 :         }
     494       178304 :     }
     495              : 
     496              :     /// 4.22 garbage collection for the memory/file arm: remove entity docs
     497              :     /// whose `expiresAt` (byte-compared against the UTC-Z `now` stamp, same
     498              :     /// as the read filter) has passed, and prune expired ATTRIBUTE instances
     499              :     /// from current-state and temporal docs — the read filter hides them,
     500              :     /// but without physical removal a long-running store (the browser's OPFS
     501              :     /// file under ticking sensors) grows without bound. `file` mode persists
     502              :     /// each removal. Returns how many docs were reaped or pruned.
     503              :     ///
     504              :     /// Runs through `on_blocking` like every other mutating path: in `file`
     505              :     /// mode it holds the write-critical section for a full scan and one
     506              :     /// `Durability::Immediate` (fsync) commit of every doc it reaped or
     507              :     /// pruned, which must never happen on an async worker thread.
     508          972 :     pub fn sweep_expired(&self, now: &str) -> usize {
     509          972 :         on_blocking(true, || self.sweep_expired_locked(now))
     510          972 :     }
     511              : 
     512          972 :     fn sweep_expired_locked(&self, now: &str) -> usize {
     513          972 :         let mut inner = self.write_inner();
     514          972 :         let mut reaped = 0usize;
     515          972 :         let mut dead: Vec<(String, String)> = Vec::new();
     516         7958 :         for (tenant, docs) in &inner.entities {
     517         8758 :             for (id, doc) in docs {
     518         6445 :                 if filter::expired_at(doc, now) {
     519          828 :                     dead.push((tenant.clone(), id.clone()));
     520         6413 :                 }
     521              :             }
     522              :         }
     523              :         // One commit for the whole pass: an Entity and its history leave the
     524              :         // file together, and the section waits for one fsync, not one a key.
     525          972 :         let mut staged = Vec::new();
     526         1760 :         for (tenant, id) in dead {
     527          828 :             if let Some(docs) = inner.entities.get_mut(&tenant) {
     528          828 :                 self.stage(&mut staged, T_ENTITIES, key_bytes(&tenant, &id), None);
     529          828 :                 docs.remove(&id);
     530          828 :                 reaped = reaped.saturating_add(1);
     531          828 :             }
     532              :             // Its history goes with it.
     533          828 :             if let Some(docs) = inner.temporal.get_mut(&tenant) {
     534          822 :                 if docs.remove(&id).is_some() {
     535          818 :                     self.stage(
     536          818 :                         &mut staged,
     537          818 :                         T_TEMPORAL_ENTITIES,
     538          818 :                         key_bytes(&tenant, &id),
     539          818 :                         None,
     540          818 :                     );
     541          818 :                 }
     542            6 :             }
     543              :         }
     544              :         let Inner {
     545          972 :             entities, temporal, ..
     546          972 :         } = &mut *inner;
     547         1944 :         for (table, map) in [
     548          972 :             (
     549          972 :                 T_ENTITIES,
     550          972 :                 entities as &mut HashMap<String, BTreeMap<String, Value>>,
     551          972 :             ),
     552          972 :             (T_TEMPORAL_ENTITIES, temporal),
     553          972 :         ] {
     554        10778 :             for (tenant, docs) in map.iter_mut() {
     555        10760 :                 for (id, doc) in docs.iter_mut() {
     556         6094 :                     if prune_expired_instances(doc, now) {
     557           24 :                         reaped = reaped.saturating_add(1);
     558           24 :                         self.stage(&mut staged, table, key_bytes(tenant, id), Some(doc));
     559         6070 :                     }
     560              :                 }
     561              :             }
     562              :         }
     563          972 :         self.commit_staged(&staged);
     564          972 :         reaped
     565          972 :     }
     566              : 
     567              :     /// Tenants that hold any subscriptions (interval-firing scan).
     568              :     /// 5.5.10: a Tenant exists once any create operation implicitly created
     569              :     /// it (any resource kind was ever written under it).
     570          260 :     pub fn tenant_exists(&self, tenant: &TenantId) -> bool {
     571          260 :         let inner = self
     572          260 :             .inner
     573          260 :             .read()
     574          260 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     575          260 :         ALL_KINDS
     576          260 :             .iter()
     577          974 :             .any(|k| Self::map(&inner, *k).contains_key(tenant.as_str()))
     578          260 :     }
     579              : 
     580              :     /// Every tenant name, sorted; the default tenant is always present, and
     581              :     /// the tenants the broker minted for itself are not — `/q/tenants` is the
     582              :     /// inventory of customer accounts, not of the broker's own bookkeeping.
     583         1008 :     pub fn tenant_ids(&self) -> Vec<String> {
     584         1008 :         let inner = self
     585         1008 :             .inner
     586         1008 :             .read()
     587         1008 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     588         1008 :         let mut names: std::collections::BTreeSet<String> =
     589         1008 :             std::iter::once(TenantId::DEFAULT.to_string()).collect();
     590        10080 :         for kind in ALL_KINDS {
     591        10080 :             names.extend(
     592        10080 :                 Self::map(&inner, kind)
     593        10080 :                     .keys()
     594        17952 :                     .filter(|t| !TenantId::is_reserved_str(t))
     595        10080 :                     .cloned(),
     596              :             );
     597              :         }
     598         1008 :         names.into_iter().collect()
     599         1008 :     }
     600              : 
     601              :     /// The types the Tenant's live Entities carry, sorted: one pass
     602              :     /// over the held documents, none of them cloned. 4.22: an expired Entity
     603              :     /// carries no type.
     604          138 :     pub fn entity_type_names(&self, tenant: &TenantId, now: &str) -> Vec<String> {
     605          138 :         self.entity_types_where(tenant, now, |_| true)
     606          138 :     }
     607              : 
     608              :     /// The types of the live Entities that carry one of `any_of`, sorted.
     609           16 :     pub fn entity_types_beside(
     610           16 :         &self,
     611           16 :         tenant: &TenantId,
     612           16 :         any_of: &[String],
     613           16 :         now: &str,
     614           16 :     ) -> Vec<String> {
     615          544 :         self.entity_types_where(tenant, now, |types| {
     616          544 :             types.len() > 1 && types.iter().any(|t| any_of.iter().any(|a| a == t))
     617          544 :         })
     618           16 :     }
     619              : 
     620              :     /// The (type, member name) pairs the Tenant's live Entities carry,
     621              :     /// sorted: one pass over the held documents.
     622           96 :     pub fn entity_attr_pairs(&self, tenant: &TenantId, now: &str) -> Vec<(String, String)> {
     623           96 :         let inner = self
     624           96 :             .inner
     625           96 :             .read()
     626           96 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     627           96 :         let mut pairs = std::collections::BTreeSet::new();
     628           96 :         if let Some(m) = Self::map(&inner, Kind::Entity).get(tenant.as_str()) {
     629          880 :             for doc in m.values().filter(|d| !filter::expired_at(d, now)) {
     630          880 :                 let types: Vec<&str> = match &doc["type"] {
     631            4 :                     Value::String(t) => vec![t.as_str()],
     632          876 :                     Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
     633            0 :                     _ => Vec::new(),
     634              :                 };
     635         3282 :                 for k in doc.as_object().into_iter().flat_map(|o| o.keys()) {
     636         3340 :                     pairs.extend(types.iter().map(|t| (*t, k.as_str())));
     637              :                 }
     638              :             }
     639           20 :         }
     640           96 :         pairs
     641           96 :             .into_iter()
     642          664 :             .map(|(t, k)| (t.to_owned(), k.to_owned()))
     643           96 :             .collect()
     644           96 :     }
     645              : 
     646          154 :     fn entity_types_where(
     647          154 :         &self,
     648          154 :         tenant: &TenantId,
     649          154 :         now: &str,
     650          154 :         keep: impl Fn(&[&str]) -> bool,
     651          154 :     ) -> Vec<String> {
     652          154 :         let inner = self
     653          154 :             .inner
     654          154 :             .read()
     655          154 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     656          154 :         let mut names = std::collections::BTreeSet::new();
     657          154 :         if let Some(m) = Self::map(&inner, Kind::Entity).get(tenant.as_str()) {
     658       112738 :             for doc in m.values().filter(|d| !filter::expired_at(d, now)) {
     659       112738 :                 let types: Vec<&str> = match &doc["type"] {
     660            4 :                     Value::String(t) => vec![t.as_str()],
     661       112734 :                     Value::Array(a) => a.iter().filter_map(Value::as_str).collect(),
     662            0 :                     _ => Vec::new(),
     663              :                 };
     664       112738 :                 if keep(&types) {
     665       112202 :                     names.extend(types);
     666       112296 :                 }
     667              :             }
     668           36 :         }
     669          154 :         names.into_iter().map(str::to_owned).collect()
     670          154 :     }
     671              : 
     672              :     /// What one tenant holds. Existence is the caller's question — an
     673              :     /// unknown tenant simply counts zero of everything.
     674           36 :     pub fn tenant_stats_one(&self, tenant: &TenantId) -> antares_store::TenantStats {
     675           36 :         let inner = self
     676           36 :             .inner
     677           36 :             .read()
     678           36 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     679          252 :         let n = |k: Kind| {
     680          252 :             Self::map(&inner, k)
     681          252 :                 .get(tenant.as_str())
     682          252 :                 .map_or(0, |m| m.len() as u64)
     683          252 :         };
     684           36 :         antares_store::TenantStats {
     685           36 :             entities: n(Kind::Entity),
     686           36 :             subscriptions: n(Kind::Subscription),
     687           36 :             registrations: n(Kind::Registration),
     688           36 :             csource_subscriptions: n(Kind::CSourceSubscription),
     689           36 :             snapshots: n(Kind::Snapshot),
     690           36 :             entity_maps: n(Kind::EntityMap),
     691           36 :             dist_subs: n(Kind::DistSub),
     692           36 :             created_at: None,
     693           36 :             tenant: tenant.as_str().to_owned(),
     694           36 :         }
     695           36 :     }
     696              : 
     697              :     /// Attribute instances held in the tenant's temporal documents.
     698           32 :     pub fn attr_instance_count(&self, tenant: &TenantId) -> u64 {
     699           32 :         let inner = self
     700           32 :             .inner
     701           32 :             .read()
     702           32 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     703           32 :         inner.temporal.get(tenant.as_str()).map_or(0, |docs| {
     704           22 :             docs.values()
     705           22 :                 .filter_map(Value::as_object)
     706           28 :                 .flat_map(|d| d.values().filter_map(Value::as_array))
     707         1544 :                 .map(|a| a.iter().filter(|i| i.is_object()).count() as u64)
     708           22 :                 .sum()
     709           22 :         })
     710           32 :     }
     711              : 
     712              :     /// The serialized size of the tenant's Entities, by first type.
     713           30 :     pub fn tenant_bytes(&self, tenant: &TenantId) -> antares_store::TenantBytes {
     714           30 :         let inner = self
     715           30 :             .inner
     716           30 :             .read()
     717           30 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     718           30 :         let mut out = antares_store::TenantBytes::default();
     719           30 :         for doc in inner
     720           30 :             .entities
     721           30 :             .get(tenant.as_str())
     722           30 :             .into_iter()
     723           30 :             .flat_map(BTreeMap::values)
     724              :         {
     725           26 :             let n = serde_json::to_vec(doc).map_or(0, |v| v.len() as u64);
     726           26 :             let ty = match doc.get("type") {
     727            0 :                 Some(Value::String(t)) => t.as_str(),
     728           26 :                 Some(Value::Array(a)) => a.first().and_then(Value::as_str).unwrap_or(""),
     729            0 :                 _ => "",
     730              :             };
     731           26 :             out.entities = out.entities.saturating_add(n);
     732           26 :             let t = out.by_type.entry(ty.to_owned()).or_default();
     733           26 :             *t = t.saturating_add(n);
     734              :         }
     735           30 :         out
     736           30 :     }
     737              : 
     738              :     /// The serialized size of the tenant's temporal documents.
     739           28 :     pub fn attr_instance_bytes(&self, tenant: &TenantId) -> u64 {
     740           28 :         let inner = self
     741           28 :             .inner
     742           28 :             .read()
     743           28 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     744           28 :         inner.temporal.get(tenant.as_str()).map_or(0, |docs| {
     745           18 :             docs.values()
     746           20 :                 .map(|d| serde_json::to_vec(d).map_or(0, |v| v.len() as u64))
     747           18 :                 .sum()
     748           18 :         })
     749           28 :     }
     750              : 
     751              :     /// Retention of current state: drop at most `batch` Entities of
     752              :     /// `tenant` typed `type_iri` that carry no `expiresAt` and whose
     753              :     /// `modifiedAt` is before `before`. Persisted in one commit in `file`
     754              :     /// mode; `dry_run` counts them and drops none.
     755           16 :     pub fn retain_current(
     756           16 :         &self,
     757           16 :         tenant: &TenantId,
     758           16 :         type_iri: &str,
     759           16 :         before: &str,
     760           16 :         batch: usize,
     761           16 :         dry_run: bool,
     762           16 :     ) -> u64 {
     763           16 :         on_blocking(true, || {
     764           16 :             let mut inner = self.write_inner();
     765           16 :             let Some(docs) = inner.entities.get_mut(tenant.as_str()) else {
     766            4 :                 return 0;
     767              :             };
     768           12 :             let dead: Vec<String> = docs
     769           12 :                 .iter()
     770           44 :                 .filter(|(_, doc)| {
     771           44 :                     typed(doc, type_iri)
     772           32 :                         && doc.get("expiresAt").is_none()
     773           20 :                         && doc
     774           20 :                             .get("modifiedAt")
     775           20 :                             .and_then(Value::as_str)
     776           20 :                             .is_some_and(|m| earlier(m, before))
     777           44 :                 })
     778           12 :                 .map(|(id, _)| id.clone())
     779           12 :                 .take(batch)
     780           12 :                 .collect();
     781           12 :             let mut staged = Vec::new();
     782           12 :             for id in dead.iter().filter(|_| !dry_run) {
     783            4 :                 self.stage(
     784            4 :                     &mut staged,
     785            4 :                     T_ENTITIES,
     786            4 :                     key_bytes(tenant.as_str(), id),
     787            4 :                     None,
     788            4 :                 );
     789            4 :                 docs.remove(id);
     790            4 :             }
     791           12 :             self.commit_staged(&staged);
     792           12 :             dead.len() as u64
     793           16 :         })
     794           16 :     }
     795              : 
     796              :     /// The Entities of every Tenant carrying their own history retention,
     797              :     /// at most `limit`, with the Property as stored.
     798            4 :     pub fn history_overrides(&self, limit: usize) -> Vec<(String, String, Value)> {
     799            4 :         let inner = self
     800            4 :             .inner
     801            4 :             .read()
     802            4 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     803            4 :         inner
     804            4 :             .entities
     805            4 :             .iter()
     806            6 :             .flat_map(|(tenant, docs)| {
     807            8 :                 docs.iter().filter_map(move |(id, doc)| {
     808            8 :                     doc.get(antares_store::HISTORY_RETENTION)
     809            8 :                         .map(|own| (tenant.clone(), id.clone(), own.clone()))
     810            8 :                 })
     811            6 :             })
     812            4 :             .take(limit)
     813            4 :             .collect()
     814            4 :     }
     815              : 
     816              :     /// Retention of history: drop at most `batch` attribute
     817              :     /// instances of the `tenant`'s temporal documents `scope` names whose
     818              :     /// instant is before `before`; an attribute left without instances goes.
     819              :     /// `dry_run` counts them and drops none.
     820           32 :     pub fn retain_history(
     821           32 :         &self,
     822           32 :         tenant: &TenantId,
     823           32 :         scope: antares_store::HistoryScope<'_>,
     824           32 :         before: &str,
     825           32 :         batch: usize,
     826           32 :         dry_run: bool,
     827           32 :     ) -> u64 {
     828           32 :         on_blocking(true, || {
     829           32 :             let mut inner = self.write_inner();
     830           32 :             let Some(docs) = inner.temporal.get_mut(tenant.as_str()) else {
     831            4 :                 return 0;
     832              :             };
     833           28 :             let mut left = batch;
     834           28 :             let mut dropped = 0u64;
     835           28 :             let mut staged = Vec::new();
     836           84 :             for (id, doc) in docs.iter_mut() {
     837           84 :                 if left == 0 {
     838            8 :                     break;
     839           76 :                 }
     840           76 :                 if !in_scope(id, doc, scope) {
     841           34 :                     continue;
     842           42 :                 }
     843           42 :                 let Some(obj) = doc.as_object_mut() else {
     844            0 :                     continue;
     845              :                 };
     846           42 :                 let before_count = dropped;
     847           42 :                 for k in attribute_names(obj) {
     848           40 :                     let Some(arr) = obj.get_mut(&k).and_then(Value::as_array_mut) else {
     849            0 :                         continue;
     850              :                     };
     851          216 :                     arr.retain(|inst| {
     852          216 :                         let old =
     853          216 :                             left > 0 && instance_instant(inst).is_some_and(|t| earlier(t, before));
     854          216 :                         if old {
     855          116 :                             left = left.saturating_sub(1);
     856          116 :                             dropped = dropped.saturating_add(1);
     857          116 :                         }
     858          216 :                         dry_run || !old
     859          216 :                     });
     860           40 :                     if arr.is_empty() && !dry_run {
     861           10 :                         obj.remove(&k);
     862           30 :                     }
     863              :                 }
     864           42 :                 if dropped != before_count && !dry_run {
     865           22 :                     self.stage(
     866           22 :                         &mut staged,
     867           22 :                         T_TEMPORAL_ENTITIES,
     868           22 :                         key_bytes(tenant.as_str(), id),
     869           22 :                         Some(doc),
     870           22 :                     );
     871           22 :                 }
     872              :             }
     873           28 :             self.commit_staged(&staged);
     874           28 :             dropped
     875           32 :         })
     876           32 :     }
     877              : 
     878              :     /// Drop every document of the given kinds for one tenant; `true` when
     879              :     /// the tenant held any of them. Persisted in one commit in `file` mode.
     880           86 :     pub fn purge_kinds(&self, tenant: &TenantId, kinds: &[Kind]) -> bool {
     881           86 :         on_blocking(true, || {
     882           86 :             let mut inner = self.write_inner();
     883           86 :             let mut staged = Vec::new();
     884           86 :             let hit = self.purge_kinds_locked(&mut inner, tenant, kinds, &mut staged);
     885           86 :             self.commit_staged(&staged);
     886           86 :             hit
     887           86 :         })
     888           86 :     }
     889              : 
     890          178 :     fn purge_kinds_locked(
     891          178 :         &self,
     892          178 :         inner: &mut Inner,
     893          178 :         tenant: &TenantId,
     894          178 :         kinds: &[Kind],
     895          178 :         staged: &mut Vec<Staged>,
     896          178 :     ) -> bool {
     897          178 :         let mut hit = false;
     898         1006 :         for kind in kinds {
     899         1006 :             if let Some(docs) = Self::map_mut(inner, *kind).remove(tenant.as_str()) {
     900          206 :                 hit = true;
     901          252 :                 for id in docs.keys() {
     902          252 :                     self.stage(
     903          252 :                         staged,
     904          252 :                         table_for(*kind),
     905          252 :                         key_bytes(tenant.as_str(), id),
     906          252 :                         None,
     907          252 :                     );
     908          252 :                 }
     909          800 :             }
     910              :         }
     911          178 :         hit
     912          178 :     }
     913              : 
     914              :     /// Remove the tenant from every kind, history included, and its own
     915              :     /// @contexts, in one commit in `file` mode: the operator's erase path
     916              :     /// never leaves part of a Tenant on disk.
     917           92 :     pub fn purge_tenant(&self, tenant: &TenantId) -> bool {
     918           92 :         on_blocking(true, || {
     919           92 :             let mut inner = self.write_inner();
     920           92 :             let mut staged = Vec::new();
     921           92 :             let existed = self.purge_kinds_locked(&mut inner, tenant, &ALL_KINDS, &mut staged);
     922              :             // ADR-0021: the Tenant's Hosted and ImplicitlyCreated @contexts are
     923              :             // its documents and go with it — a row that outlived its Tenant would
     924              :             // hand the next holder of that name a stranger's term mappings. A
     925              :             // Cached row belongs to no Tenant and stays.
     926           92 :             let dead: Vec<String> = inner
     927           92 :                 .contexts
     928           92 :                 .iter()
     929          110 :                 .filter(|(_, row)| context_row_owner(row) == Some(tenant.as_str()))
     930           92 :                 .map(|(id, _)| id.clone())
     931           92 :                 .collect();
     932           92 :             for id in dead {
     933            2 :                 self.stage(&mut staged, T_JSONLD_CONTEXTS, id.as_bytes().to_vec(), None);
     934            2 :                 inner.contexts.remove(&id);
     935            2 :             }
     936           92 :             self.commit_staged(&staged);
     937           92 :             existed
     938           92 :         })
     939           92 :     }
     940              : 
     941         2958 :     pub fn subscription_tenants(&self) -> Vec<String> {
     942         2958 :         let inner = self
     943         2958 :             .inner
     944         2958 :             .read()
     945         2958 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
     946              :         // Registrations belong to this domain: one of the hydrations that
     947              :         // walks it fills the registration mirror, which the federation path
     948              :         // then reads alone. Without them a tenant holding registrations and
     949              :         // no subscription forwarded to no Context Source.
     950         2958 :         let mut out: Vec<String> = inner
     951         2958 :             .subscriptions
     952         2958 :             .iter()
     953         2958 :             .chain(inner.csource_subscriptions.iter())
     954         2958 :             .chain(inner.registrations.iter())
     955         3048 :             .filter(|(_, m)| !m.is_empty())
     956         2958 :             .map(|(t, _)| t.clone())
     957         2958 :             .collect();
     958         2958 :         out.sort();
     959         2958 :         out.dedup();
     960         2958 :         out
     961         2958 :     }
     962              : 
     963       542602 :     fn map(inner: &Inner, kind: Kind) -> &HashMap<String, BTreeMap<String, Value>> {
     964       542602 :         match kind {
     965       347168 :             Kind::Entity => &inner.entities,
     966        10665 :             Kind::Subscription => &inner.subscriptions,
     967        20063 :             Kind::Registration => &inner.registrations,
     968         4065 :             Kind::CSourceSubscription => &inner.csource_subscriptions,
     969       125618 :             Kind::Temporal => &inner.temporal,
     970        10639 :             Kind::Snapshot => &inner.snapshots,
     971        16236 :             Kind::EntityMap => &inner.entity_map_docs,
     972         5814 :             Kind::DistSub => &inner.dist_subs,
     973         1228 :             Kind::DeadLetter => &inner.dead_letters,
     974         1106 :             Kind::View => &inner.views,
     975              :         }
     976       542602 :     }
     977              : 
     978       457199 :     fn map_mut(inner: &mut Inner, kind: Kind) -> &mut HashMap<String, BTreeMap<String, Value>> {
     979       457199 :         match kind {
     980       182966 :             Kind::Entity => &mut inner.entities,
     981         6673 :             Kind::Subscription => &mut inner.subscriptions,
     982         3826 :             Kind::Registration => &mut inner.registrations,
     983          809 :             Kind::CSourceSubscription => &mut inner.csource_subscriptions,
     984       253708 :             Kind::Temporal => &mut inner.temporal,
     985          807 :             Kind::Snapshot => &mut inner.snapshots,
     986         4912 :             Kind::EntityMap => &mut inner.entity_map_docs,
     987         3200 :             Kind::DistSub => &mut inner.dist_subs,
     988          188 :             Kind::DeadLetter => &mut inner.dead_letters,
     989          110 :             Kind::View => &mut inner.views,
     990              :         }
     991       457199 :     }
     992              : 
     993              :     /// 4.22: "expiresAt is defined as the system temporal Property at which a
     994              :     /// certain Entity, Property or Relationship shall become invalid." An
     995              :     /// entity past its expiry is invalid the moment the stamp passes, ahead
     996              :     /// of the sweep that physically reaps it, so every write path treats it
     997              :     /// as absent — otherwise the same id 404s on retrieve and 409s on create
     998              :     /// for a whole sweep interval. Only entities carry the entity-level
     999              :     /// stamp; subscriptions and registrations have their own expiry rules.
    1000       456213 :     fn is_expired(&self, inner: &Inner, kind: Kind, tenant: &str, id: &str) -> bool {
    1001       456213 :         kind == Kind::Entity
    1002       182894 :             && Self::map(inner, kind)
    1003       182894 :                 .get(tenant)
    1004       182894 :                 .and_then(|m| m.get(id))
    1005       182894 :                 .is_some_and(|d| filter::expired_at(d, &now_stamp()))
    1006       456213 :     }
    1007              : 
    1008              :     /// Insert a new resource; `false` if the id already exists.
    1009       261970 :     pub async fn create(&self, tenant: &TenantId, kind: Kind, id: &str, doc: Value) -> bool {
    1010       261970 :         let _order = match kind {
    1011       133196 :             Kind::Entity => Some(self.emit_ordered().await),
    1012       128774 :             _ => None,
    1013              :         };
    1014       261970 :         let created = on_blocking(self.shadow.is_some(), || {
    1015       261970 :             let mut inner = self.write_inner();
    1016       261970 :             let expired = self.is_expired(&inner, kind, tenant.as_str(), id);
    1017       261970 :             let m = Self::map_mut(&mut inner, kind)
    1018       261970 :                 .entry(tenant.as_str().to_owned())
    1019       261970 :                 .or_default();
    1020       261970 :             if m.contains_key(id) && !expired {
    1021           70 :                 false
    1022              :             } else {
    1023       261900 :                 m.insert(id.to_owned(), doc.clone());
    1024       261900 :                 self.persist(table_for(kind), &key_bytes(tenant.as_str(), id), Some(&doc));
    1025       261900 :                 true
    1026              :             }
    1027       261970 :         });
    1028       261970 :         if created && kind == Kind::Entity {
    1029       133146 :             self.emit(tenant, None, Some(doc)).await;
    1030       128824 :         }
    1031       261970 :         created
    1032       261970 :     }
    1033              : 
    1034              :     /// Insert or replace; returns `true` if it existed before. An expired
    1035              :     /// entity did not (4.22), so the upsert reports CREATED and the caller
    1036              :     /// answers 201 with a Location header instead of a silent 204.
    1037        18546 :     pub async fn upsert(&self, tenant: &TenantId, kind: Kind, id: &str, doc: Value) -> bool {
    1038        18546 :         let _order = match kind {
    1039        18452 :             Kind::Entity => Some(self.emit_ordered().await),
    1040           94 :             _ => None,
    1041              :         };
    1042        18546 :         let (prev, expired) = on_blocking(self.shadow.is_some(), || {
    1043        18546 :             let mut inner = self.write_inner();
    1044        18546 :             let expired = self.is_expired(&inner, kind, tenant.as_str(), id);
    1045        18546 :             let prev = Self::map_mut(&mut inner, kind)
    1046        18546 :                 .entry(tenant.as_str().to_owned())
    1047        18546 :                 .or_default()
    1048        18546 :                 .insert(id.to_owned(), doc.clone());
    1049        18546 :             self.persist(table_for(kind), &key_bytes(tenant.as_str(), id), Some(&doc));
    1050        18546 :             (prev, expired)
    1051        18546 :         });
    1052        18546 :         let existed = prev.is_some() && !expired;
    1053        18546 :         let prev = if expired { None } else { prev };
    1054        18546 :         if kind == Kind::Entity {
    1055        18452 :             self.emit(tenant, prev, Some(doc)).await;
    1056           94 :         }
    1057        18544 :         existed
    1058        18544 :     }
    1059              : 
    1060       294573 :     pub fn get(&self, tenant: &TenantId, kind: Kind, id: &str) -> Option<Value> {
    1061       294573 :         let inner = self
    1062       294573 :             .inner
    1063       294573 :             .read()
    1064       294573 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
    1065       294573 :         Self::map(&inner, kind)
    1066       294573 :             .get(tenant.as_str())
    1067       294573 :             .and_then(|m| m.get(id))
    1068       294573 :             .cloned()
    1069       294573 :     }
    1070              : 
    1071              :     /// An expired entity is already invalid (4.22): deleting it is a 404,
    1072              :     /// the same answer retrieving it gives, not a 204 for something the API
    1073              :     /// stopped serving. The row itself still goes — the sweep would take it
    1074              :     /// anyway, and leaving it would resurrect the 409.
    1075        19738 :     pub async fn delete(&self, tenant: &TenantId, kind: Kind, id: &str) -> bool {
    1076        19738 :         let _order = match kind {
    1077         7390 :             Kind::Entity => Some(self.emit_ordered().await),
    1078        12348 :             _ => None,
    1079              :         };
    1080        19738 :         let removed = on_blocking(self.shadow.is_some(), || {
    1081        19738 :             let mut inner = self.write_inner();
    1082              :             // an expired doc is left in place for the sweep to reap: removing
    1083              :             // it here without persisting the removal would resurrect it on
    1084              :             // the next boot of a `file`-mode store
    1085        19738 :             if self.is_expired(&inner, kind, tenant.as_str(), id) {
    1086           10 :                 return None;
    1087        19728 :             }
    1088        19728 :             let removed = Self::map_mut(&mut inner, kind)
    1089        19728 :                 .get_mut(tenant.as_str())
    1090        19728 :                 .and_then(|m| m.remove(id));
    1091        19728 :             if removed.is_some() {
    1092        11084 :                 self.persist(table_for(kind), &key_bytes(tenant.as_str(), id), None);
    1093        11084 :             }
    1094        19728 :             removed
    1095        19738 :         });
    1096        19738 :         let hit = removed.is_some();
    1097        19738 :         if kind == Kind::Entity {
    1098         7390 :             if let Some(old) = removed {
    1099         7328 :                 self.emit(tenant, Some(old), None).await;
    1100           62 :             }
    1101        12348 :         }
    1102        19738 :         hit
    1103        19738 :     }
    1104              : 
    1105              :     /// Delete one entity only if `keep` accepts the stored document. The
    1106              :     /// read and the removal happen under one hold of the write lock, so
    1107              :     /// nothing can replace the document between the decision and the delete.
    1108              :     /// `None` = absent, expired (4.22, as in `delete`) or refused.
    1109         1620 :     pub async fn delete_if(
    1110         1620 :         &self,
    1111         1620 :         tenant: &TenantId,
    1112         1620 :         id: &str,
    1113         1620 :         keep: &(dyn for<'v> Fn(&'v Value) -> bool + Sync),
    1114         1620 :     ) -> Option<Value> {
    1115         1620 :         let _order = self.emit_ordered().await;
    1116         1620 :         let removed = on_blocking(self.shadow.is_some(), || {
    1117         1620 :             let mut inner = self.write_inner();
    1118         1620 :             if self.is_expired(&inner, Kind::Entity, tenant.as_str(), id) {
    1119            4 :                 return None;
    1120         1616 :             }
    1121         1616 :             let m = Self::map_mut(&mut inner, Kind::Entity).get_mut(tenant.as_str())?;
    1122         1578 :             if !m.get(id).is_some_and(keep) {
    1123          122 :                 return None;
    1124         1456 :             }
    1125         1456 :             let removed = m.remove(id);
    1126         1456 :             if removed.is_some() {
    1127         1456 :                 self.persist(
    1128         1456 :                     table_for(Kind::Entity),
    1129         1456 :                     &key_bytes(tenant.as_str(), id),
    1130         1456 :                     None,
    1131         1456 :                 );
    1132         1456 :             }
    1133         1456 :             removed
    1134         1620 :         });
    1135         1620 :         if let Some(old) = &removed {
    1136         1456 :             self.emit(tenant, Some(old.clone()), None).await;
    1137          164 :         }
    1138         1620 :         removed
    1139         1620 :     }
    1140              : 
    1141              :     /// Snapshot of all docs of a kind for one tenant (id order).
    1142        10143 :     pub fn list(&self, tenant: &TenantId, kind: Kind) -> Vec<Value> {
    1143        10143 :         let inner = self
    1144        10143 :             .inner
    1145        10143 :             .read()
    1146        10143 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
    1147        10143 :         Self::map(&inner, kind)
    1148        10143 :             .get(tenant.as_str())
    1149        10143 :             .map(|m| m.values().cloned().collect())
    1150        10143 :             .unwrap_or_default()
    1151        10143 :     }
    1152              : 
    1153              :     /// One id-ordered page of docs: ids strictly greater than `after`, at
    1154              :     /// most `limit`. The per-tenant map is a `BTreeMap` keyed by id, so this
    1155              :     /// is a range walk, not a scan-and-sort.
    1156              :     ///
    1157              :     /// 4.22 applies to entities inside the walk, BEFORE the page is cut: an
    1158              :     /// expired document is not there to be paged, and dropping one after the
    1159              :     /// cut would hand the caller a short page — which every walker reads as
    1160              :     /// the end of the tenant.
    1161        40233 :     pub fn list_page(
    1162        40233 :         &self,
    1163        40233 :         tenant: &TenantId,
    1164        40233 :         kind: Kind,
    1165        40233 :         after: Option<&str>,
    1166        40233 :         limit: usize,
    1167        40233 :     ) -> Vec<Value> {
    1168              :         use std::ops::Bound;
    1169        40233 :         let inner = self
    1170        40233 :             .inner
    1171        40233 :             .read()
    1172        40233 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
    1173        40233 :         let lo = match after {
    1174          428 :             Some(a) => Bound::Excluded(a.to_owned()),
    1175        39805 :             None => Bound::Unbounded,
    1176              :         };
    1177        40233 :         let now = crate::store::any::now_utc();
    1178        40233 :         Self::map(&inner, kind)
    1179        40233 :             .get(tenant.as_str())
    1180        40233 :             .map(|m| {
    1181         8951 :                 m.range((lo, Bound::Unbounded))
    1182        12169 :                     .filter_map(|(_, v)| {
    1183         8798 :                         let mut v = v.clone();
    1184         8798 :                         if kind == Kind::Entity && crate::store::filter::strip_expired(&mut v, &now)
    1185              :                         {
    1186            8 :                             return None;
    1187         8790 :                         }
    1188         8790 :                         Some(v)
    1189         8798 :                     })
    1190         8951 :                     .take(limit)
    1191         8951 :                     .collect()
    1192         8951 :             })
    1193        40233 :             .unwrap_or_default()
    1194        40233 :     }
    1195              : 
    1196              :     /// Query Entities on the stored documents, under the read
    1197              :     /// lock: ids, the idPattern literal, types and attrs are tested on the
    1198              :     /// borrowed documents and only what the answer holds is copied, where a
    1199              :     /// snapshot of the tenant used to be. ids, types and attrs are exact
    1200              :     /// here, and so is a `q` without linked-entity terms when the request
    1201              :     /// @context comes along: it runs through the API's own evaluator.
    1202              :     /// Without anything else (`scopeQ`, a geo query, a linked
    1203              :     /// term) the outcome is `decided`: the projection applies and a page is
    1204              :     /// cut in id order (the map's order), counted when asked. Otherwise the
    1205              :     /// caller stays the arbiter over the narrowed rows. 4.22: an expired
    1206              :     /// Entity is not a match, before any cut; its expired instances are the
    1207              :     /// caller's.
    1208         2989 :     pub fn query_entities(
    1209         2989 :         &self,
    1210         2989 :         tenant: &TenantId,
    1211         2989 :         f: &filter::EntityFilter<'_>,
    1212         2989 :     ) -> filter::QueryOutcome {
    1213         2989 :         let now = crate::store::any::now_utc();
    1214              :         // a linked term reads other Entities under the caller's narrowing
    1215         2989 :         let q = match (f.q, f.ctx) {
    1216          430 :             (Some(q), Some(ctx)) if !q.has_linked_paths() => Some((q, ctx)),
    1217         2579 :             _ => None,
    1218              :         };
    1219         2989 :         let decided = (f.q.is_none() || q.is_some()) && f.scope_q.is_none() && f.geo.is_empty();
    1220         2989 :         let inner = self
    1221         2989 :             .inner
    1222         2989 :             .read()
    1223         2989 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
    1224         2989 :         let Some(m) = Self::map(&inner, Kind::Entity).get(tenant.as_str()) else {
    1225              :             return filter::QueryOutcome {
    1226          537 :                 rows: Vec::new(),
    1227          537 :                 decided,
    1228          537 :                 paged: decided && f.page.is_some(),
    1229          537 :                 total: f.page.as_ref().filter(|_| decided).map(|_| 0),
    1230              :             };
    1231              :         };
    1232              :         // a pattern that does not compile matches nothing (`Rule::id_patterns`)
    1233         2452 :         let id_res: Option<Vec<_>> = f.id_patterns.map(|ps| {
    1234            4 :             ps.iter()
    1235            6 :                 .filter_map(|p| antares_ql::regex::compile(&format!("^(?:{p})$")).ok())
    1236            4 :                 .collect()
    1237            4 :         });
    1238       758970 :         let matches = |id: &str, doc: &Value| {
    1239       758970 :             !filter::expired_at(doc, &now)
    1240       758946 :                 && id_res
    1241       758946 :                     .as_ref()
    1242       758946 :                     .is_none_or(|res| res.iter().any(|re| re.is_match(id)))
    1243       758940 :                 && f.id_literal.is_none_or(|l| {
    1244          208 :                     if l.anchored {
    1245          128 :                         id.starts_with(l.text)
    1246              :                     } else {
    1247           80 :                         id.contains(l.text)
    1248              :                     }
    1249          208 :                 })
    1250       758858 :                 && f.types.is_none_or(|sel| {
    1251       756136 :                     let held: Vec<&str> = doc["type"]
    1252       756136 :                         .as_array()
    1253       756136 :                         .map(|a| a.iter().filter_map(Value::as_str).collect())
    1254       756136 :                         .unwrap_or_default();
    1255       756136 :                     sel.iter()
    1256       878900 :                         .any(|group| group.iter().all(|w| held.contains(&w.as_str())))
    1257       756136 :                 })
    1258        61926 :                 && f.attrs
    1259        61926 :                     .is_none_or(|attrs| attrs.iter().any(|a| has_live_attr(doc, a, &now)))
    1260        60838 :                 && q.is_none_or(|(q, ctx)| {
    1261              :                     // 4.22: `q` judges what a read may see, so an instance
    1262              :                     // past its expiresAt never makes the Entity match;
    1263              :                     // only a doc carrying one pays the copy
    1264        23580 :                     if holds_expired_instance(doc, &now) {
    1265           12 :                         let mut live = doc.clone();
    1266           12 :                         filter::strip_expired(&mut live, &now);
    1267           12 :                         antares_ql::eval::eval_q(q, &live, ctx, &|_| None)
    1268              :                     } else {
    1269        23568 :                         antares_ql::eval::eval_q(q, doc, ctx, &|_| None)
    1270              :                     }
    1271        23580 :                 })
    1272       758970 :         };
    1273              :         // ids name at most what the client sent: look each up, in id order
    1274         2452 :         let candidates: Box<dyn Iterator<Item = (&String, &Value)>> = match (f.ids, f.after_id) {
    1275          318 :             (Some(ids), after) => {
    1276          318 :                 let mut want: Vec<&str> = ids.to_vec();
    1277          318 :                 want.sort_unstable();
    1278          318 :                 want.dedup();
    1279          766 :                 want.retain(|id| after.is_none_or(|a| *id > a));
    1280          762 :                 Box::new(want.into_iter().filter_map(|id| m.get_key_value(id)))
    1281              :             }
    1282           76 :             (None, Some(a)) => Box::new(
    1283           76 :                 m.range::<str, _>((std::ops::Bound::Excluded(a), std::ops::Bound::Unbounded)),
    1284           76 :             ),
    1285         2058 :             (None, None) => Box::new(m.iter()),
    1286              :         };
    1287         2452 :         let hits = candidates
    1288       758970 :             .filter(|(id, doc)| matches(id, doc))
    1289         2452 :             .map(|(_, d)| d);
    1290        30704 :         let project = |doc: &Value| {
    1291        30704 :             let mut v = doc.clone();
    1292        30704 :             if decided {
    1293        22966 :                 if let Some(o) = v.as_object_mut() {
    1294        22966 :                     if let Some(keep) = f.keep_attrs {
    1295         7640 :                         o.retain(|k, _| !k.starts_with("http") || keep.contains(k));
    1296        21462 :                     } else if let Some(drop) = f.drop_attrs {
    1297          116 :                         o.retain(|k, _| !drop.contains(k));
    1298        21446 :                     }
    1299            0 :                 }
    1300         7738 :             }
    1301        30704 :             v
    1302        30704 :         };
    1303         2452 :         let Some(p) = f.page.as_ref().filter(|_| decided) else {
    1304          634 :             return filter::QueryOutcome {
    1305          634 :                 rows: hits.map(project).collect(),
    1306          634 :                 decided,
    1307          634 :                 paged: false,
    1308          634 :                 total: None,
    1309          634 :             };
    1310              :         };
    1311         1818 :         let offset = usize::try_from(p.offset).unwrap_or(0);
    1312         1818 :         let limit = usize::try_from(p.limit).unwrap_or(usize::MAX);
    1313         1818 :         let mut rows: Vec<Value> = Vec::new();
    1314         1818 :         let (mut matched, mut more) = (0usize, false);
    1315        29064 :         for doc in hits {
    1316        29064 :             if matched >= offset && rows.len() < limit {
    1317        20164 :                 rows.push(project(doc));
    1318        20164 :             } else if rows.len() == limit && !p.count {
    1319          190 :                 more = true;
    1320          190 :                 break;
    1321         8710 :             }
    1322              :             // the rest is counted, never copied
    1323        28874 :             matched = matched.saturating_add(1);
    1324              :         }
    1325         1818 :         let total = if p.count {
    1326          145 :             matched
    1327              :         } else {
    1328              :             // the smallest total consistent with "a next page exists"
    1329         1673 :             offset
    1330         1673 :                 .saturating_add(rows.len())
    1331         1673 :                 .saturating_add(usize::from(more))
    1332              :         };
    1333         1818 :         filter::QueryOutcome {
    1334         1818 :             rows,
    1335         1818 :             decided,
    1336         1818 :             paged: true,
    1337         1818 :             total: Some(i64::try_from(total).unwrap_or(i64::MAX)),
    1338         1818 :         }
    1339         2989 :     }
    1340              : 
    1341              :     /// One id-ordered window of docs and the size of the whole set. The
    1342              :     /// per-tenant map is a `BTreeMap` keyed by id, so the window is a skip
    1343              :     /// over an already-ordered iterator.
    1344          214 :     pub fn list_slice(
    1345          214 :         &self,
    1346          214 :         tenant: &TenantId,
    1347          214 :         kind: Kind,
    1348          214 :         offset: usize,
    1349          214 :         limit: usize,
    1350          214 :     ) -> (Vec<Value>, usize) {
    1351          214 :         let inner = self
    1352          214 :             .inner
    1353          214 :             .read()
    1354          214 :             .unwrap_or_else(std::sync::PoisonError::into_inner);
    1355          214 :         Self::map(&inner, kind)
    1356          214 :             .get(tenant.as_str())
    1357          214 :             .map(|m| {
    1358          138 :                 (
    1359          138 :                     m.values().skip(offset).take(limit).cloned().collect(),
    1360          138 :                     m.len(),
    1361          138 :                 )
    1362          138 :             })
    1363          214 :             .unwrap_or_default()
    1364          214 :     }
    1365              : 
    1366              :     /// Read-modify-write on one document. Returns `None` when absent; the
    1367              :     /// closure's error aborts without writing.
    1368       154339 :     pub async fn mutate<T, E>(
    1369       154339 :         &self,
    1370       154339 :         tenant: &TenantId,
    1371       154339 :         kind: Kind,
    1372       154339 :         id: &str,
    1373       154339 :         f: impl FnOnce(&mut Value) -> Result<T, E>,
    1374       154339 :     ) -> Option<Result<T, E>> {
    1375       154339 :         let _order = match kind {
    1376        22236 :             Kind::Entity => Some(self.emit_ordered().await),
    1377       132103 :             _ => None,
    1378              :         };
    1379       154339 :         let (result, change) = on_blocking(self.shadow.is_some(), || {
    1380       154339 :             let mut inner = self.write_inner();
    1381       154339 :             if self.is_expired(&inner, kind, tenant.as_str(), id) {
    1382            6 :                 return None; // 4.22: invalid, so absent
    1383       154333 :             }
    1384       154333 :             let doc = Self::map_mut(&mut inner, kind)
    1385       154333 :                 .get_mut(tenant.as_str())
    1386       154333 :                 .and_then(|m| m.get_mut(id))?;
    1387       150951 :             let before = doc.clone();
    1388       150951 :             let mut candidate = doc.clone();
    1389       150951 :             Some(match f(&mut candidate) {
    1390       150797 :                 Ok(t) => {
    1391       150797 :                     if candidate != before {
    1392       146693 :                         self.persist(
    1393       146693 :                             table_for(kind),
    1394       146693 :                             &key_bytes(tenant.as_str(), id),
    1395       146693 :                             Some(&candidate),
    1396       146693 :                         );
    1397       146693 :                     }
    1398       150797 :                     let change = (kind == Kind::Entity && candidate != before)
    1399       150797 :                         .then(|| (before, candidate.clone()));
    1400       150797 :                     *doc = candidate;
    1401       150797 :                     (Ok(t), change)
    1402              :                 }
    1403          154 :                 Err(e) => (Err(e), None),
    1404              :             })
    1405       154339 :         })?;
    1406       150951 :         if let Some((b, a)) = change {
    1407        17924 :             self.emit(tenant, Some(b), Some(a)).await;
    1408       133027 :         }
    1409       150951 :         Some(result)
    1410       154339 :     }
    1411              : 
    1412              :     // jsonldContexts: one keyspace for the whole process (key = context id, no
    1413              :     // tenant prefix) — Cached rows are copies of public documents shared by
    1414              :     // every tenant. Ownership of the tenant-authored kinds (Hosted,
    1415              :     // ImplicitlyCreated, 5.13.1) travels in the document's "owner" member,
    1416              :     // and every call below is answered through it (ADR-0021): the Postgres
    1417              :     // arm has the same rule as a Row-Level Security policy over a generated
    1418              :     // `tenant_id` column, and this arm, which has no policy engine under it,
    1419              :     // applies it in the store.
    1420         2492 :     pub fn context_put(
    1421         2492 :         &self,
    1422         2492 :         tenant: Option<&TenantId>,
    1423         2492 :         id: &str,
    1424         2492 :         doc: Value,
    1425         2492 :     ) -> Result<(), NgsiError> {
    1426         2492 :         on_blocking(self.shadow.is_some(), || {
    1427         2492 :             let mut inner = self.write_inner();
    1428              :             // The row that is there decides whether this call may replace it,
    1429              :             // the row that arrives decides whether it may be stored: the two
    1430              :             // halves of the policy the Postgres arm writes as USING and WITH
    1431              :             // CHECK. Both are backstops — every caller mints its own id.
    1432         2492 :             let held = inner.contexts.get(id);
    1433         2492 :             if !held.is_none_or(|r| context_row_visible(r, tenant))
    1434         2490 :                 || !context_row_visible(&doc, tenant)
    1435              :             {
    1436            2 :                 return Err(NgsiError::InternalError(
    1437            2 :                     "@context belongs to another tenant".into(),
    1438            2 :                 ));
    1439         2490 :             }
    1440         2490 :             let mut staged = Vec::new();
    1441         2490 :             self.stage(
    1442         2490 :                 &mut staged,
    1443              :                 T_JSONLD_CONTEXTS,
    1444         2490 :                 id.as_bytes().to_vec(),
    1445         2490 :                 Some(&doc),
    1446              :             );
    1447         2490 :             let cached = doc.get("kind").and_then(Value::as_str) == Some("Cached");
    1448         2490 :             inner.contexts.insert(id.to_owned(), doc);
    1449              :             // 5.13.1: "Implementations shall periodically invalidate the
    1450              :             // 'Cached' @contexts." One entry is stored per distinct external
    1451              :             // URL a request references, so without a ceiling a client that
    1452              :             // references fresh URLs grows this keyspace (and, in `file` mode,
    1453              :             // the store on disk) forever. Oldest-first, and only for the
    1454              :             // Cached kind — Hosted/ImplicitlyCreated entries are resources
    1455              :             // the broker serves on demand, never a cache.
    1456         2490 :             if cached && inner.contexts.len() > MAX_CACHED_CONTEXTS {
    1457            4 :                 let dead = oldest_cached(&inner.contexts);
    1458            4 :                 for id in dead {
    1459            2 :                     self.stage(&mut staged, T_JSONLD_CONTEXTS, id.as_bytes().to_vec(), None);
    1460            2 :                     inner.contexts.remove(&id);
    1461            2 :                 }
    1462         2486 :             }
    1463              :             // the entry and the evictions it caused, in one commit
    1464         2490 :             self.commit_staged(&staged);
    1465         2490 :             Ok(())
    1466         2492 :         })
    1467         2492 :     }
    1468              : 
    1469          932 :     pub fn context_get(&self, tenant: Option<&TenantId>, id: &str) -> Option<Value> {
    1470          932 :         self.inner
    1471          932 :             .read()
    1472          932 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
    1473          932 :             .contexts
    1474          932 :             .get(id)
    1475          932 :             .filter(|r| context_row_visible(r, tenant))
    1476          932 :             .cloned()
    1477          932 :     }
    1478              : 
    1479              :     /// Read-modify-write under the write lock; `None` when the row is absent
    1480              :     /// or another Tenant's, `Some(Err)` when `f` refused (nothing written).
    1481        10730 :     pub fn context_mutate(
    1482        10730 :         &self,
    1483        10730 :         tenant: Option<&TenantId>,
    1484        10730 :         id: &str,
    1485        10730 :         f: impl FnOnce(&mut Value) -> Result<(), ()>,
    1486        10730 :     ) -> Option<Result<(), ()>> {
    1487        10730 :         on_blocking(self.shadow.is_some(), || {
    1488        10730 :             let mut inner = self.write_inner();
    1489        10730 :             let row = inner
    1490        10730 :                 .contexts
    1491        10730 :                 .get_mut(id)
    1492        10730 :                 .filter(|r| context_row_visible(r, tenant))?;
    1493        10474 :             let mut doc = row.clone();
    1494        10474 :             if f(&mut doc).is_err() {
    1495            0 :                 return Some(Err(()));
    1496        10474 :             }
    1497        10474 :             self.persist(T_JSONLD_CONTEXTS, id.as_bytes(), Some(&doc));
    1498        10474 :             *row = doc;
    1499        10474 :             Some(Ok(()))
    1500        10730 :         })
    1501        10730 :     }
    1502              : 
    1503          230 :     pub fn context_delete(&self, tenant: Option<&TenantId>, id: &str) -> bool {
    1504          230 :         on_blocking(self.shadow.is_some(), || {
    1505          230 :             let mut inner = self.write_inner();
    1506          230 :             let hit = inner
    1507          230 :                 .contexts
    1508          230 :                 .get(id)
    1509          230 :                 .is_some_and(|r| context_row_visible(r, tenant));
    1510          230 :             if hit {
    1511          224 :                 inner.contexts.remove(id);
    1512          224 :                 self.persist(T_JSONLD_CONTEXTS, id.as_bytes(), None);
    1513          224 :             }
    1514          230 :             hit
    1515          230 :         })
    1516          230 :     }
    1517              : 
    1518              :     /// Every row without its `body` member (the `@context` document itself).
    1519              :     /// A body may be 5 MiB and only the `Cached` rows are capped in number,
    1520              :     /// so cloning whole rows here was a multi-gigabyte copy on the boot path.
    1521          204 :     pub fn context_list_meta(&self, tenant: Option<&TenantId>) -> Vec<Value> {
    1522          204 :         self.inner
    1523          204 :             .read()
    1524          204 :             .unwrap_or_else(std::sync::PoisonError::into_inner)
    1525          204 :             .contexts
    1526          204 :             .values()
    1527         4680 :             .filter(|v| context_row_visible(v, tenant))
    1528         4564 :             .map(|v| {
    1529         4564 :                 let mut row = v.clone();
    1530         4564 :                 if let Some(o) = row.as_object_mut() {
    1531         4564 :                     o.remove("body");
    1532         4564 :                 }
    1533         4564 :                 row
    1534         4564 :             })
    1535          204 :             .collect()
    1536          204 :     }
    1537              : }
    1538              : 
    1539              : /// Whether `doc` holds Attribute `iri` once 4.22 has stripped its expired
    1540              : /// instances: an instance array keeps it while one instance is live, as
    1541              : /// `filter::strip_expired` decides.
    1542         1182 : fn has_live_attr(doc: &Value, iri: &str, now: &str) -> bool {
    1543         1182 :     match doc.get(iri) {
    1544           94 :         Some(Value::Array(arr)) if iri.contains(':') => {
    1545           94 :             arr.is_empty() || arr.iter().any(|i| !filter::expired_at(i, now))
    1546              :         }
    1547            0 :         Some(_) => true,
    1548         1088 :         None => false,
    1549              :     }
    1550         1182 : }
    1551              : 
    1552              : /// Whether an Attribute instance of `doc`, a sub-Attribute's included, is
    1553              : /// past its `expiresAt`: what `filter::strip_expired` would remove.
    1554        53056 : fn holds_expired_instance(doc: &Value, now: &str) -> bool {
    1555        53056 :     doc.as_object().is_some_and(|o| {
    1556        53056 :         o.iter()
    1557       138272 :             .filter(|(k, _)| k.contains(':'))
    1558        53056 :             .filter_map(|(_, v)| v.as_array())
    1559        53056 :             .flatten()
    1560        53056 :             .any(|i| filter::expired_at(i, now) || holds_expired_instance(i, now))
    1561        53056 :     })
    1562        53056 : }
    1563              : 
    1564              : #[cfg(test)]
    1565              : mod tests {
    1566              :     use super::*;
    1567              :     use serde_json::json;
    1568              : 
    1569              :     /// A change hook that PANICS must cost only its own caller: the panic
    1570              :     /// unwinds through that writer, and every later writer finds the store
    1571              :     /// usable — locks recover from the poisoning instead of turning one bad
    1572              :     /// hook into a dead store.
    1573              :     #[tokio::test(flavor = "multi_thread")]
    1574            2 :     async fn a_panicking_hook_leaves_the_store_usable() {
    1575              :         use std::sync::Arc;
    1576            2 :         let s = Arc::new(Store::default());
    1577            2 :         s.set_change_hook(Arc::new(
    1578              :             |_t: &TenantId,
    1579              :              _b: Option<Value>,
    1580              :              after: Option<Value>,
    1581              :              _v: antares_store::ChangeVersion|
    1582            4 :              -> antares_store::HookFuture<'_> {
    1583            4 :                 Box::pin(async move {
    1584            4 :                     if after.as_ref().and_then(|a| a.get("boom")).is_some() {
    1585            2 :                         panic!("hook panic");
    1586            2 :                     }
    1587            2 :                 })
    1588            4 :             },
    1589              :         ));
    1590            2 :         let t = TenantId::new("hook-panic").expect("tenant");
    1591            2 :         let s2 = s.clone();
    1592            2 :         let t2 = t.clone();
    1593            2 :         let poisoned = tokio::spawn(async move {
    1594            2 :             s2.upsert(
    1595            2 :                 &t2,
    1596            2 :                 Kind::Entity,
    1597            2 :                 "urn:x:boom",
    1598            2 :                 json!({"id": "urn:x:boom", "type": ["T"], "boom": true}),
    1599            2 :             )
    1600            2 :             .await;
    1601            2 :         })
    1602            2 :         .await;
    1603            2 :         assert!(poisoned.is_err(), "the hook's panic reaches its own caller");
    1604              :         // the NEXT writer and reader are untouched
    1605            2 :         assert!(
    1606            2 :             s.create(
    1607            2 :                 &t,
    1608            2 :                 Kind::Entity,
    1609            2 :                 "urn:x:after",
    1610            2 :                 json!({"id": "urn:x:after", "type": ["T"]})
    1611            2 :             )
    1612            2 :             .await
    1613              :         );
    1614            2 :         assert!(
    1615            2 :             s.get(&t, Kind::Entity, "urn:x:after").is_some(),
    1616              :             "one panicking hook must not take the store down"
    1617              :         );
    1618            2 :         assert!(
    1619            2 :             s.get(&t, Kind::Entity, "urn:x:boom").is_some(),
    1620            2 :             "the write that triggered the panic still committed — the hook runs after the commit"
    1621            2 :         );
    1622            2 :     }
    1623              : 
    1624              :     /// Two writers to the same entity: the change hook fires in COMMIT
    1625              :     /// order. The first writer's hook is gated open only after the second
    1626              :     /// writer has had every chance to overtake it — if the second commit may
    1627              :     /// emit before the first commit's emit, the notification pipeline sees
    1628              :     /// the versions reversed and records stale state as newest.
    1629              :     #[tokio::test(flavor = "multi_thread")]
    1630            2 :     async fn change_hook_fires_in_commit_order_per_entity() {
    1631              :         use std::sync::atomic::{AtomicBool, Ordering};
    1632              :         use std::sync::Arc;
    1633            2 :         let s = Arc::new(Store::default());
    1634            2 :         let seen: Arc<std::sync::Mutex<Vec<i64>>> = Arc::default();
    1635            2 :         let entered = Arc::new(AtomicBool::new(false));
    1636            2 :         let released = Arc::new(AtomicBool::new(false));
    1637              :         {
    1638            2 :             let (seen, entered, released) = (seen.clone(), entered.clone(), released.clone());
    1639            2 :             s.set_change_hook(Arc::new(
    1640              :                 move |_t: &TenantId,
    1641              :                       _b: Option<Value>,
    1642              :                       after: Option<Value>,
    1643              :                       _v: antares_store::ChangeVersion|
    1644            4 :                       -> antares_store::HookFuture<'_> {
    1645            4 :                     let (seen, entered, released) =
    1646            4 :                         (seen.clone(), entered.clone(), released.clone());
    1647            4 :                     Box::pin(async move {
    1648            4 :                         let v = after
    1649            4 :                             .as_ref()
    1650            4 :                             .and_then(|a| a.get("v"))
    1651            4 :                             .and_then(Value::as_i64)
    1652            4 :                             .unwrap_or(-1);
    1653            4 :                         if v == 1 {
    1654            2 :                             entered.store(true, Ordering::SeqCst);
    1655           63 :                             while !released.load(Ordering::SeqCst) {
    1656           61 :                                 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
    1657              :                             }
    1658            2 :                         }
    1659            4 :                         seen.lock()
    1660            4 :                             .unwrap_or_else(std::sync::PoisonError::into_inner)
    1661            4 :                             .push(v);
    1662            4 :                     })
    1663            4 :                 },
    1664              :             ));
    1665              :         }
    1666            2 :         let t = TenantId::new("emit-order").expect("tenant");
    1667            4 :         let doc = |v: i64| json!({"id": "urn:x:1", "type": ["T"], "v": v});
    1668            2 :         let s1 = s.clone();
    1669            2 :         let t1c = t.clone();
    1670            2 :         let w1 = tokio::spawn(async move {
    1671            2 :             s1.upsert(&t1c, Kind::Entity, "urn:x:1", doc(1)).await;
    1672            2 :         });
    1673            4 :         while !entered.load(std::sync::atomic::Ordering::SeqCst) {
    1674            2 :             tokio::time::sleep(std::time::Duration::from_millis(1)).await;
    1675              :         }
    1676              :         // writer 1 has committed and sits inside its hook. Writer 2 now has
    1677              :         // every chance to commit AND emit before writer 1's emit finishes.
    1678            2 :         let s2 = s.clone();
    1679            2 :         let t2c = t.clone();
    1680            2 :         let w2 = tokio::spawn(async move {
    1681            2 :             s2.upsert(&t2c, Kind::Entity, "urn:x:1", doc(2)).await;
    1682            2 :         });
    1683            2 :         tokio::time::sleep(std::time::Duration::from_millis(60)).await;
    1684            2 :         released.store(true, std::sync::atomic::Ordering::SeqCst);
    1685            2 :         w1.await.expect("writer 1");
    1686            2 :         w2.await.expect("writer 2");
    1687            2 :         assert_eq!(
    1688            2 :             *seen
    1689            2 :                 .lock()
    1690            2 :                 .unwrap_or_else(std::sync::PoisonError::into_inner),
    1691            2 :             vec![1, 2],
    1692            2 :             "hooks must fire in commit order, or the consumer records stale state as newest"
    1693            2 :         );
    1694            2 :     }
    1695              : 
    1696              :     #[tokio::test]
    1697            2 :     async fn commit_queue_counts_writers() {
    1698              :         // Every write passes through the counted critical section.
    1699            2 :         let s = Store::default();
    1700            2 :         assert_eq!(s.commit_queue(), (0, 0));
    1701            2 :         let t = TenantId::new("t").unwrap();
    1702            2 :         s.create(&t, Kind::Entity, "urn:a", json!({"id": "urn:a"}))
    1703            2 :             .await;
    1704            2 :         let (depth, peak) = s.commit_queue();
    1705            2 :         assert_eq!(depth, 0, "no writer in flight after the call returns");
    1706            2 :         assert!(peak >= 1, "the write itself must register in the peak");
    1707            2 :     }
    1708              : 
    1709              :     /// What a store reports about itself, for an operator reading
    1710              :     /// `/q/health`: the memory and file modes are one backend with two
    1711              :     /// durability shapes, and the health body must not present them as the
    1712              :     /// same thing.
    1713              :     #[test]
    1714            2 :     fn a_store_reports_the_engine_it_actually_runs() {
    1715            2 :         let dir = tempdir("engine");
    1716            2 :         let mem = crate::store::any::AnyStore::Mem(Store::default());
    1717            2 :         let file = crate::store::any::AnyStore::Mem(Store::open_file(&dir).expect("open"));
    1718            2 :         assert_eq!(mem.version_info()["engine"], "memory");
    1719            2 :         assert_eq!(file.version_info()["engine"], "redb");
    1720            2 :     }
    1721              : 
    1722              :     /// The commit queue is a `file`-mode signal: it exists because redb has
    1723              :     /// one writer and commits fsync through it. A pure in-memory store has no
    1724              :     /// such committer, so it reports nothing rather than a number that would
    1725              :     /// read as the same thing and mean something else.
    1726              :     #[test]
    1727            2 :     fn only_a_shadowed_store_reports_a_commit_queue() {
    1728              :         use crate::store::any::AnyStore;
    1729            2 :         assert_eq!(AnyStore::Mem(Store::default()).commit_queue(), None);
    1730            2 :         let dir = tempdir("commit-queue");
    1731            2 :         let s = Store::open_file(&dir).expect("open");
    1732            2 :         assert!(
    1733            2 :             AnyStore::Mem(s).commit_queue().is_some(),
    1734              :             "a durable store reports the queue behind its single committer"
    1735              :         );
    1736            2 :     }
    1737              : 
    1738              :     #[tokio::test]
    1739            2 :     async fn tenant_isolation() {
    1740            2 :         let s = Store::default();
    1741            2 :         let t1 = TenantId::new("t1").unwrap();
    1742            2 :         let t2 = TenantId::new("t2").unwrap();
    1743            2 :         assert!(
    1744            2 :             s.create(&t1, Kind::Entity, "urn:a", json!({"id": "urn:a"}))
    1745            2 :                 .await
    1746              :         );
    1747            2 :         assert!(s.get(&t2, Kind::Entity, "urn:a").is_none());
    1748            2 :         assert!(s.get(&t1, Kind::Entity, "urn:a").is_some());
    1749            2 :         assert!(!s.create(&t1, Kind::Entity, "urn:a", json!({})).await);
    1750            2 :         assert!(s.create(&t2, Kind::Entity, "urn:a", json!({})).await);
    1751            2 :     }
    1752              : 
    1753              :     /// 4.6.3 allows a comma as the seconds-fraction separator. Byte order
    1754              :     /// puts ',' (0x2C) before both '.' and 'Z', so a comma-form instance that
    1755              :     /// has NOT expired reads as expired against the point-form `now` this
    1756              :     /// store stamps — the write path would drop a live instance, and the
    1757              :     /// postgres path (which parses through `try_timestamptz`) would keep it.
    1758              :     #[tokio::test]
    1759            2 :     async fn a_comma_fraction_instance_is_not_pruned_before_it_expires() {
    1760            2 :         let now = "2026-09-01T12:00:00.000Z";
    1761            2 :         let mut live = json!({
    1762            2 :             "id": "urn:x", "type": ["T"],
    1763            2 :             "https://a/attr": [{"value": 1, "expiresAt": "2026-09-01T12:00:00,500Z"}]
    1764              :         });
    1765            2 :         assert!(
    1766            2 :             !prune_expired_instances(&mut live, now),
    1767              :             "live instance kept"
    1768              :         );
    1769            2 :         assert_eq!(live["https://a/attr"].as_array().map(Vec::len), Some(1));
    1770              : 
    1771              :         // one that HAS expired still goes, comma form included, and takes the
    1772              :         // attribute with it when it was the last instance
    1773            2 :         let mut gone = json!({
    1774            2 :             "id": "urn:x", "type": ["T"],
    1775            2 :             "https://a/attr": [{"value": 1, "expiresAt": "2026-09-01T11:59:59,500Z"}]
    1776              :         });
    1777            2 :         assert!(prune_expired_instances(&mut gone, now));
    1778            2 :         assert!(gone.get("https://a/attr").is_none());
    1779              : 
    1780              :         // the entity-level sweep reads the same stamp the same way
    1781            2 :         let s = Store::default();
    1782            2 :         let t = TenantId::new("t").expect("tenant");
    1783            2 :         assert!(
    1784            2 :             s.create(
    1785            2 :                 &t,
    1786            2 :                 Kind::Entity,
    1787            2 :                 "urn:live",
    1788            2 :                 json!({"id": "urn:live", "type": ["T"], "expiresAt": "2026-09-01T12:00:00,500Z"})
    1789            2 :             )
    1790            2 :             .await
    1791              :         );
    1792            2 :         assert!(
    1793            2 :             s.create(
    1794            2 :                 &t,
    1795            2 :                 Kind::Entity,
    1796            2 :                 "urn:gone",
    1797            2 :                 json!({"id": "urn:gone", "type": ["T"], "expiresAt": "2026-09-01T11:59:59,500Z"})
    1798            2 :             )
    1799            2 :             .await
    1800              :         );
    1801            2 :         assert_eq!(s.sweep_expired(now), 1, "only the expired one is reaped");
    1802            2 :         assert!(s.get(&t, Kind::Entity, "urn:live").is_some());
    1803            2 :         assert!(s.get(&t, Kind::Entity, "urn:gone").is_none());
    1804            2 :     }
    1805              : 
    1806              :     /// The same assertion over EVERY kind and the whole read/write surface,
    1807              :     /// with the SAME id on both sides — the shape a tenant-blind map gets
    1808              :     /// wrong, because it answers the neighbour's document instead of `None`.
    1809              :     #[tokio::test]
    1810            2 :     async fn no_kind_leaks_across_tenants() {
    1811            2 :         let s = Store::default();
    1812            2 :         let a = TenantId::new("t-a").expect("tenant");
    1813            2 :         let b = TenantId::new("t-b").expect("tenant");
    1814            2 :         let id = "urn:ngsi-ld:shared:1";
    1815           20 :         for kind in ALL_KINDS {
    1816           20 :             assert!(
    1817           20 :                 s.create(&a, kind, id, json!({"id": id, "owner": "a"}))
    1818           20 :                     .await
    1819            2 :             );
    1820            2 : 
    1821           20 :             assert!(s.get(&b, kind, id).is_none(), "{kind:?}: get");
    1822           20 :             assert!(s.list(&b, kind).is_empty(), "{kind:?}: list");
    1823           20 :             assert!(
    1824           20 :                 s.list_page(&b, kind, None, 100).is_empty(),
    1825            2 :                 "{kind:?}: list_page"
    1826            2 :             );
    1827           20 :             assert_eq!(
    1828           20 :                 s.list_slice(&b, kind, 0, 100),
    1829           20 :                 (Vec::new(), 0),
    1830            2 :                 "{kind:?}: list_slice"
    1831            2 :             );
    1832            2 : 
    1833            2 :             // no write reaches the neighbour's document either
    1834           20 :             assert!(!s.delete(&b, kind, id).await, "{kind:?}: delete");
    1835           20 :             assert!(
    1836           20 :                 s.mutate::<(), ()>(&b, kind, id, |d| {
    1837            2 :                     d["owner"] = json!("b");
    1838            2 :                     Ok(())
    1839            2 :                 })
    1840           20 :                 .await
    1841           20 :                 .is_none(),
    1842            2 :                 "{kind:?}: mutate"
    1843            2 :             );
    1844            2 : 
    1845            2 :             // the same id under another tenant is a create, not a conflict
    1846           20 :             assert!(
    1847           20 :                 s.create(&b, kind, id, json!({"id": id, "owner": "b"}))
    1848           20 :                     .await
    1849            2 :             );
    1850           20 :             assert_eq!(s.get(&a, kind, id).expect("a keeps its own")["owner"], "a");
    1851           20 :             assert_eq!(s.get(&b, kind, id).expect("b has its own")["owner"], "b");
    1852            2 : 
    1853            2 :             // and b deleting its own leaves a's untouched
    1854           20 :             assert!(s.delete(&b, kind, id).await, "{kind:?}: delete own");
    1855           20 :             assert!(s.get(&b, kind, id).is_none(), "{kind:?}: deleted");
    1856           20 :             assert_eq!(
    1857           20 :                 s.get(&a, kind, id)
    1858           20 :                     .expect("survives the neighbour's delete")["owner"],
    1859            2 :                 "a"
    1860            2 :             );
    1861           20 :             assert!(s.delete(&a, kind, id).await, "{kind:?}: cleanup");
    1862            2 :         }
    1863            2 :     }
    1864              : 
    1865           28 :     fn tempdir(name: &str) -> std::path::PathBuf {
    1866           28 :         let dir = std::env::temp_dir().join(format!("antares-store-{name}-{}", std::process::id()));
    1867           28 :         let _ = std::fs::remove_dir_all(&dir);
    1868           28 :         std::fs::create_dir_all(&dir).expect("tempdir");
    1869           28 :         dir
    1870           28 :     }
    1871              : 
    1872              :     /// `open_file` promises "Any open/format/decode error refuses to start —
    1873              :     /// never silently serve partial data", and the boot rebuild's kind loop
    1874              :     /// keeps it: an absent table has nothing to load, any other error refuses.
    1875              :     /// The @context table is read as `if let Ok(...)` instead, so a table that
    1876              :     /// exists and cannot be opened counts as an empty one — and that is the
    1877              :     /// table whose absence costs a Tenant its own documents, since Hosted and
    1878              :     /// ImplicitlyCreated @contexts (5.13.1) hold term mappings authored
    1879              :     /// through its requests. The broker would come up and serve without them.
    1880              :     ///
    1881              :     /// A table of the same name under different key/value types is redb's
    1882              :     /// `TableTypeMismatch`: the shape a file written by another binary, or by
    1883              :     /// an older schema, actually has.
    1884              :     #[test]
    1885            2 :     fn an_unreadable_table_refuses_the_boot_instead_of_reading_as_empty() {
    1886            2 :         let dir = tempdir("unreadable-context-table");
    1887            2 :         let path = dir.join("antares.redb");
    1888            2 :         {
    1889            2 :             let db = Database::create(&path).expect("db");
    1890            2 :             let mut tx = db.begin_write().expect("tx");
    1891            2 :             tx.set_durability(Durability::Immediate).expect("dur");
    1892            2 :             {
    1893            2 :                 let mut m = tx.open_table(T_META).expect("meta");
    1894            2 :                 m.insert("format", FORMAT_VERSION).expect("insert");
    1895            2 :             }
    1896            2 :             {
    1897            2 :                 let def = ::redb::TableDefinition::<&str, &str>::new("jsonld_contexts");
    1898            2 :                 let mut t = tx.open_table(def).expect("mismatched contexts table");
    1899            2 :                 t.insert("urn:ngsi-ld:ctx", "{}").expect("insert");
    1900            2 :             }
    1901            2 :             tx.commit().expect("commit");
    1902            2 :         }
    1903            2 :         let db = Database::create(&path).expect("reopen");
    1904            2 :         let Err(err) = Store::from_database(db, &path.display().to_string()) else {
    1905            0 :             panic!("a table that cannot be opened must refuse the boot, not start without it");
    1906              :         };
    1907            2 :         assert!(!err.is_empty(), "the refusal says what failed");
    1908            2 :     }
    1909              : 
    1910              :     /// The same read, in the guard that exists to refuse a file this binary
    1911              :     /// cannot vouch for: data present with no format marker. Skipping a table
    1912              :     /// it cannot open makes the guard conclude the file is empty and stamp
    1913              :     /// this binary's marker onto it — a write into a file it is about to
    1914              :     /// declare unreadable, and one that costs the operator the guard's own
    1915              :     /// diagnosis on every later start.
    1916              :     #[test]
    1917            2 :     fn the_marker_guard_does_not_stamp_a_file_it_could_not_read() {
    1918            2 :         let dir = tempdir("unstamped-unreadable");
    1919            2 :         let path = dir.join("antares.redb");
    1920            2 :         {
    1921            2 :             let db = Database::create(&path).expect("db");
    1922            2 :             let mut tx = db.begin_write().expect("tx");
    1923            2 :             tx.set_durability(Durability::Immediate).expect("dur");
    1924            2 :             let def = ::redb::TableDefinition::<&str, &str>::new("entities");
    1925            2 :             let mut t = tx.open_table(def).expect("mismatched entities table");
    1926            2 :             t.insert("k", "v").expect("insert");
    1927            2 :             drop(t);
    1928            2 :             tx.commit().expect("commit");
    1929            2 :         }
    1930            2 :         let db = Database::create(&path).expect("reopen");
    1931            2 :         let Err(_) = Store::from_database(db, &path.display().to_string()) else {
    1932            0 :             panic!("an unreadable table with no marker must refuse the boot");
    1933              :         };
    1934            2 :         let db = Database::create(&path).expect("reopen after the refusal");
    1935            2 :         let rt = db.begin_read().expect("read tx");
    1936            2 :         let stamped = match rt.open_table(T_META) {
    1937            0 :             Ok(m) => m.get("format").expect("get").is_some(),
    1938            2 :             Err(::redb::TableError::TableDoesNotExist(_)) => false,
    1939            0 :             Err(e) => panic!("{e}"),
    1940              :         };
    1941            2 :         assert!(
    1942            2 :             !stamped,
    1943              :             "a file the guard could not read must not carry this binary's format marker"
    1944              :         );
    1945            2 :     }
    1946              : 
    1947              :     /// Every kind + contexts round-trip through a close/reopen.
    1948              :     #[tokio::test]
    1949            2 :     async fn file_mode_survives_reopen() {
    1950            2 :         let dir = tempdir("reopen");
    1951            2 :         let t = TenantId::new("tenant_a-1").expect("tenant");
    1952              :         {
    1953            2 :             let s = Store::open_file(&dir).expect("open");
    1954           20 :             for kind in ALL_KINDS {
    1955           20 :                 assert!(
    1956           20 :                     s.create(&t, kind, "urn:x:1", json!({"kind": format!("{kind:?}")}))
    1957           20 :                         .await
    1958              :                 );
    1959              :             }
    1960              :             // a row the writers actually produce: the kind is what decides
    1961              :             // whose it is (ADR-0021), and a Cached copy belongs to no Tenant
    1962            2 :             s.context_put(
    1963            2 :                 None,
    1964            2 :                 "ctx1",
    1965            2 :                 json!({"localId": "ctx1", "kind": "Cached", "body": {"@context": {}}}),
    1966              :             )
    1967            2 :             .expect("store");
    1968              :         }
    1969            2 :         let s = Store::open_file(&dir).expect("reopen");
    1970           20 :         for kind in ALL_KINDS {
    1971           20 :             assert_eq!(
    1972           20 :                 s.get(&t, kind, "urn:x:1").expect("survives")["kind"],
    1973           20 :                 format!("{kind:?}")
    1974              :             );
    1975              :         }
    1976            2 :         assert!(s.context_get(None, "ctx1").is_some());
    1977              :         // tenant isolation intact after rebuild
    1978            2 :         assert!(s
    1979            2 :             .get(&TenantId::default(), Kind::Entity, "urn:x:1")
    1980            2 :             .is_none());
    1981            2 :         let _ = std::fs::remove_dir_all(&dir);
    1982            2 :     }
    1983              : 
    1984              :     /// Deletes and mutations reach redb — no phantom state after restart.
    1985              :     #[tokio::test]
    1986            2 :     async fn file_mode_deletes_and_updates_persist() {
    1987            2 :         let dir = tempdir("delete");
    1988            2 :         let t = TenantId::default();
    1989              :         {
    1990            2 :             let s = Store::open_file(&dir).expect("open");
    1991            2 :             s.create(&t, Kind::Entity, "urn:gone", json!({"n": 1}))
    1992            2 :                 .await;
    1993            2 :             s.create(&t, Kind::Entity, "urn:kept", json!({"n": 1}))
    1994            2 :                 .await;
    1995            2 :             assert!(s.delete(&t, Kind::Entity, "urn:gone").await);
    1996            2 :             let r: Option<Result<(), ()>> = s
    1997            2 :                 .mutate(&t, Kind::Entity, "urn:kept", |d| {
    1998            2 :                     d["n"] = json!(2);
    1999            2 :                     Ok(())
    2000            2 :                 })
    2001            2 :                 .await;
    2002            2 :             assert!(matches!(r, Some(Ok(()))));
    2003            2 :             s.context_put(None, "ctx", json!({"localId": "ctx", "kind": "Cached"}))
    2004            2 :                 .expect("store");
    2005            2 :             assert!(s.context_delete(None, "ctx"));
    2006              :         }
    2007            2 :         let s = Store::open_file(&dir).expect("reopen");
    2008            2 :         assert!(
    2009            2 :             s.get(&t, Kind::Entity, "urn:gone").is_none(),
    2010              :             "phantom 409 trap"
    2011              :         );
    2012            2 :         assert_eq!(s.get(&t, Kind::Entity, "urn:kept").expect("kept")["n"], 2);
    2013            2 :         assert!(s.context_get(None, "ctx").is_none());
    2014            2 :         let _ = std::fs::remove_dir_all(&dir);
    2015            2 :     }
    2016              : 
    2017              :     /// A future-format file refuses to load with a clear message.
    2018              :     #[test]
    2019            2 :     fn file_mode_refuses_format_mismatch() {
    2020            2 :         let dir = tempdir("format");
    2021            2 :         {
    2022            2 :             let db = Database::create(dir.join("antares.redb")).expect("db");
    2023            2 :             let mut tx = db.begin_write().expect("tx");
    2024            2 :             tx.set_durability(Durability::Immediate).expect("dur");
    2025            2 :             tx.open_table(T_META)
    2026            2 :                 .expect("meta")
    2027            2 :                 .insert("format", "999")
    2028            2 :                 .expect("insert");
    2029            2 :             tx.commit().expect("commit");
    2030            2 :         }
    2031            2 :         let err = match Store::open_file(&dir) {
    2032            2 :             Err(e) => e,
    2033            0 :             Ok(_) => panic!("must refuse a format-999 file"),
    2034              :         };
    2035            2 :         assert!(err.contains("format 999"), "err: {err}");
    2036            2 :         let _ = std::fs::remove_dir_all(&dir);
    2037            2 :     }
    2038              : 
    2039              :     /// A file that HOLDS rows but carries no format marker was written by a
    2040              :     /// binary whose key/value shape this one cannot vouch for. Refusing is
    2041              :     /// the whole point of the marker — serving it would answer requests from
    2042              :     /// data that may be misread. An empty file, by contrast, is just a fresh
    2043              :     /// one and gets the marker stamped.
    2044              :     #[test]
    2045            2 :     fn file_mode_refuses_data_without_a_format_marker() {
    2046            2 :         let dir = tempdir("nomarker");
    2047            2 :         {
    2048            2 :             let db = Database::create(dir.join("antares.redb")).expect("db");
    2049            2 :             let mut tx = db.begin_write().expect("tx");
    2050            2 :             tx.set_durability(Durability::Immediate).expect("dur");
    2051            2 :             {
    2052            2 :                 let mut t = tx.open_table(T_ENTITIES).expect("entities");
    2053            2 :                 let mut k = b"plain".to_vec();
    2054            2 :                 k.push(0);
    2055            2 :                 k.extend_from_slice(b"urn:e:1");
    2056            2 :                 let bytes = serde_json::to_vec(&json!({"id": "urn:e:1"})).expect("serialize");
    2057            2 :                 t.insert(k.as_slice(), bytes.as_slice()).expect("insert");
    2058            2 :             }
    2059            2 :             // deliberately NO meta table
    2060            2 :             tx.commit().expect("commit");
    2061            2 :         }
    2062            2 :         let err = match Store::open_file(&dir) {
    2063            2 :             Err(e) => e,
    2064            0 :             Ok(_) => panic!("must refuse data with no format marker"),
    2065              :         };
    2066            2 :         assert!(err.contains("no format marker"), "err: {err}");
    2067            2 :         assert!(err.contains("antares.redb"), "the file is named: {err}");
    2068            2 :         let _ = std::fs::remove_dir_all(&dir);
    2069              : 
    2070              :         // an EMPTY unmarked file is a fresh store, not a refusal
    2071            2 :         let fresh = tempdir("nomarker-empty");
    2072            2 :         {
    2073            2 :             let db = Database::create(fresh.join("antares.redb")).expect("db");
    2074            2 :             let tx = db.begin_write().expect("tx");
    2075            2 :             tx.commit().expect("commit");
    2076            2 :         }
    2077            2 :         Store::open_file(&fresh).expect("an empty file is a fresh store");
    2078            2 :         let _ = std::fs::remove_dir_all(&fresh);
    2079            2 :     }
    2080              : 
    2081              :     /// 4.22: "expiresAt is defined as the system temporal Property at which a
    2082              :     /// certain Entity, Property or Relationship shall become invalid." The
    2083              :     /// clause sanctions the DELETION lagging, not the invalidity — so the
    2084              :     /// write paths must agree with the read boundary the instant the stamp
    2085              :     /// passes. Before this, the same id was simultaneously a 404 on retrieve,
    2086              :     /// a 409 on create and a 204 on delete for a whole sweep interval.
    2087              :     #[tokio::test]
    2088            2 :     async fn an_expired_entity_is_absent_to_writes_too() {
    2089            2 :         let s = Store::default();
    2090            2 :         let t = TenantId::default();
    2091            2 :         let dead = json!({"id": "urn:e", "type": ["T"], "expiresAt": "2000-01-01T00:00:00Z"});
    2092            2 :         let live = json!({"id": "urn:l", "type": ["T"], "expiresAt": "2999-01-01T00:00:00Z"});
    2093            2 :         assert!(s.create(&t, Kind::Entity, "urn:e", dead.clone()).await);
    2094            2 :         assert!(s.create(&t, Kind::Entity, "urn:l", live.clone()).await);
    2095              : 
    2096              :         // patching or deleting something already invalid is a 404, not a 204
    2097            2 :         assert!(s
    2098            2 :             .mutate(&t, Kind::Entity, "urn:e", |_d| Ok::<(), ()>(()))
    2099            2 :             .await
    2100            2 :             .is_none());
    2101            2 :         assert!(
    2102            2 :             !s.delete(&t, Kind::Entity, "urn:e").await,
    2103              :             "expired delete is 404"
    2104              :         );
    2105              :         // …and creating over it succeeds instead of raising AlreadyExists
    2106            2 :         assert!(
    2107            2 :             s.create(&t, Kind::Entity, "urn:e", json!({"id": "urn:e", "n": 1}))
    2108            2 :                 .await,
    2109              :             "an expired id must not 409 a create"
    2110              :         );
    2111            2 :         assert_eq!(
    2112            2 :             s.get(&t, Kind::Entity, "urn:e").expect("recreated")["n"],
    2113              :             1,
    2114              :             "the create must have replaced the expired document"
    2115              :         );
    2116              : 
    2117              :         // an UNEXPIRED entity keeps every one of those answers
    2118            2 :         assert!(
    2119            2 :             !s.create(&t, Kind::Entity, "urn:l", live.clone()).await,
    2120              :             "409"
    2121              :         );
    2122            2 :         assert!(s
    2123            2 :             .mutate(&t, Kind::Entity, "urn:l", |_d| Ok::<(), ()>(()))
    2124            2 :             .await
    2125            2 :             .is_some());
    2126            2 :         assert!(s.delete(&t, Kind::Entity, "urn:l").await);
    2127              : 
    2128              :         // upsert over an expired id reports CREATED (201 + Location), not
    2129              :         // updated
    2130            2 :         s.create(&t, Kind::Entity, "urn:x", dead.clone()).await;
    2131            2 :         assert!(
    2132            2 :             !s.upsert(&t, Kind::Entity, "urn:x", json!({"id": "urn:x"}))
    2133            2 :                 .await,
    2134              :             "an expired id must upsert as created"
    2135              :         );
    2136            2 :         assert!(
    2137            2 :             s.upsert(&t, Kind::Entity, "urn:x", json!({"id": "urn:x", "n": 2}))
    2138            2 :                 .await,
    2139              :             "and the live one that replaced it as updated"
    2140              :         );
    2141              : 
    2142              :         // 4.22 is an ENTITY stamp: other kinds keep their own expiry rules
    2143            2 :         assert!(
    2144            2 :             s.create(&t, Kind::Subscription, "urn:s", dead.clone())
    2145            2 :                 .await
    2146              :         );
    2147            2 :         assert!(
    2148            2 :             !s.create(&t, Kind::Subscription, "urn:s", dead).await,
    2149              :             "a subscription id still 409s"
    2150              :         );
    2151            2 :         assert!(s.delete(&t, Kind::Subscription, "urn:s").await);
    2152            2 :     }
    2153              : 
    2154              :     /// The change hook drives every notification and all temporal
    2155              :     /// auto-recording, so its contract is: create emits (None, Some), delete
    2156              :     /// emits (Some, None), a real mutate emits both images — and, just as
    2157              :     /// load-bearing, a NON-entity write and a no-op mutate emit NOTHING. A
    2158              :     /// subscription leaking into the hook would be mirrored into temporal
    2159              :     /// storage; a no-op emitting would re-notify every subscriber on every
    2160              :     /// idempotent PATCH.
    2161              :     #[tokio::test]
    2162            2 :     async fn the_change_hook_fires_for_entity_changes_only() {
    2163              :         use std::sync::{Arc, Mutex};
    2164              :         type Images = (Option<Value>, Option<Value>);
    2165            2 :         let seen: Arc<Mutex<Vec<Images>>> = Arc::default();
    2166            2 :         let s = Store::default();
    2167            2 :         let t = TenantId::default();
    2168            2 :         let rec = Arc::clone(&seen);
    2169            2 :         s.set_change_hook(Arc::new(
    2170              :             move |_t: &TenantId,
    2171              :                   before: Option<Value>,
    2172              :                   after: Option<Value>,
    2173              :                   _v: antares_store::ChangeVersion|
    2174            6 :                   -> antares_store::HookFuture<'_> {
    2175            6 :                 let rec = Arc::clone(&rec);
    2176            6 :                 Box::pin(async move {
    2177            6 :                     rec.lock().expect("record").push((before, after));
    2178            6 :                 })
    2179            6 :             },
    2180              :         ));
    2181              : 
    2182            2 :         s.create(&t, Kind::Entity, "urn:e", json!({"id": "urn:e", "n": 1}))
    2183            2 :             .await;
    2184              :         // a write on another kind must not reach the hook at all
    2185            2 :         s.create(&t, Kind::Subscription, "urn:s", json!({"id": "urn:s"}))
    2186            2 :             .await;
    2187            2 :         s.upsert(
    2188            2 :             &t,
    2189            2 :             Kind::Subscription,
    2190            2 :             "urn:s",
    2191            2 :             json!({"id": "urn:s", "n": 9}),
    2192            2 :         )
    2193            2 :         .await;
    2194            2 :         s.delete(&t, Kind::Subscription, "urn:s").await;
    2195              :         // a mutate that changes nothing is not a change
    2196            2 :         let _ = s
    2197            2 :             .mutate(&t, Kind::Entity, "urn:e", |_d| Ok::<(), ()>(()))
    2198            2 :             .await;
    2199              :         // a real one is
    2200            2 :         let _ = s
    2201            2 :             .mutate(&t, Kind::Entity, "urn:e", |d| {
    2202            2 :                 d["n"] = json!(2);
    2203            2 :                 Ok::<(), ()>(())
    2204            2 :             })
    2205            2 :             .await;
    2206              :         // an aborted mutate writes nothing and emits nothing
    2207            2 :         let _ = s
    2208            2 :             .mutate(&t, Kind::Entity, "urn:e", |d| {
    2209            2 :                 d["n"] = json!(3);
    2210            2 :                 Err::<(), &str>("no")
    2211            2 :             })
    2212            2 :             .await;
    2213            2 :         s.delete(&t, Kind::Entity, "urn:e").await;
    2214              : 
    2215            2 :         let seen = seen.lock().expect("read");
    2216            2 :         assert_eq!(seen.len(), 3, "emitted: {seen:?}");
    2217            2 :         assert!(seen[0].0.is_none() && seen[0].1.is_some(), "create");
    2218            2 :         assert_eq!(seen[1].0.as_ref().expect("before")["n"], 1);
    2219            2 :         assert_eq!(seen[1].1.as_ref().expect("after")["n"], 2);
    2220            2 :         assert!(seen[2].0.is_some() && seen[2].1.is_none(), "delete");
    2221              :         // the aborted mutate left the document alone
    2222            2 :         assert!(s.get(&t, Kind::Entity, "urn:e").is_none());
    2223            2 :     }
    2224              : 
    2225              :     /// The supported backup route is stop-copy — close the broker, copy
    2226              :     /// the file, reopen the copy. This test IS that route.
    2227              :     #[tokio::test]
    2228            2 :     async fn file_mode_stop_copy_backup_restores() {
    2229            2 :         let dir = tempdir("backup");
    2230            2 :         let restore = tempdir("restore");
    2231            2 :         let t = TenantId::default();
    2232              :         {
    2233            2 :             let s = Store::open_file(&dir).expect("open");
    2234            2 :             s.create(&t, Kind::Entity, "urn:b", json!({"v": 42})).await;
    2235              :         } // broker stopped — file quiescent
    2236            2 :         std::fs::copy(dir.join("antares.redb"), restore.join("antares.redb")).expect("copy");
    2237            2 :         let s = Store::open_file(&restore).expect("open backup");
    2238            2 :         assert_eq!(s.get(&t, Kind::Entity, "urn:b").expect("restored")["v"], 42);
    2239            2 :         let _ = std::fs::remove_dir_all(&dir);
    2240            2 :         let _ = std::fs::remove_dir_all(&restore);
    2241            2 :     }
    2242              : 
    2243              :     /// 4.22: reaping an expired entity is a state change like any other, so
    2244              :     /// it must be durable — after a sweep the doc stays gone across a reopen,
    2245              :     /// whatever the tenant key on disk looks like, while a doc that has not
    2246              :     /// expired survives both.
    2247              :     #[test]
    2248            2 :     fn file_mode_sweep_removals_persist() {
    2249            2 :         let dir = tempdir("sweep-persist");
    2250              :         // Seed the file directly: one tenant key the boot rebuild accepts but
    2251              :         // `TenantId::new` rejects, alongside an ordinary one.
    2252              :         {
    2253            2 :             let db = Database::create(dir.join("antares.redb")).expect("db");
    2254            2 :             let mut tx = db.begin_write().expect("tx");
    2255            2 :             tx.set_durability(Durability::Immediate).expect("dur");
    2256            2 :             {
    2257            2 :                 let mut m = tx.open_table(T_META).expect("meta");
    2258            2 :                 m.insert("format", FORMAT_VERSION).expect("insert");
    2259            2 :             }
    2260              :             {
    2261            2 :                 let mut t = tx.open_table(T_ENTITIES).expect("entities");
    2262            6 :                 for (tenant, id, expires) in [
    2263            2 :                     ("odd.tenant", "urn:e:1", "2000-01-01T00:00:00Z"),
    2264            2 :                     ("odd.tenant", "urn:e:2", "2999-01-01T00:00:00Z"),
    2265            2 :                     ("plain", "urn:e:3", "2000-01-01T00:00:00Z"),
    2266            6 :                 ] {
    2267            6 :                     let mut k = tenant.as_bytes().to_vec();
    2268            6 :                     k.push(0);
    2269            6 :                     k.extend_from_slice(id.as_bytes());
    2270            6 :                     let doc = json!({"id": id, "type": ["T"], "expiresAt": expires});
    2271            6 :                     let bytes = serde_json::to_vec(&doc).expect("serialize");
    2272            6 :                     t.insert(k.as_slice(), bytes.as_slice()).expect("insert");
    2273            6 :                 }
    2274              :             }
    2275            2 :             tx.commit().expect("commit");
    2276              :         }
    2277              :         {
    2278            2 :             let s = Store::open_file(&dir).expect("open");
    2279            2 :             assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), 2, "both expired");
    2280              :         }
    2281            2 :         let s = Store::open_file(&dir).expect("reopen");
    2282            2 :         let inner = s.inner.read().expect("lock");
    2283            2 :         assert!(
    2284            2 :             !inner
    2285            2 :                 .entities
    2286            2 :                 .get("odd.tenant")
    2287            2 :                 .is_some_and(|d| d.contains_key("urn:e:1")),
    2288              :             "swept entity resurrected on reopen"
    2289              :         );
    2290            2 :         assert!(
    2291            2 :             inner
    2292            2 :                 .entities
    2293            2 :                 .get("odd.tenant")
    2294            2 :                 .is_some_and(|d| d.contains_key("urn:e:2")),
    2295              :             "unexpired entity must outlive the sweep"
    2296              :         );
    2297            2 :         assert!(
    2298            2 :             !inner
    2299            2 :                 .entities
    2300            2 :                 .get("plain")
    2301            2 :                 .is_some_and(|d| d.contains_key("urn:e:3")),
    2302              :             "swept entity resurrected on reopen"
    2303              :         );
    2304            2 :         drop(inner);
    2305            2 :         let _ = std::fs::remove_dir_all(&dir);
    2306            2 :     }
    2307              : 
    2308              :     /// S3-08, S3-09: in `file` mode a sweep that reaps many documents (an
    2309              :     /// expired Entity and its history each) commits them in ONE redb
    2310              :     /// transaction. One commit per key made the reap non-atomic on disk (a
    2311              :     /// crash between the Entity and its history left an orphan history no
    2312              :     /// sweep removes) and held the write section, which every reader waits
    2313              :     /// on, for one fsync per document. Timed against a single durable
    2314              :     /// commit on the same file: the sweep may cost a few, never one per key.
    2315              :     #[test]
    2316            2 :     fn a_file_mode_sweep_commits_once_whatever_it_reaps() {
    2317              :         const N: usize = 400;
    2318            2 :         let dir = tempdir("sweep-one-commit");
    2319              :         {
    2320            2 :             let db = Database::create(dir.join("antares.redb")).expect("db");
    2321            2 :             let tx = db.begin_write().expect("tx");
    2322            2 :             {
    2323            2 :                 let mut m = tx.open_table(T_META).expect("meta");
    2324            2 :                 m.insert("format", FORMAT_VERSION).expect("insert");
    2325            2 :             }
    2326            4 :             for table in [T_ENTITIES, T_TEMPORAL_ENTITIES] {
    2327            4 :                 let mut t = tx.open_table(table).expect("table");
    2328         1600 :                 for n in 0..N {
    2329         1600 :                     let id = format!("urn:e:{n}");
    2330         1600 :                     let doc = json!({"id": id, "type": ["T"], "expiresAt": "2000-01-01T00:00:00Z"});
    2331         1600 :                     let bytes = serde_json::to_vec(&doc).expect("serialize");
    2332         1600 :                     t.insert(key_bytes("t", &id).as_slice(), bytes.as_slice())
    2333         1600 :                         .expect("insert");
    2334         1600 :                 }
    2335            4 :                 let keep = serde_json::to_vec(&json!({"id": "urn:keep", "type": ["T"]}))
    2336            4 :                     .expect("serialize");
    2337            4 :                 t.insert(key_bytes("t", "urn:keep").as_slice(), keep.as_slice())
    2338            4 :                     .expect("insert");
    2339              :             }
    2340            2 :             tx.commit().expect("commit");
    2341              :         }
    2342            2 :         let s = std::sync::Arc::new(Store::open_file(&dir).expect("open"));
    2343              :         // one durable commit on this file, the unit the sweep is held to
    2344              :         const T_META_PROBE: Table = ::redb::TableDefinition::new("probe");
    2345            2 :         let started = std::time::Instant::now();
    2346           10 :         for n in 0..5 {
    2347           10 :             s.persist(
    2348           10 :                 T_META_PROBE,
    2349           10 :                 format!("probe{n}").as_bytes(),
    2350           10 :                 Some(&json!(n)),
    2351           10 :             );
    2352           10 :         }
    2353            2 :         let commit = started.elapsed() / 5;
    2354            2 :         let t = TenantId::new("t").expect("tenant");
    2355            2 :         let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    2356            2 :         let reader = {
    2357            2 :             let (s, t, stop) = (s.clone(), t.clone(), stop.clone());
    2358            2 :             std::thread::spawn(move || {
    2359            2 :                 let mut worst = std::time::Duration::ZERO;
    2360        23332 :                 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
    2361        23330 :                     let at = std::time::Instant::now();
    2362        23330 :                     assert!(s.get(&t, Kind::Entity, "urn:keep").is_some());
    2363        23330 :                     worst = worst.max(at.elapsed());
    2364              :                 }
    2365            2 :                 worst
    2366            2 :             })
    2367              :         };
    2368            2 :         std::thread::sleep(std::time::Duration::from_millis(20));
    2369            2 :         let started = std::time::Instant::now();
    2370            2 :         assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), N);
    2371            2 :         let took = started.elapsed();
    2372            2 :         stop.store(true, std::sync::atomic::Ordering::Relaxed);
    2373            2 :         let worst = reader.join().expect("reader");
    2374            2 :         eprintln!("one commit {commit:?}; sweep of {N} {took:?}; worst read {worst:?}");
    2375            2 :         assert!(
    2376            2 :             took < commit * 20 + std::time::Duration::from_millis(200),
    2377              :             "a sweep of {N} took {took:?}, a commit {commit:?}: one commit per key"
    2378              :         );
    2379            2 :         drop(s);
    2380            2 :         let s = Store::open_file(&dir).expect("reopen");
    2381            2 :         let inner = s.inner.read().expect("lock");
    2382            2 :         assert_eq!(inner.entities.get("t").map(BTreeMap::len), Some(1));
    2383            2 :         assert_eq!(inner.temporal.get("t").map(BTreeMap::len), Some(1));
    2384            2 :         drop(inner);
    2385            2 :         let _ = std::fs::remove_dir_all(&dir);
    2386            2 :     }
    2387              : 
    2388              :     #[tokio::test]
    2389            2 :     async fn sweep_prunes_expired_attribute_instances() {
    2390            2 :         let s = Store::default();
    2391            2 :         let t = TenantId::default();
    2392            2 :         s.create(
    2393            2 :             &t,
    2394            2 :             Kind::Entity,
    2395            2 :             "urn:e",
    2396            2 :             json!({"id": "urn:e", "type": ["T"],
    2397            2 :                 "attr": [
    2398            2 :                     {"type": "Property", "value": 1, "expiresAt": "2000-01-01T00:00:00Z"},
    2399            2 :                     {"type": "Property", "value": 2, "expiresAt": "2999-01-01T00:00:00Z"}],
    2400            2 :                 "gone": [
    2401            2 :                     {"type": "Property", "value": 3, "expiresAt": "2000-01-01T00:00:00Z"}]}),
    2402            2 :         )
    2403            2 :         .await;
    2404            2 :         s.create(
    2405            2 :             &t,
    2406            2 :             Kind::Temporal,
    2407            2 :             "urn:e",
    2408            2 :             json!({"id": "urn:e", "type": ["T"],
    2409            2 :                 "attr": [
    2410            2 :                     {"value": 1, "expiresAt": "2000-01-01T00:00:00Z"},
    2411            2 :                     {"value": 2, "expiresAt": "2999-01-01T00:00:00Z"},
    2412            2 :                     {"value": 3}]}),
    2413            2 :         )
    2414            2 :         .await;
    2415            2 :         assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), 2);
    2416            2 :         let e = s.get(&t, Kind::Entity, "urn:e").expect("entity survives");
    2417            2 :         assert_eq!(e["attr"].as_array().expect("attr").len(), 1);
    2418            2 :         assert_eq!(e["attr"][0]["value"], 2);
    2419            2 :         assert!(e.get("gone").is_none(), "fully-expired attribute removed");
    2420            2 :         let tp = s
    2421            2 :             .get(&t, Kind::Temporal, "urn:e")
    2422            2 :             .expect("temporal survives");
    2423            2 :         let vals: Vec<i64> = tp["attr"]
    2424            2 :             .as_array()
    2425            2 :             .expect("instances")
    2426            2 :             .iter()
    2427            4 :             .map(|i| i["value"].as_i64().expect("value"))
    2428            2 :             .collect();
    2429            2 :         assert_eq!(vals, [2, 3], "expired pruned, no-expiry instance kept");
    2430            2 :         assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), 0, "idempotent");
    2431            2 :     }
    2432              : 
    2433              :     /// ADR-0021 + 4.14: the memory/file arm has no Row-Level Security under
    2434              :     /// it, so the rule a Postgres deployment gets from the database is this
    2435              :     /// store's own job. A Hosted @context holds term mappings authored
    2436              :     /// through one Tenant's requests and belongs to that Tenant alone; a
    2437              :     /// Cached copy of a public document belongs to none and every Tenant
    2438              :     /// reaches it. A store that answered every caller would hand one
    2439              :     /// Tenant's term mappings to another, and 5.5.7 makes those mappings
    2440              :     /// decide what its payloads mean.
    2441              :     #[test]
    2442            2 :     fn clause_5_13_1_a_stored_context_answers_only_its_own_tenant() {
    2443            2 :         let s = Store::default();
    2444            2 :         let alpha = TenantId::new("alpha").expect("tenant");
    2445            2 :         let beta = TenantId::new("beta").expect("tenant");
    2446            2 :         let hosted = json!({"localId": "h", "kind": "Hosted", "owner": "alpha",
    2447            2 :                             "body": {"@context": {"a": "https://alpha.example/a"}}});
    2448            2 :         s.context_put(Some(&alpha), "h", hosted.clone())
    2449            2 :             .expect("the owner stores its own row");
    2450            2 :         s.context_put(
    2451            2 :             None,
    2452            2 :             "c",
    2453            2 :             json!({"localId": "c", "kind": "Cached", "body": {"@context": {}}}),
    2454              :         )
    2455            2 :         .expect("a Cached copy belongs to no Tenant");
    2456              : 
    2457            2 :         assert!(
    2458            2 :             s.context_get(Some(&alpha), "h").is_some(),
    2459              :             "its owner reads it"
    2460              :         );
    2461            2 :         assert!(
    2462            2 :             s.context_get(Some(&beta), "h").is_none(),
    2463              :             "another Tenant's Hosted @context is as absent as one never stored"
    2464              :         );
    2465            2 :         assert!(
    2466            2 :             s.context_get(None, "h").is_none(),
    2467              :             "no Tenant in scope reaches no Tenant's documents"
    2468              :         );
    2469            6 :         for t in [Some(&alpha), Some(&beta), None] {
    2470            6 :             assert!(
    2471            6 :                 s.context_get(t, "c").is_some(),
    2472              :                 "a Cached copy is a public document and is shared"
    2473              :             );
    2474              :         }
    2475            2 :         assert!(
    2476            2 :             !s.context_delete(Some(&beta), "h"),
    2477              :             "a foreign delete takes nothing"
    2478              :         );
    2479            2 :         assert!(
    2480            2 :             s.context_put(Some(&beta), "h", hosted).is_err(),
    2481              :             "and a foreign write does not overwrite it either"
    2482              :         );
    2483            2 :         assert!(
    2484            2 :             s.context_list_meta(Some(&beta))
    2485            2 :                 .iter()
    2486            2 :                 .all(|r| r["localId"] != "h"),
    2487              :             "another Tenant's row is not listed"
    2488              :         );
    2489            2 :         assert!(
    2490            2 :             s.context_get(Some(&alpha), "h").is_some(),
    2491              :             "the owner's row survived every foreign attempt"
    2492              :         );
    2493              : 
    2494            2 :         s.purge_tenant(&alpha);
    2495            2 :         assert!(
    2496            2 :             s.context_get(Some(&alpha), "h").is_none(),
    2497              :             "purging the Tenant takes the @contexts it stored"
    2498              :         );
    2499            2 :         assert!(
    2500            2 :             s.context_get(None, "c").is_some(),
    2501              :             "and leaves the copy that belongs to no Tenant"
    2502              :         );
    2503            2 :     }
    2504              : 
    2505              :     /// 5.13.1: "Implementations shall periodically invalidate the 'Cached'
    2506              :     /// @contexts." The memory/file arm holds one entry per distinct external
    2507              :     /// URL a request referenced — client-controlled — so the same ceiling and
    2508              :     /// oldest-first eviction the Pg arm applies holds here, and it applies to
    2509              :     /// the Cached kind ONLY: a Hosted entry is client-owned data (5.13.2).
    2510              :     #[test]
    2511            2 :     fn clause_5_13_1_cached_contexts_are_capped_oldest_first() {
    2512            2 :         let s = Store::default();
    2513         2004 :         let entry = |kind: &str, created: &str| json!({"kind": kind, "createdAt": created, "body": {"@context": {}}});
    2514              :         // a Hosted entry older than every Cached one: age must not decide
    2515            2 :         s.context_put(
    2516            2 :             Some(&TenantId::default()),
    2517            2 :             "hosted",
    2518            2 :             entry("Hosted", "2000-01-01T00:00:00Z"),
    2519              :         )
    2520            2 :         .expect("store");
    2521         2000 :         for i in 0..MAX_CACHED_CONTEXTS {
    2522         2000 :             s.context_put(
    2523         2000 :                 None,
    2524         2000 :                 &format!("cached-{i:05}"),
    2525         2000 :                 entry("Cached", &format!("2026-01-01T00:00:{:02}Z", i % 60)),
    2526         2000 :             )
    2527         2000 :             .expect("store");
    2528         2000 :         }
    2529            4 :         let cached = |s: &Store| {
    2530            4 :             s.context_list_meta(Some(&TenantId::default()))
    2531            4 :                 .iter()
    2532         4004 :                 .filter(|d| d["kind"] == "Cached")
    2533            4 :                 .count()
    2534            4 :         };
    2535            2 :         assert_eq!(
    2536            2 :             cached(&s),
    2537              :             MAX_CACHED_CONTEXTS,
    2538              :             "at the ceiling, nothing lost"
    2539              :         );
    2540              : 
    2541              :         // one more Cached entry evicts exactly one — the oldest
    2542            2 :         s.context_put(None, "cached-new", entry("Cached", "2026-06-01T00:00:00Z"))
    2543            2 :             .expect("store");
    2544            2 :         assert_eq!(cached(&s), MAX_CACHED_CONTEXTS, "the ceiling holds");
    2545            2 :         assert!(
    2546            2 :             s.context_get(None, "cached-new").is_some(),
    2547              :             "the new entry stays"
    2548              :         );
    2549            2 :         assert!(
    2550            2 :             s.context_get(None, "cached-00000").is_none(),
    2551              :             "the oldest Cached entry is the one evicted"
    2552              :         );
    2553            2 :         assert!(
    2554            2 :             s.context_get(None, "cached-00001").is_some(),
    2555              :             "eviction stops at the ceiling"
    2556              :         );
    2557            2 :         assert!(
    2558            2 :             s.context_get(Some(&TenantId::default()), "hosted")
    2559            2 :                 .is_some(),
    2560              :             "a Hosted entry is never a candidate, however old"
    2561              :         );
    2562            2 :     }
    2563              : 
    2564              :     #[tokio::test]
    2565            2 :     async fn mutate_aborts_on_error() {
    2566            2 :         let s = Store::default();
    2567            2 :         let t = TenantId::default();
    2568            2 :         s.create(&t, Kind::Entity, "urn:a", json!({"n": 1})).await;
    2569            2 :         let r: Option<Result<(), &str>> = s
    2570            2 :             .mutate(&t, Kind::Entity, "urn:a", |d| {
    2571            2 :                 d["n"] = json!(2);
    2572            2 :                 Err("nope")
    2573            2 :             })
    2574            2 :             .await;
    2575            2 :         assert!(matches!(r, Some(Err("nope"))));
    2576            2 :         assert_eq!(s.get(&t, Kind::Entity, "urn:a").unwrap()["n"], 1);
    2577            2 :     }
    2578              : 
    2579              :     /// A purged tenant is gone from every kind, the neighbour tenant is
    2580              :     /// untouched, and in `file` mode the removal survives a reopen.
    2581              :     #[tokio::test]
    2582            2 :     async fn purge_tenant_empties_every_kind_and_survives_reopen() {
    2583            2 :         let dir = tempdir("purge");
    2584            2 :         let a = TenantId::new("purge_a").expect("tenant");
    2585            2 :         let b = TenantId::new("purge_b").expect("tenant");
    2586              :         {
    2587            2 :             let s = Store::open_file(&dir).expect("open");
    2588            4 :             for t in [&a, &b] {
    2589           40 :                 for kind in ALL_KINDS {
    2590           40 :                     assert!(s.create(t, kind, "urn:x:1", json!({"id": "urn:x:1"})).await);
    2591              :                 }
    2592              :             }
    2593            4 :             assert!(s.tenant_ids().iter().any(|t| t == "purge_a"), "listed");
    2594            2 :             let row = s.tenant_stats_one(&a);
    2595            2 :             assert_eq!((row.entities, row.subscriptions, row.dist_subs), (1, 1, 1));
    2596            2 :             assert!(s.purge_tenant(&a));
    2597            2 :             assert!(!s.purge_tenant(&a), "second purge finds nothing");
    2598            2 :             assert!(!s.tenant_exists(&a));
    2599            2 :             assert!(s.tenant_exists(&b));
    2600           20 :             for kind in ALL_KINDS {
    2601           20 :                 assert!(s.get(&a, kind, "urn:x:1").is_none(), "{kind:?} row left");
    2602           20 :                 assert!(s.get(&b, kind, "urn:x:1").is_some(), "{kind:?} lost for b");
    2603              :             }
    2604            4 :             assert!(s.tenant_ids().iter().all(|t| t != "purge_a"));
    2605            2 :             assert_eq!(
    2606            2 :                 s.tenant_stats_one(&a).entities,
    2607              :                 0,
    2608              :                 "a purged tenant counts nothing"
    2609              :             );
    2610              :         }
    2611            2 :         let s = Store::open_file(&dir).expect("reopen");
    2612            2 :         assert!(!s.tenant_exists(&a), "purge must be persisted");
    2613            2 :         assert!(s.tenant_exists(&b));
    2614            2 :         let _ = std::fs::remove_dir_all(&dir);
    2615            2 :     }
    2616              : }
        

Generated by: LCOV version 2.0-1