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, TableDefinition};
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, FORMAT_VERSION, T_ENTITIES, T_JSONLD_CONTEXTS, T_META,
21 : 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[..cached.len() - MAX_CACHED_CONTEXTS]
55 2 : .iter()
56 2 : .map(|(id, _)| (*id).to_owned())
57 2 : .collect()
58 4 : }
59 :
60 : /// Drop attribute instances whose `expiresAt` passed (4.22) from an internal
61 : /// entity or temporal doc; an attribute whose last instance expired is
62 : /// removed entirely. Returns whether the doc changed.
63 1516 : fn prune_expired_instances(doc: &mut Value, now: &str) -> bool {
64 1516 : let Some(obj) = doc.as_object_mut() else {
65 0 : return false;
66 : };
67 1516 : let mut changed = false;
68 1516 : let attrs: Vec<String> = obj
69 1516 : .keys()
70 6771 : .filter(|k| !antares_model::ENTITY_META_KEYS.contains(&k.as_str()))
71 1516 : .cloned()
72 1516 : .collect();
73 1516 : for k in attrs {
74 721 : let Some(arr) = obj.get_mut(&k).and_then(Value::as_array_mut) else {
75 0 : continue;
76 : };
77 721 : let before = arr.len();
78 : // 4.6.3 leaves the seconds-fraction separator open, so the two
79 : // stamps have to be compared as instants and not as bytes — the rule
80 : // lives in `filter::expired_at`, which the read boundary uses too.
81 742 : arr.retain(|inst| !filter::expired_at(inst, now));
82 721 : if arr.len() != before {
83 12 : changed = true;
84 12 : if arr.is_empty() {
85 4 : obj.remove(&k);
86 8 : }
87 709 : }
88 : }
89 1516 : changed
90 1516 : }
91 :
92 : /// The 4.22 "now" the write paths judge `expiresAt` against — the same UTC-Z
93 : /// millisecond form the read boundary uses.
94 8818 : fn now_stamp() -> String {
95 8818 : chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
96 8818 : }
97 :
98 : const ALL_KINDS: [Kind; 9] = [
99 : Kind::Entity,
100 : Kind::Subscription,
101 : Kind::Registration,
102 : Kind::CSourceSubscription,
103 : Kind::Temporal,
104 : Kind::Snapshot,
105 : Kind::EntityMap,
106 : Kind::DistSub,
107 : Kind::DeadLetter,
108 : ];
109 :
110 : /// Run `f` off the tokio worker pool when called from a multi-thread runtime:
111 : /// a per-commit fsync must never stall an async worker. Outside a
112 : /// runtime (unit tests, startup) it just runs inline.
113 : ///
114 : /// `durable` is what decides. A per-document write blocks only where it
115 : /// commits, so it passes `shadow.is_some()`: in `memory` mode that write is
116 : /// a lock and a map insert, and handing a worker's queue to another thread —
117 : /// a handoff, and past the pool's live threads a spawn — costs more than the
118 : /// write and costs more the more cores there are to hand work between. The
119 : /// paths that hold the write section for a whole SCAN (the 4.22 sweep, the
120 : /// two purges) pass `true` whatever the mode: what makes them long there is
121 : /// the scan, not the commit.
122 68509 : fn on_blocking<T>(durable: bool, f: impl FnOnce() -> T) -> T {
123 : // wasm32: single-threaded, no tokio runtime — always inline.
124 : #[cfg(not(target_arch = "wasm32"))]
125 68509 : if durable {
126 3043 : if let Ok(h) = tokio::runtime::Handle::try_current() {
127 3037 : if h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
128 2775 : return tokio::task::block_in_place(f);
129 262 : }
130 6 : }
131 65466 : }
132 : #[cfg(target_arch = "wasm32")]
133 : let _ = durable;
134 65734 : f()
135 68509 : }
136 :
137 : #[derive(Default)]
138 : pub struct Store {
139 : inner: RwLock<Inner>,
140 : hook: RwLock<Option<ChangeHook>>,
141 : /// Set when this instance serves only the temporal seam: it never holds
142 : /// the entities, so the append guard must not look for them here.
143 : pub temporal_only: bool,
144 : /// `file` mode durability shadow; `None` = pure in-memory (`memory` mode).
145 : shadow: Option<Shadow>,
146 : /// Writers currently queued behind the single write-critical section
147 : /// (redb has ONE writer, so fsync commits serialize here). Exported via
148 : /// /q/health; the group-commit lever only gets built if a benchmark shows
149 : /// this depth sustained at the measured ~3.1k writes/s ceiling.
150 : write_waiters: std::sync::atomic::AtomicUsize,
151 : write_waiters_peak: std::sync::atomic::AtomicUsize,
152 : /// Change hooks fire in COMMIT order. The hook runs after the
153 : /// write-critical section (it may write other kinds through the store,
154 : /// so running it inside would deadlock), which lets a later commit's
155 : /// hook overtake an earlier one — the consumer would record stale state
156 : /// as newest. Entity writes therefore hold this from before the commit
157 : /// until the emit is done. Only the local single-process path needs it:
158 : /// across processes the transactional outbox is the ordered channel.
159 : emit_order: tokio::sync::Mutex<()>,
160 : }
161 :
162 : #[derive(Default)]
163 : struct Inner {
164 : /// tenant → id → internal entity doc (BTreeMap: deterministic list order).
165 : entities: HashMap<String, BTreeMap<String, Value>>,
166 : subscriptions: HashMap<String, BTreeMap<String, Value>>,
167 : registrations: HashMap<String, BTreeMap<String, Value>>,
168 : csource_subscriptions: HashMap<String, BTreeMap<String, Value>>,
169 : snapshots: HashMap<String, BTreeMap<String, Value>>,
170 : entity_map_docs: HashMap<String, BTreeMap<String, Value>>,
171 : dist_subs: HashMap<String, BTreeMap<String, Value>>,
172 : dead_letters: HashMap<String, BTreeMap<String, Value>>,
173 : /// tenant → entity id → temporal doc (attr IRI → instance array).
174 : temporal: HashMap<String, BTreeMap<String, Value>>,
175 : /// hosted/cached @context documents, shared across tenants by design.
176 : contexts: BTreeMap<String, Value>,
177 : }
178 :
179 : impl Store {
180 : /// Open (or create) the `file`-mode store: redb at `dir/antares.redb`,
181 : /// format-checked, in-memory maps rebuilt from the file.
182 : /// Any open/format/decode error refuses to start — never silently serve
183 : /// partial data.
184 56 : pub fn open_file(dir: &Path) -> Result<Self, String> {
185 56 : std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
186 56 : let path = dir.join("antares.redb");
187 56 : let db = Database::create(&path).map_err(|e| format!("open {}: {e}", path.display()))?;
188 54 : Self::from_database(db, &path.display().to_string())
189 56 : }
190 :
191 : /// The target-independent half of `open_file`: format check +
192 : /// boot rebuild over an already-constructed redb `Database`. The
193 : /// browser build calls this with an OPFS-backed database — same shadow,
194 : /// same commit-before-ack, different `StorageBackend`.
195 58 : pub fn from_database(db: Database, label: &str) -> Result<Self, String> {
196 : // Format marker. Absent marker + existing data = a file this
197 : // binary cannot vouch for; refuse rather than guess.
198 58 : let stored_format = {
199 58 : let rt = db.begin_read().map_err(|e| e.to_string())?;
200 58 : match rt.open_table(T_META) {
201 28 : Ok(t) => t
202 28 : .get("format")
203 28 : .map_err(|e| e.to_string())?
204 28 : .map(|v| v.value().to_owned()),
205 30 : Err(::redb::TableError::TableDoesNotExist(_)) => None,
206 0 : Err(e) => return Err(e.to_string()),
207 : }
208 : };
209 58 : match stored_format.as_deref() {
210 28 : Some(FORMAT_VERSION) => {}
211 2 : Some(other) => {
212 2 : return Err(format!(
213 2 : "data file {label} has format {other}, this binary supports {FORMAT_VERSION} — \
214 2 : refusing to start"
215 2 : ));
216 : }
217 : None => {
218 30 : let rt = db.begin_read().map_err(|e| e.to_string())?;
219 238 : for kind in ALL_KINDS {
220 : // Absent = nothing stored under that kind. Any other
221 : // error is a table this binary cannot read, and reading
222 : // it as empty would let the guard stamp its marker onto
223 : // a file it is about to refuse.
224 238 : let t = match rt.open_table(table_for(kind)) {
225 2 : Ok(t) => t,
226 234 : Err(::redb::TableError::TableDoesNotExist(_)) => continue,
227 2 : Err(e) => return Err(e.to_string()),
228 : };
229 2 : if t.len().map_err(|e| e.to_string())? > 0 {
230 2 : return Err(format!(
231 2 : "data file {label} holds data but no format marker — refusing to start"
232 2 : ));
233 0 : }
234 : }
235 26 : drop(rt);
236 26 : let mut tx = db.begin_write().map_err(|e| e.to_string())?;
237 26 : tx.set_durability(Durability::Immediate)
238 26 : .map_err(|e| e.to_string())?;
239 : {
240 26 : let mut t = tx.open_table(T_META).map_err(|e| e.to_string())?;
241 26 : t.insert("format", FORMAT_VERSION)
242 26 : .map_err(|e| e.to_string())?;
243 : }
244 26 : tx.commit().map_err(|e| e.to_string())?;
245 : }
246 : }
247 :
248 : // Boot rebuild — scan every table into the in-memory maps.
249 52 : let mut inner = Inner::default();
250 52 : let rt = db.begin_read().map_err(|e| e.to_string())?;
251 468 : for kind in ALL_KINDS {
252 468 : let table = match rt.open_table(table_for(kind)) {
253 66 : Ok(t) => t,
254 402 : Err(::redb::TableError::TableDoesNotExist(_)) => continue,
255 0 : Err(e) => return Err(e.to_string()),
256 : };
257 66 : let map = match kind {
258 18 : Kind::Entity => &mut inner.entities,
259 6 : Kind::Subscription => &mut inner.subscriptions,
260 6 : Kind::Registration => &mut inner.registrations,
261 4 : Kind::CSourceSubscription => &mut inner.csource_subscriptions,
262 8 : Kind::Temporal => &mut inner.temporal,
263 6 : Kind::Snapshot => &mut inner.snapshots,
264 6 : Kind::EntityMap => &mut inner.entity_map_docs,
265 6 : Kind::DistSub => &mut inner.dist_subs,
266 6 : Kind::DeadLetter => &mut inner.dead_letters,
267 : };
268 80 : for row in ::redb::ReadableTable::iter(&table).map_err(|e| e.to_string())? {
269 80 : let (k, v) = row.map_err(|e| e.to_string())?;
270 80 : let (tenant, id) = split_key(k.value())
271 80 : .ok_or_else(|| format!("undecodable key in table {kind:?}"))?;
272 80 : let doc: Value = serde_json::from_slice(v.value())
273 80 : .map_err(|e| format!("undecodable value for {tenant}/{id}: {e}"))?;
274 80 : map.entry(tenant).or_default().insert(id, doc);
275 : }
276 : }
277 : // Same rule as the kind loop above: absent is empty, unreadable is a
278 : // refusal. Hosted and ImplicitlyCreated @contexts (5.13.1) are a
279 : // Tenant's own documents, so starting without this table is serving
280 : // partial data, which `open_file` promises never to do.
281 52 : let contexts = match rt.open_table(T_JSONLD_CONTEXTS) {
282 4 : Ok(t) => Some(t),
283 46 : Err(::redb::TableError::TableDoesNotExist(_)) => None,
284 2 : Err(e) => return Err(e.to_string()),
285 : };
286 50 : if let Some(t) = contexts {
287 4 : for row in ::redb::ReadableTable::iter(&t).map_err(|e| e.to_string())? {
288 2 : let (k, v) = row.map_err(|e| e.to_string())?;
289 2 : let id = String::from_utf8(k.value().to_vec())
290 2 : .map_err(|e| format!("undecodable context id: {e}"))?;
291 2 : let doc: Value = serde_json::from_slice(v.value())
292 2 : .map_err(|e| format!("undecodable context {id}: {e}"))?;
293 2 : inner.contexts.insert(id, doc);
294 : }
295 46 : }
296 50 : drop(rt);
297 :
298 50 : Ok(Self {
299 50 : inner: RwLock::new(inner),
300 50 : hook: RwLock::new(None),
301 50 : emit_order: tokio::sync::Mutex::new(()),
302 50 : shadow: Some(Shadow { db }),
303 50 : temporal_only: false,
304 50 : write_waiters: Default::default(),
305 50 : write_waiters_peak: Default::default(),
306 50 : })
307 58 : }
308 :
309 : /// Write-through for one doc (must be called inside the write-critical
310 : /// section so redb order equals memory order).
311 58495 : fn persist(&self, table: TableDefinition<&[u8], &[u8]>, key: &[u8], doc: Option<&Value>) {
312 58495 : if let Some(shadow) = &self.shadow {
313 : // None means DELETE the key, so a document that will not encode
314 : // must skip the write rather than collapse into one.
315 1241 : let bytes = match doc {
316 772 : Some(d) => match serde_json::to_vec(d) {
317 772 : Ok(b) => Some(b),
318 0 : Err(_) => return,
319 : },
320 469 : None => None,
321 : };
322 1241 : shadow.write(table, key, bytes.as_deref());
323 57254 : }
324 58495 : }
325 :
326 : /// Acquire the write-critical section, counting queued writers.
327 : ///
328 : /// Poison recovery (`into_inner`) is deliberate: a panic inside a caller's
329 : /// mutate closure unwinds one request; poisoning would turn it into a
330 : /// whole-process brick (every later store call panicking until restart).
331 : /// Consistency holds because mutations work on a clone and swap last.
332 68509 : fn write_inner(&self) -> std::sync::RwLockWriteGuard<'_, Inner> {
333 : use std::sync::atomic::Ordering;
334 68509 : let depth = self.write_waiters.fetch_add(1, Ordering::Relaxed) + 1;
335 68509 : self.write_waiters_peak.fetch_max(depth, Ordering::Relaxed);
336 68509 : let guard = self
337 68509 : .inner
338 68509 : .write()
339 68509 : .unwrap_or_else(std::sync::PoisonError::into_inner);
340 68509 : self.write_waiters.fetch_sub(1, Ordering::Relaxed);
341 68509 : guard
342 68509 : }
343 :
344 : /// Whether writes reach a durability shadow (`file` mode, and the
345 : /// browser build over OPFS) rather than living only in memory.
346 254 : pub fn shadowed(&self) -> bool {
347 254 : self.shadow.is_some()
348 254 : }
349 :
350 : /// (Currently queued writers, peak since start). The peak going
351 : /// nowhere near sustained depth is the evidence that the group-commit
352 : /// lever stays unbuilt.
353 8 : pub fn commit_queue(&self) -> (usize, usize) {
354 : use std::sync::atomic::Ordering;
355 8 : (
356 8 : self.write_waiters.load(Ordering::Relaxed),
357 8 : self.write_waiters_peak.load(Ordering::Relaxed),
358 8 : )
359 8 : }
360 :
361 566 : pub fn set_change_hook(&self, h: ChangeHook) {
362 566 : *self
363 566 : .hook
364 566 : .write()
365 566 : .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(h);
366 566 : }
367 :
368 : /// Held from before an entity commit until its emit returns (see
369 : /// `emit_order`). Not for other kinds: their writes emit nothing, and a
370 : /// hook that writes them re-enters the store.
371 39134 : async fn emit_ordered(&self) -> tokio::sync::MutexGuard<'_, ()> {
372 39134 : self.emit_order.lock().await
373 39134 : }
374 :
375 37858 : async fn emit(&self, tenant: &TenantId, before: Option<Value>, after: Option<Value>) {
376 : // cloned out of the lock first: a read guard cannot be held across
377 : // the await the hook now costs.
378 37858 : let hook = self
379 37858 : .hook
380 37858 : .read()
381 37858 : .unwrap_or_else(std::sync::PoisonError::into_inner)
382 37858 : .clone();
383 37858 : if let Some(h) = hook {
384 5900 : h(tenant, before, after).await;
385 31958 : }
386 37856 : }
387 :
388 : /// 4.22 garbage collection for the memory/file arm: remove entity docs
389 : /// whose `expiresAt` (byte-compared against the UTC-Z `now` stamp, same
390 : /// as the read filter) has passed, and prune expired ATTRIBUTE instances
391 : /// from current-state and temporal docs — the read filter hides them,
392 : /// but without physical removal a long-running store (the browser's OPFS
393 : /// file under ticking sensors) grows without bound. `file` mode persists
394 : /// each removal. Returns how many docs were reaped or pruned.
395 : ///
396 : /// Runs through `on_blocking` like every other mutating path: in `file`
397 : /// mode it holds the write-critical section for a full scan and issues
398 : /// one `Durability::Immediate` (fsync) commit per reaped doc, which must
399 : /// never happen on an async worker thread.
400 390 : pub fn sweep_expired(&self, now: &str) -> usize {
401 390 : on_blocking(true, || self.sweep_expired_locked(now))
402 390 : }
403 :
404 390 : fn sweep_expired_locked(&self, now: &str) -> usize {
405 390 : let mut inner = self.write_inner();
406 390 : let mut reaped = 0usize;
407 390 : let mut dead: Vec<(String, String)> = Vec::new();
408 1429 : for (tenant, docs) in &inner.entities {
409 1427 : for (id, doc) in docs {
410 1144 : if filter::expired_at(doc, now) {
411 8 : dead.push((tenant.clone(), id.clone()));
412 1136 : }
413 : }
414 : }
415 390 : for (tenant, id) in dead {
416 8 : if let Some(docs) = inner.entities.get_mut(&tenant) {
417 8 : self.persist(T_ENTITIES, &key_bytes(&tenant, &id), None);
418 8 : docs.remove(&id);
419 8 : reaped += 1;
420 8 : }
421 : }
422 : let Inner {
423 390 : entities, temporal, ..
424 390 : } = &mut *inner;
425 780 : for (table, map) in [
426 390 : (
427 390 : T_ENTITIES,
428 390 : entities as &mut HashMap<String, BTreeMap<String, Value>>,
429 390 : ),
430 390 : (T_TEMPORAL_ENTITIES, temporal),
431 390 : ] {
432 1803 : for (tenant, docs) in map.iter_mut() {
433 1785 : for (id, doc) in docs.iter_mut() {
434 1512 : if prune_expired_instances(doc, now) {
435 8 : reaped += 1;
436 8 : self.persist(table, &key_bytes(tenant, id), Some(doc));
437 1504 : }
438 : }
439 : }
440 : }
441 390 : reaped
442 390 : }
443 :
444 : /// Tenants that hold any subscriptions (interval-firing scan).
445 : /// 5.5.10: a Tenant exists once any create operation implicitly created
446 : /// it (any resource kind was ever written under it).
447 300 : pub fn tenant_exists(&self, tenant: &TenantId) -> bool {
448 300 : let inner = self
449 300 : .inner
450 300 : .read()
451 300 : .unwrap_or_else(std::sync::PoisonError::into_inner);
452 300 : ALL_KINDS
453 300 : .iter()
454 830 : .any(|k| Self::map(&inner, *k).contains_key(tenant.as_str()))
455 300 : }
456 :
457 : /// Every tenant name, sorted; the default tenant is always present, and
458 : /// the tenants the broker minted for itself are not — `/q/tenants` is the
459 : /// inventory of customer accounts, not of the broker's own bookkeeping.
460 414 : pub fn tenant_ids(&self) -> Vec<String> {
461 414 : let inner = self
462 414 : .inner
463 414 : .read()
464 414 : .unwrap_or_else(std::sync::PoisonError::into_inner);
465 414 : let mut names: std::collections::BTreeSet<String> =
466 414 : std::iter::once(TenantId::DEFAULT.to_string()).collect();
467 3726 : for kind in ALL_KINDS {
468 3726 : names.extend(
469 3726 : Self::map(&inner, kind)
470 3726 : .keys()
471 3922 : .filter(|t| !TenantId::is_reserved_str(t))
472 3726 : .cloned(),
473 : );
474 : }
475 414 : names.into_iter().collect()
476 414 : }
477 :
478 : /// What one tenant holds. Existence is the caller's question — an
479 : /// unknown tenant simply counts zero of everything.
480 24 : pub fn tenant_stats_one(&self, tenant: &TenantId) -> antares_store::TenantStats {
481 24 : let inner = self
482 24 : .inner
483 24 : .read()
484 24 : .unwrap_or_else(std::sync::PoisonError::into_inner);
485 168 : let n = |k: Kind| {
486 168 : Self::map(&inner, k)
487 168 : .get(tenant.as_str())
488 168 : .map_or(0, |m| m.len() as u64)
489 168 : };
490 24 : antares_store::TenantStats {
491 24 : entities: n(Kind::Entity),
492 24 : subscriptions: n(Kind::Subscription),
493 24 : registrations: n(Kind::Registration),
494 24 : csource_subscriptions: n(Kind::CSourceSubscription),
495 24 : snapshots: n(Kind::Snapshot),
496 24 : entity_maps: n(Kind::EntityMap),
497 24 : dist_subs: n(Kind::DistSub),
498 24 : created_at: None,
499 24 : tenant: tenant.as_str().to_owned(),
500 24 : }
501 24 : }
502 :
503 : /// Attribute instances held in the tenant's temporal documents.
504 20 : pub fn attr_instance_count(&self, tenant: &TenantId) -> u64 {
505 20 : let inner = self
506 20 : .inner
507 20 : .read()
508 20 : .unwrap_or_else(std::sync::PoisonError::into_inner);
509 20 : inner.temporal.get(tenant.as_str()).map_or(0, |docs| {
510 12 : docs.values()
511 12 : .filter_map(Value::as_object)
512 12 : .flat_map(|d| d.values().filter_map(Value::as_array))
513 24 : .map(|a| a.iter().filter(|i| i.is_object()).count() as u64)
514 12 : .sum()
515 12 : })
516 20 : }
517 :
518 : /// Drop every document of the given kinds for one tenant; `true` when
519 : /// the tenant held any of them. Persisted per key in `file` mode.
520 122 : pub fn purge_kinds(&self, tenant: &TenantId, kinds: &[Kind]) -> bool {
521 122 : on_blocking(true, || {
522 122 : let mut inner = self.write_inner();
523 122 : let mut hit = false;
524 634 : for kind in kinds {
525 634 : if let Some(docs) = Self::map_mut(&mut inner, *kind).remove(tenant.as_str()) {
526 124 : hit = true;
527 158 : for id in docs.keys() {
528 158 : self.persist(table_for(*kind), &key_bytes(tenant.as_str(), id), None);
529 158 : }
530 510 : }
531 : }
532 122 : hit
533 122 : })
534 122 : }
535 :
536 : /// Remove the tenant from every kind, history included.
537 64 : pub fn purge_tenant(&self, tenant: &TenantId) -> bool {
538 64 : let existed = self.purge_kinds(tenant, &ALL_KINDS);
539 : // ADR-0021: the Tenant's Hosted and ImplicitlyCreated @contexts are
540 : // its documents and go with it — a row that outlived its Tenant would
541 : // hand the next holder of that name a stranger's term mappings. A
542 : // Cached row belongs to no Tenant and stays.
543 64 : on_blocking(true, || {
544 64 : let mut inner = self.write_inner();
545 64 : let dead: Vec<String> = inner
546 64 : .contexts
547 64 : .iter()
548 64 : .filter(|(_, row)| context_row_owner(row) == Some(tenant.as_str()))
549 64 : .map(|(id, _)| id.clone())
550 64 : .collect();
551 64 : for id in dead {
552 4 : self.persist(T_JSONLD_CONTEXTS, id.as_bytes(), None);
553 4 : inner.contexts.remove(&id);
554 4 : }
555 64 : });
556 64 : existed
557 64 : }
558 :
559 666 : pub fn subscription_tenants(&self) -> Vec<String> {
560 666 : let inner = self
561 666 : .inner
562 666 : .read()
563 666 : .unwrap_or_else(std::sync::PoisonError::into_inner);
564 : // Registrations belong to this domain: one of the hydrations that
565 : // walks it fills the registration mirror, which the federation path
566 : // then reads alone. Without them a tenant holding registrations and
567 : // no subscription forwarded to no Context Source.
568 666 : let mut out: Vec<String> = inner
569 666 : .subscriptions
570 666 : .iter()
571 666 : .chain(inner.csource_subscriptions.iter())
572 666 : .chain(inner.registrations.iter())
573 666 : .filter(|(_, m)| !m.is_empty())
574 666 : .map(|(t, _)| t.clone())
575 666 : .collect();
576 666 : out.sort();
577 666 : out.dedup();
578 666 : out
579 666 : }
580 :
581 101370 : fn map(inner: &Inner, kind: Kind) -> &HashMap<String, BTreeMap<String, Value>> {
582 101370 : match kind {
583 57851 : Kind::Entity => &inner.entities,
584 997 : Kind::Subscription => &inner.subscriptions,
585 19031 : Kind::Registration => &inner.registrations,
586 1862 : Kind::CSourceSubscription => &inner.csource_subscriptions,
587 6440 : Kind::Temporal => &inner.temporal,
588 3018 : Kind::Snapshot => &inner.snapshots,
589 8971 : Kind::EntityMap => &inner.entity_map_docs,
590 2609 : Kind::DistSub => &inner.dist_subs,
591 591 : Kind::DeadLetter => &inner.dead_letters,
592 : }
593 101370 : }
594 :
595 65923 : fn map_mut(inner: &mut Inner, kind: Kind) -> &mut HashMap<String, BTreeMap<String, Value>> {
596 65923 : match kind {
597 39182 : Kind::Entity => &mut inner.entities,
598 1554 : Kind::Subscription => &mut inner.subscriptions,
599 2115 : Kind::Registration => &mut inner.registrations,
600 136 : Kind::CSourceSubscription => &mut inner.csource_subscriptions,
601 16068 : Kind::Temporal => &mut inner.temporal,
602 616 : Kind::Snapshot => &mut inner.snapshots,
603 4780 : Kind::EntityMap => &mut inner.entity_map_docs,
604 1324 : Kind::DistSub => &mut inner.dist_subs,
605 148 : Kind::DeadLetter => &mut inner.dead_letters,
606 : }
607 65923 : }
608 :
609 : /// 4.22: "expiresAt is defined as the system temporal Property at which a
610 : /// certain Entity, Property or Relationship shall become invalid." An
611 : /// entity past its expiry is invalid the moment the stamp passes, ahead
612 : /// of the sweep that physically reaps it, so every write path treats it
613 : /// as absent — otherwise the same id 404s on retrieve and 409s on create
614 : /// for a whole sweep interval. Only entities carry the entity-level
615 : /// stamp; subscriptions and registrations have their own expiry rules.
616 65305 : fn is_expired(&self, inner: &Inner, kind: Kind, tenant: &str, id: &str) -> bool {
617 65305 : kind == Kind::Entity
618 39134 : && Self::map(inner, kind)
619 39134 : .get(tenant)
620 39134 : .and_then(|m| m.get(id))
621 39134 : .is_some_and(|d| filter::expired_at(d, &now_stamp()))
622 65305 : }
623 :
624 : /// Insert a new resource; `false` if the id already exists.
625 27135 : pub async fn create(&self, tenant: &TenantId, kind: Kind, id: &str, doc: Value) -> bool {
626 27135 : let _order = match kind {
627 16516 : Kind::Entity => Some(self.emit_ordered().await),
628 10619 : _ => None,
629 : };
630 27135 : let created = on_blocking(self.shadow.is_some(), || {
631 27135 : let mut inner = self.write_inner();
632 27135 : let expired = self.is_expired(&inner, kind, tenant.as_str(), id);
633 27135 : let m = Self::map_mut(&mut inner, kind)
634 27135 : .entry(tenant.as_str().to_owned())
635 27135 : .or_default();
636 27135 : if m.contains_key(id) && !expired {
637 38 : false
638 : } else {
639 27097 : m.insert(id.to_owned(), doc.clone());
640 27097 : self.persist(table_for(kind), &key_bytes(tenant.as_str(), id), Some(&doc));
641 27097 : true
642 : }
643 27135 : });
644 27135 : if created && kind == Kind::Entity {
645 16498 : self.emit(tenant, None, Some(doc)).await;
646 10637 : }
647 27135 : created
648 27135 : }
649 :
650 : /// Insert or replace; returns `true` if it existed before. An expired
651 : /// entity did not (4.22), so the upsert reports CREATED and the caller
652 : /// answers 201 with a Location header instead of a silent 204.
653 12734 : pub async fn upsert(&self, tenant: &TenantId, kind: Kind, id: &str, doc: Value) -> bool {
654 12734 : let _order = match kind {
655 12676 : Kind::Entity => Some(self.emit_ordered().await),
656 58 : _ => None,
657 : };
658 12734 : let (prev, expired) = on_blocking(self.shadow.is_some(), || {
659 12734 : let mut inner = self.write_inner();
660 12734 : let expired = self.is_expired(&inner, kind, tenant.as_str(), id);
661 12734 : let prev = Self::map_mut(&mut inner, kind)
662 12734 : .entry(tenant.as_str().to_owned())
663 12734 : .or_default()
664 12734 : .insert(id.to_owned(), doc.clone());
665 12734 : self.persist(table_for(kind), &key_bytes(tenant.as_str(), id), Some(&doc));
666 12734 : (prev, expired)
667 12734 : });
668 12734 : let existed = prev.is_some() && !expired;
669 12734 : let prev = if expired { None } else { prev };
670 12734 : if kind == Kind::Entity {
671 12676 : self.emit(tenant, prev, Some(doc)).await;
672 58 : }
673 12732 : existed
674 12732 : }
675 :
676 29477 : pub fn get(&self, tenant: &TenantId, kind: Kind, id: &str) -> Option<Value> {
677 29477 : let inner = self
678 29477 : .inner
679 29477 : .read()
680 29477 : .unwrap_or_else(std::sync::PoisonError::into_inner);
681 29477 : Self::map(&inner, kind)
682 29477 : .get(tenant.as_str())
683 29477 : .and_then(|m| m.get(id))
684 29477 : .cloned()
685 29477 : }
686 :
687 : /// An expired entity is already invalid (4.22): deleting it is a 404,
688 : /// the same answer retrieving it gives, not a 204 for something the API
689 : /// stopped serving. The row itself still goes — the sweep would take it
690 : /// anyway, and leaving it would resurrect the 409.
691 10529 : pub async fn delete(&self, tenant: &TenantId, kind: Kind, id: &str) -> bool {
692 10529 : let _order = match kind {
693 4312 : Kind::Entity => Some(self.emit_ordered().await),
694 6217 : _ => None,
695 : };
696 10529 : let removed = on_blocking(self.shadow.is_some(), || {
697 10529 : let mut inner = self.write_inner();
698 : // an expired doc is left in place for the sweep to reap: removing
699 : // it here without persisting the removal would resurrect it on
700 : // the next boot of a `file`-mode store
701 10529 : if self.is_expired(&inner, kind, tenant.as_str(), id) {
702 10 : return None;
703 10519 : }
704 10519 : let removed = Self::map_mut(&mut inner, kind)
705 10519 : .get_mut(tenant.as_str())
706 10519 : .and_then(|m| m.remove(id));
707 10519 : if removed.is_some() {
708 4841 : self.persist(table_for(kind), &key_bytes(tenant.as_str(), id), None);
709 5819 : }
710 10519 : removed
711 10529 : });
712 10529 : let hit = removed.is_some();
713 10529 : if kind == Kind::Entity {
714 4312 : if let Some(old) = removed {
715 4130 : self.emit(tenant, Some(old), None).await;
716 182 : }
717 6217 : }
718 10529 : hit
719 10529 : }
720 :
721 : /// Delete one entity only if `keep` accepts the stored document. The
722 : /// read and the removal happen under one hold of the write lock, so
723 : /// nothing can replace the document between the decision and the delete.
724 : /// `None` = absent, expired (4.22, as in `delete`) or refused.
725 1164 : pub async fn delete_if(
726 1164 : &self,
727 1164 : tenant: &TenantId,
728 1164 : id: &str,
729 1164 : keep: &(dyn for<'v> Fn(&'v Value) -> bool + Sync),
730 1164 : ) -> Option<Value> {
731 1164 : let _order = self.emit_ordered().await;
732 1164 : let removed = on_blocking(self.shadow.is_some(), || {
733 1164 : let mut inner = self.write_inner();
734 1164 : if self.is_expired(&inner, Kind::Entity, tenant.as_str(), id) {
735 0 : return None;
736 1164 : }
737 1164 : let m = Self::map_mut(&mut inner, Kind::Entity).get_mut(tenant.as_str())?;
738 1126 : if !m.get(id).is_some_and(keep) {
739 886 : return None;
740 240 : }
741 240 : let removed = m.remove(id);
742 240 : if removed.is_some() {
743 240 : self.persist(
744 240 : table_for(Kind::Entity),
745 240 : &key_bytes(tenant.as_str(), id),
746 240 : None,
747 240 : );
748 240 : }
749 240 : removed
750 1164 : });
751 1164 : if let Some(old) = &removed {
752 240 : self.emit(tenant, Some(old.clone()), None).await;
753 924 : }
754 1164 : removed
755 1164 : }
756 :
757 : /// Snapshot of all docs of a kind for one tenant (id order).
758 21886 : pub fn list(&self, tenant: &TenantId, kind: Kind) -> Vec<Value> {
759 21886 : let inner = self
760 21886 : .inner
761 21886 : .read()
762 21886 : .unwrap_or_else(std::sync::PoisonError::into_inner);
763 21886 : Self::map(&inner, kind)
764 21886 : .get(tenant.as_str())
765 21886 : .map(|m| m.values().cloned().collect())
766 21886 : .unwrap_or_default()
767 21886 : }
768 :
769 : /// One id-ordered page of docs: ids strictly greater than `after`, at
770 : /// most `limit`. The per-tenant map is a `BTreeMap` keyed by id, so this
771 : /// is a range walk, not a scan-and-sort.
772 : ///
773 : /// 4.22 applies to entities inside the walk, BEFORE the page is cut: an
774 : /// expired document is not there to be paged, and dropping one after the
775 : /// cut would hand the caller a short page — which every walker reads as
776 : /// the end of the tenant.
777 6001 : pub fn list_page(
778 6001 : &self,
779 6001 : tenant: &TenantId,
780 6001 : kind: Kind,
781 6001 : after: Option<&str>,
782 6001 : limit: usize,
783 6001 : ) -> Vec<Value> {
784 : use std::ops::Bound;
785 6001 : let inner = self
786 6001 : .inner
787 6001 : .read()
788 6001 : .unwrap_or_else(std::sync::PoisonError::into_inner);
789 6001 : let lo = match after {
790 28 : Some(a) => Bound::Excluded(a.to_owned()),
791 5973 : None => Bound::Unbounded,
792 : };
793 6001 : let now = crate::store::any::now_utc();
794 6001 : Self::map(&inner, kind)
795 6001 : .get(tenant.as_str())
796 6001 : .map(|m| {
797 2288 : m.range((lo, Bound::Unbounded))
798 6944 : .filter_map(|(_, v)| {
799 6944 : let mut v = v.clone();
800 6944 : if kind == Kind::Entity && crate::store::filter::strip_expired(&mut v, &now)
801 : {
802 8 : return None;
803 6936 : }
804 6936 : Some(v)
805 6944 : })
806 2288 : .take(limit)
807 2288 : .collect()
808 2288 : })
809 6001 : .unwrap_or_default()
810 6001 : }
811 :
812 : /// One id-ordered window of docs and the size of the whole set. The
813 : /// per-tenant map is a `BTreeMap` keyed by id, so the window is a skip
814 : /// over an already-ordered iterator.
815 148 : pub fn list_slice(
816 148 : &self,
817 148 : tenant: &TenantId,
818 148 : kind: Kind,
819 148 : offset: usize,
820 148 : limit: usize,
821 148 : ) -> (Vec<Value>, usize) {
822 148 : let inner = self
823 148 : .inner
824 148 : .read()
825 148 : .unwrap_or_else(std::sync::PoisonError::into_inner);
826 148 : Self::map(&inner, kind)
827 148 : .get(tenant.as_str())
828 148 : .map(|m| {
829 80 : (
830 80 : m.values().skip(offset).take(limit).cloned().collect(),
831 80 : m.len(),
832 80 : )
833 80 : })
834 148 : .unwrap_or_default()
835 148 : }
836 :
837 : /// Read-modify-write on one document. Returns `None` when absent; the
838 : /// closure's error aborts without writing.
839 13743 : pub async fn mutate<T, E>(
840 13743 : &self,
841 13743 : tenant: &TenantId,
842 13743 : kind: Kind,
843 13743 : id: &str,
844 13743 : f: impl FnOnce(&mut Value) -> Result<T, E>,
845 13743 : ) -> Option<Result<T, E>> {
846 13743 : let _order = match kind {
847 4466 : Kind::Entity => Some(self.emit_ordered().await),
848 9277 : _ => None,
849 : };
850 13743 : let (result, change) = on_blocking(self.shadow.is_some(), || {
851 13743 : let mut inner = self.write_inner();
852 13743 : if self.is_expired(&inner, kind, tenant.as_str(), id) {
853 6 : return None; // 4.22: invalid, so absent
854 13737 : }
855 13737 : let doc = Self::map_mut(&mut inner, kind)
856 13737 : .get_mut(tenant.as_str())
857 13737 : .and_then(|m| m.get_mut(id))?;
858 10901 : let before = doc.clone();
859 10901 : let mut candidate = doc.clone();
860 10901 : Some(match f(&mut candidate) {
861 10847 : Ok(t) => {
862 10847 : if candidate != before {
863 10783 : self.persist(
864 10783 : table_for(kind),
865 10783 : &key_bytes(tenant.as_str(), id),
866 10783 : Some(&candidate),
867 10783 : );
868 10783 : }
869 10847 : let change = (kind == Kind::Entity && candidate != before)
870 10847 : .then(|| (before, candidate.clone()));
871 10847 : *doc = candidate;
872 10847 : (Ok(t), change)
873 : }
874 54 : Err(e) => (Err(e), None),
875 : })
876 13743 : })?;
877 10901 : if let Some((b, a)) = change {
878 4314 : self.emit(tenant, Some(b), Some(a)).await;
879 6587 : }
880 10901 : Some(result)
881 13743 : }
882 :
883 : // jsonldContexts: one keyspace for the whole process (key = context id, no
884 : // tenant prefix) — Cached rows are copies of public documents shared by
885 : // every tenant. Ownership of the tenant-authored kinds (Hosted,
886 : // ImplicitlyCreated, 5.13.1) travels in the document's "owner" member,
887 : // and every call below is answered through it (ADR-0021): the Postgres
888 : // arm has the same rule as a Row-Level Security policy over a generated
889 : // `tenant_id` column, and this arm, which has no policy engine under it,
890 : // applies it in the store.
891 2500 : pub fn context_put(
892 2500 : &self,
893 2500 : tenant: Option<&TenantId>,
894 2500 : id: &str,
895 2500 : doc: Value,
896 2500 : ) -> Result<(), NgsiError> {
897 2500 : on_blocking(self.shadow.is_some(), || {
898 2500 : let mut inner = self.write_inner();
899 : // The row that is there decides whether this call may replace it,
900 : // the row that arrives decides whether it may be stored: the two
901 : // halves of the policy the Postgres arm writes as USING and WITH
902 : // CHECK. Both are backstops — every caller mints its own id.
903 2500 : let held = inner.contexts.get(id);
904 2500 : if !held.is_none_or(|r| context_row_visible(r, tenant))
905 2498 : || !context_row_visible(&doc, tenant)
906 : {
907 2 : return Err(NgsiError::InternalError(
908 2 : "@context belongs to another tenant".into(),
909 2 : ));
910 2498 : }
911 2498 : self.persist(T_JSONLD_CONTEXTS, id.as_bytes(), Some(&doc));
912 2498 : let cached = doc.get("kind").and_then(Value::as_str) == Some("Cached");
913 2498 : inner.contexts.insert(id.to_owned(), doc);
914 : // 5.13.1: "Implementations shall periodically invalidate the
915 : // 'Cached' @contexts." One entry is stored per distinct external
916 : // URL a request references, so without a ceiling a client that
917 : // references fresh URLs grows this keyspace (and, in `file` mode,
918 : // the store on disk) forever. Oldest-first, and only for the
919 : // Cached kind — Hosted/ImplicitlyCreated entries are resources
920 : // the broker serves on demand, never a cache.
921 2498 : if cached && inner.contexts.len() > MAX_CACHED_CONTEXTS {
922 4 : let dead = oldest_cached(&inner.contexts);
923 4 : for id in dead {
924 2 : self.persist(T_JSONLD_CONTEXTS, id.as_bytes(), None);
925 2 : inner.contexts.remove(&id);
926 2 : }
927 2494 : }
928 2498 : Ok(())
929 2500 : })
930 2500 : }
931 :
932 884 : pub fn context_get(&self, tenant: Option<&TenantId>, id: &str) -> Option<Value> {
933 884 : self.inner
934 884 : .read()
935 884 : .unwrap_or_else(std::sync::PoisonError::into_inner)
936 884 : .contexts
937 884 : .get(id)
938 884 : .filter(|r| context_row_visible(r, tenant))
939 884 : .cloned()
940 884 : }
941 :
942 128 : pub fn context_delete(&self, tenant: Option<&TenantId>, id: &str) -> bool {
943 128 : on_blocking(self.shadow.is_some(), || {
944 128 : let mut inner = self.write_inner();
945 128 : let hit = inner
946 128 : .contexts
947 128 : .get(id)
948 128 : .is_some_and(|r| context_row_visible(r, tenant));
949 128 : if hit {
950 122 : inner.contexts.remove(id);
951 122 : self.persist(T_JSONLD_CONTEXTS, id.as_bytes(), None);
952 122 : }
953 128 : hit
954 128 : })
955 128 : }
956 :
957 : /// Every row without its `body` member (the `@context` document itself).
958 : /// A body may be 5 MiB and only the `Cached` rows are capped in number,
959 : /// so cloning whole rows here was a multi-gigabyte copy on the boot path.
960 158 : pub fn context_list_meta(&self, tenant: Option<&TenantId>) -> Vec<Value> {
961 158 : self.inner
962 158 : .read()
963 158 : .unwrap_or_else(std::sync::PoisonError::into_inner)
964 158 : .contexts
965 158 : .values()
966 4302 : .filter(|v| context_row_visible(v, tenant))
967 4220 : .map(|v| {
968 4220 : let mut row = v.clone();
969 4220 : if let Some(o) = row.as_object_mut() {
970 4220 : o.remove("body");
971 4220 : }
972 4220 : row
973 4220 : })
974 158 : .collect()
975 158 : }
976 : }
977 :
978 : #[cfg(test)]
979 : mod tests {
980 : use super::*;
981 : use serde_json::json;
982 :
983 : /// A change hook that PANICS must cost only its own caller: the panic
984 : /// unwinds through that writer, and every later writer finds the store
985 : /// usable — locks recover from the poisoning instead of turning one bad
986 : /// hook into a dead store.
987 : #[tokio::test(flavor = "multi_thread")]
988 2 : async fn a_panicking_hook_leaves_the_store_usable() {
989 : use std::sync::Arc;
990 2 : let s = Arc::new(Store::default());
991 2 : s.set_change_hook(Arc::new(
992 : |_t: &TenantId,
993 : _b: Option<Value>,
994 : after: Option<Value>|
995 4 : -> antares_store::HookFuture<'_> {
996 4 : Box::pin(async move {
997 4 : if after.as_ref().and_then(|a| a.get("boom")).is_some() {
998 2 : panic!("hook panic");
999 2 : }
1000 2 : })
1001 4 : },
1002 : ));
1003 2 : let t = TenantId::new("hook-panic").expect("tenant");
1004 2 : let s2 = s.clone();
1005 2 : let t2 = t.clone();
1006 2 : let poisoned = tokio::spawn(async move {
1007 2 : s2.upsert(
1008 2 : &t2,
1009 2 : Kind::Entity,
1010 2 : "urn:x:boom",
1011 2 : json!({"id": "urn:x:boom", "type": ["T"], "boom": true}),
1012 2 : )
1013 2 : .await;
1014 2 : })
1015 2 : .await;
1016 2 : assert!(poisoned.is_err(), "the hook's panic reaches its own caller");
1017 : // the NEXT writer and reader are untouched
1018 2 : assert!(
1019 2 : s.create(
1020 2 : &t,
1021 2 : Kind::Entity,
1022 2 : "urn:x:after",
1023 2 : json!({"id": "urn:x:after", "type": ["T"]})
1024 2 : )
1025 2 : .await
1026 : );
1027 2 : assert!(
1028 2 : s.get(&t, Kind::Entity, "urn:x:after").is_some(),
1029 : "one panicking hook must not take the store down"
1030 : );
1031 2 : assert!(
1032 2 : s.get(&t, Kind::Entity, "urn:x:boom").is_some(),
1033 2 : "the write that triggered the panic still committed — the hook runs after the commit"
1034 2 : );
1035 2 : }
1036 :
1037 : /// Two writers to the same entity: the change hook fires in COMMIT
1038 : /// order. The first writer's hook is gated open only after the second
1039 : /// writer has had every chance to overtake it — if the second commit may
1040 : /// emit before the first commit's emit, the notification pipeline sees
1041 : /// the versions reversed and records stale state as newest.
1042 : #[tokio::test(flavor = "multi_thread")]
1043 2 : async fn change_hook_fires_in_commit_order_per_entity() {
1044 : use std::sync::atomic::{AtomicBool, Ordering};
1045 : use std::sync::Arc;
1046 2 : let s = Arc::new(Store::default());
1047 2 : let seen: Arc<std::sync::Mutex<Vec<i64>>> = Arc::default();
1048 2 : let entered = Arc::new(AtomicBool::new(false));
1049 2 : let released = Arc::new(AtomicBool::new(false));
1050 : {
1051 2 : let (seen, entered, released) = (seen.clone(), entered.clone(), released.clone());
1052 2 : s.set_change_hook(Arc::new(
1053 : move |_t: &TenantId,
1054 : _b: Option<Value>,
1055 : after: Option<Value>|
1056 4 : -> antares_store::HookFuture<'_> {
1057 4 : let (seen, entered, released) =
1058 4 : (seen.clone(), entered.clone(), released.clone());
1059 4 : Box::pin(async move {
1060 4 : let v = after
1061 4 : .as_ref()
1062 4 : .and_then(|a| a.get("v"))
1063 4 : .and_then(Value::as_i64)
1064 4 : .unwrap_or(-1);
1065 4 : if v == 1 {
1066 2 : entered.store(true, Ordering::SeqCst);
1067 63 : while !released.load(Ordering::SeqCst) {
1068 61 : tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1069 : }
1070 2 : }
1071 4 : seen.lock()
1072 4 : .unwrap_or_else(std::sync::PoisonError::into_inner)
1073 4 : .push(v);
1074 4 : })
1075 4 : },
1076 : ));
1077 : }
1078 2 : let t = TenantId::new("emit-order").expect("tenant");
1079 4 : let doc = |v: i64| json!({"id": "urn:x:1", "type": ["T"], "v": v});
1080 2 : let s1 = s.clone();
1081 2 : let t1c = t.clone();
1082 2 : let w1 = tokio::spawn(async move {
1083 2 : s1.upsert(&t1c, Kind::Entity, "urn:x:1", doc(1)).await;
1084 2 : });
1085 4 : while !entered.load(std::sync::atomic::Ordering::SeqCst) {
1086 2 : tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1087 : }
1088 : // writer 1 has committed and sits inside its hook. Writer 2 now has
1089 : // every chance to commit AND emit before writer 1's emit finishes.
1090 2 : let s2 = s.clone();
1091 2 : let t2c = t.clone();
1092 2 : let w2 = tokio::spawn(async move {
1093 2 : s2.upsert(&t2c, Kind::Entity, "urn:x:1", doc(2)).await;
1094 2 : });
1095 2 : tokio::time::sleep(std::time::Duration::from_millis(60)).await;
1096 2 : released.store(true, std::sync::atomic::Ordering::SeqCst);
1097 2 : w1.await.expect("writer 1");
1098 2 : w2.await.expect("writer 2");
1099 2 : assert_eq!(
1100 2 : *seen
1101 2 : .lock()
1102 2 : .unwrap_or_else(std::sync::PoisonError::into_inner),
1103 2 : vec![1, 2],
1104 2 : "hooks must fire in commit order, or the consumer records stale state as newest"
1105 2 : );
1106 2 : }
1107 :
1108 : #[tokio::test]
1109 2 : async fn commit_queue_counts_writers() {
1110 : // Every write passes through the counted critical section.
1111 2 : let s = Store::default();
1112 2 : assert_eq!(s.commit_queue(), (0, 0));
1113 2 : let t = TenantId::new("t").unwrap();
1114 2 : s.create(&t, Kind::Entity, "urn:a", json!({"id": "urn:a"}))
1115 2 : .await;
1116 2 : let (depth, peak) = s.commit_queue();
1117 2 : assert_eq!(depth, 0, "no writer in flight after the call returns");
1118 2 : assert!(peak >= 1, "the write itself must register in the peak");
1119 2 : }
1120 :
1121 : /// What a store reports about itself, for an operator reading
1122 : /// `/q/health`: the memory and file modes are one backend with two
1123 : /// durability shapes, and the health body must not present them as the
1124 : /// same thing.
1125 : #[test]
1126 2 : fn a_store_reports_the_engine_it_actually_runs() {
1127 2 : let dir = tempdir("engine");
1128 2 : let mem = crate::store::any::AnyStore::Mem(Store::default());
1129 2 : let file = crate::store::any::AnyStore::Mem(Store::open_file(&dir).expect("open"));
1130 2 : assert_eq!(mem.version_info()["engine"], "memory");
1131 2 : assert_eq!(file.version_info()["engine"], "redb");
1132 2 : }
1133 :
1134 : /// The commit queue is a `file`-mode signal: it exists because redb has
1135 : /// one writer and commits fsync through it. A pure in-memory store has no
1136 : /// such committer, so it reports nothing rather than a number that would
1137 : /// read as the same thing and mean something else.
1138 : #[test]
1139 2 : fn only_a_shadowed_store_reports_a_commit_queue() {
1140 : use crate::store::any::AnyStore;
1141 2 : assert_eq!(AnyStore::Mem(Store::default()).commit_queue(), None);
1142 2 : let dir = tempdir("commit-queue");
1143 2 : let s = Store::open_file(&dir).expect("open");
1144 2 : assert!(
1145 2 : AnyStore::Mem(s).commit_queue().is_some(),
1146 : "a durable store reports the queue behind its single committer"
1147 : );
1148 2 : }
1149 :
1150 : #[tokio::test]
1151 2 : async fn tenant_isolation() {
1152 2 : let s = Store::default();
1153 2 : let t1 = TenantId::new("t1").unwrap();
1154 2 : let t2 = TenantId::new("t2").unwrap();
1155 2 : assert!(
1156 2 : s.create(&t1, Kind::Entity, "urn:a", json!({"id": "urn:a"}))
1157 2 : .await
1158 : );
1159 2 : assert!(s.get(&t2, Kind::Entity, "urn:a").is_none());
1160 2 : assert!(s.get(&t1, Kind::Entity, "urn:a").is_some());
1161 2 : assert!(!s.create(&t1, Kind::Entity, "urn:a", json!({})).await);
1162 2 : assert!(s.create(&t2, Kind::Entity, "urn:a", json!({})).await);
1163 2 : }
1164 :
1165 : /// 4.6.3 allows a comma as the seconds-fraction separator. Byte order
1166 : /// puts ',' (0x2C) before both '.' and 'Z', so a comma-form instance that
1167 : /// has NOT expired reads as expired against the point-form `now` this
1168 : /// store stamps — the write path would drop a live instance, and the
1169 : /// postgres path (which parses through `try_timestamptz`) would keep it.
1170 : #[tokio::test]
1171 2 : async fn a_comma_fraction_instance_is_not_pruned_before_it_expires() {
1172 2 : let now = "2026-09-01T12:00:00.000Z";
1173 2 : let mut live = json!({
1174 2 : "id": "urn:x", "type": ["T"],
1175 2 : "https://a/attr": [{"value": 1, "expiresAt": "2026-09-01T12:00:00,500Z"}]
1176 : });
1177 2 : assert!(
1178 2 : !prune_expired_instances(&mut live, now),
1179 : "live instance kept"
1180 : );
1181 2 : assert_eq!(live["https://a/attr"].as_array().map(Vec::len), Some(1));
1182 :
1183 : // one that HAS expired still goes, comma form included, and takes the
1184 : // attribute with it when it was the last instance
1185 2 : let mut gone = json!({
1186 2 : "id": "urn:x", "type": ["T"],
1187 2 : "https://a/attr": [{"value": 1, "expiresAt": "2026-09-01T11:59:59,500Z"}]
1188 : });
1189 2 : assert!(prune_expired_instances(&mut gone, now));
1190 2 : assert!(gone.get("https://a/attr").is_none());
1191 :
1192 : // the entity-level sweep reads the same stamp the same way
1193 2 : let s = Store::default();
1194 2 : let t = TenantId::new("t").expect("tenant");
1195 2 : assert!(
1196 2 : s.create(
1197 2 : &t,
1198 2 : Kind::Entity,
1199 2 : "urn:live",
1200 2 : json!({"id": "urn:live", "type": ["T"], "expiresAt": "2026-09-01T12:00:00,500Z"})
1201 2 : )
1202 2 : .await
1203 : );
1204 2 : assert!(
1205 2 : s.create(
1206 2 : &t,
1207 2 : Kind::Entity,
1208 2 : "urn:gone",
1209 2 : json!({"id": "urn:gone", "type": ["T"], "expiresAt": "2026-09-01T11:59:59,500Z"})
1210 2 : )
1211 2 : .await
1212 : );
1213 2 : assert_eq!(s.sweep_expired(now), 1, "only the expired one is reaped");
1214 2 : assert!(s.get(&t, Kind::Entity, "urn:live").is_some());
1215 2 : assert!(s.get(&t, Kind::Entity, "urn:gone").is_none());
1216 2 : }
1217 :
1218 : /// The same assertion over EVERY kind and the whole read/write surface,
1219 : /// with the SAME id on both sides — the shape a tenant-blind map gets
1220 : /// wrong, because it answers the neighbour's document instead of `None`.
1221 : #[tokio::test]
1222 2 : async fn no_kind_leaks_across_tenants() {
1223 2 : let s = Store::default();
1224 2 : let a = TenantId::new("t-a").expect("tenant");
1225 2 : let b = TenantId::new("t-b").expect("tenant");
1226 2 : let id = "urn:ngsi-ld:shared:1";
1227 18 : for kind in ALL_KINDS {
1228 18 : assert!(
1229 18 : s.create(&a, kind, id, json!({"id": id, "owner": "a"}))
1230 18 : .await
1231 2 : );
1232 2 :
1233 18 : assert!(s.get(&b, kind, id).is_none(), "{kind:?}: get");
1234 18 : assert!(s.list(&b, kind).is_empty(), "{kind:?}: list");
1235 18 : assert!(
1236 18 : s.list_page(&b, kind, None, 100).is_empty(),
1237 2 : "{kind:?}: list_page"
1238 2 : );
1239 18 : assert_eq!(
1240 18 : s.list_slice(&b, kind, 0, 100),
1241 18 : (Vec::new(), 0),
1242 2 : "{kind:?}: list_slice"
1243 2 : );
1244 2 :
1245 2 : // no write reaches the neighbour's document either
1246 18 : assert!(!s.delete(&b, kind, id).await, "{kind:?}: delete");
1247 18 : assert!(
1248 18 : s.mutate::<(), ()>(&b, kind, id, |d| {
1249 2 : d["owner"] = json!("b");
1250 2 : Ok(())
1251 2 : })
1252 18 : .await
1253 18 : .is_none(),
1254 2 : "{kind:?}: mutate"
1255 2 : );
1256 2 :
1257 2 : // the same id under another tenant is a create, not a conflict
1258 18 : assert!(
1259 18 : s.create(&b, kind, id, json!({"id": id, "owner": "b"}))
1260 18 : .await
1261 2 : );
1262 18 : assert_eq!(s.get(&a, kind, id).expect("a keeps its own")["owner"], "a");
1263 18 : assert_eq!(s.get(&b, kind, id).expect("b has its own")["owner"], "b");
1264 2 :
1265 2 : // and b deleting its own leaves a's untouched
1266 18 : assert!(s.delete(&b, kind, id).await, "{kind:?}: delete own");
1267 18 : assert!(s.get(&b, kind, id).is_none(), "{kind:?}: deleted");
1268 18 : assert_eq!(
1269 18 : s.get(&a, kind, id)
1270 18 : .expect("survives the neighbour's delete")["owner"],
1271 2 : "a"
1272 2 : );
1273 18 : assert!(s.delete(&a, kind, id).await, "{kind:?}: cleanup");
1274 2 : }
1275 2 : }
1276 :
1277 26 : fn tempdir(name: &str) -> std::path::PathBuf {
1278 26 : let dir = std::env::temp_dir().join(format!("antares-store-{name}-{}", std::process::id()));
1279 26 : let _ = std::fs::remove_dir_all(&dir);
1280 26 : std::fs::create_dir_all(&dir).expect("tempdir");
1281 26 : dir
1282 26 : }
1283 :
1284 : /// `open_file` promises "Any open/format/decode error refuses to start —
1285 : /// never silently serve partial data", and the boot rebuild's kind loop
1286 : /// keeps it: an absent table has nothing to load, any other error refuses.
1287 : /// The @context table is read as `if let Ok(...)` instead, so a table that
1288 : /// exists and cannot be opened counts as an empty one — and that is the
1289 : /// table whose absence costs a Tenant its own documents, since Hosted and
1290 : /// ImplicitlyCreated @contexts (5.13.1) hold term mappings authored
1291 : /// through its requests. The broker would come up and serve without them.
1292 : ///
1293 : /// A table of the same name under different key/value types is redb's
1294 : /// `TableTypeMismatch`: the shape a file written by another binary, or by
1295 : /// an older schema, actually has.
1296 : #[test]
1297 2 : fn an_unreadable_table_refuses_the_boot_instead_of_reading_as_empty() {
1298 2 : let dir = tempdir("unreadable-context-table");
1299 2 : let path = dir.join("antares.redb");
1300 2 : {
1301 2 : let db = Database::create(&path).expect("db");
1302 2 : let mut tx = db.begin_write().expect("tx");
1303 2 : tx.set_durability(Durability::Immediate).expect("dur");
1304 2 : {
1305 2 : let mut m = tx.open_table(T_META).expect("meta");
1306 2 : m.insert("format", FORMAT_VERSION).expect("insert");
1307 2 : }
1308 2 : {
1309 2 : let def = ::redb::TableDefinition::<&str, &str>::new("jsonld_contexts");
1310 2 : let mut t = tx.open_table(def).expect("mismatched contexts table");
1311 2 : t.insert("urn:ngsi-ld:ctx", "{}").expect("insert");
1312 2 : }
1313 2 : tx.commit().expect("commit");
1314 2 : }
1315 2 : let db = Database::create(&path).expect("reopen");
1316 2 : let Err(err) = Store::from_database(db, &path.display().to_string()) else {
1317 0 : panic!("a table that cannot be opened must refuse the boot, not start without it");
1318 : };
1319 2 : assert!(!err.is_empty(), "the refusal says what failed");
1320 2 : }
1321 :
1322 : /// The same read, in the guard that exists to refuse a file this binary
1323 : /// cannot vouch for: data present with no format marker. Skipping a table
1324 : /// it cannot open makes the guard conclude the file is empty and stamp
1325 : /// this binary's marker onto it — a write into a file it is about to
1326 : /// declare unreadable, and one that costs the operator the guard's own
1327 : /// diagnosis on every later start.
1328 : #[test]
1329 2 : fn the_marker_guard_does_not_stamp_a_file_it_could_not_read() {
1330 2 : let dir = tempdir("unstamped-unreadable");
1331 2 : let path = dir.join("antares.redb");
1332 2 : {
1333 2 : let db = Database::create(&path).expect("db");
1334 2 : let mut tx = db.begin_write().expect("tx");
1335 2 : tx.set_durability(Durability::Immediate).expect("dur");
1336 2 : let def = ::redb::TableDefinition::<&str, &str>::new("entities");
1337 2 : let mut t = tx.open_table(def).expect("mismatched entities table");
1338 2 : t.insert("k", "v").expect("insert");
1339 2 : drop(t);
1340 2 : tx.commit().expect("commit");
1341 2 : }
1342 2 : let db = Database::create(&path).expect("reopen");
1343 2 : let Err(_) = Store::from_database(db, &path.display().to_string()) else {
1344 0 : panic!("an unreadable table with no marker must refuse the boot");
1345 : };
1346 2 : let db = Database::create(&path).expect("reopen after the refusal");
1347 2 : let rt = db.begin_read().expect("read tx");
1348 2 : let stamped = match rt.open_table(T_META) {
1349 0 : Ok(m) => m.get("format").expect("get").is_some(),
1350 2 : Err(::redb::TableError::TableDoesNotExist(_)) => false,
1351 0 : Err(e) => panic!("{e}"),
1352 : };
1353 2 : assert!(
1354 2 : !stamped,
1355 : "a file the guard could not read must not carry this binary's format marker"
1356 : );
1357 2 : }
1358 :
1359 : /// Every kind + contexts round-trip through a close/reopen.
1360 : #[tokio::test]
1361 2 : async fn file_mode_survives_reopen() {
1362 2 : let dir = tempdir("reopen");
1363 2 : let t = TenantId::new("tenant_a-1").expect("tenant");
1364 : {
1365 2 : let s = Store::open_file(&dir).expect("open");
1366 18 : for kind in ALL_KINDS {
1367 18 : assert!(
1368 18 : s.create(&t, kind, "urn:x:1", json!({"kind": format!("{kind:?}")}))
1369 18 : .await
1370 : );
1371 : }
1372 : // a row the writers actually produce: the kind is what decides
1373 : // whose it is (ADR-0021), and a Cached copy belongs to no Tenant
1374 2 : s.context_put(
1375 2 : None,
1376 2 : "ctx1",
1377 2 : json!({"localId": "ctx1", "kind": "Cached", "body": {"@context": {}}}),
1378 : )
1379 2 : .expect("store");
1380 : }
1381 2 : let s = Store::open_file(&dir).expect("reopen");
1382 18 : for kind in ALL_KINDS {
1383 18 : assert_eq!(
1384 18 : s.get(&t, kind, "urn:x:1").expect("survives")["kind"],
1385 18 : format!("{kind:?}")
1386 : );
1387 : }
1388 2 : assert!(s.context_get(None, "ctx1").is_some());
1389 : // tenant isolation intact after rebuild
1390 2 : assert!(s
1391 2 : .get(&TenantId::default(), Kind::Entity, "urn:x:1")
1392 2 : .is_none());
1393 2 : let _ = std::fs::remove_dir_all(&dir);
1394 2 : }
1395 :
1396 : /// Deletes and mutations reach redb — no phantom state after restart.
1397 : #[tokio::test]
1398 2 : async fn file_mode_deletes_and_updates_persist() {
1399 2 : let dir = tempdir("delete");
1400 2 : let t = TenantId::default();
1401 : {
1402 2 : let s = Store::open_file(&dir).expect("open");
1403 2 : s.create(&t, Kind::Entity, "urn:gone", json!({"n": 1}))
1404 2 : .await;
1405 2 : s.create(&t, Kind::Entity, "urn:kept", json!({"n": 1}))
1406 2 : .await;
1407 2 : assert!(s.delete(&t, Kind::Entity, "urn:gone").await);
1408 2 : let r: Option<Result<(), ()>> = s
1409 2 : .mutate(&t, Kind::Entity, "urn:kept", |d| {
1410 2 : d["n"] = json!(2);
1411 2 : Ok(())
1412 2 : })
1413 2 : .await;
1414 2 : assert!(matches!(r, Some(Ok(()))));
1415 2 : s.context_put(None, "ctx", json!({"localId": "ctx", "kind": "Cached"}))
1416 2 : .expect("store");
1417 2 : assert!(s.context_delete(None, "ctx"));
1418 : }
1419 2 : let s = Store::open_file(&dir).expect("reopen");
1420 2 : assert!(
1421 2 : s.get(&t, Kind::Entity, "urn:gone").is_none(),
1422 : "phantom 409 trap"
1423 : );
1424 2 : assert_eq!(s.get(&t, Kind::Entity, "urn:kept").expect("kept")["n"], 2);
1425 2 : assert!(s.context_get(None, "ctx").is_none());
1426 2 : let _ = std::fs::remove_dir_all(&dir);
1427 2 : }
1428 :
1429 : /// A future-format file refuses to load with a clear message.
1430 : #[test]
1431 2 : fn file_mode_refuses_format_mismatch() {
1432 2 : let dir = tempdir("format");
1433 2 : {
1434 2 : let db = Database::create(dir.join("antares.redb")).expect("db");
1435 2 : let mut tx = db.begin_write().expect("tx");
1436 2 : tx.set_durability(Durability::Immediate).expect("dur");
1437 2 : tx.open_table(T_META)
1438 2 : .expect("meta")
1439 2 : .insert("format", "999")
1440 2 : .expect("insert");
1441 2 : tx.commit().expect("commit");
1442 2 : }
1443 2 : let err = match Store::open_file(&dir) {
1444 2 : Err(e) => e,
1445 0 : Ok(_) => panic!("must refuse a format-999 file"),
1446 : };
1447 2 : assert!(err.contains("format 999"), "err: {err}");
1448 2 : let _ = std::fs::remove_dir_all(&dir);
1449 2 : }
1450 :
1451 : /// A file that HOLDS rows but carries no format marker was written by a
1452 : /// binary whose key/value shape this one cannot vouch for. Refusing is
1453 : /// the whole point of the marker — serving it would answer requests from
1454 : /// data that may be misread. An empty file, by contrast, is just a fresh
1455 : /// one and gets the marker stamped.
1456 : #[test]
1457 2 : fn file_mode_refuses_data_without_a_format_marker() {
1458 2 : let dir = tempdir("nomarker");
1459 2 : {
1460 2 : let db = Database::create(dir.join("antares.redb")).expect("db");
1461 2 : let mut tx = db.begin_write().expect("tx");
1462 2 : tx.set_durability(Durability::Immediate).expect("dur");
1463 2 : {
1464 2 : let mut t = tx.open_table(T_ENTITIES).expect("entities");
1465 2 : let mut k = b"plain".to_vec();
1466 2 : k.push(0);
1467 2 : k.extend_from_slice(b"urn:e:1");
1468 2 : let bytes = serde_json::to_vec(&json!({"id": "urn:e:1"})).expect("serialize");
1469 2 : t.insert(k.as_slice(), bytes.as_slice()).expect("insert");
1470 2 : }
1471 2 : // deliberately NO meta table
1472 2 : tx.commit().expect("commit");
1473 2 : }
1474 2 : let err = match Store::open_file(&dir) {
1475 2 : Err(e) => e,
1476 0 : Ok(_) => panic!("must refuse data with no format marker"),
1477 : };
1478 2 : assert!(err.contains("no format marker"), "err: {err}");
1479 2 : assert!(err.contains("antares.redb"), "the file is named: {err}");
1480 2 : let _ = std::fs::remove_dir_all(&dir);
1481 :
1482 : // an EMPTY unmarked file is a fresh store, not a refusal
1483 2 : let fresh = tempdir("nomarker-empty");
1484 2 : {
1485 2 : let db = Database::create(fresh.join("antares.redb")).expect("db");
1486 2 : let tx = db.begin_write().expect("tx");
1487 2 : tx.commit().expect("commit");
1488 2 : }
1489 2 : Store::open_file(&fresh).expect("an empty file is a fresh store");
1490 2 : let _ = std::fs::remove_dir_all(&fresh);
1491 2 : }
1492 :
1493 : /// 4.22: "expiresAt is defined as the system temporal Property at which a
1494 : /// certain Entity, Property or Relationship shall become invalid." The
1495 : /// clause sanctions the DELETION lagging, not the invalidity — so the
1496 : /// write paths must agree with the read boundary the instant the stamp
1497 : /// passes. Before this, the same id was simultaneously a 404 on retrieve,
1498 : /// a 409 on create and a 204 on delete for a whole sweep interval.
1499 : #[tokio::test]
1500 2 : async fn an_expired_entity_is_absent_to_writes_too() {
1501 2 : let s = Store::default();
1502 2 : let t = TenantId::default();
1503 2 : let dead = json!({"id": "urn:e", "type": ["T"], "expiresAt": "2000-01-01T00:00:00Z"});
1504 2 : let live = json!({"id": "urn:l", "type": ["T"], "expiresAt": "2999-01-01T00:00:00Z"});
1505 2 : assert!(s.create(&t, Kind::Entity, "urn:e", dead.clone()).await);
1506 2 : assert!(s.create(&t, Kind::Entity, "urn:l", live.clone()).await);
1507 :
1508 : // patching or deleting something already invalid is a 404, not a 204
1509 2 : assert!(s
1510 2 : .mutate(&t, Kind::Entity, "urn:e", |_d| Ok::<(), ()>(()))
1511 2 : .await
1512 2 : .is_none());
1513 2 : assert!(
1514 2 : !s.delete(&t, Kind::Entity, "urn:e").await,
1515 : "expired delete is 404"
1516 : );
1517 : // …and creating over it succeeds instead of raising AlreadyExists
1518 2 : assert!(
1519 2 : s.create(&t, Kind::Entity, "urn:e", json!({"id": "urn:e", "n": 1}))
1520 2 : .await,
1521 : "an expired id must not 409 a create"
1522 : );
1523 2 : assert_eq!(
1524 2 : s.get(&t, Kind::Entity, "urn:e").expect("recreated")["n"],
1525 : 1,
1526 : "the create must have replaced the expired document"
1527 : );
1528 :
1529 : // an UNEXPIRED entity keeps every one of those answers
1530 2 : assert!(
1531 2 : !s.create(&t, Kind::Entity, "urn:l", live.clone()).await,
1532 : "409"
1533 : );
1534 2 : assert!(s
1535 2 : .mutate(&t, Kind::Entity, "urn:l", |_d| Ok::<(), ()>(()))
1536 2 : .await
1537 2 : .is_some());
1538 2 : assert!(s.delete(&t, Kind::Entity, "urn:l").await);
1539 :
1540 : // upsert over an expired id reports CREATED (201 + Location), not
1541 : // updated
1542 2 : s.create(&t, Kind::Entity, "urn:x", dead.clone()).await;
1543 2 : assert!(
1544 2 : !s.upsert(&t, Kind::Entity, "urn:x", json!({"id": "urn:x"}))
1545 2 : .await,
1546 : "an expired id must upsert as created"
1547 : );
1548 2 : assert!(
1549 2 : s.upsert(&t, Kind::Entity, "urn:x", json!({"id": "urn:x", "n": 2}))
1550 2 : .await,
1551 : "and the live one that replaced it as updated"
1552 : );
1553 :
1554 : // 4.22 is an ENTITY stamp: other kinds keep their own expiry rules
1555 2 : assert!(
1556 2 : s.create(&t, Kind::Subscription, "urn:s", dead.clone())
1557 2 : .await
1558 : );
1559 2 : assert!(
1560 2 : !s.create(&t, Kind::Subscription, "urn:s", dead).await,
1561 : "a subscription id still 409s"
1562 : );
1563 2 : assert!(s.delete(&t, Kind::Subscription, "urn:s").await);
1564 2 : }
1565 :
1566 : /// The change hook drives every notification and all temporal
1567 : /// auto-recording, so its contract is: create emits (None, Some), delete
1568 : /// emits (Some, None), a real mutate emits both images — and, just as
1569 : /// load-bearing, a NON-entity write and a no-op mutate emit NOTHING. A
1570 : /// subscription leaking into the hook would be mirrored into temporal
1571 : /// storage; a no-op emitting would re-notify every subscriber on every
1572 : /// idempotent PATCH.
1573 : #[tokio::test]
1574 2 : async fn the_change_hook_fires_for_entity_changes_only() {
1575 : use std::sync::{Arc, Mutex};
1576 : type Images = (Option<Value>, Option<Value>);
1577 2 : let seen: Arc<Mutex<Vec<Images>>> = Arc::default();
1578 2 : let s = Store::default();
1579 2 : let t = TenantId::default();
1580 2 : let rec = Arc::clone(&seen);
1581 2 : s.set_change_hook(Arc::new(
1582 : move |_t: &TenantId,
1583 : before: Option<Value>,
1584 : after: Option<Value>|
1585 6 : -> antares_store::HookFuture<'_> {
1586 6 : let rec = Arc::clone(&rec);
1587 6 : Box::pin(async move {
1588 6 : rec.lock().expect("record").push((before, after));
1589 6 : })
1590 6 : },
1591 : ));
1592 :
1593 2 : s.create(&t, Kind::Entity, "urn:e", json!({"id": "urn:e", "n": 1}))
1594 2 : .await;
1595 : // a write on another kind must not reach the hook at all
1596 2 : s.create(&t, Kind::Subscription, "urn:s", json!({"id": "urn:s"}))
1597 2 : .await;
1598 2 : s.upsert(
1599 2 : &t,
1600 2 : Kind::Subscription,
1601 2 : "urn:s",
1602 2 : json!({"id": "urn:s", "n": 9}),
1603 2 : )
1604 2 : .await;
1605 2 : s.delete(&t, Kind::Subscription, "urn:s").await;
1606 : // a mutate that changes nothing is not a change
1607 2 : let _ = s
1608 2 : .mutate(&t, Kind::Entity, "urn:e", |_d| Ok::<(), ()>(()))
1609 2 : .await;
1610 : // a real one is
1611 2 : let _ = s
1612 2 : .mutate(&t, Kind::Entity, "urn:e", |d| {
1613 2 : d["n"] = json!(2);
1614 2 : Ok::<(), ()>(())
1615 2 : })
1616 2 : .await;
1617 : // an aborted mutate writes nothing and emits nothing
1618 2 : let _ = s
1619 2 : .mutate(&t, Kind::Entity, "urn:e", |d| {
1620 2 : d["n"] = json!(3);
1621 2 : Err::<(), &str>("no")
1622 2 : })
1623 2 : .await;
1624 2 : s.delete(&t, Kind::Entity, "urn:e").await;
1625 :
1626 2 : let seen = seen.lock().expect("read");
1627 2 : assert_eq!(seen.len(), 3, "emitted: {seen:?}");
1628 2 : assert!(seen[0].0.is_none() && seen[0].1.is_some(), "create");
1629 2 : assert_eq!(seen[1].0.as_ref().expect("before")["n"], 1);
1630 2 : assert_eq!(seen[1].1.as_ref().expect("after")["n"], 2);
1631 2 : assert!(seen[2].0.is_some() && seen[2].1.is_none(), "delete");
1632 : // the aborted mutate left the document alone
1633 2 : assert!(s.get(&t, Kind::Entity, "urn:e").is_none());
1634 2 : }
1635 :
1636 : /// The supported backup route is stop-copy — close the broker, copy
1637 : /// the file, reopen the copy. This test IS that route.
1638 : #[tokio::test]
1639 2 : async fn file_mode_stop_copy_backup_restores() {
1640 2 : let dir = tempdir("backup");
1641 2 : let restore = tempdir("restore");
1642 2 : let t = TenantId::default();
1643 : {
1644 2 : let s = Store::open_file(&dir).expect("open");
1645 2 : s.create(&t, Kind::Entity, "urn:b", json!({"v": 42})).await;
1646 : } // broker stopped — file quiescent
1647 2 : std::fs::copy(dir.join("antares.redb"), restore.join("antares.redb")).expect("copy");
1648 2 : let s = Store::open_file(&restore).expect("open backup");
1649 2 : assert_eq!(s.get(&t, Kind::Entity, "urn:b").expect("restored")["v"], 42);
1650 2 : let _ = std::fs::remove_dir_all(&dir);
1651 2 : let _ = std::fs::remove_dir_all(&restore);
1652 2 : }
1653 :
1654 : /// 4.22: reaping an expired entity is a state change like any other, so
1655 : /// it must be durable — after a sweep the doc stays gone across a reopen,
1656 : /// whatever the tenant key on disk looks like, while a doc that has not
1657 : /// expired survives both.
1658 : #[test]
1659 2 : fn file_mode_sweep_removals_persist() {
1660 2 : let dir = tempdir("sweep-persist");
1661 : // Seed the file directly: one tenant key the boot rebuild accepts but
1662 : // `TenantId::new` rejects, alongside an ordinary one.
1663 : {
1664 2 : let db = Database::create(dir.join("antares.redb")).expect("db");
1665 2 : let mut tx = db.begin_write().expect("tx");
1666 2 : tx.set_durability(Durability::Immediate).expect("dur");
1667 2 : {
1668 2 : let mut m = tx.open_table(T_META).expect("meta");
1669 2 : m.insert("format", FORMAT_VERSION).expect("insert");
1670 2 : }
1671 : {
1672 2 : let mut t = tx.open_table(T_ENTITIES).expect("entities");
1673 6 : for (tenant, id, expires) in [
1674 2 : ("odd.tenant", "urn:e:1", "2000-01-01T00:00:00Z"),
1675 2 : ("odd.tenant", "urn:e:2", "2999-01-01T00:00:00Z"),
1676 2 : ("plain", "urn:e:3", "2000-01-01T00:00:00Z"),
1677 6 : ] {
1678 6 : let mut k = tenant.as_bytes().to_vec();
1679 6 : k.push(0);
1680 6 : k.extend_from_slice(id.as_bytes());
1681 6 : let doc = json!({"id": id, "type": ["T"], "expiresAt": expires});
1682 6 : let bytes = serde_json::to_vec(&doc).expect("serialize");
1683 6 : t.insert(k.as_slice(), bytes.as_slice()).expect("insert");
1684 6 : }
1685 : }
1686 2 : tx.commit().expect("commit");
1687 : }
1688 : {
1689 2 : let s = Store::open_file(&dir).expect("open");
1690 2 : assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), 2, "both expired");
1691 : }
1692 2 : let s = Store::open_file(&dir).expect("reopen");
1693 2 : let inner = s.inner.read().expect("lock");
1694 2 : assert!(
1695 2 : !inner
1696 2 : .entities
1697 2 : .get("odd.tenant")
1698 2 : .is_some_and(|d| d.contains_key("urn:e:1")),
1699 : "swept entity resurrected on reopen"
1700 : );
1701 2 : assert!(
1702 2 : inner
1703 2 : .entities
1704 2 : .get("odd.tenant")
1705 2 : .is_some_and(|d| d.contains_key("urn:e:2")),
1706 : "unexpired entity must outlive the sweep"
1707 : );
1708 2 : assert!(
1709 2 : !inner
1710 2 : .entities
1711 2 : .get("plain")
1712 2 : .is_some_and(|d| d.contains_key("urn:e:3")),
1713 : "swept entity resurrected on reopen"
1714 : );
1715 2 : drop(inner);
1716 2 : let _ = std::fs::remove_dir_all(&dir);
1717 2 : }
1718 :
1719 : #[tokio::test]
1720 2 : async fn sweep_prunes_expired_attribute_instances() {
1721 2 : let s = Store::default();
1722 2 : let t = TenantId::default();
1723 2 : s.create(
1724 2 : &t,
1725 2 : Kind::Entity,
1726 2 : "urn:e",
1727 2 : json!({"id": "urn:e", "type": ["T"],
1728 2 : "attr": [
1729 2 : {"type": "Property", "value": 1, "expiresAt": "2000-01-01T00:00:00Z"},
1730 2 : {"type": "Property", "value": 2, "expiresAt": "2999-01-01T00:00:00Z"}],
1731 2 : "gone": [
1732 2 : {"type": "Property", "value": 3, "expiresAt": "2000-01-01T00:00:00Z"}]}),
1733 2 : )
1734 2 : .await;
1735 2 : s.create(
1736 2 : &t,
1737 2 : Kind::Temporal,
1738 2 : "urn:e",
1739 2 : json!({"id": "urn:e", "type": ["T"],
1740 2 : "attr": [
1741 2 : {"value": 1, "expiresAt": "2000-01-01T00:00:00Z"},
1742 2 : {"value": 2, "expiresAt": "2999-01-01T00:00:00Z"},
1743 2 : {"value": 3}]}),
1744 2 : )
1745 2 : .await;
1746 2 : assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), 2);
1747 2 : let e = s.get(&t, Kind::Entity, "urn:e").expect("entity survives");
1748 2 : assert_eq!(e["attr"].as_array().expect("attr").len(), 1);
1749 2 : assert_eq!(e["attr"][0]["value"], 2);
1750 2 : assert!(e.get("gone").is_none(), "fully-expired attribute removed");
1751 2 : let tp = s
1752 2 : .get(&t, Kind::Temporal, "urn:e")
1753 2 : .expect("temporal survives");
1754 2 : let vals: Vec<i64> = tp["attr"]
1755 2 : .as_array()
1756 2 : .expect("instances")
1757 2 : .iter()
1758 4 : .map(|i| i["value"].as_i64().expect("value"))
1759 2 : .collect();
1760 2 : assert_eq!(vals, [2, 3], "expired pruned, no-expiry instance kept");
1761 2 : assert_eq!(s.sweep_expired("2026-01-01T00:00:00Z"), 0, "idempotent");
1762 2 : }
1763 :
1764 : /// ADR-0021 + 4.14: the memory/file arm has no Row-Level Security under
1765 : /// it, so the rule a Postgres deployment gets from the database is this
1766 : /// store's own job. A Hosted @context holds term mappings authored
1767 : /// through one Tenant's requests and belongs to that Tenant alone; a
1768 : /// Cached copy of a public document belongs to none and every Tenant
1769 : /// reaches it. A store that answered every caller would hand one
1770 : /// Tenant's term mappings to another, and 5.5.7 makes those mappings
1771 : /// decide what its payloads mean.
1772 : #[test]
1773 2 : fn clause_5_13_1_a_stored_context_answers_only_its_own_tenant() {
1774 2 : let s = Store::default();
1775 2 : let alpha = TenantId::new("alpha").expect("tenant");
1776 2 : let beta = TenantId::new("beta").expect("tenant");
1777 2 : let hosted = json!({"localId": "h", "kind": "Hosted", "owner": "alpha",
1778 2 : "body": {"@context": {"a": "https://alpha.example/a"}}});
1779 2 : s.context_put(Some(&alpha), "h", hosted.clone())
1780 2 : .expect("the owner stores its own row");
1781 2 : s.context_put(
1782 2 : None,
1783 2 : "c",
1784 2 : json!({"localId": "c", "kind": "Cached", "body": {"@context": {}}}),
1785 : )
1786 2 : .expect("a Cached copy belongs to no Tenant");
1787 :
1788 2 : assert!(
1789 2 : s.context_get(Some(&alpha), "h").is_some(),
1790 : "its owner reads it"
1791 : );
1792 2 : assert!(
1793 2 : s.context_get(Some(&beta), "h").is_none(),
1794 : "another Tenant's Hosted @context is as absent as one never stored"
1795 : );
1796 2 : assert!(
1797 2 : s.context_get(None, "h").is_none(),
1798 : "no Tenant in scope reaches no Tenant's documents"
1799 : );
1800 6 : for t in [Some(&alpha), Some(&beta), None] {
1801 6 : assert!(
1802 6 : s.context_get(t, "c").is_some(),
1803 : "a Cached copy is a public document and is shared"
1804 : );
1805 : }
1806 2 : assert!(
1807 2 : !s.context_delete(Some(&beta), "h"),
1808 : "a foreign delete takes nothing"
1809 : );
1810 2 : assert!(
1811 2 : s.context_put(Some(&beta), "h", hosted).is_err(),
1812 : "and a foreign write does not overwrite it either"
1813 : );
1814 2 : assert!(
1815 2 : s.context_list_meta(Some(&beta))
1816 2 : .iter()
1817 2 : .all(|r| r["localId"] != "h"),
1818 : "another Tenant's row is not listed"
1819 : );
1820 2 : assert!(
1821 2 : s.context_get(Some(&alpha), "h").is_some(),
1822 : "the owner's row survived every foreign attempt"
1823 : );
1824 :
1825 2 : s.purge_tenant(&alpha);
1826 2 : assert!(
1827 2 : s.context_get(Some(&alpha), "h").is_none(),
1828 : "purging the Tenant takes the @contexts it stored"
1829 : );
1830 2 : assert!(
1831 2 : s.context_get(None, "c").is_some(),
1832 : "and leaves the copy that belongs to no Tenant"
1833 : );
1834 2 : }
1835 :
1836 : /// 5.13.1: "Implementations shall periodically invalidate the 'Cached'
1837 : /// @contexts." The memory/file arm holds one entry per distinct external
1838 : /// URL a request referenced — client-controlled — so the same ceiling and
1839 : /// oldest-first eviction the Pg arm applies holds here, and it applies to
1840 : /// the Cached kind ONLY: a Hosted entry is client-owned data (5.13.2).
1841 : #[test]
1842 2 : fn clause_5_13_1_cached_contexts_are_capped_oldest_first() {
1843 2 : let s = Store::default();
1844 2004 : let entry = |kind: &str, created: &str| json!({"kind": kind, "createdAt": created, "body": {"@context": {}}});
1845 : // a Hosted entry older than every Cached one: age must not decide
1846 2 : s.context_put(
1847 2 : Some(&TenantId::default()),
1848 2 : "hosted",
1849 2 : entry("Hosted", "2000-01-01T00:00:00Z"),
1850 : )
1851 2 : .expect("store");
1852 2000 : for i in 0..MAX_CACHED_CONTEXTS {
1853 2000 : s.context_put(
1854 2000 : None,
1855 2000 : &format!("cached-{i:05}"),
1856 2000 : entry("Cached", &format!("2026-01-01T00:00:{:02}Z", i % 60)),
1857 2000 : )
1858 2000 : .expect("store");
1859 2000 : }
1860 4 : let cached = |s: &Store| {
1861 4 : s.context_list_meta(Some(&TenantId::default()))
1862 4 : .iter()
1863 4004 : .filter(|d| d["kind"] == "Cached")
1864 4 : .count()
1865 4 : };
1866 2 : assert_eq!(
1867 2 : cached(&s),
1868 : MAX_CACHED_CONTEXTS,
1869 : "at the ceiling, nothing lost"
1870 : );
1871 :
1872 : // one more Cached entry evicts exactly one — the oldest
1873 2 : s.context_put(None, "cached-new", entry("Cached", "2026-06-01T00:00:00Z"))
1874 2 : .expect("store");
1875 2 : assert_eq!(cached(&s), MAX_CACHED_CONTEXTS, "the ceiling holds");
1876 2 : assert!(
1877 2 : s.context_get(None, "cached-new").is_some(),
1878 : "the new entry stays"
1879 : );
1880 2 : assert!(
1881 2 : s.context_get(None, "cached-00000").is_none(),
1882 : "the oldest Cached entry is the one evicted"
1883 : );
1884 2 : assert!(
1885 2 : s.context_get(None, "cached-00001").is_some(),
1886 : "eviction stops at the ceiling"
1887 : );
1888 2 : assert!(
1889 2 : s.context_get(Some(&TenantId::default()), "hosted")
1890 2 : .is_some(),
1891 : "a Hosted entry is never a candidate, however old"
1892 : );
1893 2 : }
1894 :
1895 : #[tokio::test]
1896 2 : async fn mutate_aborts_on_error() {
1897 2 : let s = Store::default();
1898 2 : let t = TenantId::default();
1899 2 : s.create(&t, Kind::Entity, "urn:a", json!({"n": 1})).await;
1900 2 : let r: Option<Result<(), &str>> = s
1901 2 : .mutate(&t, Kind::Entity, "urn:a", |d| {
1902 2 : d["n"] = json!(2);
1903 2 : Err("nope")
1904 2 : })
1905 2 : .await;
1906 2 : assert!(matches!(r, Some(Err("nope"))));
1907 2 : assert_eq!(s.get(&t, Kind::Entity, "urn:a").unwrap()["n"], 1);
1908 2 : }
1909 :
1910 : /// A purged tenant is gone from every kind, the neighbour tenant is
1911 : /// untouched, and in `file` mode the removal survives a reopen.
1912 : #[tokio::test]
1913 2 : async fn purge_tenant_empties_every_kind_and_survives_reopen() {
1914 2 : let dir = tempdir("purge");
1915 2 : let a = TenantId::new("purge_a").expect("tenant");
1916 2 : let b = TenantId::new("purge_b").expect("tenant");
1917 : {
1918 2 : let s = Store::open_file(&dir).expect("open");
1919 4 : for t in [&a, &b] {
1920 36 : for kind in ALL_KINDS {
1921 36 : assert!(s.create(t, kind, "urn:x:1", json!({"id": "urn:x:1"})).await);
1922 : }
1923 : }
1924 4 : assert!(s.tenant_ids().iter().any(|t| t == "purge_a"), "listed");
1925 2 : let row = s.tenant_stats_one(&a);
1926 2 : assert_eq!((row.entities, row.subscriptions, row.dist_subs), (1, 1, 1));
1927 2 : assert!(s.purge_tenant(&a));
1928 2 : assert!(!s.purge_tenant(&a), "second purge finds nothing");
1929 2 : assert!(!s.tenant_exists(&a));
1930 2 : assert!(s.tenant_exists(&b));
1931 18 : for kind in ALL_KINDS {
1932 18 : assert!(s.get(&a, kind, "urn:x:1").is_none(), "{kind:?} row left");
1933 18 : assert!(s.get(&b, kind, "urn:x:1").is_some(), "{kind:?} lost for b");
1934 : }
1935 4 : assert!(s.tenant_ids().iter().all(|t| t != "purge_a"));
1936 2 : assert_eq!(
1937 2 : s.tenant_stats_one(&a).entities,
1938 : 0,
1939 : "a purged tenant counts nothing"
1940 : );
1941 : }
1942 2 : let s = Store::open_file(&dir).expect("reopen");
1943 2 : assert!(!s.tenant_exists(&a), "purge must be persisted");
1944 2 : assert!(s.tenant_exists(&b));
1945 2 : let _ = std::fs::remove_dir_all(&dir);
1946 2 : }
1947 : }
|