Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! Subscription matching + HTTP notification delivery (5.8.6, 5.3.1).
3 : //!
4 : //! Change detection: the store's change hook feeds every entity write here as
5 : //! a (before, after) pair; attribute-level changes are derived by diffing —
6 : //! one hook point instead of one call per write handler.
7 : //!
8 : //! Candidate lookup is index-shaped. `SubMirror` keeps inverted
9 : //! (tenant, type) and (tenant, watched-attr) maps next to the docs, so one
10 : //! change evaluates only the subscriptions that could possibly fire — never
11 : //! a scan over all of a tenant's subscriptions. Subscriptions the index
12 : //! cannot classify exactly (4.17 type-selection expressions) fall into a
13 : //! `broad` bucket that is always evaluated: the index may over-select,
14 : //! never under-select. Full evaluation (selector/q/geo/scope/triggers)
15 : //! stays the truth for every candidate.
16 :
17 : use crate::mirror::{index_keys, Change, Keys, Mirror, SubMirror, CSUB_SWEEP_BACKSTOP_MS};
18 : use crate::negotiate::{inject_context, link_header_value};
19 : use crate::state::{now_iso, AppState};
20 : use antares_jsonld::Context;
21 : use antares_model::{dt_key, TenantId};
22 : use antares_store::CurrentStateDriverExt;
23 : use antares_store::Kind;
24 : use antares_store::{TemporalEvent, TemporalOp};
25 : use serde_json::{json, Map, Value};
26 : use std::collections::HashMap;
27 : use std::sync::Arc;
28 :
29 : const DEFAULT_TRIGGERS: &[&str] = &["attributeCreated", "attributeUpdated"];
30 : /// Depth of the change→matcher queue, the same ring size the local bus uses.
31 : /// Held with the other published ceilings so `/q/health` reports it.
32 : use crate::bounds::{CHANGE_QUEUE, DELIVERY_WIDTH, DELIVERY_WIDTH_PER_TENANT};
33 : static CHANGES_DROPPED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
34 : static TASK_PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
35 : /// Hand a batch to the matcher queue: counted as pending on acceptance,
36 : /// counted as dropped when the queue is full. Not the durable outbox
37 : /// (`antares_sql::store::pg::outbox::enqueue`), which is a row inside the
38 : /// caller's transaction: this ring lives in the process and a full one
39 : /// drops, which is why the drop is counted.
40 6446 : fn queue_for_matching(
41 6446 : tx: &tokio::sync::mpsc::Sender<Vec<Change>>,
42 6446 : pending: &std::sync::atomic::AtomicUsize,
43 6446 : changes: Vec<Change>,
44 6446 : ) {
45 6446 : if tx.try_send(changes).is_ok() {
46 6442 : pending.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
47 6442 : } else {
48 4 : note_drop();
49 4 : }
50 6446 : }
51 :
52 : /// Changes dropped because the matcher queue was full, since process start.
53 4202 : pub(crate) fn changes_dropped() -> u64 {
54 4202 : CHANGES_DROPPED.load(std::sync::atomic::Ordering::Relaxed)
55 4202 : }
56 :
57 : /// Panics absorbed at the notification-task boundary, since process start.
58 90 : pub(crate) fn task_panics() -> u64 {
59 90 : TASK_PANICS.load(std::sync::atomic::Ordering::Relaxed)
60 90 : }
61 :
62 : /// Entity-level members that are NOT Attributes. Table 5.2.12-1 scopes
63 : /// watchedAttributes to "Properties or Relationships", so the entity's own
64 : /// system members (including 4.22 `expiresAt` and `deletedAt`) must never be
65 : /// diffed as attribute-level changes.
66 : #[derive(Clone, Copy, PartialEq, Debug)]
67 : enum ChangeClass {
68 : Created,
69 : Updated,
70 : Deleted,
71 : }
72 :
73 : /// Where the matcher reads candidates from: the indexed mirror (both bus
74 : /// modes wire one), with the store scan only as the never-wired
75 : /// fallback so a missing mirror degrades to correct-but-slow.
76 2373 : async fn subs_for(
77 2373 : st: &AppState,
78 2373 : tenant: &TenantId,
79 2373 : types: &[&str],
80 2373 : changed: &[&str],
81 2373 : ) -> Vec<Arc<Value>> {
82 2373 : match &st.sub_mirror {
83 2373 : Some(m) => m.candidates(tenant.as_str(), types, changed),
84 : // The scan is the fallback, so its own failure may not be read as
85 : // "this tenant has no subscriptions" either: that is the silence
86 : // the mirror seed refuses to install.
87 0 : None => match st.store.list(tenant, Kind::Subscription).await {
88 0 : Ok(subs) => subs.into_iter().map(Arc::new).collect(),
89 0 : Err(e) => {
90 0 : tracing::error!(
91 : "subscription scan failed for tenant {}: {e}; no candidate matched this change",
92 0 : tenant.as_str()
93 : );
94 0 : Vec::new()
95 : }
96 : },
97 : }
98 2373 : }
99 :
100 : /// Fill a mirror from the store, or say why it could not be filled.
101 : ///
102 : /// Every document of every tenant has to be in the mirror before it is
103 : /// installed. `CurrentStateDriver::subscription_tenants` states the rule for
104 : /// the data path — "A SUBSET is a silent outage: a tenant missing here never
105 : /// fires a periodic notification and never reaches the mirror" — and an
106 : /// error absorbed into an empty list is the same subset by another route.
107 : /// A connection failure at startup refuses it, so this is reachable rather
108 : /// than theoretical.
109 : ///
110 : /// Paged, not `list`: 5.5.6 licenses TooManyResults for "a query operation
111 : /// … producing so many results that can potentially exhaust client or
112 : /// server resources", and the seed is not one — it must see every
113 : /// document of every tenant or it is the silent outage above. Reading it
114 : /// through the ceiling `list` carries for client queries made one tenant's
115 : /// stored volume decide whether OTHER tenants are matched at all.
116 : ///
117 : /// One function for every mirror and both bus modes. `bus=local` seeds the
118 : /// subscription mirror here; `bus=nats` seeds a subscription mirror and a
119 : /// registration mirror, and re-seeds the registration one after a consumer
120 : /// gap. Those were a second copy of this walk that kept the ceiling and
121 : /// swallowed the error into an empty list, which is how a rule fixed in one
122 : /// place stayed broken in the others.
123 : ///
124 : /// The domain is `subscription_tenants`, whose contract covers every kind a
125 : /// mirror is built from. A tenant holding nothing of this kind costs one
126 : /// empty page.
127 576 : pub async fn seed_mirror(
128 576 : store: &dyn antares_store::CurrentStateDriver,
129 576 : mirror: &dyn Mirror,
130 576 : kind: Kind,
131 576 : ) -> Result<(), antares_model::NgsiError> {
132 576 : for tenant_str in store.subscription_tenants().await? {
133 : // The store's own enumeration, so the grammar is all that is left to
134 : // check: 5.5.15 permits a Subscription inside a Snapshot, and its
135 : // tenant is the synthetic one no client may name.
136 135 : let tenant = TenantId::new_internal(&tenant_str)?;
137 135 : let mut after: Option<String> = None;
138 : loop {
139 135 : let page = store
140 135 : .list_page(&tenant, kind, after.as_deref(), SEED_PAGE)
141 135 : .await?;
142 135 : let short = page.len() < SEED_PAGE;
143 135 : let before = after.clone();
144 135 : for doc in page {
145 56 : if let Some(id) = doc.get("id").and_then(Value::as_str) {
146 56 : let id = id.to_owned();
147 56 : after = Some(id.clone());
148 56 : mirror.apply(&tenant_str, &id, Some(doc));
149 56 : }
150 : }
151 : // A short page is the end. A cursor that did not move is also the
152 : // end, and it is the load-bearing half: only a document carrying
153 : // an `id` can advance it, so a full page without one would
154 : // otherwise re-read the same page forever. No write path stores
155 : // such a document — which is exactly why the loop may not depend
156 : // on that staying true.
157 135 : if short || after == before {
158 135 : break;
159 0 : }
160 : }
161 : }
162 576 : Ok(())
163 576 : }
164 :
165 : /// Documents per mirror-seed page: the peak transient allocation of the
166 : /// walk, paid once per tenant at startup.
167 : const SEED_PAGE: usize = 1_000;
168 :
169 : /// Install the subscription mirror, the store change hook that feeds the
170 : /// matcher, and the background tasks the pipeline needs. Called once at
171 : /// startup, by `crate::wire`.
172 560 : pub(crate) async fn wire_matcher(state: &mut AppState) {
173 : // bus=local: the same indexed mirror the nats wiring builds, fed
174 : // synchronously by the CUD hook — the matcher never rescans the store.
175 560 : let mirror = Arc::new(SubMirror::default());
176 560 : match seed_mirror(state.store.as_ref(), mirror.as_ref(), Kind::Subscription).await {
177 560 : Ok(()) => state.sub_mirror = Some(mirror.clone()),
178 : // Not installed, so `subs_for` takes the store scan it documents as
179 : // the missing-mirror fallback. Installing what the seed managed to
180 : // read would be the one outcome that is neither correct nor slow:
181 : // the matcher reads candidates from the mirror alone, so a
182 : // subscription absent from it never fires again in this process.
183 0 : Err(e) => tracing::error!(
184 : "subscription mirror seed failed ({e}); \
185 : matching falls back to a store scan per change"
186 : ),
187 : }
188 560 : let m = mirror.clone();
189 560 : state.sub_sync = Some(Arc::new(
190 306 : move |tenant: &TenantId, kind: Kind, id: &str, doc: Option<&Value>| match kind {
191 2 : Kind::CSourceSubscription => m.csub_written(),
192 304 : _ => m.apply(tenant.as_str(), id, doc.cloned()),
193 306 : },
194 : ));
195 :
196 : // The queue carries whole before+after payloads and is drained one
197 : // inline delivery at a time, so behind one slow subscriber an unbounded
198 : // queue grows until the process dies. Bounded instead: a full queue drops
199 : // the change and counts it.
200 560 : let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<Change>>(CHANGE_QUEUE);
201 560 : let flush_tx = tx.clone();
202 560 : let flush_pending = state.pending_changes.clone();
203 6248 : state.change_flush = Some(Arc::new(move |changes: Vec<Change>| {
204 6248 : queue_for_matching(&flush_tx, &flush_pending, changes)
205 6248 : }));
206 : // Temporal auto-recording runs SYNCHRONOUSLY on the hook (read-your-writes:
207 : // the ETSI suite queries history immediately after a write); the matcher
208 : // work is handed to the async task below. One choke point for every write.
209 560 : let st_rec = state.clone();
210 560 : let hook_pending = state.pending_changes.clone();
211 560 : state.store.set_change_hook(Arc::new(
212 : move |tenant: &TenantId,
213 : before: Option<Value>,
214 : after: Option<Value>|
215 6462 : -> antares_store::HookFuture<'_> {
216 6462 : let st_rec = st_rec.clone();
217 6462 : let tx = tx.clone();
218 6462 : let hook_pending = hook_pending.clone();
219 6462 : Box::pin(async move {
220 6462 : record_temporal_change(&st_rec, tenant, before.as_ref(), after.as_ref()).await;
221 : // inside a request the change rides the request's buffer and
222 : // reaches the matcher with the rest of that request's changes
223 198 : let Some(change) =
224 6462 : crate::history::buffer_change((tenant.as_str().to_owned(), before, after))
225 : else {
226 6264 : return;
227 : };
228 198 : queue_for_matching(&tx, &hook_pending, vec![change]);
229 6462 : })
230 6462 : },
231 : ));
232 560 : let st = state.clone();
233 560 : let pending = state.pending_changes.clone();
234 560 : crate::spawn_loop(async move {
235 2813 : while let Some(mut batch) = rx.recv().await {
236 2326 : let mut taken = 1;
237 : // everything already queued behind it rides the same pass
238 2339 : while batch.len() < CHANGE_BATCH {
239 2339 : match rx.try_recv() {
240 13 : Ok(c) => {
241 13 : batch.extend(c);
242 13 : taken += 1;
243 13 : }
244 2326 : Err(_) => break,
245 : }
246 : }
247 2326 : let st = st.clone();
248 2326 : guarded(async move { process_changes(&st, batch).await }).await;
249 2277 : pending.fetch_sub(taken, std::sync::atomic::Ordering::SeqCst);
250 : }
251 0 : });
252 560 : let st = state.clone();
253 560 : crate::spawn_loop(async move {
254 : loop {
255 : // Tokio's timer natively; the browser's own timer on wasm32
256 : // (tokio time never fires without a reactor there).
257 : #[cfg(not(target_arch = "wasm32"))]
258 3825 : tokio::time::sleep(std::time::Duration::from_millis(500)).await;
259 : #[cfg(target_arch = "wasm32")]
260 : gloo_timers::future::TimeoutFuture::new(500).await;
261 3288 : let st = st.clone();
262 3288 : guarded(async move { interval_tick(&st).await }).await;
263 : }
264 : });
265 560 : }
266 :
267 : /// Run one pipeline step on its own task so a panic inside it cannot end
268 : /// notification delivery for the whole process: the task boundary absorbs
269 : /// the panic, it is counted and logged, and the caller keeps consuming — a
270 : /// later matching change still notifies (5.8.6). The step is awaited, so
271 : /// delivery stays as serial as it was.
272 : #[cfg(not(target_arch = "wasm32"))]
273 5614 : async fn guarded<F>(fut: F)
274 5614 : where
275 5614 : F: std::future::Future<Output = ()>,
276 5614 : {
277 : use futures_util::FutureExt as _;
278 : // Caught here rather than on a spawned task: a task boundary would demand
279 : // Send + 'static of the step, and the interval step holds state that is
280 : // not Sync. Unwinding in place absorbs the panic just as well and keeps
281 : // the step running on this task, so delivery stays exactly as serial.
282 5614 : if std::panic::AssertUnwindSafe(fut)
283 5614 : .catch_unwind()
284 5614 : .await
285 5565 : .is_err()
286 0 : {
287 0 : note_panic();
288 5565 : }
289 5565 : }
290 :
291 : #[cfg(not(target_arch = "wasm32"))]
292 0 : fn note_panic() {
293 0 : TASK_PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
294 0 : metrics::counter!("antares_notification_task_panics_total").increment(1);
295 0 : tracing::error!("notification pipeline task panicked; this change is lost");
296 0 : }
297 :
298 : /// Wasm32 has no task boundary to catch with (single-threaded executor, and
299 : /// the browser profile aborts on panic); the step runs inline.
300 : #[cfg(target_arch = "wasm32")]
301 : async fn guarded<F>(fut: F)
302 : where
303 : F: std::future::Future<Output = ()>,
304 : {
305 : fut.await;
306 : }
307 :
308 : /// Whether two attribute instances are the same once the volatile members
309 : /// — the timestamps the broker itself stamps, and the instance identity —
310 : /// are set aside. Compared in place: this runs per changed attribute per
311 : /// matching subscription per event, and materializing two stripped copies of
312 : /// every JSON tree to throw them away one comparison later is the cost that
313 : /// multiplies by.
314 1471 : fn stable_eq(a: &Value, b: &Value) -> bool {
315 5079 : fn volatile(k: &str) -> bool {
316 5079 : matches!(k, "createdAt" | "modifiedAt" | "instanceId")
317 5079 : }
318 1471 : match (a, b) {
319 424 : (Value::Object(oa), Value::Object(ob)) => {
320 3586 : let count = |o: &Map<String, Value>| o.keys().filter(|k| !volatile(k)).count();
321 : // by lookup rather than by zipped iteration: which order a
322 : // serde_json Map yields is a cargo feature away from changing,
323 : // and features are additive
324 424 : count(oa) == count(ob)
325 396 : && oa
326 396 : .iter()
327 1493 : .filter(|(k, _)| !volatile(k))
328 749 : .all(|(k, va)| ob.get(k).is_some_and(|vb| stable_eq(va, vb)))
329 : }
330 294 : (Value::Array(aa), Value::Array(ab)) => {
331 294 : aa.len() == ab.len() && aa.iter().zip(ab).all(|(x, y)| stable_eq(x, y))
332 : }
333 753 : _ => a == b,
334 : }
335 1471 : }
336 :
337 16738 : fn attr_keys(doc: &Value) -> Vec<String> {
338 16738 : doc.as_object()
339 16738 : .map(|o| {
340 16738 : o.keys()
341 43152 : .filter(|k| !crate::repr::ENTITY_META.contains(&k.as_str()))
342 16738 : .cloned()
343 16738 : .collect()
344 16738 : })
345 16738 : .unwrap_or_default()
346 16738 : }
347 :
348 : /// Per-attribute change classification between two internal docs.
349 8369 : fn diff(before: Option<&Value>, after: Option<&Value>) -> Vec<(String, ChangeClass)> {
350 8369 : let empty = Value::Object(Map::new());
351 8369 : let b = before.unwrap_or(&empty);
352 8369 : let a = after.unwrap_or(&empty);
353 8369 : let mut keys = attr_keys(b);
354 8369 : for k in attr_keys(a) {
355 7296 : if !keys.contains(&k) {
356 7010 : keys.push(k);
357 7010 : }
358 : }
359 8369 : let mut out = Vec::new();
360 8369 : for k in keys {
361 7866 : let bv = b.get(&k);
362 7866 : let av = a.get(&k);
363 7866 : match (bv, av) {
364 7010 : (None, Some(_)) => out.push((k, ChangeClass::Created)),
365 570 : (Some(_), None) => out.push((k, ChangeClass::Deleted)),
366 286 : (Some(x), Some(y)) => {
367 : // instance-level deletion (by datasetId) counts as
368 : // attributeDeleted even when other instances survive (5.8.6)
369 286 : let bx: Vec<&Value> = x.as_array().map(|a| a.iter().collect()).unwrap_or_default();
370 286 : let by: Vec<&Value> = y.as_array().map(|a| a.iter().collect()).unwrap_or_default();
371 286 : let removed = bx
372 286 : .iter()
373 290 : .any(|bi| !by.iter().any(|ai| instance_ds(ai) == instance_ds(bi)));
374 286 : if removed {
375 8 : out.push((k.clone(), ChangeClass::Deleted));
376 8 : let survivors_changed = by.iter().any(|ai| {
377 12 : match bx.iter().find(|bi| instance_ds(bi) == instance_ds(ai)) {
378 8 : Some(bi) => !stable_eq(bi, ai),
379 0 : None => true,
380 : }
381 8 : });
382 8 : if survivors_changed {
383 0 : out.push((k, ChangeClass::Updated));
384 8 : }
385 278 : } else if !stable_eq(x, y) {
386 196 : out.push((k, ChangeClass::Updated));
387 196 : }
388 : }
389 0 : _ => {}
390 : }
391 : }
392 8369 : out
393 8369 : }
394 :
395 : /// Auto-record a current-state change into the temporal representation
396 : /// (5.6.11). Driven by the store's SYNCHRONOUS change hook, which fires inside
397 : /// every entity write (create, update, partial update, merge, replace, batch)
398 : /// for both the memory and postgres stores — so a new write path records
399 : /// without the handler having to remember. The per-handler `mirror_record`
400 : /// this replaced was the forgettable trap that left Partial Attribute Update
401 : /// (5.6.4) and Replace Attribute (5.6.19) silently unrecorded.
402 : ///
403 : /// Append-only, instance-precise: only the instances that are new or changed
404 : /// (by datasetId, ignoring volatile members) are appended, so a multi-instance
405 : /// attribute does not re-record unchanged datasets. Entity and attribute
406 : /// DELETIONS keep their dedicated typed-null mirrors (`mirror_delete_entity` /
407 : /// `mirror_delete_attr`), which the delete handlers still call — their deletion
408 : /// shape is not derivable from a plain append.
409 6462 : pub async fn record_temporal_change(
410 6462 : st: &AppState,
411 6462 : tenant: &TenantId,
412 6462 : before: Option<&Value>,
413 6462 : after: Option<&Value>,
414 6462 : ) {
415 6462 : if !st.record_locally() {
416 0 : return;
417 6462 : }
418 6462 : let Some(after) = after else {
419 474 : return; // entity deletion — handled by mirror_delete_entity
420 : };
421 5988 : let Some(id) = after.get("id").and_then(Value::as_str) else {
422 0 : return;
423 : };
424 5988 : let mut shell = Map::new();
425 29940 : for k in ["id", "type", "createdAt", "modifiedAt", "scope"] {
426 29940 : if let Some(v) = after.get(k) {
427 23866 : shell.insert(k.to_string(), v.clone());
428 23866 : }
429 : }
430 5988 : let shell = Value::Object(shell);
431 5988 : let event = |op, attr: &str, instance| TemporalEvent {
432 5654 : op,
433 5654 : tenant: tenant.clone(),
434 5654 : entity_id: id.to_owned(),
435 5654 : shell: shell.clone(),
436 5654 : attr: attr.to_owned(),
437 5654 : instance,
438 5654 : };
439 : // 4.5.6: the Scope of a Temporal Evolution is represented as a temporal
440 : // Property whose only sub-properties are the non-reified createdAt,
441 : // modifiedAt, deletedAt and observedAt; when it "is updated as the result
442 : // of a change from the Core API, the observedAt sub-Property should be
443 : // set as a copy of the modifiedAt sub-Property".
444 5988 : if before.is_some_and(|b| b.get("scope") != after.get("scope")) {
445 10 : if let Some(scope) = after.get("scope") {
446 2 : let ts = after
447 2 : .get("modifiedAt")
448 2 : .and_then(Value::as_str)
449 2 : .map(String::from)
450 2 : .unwrap_or_else(now_iso);
451 2 : let inst = json!({
452 2 : "type": "Property",
453 2 : "value": scope.clone(),
454 2 : "instanceId": format!("urn:ngsi-ld:Instance:{}", uuid::Uuid::new_v4()),
455 2 : "createdAt": ts, "modifiedAt": ts, "observedAt": ts,
456 : });
457 2 : crate::history::push(st, event(TemporalOp::ScopeChanged, "scope", inst)).await;
458 8 : }
459 5978 : }
460 5988 : for (k, class) in diff(before, Some(after)) {
461 5668 : let op = match class {
462 5546 : ChangeClass::Created => TemporalOp::AttrCreated,
463 98 : ChangeClass::Updated => TemporalOp::AttrModified,
464 24 : ChangeClass::Deleted => continue, // handled by mirror_delete_attr
465 : };
466 5644 : let Some(av) = after.get(&k) else { continue };
467 : // gate 1, value-change: an unchanged instance produces no event
468 5652 : for mut inst in changed_instances(before.and_then(|b| b.get(&k)), av) {
469 5652 : if let Some(o) = inst.as_object_mut() {
470 5652 : let iid = instance_id(id, &k, &*o);
471 5652 : o.entry("instanceId".to_owned())
472 5652 : .or_insert_with(|| Value::String(iid));
473 0 : }
474 5652 : crate::history::push(st, event(op, &k, inst)).await;
475 : }
476 : }
477 6462 : }
478 :
479 : /// 4.5.7: an instance is the Attribute "at a particular point in time",
480 : /// recorded as its observedAt. The id of an observed instance is therefore
481 : /// derived from (entity, attribute, datasetId, observedAt), so a re-send for
482 : /// the same instant lands on the same row — the temporal store's upsert key —
483 : /// and corrects it instead of appending a duplicate. Without observedAt there
484 : /// is no instant to key on: a fresh random id, append-only.
485 : ///
486 : /// The instant is keyed through `dt_key`, not through the stamp as written.
487 : /// 4.6.3 leaves the seconds fraction optional and accepts a comma separator
488 : /// in requests, and the broker stores a DateTime exactly as the client wrote
489 : /// it, so one instant arrives under several spellings; keying the raw text
490 : /// gave each spelling its own instance and left the correction's target in
491 : /// place beside it — the failure 4.5.7 calls severe "in the case of
492 : /// modification or deletion requests for legal reasons".
493 5728 : fn instance_id(entity: &str, attr: &str, inst: &serde_json::Map<String, Value>) -> String {
494 5728 : let u = match inst.get("observedAt").and_then(Value::as_str) {
495 312 : Some(at) => {
496 312 : let ds = inst
497 312 : .get("datasetId")
498 312 : .and_then(Value::as_str)
499 312 : .unwrap_or("@none");
500 312 : uuid::Uuid::new_v5(
501 312 : &uuid::Uuid::NAMESPACE_URL,
502 312 : format!("{entity}\n{attr}\n{ds}\n{}", dt_key(at)).as_bytes(),
503 : )
504 : }
505 5416 : None => uuid::Uuid::new_v4(),
506 : };
507 5728 : format!("urn:ngsi-ld:Instance:{u}")
508 5728 : }
509 :
510 : /// The instances in `after` that are new or changed vs `before` — matched by
511 : /// datasetId, compared via `stable_eq` (volatile members ignored). A newly
512 : /// created attribute (`before` None) contributes every instance.
513 5644 : fn changed_instances(before: Option<&Value>, after: &Value) -> Vec<Value> {
514 5644 : let before_arr: Vec<&Value> = before
515 5644 : .and_then(Value::as_array)
516 5644 : .map(|a| a.iter().collect())
517 5644 : .unwrap_or_default();
518 5644 : after
519 5644 : .as_array()
520 5644 : .cloned()
521 5644 : .unwrap_or_default()
522 5644 : .into_iter()
523 5656 : .filter(|ai| {
524 5656 : match before_arr
525 5656 : .iter()
526 5656 : .find(|bi| instance_ds(bi) == instance_ds(ai))
527 : {
528 5558 : None => true,
529 98 : Some(bi) => !stable_eq(bi, ai),
530 : }
531 5656 : })
532 5644 : .collect()
533 5644 : }
534 :
535 : /// One string-valued member of a stored Subscription, absent or of another
536 : /// JSON type reading the same as absent.
537 1546 : fn sub_str<'a>(sub: &'a Value, key: &str) -> Option<&'a str> {
538 1546 : sub.get(key).and_then(Value::as_str)
539 1546 : }
540 :
541 : /// 4.9 EXAMPLE 13/14: linked-entity q terms (`attr{path}`) resolve through
542 : /// the local store, same tenant. The evaluator is synchronous and the store
543 : /// is not, so `eval` runs against a cache: a URI the cache misses is
544 : /// recorded, reads as absent for that pass, and is fetched before the next
545 : /// one. The first pass that misses nothing is the answer. Every miss of a
546 : /// pass is fetched together, so the pass count is the depth of the link
547 : /// chain rather than the number of entities it touches.
548 862 : pub(crate) async fn linked_eval<F>(st: &AppState, tenant: &TenantId, mut eval: F) -> bool
549 862 : where
550 862 : F: for<'a> FnMut(antares_ql::eval::EntityLookup<'a>) -> bool,
551 862 : {
552 862 : let mut cache: HashMap<String, Option<Value>> = HashMap::new();
553 : // ponytail: the evaluator's own lookup budget bounds how many distinct
554 : // URIs one expression can name, so it bounds the passes too; a chain
555 : // deeper than that reads as unresolved, which is what exhausting the
556 : // budget inside the evaluator already does.
557 862 : for _ in 0..antares_ql::eval::MAX_Q_LINK_LOOKUPS {
558 874 : let missed = std::cell::RefCell::new(Vec::new());
559 874 : let out = eval(&|uri: &str| match cache.get(uri) {
560 12 : Some(doc) => doc.clone(),
561 : None => {
562 12 : missed.borrow_mut().push(uri.to_owned());
563 12 : None
564 : }
565 24 : });
566 874 : let missed = missed.into_inner();
567 874 : if missed.is_empty() {
568 862 : return out;
569 12 : }
570 12 : for uri in missed {
571 12 : let doc = st
572 12 : .store
573 12 : .get(tenant, Kind::Entity, &uri)
574 12 : .await
575 12 : .ok()
576 12 : .flatten();
577 12 : cache.insert(uri, doc);
578 : }
579 : }
580 0 : eval(&|uri: &str| cache.get(uri).cloned().flatten())
581 862 : }
582 :
583 : pub(crate) use antares_matcher::{
584 : conditions_match, geo_params, is_active, selector_match, throttled,
585 : };
586 :
587 : /// The @context governing a subscription's notifications (5.8.6): the
588 : /// jsonldContext member if set, else the @context of the creating request.
589 1155 : pub(crate) async fn sub_context(st: &AppState, tenant: &TenantId, sub: &Value) -> Arc<Context> {
590 : // Borrowed: the resolver only reads it, and this is on the per-candidate
591 : // path, where cloning an inline @context copied the whole document.
592 1155 : let source = sub.get("jsonldContext").or_else(|| sub.get("__context"));
593 671 : match source {
594 : // 5.5.10: the Subscription belongs to one Tenant, so the @context it
595 : // names resolves within that Tenant — a Hosted @context another Tenant
596 : // stored (5.13.1) is not in scope here and falls back to the core
597 : // context rather than compacting this Notification against it.
598 671 : Some(v) if !v.is_null() => st
599 671 : .loader
600 671 : .resolve_quiet_for(tenant, v)
601 671 : .await
602 671 : .unwrap_or_else(|_| st.loader.core()),
603 484 : _ => st.loader.core(),
604 : }
605 1155 : }
606 :
607 : /// A resolved `@context` per Tenant, then per the `@context` URL the
608 : /// Subscription names. Keyed by Tenant first: 5.5.10 scopes a resolution to
609 : /// one Tenant, so one Tenant's resolved context is never reachable under
610 : /// another's identifier even where both name the same URL.
611 : type CtxMemo = HashMap<String, HashMap<String, Arc<Context>>>;
612 :
613 : /// `sub_context` over one drain's memo. Resolving is a cache lookup per call,
614 : /// and this runs once per candidate subscription per change: a drain of
615 : /// `CHANGE_BATCH` changes resolves the same handful of `@context` URLs
616 : /// thousands of times over. The memo lives for the one drain that owns it, so
617 : /// an `@context` a request replaces or deletes meanwhile is picked up by the
618 : /// next drain. A Subscription naming an inline `@context` rather than a URL
619 : /// resolves as before — it has no cheap identity to key on.
620 430 : async fn sub_context_memo(
621 430 : st: &AppState,
622 430 : tenant: &TenantId,
623 430 : tenant_str: &str,
624 430 : sub: &Value,
625 430 : memo: &mut CtxMemo,
626 430 : ) -> Arc<Context> {
627 430 : let url = sub
628 430 : .get("jsonldContext")
629 430 : .or_else(|| sub.get("__context"))
630 430 : .and_then(Value::as_str);
631 430 : let Some(url) = url else {
632 166 : return sub_context(st, tenant, sub).await;
633 : };
634 264 : if let Some(hit) = memo.get(tenant_str).and_then(|by_url| by_url.get(url)) {
635 16 : return Arc::clone(hit);
636 248 : }
637 248 : let ctx = sub_context(st, tenant, sub).await;
638 248 : memo.entry(tenant_str.to_owned())
639 248 : .or_default()
640 248 : .insert(url.to_owned(), Arc::clone(&ctx));
641 248 : ctx
642 430 : }
643 :
644 : /// Per-type NGSI-LD-null member and its showChanges previous-member (5.8.6).
645 76 : fn null_members(atype: &str) -> (&'static str, Value, &'static str) {
646 76 : let null = Value::String("urn:ngsi-ld:null".into());
647 76 : match atype {
648 76 : "Relationship" => ("object", null, "previousObject"),
649 68 : "LanguageProperty" => (
650 24 : "languageMap",
651 24 : json!({"@none": "urn:ngsi-ld:null"}),
652 24 : "previousLanguageMap",
653 24 : ),
654 44 : "JsonProperty" => ("json", null, "previousJson"),
655 40 : "VocabProperty" => ("vocab", null, "previousVocab"),
656 36 : _ => ("value", null, "previousValue"),
657 : }
658 76 : }
659 :
660 0 : fn current_member(atype: &str) -> &'static str {
661 0 : null_members(atype).0
662 0 : }
663 :
664 : /// The deletion tombstone for one former attribute instance (5.8.6 payload
665 : /// forms: typed null + optional datasetId / sysAttrs stamps / previous value).
666 76 : fn tombstone(before_inst: &Value, sys: bool, show: bool, now: &str) -> Value {
667 76 : let atype = before_inst
668 76 : .get("type")
669 76 : .and_then(Value::as_str)
670 76 : .unwrap_or("Property");
671 76 : let (member, null_val, prev_member) = null_members(atype);
672 76 : let mut m = Map::new();
673 76 : m.insert("type".into(), Value::String(atype.to_owned()));
674 76 : m.insert(member.into(), null_val);
675 76 : if let Some(ds) = before_inst.get("datasetId") {
676 8 : m.insert("datasetId".into(), ds.clone());
677 68 : }
678 76 : if sys {
679 24 : for k in ["createdAt", "modifiedAt"] {
680 24 : if let Some(v) = before_inst.get(k) {
681 4 : m.insert(k.into(), v.clone());
682 20 : }
683 : }
684 12 : m.insert("deletedAt".into(), Value::String(now.to_owned()));
685 64 : }
686 76 : if show {
687 12 : if let Some(prev) = before_inst.get(member) {
688 12 : m.insert(prev_member.into(), prev.clone());
689 12 : }
690 64 : }
691 76 : Value::Object(m)
692 76 : }
693 :
694 932 : fn instance_ds(inst: &Value) -> Option<&str> {
695 932 : inst.get("datasetId").and_then(Value::as_str)
696 932 : }
697 :
698 : /// Instances of `before[attr]` that no longer exist in `after[attr]`
699 : /// (matched by datasetId — instance-level deletions count, 046_22_06).
700 76 : fn deleted_instances<'a>(before: &'a Value, after: Option<&Value>, attr: &str) -> Vec<&'a Value> {
701 76 : let b: Vec<&Value> = before
702 76 : .get(attr)
703 76 : .and_then(Value::as_array)
704 76 : .map(|a| a.iter().collect())
705 76 : .unwrap_or_default();
706 76 : let a: Vec<&Value> = after
707 76 : .and_then(|d| d.get(attr))
708 76 : .and_then(Value::as_array)
709 76 : .map(|x| x.iter().collect())
710 76 : .unwrap_or_default();
711 76 : b.into_iter()
712 96 : .filter(|bi| !a.iter().any(|ai| instance_ds(ai) == instance_ds(bi)))
713 76 : .collect()
714 76 : }
715 :
716 : pub(crate) struct NotifShape {
717 : pub(crate) repr: crate::repr::Repr,
718 : pub(crate) show_changes: bool,
719 : pub(crate) join: Option<(String, usize)>,
720 : }
721 :
722 778 : pub(crate) fn notif_shape(sub: &Value, ctx: &Context) -> NotifShape {
723 778 : let n = sub.get("notification").and_then(Value::as_object);
724 778 : let format = n
725 778 : .and_then(|n| n.get("format"))
726 778 : .and_then(Value::as_str)
727 778 : .unwrap_or("normalized");
728 778 : let mut repr = crate::repr::Repr {
729 778 : sys_attrs: n
730 778 : .and_then(|n| n.get("sysAttrs"))
731 778 : .and_then(Value::as_bool)
732 778 : .unwrap_or(false),
733 778 : key_values: matches!(format, "keyValues" | "simplified"),
734 778 : concise: format == "concise",
735 778 : ..Default::default()
736 : };
737 2334 : let names = |key: &str| -> Option<Vec<String>> {
738 2334 : n.and_then(|n| n.get(key))
739 2334 : .and_then(Value::as_array)
740 2334 : .map(|a| {
741 12 : a.iter()
742 12 : .filter_map(Value::as_str)
743 12 : .map(str::to_owned)
744 12 : .collect()
745 12 : })
746 2334 : };
747 778 : if let Some(attrs) = names("attributes") {
748 0 : repr.attrs = Some(attrs.iter().map(|a| ctx.expand_key(a)).collect());
749 778 : }
750 : // 4.21 NGSI-LD Attribute Projection Language: pick/omit values are
751 : // projection language strings, which Table 5.2.14.1-1 requires for the
752 : // notification members too ("a valid attribute projection language string
753 : // as per clause 4.21"). Each term may carry a LinkedEntityTerm
754 : // (`ProjectionTerm = AttrName *1(LinkedEntityTerm)`), which is what
755 : // constrains an Attribute inside a Linked Entity retrieved by join. Building
756 : // the nodes by hand here degraded `refDevice{type}` to a literal Attribute
757 : // name matching nothing, so the term was dropped instead of applied.
758 : // A term that fails to parse is dropped rather than kept flat: for pick that
759 : // withholds the Attribute, which is the safe direction.
760 778 : let nodes = |list: Vec<String>| -> Vec<crate::repr::ProjNode> {
761 12 : list.iter()
762 34 : .filter_map(|term| crate::repr::parse_projection(term, ctx).ok())
763 12 : .flatten()
764 12 : .collect()
765 12 : };
766 778 : if let Some(pick) = names("pick") {
767 10 : repr.pick = Some(nodes(pick));
768 768 : }
769 778 : if let Some(omit) = names("omit") {
770 2 : repr.omit = Some(nodes(omit));
771 776 : }
772 778 : if let Some(ds) = sub.get("datasetId").and_then(Value::as_array) {
773 0 : repr.dataset_id = Some(
774 0 : ds.iter()
775 0 : .filter_map(Value::as_str)
776 0 : .map(str::to_owned)
777 0 : .collect(),
778 0 : );
779 778 : }
780 778 : let join = n
781 778 : .and_then(|n| n.get("join"))
782 778 : .and_then(Value::as_str)
783 778 : .filter(|j| *j == "inline" || *j == "flat")
784 778 : .map(|j| {
785 : // the stored member is bounded at creation, but a Subscription
786 : // written before that bound existed is still on disk, so the
787 : // traversal takes the ceiling from here too
788 12 : let level = (n
789 12 : .and_then(|n| n.get("joinLevel"))
790 12 : .and_then(Value::as_u64)
791 12 : .unwrap_or(1) as usize)
792 12 : .min(crate::bounds::MAX_JOIN_LEVEL);
793 12 : (j.to_owned(), level)
794 12 : });
795 : NotifShape {
796 778 : repr,
797 778 : show_changes: n
798 778 : .and_then(|n| n.get("showChanges"))
799 778 : .and_then(Value::as_bool)
800 778 : .unwrap_or(false),
801 778 : join,
802 : }
803 778 : }
804 :
805 : /// Build the notification `data` array for one change and one subscription.
806 : #[allow(clippy::too_many_arguments)] // one param per 5.8.6 payload input
807 762 : async fn build_data(
808 762 : st: &AppState,
809 762 : tenant: &TenantId,
810 762 : sub: &Value,
811 762 : ctx: &Context,
812 762 : before: Option<&Value>,
813 762 : after: Option<&Value>,
814 762 : relevant_deleted_attrs: &[String],
815 762 : entity_deleted_fired: bool,
816 762 : now: &str,
817 762 : ) -> Vec<Value> {
818 762 : let shape = notif_shape(sub, ctx);
819 762 : let sys = shape.repr.sys_attrs;
820 762 : let show = shape.show_changes;
821 762 : let internal = match after {
822 762 : Some(a) => {
823 762 : let mut doc = a.clone();
824 762 : if show {
825 : // previous* on changed instances (046_31..33). An entity off
826 : // the change feed is an object; one that is not carries
827 : // nothing to decorate and travels on unchanged.
828 8 : if let (Some(b), Some(obj)) = (before, doc.as_object_mut()) {
829 16 : for (k, v) in obj.iter_mut() {
830 16 : if crate::repr::ENTITY_META.contains(&k.as_str()) {
831 16 : continue;
832 0 : }
833 0 : let Some(arr) = v.as_array_mut() else {
834 0 : continue;
835 : };
836 0 : for inst in arr {
837 0 : let Some(bi) = b.get(k).and_then(Value::as_array).and_then(|ba| {
838 0 : ba.iter().find(|x| instance_ds(x) == instance_ds(inst))
839 0 : }) else {
840 0 : continue;
841 : };
842 0 : let atype = inst
843 0 : .get("type")
844 0 : .and_then(Value::as_str)
845 0 : .unwrap_or("Property");
846 0 : let member = current_member(atype);
847 0 : let (_, _, prev_member) = null_members(atype);
848 0 : if bi.get(member) != inst.get(member) {
849 0 : if let (Some(pv), Some(io)) =
850 0 : (bi.get(member).cloned(), inst.as_object_mut())
851 0 : {
852 0 : io.insert(prev_member.into(), pv);
853 0 : }
854 0 : }
855 : }
856 : }
857 0 : }
858 754 : }
859 : // deletion tombstones appended beside surviving instances
860 762 : if let Some(b) = before {
861 76 : for attr in relevant_deleted_attrs {
862 76 : let gone = deleted_instances(b, Some(&doc), attr);
863 76 : if gone.is_empty() {
864 0 : continue;
865 76 : }
866 76 : let attr_absent = doc.get(attr).is_none();
867 76 : let tss: Vec<Value> = if attr_absent {
868 : // whole attribute deleted at once: ONE tombstone,
869 : // no datasetId (046_22_08)
870 68 : let base = gone
871 68 : .iter()
872 68 : .find(|i| instance_ds(i).is_none())
873 68 : .unwrap_or(&gone[0]);
874 68 : let mut ts = tombstone(base, sys, show, now);
875 68 : if let Some(o) = ts.as_object_mut() {
876 68 : o.remove("datasetId");
877 68 : }
878 68 : vec![ts]
879 : } else {
880 8 : gone.iter()
881 8 : .map(|di| tombstone(di, sys, show, now))
882 8 : .collect()
883 : };
884 76 : let Some(target) = doc.as_object_mut() else {
885 0 : continue;
886 : };
887 76 : if let Some(arr) = target
888 76 : .entry(attr.clone())
889 76 : .or_insert_with(|| Value::Array(vec![]))
890 76 : .as_array_mut()
891 76 : {
892 76 : arr.extend(tss);
893 76 : }
894 : }
895 700 : }
896 762 : doc
897 : }
898 : None => {
899 : // entity deleted: tombstone entity (046_21) + per-trigger attrs.
900 : // The caller returns early unless one of before/after is there,
901 : // so this arm has a before; without one there is nothing to
902 : // describe and nothing to notify about.
903 0 : let Some(b) = before else {
904 0 : return Vec::new();
905 : };
906 0 : let mut m = Map::new();
907 0 : for k in ["id", "type"] {
908 0 : if let Some(v) = b.get(k) {
909 0 : m.insert(k.into(), v.clone());
910 0 : }
911 : }
912 0 : if sys {
913 0 : for k in ["createdAt", "modifiedAt"] {
914 0 : if let Some(v) = b.get(k) {
915 0 : m.insert(k.into(), v.clone());
916 0 : }
917 : }
918 0 : }
919 0 : m.insert("deletedAt".into(), Value::String(now.to_owned()));
920 0 : let attrs: Vec<String> = if entity_deleted_fired && show {
921 0 : attr_keys(b) // showChanges: every attribute, tombstoned (046_37)
922 : } else {
923 0 : relevant_deleted_attrs.to_vec()
924 : };
925 0 : for attr in attrs {
926 0 : let insts: Vec<Value> = b
927 0 : .get(&attr)
928 0 : .and_then(Value::as_array)
929 0 : .map(|a| a.iter().map(|i| tombstone(i, sys, show, now)).collect())
930 0 : .unwrap_or_default();
931 0 : if !insts.is_empty() {
932 0 : m.insert(attr, Value::Array(insts));
933 0 : }
934 : }
935 0 : Value::Object(m)
936 : }
937 : };
938 762 : let shaped = crate::repr::apply(&internal, &shape.repr);
939 762 : let mut main = crate::repr::compact_for(&shape.repr, &shaped, ctx);
940 762 : let mut data = Vec::new();
941 4 : match &shape.join {
942 12 : Some((mode, level)) if mode == "inline" => {
943 8 : crate::repr::inline_join(st, tenant, ctx, &shape.repr, &mut main, *level).await;
944 8 : data.push(main);
945 : }
946 4 : Some((mode, level)) if mode == "flat" => {
947 4 : let main_id = internal.get("id").and_then(Value::as_str).unwrap_or("");
948 4 : let mut linked = std::collections::BTreeMap::new();
949 4 : crate::repr::collect_flat(st, tenant, &shape.repr, &internal, *level, &mut linked)
950 4 : .await;
951 4 : data.push(main);
952 4 : for (id, (ldoc, lrepr)) in linked {
953 4 : if id != main_id {
954 4 : data.push(crate::repr::compact_for(
955 4 : &lrepr,
956 4 : &crate::repr::apply(&ldoc, &lrepr),
957 4 : ctx,
958 4 : ));
959 4 : }
960 : }
961 : }
962 750 : _ => data.push(main),
963 : }
964 762 : data
965 762 : }
966 :
967 : /// The notification triggers of one subscription, in the form the matcher
968 : /// compares against (Table 5.2.12-1): absent means the default combination
969 : /// `"attributeCreated"` + `"attributeUpdated"`, and `"entityUpdated"` "is
970 : /// equivalent to the combination `"attributeCreated"`, `"attributeUpdated"`
971 : /// and `"attributeDeleted"`" — so it is expanded here, at the single point
972 : /// the list is read, rather than at each comparison.
973 446 : fn triggers_of(sub: &Value) -> Vec<&str> {
974 : // Borrowed from the Subscription, not copied out of it: this runs once
975 : // per candidate per change, and the default arm used to allocate a
976 : // String for each of DEFAULT_TRIGGERS, which are already 'static.
977 446 : let mut triggers: Vec<&str> = sub
978 446 : .get("notificationTrigger")
979 446 : .and_then(Value::as_array)
980 446 : .map(|a| a.iter().filter_map(Value::as_str).collect())
981 446 : .unwrap_or_else(|| DEFAULT_TRIGGERS.to_vec());
982 446 : if triggers.contains(&"entityUpdated") {
983 36 : for t in DEFAULT_TRIGGERS.iter().copied().chain(["attributeDeleted"]) {
984 36 : if !triggers.contains(&t) {
985 24 : triggers.push(t);
986 24 : }
987 : }
988 434 : }
989 446 : triggers
990 446 : }
991 :
992 : /// Changes one drain of the queue folds into one delivery pass — a batch
993 : /// request's N writes arrive as N events back to back and leave as ONE
994 : /// notification per matching subscription. Bounded so a flood cannot hold
995 : /// the first notification back indefinitely.
996 : const CHANGE_BATCH: usize = 256;
997 :
998 : /// One matched (subscription, entity) pair before delivery.
999 : struct Matched {
1000 : tenant: TenantId,
1001 : /// Shared with the mirror that holds it: a change is evaluated against
1002 : /// every candidate subscription, so owning a copy here made the cost of
1003 : /// a change scale with the size of those documents.
1004 : sub: Arc<Value>,
1005 : ctx: Arc<Context>,
1006 : data: Vec<Value>,
1007 : }
1008 :
1009 0 : pub async fn process_change(
1010 0 : st: &AppState,
1011 0 : tenant_str: &str,
1012 0 : before: Option<Value>,
1013 0 : after: Option<Value>,
1014 0 : ) {
1015 0 : process_changes(st, vec![(tenant_str.to_owned(), before, after)]).await;
1016 0 : }
1017 :
1018 : /// 5.8.6: "the Notification ... data ... shall contain the Entities that
1019 : /// match" — every change of one drain that matches the same subscription
1020 : /// travels in one notification, so a batch of N entities is one POST with N
1021 : /// data entries (and timesSent moves by one), never N POSTs.
1022 2330 : pub(crate) async fn process_changes(st: &AppState, changes: Vec<Change>) {
1023 2330 : let mut groups: Vec<Matched> = Vec::new();
1024 : // (tenant, subscription id) → its group. A scan for the group would be
1025 : // linear in the subscriptions already matched, and a drain of
1026 : // CHANGE_BATCH changes over S matching subscriptions walks it S times per
1027 : // change: quadratic in the one dimension this broker is built to grow
1028 : // (100 000 subscriptions), on the notification hot path.
1029 2330 : let mut index: std::collections::HashMap<(String, String), usize> =
1030 2330 : std::collections::HashMap::new();
1031 2330 : let mut ctx_memo = CtxMemo::new();
1032 2373 : for (tenant_str, before, after) in changes {
1033 2373 : for m in matches_for(st, &tenant_str, before, after, &mut ctx_memo).await {
1034 394 : let key = (
1035 394 : m.tenant.as_str().to_owned(),
1036 394 : m.sub
1037 394 : .get("id")
1038 394 : .and_then(Value::as_str)
1039 394 : .unwrap_or_default()
1040 394 : .to_owned(),
1041 394 : );
1042 394 : match index.get(&key) {
1043 136 : Some(&i) => groups[i].data.extend(m.data),
1044 258 : None => {
1045 258 : index.insert(key, groups.len());
1046 258 : groups.push(m);
1047 258 : }
1048 : }
1049 : }
1050 : }
1051 : // Groups are distinct subscriptions, so they leave concurrently; a
1052 : // subscription's changes in this drain are already one group, and the
1053 : // next drain starts only after this one, so per-subscription order holds.
1054 : // Each group is its own task: a group's bookkeeping reads and writes
1055 : // await the store, and one task's `for_each_concurrent` would still put
1056 : // every group's round-trips on a single task's poll budget.
1057 : #[cfg(not(target_arch = "wasm32"))]
1058 : {
1059 2330 : let mut set = tokio::task::JoinSet::new();
1060 2330 : for g in groups {
1061 258 : let st = st.clone();
1062 258 : set.spawn(async move {
1063 258 : let _permits = delivery_permits(&g.tenant).await;
1064 258 : deliver(&st, &g.tenant, &g.sub, g.data, &g.ctx).await;
1065 217 : });
1066 : }
1067 2539 : while let Some(joined) = set.join_next().await {
1068 209 : if joined.is_err() {
1069 0 : note_panic();
1070 209 : }
1071 : }
1072 : }
1073 : #[cfg(target_arch = "wasm32")]
1074 : {
1075 : use futures_util::StreamExt;
1076 : futures_util::stream::iter(groups)
1077 : .for_each_concurrent(*DELIVERY_WIDTH, |g| async move {
1078 : deliver(st, &g.tenant, &g.sub, g.data, &g.ctx).await;
1079 : })
1080 : .await;
1081 : }
1082 2281 : }
1083 :
1084 : /// A change the full queue refused: counted, and said once per thousand so
1085 : /// a 40 % delivery gap is visible in the log and not only on the counter.
1086 4 : fn note_drop() {
1087 4 : let n = CHANGES_DROPPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1088 4 : metrics::counter!("antares_notification_changes_dropped_total").increment(1);
1089 4 : if n == 1 || n.is_multiple_of(1000) {
1090 4 : tracing::warn!("notification change queue full: {n} changes dropped so far (delivery slower than the write rate)");
1091 0 : }
1092 4 : }
1093 :
1094 : #[cfg(not(target_arch = "wasm32"))]
1095 : static DELIVERY_SLOTS: std::sync::LazyLock<tokio::sync::Semaphore> =
1096 46 : std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(*DELIVERY_WIDTH));
1097 :
1098 : #[cfg(not(target_arch = "wasm32"))]
1099 : type TenantSlots = std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>;
1100 : #[cfg(not(target_arch = "wasm32"))]
1101 : static TENANT_SLOTS: std::sync::LazyLock<TenantSlots> =
1102 : std::sync::LazyLock::new(TenantSlots::default);
1103 :
1104 : /// The tenant's share, created on first use. An entry lives only while a
1105 : /// delivery is holding it: a lookup that has to create one first drops every
1106 : /// entry nothing else still references, so this map is bounded by the
1107 : /// delivery width and not by how many of the broker's 10 000 tenants have
1108 : /// ever had a subscription fire.
1109 : #[cfg(not(target_arch = "wasm32"))]
1110 570 : fn tenant_slots(tenant: &TenantId) -> Arc<tokio::sync::Semaphore> {
1111 : // A poisoned map is a panic in some past delivery, not a reason to stop
1112 : // delivering: what it holds is rebuildable and bounded either way.
1113 570 : let mut map = TENANT_SLOTS.lock().unwrap_or_else(|p| p.into_inner());
1114 570 : if let Some(s) = map.get(tenant.as_str()) {
1115 514 : return Arc::clone(s);
1116 56 : }
1117 56 : map.retain(|_, s| Arc::strong_count(s) > 1);
1118 56 : let slots = Arc::new(tokio::sync::Semaphore::new(*DELIVERY_WIDTH_PER_TENANT));
1119 56 : map.insert(tenant.as_str().to_owned(), Arc::clone(&slots));
1120 56 : slots
1121 570 : }
1122 :
1123 : /// Both permits one delivery holds until it settles. The tenant's share is
1124 : /// taken first, so a tenant already at its share waits there instead of
1125 : /// waiting inside one of the broker's slots.
1126 : #[cfg(not(target_arch = "wasm32"))]
1127 570 : async fn delivery_permits(tenant: &TenantId) -> impl Sized {
1128 570 : let share = tenant_slots(tenant).acquire_owned().await;
1129 314 : (share, DELIVERY_SLOTS.acquire().await)
1130 314 : }
1131 :
1132 2373 : async fn matches_for(
1133 2373 : st: &AppState,
1134 2373 : tenant_str: &str,
1135 2373 : before: Option<Value>,
1136 2373 : after: Option<Value>,
1137 2373 : ctx_memo: &mut CtxMemo,
1138 2373 : ) -> Vec<Matched> {
1139 2373 : let mut out = Vec::new();
1140 : // The tenant of the write that fired the change hook — this broker's own
1141 : // value, and a write inside a Snapshot (5.5.15) carries the synthetic one.
1142 2373 : let Ok(tenant) = TenantId::new_internal(tenant_str) else {
1143 0 : return out;
1144 : };
1145 2373 : let changes = diff(before.as_ref(), after.as_ref());
1146 2373 : let entity_trigger = match (&before, &after) {
1147 1696 : (None, Some(_)) => "entityCreated",
1148 472 : (Some(_), None) => "entityDeleted",
1149 205 : _ => "entityUpdated",
1150 : };
1151 2373 : let eval_doc = after.as_ref().or(before.as_ref());
1152 2373 : let Some(eval_doc) = eval_doc else { return out };
1153 : // Candidate lookup by the entity's types and the changed attribute
1154 : // IRIs — no linear scan over all subscriptions.
1155 2373 : let types: Vec<&str> = eval_doc
1156 2373 : .get("type")
1157 2373 : .and_then(Value::as_array)
1158 2373 : .map(|a| a.iter().filter_map(Value::as_str).collect())
1159 2373 : .unwrap_or_default();
1160 2373 : let changed_keys: Vec<&str> = changes.iter().map(|(k, _)| k.as_str()).collect();
1161 2373 : let subs = subs_for(st, &tenant, &types, &changed_keys).await;
1162 2373 : for sub in subs {
1163 446 : if !is_active(&sub) || sub.get("timeInterval").is_some() {
1164 20 : continue;
1165 426 : }
1166 426 : let triggers = triggers_of(&sub);
1167 : // which attribute-level changes this sub cares about
1168 426 : let watched: Option<Vec<&str>> = sub
1169 426 : .get("watchedAttributes")
1170 426 : .and_then(Value::as_array)
1171 426 : .map(|a| a.iter().filter_map(Value::as_str).collect());
1172 426 : let relevant: Vec<&(String, ChangeClass)> = changes
1173 426 : .iter()
1174 450 : .filter(|(k, _)| watched.as_ref().is_none_or(|w| w.contains(&k.as_str())))
1175 426 : .collect();
1176 426 : let attr_trigger_fired = relevant.iter().any(|(_, c)| {
1177 422 : let t = match c {
1178 410 : ChangeClass::Created => "attributeCreated",
1179 8 : ChangeClass::Updated => "attributeUpdated",
1180 4 : ChangeClass::Deleted => "attributeDeleted",
1181 : };
1182 422 : triggers.contains(&t)
1183 422 : });
1184 426 : let entity_trigger_fired =
1185 426 : triggers.contains(&entity_trigger) && (watched.is_none() || !relevant.is_empty());
1186 426 : if !attr_trigger_fired && !entity_trigger_fired {
1187 12 : continue;
1188 414 : }
1189 414 : let ctx = sub_context_memo(st, &tenant, tenant_str, &sub, ctx_memo).await;
1190 414 : if !selector_match(&sub, eval_doc, &ctx) {
1191 0 : continue;
1192 414 : }
1193 426 : if !linked_eval(st, &tenant, |l| conditions_match(&sub, eval_doc, &ctx, l)).await {
1194 14 : continue;
1195 400 : }
1196 400 : if throttled(&sub) {
1197 6 : continue;
1198 394 : }
1199 394 : let deleted: Vec<String> = if triggers.contains(&"attributeDeleted") {
1200 4 : relevant
1201 4 : .iter()
1202 4 : .filter(|(_, c)| *c == ChangeClass::Deleted)
1203 4 : .map(|(k, _)| k.clone())
1204 4 : .collect()
1205 : } else {
1206 390 : Vec::new()
1207 : };
1208 394 : let entity_deleted_fired = after.is_none() && triggers.contains(&"entityDeleted");
1209 394 : let now = now_iso();
1210 394 : let data = build_data(
1211 394 : st,
1212 394 : &tenant,
1213 394 : &sub,
1214 394 : &ctx,
1215 394 : before.as_ref(),
1216 394 : after.as_ref(),
1217 394 : &deleted,
1218 394 : entity_deleted_fired,
1219 394 : &now,
1220 394 : )
1221 394 : .await;
1222 394 : out.push(Matched {
1223 394 : tenant: tenant.clone(),
1224 394 : sub,
1225 394 : ctx,
1226 394 : data,
1227 394 : });
1228 : }
1229 2373 : out
1230 2373 : }
1231 :
1232 : /// When an interval subscription is next due, in epoch millis: one
1233 : /// `timeInterval` after the last Notification it sent (Table 5.2.14.2-1
1234 : /// `lastNotification`), or after its creation while it has sent none. Without
1235 : /// either anchor it is due immediately.
1236 344 : fn due_at_ms(sub: &Value, interval: f64) -> i64 {
1237 344 : let anchor = sub
1238 344 : .get("notification")
1239 344 : .and_then(|n| n.get("lastNotification"))
1240 344 : .and_then(Value::as_str)
1241 344 : .or_else(|| sub.get("createdAt").and_then(Value::as_str));
1242 344 : match anchor.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) {
1243 344 : Some(last) => last
1244 344 : .timestamp_millis()
1245 344 : .saturating_add(interval_offset_ms(interval)),
1246 0 : None => i64::MIN,
1247 : }
1248 344 : }
1249 :
1250 : /// One period of a periodic Subscription (5.2.12 `timeInterval`) in
1251 : /// milliseconds.
1252 : ///
1253 : /// Table 5.2.12-1 bounds the member only as greater than 0, so the seconds a
1254 : /// client names can exceed what epoch milliseconds hold. The cast saturates,
1255 : /// and every caller adds it with `saturating_add`, which puts an interval the
1256 : /// broker cannot schedule at the end of representable time. Adding it plainly
1257 : /// wraps the sum negative, and a negative firing instant reads as permanently
1258 : /// due: the subscription then fires its whole query on every tick, and the
1259 : /// same value poisons the process-wide sweep clock those minima feed.
1260 680 : fn interval_offset_ms(interval: f64) -> i64 {
1261 680 : (interval * 1000.0) as i64
1262 680 : }
1263 :
1264 : /// The exact Entity ids a subscription's `entities` selector pins down, or
1265 : /// `None` when it leaves any of them open. Table 5.2.33-1: `id` is a String
1266 : /// or a String[] and "id takes precedence over idPattern", so an entry
1267 : /// carrying an id constrains the read exactly — while one entry without an id
1268 : /// (a bare type, or an idPattern no store column can answer) admits every id
1269 : /// and forfeits the narrowing for the whole OR-ed selector.
1270 392 : fn selector_ids(sub: &Value) -> Option<Vec<String>> {
1271 392 : let sel = sub.get("entities").and_then(Value::as_array)?;
1272 380 : let mut ids = Vec::new();
1273 388 : for e in sel {
1274 388 : match e.get("id") {
1275 32 : Some(Value::String(i)) => ids.push(i.clone()),
1276 8 : Some(Value::Array(a)) => {
1277 16 : for v in a {
1278 16 : ids.push(v.as_str()?.to_owned());
1279 : }
1280 : }
1281 348 : _ => return None,
1282 : }
1283 : }
1284 32 : (!ids.is_empty()).then_some(ids)
1285 392 : }
1286 :
1287 : /// A store read on a delivery path has no caller to fail: the sweep is a
1288 : /// timer, the fan-out is spawned, and both answer `()`. Silence is what makes
1289 : /// a failure dangerous here — a subscription that stops firing because the
1290 : /// store could not be read looks exactly like one with nothing to send — so
1291 : /// the failure is named and the path continues on the empty set it would
1292 : /// have continued on anyway.
1293 5699 : fn read_or_warn<T>(res: Result<Vec<T>, antares_model::NgsiError>, what: &str) -> Vec<T> {
1294 5699 : res.unwrap_or_else(|e| {
1295 4 : tracing::warn!("notification path: reading {what} failed: {e}");
1296 4 : Vec::new()
1297 4 : })
1298 5699 : }
1299 :
1300 : /// timeInterval subscriptions: fire when due, with all matching entities.
1301 : /// Multi-instance: claim one interval firing under the subscription row
1302 : /// lock — N matcher pods race, exactly one wins (single-winner by
1303 : /// lock, no leader election). The due-check reruns INSIDE the lock; the
1304 : /// winner stamps `lastNotification` as its claim, losers see not-due and
1305 : /// roll back. Only engaged in bus=nats mode — single-process behaviour (and
1306 : /// its 046_12 bookkeeping ordering) is untouched.
1307 : ///
1308 : /// `None` = the firing is not this pod's. `Some(prev)` = claimed, carrying
1309 : /// the `lastNotification` the claim overwrote: a firing that turns out to
1310 : /// have nothing to send gives it back through [`release_interval`], because
1311 : /// Table 5.2.14.2-1 stamps the instant a notification was SENT and 5.8.6
1312 : /// sends none when nothing matches.
1313 4 : async fn claim_interval(
1314 4 : st: &AppState,
1315 4 : tenant: &TenantId,
1316 4 : kind: Kind,
1317 4 : sub: &Value,
1318 4 : interval: f64,
1319 4 : ) -> Option<Option<Value>> {
1320 4 : let id = sub.get("id").and_then(Value::as_str)?;
1321 4 : let mut prev: Option<Value> = None;
1322 4 : let res = st
1323 4 : .store
1324 4 : .mutate(tenant, kind, id, |doc| {
1325 4 : if chrono::Utc::now().timestamp_millis() < due_at_ms(doc, interval) {
1326 0 : return Err(());
1327 4 : }
1328 4 : let Some(sub_doc) = doc.as_object_mut() else {
1329 : // a stored Subscription is an object; one that is not carries no
1330 : // notification member to stamp, and Err(()) is the same "nothing
1331 : // written" the not-due branch above returns
1332 0 : return Err(());
1333 : };
1334 4 : if let Some(n) = sub_doc
1335 4 : .entry("notification")
1336 4 : .or_insert_with(|| json!({}))
1337 4 : .as_object_mut()
1338 4 : {
1339 4 : prev = n.insert("lastNotification".into(), Value::String(now_iso()));
1340 4 : }
1341 4 : Ok(())
1342 4 : })
1343 4 : .await;
1344 4 : matches!(res, Ok(Some(Ok(())))).then_some(prev)
1345 4 : }
1346 :
1347 : /// Give a claimed firing back (5.8.6: nothing matched, so nothing was sent).
1348 : /// The stamp returns to what [`claim_interval`] found, which both keeps
1349 : /// `lastNotification` truthful and leaves the subscription due, exactly as
1350 : /// the single-process path does.
1351 4 : async fn release_interval(
1352 4 : st: &AppState,
1353 4 : tenant: &TenantId,
1354 4 : kind: Kind,
1355 4 : id: &str,
1356 4 : prev: Option<Value>,
1357 4 : ) {
1358 4 : let res = st
1359 4 : .store
1360 4 : .mutate::<(), ()>(tenant, kind, id, |doc| {
1361 4 : if let Some(n) = doc
1362 4 : .as_object_mut()
1363 4 : .and_then(|o| o.get_mut("notification"))
1364 4 : .and_then(Value::as_object_mut)
1365 : {
1366 4 : match &prev {
1367 0 : Some(v) => n.insert("lastNotification".into(), v.clone()),
1368 4 : None => n.remove("lastNotification"),
1369 : };
1370 0 : }
1371 4 : Ok(())
1372 4 : })
1373 4 : .await;
1374 4 : if let Err(e) = res {
1375 0 : tracing::warn!("releasing the interval claim for {id} failed: {e}");
1376 4 : }
1377 4 : }
1378 :
1379 : /// One sweep of the interval subscriptions (5.8.6, 5.11.7): "If a
1380 : /// Subscription defines a timeInterval member, a Notification shall be sent
1381 : /// periodically, when the time interval (in seconds) specified in such value
1382 : /// field is reached, regardless of Attribute changes."
1383 : ///
1384 : /// The sweep runs on a fixed tick, so its idle cost is what has to stay
1385 : /// small. Two things keep it off the store. A tick that cannot fire anything
1386 : /// returns before enumerating tenants: each sweep records the earliest instant
1387 : /// a subscription it saw can next be due, and a write zeroes that clock —
1388 : /// through the mirror for Subscriptions, through `csub_written` for the
1389 : /// Context Source Registration Subscription half, which is mirrored by clock
1390 : /// rather than by document. A lost signal is repaired within
1391 : /// `CSUB_SWEEP_BACKSTOP_MS`. And a due subscription reads only the Entities
1392 : /// its own selector can match instead of its tenant's entity set.
1393 3320 : pub async fn interval_tick(st: &AppState) {
1394 : use std::sync::atomic::Ordering::Relaxed;
1395 3320 : let now_ms = chrono::Utc::now().timestamp_millis();
1396 3320 : let clocks = st.sub_mirror.as_ref().map(|m| {
1397 3304 : (
1398 3304 : m.next_sub_sweep_ms.load(Relaxed),
1399 3304 : m.next_csub_sweep_ms.load(Relaxed),
1400 3304 : )
1401 3304 : });
1402 3320 : let (sweep_subs, sweep_csubs) = match clocks {
1403 3304 : Some((sub_clock, csub_clock)) => (now_ms >= sub_clock, now_ms >= csub_clock),
1404 : // Never-wired fallback: no clock to keep, so every tick sweeps.
1405 16 : None => (true, true),
1406 : };
1407 3320 : if !sweep_subs && !sweep_csubs {
1408 3218 : return;
1409 102 : }
1410 : // Earliest next-due instant seen by this sweep, per half.
1411 102 : let mut next_sub = i64::MAX;
1412 102 : let mut next_csub = i64::MAX;
1413 : // One sweep visits every tenant, and a delivery costs up to the
1414 : // endpoint's whole timeout (Table 5.2.15-1, 30 s at the ceiling). Awaited
1415 : // in turn, one unresponsive endpoint becomes the deadline of every other
1416 : // subscriber's periodic notification. The deliveries of a tick therefore
1417 : // run together, under the same width the change path uses, and the tick
1418 : // still does not return until they have settled: ticks never overlap, so
1419 : // a subscription cannot be fired twice for one period.
1420 : #[cfg(not(target_arch = "wasm32"))]
1421 102 : let mut sending = tokio::task::JoinSet::new();
1422 749 : for tenant_str in read_or_warn(
1423 102 : st.store.subscription_tenants().await,
1424 102 : "the tenants with subscriptions",
1425 : ) {
1426 733 : let Ok(tenant) = TenantId::new_internal(&tenant_str) else {
1427 0 : continue;
1428 : };
1429 : // Same source the matcher reads: the indexed mirror, with the store
1430 : // list only as the never-wired fallback.
1431 733 : let subs = match (&st.sub_mirror, sweep_subs) {
1432 586 : (_, false) => Vec::new(),
1433 131 : (Some(m), _) => m.periodic_docs(tenant.as_str()),
1434 16 : (None, _) => read_or_warn(
1435 16 : st.store.list(&tenant, Kind::Subscription).await,
1436 16 : "the periodic Subscriptions",
1437 : )
1438 16 : .into_iter()
1439 16 : .map(Arc::new)
1440 16 : .collect(),
1441 : };
1442 879 : for sub in subs {
1443 324 : let Some(interval) = sub.get("timeInterval").and_then(Value::as_f64) else {
1444 0 : continue;
1445 : };
1446 324 : if !is_active(&sub) {
1447 0 : continue;
1448 324 : }
1449 324 : let due_at = due_at_ms(&sub, interval);
1450 324 : if now_ms < due_at {
1451 0 : next_sub = next_sub.min(due_at);
1452 0 : continue;
1453 324 : }
1454 : // Due: the following firing is one interval away. Recorded before
1455 : // the claim, so a pod that LOSES the race (another one is firing
1456 : // this subscription right now) keeps sweeping on the interval
1457 : // instead of parking on an anchor only the winner advanced.
1458 324 : next_sub = next_sub.min(now_ms.saturating_add(interval_offset_ms(interval)));
1459 324 : let claim = if st.nats {
1460 4 : match claim_interval(st, &tenant, Kind::Subscription, &sub, interval).await {
1461 4 : Some(prev) => Some(prev),
1462 0 : None => continue,
1463 : }
1464 : } else {
1465 320 : None
1466 : };
1467 324 : let ctx = sub_context(st, &tenant, &sub).await;
1468 324 : let now = now_iso();
1469 : // 5.8.6: the periodic Notification "shall include all the
1470 : // subscribed Entities that match the query, geoquery and Scope
1471 : // query conditions" — so the read is exactly this subscription's
1472 : // own selector (5.2.33) and conditions, never the tenant's entity
1473 : // set. Only predicates a store reproduces without hiding a
1474 : // candidate are offered (ids when every selector entry names one,
1475 : // types when the index proves them plain, q/scopeQ/geoQ under the
1476 : // store's own rule that SQL removes rows and never decides them);
1477 : // the selector_match/conditions_match pair below stays the
1478 : // arbiter, exactly as on the query path.
1479 324 : let type_groups: Vec<Vec<String>> = match index_keys(&sub) {
1480 324 : Keys::Types(ts) => ts.into_iter().map(|t| vec![t]).collect(),
1481 0 : _ => Vec::new(),
1482 : };
1483 324 : let ids = selector_ids(&sub);
1484 : // The filter borrows a term expander that is not Sync, so it lives
1485 : // and dies inside this block: held across the delivery await it
1486 : // would make the whole interval task non-Send.
1487 324 : let rows = {
1488 324 : let expand = |t: &str| ctx.expand_key(t);
1489 324 : let id_refs: Vec<&str> = ids.iter().flatten().map(String::as_str).collect();
1490 : // q values in subscription bodies may be percent-encoded (4.9);
1491 : // parses shared per distinct expression text, as in
1492 : // conditions_match — the sweep re-runs per due subscription
1493 324 : let q_ast = sub_str(&sub, "q").and_then(|q| {
1494 0 : antares_ql::regex::q_node(&crate::negotiate::percent_decode(q.as_bytes()))
1495 0 : });
1496 324 : let geo = sub.get("geoQ").and_then(Value::as_object).and_then(|g| {
1497 0 : let key = serde_json::to_string(g).unwrap_or_default();
1498 0 : antares_ql::regex::geo_query(&key, || {
1499 0 : antares_ql::geo::GeoQuery::from_params(&geo_params(g))
1500 0 : .ok()
1501 0 : .flatten()
1502 0 : })
1503 0 : });
1504 324 : let geo_spec = geo.as_ref().and_then(|g| g.to_sql_spec(&ctx));
1505 324 : let filter = antares_store::filter::EntityFilter {
1506 324 : ids: ids.as_ref().map(|_| id_refs.as_slice()),
1507 324 : types: (!type_groups.is_empty()).then_some(type_groups.as_slice()),
1508 324 : q: q_ast.as_deref(),
1509 324 : scope_q: sub_str(&sub, "scopeQ"),
1510 324 : geo: geo_spec.as_ref(),
1511 324 : expand: &expand,
1512 324 : ..Default::default()
1513 : };
1514 324 : read_or_warn(
1515 324 : st.store
1516 324 : .query_entities(&tenant, &filter)
1517 324 : .await
1518 324 : .map(|o| o.rows),
1519 324 : "the Entities a periodic Subscription notifies about",
1520 : )
1521 : };
1522 324 : let mut matching: Vec<Value> = Vec::new();
1523 340 : for d in rows {
1524 340 : if !selector_match(&sub, &d, &ctx)
1525 312 : || !linked_eval(st, &tenant, |l| conditions_match(&sub, &d, &ctx, l)).await
1526 : {
1527 28 : continue;
1528 312 : }
1529 312 : matching.extend(
1530 312 : build_data(st, &tenant, &sub, &ctx, None, Some(&d), &[], false, &now).await,
1531 : );
1532 : }
1533 324 : if matching.is_empty() {
1534 : // 5.8.6: "If there are no matching Entities, no Notification
1535 : // is sent" — lastNotification stays untouched, so this
1536 : // subscription is still due and every following tick
1537 : // re-checks it. A claim taken to win the firing is given back
1538 : // here, or the multi-pod path would stamp an instant nothing
1539 : // was sent at and park the subscription for a whole interval.
1540 12 : if let (Some(prev), Some(id)) = (claim, sub.get("id").and_then(Value::as_str)) {
1541 4 : release_interval(st, &tenant, Kind::Subscription, id, prev).await;
1542 8 : }
1543 12 : next_sub = next_sub.min(due_at);
1544 12 : continue;
1545 312 : }
1546 : #[cfg(not(target_arch = "wasm32"))]
1547 : {
1548 312 : let (st, tenant, ctx) = (st.clone(), tenant.clone(), Arc::clone(&ctx));
1549 312 : sending.spawn(async move {
1550 312 : let _permits = delivery_permits(&tenant).await;
1551 56 : deliver(&st, &tenant, &sub, matching, &ctx).await;
1552 24 : });
1553 : }
1554 : #[cfg(target_arch = "wasm32")]
1555 : deliver(st, &tenant, &sub, matching, &ctx).await;
1556 : }
1557 733 : if !sweep_csubs {
1558 4 : continue;
1559 729 : }
1560 : // csource timeInterval subs: periodic CSourceNotification with all
1561 : // matching registrations, independent of changes (5.11.7)
1562 729 : for sub in read_or_warn(
1563 729 : st.store.list(&tenant, Kind::CSourceSubscription).await,
1564 729 : "the periodic Context Source Registration Subscriptions",
1565 : ) {
1566 0 : let Some(interval) = sub.get("timeInterval").and_then(Value::as_f64) else {
1567 0 : continue;
1568 : };
1569 0 : if !is_active(&sub) {
1570 0 : continue;
1571 0 : }
1572 0 : let due_at = due_at_ms(&sub, interval);
1573 0 : if now_ms < due_at {
1574 0 : next_csub = next_csub.min(due_at);
1575 0 : continue;
1576 0 : }
1577 0 : next_csub = next_csub.min(now_ms.saturating_add(interval_offset_ms(interval)));
1578 : // 5.11.7 sends the periodic CSourceNotification whatever the
1579 : // matching set is, so this claim is never given back.
1580 0 : if st.nats
1581 0 : && claim_interval(st, &tenant, Kind::CSourceSubscription, &sub, interval)
1582 0 : .await
1583 0 : .is_none()
1584 : {
1585 0 : continue;
1586 0 : }
1587 0 : let ctx = sub_context(st, &tenant, &sub).await;
1588 0 : let spec = crate::registry::spec_for_subscription(&sub);
1589 0 : let data: Vec<Value> = read_or_warn(
1590 0 : st.store.list(&tenant, Kind::Registration).await,
1591 0 : "the registrations a periodic Context Source Notification carries",
1592 : )
1593 0 : .into_iter()
1594 0 : .filter(|r| crate::registry::csr_matches_subscription(&sub, r, &ctx))
1595 0 : .map(|r| {
1596 0 : let mut p = crate::registry::present_registration(
1597 0 : &filter_csr(&spec, &r, &ctx),
1598 0 : &ctx,
1599 : false,
1600 : );
1601 0 : arrayify_entity_types(&mut p);
1602 0 : p
1603 0 : })
1604 0 : .collect();
1605 : #[cfg(not(target_arch = "wasm32"))]
1606 : {
1607 0 : let (st, tenant, ctx) = (st.clone(), tenant.clone(), Arc::clone(&ctx));
1608 0 : sending.spawn(async move {
1609 0 : let _permit = DELIVERY_SLOTS.acquire().await;
1610 0 : deliver_csource(&st, &tenant, &sub, data, &ctx, "newlyMatching").await;
1611 0 : });
1612 : }
1613 : #[cfg(target_arch = "wasm32")]
1614 : deliver_csource(st, &tenant, &sub, data, &ctx, "newlyMatching").await;
1615 : }
1616 : }
1617 : #[cfg(not(target_arch = "wasm32"))]
1618 126 : while let Some(joined) = sending.join_next().await {
1619 24 : if joined.is_err() {
1620 0 : note_panic();
1621 24 : }
1622 : }
1623 98 : if let (Some(m), Some((sub_clock, csub_clock))) = (&st.sub_mirror, clocks) {
1624 : // A periodic subscription written DURING the sweep has already zeroed
1625 : // the clock: the exchange then fails, the zero stands and the next
1626 : // tick sweeps rather than waiting out an interval computed without it.
1627 86 : if sweep_subs {
1628 58 : let _ = m
1629 58 : .next_sub_sweep_ms
1630 58 : .compare_exchange(sub_clock, next_sub, Relaxed, Relaxed);
1631 62 : }
1632 86 : if sweep_csubs {
1633 82 : let _ = m.next_csub_sweep_ms.compare_exchange(
1634 82 : csub_clock,
1635 82 : next_csub.min(now_ms + CSUB_SWEEP_BACKSTOP_MS),
1636 82 : Relaxed,
1637 82 : Relaxed,
1638 82 : );
1639 82 : }
1640 12 : }
1641 3316 : }
1642 :
1643 : /// POST the Notification (5.3.1) and write the 5.2.14.2 bookkeeping back.
1644 344 : pub(crate) async fn deliver(
1645 344 : st: &AppState,
1646 344 : tenant: &TenantId,
1647 344 : sub: &Value,
1648 344 : data: Vec<Value>,
1649 344 : ctx: &Context,
1650 344 : ) {
1651 : // a notification body is bounded the way an inbound body is (6.3.4 wall,
1652 : // MAX_BODY_BYTES): a grouped delivery over the cap leaves as several
1653 : // notifications, each whole entities, never one unbounded POST
1654 344 : for chunk in chunk_by_bytes(data, *crate::bounds::MAX_BODY_BYTES) {
1655 344 : deliver_as(
1656 344 : st,
1657 344 : tenant,
1658 344 : Kind::Subscription,
1659 344 : sub,
1660 344 : "Notification",
1661 344 : chunk,
1662 344 : ctx,
1663 344 : None,
1664 344 : )
1665 344 : .await;
1666 : }
1667 270 : }
1668 :
1669 : /// Split `data` into runs whose serialized sizes stay under `cap`, cutting
1670 : /// only at whole items; one item alone over the cap still travels alone.
1671 413 : fn chunk_by_bytes(data: Vec<Value>, cap: usize) -> Vec<Vec<Value>> {
1672 413 : let mut out: Vec<Vec<Value>> = Vec::new();
1673 413 : let mut size = 0usize;
1674 583 : for item in data {
1675 583 : let n = serde_json::to_vec(&item).map(|b| b.len()).unwrap_or(0);
1676 583 : match out.last_mut() {
1677 174 : Some(run) if size + n <= cap => {
1678 160 : run.push(item);
1679 160 : size += n;
1680 160 : }
1681 423 : _ => {
1682 423 : out.push(vec![item]);
1683 423 : size = n;
1684 423 : }
1685 : }
1686 : }
1687 413 : out
1688 413 : }
1689 :
1690 : /// 5.11.7: which csource subs care about a registration change, and why.
1691 34 : fn csource_trigger(
1692 34 : sub: &Value,
1693 34 : before: Option<&Value>,
1694 34 : after: Option<&Value>,
1695 34 : ctx: &Context,
1696 34 : ) -> Option<&'static str> {
1697 68 : let m = |d: Option<&Value>| {
1698 68 : d.is_some_and(|d| crate::registry::csr_matches_subscription(sub, d, ctx))
1699 68 : };
1700 34 : match (m(before), m(after)) {
1701 16 : (false, true) => Some("newlyMatching"),
1702 4 : (true, true) => Some("updated"),
1703 10 : (true, false) => Some("noLongerMatching"),
1704 4 : (false, false) => None,
1705 : }
1706 34 : }
1707 :
1708 : /// The notification validator reads EntityInfo.type as an array
1709 : /// (`entities[0]["type"][0]`) — normalize to array form in notification data.
1710 81 : fn arrayify_entity_types(reg: &mut Value) {
1711 81 : let Some(infos) = reg.get_mut("information").and_then(Value::as_array_mut) else {
1712 0 : return;
1713 : };
1714 81 : for info in infos {
1715 81 : let Some(es) = info.get_mut("entities").and_then(Value::as_array_mut) else {
1716 0 : continue;
1717 : };
1718 81 : for e in es {
1719 81 : if let Some(t) = e.get("type").filter(|t| t.is_string()).cloned() {
1720 81 : if let Some(o) = e.as_object_mut() {
1721 81 : o.insert("type".into(), Value::Array(vec![t]));
1722 81 : }
1723 0 : }
1724 : }
1725 : }
1726 81 : }
1727 :
1728 : /// One prepared CSource notification, ready to send.
1729 : pub struct CsourceJob {
1730 : sub: Value,
1731 : presented: Value,
1732 : ctx: std::sync::Arc<antares_jsonld::Context>,
1733 : reason: &'static str,
1734 : }
1735 :
1736 : /// Registration create/update/delete → CSourceNotification fan-out (5.11.7),
1737 : /// in two phases: `prepare_csource_jobs` runs IN the request path (store
1738 : /// reads + matching + payload build — so job order is the handlers' commit
1739 : /// order even on a slower store), and the caller spawns `send_csource_jobs`
1740 : /// (network only — the ack must not block on the receiver: the ETSI mock
1741 : /// replies only when the robot side wakes).
1742 2134 : pub(crate) async fn prepare_csource_jobs(
1743 2134 : st: &AppState,
1744 2134 : tenant: &TenantId,
1745 2134 : before: Option<Value>,
1746 2134 : after: Option<Value>,
1747 2134 : ) -> Vec<CsourceJob> {
1748 2134 : let mut jobs = Vec::new();
1749 : // 5.8.1.4: the Registration Subscriptions the distributed half owns are
1750 : // not client resources, so they live under Kind::DistSub beside the
1751 : // mapping documents — which carry no `type`. A document under the
1752 : // client kind in the internal id namespace is a leftover of a release
1753 : // that stored the internal ones there, and drives nothing.
1754 2134 : let client = read_or_warn(
1755 2134 : st.store.list(tenant, Kind::CSourceSubscription).await,
1756 2134 : "the Context Source Registration Subscriptions of a changed registration",
1757 : )
1758 2134 : .into_iter()
1759 2134 : .filter(|d| {
1760 0 : sub_str(d, "id")
1761 0 : .is_some_and(|id| crate::registry::csr_kind(id) == Kind::CSourceSubscription)
1762 0 : });
1763 2134 : let internal = read_or_warn(
1764 2134 : st.store.list(tenant, Kind::DistSub).await,
1765 2134 : "the internal Registration Subscriptions of a changed registration",
1766 : )
1767 2134 : .into_iter()
1768 2134 : .filter(|d| d.get("type").and_then(Value::as_str) == Some("Subscription"));
1769 2134 : for sub in client.chain(internal) {
1770 10 : if !is_active(&sub) || sub.get("timeInterval").is_some() {
1771 0 : continue;
1772 10 : }
1773 10 : let ctx = sub_context(st, tenant, &sub).await;
1774 10 : let Some(reason) = csource_trigger(&sub, before.as_ref(), after.as_ref(), &ctx) else {
1775 0 : continue;
1776 : };
1777 10 : let spec = crate::registry::spec_for_subscription(&sub);
1778 10 : let source = if reason == "noLongerMatching" {
1779 2 : &before
1780 : } else {
1781 8 : &after
1782 : };
1783 10 : let Some(reg) = source.as_ref().or(before.as_ref()) else {
1784 0 : continue;
1785 : };
1786 10 : let filtered = filter_csr(&spec, reg, &ctx);
1787 10 : let mut presented = crate::registry::present_registration(&filtered, &ctx, false);
1788 10 : arrayify_entity_types(&mut presented);
1789 10 : jobs.push(CsourceJob {
1790 10 : sub,
1791 10 : presented,
1792 10 : ctx,
1793 10 : reason,
1794 10 : });
1795 : }
1796 2134 : jobs
1797 2134 : }
1798 :
1799 2022 : pub(crate) async fn send_csource_jobs(st: &AppState, tenant: &TenantId, jobs: Vec<CsourceJob>) {
1800 2022 : for job in jobs {
1801 : // 5.11.7: re-check the subscription still exists right
1802 : // before the send — a deleted subscription must never notify.
1803 6 : let sub_id = job
1804 6 : .sub
1805 6 : .get("id")
1806 6 : .and_then(Value::as_str)
1807 6 : .unwrap_or_default();
1808 6 : let kind = crate::registry::csr_kind(sub_id);
1809 6 : if !matches!(st.store.get(tenant, kind, sub_id).await, Ok(Some(_))) {
1810 0 : continue;
1811 6 : }
1812 6 : deliver_as(
1813 6 : st,
1814 6 : tenant,
1815 6 : kind,
1816 6 : &job.sub,
1817 6 : "ContextSourceNotification",
1818 6 : vec![job.presented],
1819 6 : &job.ctx,
1820 6 : Some(job.reason),
1821 6 : )
1822 6 : .await;
1823 : }
1824 2022 : }
1825 :
1826 : /// Registration writes prepare one job per CSource subscription of the
1827 : /// tenant (5.11.7) and every subscription with localOnly != true owns one,
1828 : /// so a registration stream against many subscriptions queues
1829 : /// subscriptions × registrations jobs faster than the sources drain them —
1830 : /// at 10 000 × 100 the queued jobs were a 3 GB broker peak. The permit is
1831 : /// taken in the request path: a write waits for a fan-out slot instead of
1832 : /// stacking jobs, and the prepare order (the handlers' commit order) holds.
1833 : const CSOURCE_FANOUT_WIDTH: usize = 64;
1834 : static CSOURCE_FANOUT: tokio::sync::Semaphore =
1835 : tokio::sync::Semaphore::const_new(CSOURCE_FANOUT_WIDTH);
1836 :
1837 : /// Registration create/update/delete → prepare in the request path, send
1838 : /// spawned (the ack must not block on the receiver), bounded as above.
1839 2134 : pub(crate) async fn csource_fanout(
1840 2134 : st: &AppState,
1841 2134 : tenant: &TenantId,
1842 2134 : before: Option<Value>,
1843 2134 : after: Option<Value>,
1844 2134 : ) {
1845 : // never closed, so acquire only fails if it were — then send unbounded
1846 2134 : let permit = CSOURCE_FANOUT.acquire().await.ok();
1847 2134 : let jobs = prepare_csource_jobs(st, tenant, before, after).await;
1848 2134 : let (st2, t2) = (st.clone(), tenant.clone());
1849 2134 : crate::spawn(async move {
1850 2022 : send_csource_jobs(&st2, &t2, jobs).await;
1851 2022 : drop(permit);
1852 2022 : });
1853 2134 : }
1854 :
1855 : /// POST a CSourceNotification (5.3.2) under the same body bound as every
1856 : /// other one: 5.11.2.4 sends "all matching Context Source Registrations",
1857 : /// and a broker holding 100 000 of them must not turn that into one
1858 : /// unbounded request. Over the cap the set leaves as several notifications,
1859 : /// each carrying whole registrations — the trade [`deliver`] already makes
1860 : /// for entity Notifications.
1861 57 : async fn deliver_csource(
1862 57 : st: &AppState,
1863 57 : tenant: &TenantId,
1864 57 : sub: &Value,
1865 57 : data: Vec<Value>,
1866 57 : ctx: &Context,
1867 57 : reason: &str,
1868 57 : ) {
1869 57 : let kind = crate::registry::csr_kind(sub_str(sub, "id").unwrap_or_default());
1870 59 : for chunk in chunk_by_bytes(data, *crate::bounds::MAX_BODY_BYTES) {
1871 59 : deliver_as(
1872 59 : st,
1873 59 : tenant,
1874 59 : kind,
1875 59 : sub,
1876 59 : "ContextSourceNotification",
1877 59 : chunk,
1878 59 : ctx,
1879 59 : Some(reason),
1880 59 : )
1881 59 : .await;
1882 : }
1883 55 : }
1884 :
1885 : /// Initial / post-update CSourceNotification with all currently matching
1886 : /// registrations (5.11.2.4 / 5.11.3.4).
1887 260 : pub(crate) async fn csource_initial(st: &AppState, tenant: &TenantId, sub_id: &str) {
1888 260 : let Some(sub) = st
1889 260 : .store
1890 260 : .get(tenant, crate::registry::csr_kind(sub_id), sub_id)
1891 260 : .await
1892 260 : .ok()
1893 260 : .flatten()
1894 : else {
1895 0 : return;
1896 : };
1897 260 : if !is_active(&sub) {
1898 0 : return;
1899 260 : }
1900 260 : let ctx = sub_context(st, tenant, &sub).await;
1901 260 : let spec = crate::registry::spec_for_subscription(&sub);
1902 260 : let data: Vec<Value> = read_or_warn(
1903 260 : st.store.list(tenant, Kind::Registration).await,
1904 260 : "the registrations an initial Context Source Notification carries",
1905 : )
1906 260 : .into_iter()
1907 260 : .filter(|r| crate::registry::csr_matches_subscription(&sub, r, &ctx))
1908 260 : .map(|r| {
1909 71 : let mut p =
1910 71 : crate::registry::present_registration(&filter_csr(&spec, &r, &ctx), &ctx, false);
1911 71 : arrayify_entity_types(&mut p);
1912 71 : p
1913 71 : })
1914 260 : .collect();
1915 260 : if data.is_empty() {
1916 203 : return; // nothing currently matching ⇒ no initial notification
1917 57 : }
1918 57 : deliver_csource(st, tenant, &sub, data, &ctx, "newlyMatching").await;
1919 258 : }
1920 :
1921 : /// Registration copy reduced to the matching RegistrationInfo elements
1922 : /// (5.10.2.5 / 5.11.7 "filtered Context Source Registrations").
1923 81 : fn filter_csr(spec: &crate::registry::CsrSpec, reg: &Value, ctx: &Context) -> Value {
1924 81 : let mut out = reg.clone();
1925 81 : let matching: Vec<Value> = crate::registry::matching_infos(spec, reg, ctx)
1926 81 : .into_iter()
1927 81 : .cloned()
1928 81 : .collect();
1929 81 : if !matching.is_empty() {
1930 81 : if let Some(o) = out.as_object_mut() {
1931 81 : o.insert("information".into(), Value::Array(matching));
1932 81 : }
1933 0 : }
1934 81 : out
1935 81 : }
1936 :
1937 : /// Table 5.2.15-1 `timeout`: per-endpoint delivery deadline in milliseconds.
1938 : /// "The NGSI-LD system can override this value" — clamped to [100 ms, 30 s]
1939 : /// so one subscription cannot park a delivery task for minutes. Default 5 s
1940 : /// (the previous hard-coded deadline). HTTP only: the clause scopes it to
1941 : /// bindings that "always return a response".
1942 400 : fn endpoint_timeout_ms(ep: &serde_json::Map<String, Value>) -> u32 {
1943 400 : ep.get("timeout")
1944 400 : .and_then(Value::as_f64)
1945 400 : .filter(|t| *t > 0.0)
1946 400 : .map(|t| (t as u32).clamp(100, 30_000))
1947 400 : .unwrap_or(5_000)
1948 400 : }
1949 :
1950 : /// Table 5.2.15-1 `cooldown`: "Once a failure has occurred, minimum period of
1951 : /// time in milliseconds which shall elapse before attempting to make a
1952 : /// subsequent notification to the same endpoint after failure. If requests
1953 : /// are received before the cooldown period has expired, no notification is
1954 : /// sent." — i.e. matches inside the window are DROPPED, not queued.
1955 412 : fn in_cooldown(sub: &Value, now: chrono::DateTime<chrono::Utc>) -> bool {
1956 412 : let n = sub.get("notification");
1957 412 : let Some(cd) = n
1958 412 : .and_then(|n| n.get("endpoint"))
1959 412 : .and_then(|e| e.get("cooldown"))
1960 412 : .and_then(Value::as_f64)
1961 412 : .filter(|c| *c > 0.0)
1962 : else {
1963 400 : return false;
1964 : };
1965 : // the gate exists only "once a failure has occurred" and only until a
1966 : // success clears it — notification.status tracks exactly that (5.2.14.2)
1967 12 : if n.and_then(|n| n.get("status")).and_then(Value::as_str) != Some("failed") {
1968 4 : return false;
1969 8 : }
1970 8 : let Some(lf) = n.and_then(|n| n.get("lastFailure")).and_then(Value::as_str) else {
1971 0 : return false;
1972 : };
1973 8 : let Ok(t) = chrono::DateTime::parse_from_rfc3339(lf) else {
1974 0 : return false;
1975 : };
1976 8 : let elapsed = now
1977 8 : .signed_duration_since(t.with_timezone(&chrono::Utc))
1978 8 : .num_milliseconds();
1979 8 : (elapsed as f64) < cd
1980 412 : }
1981 :
1982 : #[allow(clippy::too_many_arguments)] // one param per notification dimension
1983 457 : async fn deliver_as(
1984 457 : st: &AppState,
1985 457 : tenant: &TenantId,
1986 457 : kind: Kind,
1987 457 : sub: &Value,
1988 457 : ntype: &str,
1989 457 : data: Vec<Value>,
1990 457 : ctx: &Context,
1991 457 : trigger_reason: Option<&str>,
1992 457 : ) {
1993 : // 5.2.12: a paused subscription (isActive false) and an expired one send
1994 : // nothing. Every caller that assembles data locally checks this first;
1995 : // the ones that arrive with data already assembled — the 5.8.6 inbound
1996 : // notification among them — did not, so the check belongs here too.
1997 457 : if !is_active(sub) {
1998 0 : return;
1999 457 : }
2000 457 : let sub_id = sub_str(sub, "id").unwrap_or_default().to_owned();
2001 457 : let Some(ep) = sub
2002 457 : .get("notification")
2003 457 : .and_then(|n| n.get("endpoint"))
2004 457 : .and_then(Value::as_object)
2005 : else {
2006 0 : return;
2007 : };
2008 457 : let Some(uri) = ep.get("uri").and_then(Value::as_str) else {
2009 0 : return;
2010 : };
2011 : // 5.8.1.4 consumer half: the internal CSR subscription's notifications
2012 : // are handled in-process (urn:antares:distsub:{tenant}\n{own sub id})
2013 457 : if let Some(own) = uri.strip_prefix("urn:antares:distsub:") {
2014 59 : if let (Some((_, own_id)), Some(handler)) =
2015 61 : (own.split_once('\n'), st.csource_notification.as_ref())
2016 : {
2017 59 : handler(st, tenant, own_id, trigger_reason, &data).await;
2018 2 : }
2019 59 : return;
2020 396 : }
2021 : // 6.3.8: the binding comes from the registry and nowhere else. Creation
2022 : // rejects an endpoint whose scheme no sink serves, so a stored row that
2023 : // still names one was hand-edited — it is dropped, never delivered
2024 : // through some other binding.
2025 396 : if st.sinks.sink_for_uri(uri).is_none() {
2026 0 : tracing::warn!(
2027 : "subscription {sub_id} endpoint {} has no registered binding",
2028 0 : redact_userinfo(uri)
2029 : );
2030 0 : return;
2031 396 : }
2032 : // endpoint.cooldown — drop (never queue) while the window is open.
2033 : // Before any bookkeeping: a suppressed notification was never sent, so
2034 : // timesSent/lastNotification must not move.
2035 396 : if in_cooldown(sub, chrono::Utc::now()) {
2036 0 : tracing::debug!("subscription {sub_id} in cooldown; notification suppressed (5.2.15)");
2037 0 : return;
2038 396 : }
2039 : // An open circuit is the same class of self-inflicted suppression: no
2040 : // request leaves the process, so Table 5.2.14.2-1 timesSent ("number of
2041 : // times that the notification has been sent") and lastNotification ("the
2042 : // instant when the last notification has been sent") must not move
2043 : // either. `is_open` returning false IS the half-open probe, so the
2044 : // check stays exactly once per attempt.
2045 : // A binding that opens no socket has no destination for the policy or
2046 : // the breaker to judge; every network binding is policed below.
2047 396 : let policed = st.sinks.sink_for_uri(uri).is_some_and(|s| s.network());
2048 396 : if policed && st.egress.is_open(tenant.as_str(), uri) {
2049 8 : tracing::debug!(
2050 : "notification to {} short-circuited (breaker open)",
2051 0 : redact_userinfo(uri)
2052 : );
2053 8 : return;
2054 388 : }
2055 388 : let accept = ep
2056 388 : .get("accept")
2057 388 : .and_then(Value::as_str)
2058 388 : .unwrap_or("application/json");
2059 388 : let now = now_iso();
2060 388 : let mut body = json!({
2061 388 : "id": format!("urn:ngsi-ld:{ntype}:{}", uuid::Uuid::new_v4()),
2062 388 : "type": ntype,
2063 388 : "subscriptionId": sub_id,
2064 388 : "notifiedAt": now,
2065 388 : "data": data,
2066 : });
2067 388 : if let Some(r) = trigger_reason {
2068 4 : body["triggerReason"] = Value::String(r.into());
2069 384 : }
2070 : // ADR-0020: the engine sees the notification the broker means to send,
2071 : // before it is encoded — the data entities are still the 5.2.14.1
2072 : // documents the subscription asked for, not a FeatureCollection or a
2073 : // set of @context-injected ones, so a projection lands on entities the
2074 : // engine can name. Before the bookkeeping below, because 5.8.6 moves
2075 : // timesSent for a notification that "shall be sent": one that is
2076 : // dropped here never was, exactly like a cooldown or an open breaker.
2077 : // The subject is the subscriber's, stored when the subscription was
2078 : // created — delivery is broker-initiated and there is no request here
2079 : // to read one off.
2080 : // With no engine attached the answer is Deliver and the subject is
2081 : // never read out of the subscription — this runs once per notification
2082 : // per subscription.
2083 388 : if let Some(engine) = &st.policy {
2084 16 : match crate::policy::pre_notify(
2085 16 : engine.as_ref(),
2086 16 : &crate::policy::stored_subject(tenant, sub),
2087 16 : sub,
2088 16 : &mut body,
2089 16 : ) {
2090 6 : crate::policy::NotifyDecision::Deliver => {}
2091 : crate::policy::NotifyDecision::Drop => {
2092 2 : tracing::debug!("subscription {sub_id} notification dropped by the policy engine");
2093 2 : return;
2094 : }
2095 8 : crate::policy::NotifyDecision::Filter(f) => {
2096 : // a condition the notification path cannot re-run is not a
2097 : // narrowing it can claim to have applied
2098 8 : if f.q.is_some() || f.scope_q.is_some() {
2099 2 : tracing::error!(
2100 : "policy engine {} narrowed a notification with a query; dropping it",
2101 0 : engine.name()
2102 : );
2103 2 : return;
2104 6 : }
2105 6 : let f = crate::repr::compacted_filter(&f, ctx);
2106 6 : if let Some(arr) = body.get_mut("data").and_then(Value::as_array_mut) {
2107 6 : for e in arr.iter_mut() {
2108 6 : f.project(e);
2109 6 : }
2110 0 : }
2111 : }
2112 : }
2113 372 : }
2114 : // 5.8.6: a subscription's ngsildConformance pins the notification format —
2115 : // amend the data entities per the 4.3.6.8 fallbacks.
2116 384 : if let Some(ver) = sub_str(sub, "ngsildConformance").and_then(crate::conformance::parse_version)
2117 : {
2118 0 : if let Some(d) = body.get_mut("data") {
2119 0 : crate::conformance::amend_payload(d, ver);
2120 0 : }
2121 384 : }
2122 384 : if accept == "application/ld+json" {
2123 : // JSON-LD notifications carry the @context inside each data entity
2124 : // (046_14: data[0] must contain @context; no Link header) — same rule
2125 : // over MQTT: with ld+json the @context travels in the body (7.2).
2126 2 : if let Some(arr) = body.get_mut("data").and_then(Value::as_array_mut) {
2127 2 : for e in arr.iter_mut() {
2128 2 : *e = inject_context(e.clone(), ctx);
2129 2 : }
2130 0 : }
2131 382 : }
2132 384 : if accept == "application/geo+json" {
2133 : // Table 5.3.1-1: with endpoint.accept application/geo+json, data is
2134 : // a FeatureCollection (5.2.30); if receiverInfo carries
2135 : // Prefer=body=json the FeatureCollection carries no @context.
2136 4 : let prefer_body_json = ep
2137 4 : .get("receiverInfo")
2138 4 : .and_then(Value::as_array)
2139 4 : .is_some_and(|ri| {
2140 2 : ri.iter().any(|kv| {
2141 2 : kv.get("key").and_then(Value::as_str) == Some("Prefer")
2142 2 : && kv.get("value").and_then(Value::as_str) == Some("body=json")
2143 2 : })
2144 2 : });
2145 4 : let entities = body["data"].as_array().cloned().unwrap_or_default();
2146 4 : let mut fc = crate::repr::to_geojson_collection(entities, None);
2147 4 : if !prefer_body_json {
2148 2 : fc["@context"] = crate::negotiate::served_context(ctx);
2149 2 : }
2150 4 : body["data"] = fc;
2151 380 : }
2152 384 : let receiver_info = kv_pairs(ep.get("receiverInfo"));
2153 :
2154 : // 6.3.22: a subscription living under a snapshot's synthetic tenant
2155 : // notifies with the NGSILD-Snapshot header and the OWNER tenant — the
2156 : // internal "snap-…" tenant never leaks.
2157 384 : let (hdr_tenant, snapshot_id) =
2158 384 : match crate::snapshots::snapshot_of_synth(st, tenant.as_str()).await {
2159 2 : Some((owner, sid)) => (owner, Some(sid)),
2160 382 : None => (tenant.clone(), None),
2161 : };
2162 :
2163 : // Prepared BEFORE the bookkeeping writeback so the optimistic stamp
2164 : // covers only the in-flight attempt (046_12_01 race). The parts are
2165 : // transport-neutral: the sink registered for the endpoint's scheme turns
2166 : // them into HTTP headers (6.3.8) or an MQTT metadata object (Table
2167 : // 7.2-2).
2168 384 : let mut info = receiver_info;
2169 384 : strip_reserved_markers(&mut info);
2170 384 : if hdr_tenant.as_str() != "default" {
2171 54 : info.push(("NGSILD-Tenant".into(), hdr_tenant.as_str().to_owned()));
2172 330 : }
2173 384 : if let Some(sid) = &snapshot_id {
2174 2 : info.push(("NGSILD-Snapshot".into(), sid.clone()));
2175 382 : }
2176 384 : let outbound = Outbound {
2177 384 : body,
2178 384 : accept: accept.to_owned(),
2179 384 : link: link_header_value(ctx),
2180 384 : receiver_info: info,
2181 384 : notifier_info: kv_pairs(ep.get("notifierInfo")),
2182 384 : };
2183 : // Bookkeeping BEFORE the send (5.8.6/5.2.14.2: lastNotification is the
2184 : // instant the notification is sent). The ETSI mock unblocks the test the
2185 : // moment the request ARRIVES, so a post-response-only writeback races the
2186 : // test's immediate Retrieve Subscription (CI flake on 046_12_01).
2187 : // Optimistic ok; a failed attempt is corrected right below — the transient
2188 : // window is the in-flight attempt itself, and the failure TPs wait for the
2189 : // attempt to resolve before asserting.
2190 : // One store call: the stamp is a fixed mutation, so a backend can write
2191 : // it as a single statement instead of locking the row across a round
2192 : // trip. At fan-out that lock is what serializes delivery.
2193 384 : let booked = st
2194 384 : .store
2195 384 : .record_delivery(tenant, kind, &sub_id, &now)
2196 384 : .await
2197 384 : .unwrap_or_else(|e| {
2198 0 : tracing::warn!("bookkeeping writeback failed: {e}");
2199 0 : None
2200 0 : });
2201 : // 5.8.6: notifications are sent for the subscriptions the broker holds.
2202 : // No row to book against means the subscription was deleted (or the
2203 : // store failed) between matching and delivery — nothing may be sent.
2204 384 : let Some(booked) = booked else {
2205 4 : return;
2206 : };
2207 380 : let mut prev_success = booked.prev_success;
2208 380 : mirror_bookkeeping(st, tenant, kind, &sub_id, Some(booked.doc));
2209 : // The notification endpoint is an egress destination like any other
2210 : // — policy check once, breaker consulted before the attempt.
2211 : // A refusal is a delivery failure for bookkeeping (status "failed",
2212 : // lastSuccess rolled back below) but never breaker state: the policy
2213 : // verdict says nothing about the endpoint's health.
2214 380 : let refused = policed
2215 376 : && match st.egress.check_destination(uri).await {
2216 372 : Ok(()) => false,
2217 4 : Err(e) => {
2218 4 : tracing::warn!(
2219 : "notification endpoint {} refused by egress policy: {e}",
2220 0 : redact_userinfo(uri)
2221 : );
2222 4 : true
2223 : }
2224 : };
2225 : // (delivered, timed_out): only a TIMEOUT-class failure feeds the breaker
2226 : // — that protects against peers that eat the deadline. An endpoint
2227 : // that ANSWERS (any status) is alive, costs only its own response time,
2228 : // and 6.3.8 says the notification shall be sent — suppressing sends to a
2229 : // responding host:port starves unrelated subscriptions sharing it.
2230 380 : let timeout_ms = endpoint_timeout_ms(ep);
2231 380 : let first = if refused {
2232 4 : Err((false, "refused by egress policy".to_owned()))
2233 : } else {
2234 376 : send_outbound(st, uri, timeout_ms, &outbound).await
2235 : };
2236 306 : let (ok, timed_out) = match &first {
2237 216 : Ok(()) => (true, false),
2238 90 : Err((t, _)) => (false, *t),
2239 : };
2240 306 : if policed && !refused {
2241 298 : if ok {
2242 212 : st.egress.record_success(tenant.as_str(), uri);
2243 212 : } else if timed_out {
2244 36 : st.egress.record_failure(tenant.as_str(), uri);
2245 50 : } else {
2246 50 : // the destination responded (or refused fast): alive — clear
2247 50 : // any stale consecutive-timeout state
2248 50 : st.egress.record_success(tenant.as_str(), uri);
2249 50 : }
2250 8 : }
2251 : // Delivery counters by binding (facade — no-op without the broker's
2252 : // recorder). The label is the sink's first scheme, so the two members of
2253 : // a family share one series.
2254 306 : let scheme = st
2255 306 : .sinks
2256 306 : .sink_for_uri(uri)
2257 306 : .and_then(|s| s.schemes().first().copied())
2258 306 : .unwrap_or("unknown");
2259 306 : if ok {
2260 216 : metrics::counter!("antares_notifications_sent_total", "scheme" => scheme).increment(1);
2261 216 : } else {
2262 90 : metrics::counter!("antares_notifications_failed_total", "scheme" => scheme).increment(1);
2263 90 : }
2264 306 : if !ok {
2265 : // 5.8.6 / 5.11.7: subscription status → "failed" on delivery failure;
2266 : // roll back the optimistic lastSuccess stamp.
2267 90 : let ts = now_iso();
2268 90 : let mut failed_doc: Option<Value> = None;
2269 90 : st.store
2270 90 : .mutate(tenant, kind, &sub_id, |doc| {
2271 90 : if let Some(o) = doc.as_object_mut() {
2272 90 : o.insert("status".into(), Value::String("failed".into()));
2273 90 : }
2274 90 : if let Some(n) = doc
2275 90 : .as_object_mut()
2276 90 : .and_then(|o| o.get_mut("notification"))
2277 90 : .and_then(Value::as_object_mut)
2278 : {
2279 90 : match prev_success.take() {
2280 0 : Some(v) => n.insert("lastSuccess".into(), v),
2281 90 : None => n.remove("lastSuccess"),
2282 : };
2283 90 : n.insert("lastFailure".into(), Value::String(ts.clone()));
2284 : // Table 5.2.14.2-1 timesFailed: "Number of times an
2285 : // unsuccessful response (or timeout) has been received
2286 : // when delivering the notification" — an output-only
2287 : // member implementations shall generate.
2288 90 : let failed = n.get("timesFailed").and_then(Value::as_i64).unwrap_or(0);
2289 90 : n.insert("timesFailed".into(), json!(failed + 1));
2290 90 : n.insert("status".into(), Value::String("failed".into()));
2291 0 : }
2292 90 : failed_doc = Some(doc.clone());
2293 90 : Ok::<(), antares_model::NgsiError>(())
2294 90 : })
2295 90 : .await
2296 90 : .unwrap_or_else(|e| {
2297 0 : tracing::warn!("failure-status writeback failed: {e}");
2298 0 : None
2299 0 : });
2300 90 : mirror_bookkeeping(st, tenant, kind, &sub_id, failed_doc);
2301 : // Retries are transport, not new notifications: they run on their
2302 : // own task (never on the request path, never delaying another
2303 : // subscription's delivery) and book only the final outcome — a
2304 : // success sets lastSuccess/status ok without touching timesSent;
2305 : // an exhausted policy leaves a dead letter.
2306 : #[cfg(not(target_arch = "wasm32"))]
2307 90 : if !refused && st.delivery.attempts > 1 {
2308 16 : let first_err = first.err().map(|(_, e)| e).unwrap_or_default();
2309 16 : let (st, tenant, uri) = (st.clone(), tenant.clone(), uri.to_owned());
2310 16 : crate::spawn(async move {
2311 16 : retry_and_settle(
2312 16 : &st, &tenant, kind, &sub_id, &uri, timeout_ms, outbound, first_err,
2313 16 : )
2314 16 : .await;
2315 12 : });
2316 74 : }
2317 216 : }
2318 381 : }
2319 :
2320 : /// A `KeyValuePair[]` member of `endpoint` (Table 5.2.15-1) as owned pairs.
2321 : /// A member that is not an array of well-formed pairs contributes nothing:
2322 : /// 5.2.12 validation at creation already refused a malformed one.
2323 768 : fn kv_pairs(v: Option<&Value>) -> Vec<(String, String)> {
2324 768 : v.and_then(Value::as_array)
2325 768 : .map(|a| {
2326 4 : a.iter()
2327 10 : .filter_map(|kv| {
2328 : Some((
2329 10 : kv.get("key")?.as_str()?.to_owned(),
2330 10 : kv.get("value")?.as_str()?.to_owned(),
2331 : ))
2332 10 : })
2333 4 : .collect()
2334 4 : })
2335 768 : .unwrap_or_default()
2336 768 : }
2337 :
2338 : /// 6.3.22 / 6.3.8: `NGSILD-Tenant` and `NGSILD-Snapshot` on a notification
2339 : /// are the broker's own statement of where the data came from, appended to
2340 : /// the `receiverInfo` pairs. The HTTP binding appends every pair it is
2341 : /// handed, so a subscriber naming one of the two in `receiverInfo` would
2342 : /// put a second value of it on the wire beside the broker's, and a receiver
2343 : /// reading "the" tenant of a notification could not tell which one the
2344 : /// broker meant. Ordinary custom headers are untouched.
2345 388 : fn strip_reserved_markers(info: &mut Vec<(String, String)>) {
2346 388 : info.retain(|(k, _)| {
2347 30 : !k.eq_ignore_ascii_case("NGSILD-Tenant") && !k.eq_ignore_ascii_case("NGSILD-Snapshot")
2348 30 : });
2349 388 : }
2350 :
2351 : /// One attempt on the wire, through the binding the registry holds for the
2352 : /// endpoint's scheme (6.3.8). `Err((timed_out, why))`: only a timeout-class
2353 : /// failure feeds the breaker — an endpoint that answers is alive.
2354 394 : async fn send_outbound(
2355 394 : st: &AppState,
2356 394 : uri: &str,
2357 394 : timeout_ms: u32,
2358 394 : outbound: &Outbound,
2359 394 : ) -> Result<(), (bool, String)> {
2360 394 : let Some(sink) = st.sinks.sink_for_uri(uri) else {
2361 2 : return Err((
2362 2 : false,
2363 2 : format!(
2364 2 : "no notification binding registered for {}",
2365 2 : redact_userinfo(uri)
2366 2 : ),
2367 2 : ));
2368 : };
2369 392 : sink.deliver(
2370 392 : uri,
2371 392 : outbound,
2372 392 : std::time::Duration::from_millis(u64::from(timeout_ms)),
2373 392 : )
2374 392 : .await
2375 318 : .map_err(|e| (e.timed_out, e.message))
2376 320 : }
2377 :
2378 : static DEAD_LETTERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2379 :
2380 : /// Dead letters written by this process since start (`/q/health`
2381 : /// deadLetters); the letters themselves live in the store.
2382 98 : pub(crate) fn dead_letters_written() -> u64 {
2383 98 : DEAD_LETTERS.load(std::sync::atomic::Ordering::Relaxed)
2384 98 : }
2385 :
2386 : /// The retries the delivery policy grants after a failed first attempt,
2387 : /// then the settlement: lastSuccess/status ok on success, a dead letter
2388 : /// when the policy is exhausted.
2389 : #[cfg(not(target_arch = "wasm32"))]
2390 : #[allow(clippy::too_many_arguments)] // one param per piece of the attempt's state
2391 16 : async fn retry_and_settle(
2392 16 : st: &AppState,
2393 16 : tenant: &TenantId,
2394 16 : kind: Kind,
2395 16 : sub_id: &str,
2396 16 : uri: &str,
2397 16 : timeout_ms: u32,
2398 16 : outbound: Outbound,
2399 16 : first_err: String,
2400 16 : ) {
2401 16 : let policy = st.delivery;
2402 16 : let started = std::time::Instant::now();
2403 16 : let first_at = now_iso();
2404 16 : let mut made = 1u32;
2405 16 : let mut last_err = first_err.clone();
2406 24 : while let Some(delay) = policy.next_delay(made, started.elapsed()) {
2407 20 : tokio::time::sleep(delay).await;
2408 : // the subscription may have gone, or its endpoint may have tripped
2409 : // the breaker meanwhile — a retry is still one more attempt
2410 16 : if st
2411 16 : .store
2412 16 : .get(tenant, kind, sub_id)
2413 16 : .await
2414 16 : .ok()
2415 16 : .flatten()
2416 16 : .is_none()
2417 : {
2418 4 : return;
2419 12 : }
2420 12 : made += 1;
2421 12 : match send_outbound(st, uri, timeout_ms, &outbound).await {
2422 : Ok(()) => {
2423 4 : st.egress.record_success(tenant.as_str(), uri);
2424 4 : metrics::counter!("antares_notifications_retried_total", "outcome" => "ok")
2425 4 : .increment(1);
2426 4 : let ts = now_iso();
2427 4 : let mut retried_doc: Option<Value> = None;
2428 4 : st.store
2429 4 : .mutate(tenant, kind, sub_id, |doc| {
2430 4 : if let Some(o) = doc.as_object_mut() {
2431 4 : o.remove("status");
2432 4 : }
2433 4 : if let Some(n) = doc
2434 4 : .as_object_mut()
2435 4 : .and_then(|o| o.get_mut("notification"))
2436 4 : .and_then(Value::as_object_mut)
2437 4 : {
2438 4 : n.insert("lastSuccess".into(), Value::String(ts.clone()));
2439 4 : n.insert("status".into(), Value::String("ok".into()));
2440 4 : }
2441 4 : retried_doc = Some(doc.clone());
2442 4 : Ok::<(), antares_model::NgsiError>(())
2443 4 : })
2444 4 : .await
2445 4 : .unwrap_or_else(|e| {
2446 0 : tracing::warn!("retry bookkeeping writeback failed: {e}");
2447 0 : None
2448 0 : });
2449 4 : mirror_bookkeeping(st, tenant, kind, sub_id, retried_doc);
2450 4 : return;
2451 : }
2452 8 : Err((timed_out, e)) => {
2453 8 : if timed_out {
2454 0 : st.egress.record_failure(tenant.as_str(), uri);
2455 8 : } else {
2456 8 : st.egress.record_success(tenant.as_str(), uri);
2457 8 : }
2458 8 : last_err = e;
2459 : }
2460 : }
2461 : }
2462 4 : metrics::counter!("antares_notifications_retried_total", "outcome" => "dead").increment(1);
2463 4 : let letter = dead_letter(
2464 4 : sub_id, uri, timeout_ms, &outbound, made, &first_err, &last_err, &first_at,
2465 : );
2466 4 : let id = letter["id"].as_str().unwrap_or_default().to_owned();
2467 4 : match st.store.create(tenant, Kind::DeadLetter, &id, letter).await {
2468 : Ok(_) => {
2469 4 : DEAD_LETTERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2470 4 : tracing::warn!(
2471 : "notification for {sub_id} to {} dead-lettered after {made} attempts: {last_err}",
2472 0 : redact_userinfo(uri)
2473 : );
2474 : }
2475 0 : Err(e) => tracing::error!("dead letter for {sub_id} could not be stored: {e}"),
2476 : }
2477 12 : }
2478 :
2479 : /// The dead-letter document: everything a replay needs to send the very
2480 : /// same request again, plus the attempt history.
2481 : #[allow(clippy::too_many_arguments)] // one param per stored letter member
2482 4 : fn dead_letter(
2483 4 : sub_id: &str,
2484 4 : uri: &str,
2485 4 : timeout_ms: u32,
2486 4 : outbound: &Outbound,
2487 4 : attempts: u32,
2488 4 : first_err: &str,
2489 4 : last_err: &str,
2490 4 : first_at: &str,
2491 4 : ) -> Value {
2492 4 : let mut doc = json!({
2493 4 : "id": format!("urn:ngsi-ld:DeadLetter:{}", uuid::Uuid::new_v4()),
2494 4 : "type": "DeadLetter",
2495 4 : "subscriptionId": sub_id,
2496 4 : "uri": uri,
2497 4 : "timeoutMs": timeout_ms,
2498 4 : "attempts": attempts,
2499 4 : "firstError": first_err,
2500 4 : "lastError": last_err,
2501 4 : "firstAt": first_at,
2502 4 : "lastAt": now_iso(),
2503 : });
2504 4 : doc["binding"] = json!(antares_notifier::SinkRegistry::scheme_of(uri));
2505 4 : doc["payload"] = outbound.body.clone();
2506 4 : doc["accept"] = json!(outbound.accept);
2507 4 : doc["link"] = json!(outbound.link);
2508 4 : doc["receiverInfo"] = json!(outbound.receiver_info);
2509 4 : doc["notifierInfo"] = json!(outbound.notifier_info);
2510 4 : doc
2511 4 : }
2512 :
2513 : /// Replay one dead letter through the same binding, once. `Ok` = delivered
2514 : /// (the caller deletes the letter); `Err` carries the failure text.
2515 12 : pub(crate) async fn replay_dead_letter(st: &AppState, letter: &Value) -> Result<(), String> {
2516 12 : let uri = letter["uri"].as_str().ok_or("dead letter without uri")?;
2517 10 : let timeout_ms = letter["timeoutMs"].as_u64().unwrap_or(5_000) as u32;
2518 10 : let outbound = Outbound::from_dead_letter(letter)?;
2519 : // the egress policy of the moment applies, exactly as for a fresh send
2520 8 : if st.sinks.sink_for_uri(uri).is_some_and(|s| s.network()) {
2521 6 : st.egress
2522 6 : .check_destination(uri)
2523 6 : .await
2524 6 : .map_err(|e| e.to_string())?;
2525 2 : }
2526 6 : send_outbound(st, uri, timeout_ms, &outbound)
2527 6 : .await
2528 6 : .map_err(|(_, e)| e)
2529 12 : }
2530 :
2531 : pub(crate) use antares_notifier::{redact_userinfo, Outbound};
2532 :
2533 : /// The matcher reads subscriptions from the
2534 : /// SubMirror, so every notification bookkeeping writeback must be applied
2535 : /// there too — otherwise the mirror copy never gains
2536 : /// `notification.lastNotification` and 5.2.12 `throttling` suppresses
2537 : /// nothing. In-process apply only: a KV write per notification would not
2538 : /// scale to the 100k-sub target, so in bus=nats multi-pod deployments the
2539 : /// throttling window is per-pod approximate.
2540 : /// Known ceiling: exact distributed throttling = per-notification KV sync or a
2541 : /// store read in `throttled()`; add if a deployment needs the strict window.
2542 : /// The mirror learns the counters from the document the writeback just
2543 : /// committed under the row lock; `None` (no row) leaves it untouched.
2544 474 : fn mirror_bookkeeping(
2545 474 : st: &AppState,
2546 474 : tenant: &TenantId,
2547 474 : kind: Kind,
2548 474 : sub_id: &str,
2549 474 : doc: Option<Value>,
2550 474 : ) {
2551 474 : if kind != Kind::Subscription {
2552 4 : return;
2553 470 : }
2554 470 : if let (Some(m), Some(doc)) = (&st.sub_mirror, doc) {
2555 330 : m.apply(tenant.as_str(), sub_id, Some(doc));
2556 330 : }
2557 474 : }
2558 :
2559 : #[cfg(all(test, not(target_arch = "wasm32")))]
2560 : mod deleted_subscription_delivery {
2561 : use super::*;
2562 :
2563 : /// 5.8.6: notifications are sent for the subscriptions a Context Broker
2564 : /// holds — a subscription deleted between matching and delivery no
2565 : /// longer exists, so its endpoint must receive nothing.
2566 : #[tokio::test(flavor = "multi_thread")]
2567 4 : async fn deleted_subscription_receives_no_notification() {
2568 4 : crate::allow_private();
2569 4 : let st = AppState::new("antares-deleted-sub-test".into());
2570 4 : let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
2571 4 : let c = count.clone();
2572 4 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2573 4 : .await
2574 4 : .expect("bind");
2575 4 : let addr = listener.local_addr().expect("addr");
2576 4 : let app = axum::Router::new().route(
2577 4 : "/notify",
2578 4 : axum::routing::post(move || {
2579 0 : let c = c.clone();
2580 0 : async move {
2581 0 : c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2582 0 : axum::http::StatusCode::OK
2583 0 : }
2584 0 : }),
2585 : );
2586 4 : tokio::spawn(async move {
2587 4 : axum::serve(listener, app).await.expect("serve");
2588 0 : });
2589 4 : let tenant = TenantId::new("default").expect("tenant");
2590 4 : let ctx = antares_jsonld::Loader::new().core();
2591 : // the sub doc is a snapshot whose row is NOT in the store — the
2592 : // deleted-concurrently case
2593 4 : let sub = json!({
2594 4 : "id": "urn:ngsi-ld:Subscription:ghost",
2595 4 : "type": "Subscription",
2596 4 : "entities": [{"type": "Vehicle"}],
2597 4 : "notification": {"endpoint": {"uri": format!("http://{addr}/notify")}},
2598 : });
2599 4 : let data = vec![json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle"})];
2600 4 : deliver_as(
2601 4 : &st,
2602 4 : &tenant,
2603 4 : Kind::Subscription,
2604 4 : &sub,
2605 4 : "Notification",
2606 4 : data,
2607 4 : &ctx,
2608 4 : None,
2609 4 : )
2610 4 : .await;
2611 4 : assert_eq!(
2612 4 : count.load(std::sync::atomic::Ordering::SeqCst),
2613 4 : 0,
2614 4 : "a deleted subscription's endpoint must receive NO notification"
2615 4 : );
2616 4 : }
2617 : }
2618 :
2619 : #[cfg(test)]
2620 : mod interval_tests {
2621 : use super::*;
2622 : use serde_json::json;
2623 :
2624 : /// 5.2.12 Table 5.2.12-1 bounds `Subscription.timeInterval` only as
2625 : /// "greater than 0", so a client may name an interval whose milliseconds
2626 : /// do not fit in the epoch arithmetic. A firing instant the broker cannot
2627 : /// represent must read as far in the future, never as far in the past: a
2628 : /// wrapped anchor is a NEGATIVE instant, which reports the subscription
2629 : /// as permanently due and fires it on every tick, each firing running the
2630 : /// subscription's whole query.
2631 : #[test]
2632 4 : fn an_interval_too_large_to_schedule_is_never_due_rather_than_always() {
2633 4 : let sub = json!({
2634 4 : "id": "urn:ngsi-ld:Subscription:huge",
2635 4 : "type": "Subscription",
2636 4 : "createdAt": "2026-01-01T00:00:00Z",
2637 : });
2638 4 : let now = chrono::Utc::now().timestamp_millis();
2639 12 : for interval in [1e18, 1e30, f64::MAX] {
2640 12 : let due = due_at_ms(&sub, interval);
2641 12 : assert!(
2642 12 : due > now,
2643 : "timeInterval {interval} is due at {due}, which is not after {now}"
2644 : );
2645 : }
2646 : // and an interval that DOES fit still schedules exactly one interval
2647 : // past the anchor, so the guard costs the ordinary case nothing
2648 4 : let anchor = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
2649 4 : .expect("anchor")
2650 4 : .timestamp_millis();
2651 4 : assert_eq!(due_at_ms(&sub, 30.0), anchor + 30_000);
2652 4 : }
2653 :
2654 : /// A subscriber cannot put its own value of a marker the broker sets on
2655 : /// the wire: the pair is dropped before the broker appends its own, and
2656 : /// the drop is by ASCII case-insensitive name, since a header name is
2657 : /// case-insensitive (IETF RFC 9110 clause 5.1) and the pair would
2658 : /// otherwise slip past under a different spelling.
2659 : #[test]
2660 4 : fn a_subscriber_cannot_add_its_own_notification_markers() {
2661 4 : let mut info = vec![
2662 4 : ("Authorization".to_owned(), "Bearer t".to_owned()),
2663 4 : ("ngsild-tenant".to_owned(), "victim".to_owned()),
2664 4 : ("NGSILD-Tenant".to_owned(), "victim".to_owned()),
2665 4 : (
2666 4 : "NGSILD-SNAPSHOT".to_owned(),
2667 4 : "urn:ngsi-ld:Snapshot:x".to_owned(),
2668 4 : ),
2669 4 : ("X-NGSILD-Tenant".to_owned(), "kept".to_owned()),
2670 : ];
2671 4 : strip_reserved_markers(&mut info);
2672 4 : assert_eq!(
2673 : info,
2674 4 : [
2675 4 : ("Authorization".to_owned(), "Bearer t".to_owned()),
2676 4 : ("X-NGSILD-Tenant".to_owned(), "kept".to_owned()),
2677 4 : ]
2678 : );
2679 4 : }
2680 :
2681 : /// The sweep's own clock takes the same offset, so the same overflow
2682 : /// would park (or un-park) every tenant's sweep, not just this
2683 : /// subscription: `next_sub`/`next_csub` are process-wide minima.
2684 : #[test]
2685 4 : fn the_sweep_clock_offset_survives_an_unschedulable_interval() {
2686 4 : let now_ms = chrono::Utc::now().timestamp_millis();
2687 8 : for interval in [1e18, f64::MAX] {
2688 8 : let next = now_ms.saturating_add(interval_offset_ms(interval));
2689 8 : assert!(next > now_ms, "sweep clock went backwards for {interval}");
2690 : }
2691 4 : assert_eq!(interval_offset_ms(1.5), 1500);
2692 4 : }
2693 : }
2694 :
2695 : #[cfg(test)]
2696 : mod endpoint_tests {
2697 : use super::*;
2698 : use serde_json::json;
2699 :
2700 20 : fn ep(v: Value) -> serde_json::Map<String, Value> {
2701 20 : v.as_object().expect("map").clone()
2702 20 : }
2703 :
2704 : /// Clause 4.21 + Table 5.2.14.1-1: notification `pick`/`omit` are
2705 : /// "a valid attribute projection language string as per clause 4.21", so a
2706 : /// LinkedEntityTerm (`ProjectionTerm = AttrName *1(LinkedEntityTerm)`) must
2707 : /// constrain the Linked Entity, exactly as it does on the query path.
2708 : #[test]
2709 4 : fn notification_projection_parses_linked_entity_terms() {
2710 4 : let ctx = antares_jsonld::Loader::new().core();
2711 4 : let sub = json!({
2712 4 : "notification": { "pick": ["id", "type", "refDevice{type}"] }
2713 : });
2714 4 : let shape = notif_shape(&sub, &ctx);
2715 4 : let pick = shape.repr.pick.expect("pick parsed");
2716 :
2717 12 : let linked = pick.iter().find(|n| n.raw == "refDevice").expect(
2718 4 : "refDevice must survive as its own term, not as the literal \"refDevice{type}\"",
2719 : );
2720 4 : let children = linked
2721 4 : .children
2722 4 : .as_ref()
2723 4 : .expect("the {…} term must become children so the Linked Entity is constrained");
2724 4 : assert!(
2725 4 : children.iter().any(|c| c.raw == "type"),
2726 : "refDevice{{type}} must select `type` inside the Linked Entity"
2727 : );
2728 4 : }
2729 :
2730 : /// Table 5.2.15-1 `timeout`: honored, clamped, defaulted.
2731 : #[test]
2732 4 : fn endpoint_timeout_is_honored_clamped_and_defaulted() {
2733 4 : assert_eq!(endpoint_timeout_ms(&ep(json!({"timeout": 1500}))), 1500);
2734 4 : assert_eq!(endpoint_timeout_ms(&ep(json!({}))), 5_000, "default");
2735 : // "The NGSI-LD system can override this value" — the clamp is that
2736 : // override, keeping delivery tasks bounded
2737 4 : assert_eq!(endpoint_timeout_ms(&ep(json!({"timeout": 600000}))), 30_000);
2738 4 : assert_eq!(endpoint_timeout_ms(&ep(json!({"timeout": 1}))), 100);
2739 : // creation rejects <=0, but a hand-edited row must not panic
2740 4 : assert_eq!(endpoint_timeout_ms(&ep(json!({"timeout": -5}))), 5_000);
2741 4 : }
2742 :
2743 : /// Table 5.2.15-1 `cooldown`: gate opens only after a
2744 : /// failure and closes once the window elapses or a success lands.
2745 : #[test]
2746 4 : fn cooldown_gates_only_failed_subscriptions_within_the_window() {
2747 4 : let now = chrono::Utc::now();
2748 4 : let recent = (now - chrono::Duration::milliseconds(500)).to_rfc3339();
2749 4 : let old = (now - chrono::Duration::milliseconds(5_000)).to_rfc3339();
2750 12 : let sub = |status: &str, last_failure: &str| {
2751 12 : json!({
2752 12 : "notification": {
2753 12 : "status": status,
2754 12 : "lastFailure": last_failure,
2755 12 : "endpoint": {"uri": "http://x/n", "cooldown": 2000}
2756 : }
2757 : })
2758 12 : };
2759 4 : assert!(
2760 4 : in_cooldown(&sub("failed", &recent), now),
2761 : "failed 0.5s ago, 2s cooldown ⇒ suppressed"
2762 : );
2763 4 : assert!(
2764 4 : !in_cooldown(&sub("failed", &old), now),
2765 : "failure outside the window ⇒ delivered"
2766 : );
2767 4 : assert!(
2768 4 : !in_cooldown(&sub("ok", &recent), now),
2769 : "a success clears the gate — status is not \"failed\""
2770 : );
2771 4 : let no_cooldown = json!({
2772 4 : "notification": {
2773 4 : "status": "failed", "lastFailure": recent,
2774 4 : "endpoint": {"uri": "http://x/n"}
2775 : }
2776 : });
2777 4 : assert!(
2778 4 : !in_cooldown(&no_cooldown, now),
2779 : "no cooldown member ⇒ no gate"
2780 : );
2781 4 : }
2782 : }
2783 :
2784 : #[cfg(test)]
2785 : mod clause_5_8_1 {
2786 : use super::*;
2787 :
2788 : /// 5.8.1.4: "the status of the Subscription changes automatically to
2789 : /// \"expired\", so that notifications will no longer be sent" — an
2790 : /// expiresAt spelled without a seconds fraction must count as expired
2791 : /// the moment now (spelled with milliseconds) passes it. A raw
2792 : /// lexicographic compare ranks 'Z' above '.' and keeps the
2793 : /// subscription alive for the whole boundary second.
2794 : #[test]
2795 4 : fn expiry_compare_survives_fraction_spellings() {
2796 4 : let now = crate::state::now_iso();
2797 4 : let secs = &now[..19];
2798 4 : std::thread::sleep(std::time::Duration::from_millis(5));
2799 4 : let sub = serde_json::json!({ "expiresAt": format!("{secs}Z") });
2800 4 : assert!(
2801 4 : !is_active(&sub),
2802 : "expiresAt {secs}Z lies in the past and must expire the subscription"
2803 : );
2804 : // a genuinely future expiry stays active
2805 4 : let sub = serde_json::json!({ "expiresAt": "2999-01-01T00:00:00Z" });
2806 4 : assert!(is_active(&sub));
2807 4 : }
2808 : }
2809 :
2810 : #[cfg(test)]
2811 : mod clause_5_3_3 {
2812 : use super::*;
2813 : use serde_json::json;
2814 :
2815 : /// 5.3.2 triggerReason + 5.3.3 TriggerReasonEnumeration: newlyMatching
2816 : /// (did not match -> matches), updated (matched -> still matches),
2817 : /// noLongerMatching (matched -> no longer / deleted); no notification
2818 : /// when neither side matches.
2819 : #[test]
2820 4 : fn trigger_reason_enumeration() {
2821 4 : let ctx = antares_jsonld::Loader::new().core();
2822 4 : let sub = json!({"entities": [
2823 4 : {"type": "https://uri.etsi.org/ngsi-ld/default-context/Building"}]});
2824 8 : let reg = |t: &str| {
2825 8 : json!({"id": "urn:csr:1", "type": "ContextSourceRegistration",
2826 8 : "endpoint": "http://peer:9090",
2827 8 : "information": [{"entities": [
2828 8 : {"type": format!("https://uri.etsi.org/ngsi-ld/default-context/{t}")}]}]})
2829 8 : };
2830 4 : let hit = reg("Building");
2831 4 : let miss = reg("Vehicle");
2832 4 : assert_eq!(
2833 4 : csource_trigger(&sub, None, Some(&hit), &ctx),
2834 : Some("newlyMatching")
2835 : );
2836 4 : assert_eq!(
2837 4 : csource_trigger(&sub, Some(&miss), Some(&hit), &ctx),
2838 : Some("newlyMatching"),
2839 : "an update that STARTS matching is newlyMatching"
2840 : );
2841 4 : assert_eq!(
2842 4 : csource_trigger(&sub, Some(&hit), Some(&hit), &ctx),
2843 : Some("updated")
2844 : );
2845 4 : assert_eq!(
2846 4 : csource_trigger(&sub, Some(&hit), None, &ctx),
2847 : Some("noLongerMatching"),
2848 : "deletion of a matching registration"
2849 : );
2850 4 : assert_eq!(
2851 4 : csource_trigger(&sub, Some(&hit), Some(&miss), &ctx),
2852 : Some("noLongerMatching"),
2853 : "an update that STOPS matching"
2854 : );
2855 4 : assert_eq!(
2856 4 : csource_trigger(&sub, Some(&miss), Some(&miss), &ctx),
2857 : None,
2858 : "never-matching changes produce no notification"
2859 : );
2860 4 : }
2861 : }
2862 :
2863 : #[cfg(test)]
2864 : mod clause_5_2_33 {
2865 : use super::*;
2866 : use serde_json::json;
2867 :
2868 : /// Table 5.2.33-1: id is String or String[]; "id takes precedence over
2869 : /// idPattern" when a selector carries both.
2870 : #[test]
2871 4 : fn selector_id_array_and_precedence() {
2872 4 : let ctx = antares_jsonld::Loader::new().core();
2873 4 : let doc = json!({"id": "urn:x:A", "type": ["T"]});
2874 16 : let sub = |e: serde_json::Value| json!({"entities": [e]});
2875 4 : assert!(!selector_match(
2876 4 : &sub(json!({"type": "T", "idPattern": "^urn:x:B"})),
2877 4 : &doc,
2878 4 : &ctx
2879 4 : ));
2880 4 : assert!(
2881 4 : selector_match(
2882 4 : &sub(json!({"type": "T", "id": "urn:x:A", "idPattern": "^urn:x:B"})),
2883 4 : &doc,
2884 4 : &ctx
2885 : ),
2886 : "id takes precedence over idPattern"
2887 : );
2888 4 : assert!(selector_match(
2889 4 : &sub(json!({"type": "T", "id": ["urn:x:A", "urn:x:C"]})),
2890 4 : &doc,
2891 4 : &ctx
2892 : ));
2893 4 : assert!(
2894 4 : !selector_match(&sub(json!({"type": "T", "id": ["urn:x:B"]})), &doc, &ctx),
2895 : "an id array not containing the entity id must not match"
2896 : );
2897 4 : }
2898 : }
2899 :
2900 : /// Availability of the change→notification pipeline itself: the consumer
2901 : /// task must survive a panic, and its queue must stay bounded.
2902 : #[cfg(all(test, not(target_arch = "wasm32")))]
2903 : mod change_pipeline {
2904 : use super::*;
2905 : use serde_json::json;
2906 : use std::sync::atomic::{AtomicUsize, Ordering};
2907 : use std::sync::Arc;
2908 : use tower::ServiceExt;
2909 :
2910 4228 : async fn post(st: &AppState, uri: &str, body: Value) -> u16 {
2911 4228 : let body = body.to_string();
2912 4228 : crate::router(st.clone())
2913 4228 : .oneshot(
2914 4228 : axum::http::Request::builder()
2915 4228 : .method("POST")
2916 4228 : .uri(uri)
2917 4228 : .header("Content-Type", "application/json")
2918 4228 : .header("Content-Length", body.len())
2919 4228 : .body(axum::body::Body::from(body))
2920 4228 : .expect("request"),
2921 4228 : )
2922 4228 : .await
2923 4228 : .expect("response")
2924 4228 : .status()
2925 4228 : .as_u16()
2926 4228 : }
2927 :
2928 : /// An endpoint that answers 200 and counts the notifications it got.
2929 20 : async fn counting_endpoint() -> (String, Arc<AtomicUsize>) {
2930 20 : let hits: Arc<AtomicUsize> = Arc::default();
2931 20 : let seen = hits.clone();
2932 20 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
2933 20 : .await
2934 20 : .expect("bind");
2935 20 : let addr = listener.local_addr().expect("addr");
2936 20 : let app = axum::Router::new().route(
2937 20 : "/notify",
2938 100 : axum::routing::post(move || {
2939 100 : let seen = seen.clone();
2940 100 : async move {
2941 100 : seen.fetch_add(1, Ordering::SeqCst);
2942 100 : axum::http::StatusCode::OK
2943 100 : }
2944 100 : }),
2945 : );
2946 20 : tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
2947 20 : (format!("http://{addr}/notify"), hits)
2948 20 : }
2949 :
2950 16 : async fn subscribe(st: &AppState, id: &str, uri: &str, timeout_ms: u32) {
2951 16 : let status = post(
2952 16 : st,
2953 16 : "/ngsi-ld/v1/subscriptions",
2954 16 : json!({
2955 16 : "id": format!("urn:ngsi-ld:Subscription:{id}"),
2956 16 : "type": "Subscription",
2957 16 : "entities": [{"type": "Vehicle"}],
2958 16 : "notification": {"endpoint": {"uri": uri, "timeout": timeout_ms}},
2959 16 : }),
2960 16 : )
2961 16 : .await;
2962 16 : assert_eq!(status, 201, "subscription created");
2963 16 : }
2964 :
2965 4204 : async fn create_vehicle(st: &AppState, n: usize) -> u16 {
2966 4204 : post(
2967 4204 : st,
2968 4204 : "/ngsi-ld/v1/entities",
2969 4204 : json!({
2970 4204 : "id": format!("urn:ngsi-ld:Vehicle:pipe{n}"),
2971 4204 : "type": "Vehicle",
2972 4204 : "speed": {"type": "Property", "value": n},
2973 4204 : }),
2974 4204 : )
2975 4204 : .await
2976 4204 : }
2977 :
2978 : /// Table 5.2.14.2-1 timesSent / lastNotification: the mirror the matcher
2979 : /// reads carries the counters the delivery writeback committed, and the
2980 : /// store row agrees — one writeback, no second read.
2981 : #[tokio::test(flavor = "multi_thread")]
2982 4 : async fn mirror_carries_the_booked_counters() {
2983 4 : crate::allow_private();
2984 4 : let (uri, hits) = counting_endpoint().await;
2985 4 : let mut st = AppState::new("antares-mirror-booked".into());
2986 4 : crate::wire(&mut st).await;
2987 4 : subscribe(&st, "booked", &uri, 30_000).await;
2988 4 : assert_eq!(create_vehicle(&st, 7).await, 201);
2989 4 : let deadline = std::time::Instant::now()
2990 4 : + std::time::Duration::from_secs(10 * crate::state::slow_factor());
2991 8 : while hits.load(Ordering::SeqCst) < 1 && std::time::Instant::now() < deadline {
2992 4 : tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2993 : }
2994 4 : assert_eq!(hits.load(Ordering::SeqCst), 1, "one notification delivered");
2995 4 : let id = "urn:ngsi-ld:Subscription:booked";
2996 4 : let mirrored = st
2997 4 : .sub_mirror
2998 4 : .as_ref()
2999 4 : .expect("mirror wired")
3000 4 : .docs("default")
3001 4 : .into_iter()
3002 4 : .find(|d| d["id"] == id)
3003 4 : .expect("subscription mirrored");
3004 4 : assert_eq!(mirrored["notification"]["timesSent"], json!(1));
3005 4 : assert!(mirrored["notification"]["lastNotification"].is_string());
3006 4 : let tenant = TenantId::new("default").expect("tenant");
3007 4 : let stored = st
3008 4 : .store
3009 4 : .get(&tenant, Kind::Subscription, id)
3010 4 : .await
3011 4 : .expect("store")
3012 4 : .expect("row");
3013 4 : assert_eq!(stored["notification"]["timesSent"], json!(1));
3014 4 : assert_eq!(
3015 4 : stored["notification"]["lastNotification"],
3016 4 : mirrored["notification"]["lastNotification"]
3017 4 : );
3018 4 : }
3019 :
3020 : /// 5.8.6: a matching change notifies, and a panic around ONE change must
3021 : /// not end notification delivery for the process — the NEXT change still
3022 : /// notifies. The lock poisoning here used to make the matcher itself
3023 : /// panic; since the mirrors recover from poisoning, the first change may
3024 : /// legitimately deliver too. The contract under test is therefore that
3025 : /// the SECOND change's notification arrives — not a count, which only
3026 : /// measured the race between the two deliveries and failed either way on
3027 : /// slow machines.
3028 : #[tokio::test(flavor = "multi_thread")]
3029 4 : async fn panicking_change_does_not_stop_the_next_notification() {
3030 4 : crate::allow_private();
3031 4 : let (uri, hits) = counting_endpoint().await;
3032 4 : let mut st = AppState::new("antares-panic-guard".into());
3033 4 : crate::wire(&mut st).await;
3034 4 : subscribe(&st, "guard", &uri, 2_000).await;
3035 : // Poison the mirror lock while a change is in flight: whatever the
3036 : // matcher does with that (recover, or panic into the supervision
3037 : // boundary), the pipeline must keep delivering afterwards.
3038 4 : let mirror = st.sub_mirror.clone().expect("mirror");
3039 4 : mirror.poison();
3040 4 : assert_eq!(create_vehicle(&st, 1).await, 201);
3041 4 : assert_eq!(create_vehicle(&st, 2).await, 201);
3042 4 : let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3043 6 : while hits.load(Ordering::SeqCst) < 1 && std::time::Instant::now() < deadline {
3044 2 : tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3045 : }
3046 4 : let got = hits.load(Ordering::SeqCst);
3047 4 : assert!(
3048 4 : (1..=2).contains(&got),
3049 : "delivery must survive the poisoned change: expected 1 or 2 notifications, got {got}"
3050 : );
3051 : // and the pipeline is still alive for a THIRD change after the dust
3052 : // settles — the actual supervision contract
3053 4 : let before = hits.load(Ordering::SeqCst);
3054 4 : assert_eq!(create_vehicle(&st, 3).await, 201);
3055 4 : let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3056 6 : while hits.load(Ordering::SeqCst) <= before && std::time::Instant::now() < deadline {
3057 4 : tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3058 4 : }
3059 4 : assert!(
3060 4 : hits.load(Ordering::SeqCst) > before,
3061 4 : "a change created after the poisoned one must still notify"
3062 4 : );
3063 4 : }
3064 :
3065 : /// The matcher queue is bounded: behind a stalled subscriber the excess
3066 : /// changes are dropped and counted instead of growing without limit.
3067 : #[tokio::test(flavor = "multi_thread")]
3068 4 : async fn overflowing_change_queue_drops_and_counts() {
3069 : // stages a race between producer and a parked consumer; under a
3070 : // sanitizer's slowdown the producer never outruns the queue
3071 : // (same for the file store: fsync per create is slower than the drain)
3072 4 : if std::env::var_os("ANTARES_TEST_SANITIZER").is_some()
3073 4 : || std::env::var("ANTARES_TEST_STORE").is_ok_and(|s| s == "file")
3074 : {
3075 0 : return;
3076 4 : }
3077 4 : crate::allow_private();
3078 : // accepts, reads nothing, never answers — the serial consumer parks
3079 : // on the first delivery for the endpoint's whole timeout
3080 4 : let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
3081 4 : let addr = listener.local_addr().expect("addr");
3082 4 : std::thread::spawn(move || {
3083 4 : let mut held = Vec::new();
3084 4 : for s in listener.incoming().flatten() {
3085 4 : held.push(s);
3086 4 : }
3087 4 : });
3088 4 : let mut st = AppState::new("antares-queue-bound".into());
3089 4 : crate::wire(&mut st).await;
3090 : // the stall is bounded by the outbound client's own 5 s timeout, not
3091 : // by endpoint.timeout: after each timeout the consumer frees another
3092 : // CHANGE_BATCH slots, and on a slow runner a single-queue-depth loop
3093 : // fits inside that window and never overflows (seen in CI). Produce
3094 : // several queue depths and stop at the first counted drop — the
3095 : // producer only has to outpace the drain, not beat one window.
3096 4 : subscribe(&st, "staller", &format!("http://{addr}/notify"), 30_000).await;
3097 4 : let before = changes_dropped();
3098 4104 : for n in 0..(4 * CHANGE_QUEUE) {
3099 4104 : assert_eq!(create_vehicle(&st, 1_000 + n).await, 201);
3100 4104 : if changes_dropped() > before {
3101 4 : break;
3102 4100 : }
3103 4 : }
3104 4 : assert!(
3105 4 : changes_dropped() > before,
3106 4 : "a full matcher queue must drop and count, not grow (dropped {} → {})",
3107 4 : before,
3108 4 : changes_dropped()
3109 4 : );
3110 4 : }
3111 :
3112 : /// Every accepted change is counted until its pass has run, so a drain
3113 : /// that waits for zero closes the pool only after the last delivery.
3114 : #[tokio::test(flavor = "multi_thread")]
3115 4 : async fn pending_changes_return_to_zero_once_delivered() {
3116 4 : crate::allow_private();
3117 4 : let (uri, hits) = counting_endpoint().await;
3118 4 : let mut st = AppState::new("antares-pending-changes".into());
3119 4 : crate::wire(&mut st).await;
3120 4 : subscribe(&st, "counter", &uri, 30_000).await;
3121 80 : for n in 0..20 {
3122 80 : assert_eq!(create_vehicle(&st, 5_000 + n).await, 201);
3123 : }
3124 4 : let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3125 : // one pass folds queued changes into one notification, so the hit
3126 : // count is at least one, not twenty
3127 12 : let pending = || st.pending_changes.load(Ordering::SeqCst);
3128 8 : while (hits.load(Ordering::SeqCst) == 0 || pending() > 0)
3129 4 : && std::time::Instant::now() < deadline
3130 : {
3131 4 : tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3132 : }
3133 4 : assert!(hits.load(Ordering::SeqCst) > 0, "nothing delivered");
3134 4 : assert_eq!(
3135 4 : pending(),
3136 4 : 0,
3137 4 : "pending must fall back to zero after delivery"
3138 4 : );
3139 4 : }
3140 :
3141 : /// Table 5.2.12-1: "\"entityUpdated\" is equivalent to the combination
3142 : /// \"attributeCreated\", \"attributeUpdated\" and \"attributeDeleted\"",
3143 : /// so such a subscription notifies on a creation exactly like the
3144 : /// spelled-out list does — while "entityDeleted" alone does not.
3145 : #[tokio::test(flavor = "multi_thread")]
3146 4 : async fn entity_updated_trigger_notifies_on_entity_creation() {
3147 : // under a sanitizer 370 concurrent tests starve the endpoint past the
3148 : // outbound client's 5 s cap (seen twice in strict); triggers, not latency
3149 4 : if std::env::var_os("ANTARES_TEST_SANITIZER").is_some() {
3150 0 : return;
3151 4 : }
3152 4 : crate::allow_private();
3153 4 : let (uri, hits) = counting_endpoint().await;
3154 4 : let (quiet_uri, quiet_hits) = counting_endpoint().await;
3155 4 : let mut st = AppState::new("antares-trigger-equivalence".into());
3156 4 : crate::wire(&mut st).await;
3157 8 : let sub = |id: &str, trigger: &str, uri: &str| {
3158 8 : json!({
3159 8 : "id": format!("urn:ngsi-ld:Subscription:{id}"),
3160 8 : "type": "Subscription",
3161 8 : "entities": [{"type": "Vehicle"}],
3162 8 : "notificationTrigger": [trigger],
3163 : // 30 s: a sanitizer runner with 370 concurrent tests took
3164 : // 8 s to deliver once; this test is about triggers, not latency
3165 8 : "notification": {"endpoint": {"uri": uri, "timeout": 30_000}},
3166 : })
3167 8 : };
3168 8 : for body in [
3169 4 : sub("eu", "entityUpdated", &uri),
3170 4 : sub("ed", "entityDeleted", &quiet_uri),
3171 4 : ] {
3172 8 : assert_eq!(post(&st, "/ngsi-ld/v1/subscriptions", body).await, 201);
3173 : }
3174 4 : assert_eq!(create_vehicle(&st, 7).await, 201);
3175 : // a wall-clock bound, not an iteration count: a sanitizer build
3176 : // delivers the same notification an order of magnitude slower
3177 4 : let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
3178 8 : while hits.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
3179 4 : tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3180 : }
3181 : // the receiver's count alone cannot say WHY nothing arrived: the
3182 : // subscription's own bookkeeping and the pipeline counters can
3183 4 : let sub = st
3184 4 : .store
3185 4 : .get(
3186 4 : &TenantId::default(),
3187 4 : Kind::Subscription,
3188 4 : "urn:ngsi-ld:Subscription:eu",
3189 4 : )
3190 4 : .await
3191 4 : .ok()
3192 4 : .flatten()
3193 4 : .map(|s| s["notification"].to_string())
3194 4 : .unwrap_or_else(|| "subscription missing".into());
3195 4 : let diagnosis = || {
3196 0 : format!(
3197 : "notification={sub} changes_dropped={} task_panics={} dead_letters={}",
3198 0 : changes_dropped(),
3199 0 : task_panics(),
3200 0 : DEAD_LETTERS.load(Ordering::Relaxed)
3201 : )
3202 0 : };
3203 4 : assert_eq!(
3204 4 : hits.load(Ordering::SeqCst),
3205 : 1,
3206 : "entityUpdated implies attributeCreated, so a creation notifies; {}",
3207 0 : diagnosis()
3208 : );
3209 4 : assert_eq!(
3210 4 : quiet_hits.load(Ordering::SeqCst),
3211 4 : 0,
3212 4 : "entityDeleted carries no equivalence and must not fire on a creation"
3213 4 : );
3214 4 : }
3215 : }
3216 :
3217 : #[cfg(test)]
3218 : mod clause_5_2_12_triggers {
3219 : use super::*;
3220 : use serde_json::json;
3221 :
3222 : /// Table 5.2.12-1 notificationTrigger: "If not present, the default is
3223 : /// the combination \"attributeCreated\" and \"attributeUpdated\".
3224 : /// \"entityUpdated\" is equivalent to the combination
3225 : /// \"attributeCreated\", \"attributeUpdated\" and \"attributeDeleted\"."
3226 : #[test]
3227 4 : fn entity_updated_expands_to_its_equivalent_attribute_triggers() {
3228 20 : let has = |v: &[&str], t: &str| v.contains(&t);
3229 :
3230 4 : let empty = json!({});
3231 4 : let default = triggers_of(&empty);
3232 4 : assert_eq!(default, vec!["attributeCreated", "attributeUpdated"]);
3233 4 : assert!(
3234 4 : !has(&default, "attributeDeleted"),
3235 : "the default combination is two triggers, not three"
3236 : );
3237 :
3238 4 : let updated = json!({"notificationTrigger": ["entityUpdated"]});
3239 4 : let expanded = triggers_of(&updated);
3240 12 : for t in ["attributeCreated", "attributeUpdated", "attributeDeleted"] {
3241 12 : assert!(has(&expanded, t), "entityUpdated must imply {t}");
3242 : }
3243 4 : assert!(
3244 4 : has(&expanded, "entityUpdated"),
3245 : "the declared trigger itself survives the expansion"
3246 : );
3247 :
3248 : // Only entityUpdated carries the equivalence: the other two entity
3249 : // triggers must NOT gain attribute triggers.
3250 8 : for t in ["entityCreated", "entityDeleted"] {
3251 8 : let one = json!({ "notificationTrigger": [t] });
3252 8 : let only = triggers_of(&one);
3253 8 : assert_eq!(only, vec![t], "{t} is not an equivalence");
3254 : }
3255 :
3256 : // Idempotent: an explicit list that already spells the combination
3257 : // out gains nothing, and the equivalent forms agree.
3258 4 : let spelled = json!({"notificationTrigger":
3259 : ["entityUpdated", "attributeCreated", "attributeUpdated", "attributeDeleted"]});
3260 4 : let literal = triggers_of(&spelled);
3261 4 : assert_eq!(literal.len(), 4, "no duplicates are appended");
3262 4 : let mut a = expanded.clone();
3263 4 : let mut b = literal.clone();
3264 4 : a.sort();
3265 4 : b.sort();
3266 4 : assert_eq!(a, b, "[\"entityUpdated\"] == the spelled-out combination");
3267 4 : }
3268 :
3269 : /// Table 5.2.12-1 scopes watchedAttributes to "Watched Attributes
3270 : /// (Properties or Relationships)", so a write that only moves the
3271 : /// entity's own system members is no attribute-level change and must
3272 : /// raise no attributeCreated/attributeUpdated trigger.
3273 : #[test]
3274 4 : fn entity_system_members_are_not_attribute_changes() {
3275 4 : let before = json!({
3276 4 : "id": "urn:ngsi-ld:Vehicle:1",
3277 4 : "type": ["Vehicle"],
3278 4 : "expiresAt": "2030-01-01T00:00:00Z",
3279 : });
3280 4 : let after = json!({
3281 4 : "id": "urn:ngsi-ld:Vehicle:1",
3282 4 : "type": ["Vehicle"],
3283 4 : "expiresAt": "2031-01-01T00:00:00Z",
3284 4 : "deletedAt": "2031-01-01T00:00:00Z",
3285 4 : "modifiedAt": "2026-01-01T00:00:00Z",
3286 4 : "scope": "/a",
3287 : });
3288 4 : assert!(
3289 4 : diff(Some(&before), Some(&after)).is_empty(),
3290 : "entity-level system members are not Attributes"
3291 : );
3292 : // Positive control: a real Property change is still reported.
3293 4 : let mut with_attr = after.clone();
3294 4 : with_attr["speed"] = json!([{"type": "Property", "value": 1}]);
3295 4 : assert_eq!(
3296 4 : diff(Some(&before), Some(&with_attr)),
3297 4 : vec![("speed".to_owned(), ChangeClass::Created)]
3298 : );
3299 4 : }
3300 : }
3301 :
3302 : #[cfg(test)]
3303 : mod clause_4_5_7_instance_identity {
3304 : use super::*;
3305 : use serde_json::json;
3306 :
3307 76 : fn id_of(observed: Option<&str>, dataset: Option<&str>) -> String {
3308 76 : let mut inst = Map::new();
3309 76 : if let Some(o) = observed {
3310 68 : inst.insert("observedAt".into(), json!(o));
3311 68 : }
3312 76 : if let Some(d) = dataset {
3313 32 : inst.insert("datasetId".into(), json!(d));
3314 44 : }
3315 76 : instance_id("urn:ngsi-ld:Vehicle:1", "https://a/speed", &inst)
3316 76 : }
3317 :
3318 : /// 4.5.7: an instance is the Property "at a particular point in time,
3319 : /// which is recorded as a Temporal Property of the instance (typically
3320 : /// observedAt)" — so the instant, not the way a client spelled it,
3321 : /// decides which instance a record belongs to. 4.6.3 leaves the seconds
3322 : /// fraction optional and accepts a comma separator in requests, and the
3323 : /// broker stores the stamp as written, so one instant reaches this
3324 : /// function under several spellings.
3325 : ///
3326 : /// The consequence is the one the clause names: "Without such an
3327 : /// instanceId, it is not possible to selectively modify or delete
3328 : /// temporal information via the NGSI-LD API. The consequences of this
3329 : /// may be severe in the case of modification or deletion requests for
3330 : /// legal reasons". A correction re-sent with a different spelling landed
3331 : /// on a second row and left the value it was correcting in place.
3332 : #[test]
3333 4 : fn one_instant_is_one_instance_however_it_is_spelled() {
3334 4 : let spellings = [
3335 4 : "2020-01-01T00:00:00Z",
3336 4 : "2020-01-01T00:00:00.0Z",
3337 4 : "2020-01-01T00:00:00.000Z",
3338 4 : "2020-01-01T00:00:00.000000Z",
3339 4 : "2020-01-01T00:00:00,000Z",
3340 4 : ];
3341 4 : let first = id_of(Some(spellings[0]), None);
3342 16 : for s in &spellings[1..] {
3343 16 : assert_eq!(id_of(Some(s), None), first, "{s} is the same instant");
3344 : }
3345 : // and with a datasetId, which is part of the same identity
3346 4 : let first = id_of(Some(spellings[0]), Some("urn:ds:1"));
3347 16 : for s in &spellings[1..] {
3348 16 : assert_eq!(
3349 16 : id_of(Some(s), Some("urn:ds:1")),
3350 : first,
3351 : "{s} is the same instant"
3352 : );
3353 : }
3354 4 : }
3355 :
3356 : /// The identity still separates what the clause separates: a different
3357 : /// instant, a different dataset and a different fractional value are
3358 : /// different instances.
3359 : #[test]
3360 4 : fn different_instants_and_datasets_stay_different_instances() {
3361 4 : let base = id_of(Some("2020-01-01T00:00:00Z"), None);
3362 16 : for other in [
3363 4 : id_of(Some("2020-01-01T00:00:00.500Z"), None),
3364 4 : id_of(Some("2020-01-01T00:00:01Z"), None),
3365 4 : id_of(Some("2020-01-02T00:00:00Z"), None),
3366 4 : id_of(Some("2020-01-01T00:00:00Z"), Some("urn:ds:1")),
3367 4 : ] {
3368 16 : assert_ne!(other, base);
3369 : }
3370 4 : assert_ne!(
3371 4 : id_of(Some("2020-01-01T00:00:00.500Z"), Some("urn:ds:1")),
3372 4 : id_of(Some("2020-01-01T00:00:00.500Z"), Some("urn:ds:2"))
3373 : );
3374 : // "Without observedAt there is no instant to key on": a fresh id
3375 : // every time, so two unobserved records never collide.
3376 4 : assert_ne!(id_of(None, None), id_of(None, None));
3377 4 : }
3378 : }
3379 :
3380 : #[cfg(test)]
3381 : mod clause_5_8_6_deletion_payload {
3382 : use super::*;
3383 : use serde_json::json;
3384 :
3385 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context";
3386 :
3387 56 : fn ctx() -> Arc<Context> {
3388 56 : antares_jsonld::Loader::new().core()
3389 56 : }
3390 :
3391 : /// Instances of one attribute in a notification data entry, whether the
3392 : /// representation collapsed a single instance to an object or kept an
3393 : /// array.
3394 32 : fn insts<'a>(entry: &'a Value, name: &str) -> Vec<&'a Value> {
3395 32 : match entry.get(name) {
3396 4 : Some(Value::Array(a)) => a.iter().collect(),
3397 28 : Some(v) => vec![v],
3398 0 : None => Vec::new(),
3399 : }
3400 32 : }
3401 :
3402 56 : async fn build(
3403 56 : sub: &Value,
3404 56 : before: &Value,
3405 56 : after: Option<&Value>,
3406 56 : deleted: &[String],
3407 56 : entity_deleted: bool,
3408 56 : ) -> Value {
3409 56 : let st = AppState::new("antares-tombstone-test".into());
3410 56 : let tenant = TenantId::new("default").expect("tenant");
3411 56 : let ctx = ctx();
3412 56 : let data = build_data(
3413 56 : &st,
3414 56 : &tenant,
3415 56 : sub,
3416 56 : &ctx,
3417 56 : Some(before),
3418 56 : after,
3419 56 : deleted,
3420 56 : entity_deleted,
3421 56 : "2026-01-01T00:00:00Z",
3422 56 : )
3423 56 : .await;
3424 56 : assert_eq!(data.len(), 1, "one changed entity ⇒ one data entry");
3425 56 : data.into_iter().next().expect("entry")
3426 56 : }
3427 :
3428 : /// 5.8.6: a deleted Attribute is notified as the NGSI-LD null value of
3429 : /// its own type — `object` for a Relationship, the `{"@none": …}`
3430 : /// languageMap form for a LanguageProperty, `json`, `vocab`, `value`.
3431 : #[tokio::test]
3432 4 : async fn typed_null_member_per_attribute_type() {
3433 4 : let cases = [
3434 4 : ("Property", "value", json!("urn:ngsi-ld:null")),
3435 4 : ("Relationship", "object", json!("urn:ngsi-ld:null")),
3436 4 : (
3437 4 : "LanguageProperty",
3438 4 : "languageMap",
3439 4 : json!({"@none": "urn:ngsi-ld:null"}),
3440 4 : ),
3441 4 : ("JsonProperty", "json", json!("urn:ngsi-ld:null")),
3442 4 : ("VocabProperty", "vocab", json!("urn:ngsi-ld:null")),
3443 4 : ];
3444 20 : for (atype, member, null_value) in cases {
3445 20 : let before = json!({
3446 20 : "id": "urn:ngsi-ld:Vehicle:tomb",
3447 20 : "type": [format!("{DC}/Vehicle")],
3448 20 : format!("{DC}/gone"): [{"type": atype, member: json!("previous")}],
3449 4 : });
3450 20 : let after = json!({
3451 20 : "id": "urn:ngsi-ld:Vehicle:tomb",
3452 20 : "type": [format!("{DC}/Vehicle")],
3453 4 : });
3454 20 : let entry = build(
3455 20 : &json!({"notification": {}}),
3456 20 : &before,
3457 20 : Some(&after),
3458 20 : &[format!("{DC}/gone")],
3459 20 : false,
3460 20 : )
3461 20 : .await;
3462 20 : let got = insts(&entry, "gone");
3463 20 : assert_eq!(got.len(), 1, "{atype}: one tombstone");
3464 20 : assert_eq!(got[0].get("type"), Some(&json!(atype)));
3465 20 : assert_eq!(
3466 20 : got[0].get(member),
3467 20 : Some(&null_value),
3468 4 : "{atype} tombstones through its own {member} member"
3469 4 : );
3470 4 : // showChanges false and sysAttrs false ⇒ neither stamp appears
3471 20 : assert!(
3472 20 : got[0].get("deletedAt").is_none(),
3473 4 : "{atype}: no deletedAt without sysAttrs"
3474 4 : );
3475 20 : assert!(
3476 20 : got[0]
3477 20 : .as_object()
3478 40 : .is_some_and(|o| !o.keys().any(|k| k.starts_with("previous"))),
3479 4 : "{atype}: no previous* member without showChanges"
3480 4 : );
3481 4 : }
3482 4 : }
3483 :
3484 : /// 5.8.6: a whole Attribute deleted at once is ONE tombstone with no
3485 : /// datasetId, while losing individual instances tombstones each lost
3486 : /// datasetId and leaves the survivors untouched.
3487 : #[tokio::test]
3488 4 : async fn whole_attribute_versus_per_instance_deletion() {
3489 4 : let three = json!([
3490 4 : {"type": "Property", "value": 1},
3491 4 : {"type": "Property", "value": 2, "datasetId": "urn:ds:a"},
3492 4 : {"type": "Property", "value": 3, "datasetId": "urn:ds:b"},
3493 : ]);
3494 4 : let before = json!({
3495 4 : "id": "urn:ngsi-ld:Vehicle:multi",
3496 4 : "type": [format!("{DC}/Vehicle")],
3497 4 : format!("{DC}/speed"): three,
3498 : });
3499 4 : let attr = vec![format!("{DC}/speed")];
3500 :
3501 : // whole attribute gone
3502 4 : let after = json!({
3503 4 : "id": "urn:ngsi-ld:Vehicle:multi",
3504 4 : "type": [format!("{DC}/Vehicle")],
3505 : });
3506 4 : let entry = build(
3507 4 : &json!({"notification": {}}),
3508 4 : &before,
3509 4 : Some(&after),
3510 4 : &attr,
3511 4 : false,
3512 4 : )
3513 4 : .await;
3514 4 : let got = insts(&entry, "speed");
3515 4 : assert_eq!(got.len(), 1, "a whole-attribute deletion is one tombstone");
3516 4 : assert!(
3517 4 : got[0].get("datasetId").is_none(),
3518 : "the single tombstone carries no datasetId"
3519 : );
3520 :
3521 : // one instance gone, two survive
3522 4 : let after = json!({
3523 4 : "id": "urn:ngsi-ld:Vehicle:multi",
3524 4 : "type": [format!("{DC}/Vehicle")],
3525 4 : format!("{DC}/speed"): [
3526 4 : {"type": "Property", "value": 1},
3527 4 : {"type": "Property", "value": 3, "datasetId": "urn:ds:b"},
3528 : ],
3529 : });
3530 4 : let entry = build(
3531 4 : &json!({"notification": {}}),
3532 4 : &before,
3533 4 : Some(&after),
3534 4 : &attr,
3535 4 : false,
3536 4 : )
3537 4 : .await;
3538 4 : let got = insts(&entry, "speed");
3539 4 : assert_eq!(got.len(), 3, "two survivors plus one tombstone");
3540 4 : let tombs: Vec<&&Value> = got
3541 4 : .iter()
3542 12 : .filter(|i| i.get("value") == Some(&json!("urn:ngsi-ld:null")))
3543 4 : .collect();
3544 4 : assert_eq!(tombs.len(), 1, "exactly the lost instance is tombstoned");
3545 4 : assert_eq!(tombs[0].get("datasetId"), Some(&json!("urn:ds:a")));
3546 8 : for surviving in got
3547 4 : .iter()
3548 12 : .filter(|i| i.get("value") != Some(&json!("urn:ngsi-ld:null")))
3549 4 : {
3550 8 : assert!(
3551 8 : surviving.get("deletedAt").is_none(),
3552 4 : "a surviving instance is not marked deleted"
3553 4 : );
3554 4 : }
3555 4 : }
3556 :
3557 : /// 5.8.6: "If an Attribute has been deleted, only the name of the
3558 : /// attribute as key and the URI `urn:ngsi-ld:null` as value shall be
3559 : /// provided, unless more information is required. The latter is the case,
3560 : /// if: a datasetId needs to be provided; the notification.sysAttrs is set
3561 : /// to true …; notification.showChanges is set to true …. In all such
3562 : /// cases, a JSON object with all the required information is provided,
3563 : /// where the value or the object is set to the URI `urn:ngsi-ld:null`
3564 : /// respectively or, in case of a LanguageProperty, the languageMap is set
3565 : /// to `{"@none": "urn:ngsi-ld:null"}`."
3566 : ///
3567 : /// The bare-key form is the one an interoperability campaign reads, and
3568 : /// it is reached only through the concise collapse — the tombstone itself
3569 : /// is always built as an object, so nothing below this level can tell the
3570 : /// two apart. 5.5.4 confines the bare form to concise: normalized keeps
3571 : /// the object, and a first-level `urn:ngsi-ld:null` is BadRequestData
3572 : /// everywhere else.
3573 : #[tokio::test]
3574 4 : async fn a_deleted_attribute_is_bare_unless_it_has_more_to_say() {
3575 4 : let speed = format!("{DC}/speed");
3576 4 : let label = format!("{DC}/label");
3577 4 : let before = json!({
3578 4 : "id": "urn:ngsi-ld:Vehicle:c",
3579 4 : "type": [format!("{DC}/Vehicle")],
3580 4 : speed.clone(): [{"type": "Property", "value": 1}],
3581 4 : label.clone(): [{"type": "LanguageProperty", "languageMap": {"en": "hi"}}],
3582 : });
3583 4 : let after = json!({
3584 4 : "id": "urn:ngsi-ld:Vehicle:c",
3585 4 : "type": [format!("{DC}/Vehicle")],
3586 : });
3587 4 : let deleted = [speed.clone(), label.clone()];
3588 : // a nested fn, not a closure: the future borrows the arguments and
3589 : // the `sub` it builds, and a closure cannot return a value borrowing
3590 : // its own captures
3591 20 : async fn entry(before: &Value, after: &Value, deleted: &[String], n: Value) -> Value {
3592 20 : build(
3593 20 : &json!({"notification": n}),
3594 20 : before,
3595 20 : Some(after),
3596 20 : deleted,
3597 20 : false,
3598 20 : )
3599 20 : .await
3600 20 : }
3601 :
3602 : // Nothing more is required: the attribute IS the URI.
3603 8 : for fmt in ["concise", "simplified"] {
3604 8 : let e = entry(&before, &after, &deleted, json!({"format": fmt})).await;
3605 8 : assert_eq!(
3606 8 : e["speed"],
3607 8 : json!("urn:ngsi-ld:null"),
3608 : "{fmt}: a plain deletion is the bare URI, not an object"
3609 : );
3610 8 : assert_eq!(
3611 8 : e["label"],
3612 8 : json!({"languageMap": {"@none": "urn:ngsi-ld:null"}}),
3613 : "{fmt}: a LanguageProperty deletion is the @none map"
3614 : );
3615 : }
3616 :
3617 : // Normalized is not the bare form (5.5.4 confines that to concise).
3618 4 : let e = entry(&before, &after, &deleted, json!({"format": "normalized"})).await;
3619 4 : assert_eq!(
3620 4 : e["speed"],
3621 4 : json!({"type": "Property", "value": "urn:ngsi-ld:null"}),
3622 : "normalized keeps the typed object"
3623 : );
3624 :
3625 : // sysAttrs: the system-generated sub-attributes have to be provided,
3626 : // so the deletion becomes an object carrying them.
3627 4 : let e = entry(
3628 4 : &before,
3629 4 : &after,
3630 4 : &deleted,
3631 4 : json!({"format": "concise", "sysAttrs": true}),
3632 4 : )
3633 4 : .await;
3634 4 : assert_eq!(e["speed"]["value"], json!("urn:ngsi-ld:null"));
3635 4 : assert_eq!(e["speed"]["deletedAt"], json!("2026-01-01T00:00:00Z"));
3636 :
3637 : // showChanges: a previous value has to be provided.
3638 4 : let e = entry(
3639 4 : &before,
3640 4 : &after,
3641 4 : &deleted,
3642 4 : json!({"format": "concise", "showChanges": true}),
3643 4 : )
3644 4 : .await;
3645 4 : assert_eq!(e["speed"]["value"], json!("urn:ngsi-ld:null"));
3646 4 : assert_eq!(e["speed"]["previousValue"], json!(1));
3647 4 : assert_eq!(
3648 4 : e["label"]["previousLanguageMap"],
3649 4 : json!({"en": "hi"}),
3650 : "a LanguageProperty reports previousLanguageMap"
3651 : );
3652 :
3653 : // A datasetId needs to be provided: one instance of two goes, so the
3654 : // tombstone must name which — and stays an object to do it.
3655 4 : let before_ds = json!({
3656 4 : "id": "urn:ngsi-ld:Vehicle:c",
3657 4 : "type": [format!("{DC}/Vehicle")],
3658 4 : speed.clone(): [{"type": "Property", "value": 1, "datasetId": "urn:ds:a"},
3659 4 : {"type": "Property", "value": 2, "datasetId": "urn:ds:b"}],
3660 : });
3661 4 : let after_ds = json!({
3662 4 : "id": "urn:ngsi-ld:Vehicle:c",
3663 4 : "type": [format!("{DC}/Vehicle")],
3664 4 : speed.clone(): [{"type": "Property", "value": 2, "datasetId": "urn:ds:b"}],
3665 : });
3666 4 : let e = build(
3667 4 : &json!({"notification": {"format": "concise"}}),
3668 4 : &before_ds,
3669 4 : Some(&after_ds),
3670 4 : &[speed],
3671 4 : false,
3672 4 : )
3673 4 : .await;
3674 4 : let gone = e["speed"]
3675 4 : .as_array()
3676 8 : .and_then(|a| a.iter().find(|i| i["value"] == json!("urn:ngsi-ld:null")))
3677 4 : .expect("the lost instance is tombstoned");
3678 4 : assert_eq!(gone["datasetId"], json!("urn:ds:a"));
3679 4 : assert!(
3680 4 : e["speed"]
3681 4 : .as_array()
3682 4 : .is_some_and(|a| a.iter().any(|i| i["value"] == json!(2))),
3683 4 : "the surviving instance is still reported: {e}"
3684 4 : );
3685 4 : }
3686 :
3687 : /// 5.8.6 with sysAttrs and showChanges: the tombstone carries deletedAt
3688 : /// and the previous value of its own typed member.
3689 : #[tokio::test]
3690 4 : async fn sys_attrs_and_show_changes_stamp_the_tombstone() {
3691 4 : let before = json!({
3692 4 : "id": "urn:ngsi-ld:Vehicle:stamp",
3693 4 : "type": [format!("{DC}/Vehicle")],
3694 4 : format!("{DC}/where"): [{"type": "Relationship", "object": "urn:ngsi-ld:P:1",
3695 4 : "createdAt": "2025-01-01T00:00:00Z"}],
3696 : });
3697 4 : let after = json!({
3698 4 : "id": "urn:ngsi-ld:Vehicle:stamp",
3699 4 : "type": [format!("{DC}/Vehicle")],
3700 : });
3701 4 : let sub = json!({"notification": {"sysAttrs": true, "showChanges": true}});
3702 4 : let entry = build(&sub, &before, Some(&after), &[format!("{DC}/where")], false).await;
3703 4 : let got = insts(&entry, "where");
3704 4 : assert_eq!(got.len(), 1);
3705 4 : assert_eq!(got[0].get("object"), Some(&json!("urn:ngsi-ld:null")));
3706 4 : assert_eq!(
3707 4 : got[0].get("deletedAt"),
3708 4 : Some(&json!("2026-01-01T00:00:00Z"))
3709 : );
3710 4 : assert_eq!(
3711 4 : got[0].get("previousObject"),
3712 4 : Some(&json!("urn:ngsi-ld:P:1")),
3713 : "showChanges reports the previous object, not previousValue"
3714 : );
3715 4 : assert!(
3716 4 : got[0].get("previousValue").is_none(),
3717 4 : "a Relationship never reports previousValue"
3718 4 : );
3719 4 : }
3720 : }
3721 :
3722 : #[cfg(test)]
3723 : mod candidate_index {
3724 : use super::*;
3725 : use serde_json::json;
3726 :
3727 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context";
3728 :
3729 : /// The index may over-select, never under-select: every subscription
3730 : /// `selector_match` accepts for a change must come back from
3731 : /// `candidates()` for that change's types and changed attributes —
3732 : /// otherwise it silently stops firing (5.8.6).
3733 : #[test]
3734 4 : fn candidates_never_under_select_for_any_selector_shape() {
3735 4 : let ctx = antares_jsonld::Loader::new().core();
3736 4 : let doc = json!({
3737 4 : "id": "urn:ngsi-ld:Vehicle:1",
3738 4 : "type": [format!("{DC}/Vehicle")],
3739 4 : format!("{DC}/speed"): [{"type": "Property", "value": 1}],
3740 : });
3741 4 : let shapes: Vec<(&str, Value)> = vec![
3742 4 : (
3743 4 : "plain type",
3744 4 : json!({"entities": [{"type": format!("{DC}/Vehicle")}]}),
3745 4 : ),
3746 4 : (
3747 4 : "multiple types",
3748 4 : json!({"entities": [{"type": format!("{DC}/Vehicle")},
3749 4 : {"type": format!("{DC}/Building")}]}),
3750 4 : ),
3751 4 : (
3752 4 : "id only",
3753 4 : json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:1"}]}),
3754 4 : ),
3755 4 : (
3756 4 : "idPattern only",
3757 4 : json!({"entities": [{"idPattern": "^urn:ngsi-ld:Vehicle:"}]}),
3758 4 : ),
3759 4 : (
3760 4 : "type selection expression",
3761 4 : json!({"entities": [{"type": format!("{DC}/Vehicle|{DC}/Building")}]}),
3762 4 : ),
3763 4 : (
3764 4 : "watchedAttributes only",
3765 4 : json!({"watchedAttributes": [format!("{DC}/speed")]}),
3766 4 : ),
3767 4 : (
3768 4 : "watchedAttributes with entities",
3769 4 : json!({"entities": [{"type": format!("{DC}/Vehicle")}],
3770 4 : "watchedAttributes": [format!("{DC}/speed")]}),
3771 4 : ),
3772 4 : (
3773 4 : "type with idPattern",
3774 4 : json!({"entities": [{"type": format!("{DC}/Vehicle"),
3775 4 : "idPattern": "^urn:ngsi-ld:Vehicle:"}]}),
3776 4 : ),
3777 : ];
3778 4 : let mirror = SubMirror::default();
3779 32 : for (i, (_, doc)) in shapes.iter().enumerate() {
3780 32 : let mut sub = doc.clone();
3781 32 : sub["id"] = json!(format!("urn:ngsi-ld:Subscription:{i}"));
3782 32 : mirror.apply(
3783 32 : "default",
3784 32 : &format!("urn:ngsi-ld:Subscription:{i}"),
3785 32 : Some(sub),
3786 32 : );
3787 32 : }
3788 4 : let types = [format!("{DC}/Vehicle")];
3789 4 : let changed = [format!("{DC}/speed")];
3790 4 : let got = mirror.candidates(
3791 4 : "default",
3792 4 : &types.iter().map(String::as_str).collect::<Vec<_>>(),
3793 4 : &changed.iter().map(String::as_str).collect::<Vec<_>>(),
3794 : );
3795 32 : for (i, (name, shape)) in shapes.iter().enumerate() {
3796 32 : if !selector_match(shape, &doc, &ctx) {
3797 0 : continue;
3798 32 : }
3799 32 : let id = format!("urn:ngsi-ld:Subscription:{i}");
3800 32 : assert!(
3801 32 : got.iter()
3802 144 : .any(|c| c.get("id").and_then(Value::as_str) == Some(id.as_str())),
3803 : "{name}: selector_match accepts it, so candidates() must return it"
3804 : );
3805 : }
3806 : // A change touching neither the type nor the watched attribute still
3807 : // yields the shapes the index cannot classify — over-selection is
3808 : // allowed — but never the exactly-classified plain-type subscription.
3809 4 : let other = mirror.candidates("default", &[&format!("{DC}/Device")], &[]);
3810 4 : assert!(
3811 4 : !other
3812 4 : .iter()
3813 12 : .any(|c| c.get("id").and_then(Value::as_str) == Some("urn:ngsi-ld:Subscription:0")),
3814 : "an exactly classified type subscription is not evaluated for other types"
3815 : );
3816 4 : }
3817 :
3818 : /// The two classification sites must stay in the superset relation:
3819 : /// every character `selector_match` reads as a 4.17 type-selection
3820 : /// expression has to send the subscription to the broad bucket, or the
3821 : /// index would under-select.
3822 : #[test]
3823 4 : fn type_selection_expressions_are_always_broad() {
3824 16 : for c in ['|', ',', ';', '('] {
3825 16 : let sub = json!({"entities": [{"type": format!("{DC}/A{c}{DC}/B")}]});
3826 16 : assert!(
3827 16 : matches!(index_keys(&sub), Keys::Broad),
3828 : "a type containing {c:?} is a selection expression for selector_match, \
3829 : so the index must not classify it as a plain type"
3830 : );
3831 : }
3832 : // Table 5.2.33-1's "*" is the other member of that relation: it is
3833 : // stored raw and `selector_match` reads it as every type, so an index
3834 : // that classified it as the literal type "*" would look up a key no
3835 : // change carries and the subscription would never be a candidate.
3836 4 : assert!(
3837 0 : matches!(
3838 4 : index_keys(&json!({"entities": [{"type": "*"}]})),
3839 : Keys::Broad
3840 : ),
3841 : "a \"*\" selector matches every type, so the index must go broad"
3842 : );
3843 4 : assert!(matches!(
3844 4 : index_keys(&json!({"entities": [{"type": format!("{DC}/Vehicle")}]})),
3845 : Keys::Types(_)
3846 : ));
3847 4 : }
3848 : }
3849 :
3850 : /// Table 5.2.14.2-1 bookkeeping around a delivery that fails or is never
3851 : /// attempted at all.
3852 : #[cfg(all(test, not(target_arch = "wasm32")))]
3853 : mod clause_5_2_14_2_bookkeeping {
3854 : use super::*;
3855 : use serde_json::json;
3856 : use std::sync::atomic::{AtomicUsize, Ordering};
3857 :
3858 : const SUB_ID: &str = "urn:ngsi-ld:Subscription:book";
3859 :
3860 : /// An endpoint answering `status`, counting the requests that reach it.
3861 8 : async fn endpoint(status: axum::http::StatusCode) -> (String, Arc<AtomicUsize>) {
3862 8 : let hits: Arc<AtomicUsize> = Arc::default();
3863 8 : let seen = hits.clone();
3864 8 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
3865 8 : .await
3866 8 : .expect("bind");
3867 8 : let addr = listener.local_addr().expect("addr");
3868 8 : let app = axum::Router::new().route(
3869 8 : "/notify",
3870 12 : axum::routing::post(move || {
3871 12 : let seen = seen.clone();
3872 12 : async move {
3873 12 : seen.fetch_add(1, Ordering::SeqCst);
3874 12 : status
3875 12 : }
3876 12 : }),
3877 : );
3878 8 : tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
3879 8 : (format!("http://{addr}/notify"), hits)
3880 8 : }
3881 :
3882 16 : async fn stored_notification(st: &AppState, tenant: &TenantId) -> Value {
3883 16 : st.store
3884 16 : .get(tenant, Kind::Subscription, SUB_ID)
3885 16 : .await
3886 16 : .expect("store read")
3887 16 : .expect("subscription row")
3888 16 : .get("notification")
3889 16 : .cloned()
3890 16 : .expect("notification member")
3891 16 : }
3892 :
3893 16 : async fn send(st: &AppState, tenant: &TenantId, sub: &Value) {
3894 16 : let ctx = antares_jsonld::Loader::new().core();
3895 16 : deliver_as(
3896 16 : st,
3897 16 : tenant,
3898 16 : Kind::Subscription,
3899 16 : sub,
3900 16 : "Notification",
3901 16 : vec![json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle"})],
3902 16 : &ctx,
3903 16 : None,
3904 16 : )
3905 16 : .await;
3906 16 : }
3907 :
3908 8 : async fn subscribe(st: &AppState, tenant: &TenantId, uri: &str) -> Value {
3909 8 : let sub = json!({
3910 8 : "id": SUB_ID,
3911 8 : "type": "Subscription",
3912 8 : "entities": [{"type": "Vehicle"}],
3913 8 : "notification": {"endpoint": {"uri": uri}},
3914 : });
3915 8 : st.store
3916 8 : .create(tenant, Kind::Subscription, SUB_ID, sub.clone())
3917 8 : .await
3918 8 : .expect("subscription row");
3919 8 : sub
3920 8 : }
3921 :
3922 : /// Table 5.2.14.2-1 timesFailed: "Number of times an unsuccessful
3923 : /// response (or timeout) has been received when delivering the
3924 : /// notification" — an output-only member implementations shall generate.
3925 : #[tokio::test(flavor = "multi_thread")]
3926 4 : async fn failed_delivery_generates_and_increments_times_failed() {
3927 4 : crate::allow_private();
3928 4 : let (uri, hits) = endpoint(axum::http::StatusCode::INTERNAL_SERVER_ERROR).await;
3929 4 : let st = AppState::new("antares-times-failed".into());
3930 4 : let tenant = TenantId::new("default").expect("tenant");
3931 4 : let sub = subscribe(&st, &tenant, &uri).await;
3932 :
3933 4 : send(&st, &tenant, &sub).await;
3934 4 : let n = stored_notification(&st, &tenant).await;
3935 4 : assert_eq!(hits.load(Ordering::SeqCst), 1, "the endpoint was tried");
3936 4 : assert_eq!(n.get("timesFailed"), Some(&json!(1)));
3937 4 : assert_eq!(n.get("timesSent"), Some(&json!(1)), "the attempt was sent");
3938 4 : assert_eq!(n.get("status"), Some(&json!("failed")));
3939 4 : assert!(
3940 4 : n.get("lastSuccess").is_none(),
3941 : "a failure rolls the optimistic lastSuccess back"
3942 : );
3943 :
3944 4 : send(&st, &tenant, &sub).await;
3945 4 : let n = stored_notification(&st, &tenant).await;
3946 4 : assert_eq!(
3947 4 : n.get("timesFailed"),
3948 4 : Some(&json!(2)),
3949 4 : "timesFailed counts every unsuccessful response"
3950 4 : );
3951 4 : }
3952 :
3953 : /// Table 5.2.14.2-1 timesSent = "Number of times that the notification
3954 : /// has been sent" and lastNotification = "the instant when the last
3955 : /// notification has been sent": a change suppressed by the open circuit
3956 : /// never reaches the wire, so neither member may move.
3957 : #[tokio::test(flavor = "multi_thread")]
3958 4 : async fn breaker_suppressed_delivery_does_not_move_times_sent() {
3959 4 : crate::allow_private();
3960 4 : let (uri, hits) = endpoint(axum::http::StatusCode::OK).await;
3961 4 : let st = AppState::new("antares-breaker-bookkeeping".into());
3962 4 : let tenant = TenantId::new("default").expect("tenant");
3963 4 : let sub = subscribe(&st, &tenant, &uri).await;
3964 20 : for _ in 0..crate::egress::TRIP_AFTER {
3965 20 : st.egress.record_failure(tenant.as_str(), &uri);
3966 20 : }
3967 4 : assert!(
3968 4 : st.egress.is_open(tenant.as_str(), &uri),
3969 : "the destination is open-circuit"
3970 : );
3971 :
3972 4 : send(&st, &tenant, &sub).await;
3973 4 : let n = stored_notification(&st, &tenant).await;
3974 4 : assert_eq!(hits.load(Ordering::SeqCst), 0, "nothing left the process");
3975 4 : assert!(
3976 4 : n.get("timesSent").is_none(),
3977 : "a suppressed notification was never sent"
3978 : );
3979 4 : assert!(
3980 4 : n.get("lastNotification").is_none(),
3981 : "lastNotification is the instant a notification was sent"
3982 : );
3983 :
3984 : // Positive control: with the circuit closed the same call delivers,
3985 : // so the assertions above cannot pass vacuously.
3986 4 : st.egress.record_success(tenant.as_str(), &uri);
3987 4 : send(&st, &tenant, &sub).await;
3988 4 : let n = stored_notification(&st, &tenant).await;
3989 4 : assert_eq!(hits.load(Ordering::SeqCst), 1);
3990 4 : assert_eq!(n.get("timesSent"), Some(&json!(1)));
3991 4 : assert!(n.get("lastNotification").is_some());
3992 4 : }
3993 : }
3994 :
3995 : /// Delivery policy: retries are transport under one notification (5.8.6
3996 : /// books the notification once), a success by retry sets lastSuccess and
3997 : /// status ok, an exhausted policy leaves exactly one dead letter, and the
3998 : /// default policy is the single attempt the clause describes.
3999 : #[cfg(all(test, not(target_arch = "wasm32")))]
4000 : mod delivery_policy_tests {
4001 : use super::*;
4002 : use antares_notifier::DeliveryPolicy;
4003 : use std::sync::atomic::{AtomicUsize, Ordering};
4004 : use std::time::Duration;
4005 :
4006 : const SUB_ID: &str = "urn:ngsi-ld:Subscription:policy";
4007 :
4008 20 : fn policy(attempts: u32, backoff_ms: u64) -> DeliveryPolicy {
4009 20 : DeliveryPolicy {
4010 20 : attempts,
4011 20 : backoff: Duration::from_millis(backoff_ms),
4012 20 : jitter: 0.0,
4013 20 : max_age: Duration::from_secs(60),
4014 20 : }
4015 20 : }
4016 :
4017 : /// Answers 500 to the first `fail_first` requests, 200 afterwards.
4018 28 : async fn flaky_endpoint(fail_first: usize) -> (String, Arc<AtomicUsize>) {
4019 28 : let hits: Arc<AtomicUsize> = Arc::default();
4020 28 : let seen = hits.clone();
4021 28 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4022 28 : .await
4023 28 : .expect("bind");
4024 28 : let addr = listener.local_addr().expect("addr");
4025 28 : let app = axum::Router::new().route(
4026 28 : "/notify",
4027 36 : axum::routing::post(move || {
4028 36 : let seen = seen.clone();
4029 36 : async move {
4030 36 : let n = seen.fetch_add(1, Ordering::SeqCst);
4031 36 : if n < fail_first {
4032 28 : axum::http::StatusCode::INTERNAL_SERVER_ERROR
4033 : } else {
4034 8 : axum::http::StatusCode::OK
4035 : }
4036 36 : }
4037 36 : }),
4038 : );
4039 28 : tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
4040 28 : (format!("http://{addr}/notify"), hits)
4041 28 : }
4042 :
4043 24 : fn state(p: DeliveryPolicy) -> (AppState, TenantId) {
4044 24 : crate::allow_private();
4045 24 : let mut st = AppState::new("antares-policy".into());
4046 24 : st.delivery = p;
4047 24 : (st, TenantId::new("default").expect("tenant"))
4048 24 : }
4049 :
4050 28 : async fn subscribe(st: &AppState, tenant: &TenantId, id: &str, uri: &str) -> Value {
4051 28 : let sub = json!({
4052 28 : "id": id,
4053 28 : "type": "Subscription",
4054 28 : "entities": [{"type": "Vehicle"}],
4055 28 : "notification": {"endpoint": {"uri": uri}},
4056 : });
4057 28 : st.store
4058 28 : .create(tenant, Kind::Subscription, id, sub.clone())
4059 28 : .await
4060 28 : .expect("subscription row");
4061 28 : sub
4062 28 : }
4063 :
4064 28 : async fn send(st: &AppState, tenant: &TenantId, sub: &Value) {
4065 28 : let ctx = antares_jsonld::Loader::new().core();
4066 28 : deliver_as(
4067 28 : st,
4068 28 : tenant,
4069 28 : Kind::Subscription,
4070 28 : sub,
4071 28 : "Notification",
4072 28 : vec![json!({"id": "urn:ngsi-ld:Vehicle:1", "type": "Vehicle"})],
4073 28 : &ctx,
4074 28 : None,
4075 28 : )
4076 28 : .await;
4077 28 : }
4078 :
4079 39 : async fn notification(st: &AppState, tenant: &TenantId, id: &str) -> Value {
4080 39 : st.store
4081 39 : .get(tenant, Kind::Subscription, id)
4082 39 : .await
4083 39 : .expect("store read")
4084 39 : .expect("subscription row")["notification"]
4085 39 : .clone()
4086 39 : }
4087 :
4088 31 : async fn letters(st: &AppState, tenant: &TenantId) -> Vec<Value> {
4089 31 : st.store.list(tenant, Kind::DeadLetter).await.expect("list")
4090 31 : }
4091 :
4092 8 : async fn wait_until<F, Fut>(mut cond: F, what: &str)
4093 8 : where
4094 8 : F: FnMut() -> Fut,
4095 8 : Fut: std::future::Future<Output = bool>,
4096 8 : {
4097 8 : for _ in 0..100 {
4098 30 : if cond().await {
4099 8 : return;
4100 22 : }
4101 22 : tokio::time::sleep(Duration::from_millis(50)).await;
4102 : }
4103 0 : panic!("timed out waiting for {what}");
4104 8 : }
4105 :
4106 : #[tokio::test(flavor = "multi_thread")]
4107 4 : async fn a_retry_that_succeeds_is_one_notification() {
4108 4 : let (st, t) = state(policy(3, 50));
4109 4 : let (uri, hits) = flaky_endpoint(2).await;
4110 4 : let sub = subscribe(&st, &t, SUB_ID, &uri).await;
4111 4 : send(&st, &t, &sub).await;
4112 : // the first attempt is booked at once, as 5.8.6 says
4113 4 : let n = notification(&st, &t, SUB_ID).await;
4114 4 : assert_eq!(hits.load(Ordering::SeqCst), 1);
4115 4 : assert_eq!(n["timesSent"], json!(1));
4116 4 : assert_eq!(n["status"], json!("failed"));
4117 4 : assert!(n.get("lastSuccess").is_none());
4118 4 : wait_until(
4119 38 : || async { notification(&st, &t, SUB_ID).await["status"] == json!("ok") },
4120 4 : "retry success",
4121 : )
4122 4 : .await;
4123 4 : let n = notification(&st, &t, SUB_ID).await;
4124 4 : assert_eq!(hits.load(Ordering::SeqCst), 3, "two retries were made");
4125 4 : assert_eq!(
4126 4 : n["timesSent"],
4127 4 : json!(1),
4128 : "retries never count as a second notification"
4129 : );
4130 4 : assert_eq!(
4131 4 : n["timesFailed"],
4132 4 : json!(1),
4133 : "the failed first attempt was booked once"
4134 : );
4135 4 : assert!(n.get("lastSuccess").is_some());
4136 4 : assert!(
4137 4 : n.get("lastFailure").is_some(),
4138 : "the earlier failure stays recorded"
4139 : );
4140 4 : assert!(
4141 4 : letters(&st, &t).await.is_empty(),
4142 4 : "a delivered notification is no dead letter"
4143 4 : );
4144 4 : }
4145 :
4146 : #[tokio::test(flavor = "multi_thread")]
4147 4 : async fn an_exhausted_policy_leaves_exactly_one_dead_letter() {
4148 4 : let (st, t) = state(policy(2, 50));
4149 4 : let (uri, hits) = flaky_endpoint(usize::MAX).await;
4150 4 : let sub = subscribe(&st, &t, SUB_ID, &uri).await;
4151 4 : let before = dead_letters_written();
4152 4 : send(&st, &t, &sub).await;
4153 4 : wait_until(
4154 22 : || async { !letters(&st, &t).await.is_empty() },
4155 4 : "dead letter",
4156 : )
4157 4 : .await;
4158 4 : tokio::time::sleep(Duration::from_millis(200)).await;
4159 4 : let l = letters(&st, &t).await;
4160 4 : assert_eq!(l.len(), 1, "{l:?}");
4161 4 : assert_eq!(hits.load(Ordering::SeqCst), 2, "attempts = policy.attempts");
4162 4 : let l = &l[0];
4163 4 : assert_eq!(l["subscriptionId"], json!(SUB_ID));
4164 4 : assert_eq!(l["attempts"], json!(2));
4165 4 : assert_eq!(l["binding"], json!("http"));
4166 4 : assert_eq!(l["uri"], json!(uri));
4167 4 : assert_eq!(l["lastError"], json!("HTTP 500"));
4168 4 : assert_eq!(l["payload"]["type"], json!("Notification"));
4169 4 : assert_eq!(l["payload"]["subscriptionId"], json!(SUB_ID));
4170 4 : assert!(l["id"]
4171 4 : .as_str()
4172 4 : .is_some_and(|i| i.starts_with("urn:ngsi-ld:DeadLetter:")));
4173 : // the letter carries the endpoint members the binding renders from,
4174 : // so a replay produces the identical request
4175 4 : assert_eq!(l["accept"], json!("application/json"));
4176 4 : assert!(l["link"].as_str().is_some_and(|v| v.contains("rel=")));
4177 4 : assert!(l["receiverInfo"].is_array());
4178 4 : assert!(dead_letters_written() > before);
4179 4 : let n = notification(&st, &t, SUB_ID).await;
4180 4 : assert_eq!(n["timesSent"], json!(1));
4181 4 : assert_eq!(n["timesFailed"], json!(1));
4182 4 : assert_eq!(n["status"], json!("failed"));
4183 4 : }
4184 :
4185 : #[tokio::test(flavor = "multi_thread")]
4186 4 : async fn the_default_policy_never_retries_and_never_dead_letters() {
4187 4 : let (st, t) = state(DeliveryPolicy::default());
4188 4 : let (uri, hits) = flaky_endpoint(usize::MAX).await;
4189 4 : let sub = subscribe(&st, &t, SUB_ID, &uri).await;
4190 4 : send(&st, &t, &sub).await;
4191 4 : tokio::time::sleep(Duration::from_millis(400)).await;
4192 4 : assert_eq!(hits.load(Ordering::SeqCst), 1);
4193 4 : assert!(letters(&st, &t).await.is_empty());
4194 4 : assert_eq!(
4195 4 : notification(&st, &t, SUB_ID).await["status"],
4196 4 : json!("failed")
4197 4 : );
4198 4 : }
4199 :
4200 : #[tokio::test(flavor = "multi_thread")]
4201 4 : async fn a_backoff_on_one_subscription_does_not_delay_another() {
4202 4 : let (st, t) = state(policy(2, 3_000));
4203 4 : let (dead, _) = flaky_endpoint(usize::MAX).await;
4204 4 : let (live, live_hits) = flaky_endpoint(0).await;
4205 4 : let a = subscribe(&st, &t, "urn:ngsi-ld:Subscription:a", &dead).await;
4206 4 : let b = subscribe(&st, &t, "urn:ngsi-ld:Subscription:b", &live).await;
4207 4 : let started = std::time::Instant::now();
4208 4 : send(&st, &t, &a).await;
4209 4 : send(&st, &t, &b).await;
4210 4 : assert_eq!(live_hits.load(Ordering::SeqCst), 1, "B delivered");
4211 4 : assert!(
4212 4 : started.elapsed() < Duration::from_secs(1),
4213 4 : "A's 3 s backoff must not sit on the delivery path: {:?}",
4214 4 : started.elapsed()
4215 4 : );
4216 4 : }
4217 :
4218 : #[tokio::test(flavor = "multi_thread")]
4219 4 : async fn retries_stop_when_the_subscription_is_deleted() {
4220 4 : let (st, t) = state(policy(4, 100));
4221 4 : let (uri, hits) = flaky_endpoint(usize::MAX).await;
4222 4 : let sub = subscribe(&st, &t, SUB_ID, &uri).await;
4223 4 : send(&st, &t, &sub).await;
4224 4 : st.store
4225 4 : .delete(&t, Kind::Subscription, SUB_ID)
4226 4 : .await
4227 4 : .expect("delete");
4228 4 : tokio::time::sleep(Duration::from_millis(800)).await;
4229 4 : assert_eq!(
4230 4 : hits.load(Ordering::SeqCst),
4231 : 1,
4232 : "no retry for a gone subscription"
4233 : );
4234 4 : assert!(
4235 4 : letters(&st, &t).await.is_empty(),
4236 4 : "no dead letter for a gone subscription"
4237 4 : );
4238 4 : }
4239 :
4240 : #[tokio::test(flavor = "multi_thread")]
4241 4 : async fn an_egress_refusal_is_never_retried() {
4242 4 : let (mut st, t) = state(policy(3, 50));
4243 : // the deny policy is built directly: the environment is shared by
4244 : // every test thread in this process, and a state constructed while
4245 : // the variable read "false" would refuse its loopback endpoint for
4246 : // the rest of its life
4247 4 : st.egress = Arc::new(crate::egress::Egress::new(antares_jsonld::EgressPolicy {
4248 4 : allow_private: false,
4249 4 : }));
4250 4 : let (uri, hits) = flaky_endpoint(0).await;
4251 4 : let sub = subscribe(&st, &t, SUB_ID, &uri).await;
4252 4 : send(&st, &t, &sub).await;
4253 4 : tokio::time::sleep(Duration::from_millis(400)).await;
4254 4 : assert_eq!(
4255 4 : hits.load(Ordering::SeqCst),
4256 : 0,
4257 : "policy refusal: nothing leaves"
4258 : );
4259 4 : assert!(
4260 4 : letters(&st, &t).await.is_empty(),
4261 : "a policy verdict is not a transport failure"
4262 : );
4263 4 : assert_eq!(
4264 4 : notification(&st, &t, SUB_ID).await["status"],
4265 4 : json!("failed")
4266 4 : );
4267 4 : }
4268 : }
4269 :
4270 : /// 5.8.6 periodic notifications: what a due `timeInterval` subscription
4271 : /// reads, what it sends, and which ticks it costs nothing at all.
4272 : #[cfg(all(test, not(target_arch = "wasm32")))]
4273 : mod clause_5_8_6_periodic_sweep {
4274 : use super::*;
4275 : use serde_json::json;
4276 :
4277 : const DC: &str = "https://uri.etsi.org/ngsi-ld/default-context";
4278 :
4279 32 : fn entity(id: &str, ty: &str) -> Value {
4280 32 : json!({"id": id, "type": [format!("{DC}/{ty}")]})
4281 32 : }
4282 :
4283 : /// 5.8.6 narrows the periodic read to "all the subscribed Entities" — and
4284 : /// a narrowing may only ever over-select: every Entity the arbiter
4285 : /// (`selector_match`) accepts has to survive both predicates the sweep
4286 : /// hands the store, or that subscription silently stops reporting it.
4287 : #[test]
4288 4 : fn periodic_read_narrowing_never_under_selects() {
4289 4 : let ctx = antares_jsonld::Loader::new().core();
4290 4 : let docs = [
4291 4 : entity("urn:ngsi-ld:Vehicle:1", "Vehicle"),
4292 4 : entity("urn:ngsi-ld:Vehicle:2", "Vehicle"),
4293 4 : entity("urn:ngsi-ld:Building:1", "Building"),
4294 4 : ];
4295 4 : let subs: Vec<(&str, Value)> = vec![
4296 4 : (
4297 4 : "plain type",
4298 4 : json!({"entities": [{"type": format!("{DC}/Vehicle")}]}),
4299 4 : ),
4300 4 : (
4301 4 : "one id with its type",
4302 4 : json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:2",
4303 4 : "type": format!("{DC}/Vehicle")}]}),
4304 4 : ),
4305 4 : (
4306 4 : "id array",
4307 4 : json!({"entities": [{"id": ["urn:ngsi-ld:Vehicle:1",
4308 4 : "urn:ngsi-ld:Building:1"]}]}),
4309 4 : ),
4310 4 : (
4311 4 : "one id plus a bare-type entry",
4312 4 : json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:1"},
4313 4 : {"type": format!("{DC}/Building")}]}),
4314 4 : ),
4315 4 : (
4316 4 : "idPattern only",
4317 4 : json!({"entities": [{"idPattern": "^urn:ngsi-ld:Vehicle:"}]}),
4318 4 : ),
4319 4 : (
4320 4 : "id overriding a contradicting idPattern",
4321 4 : json!({"entities": [{"id": "urn:ngsi-ld:Vehicle:1",
4322 4 : "idPattern": "^urn:ngsi-ld:Building:"}]}),
4323 4 : ),
4324 4 : (
4325 4 : "type selection expression",
4326 4 : json!({"entities": [{"type": format!("{DC}/Vehicle|{DC}/Building")}]}),
4327 4 : ),
4328 4 : (
4329 4 : "no entities selector at all",
4330 4 : json!({"watchedAttributes": [format!("{DC}/speed")]}),
4331 4 : ),
4332 : ];
4333 32 : for (name, sub) in &subs {
4334 32 : let ids = selector_ids(sub);
4335 32 : let type_groups: Vec<Vec<String>> = match index_keys(sub) {
4336 8 : Keys::Types(ts) => ts.into_iter().map(|t| vec![t]).collect(),
4337 24 : _ => Vec::new(),
4338 : };
4339 96 : for doc in &docs {
4340 96 : if !selector_match(sub, doc, &ctx) {
4341 32 : continue;
4342 64 : }
4343 64 : let id = doc["id"].as_str().expect("id");
4344 64 : if let Some(ids) = &ids {
4345 16 : assert!(
4346 20 : ids.iter().any(|i| i == id),
4347 : "{name}: selector_match accepts {id}, so the id narrowing must keep it"
4348 : );
4349 48 : }
4350 64 : if !type_groups.is_empty() {
4351 12 : let types: Vec<&str> = doc["type"]
4352 12 : .as_array()
4353 12 : .expect("type array")
4354 12 : .iter()
4355 12 : .filter_map(Value::as_str)
4356 12 : .collect();
4357 12 : assert!(
4358 12 : type_groups
4359 12 : .iter()
4360 12 : .any(|g| g.iter().all(|t| types.contains(&t.as_str()))),
4361 : "{name}: selector_match accepts {id}, so the type narrowing must keep it"
4362 : );
4363 52 : }
4364 : }
4365 : }
4366 4 : }
4367 :
4368 : /// Table 5.2.33-1: `id` is a String or a String[] and takes precedence
4369 : /// over `idPattern`, so it pins the read exactly — while any entry
4370 : /// leaving the id open (a bare type, an idPattern, no selector at all)
4371 : /// must yield NO id predicate, since the OR-ed selector then admits
4372 : /// Entities no listed id names.
4373 : #[test]
4374 4 : fn periodic_read_narrows_by_id_only_when_every_selector_entry_pins_one() {
4375 4 : assert_eq!(
4376 4 : selector_ids(&json!({"entities": [{"id": "urn:x:A", "type": "T"}]})),
4377 4 : Some(vec!["urn:x:A".to_owned()])
4378 : );
4379 4 : assert_eq!(
4380 4 : selector_ids(&json!({"entities": [{"id": ["urn:x:A", "urn:x:B"]},
4381 4 : {"id": "urn:x:C"}]})),
4382 4 : Some(vec![
4383 4 : "urn:x:A".to_owned(),
4384 4 : "urn:x:B".to_owned(),
4385 4 : "urn:x:C".to_owned()
4386 4 : ])
4387 : );
4388 28 : for open in [
4389 4 : json!({"entities": [{"id": "urn:x:A"}, {"type": "T"}]}),
4390 4 : json!({"entities": [{"idPattern": "^urn:x:"}]}),
4391 4 : json!({"entities": [{"type": "T"}]}),
4392 4 : json!({"entities": []}),
4393 4 : json!({"watchedAttributes": ["a"]}),
4394 4 : json!({}),
4395 4 : json!({"entities": [{"id": {"not": "a string"}}]}),
4396 4 : ] {
4397 28 : assert_eq!(
4398 28 : selector_ids(&open),
4399 : None,
4400 : "{open} leaves the id open — narrowing by id would drop matching Entities"
4401 : );
4402 : }
4403 4 : }
4404 :
4405 : /// An endpoint that keeps every notification body it receives.
4406 8 : async fn recording_endpoint() -> (String, Arc<std::sync::Mutex<Vec<Value>>>) {
4407 8 : let seen: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
4408 8 : let sink = seen.clone();
4409 8 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4410 8 : .await
4411 8 : .expect("bind");
4412 8 : let addr = listener.local_addr().expect("addr");
4413 8 : let app = axum::Router::new().route(
4414 8 : "/notify",
4415 8 : axum::routing::post(move |body: String| {
4416 8 : let sink = sink.clone();
4417 8 : async move {
4418 8 : if let Ok(v) = serde_json::from_str::<Value>(&body) {
4419 8 : sink.lock().expect("recorded bodies").push(v);
4420 8 : }
4421 8 : axum::http::StatusCode::OK
4422 8 : }
4423 8 : }),
4424 : );
4425 8 : tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
4426 8 : (format!("http://{addr}/notify"), seen)
4427 8 : }
4428 :
4429 : /// A periodic subscription created `age_s` seconds ago with a one-second
4430 : /// interval — due on the next sweep.
4431 12 : fn periodic(id: &str, uri: &str, entities: Value, age_s: i64) -> Value {
4432 12 : let created = chrono::Utc::now() - chrono::Duration::seconds(age_s);
4433 12 : json!({
4434 12 : "id": id,
4435 12 : "type": "Subscription",
4436 12 : "timeInterval": 1,
4437 12 : "createdAt": created.to_rfc3339(),
4438 12 : "entities": entities,
4439 12 : "notification": {"endpoint": {"uri": uri, "accept": "application/json"}},
4440 : })
4441 12 : }
4442 :
4443 12 : async fn install(st: &AppState, tenant: &TenantId, sub: &Value) {
4444 12 : let id = sub["id"].as_str().expect("sub id");
4445 12 : st.store
4446 12 : .create(tenant, Kind::Subscription, id, sub.clone())
4447 12 : .await
4448 12 : .expect("subscription row");
4449 12 : if let Some(m) = &st.sub_mirror {
4450 12 : m.apply(tenant.as_str(), id, Some(sub.clone()));
4451 12 : }
4452 12 : }
4453 :
4454 16 : fn state_with_mirror(alias: &str) -> (AppState, Arc<SubMirror>, TenantId) {
4455 16 : let mut st = AppState::new(alias.into());
4456 16 : let mirror = Arc::new(SubMirror::default());
4457 16 : st.sub_mirror = Some(mirror.clone());
4458 16 : let tenant = TenantId::new("default").expect("tenant");
4459 16 : (st, mirror, tenant)
4460 16 : }
4461 :
4462 : /// 5.8.6: the periodic Notification carries "all the subscribed Entities
4463 : /// that match the query, geoquery and Scope query conditions" — the one
4464 : /// Entity this selector names out of a populated tenant, and nothing
4465 : /// else. And for the subscription whose selector matches nothing: "If
4466 : /// there are no matching Entities, no Notification is sent."
4467 : #[tokio::test(flavor = "multi_thread")]
4468 4 : async fn periodic_sweep_notifies_only_the_subscribed_entities() {
4469 4 : crate::allow_private();
4470 4 : let (uri, seen) = recording_endpoint().await;
4471 4 : let (st, _mirror, tenant) = state_with_mirror("antares-periodic-narrowing");
4472 16 : for (id, ty) in [
4473 4 : ("urn:ngsi-ld:Vehicle:1", "Vehicle"),
4474 4 : ("urn:ngsi-ld:Vehicle:2", "Vehicle"),
4475 4 : ("urn:ngsi-ld:Vehicle:3", "Vehicle"),
4476 4 : ("urn:ngsi-ld:Building:1", "Building"),
4477 4 : ] {
4478 16 : st.store
4479 16 : .create(&tenant, Kind::Entity, id, entity(id, ty))
4480 16 : .await
4481 16 : .expect("entity row");
4482 : }
4483 4 : install(
4484 4 : &st,
4485 4 : &tenant,
4486 4 : &periodic(
4487 4 : "urn:ngsi-ld:Subscription:one",
4488 4 : &uri,
4489 4 : json!([{"id": "urn:ngsi-ld:Vehicle:2", "type": format!("{DC}/Vehicle")}]),
4490 4 : 10,
4491 4 : ),
4492 4 : )
4493 4 : .await;
4494 4 : install(
4495 4 : &st,
4496 4 : &tenant,
4497 4 : &periodic(
4498 4 : "urn:ngsi-ld:Subscription:none",
4499 4 : &uri,
4500 4 : json!([{"id": "urn:ngsi-ld:Vehicle:404", "type": format!("{DC}/Vehicle")}]),
4501 4 : 10,
4502 4 : ),
4503 4 : )
4504 4 : .await;
4505 :
4506 4 : interval_tick(&st).await;
4507 :
4508 4 : let bodies = seen.lock().expect("recorded bodies").clone();
4509 4 : assert_eq!(bodies.len(), 1, "exactly one subscription had a match");
4510 4 : let body = &bodies[0];
4511 4 : assert_eq!(
4512 4 : body["subscriptionId"], "urn:ngsi-ld:Subscription:one",
4513 : "a subscription matching no Entity sends no Notification"
4514 : );
4515 4 : let data = body["data"].as_array().expect("data array");
4516 4 : assert_eq!(data.len(), 1, "only the subscribed Entity is included");
4517 4 : assert_eq!(data[0]["id"], "urn:ngsi-ld:Vehicle:2");
4518 12 : for other in [
4519 4 : "urn:ngsi-ld:Vehicle:1",
4520 4 : "urn:ngsi-ld:Vehicle:3",
4521 4 : "urn:ngsi-ld:Building:1",
4522 4 : ] {
4523 12 : assert!(
4524 12 : !body.to_string().contains(other),
4525 4 : "{other} is not subscribed and must not appear in the notification"
4526 4 : );
4527 4 : }
4528 4 : }
4529 :
4530 : /// The sweep clock: 5.8.6 sends the periodic Notification "when the time
4531 : /// interval (in seconds) specified in such value field is reached", so a
4532 : /// tick before the earliest such instant cannot fire and must not sweep —
4533 : /// while writing a periodic subscription clears the clock, because a
4534 : /// subscription the previous sweep never saw may be due sooner.
4535 : #[tokio::test(flavor = "multi_thread")]
4536 4 : async fn armed_sweep_clock_skips_the_tick_until_a_subscription_write_clears_it() {
4537 : use std::sync::atomic::Ordering::Relaxed;
4538 4 : crate::allow_private();
4539 4 : let (uri, seen) = recording_endpoint().await;
4540 4 : let (st, mirror, tenant) = state_with_mirror("antares-periodic-clock");
4541 4 : st.store
4542 4 : .create(
4543 4 : &tenant,
4544 4 : Kind::Entity,
4545 4 : "urn:ngsi-ld:Vehicle:1",
4546 4 : entity("urn:ngsi-ld:Vehicle:1", "Vehicle"),
4547 4 : )
4548 4 : .await
4549 4 : .expect("entity row");
4550 4 : let sub = periodic(
4551 4 : "urn:ngsi-ld:Subscription:clock",
4552 4 : &uri,
4553 4 : json!([{"type": format!("{DC}/Vehicle")}]),
4554 : 10,
4555 : );
4556 4 : install(&st, &tenant, &sub).await;
4557 4 : let armed = chrono::Utc::now().timestamp_millis() + 60_000;
4558 4 : mirror.next_sub_sweep_ms.store(armed, Relaxed);
4559 :
4560 4 : interval_tick(&st).await;
4561 4 : assert!(
4562 4 : seen.lock().expect("recorded bodies").is_empty(),
4563 : "a tick before the clock must not fire, due subscription or not"
4564 : );
4565 4 : assert_eq!(
4566 4 : mirror.next_sub_sweep_ms.load(Relaxed),
4567 : armed,
4568 : "a skipped tick leaves the clock alone"
4569 : );
4570 :
4571 : // what a subscription write does
4572 4 : mirror.apply(
4573 4 : tenant.as_str(),
4574 4 : sub["id"].as_str().expect("sub id"),
4575 4 : Some(sub.clone()),
4576 4 : );
4577 4 : assert_eq!(
4578 4 : mirror.next_sub_sweep_ms.load(Relaxed),
4579 : 0,
4580 : "writing a periodic subscription clears the sweep clock"
4581 : );
4582 :
4583 4 : interval_tick(&st).await;
4584 4 : assert_eq!(
4585 4 : seen.lock().expect("recorded bodies").len(),
4586 : 1,
4587 : "with the clock cleared the due subscription fires"
4588 : );
4589 4 : assert!(
4590 4 : mirror.next_sub_sweep_ms.load(Relaxed) > chrono::Utc::now().timestamp_millis(),
4591 4 : "after firing, the clock points at the next due instant"
4592 4 : );
4593 4 : }
4594 :
4595 : /// 5.11.7: a Context Source Registration Subscription with `timeInterval`
4596 : /// fires periodically. Its writes reach the sweep as a signal rather than
4597 : /// as a mirrored document, so a write must clear the clock the way a
4598 : /// Subscription write does — otherwise a new one waits out a clock
4599 : /// computed before it existed.
4600 : #[tokio::test(flavor = "multi_thread")]
4601 4 : async fn a_written_csource_subscription_clears_the_csub_sweep_clock() {
4602 : use std::sync::atomic::Ordering::Relaxed;
4603 4 : let (_st, mirror, _tenant) = state_with_mirror("antares-csub-clock");
4604 4 : let armed = chrono::Utc::now().timestamp_millis() + 600_000;
4605 4 : mirror.next_csub_sweep_ms.store(armed, Relaxed);
4606 4 : assert_eq!(
4607 4 : mirror.next_csub_sweep_ms.load(Relaxed),
4608 : armed,
4609 : "the clock stands until something writes"
4610 : );
4611 :
4612 4 : mirror.csub_written();
4613 :
4614 4 : assert_eq!(
4615 4 : mirror.next_csub_sweep_ms.load(Relaxed),
4616 4 : 0,
4617 4 : "writing a Context Source Registration Subscription clears the sweep clock"
4618 4 : );
4619 4 : }
4620 :
4621 : /// With nothing periodic to serve, the sweep must park past the next few
4622 : /// ticks instead of re-listing every tenant's Context Source Registration
4623 : /// Subscriptions every second: at the tenant target that poll is the
4624 : /// broker's whole idle cost.
4625 : #[tokio::test(flavor = "multi_thread")]
4626 4 : async fn a_sweep_that_finds_nothing_periodic_parks_the_csub_clock() {
4627 : use std::sync::atomic::Ordering::Relaxed;
4628 4 : let (st, mirror, _tenant) = state_with_mirror("antares-csub-park");
4629 4 : mirror.next_csub_sweep_ms.store(0, Relaxed);
4630 :
4631 4 : interval_tick(&st).await;
4632 :
4633 4 : let parked = mirror.next_csub_sweep_ms.load(Relaxed);
4634 4 : let now = chrono::Utc::now().timestamp_millis();
4635 4 : assert!(
4636 4 : parked > now + 1_000,
4637 4 : "an idle sweep parked until {parked} ({} ms out) — the poll is still the fast path",
4638 4 : parked - now
4639 4 : );
4640 4 : }
4641 : }
4642 :
4643 : #[cfg(test)]
4644 : mod clause_5_8_6_grouped_delivery {
4645 : use super::*;
4646 : use serde_json::json;
4647 : use tower::ServiceExt as _;
4648 :
4649 8 : async fn post(st: &AppState, uri: &str, body: Value) -> u16 {
4650 8 : let body = body.to_string();
4651 8 : crate::router(st.clone())
4652 8 : .oneshot(
4653 8 : axum::http::Request::builder()
4654 8 : .method("POST")
4655 8 : .uri(uri)
4656 8 : .header("Content-Type", "application/json")
4657 8 : .header("Content-Length", body.len())
4658 8 : .body(axum::body::Body::from(body))
4659 8 : .expect("request"),
4660 8 : )
4661 8 : .await
4662 8 : .expect("response")
4663 8 : .status()
4664 8 : .as_u16()
4665 8 : }
4666 :
4667 4 : async fn recording_endpoint() -> (String, Arc<std::sync::Mutex<Vec<Value>>>) {
4668 4 : let seen: Arc<std::sync::Mutex<Vec<Value>>> = Arc::default();
4669 4 : let sink = seen.clone();
4670 4 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4671 4 : .await
4672 4 : .expect("bind");
4673 4 : let addr = listener.local_addr().expect("addr");
4674 4 : let app = axum::Router::new().route(
4675 4 : "/notify",
4676 4 : axum::routing::post(move |body: String| {
4677 4 : let sink = sink.clone();
4678 4 : async move {
4679 4 : if let Ok(v) = serde_json::from_str::<Value>(&body) {
4680 4 : sink.lock().expect("recorded bodies").push(v);
4681 4 : }
4682 4 : axum::http::StatusCode::OK
4683 4 : }
4684 4 : }),
4685 : );
4686 4 : tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
4687 4 : (format!("http://{addr}/notify"), seen)
4688 4 : }
4689 :
4690 : /// 5.8.6: one batch request writing N matching entities is ONE
4691 : /// notification whose `data` carries the N entities — not N
4692 : /// notifications — and Table 5.2.14.2-1 `timesSent` moves by one.
4693 : #[tokio::test(flavor = "multi_thread")]
4694 4 : async fn a_batch_of_matching_entities_is_one_notification() {
4695 4 : crate::allow_private();
4696 4 : let (uri, seen) = recording_endpoint().await;
4697 4 : let mut st = AppState::new("antares-grouped-delivery".into());
4698 4 : crate::wire(&mut st).await;
4699 4 : assert_eq!(
4700 4 : post(
4701 4 : &st,
4702 4 : "/ngsi-ld/v1/subscriptions",
4703 4 : json!({
4704 4 : "id": "urn:ngsi-ld:Subscription:grouped",
4705 4 : "type": "Subscription",
4706 4 : "entities": [{"type": "Vehicle"}],
4707 4 : "notification": {"endpoint": {"uri": uri, "accept": "application/json"}},
4708 4 : }),
4709 4 : )
4710 4 : .await,
4711 : 201
4712 : );
4713 4 : let batch: Vec<Value> = (1..=3)
4714 12 : .map(|i| {
4715 12 : json!({"id": format!("urn:ngsi-ld:Vehicle:{i}"), "type": "Vehicle",
4716 12 : "speed": {"type": "Property", "value": i}})
4717 12 : })
4718 4 : .collect();
4719 4 : assert_eq!(
4720 4 : post(&st, "/ngsi-ld/v1/entityOperations/create", json!(batch)).await,
4721 : 201
4722 : );
4723 4 : for _ in 0..600 {
4724 4 : tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4725 4 : if !seen.lock().expect("bodies").is_empty() {
4726 4 : break;
4727 0 : }
4728 : }
4729 : // settle: a second POST, if the broker were still splitting, lands here
4730 4 : tokio::time::sleep(std::time::Duration::from_millis(300)).await;
4731 4 : let bodies = seen.lock().expect("bodies").clone();
4732 4 : assert_eq!(bodies.len(), 1, "one POST for the batch, got {bodies:?}");
4733 4 : let data = bodies[0]["data"].as_array().expect("data array");
4734 12 : let mut ids: Vec<&str> = data.iter().filter_map(|e| e["id"].as_str()).collect();
4735 4 : ids.sort_unstable();
4736 4 : assert_eq!(
4737 : ids,
4738 : [
4739 : "urn:ngsi-ld:Vehicle:1",
4740 : "urn:ngsi-ld:Vehicle:2",
4741 : "urn:ngsi-ld:Vehicle:3"
4742 : ]
4743 : );
4744 4 : let tenant = TenantId::new("default").expect("tenant");
4745 4 : let sub = st
4746 4 : .store
4747 4 : .get(
4748 4 : &tenant,
4749 4 : Kind::Subscription,
4750 4 : "urn:ngsi-ld:Subscription:grouped",
4751 4 : )
4752 4 : .await
4753 4 : .expect("store")
4754 4 : .expect("row");
4755 4 : assert_eq!(sub["notification"]["timesSent"], json!(1));
4756 4 : }
4757 : }
4758 :
4759 : #[cfg(test)]
4760 : mod notification_body_bound {
4761 : use super::*;
4762 : use serde_json::json;
4763 :
4764 : /// A grouped notification is cut into whole-entity runs under the byte
4765 : /// cap; nothing is split mid-entity and an oversize single entity still
4766 : /// travels (alone) instead of being dropped.
4767 : #[test]
4768 4 : fn chunks_cut_at_whole_items_under_the_cap() {
4769 32 : let item = |i: usize| json!({"id": format!("urn:ngsi-ld:V:{i}"), "v": "x".repeat(40)});
4770 4 : let one = serde_json::to_vec(&item(0)).expect("json").len();
4771 4 : let runs = chunk_by_bytes((0..5).map(item).collect(), one * 2);
4772 4 : assert_eq!(runs.iter().map(Vec::len).collect::<Vec<_>>(), [2, 2, 1]);
4773 4 : assert_eq!(runs[2][0]["id"], json!("urn:ngsi-ld:V:4"));
4774 4 : let runs = chunk_by_bytes(vec![item(0), item(1)], one / 2);
4775 4 : assert_eq!(runs.len(), 2, "an over-cap item is its own run, never lost");
4776 4 : assert!(chunk_by_bytes(Vec::new(), 1).is_empty());
4777 4 : }
4778 : }
4779 :
4780 : #[cfg(test)]
4781 : mod interval_claim {
4782 : use super::*;
4783 : use serde_json::json;
4784 :
4785 8 : async fn seed(st: &AppState, tenant: &TenantId) {
4786 8 : st.store
4787 8 : .create(
4788 8 : tenant,
4789 8 : Kind::Subscription,
4790 8 : "urn:ngsi-ld:Subscription:tick",
4791 8 : json!({
4792 8 : "id": "urn:ngsi-ld:Subscription:tick",
4793 8 : "type": "Subscription",
4794 8 : "entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}],
4795 8 : "timeInterval": 1,
4796 8 : "status": "active",
4797 8 : "createdAt": "2020-01-01T00:00:00Z",
4798 8 : "notification": {"endpoint": {"uri": "http://127.0.0.1:9/notify"}},
4799 8 : }),
4800 8 : )
4801 8 : .await
4802 8 : .expect("seed");
4803 8 : }
4804 :
4805 8 : async fn stamp(st: &AppState, tenant: &TenantId) -> Option<String> {
4806 8 : st.store
4807 8 : .get(tenant, Kind::Subscription, "urn:ngsi-ld:Subscription:tick")
4808 8 : .await
4809 8 : .expect("store")
4810 8 : .expect("row")["notification"]["lastNotification"]
4811 8 : .as_str()
4812 8 : .map(str::to_owned)
4813 8 : }
4814 :
4815 : /// 5.8.6: "If there are no matching Entities, no Notification is sent",
4816 : /// and Table 5.2.14.2-1 makes `lastNotification` "the timestamp
4817 : /// corresponding to the instant when the last notification was sent".
4818 : /// The multi-pod claim stamps that member to win the firing, so a due
4819 : /// subscription that then matches nothing must have the stamp put back:
4820 : /// otherwise a client reads a notification instant for a notification
4821 : /// that never happened, and the subscription — still owing its firing —
4822 : /// waits out a whole interval that the single-process path does not.
4823 : #[tokio::test]
4824 4 : async fn a_claimed_firing_that_matches_nothing_leaves_no_notification_instant() {
4825 4 : let tenant = TenantId::new("default").expect("tenant");
4826 8 : for nats in [false, true] {
4827 8 : let mut st = AppState::new("me".into());
4828 8 : st.nats = nats;
4829 8 : seed(&st, &tenant).await;
4830 8 : interval_tick(&st).await;
4831 8 : assert_eq!(
4832 8 : stamp(&st, &tenant).await,
4833 4 : None,
4834 4 : "nats={nats}: nothing matched, so nothing was sent"
4835 4 : );
4836 4 : }
4837 4 : }
4838 : }
4839 :
4840 : #[cfg(all(test, not(target_arch = "wasm32")))]
4841 : mod change_grouping {
4842 : use super::*;
4843 : use std::sync::atomic::{AtomicUsize, Ordering};
4844 : use std::sync::Arc as StdArc;
4845 :
4846 : /// 5.8.6: "the Notification … data … shall contain the Entities that
4847 : /// match" — every change of one drain that matches the same Subscription
4848 : /// travels in ONE notification. Grouping is what makes a batch of N
4849 : /// writes one POST with N data entries instead of N POSTs, and it has to
4850 : /// hold when many Subscriptions match the same change: each of them gets
4851 : /// exactly one notification carrying every entity, and no entity lands on
4852 : /// the wrong Subscription.
4853 : #[tokio::test(flavor = "multi_thread")]
4854 4 : async fn every_change_of_a_drain_reaches_each_matching_subscription_once() {
4855 4 : crate::allow_private();
4856 4 : let mut st = AppState::new("antares-grouping-test".into());
4857 4 : crate::wire(&mut st).await;
4858 4 : let tenant = TenantId::new("default").expect("tenant");
4859 4 : let posts: StdArc<AtomicUsize> = StdArc::default();
4860 4 : let entities: StdArc<std::sync::Mutex<Vec<usize>>> = StdArc::default();
4861 4 : let (p, e) = (posts.clone(), entities.clone());
4862 4 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4863 4 : .await
4864 4 : .expect("bind");
4865 4 : let addr = listener.local_addr().expect("addr");
4866 4 : let app = axum::Router::new().route(
4867 4 : "/notify",
4868 32 : axum::routing::post(move |body: String| {
4869 32 : let (p, e) = (p.clone(), e.clone());
4870 32 : async move {
4871 32 : p.fetch_add(1, Ordering::SeqCst);
4872 32 : let v: Value = serde_json::from_str(&body).unwrap_or(Value::Null);
4873 32 : e.lock()
4874 32 : .expect("seen")
4875 32 : .push(v["data"].as_array().map(Vec::len).unwrap_or(0));
4876 32 : axum::http::StatusCode::OK
4877 32 : }
4878 32 : }),
4879 : );
4880 4 : tokio::spawn(async move {
4881 4 : axum::serve(listener, app).await.expect("serve");
4882 0 : });
4883 :
4884 : const SUBS: usize = 8;
4885 : const CHANGES: usize = 5;
4886 32 : for i in 0..SUBS {
4887 32 : let sub = json!({
4888 32 : "id": format!("urn:ngsi-ld:Subscription:group-{i}"),
4889 32 : "type": "Subscription",
4890 32 : "status": "active",
4891 32 : "entities": [{"type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"}],
4892 32 : "notification": {"endpoint": {"uri": format!("http://{addr}/notify")}},
4893 : });
4894 32 : st.store
4895 32 : .create(
4896 32 : &tenant,
4897 32 : Kind::Subscription,
4898 32 : &format!("urn:ngsi-ld:Subscription:group-{i}"),
4899 32 : sub.clone(),
4900 32 : )
4901 32 : .await
4902 32 : .expect("seed subscription");
4903 32 : if let Some(m) = &st.sub_mirror {
4904 32 : m.apply(
4905 32 : tenant.as_str(),
4906 32 : &format!("urn:ngsi-ld:Subscription:group-{i}"),
4907 32 : Some(sub),
4908 32 : );
4909 32 : }
4910 : }
4911 4 : let changes: Vec<Change> = (0..CHANGES)
4912 20 : .map(|i| {
4913 20 : (
4914 20 : tenant.as_str().to_owned(),
4915 20 : None,
4916 20 : Some(json!({
4917 20 : "id": format!("urn:ngsi-ld:Vehicle:{i}"),
4918 20 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
4919 20 : "https://uri.etsi.org/ngsi-ld/default-context/speed": [
4920 20 : {"type": "Property", "value": i}
4921 20 : ],
4922 20 : })),
4923 20 : )
4924 20 : })
4925 4 : .collect();
4926 4 : process_changes(&st, changes).await;
4927 4 : assert_eq!(
4928 4 : posts.load(Ordering::SeqCst),
4929 : SUBS,
4930 : "one notification per matching subscription, never one per change"
4931 : );
4932 4 : let sizes = entities.lock().expect("seen").clone();
4933 4 : assert_eq!(
4934 4 : sizes,
4935 4 : vec![CHANGES; SUBS],
4936 4 : "each notification carries every entity of the drain"
4937 4 : );
4938 4 : }
4939 : }
4940 :
4941 : #[cfg(all(test, not(target_arch = "wasm32")))]
4942 : mod interval_sweep_concurrency {
4943 : use super::*;
4944 :
4945 : /// 5.8.6 sends a periodic Notification "when the time interval … is
4946 : /// reached". One sweep visits every tenant and every due Subscription, so
4947 : /// whatever it does per Subscription it does 10 000 tenants' worth of —
4948 : /// and a notification endpoint may take its whole `endpoint.timeout` to
4949 : /// answer (Table 5.2.15-1). Awaiting each delivery in turn makes one
4950 : /// unresponsive endpoint the deadline of every other subscriber's
4951 : /// periodic notification, on a broker whose targets are 10 000 tenants
4952 : /// and 100 000 subscriptions.
4953 : #[tokio::test(flavor = "multi_thread")]
4954 4 : async fn one_unresponsive_endpoint_does_not_hold_up_the_other_subscriptions() {
4955 4 : crate::allow_private();
4956 : // a listener that accepts and never answers: every delivery to it
4957 : // costs exactly its endpoint timeout
4958 4 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4959 4 : .await
4960 4 : .expect("bind");
4961 4 : let addr = listener.local_addr().expect("addr");
4962 4 : tokio::spawn(async move {
4963 4 : let mut held = Vec::new();
4964 20 : while let Ok((s, _)) = listener.accept().await {
4965 16 : held.push(s);
4966 16 : }
4967 0 : });
4968 : // no mirror: the sweep reads the subscriptions from the store, which
4969 : // is where this test seeds them
4970 4 : let st = AppState::new("antares-sweep-test".into());
4971 4 : let tenant = TenantId::new("default").expect("tenant");
4972 4 : st.store
4973 4 : .create(
4974 4 : &tenant,
4975 4 : Kind::Entity,
4976 4 : "urn:ngsi-ld:Vehicle:sweep",
4977 4 : json!({
4978 4 : "id": "urn:ngsi-ld:Vehicle:sweep",
4979 4 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
4980 4 : }),
4981 4 : )
4982 4 : .await
4983 4 : .expect("seed entity");
4984 : const SUBS: u32 = 4;
4985 : const TIMEOUT_MS: u64 = 300;
4986 16 : for i in 0..SUBS {
4987 16 : let id = format!("urn:ngsi-ld:Subscription:sweep-{i}");
4988 16 : st.store
4989 16 : .create(
4990 16 : &tenant,
4991 16 : Kind::Subscription,
4992 16 : &id,
4993 16 : json!({
4994 16 : "id": id,
4995 16 : "type": "Subscription",
4996 16 : "status": "active",
4997 16 : "timeInterval": 1,
4998 16 : "createdAt": "2020-01-01T00:00:00Z",
4999 16 : "entities": [{
5000 16 : "type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"
5001 16 : }],
5002 16 : "notification": {"endpoint": {
5003 16 : "uri": format!("http://{addr}/notify"),
5004 16 : "timeout": TIMEOUT_MS,
5005 16 : }},
5006 16 : }),
5007 16 : )
5008 16 : .await
5009 16 : .expect("seed subscription");
5010 : }
5011 4 : let started = std::time::Instant::now();
5012 4 : interval_tick(&st).await;
5013 4 : let elapsed = started.elapsed().as_millis() as u64;
5014 4 : let serial = TIMEOUT_MS * u64::from(SUBS);
5015 4 : assert!(
5016 4 : elapsed < serial * crate::state::slow_factor(),
5017 4 : "the sweep took {elapsed} ms — {SUBS} deliveries of {TIMEOUT_MS} ms ran one after \
5018 4 : the other instead of together"
5019 4 : );
5020 4 : }
5021 : }
5022 :
5023 : /// What `stable_eq` treats as the same attribute instance (5.8.6: a change
5024 : /// notification carries the attributes that changed, and the members the
5025 : /// broker stamps itself are not a change).
5026 : #[cfg(test)]
5027 : mod stable_comparison {
5028 : use super::*;
5029 : use serde_json::json;
5030 :
5031 : #[test]
5032 4 : fn only_the_volatile_members_may_differ() {
5033 4 : let base = json!({"type": "Property", "value": 1,
5034 4 : "createdAt": "2020-01-01T00:00:00Z",
5035 4 : "modifiedAt": "2020-01-01T00:00:00Z",
5036 4 : "instanceId": "urn:ngsi-ld:Instance:1"});
5037 : // every volatile member differs, or is missing outright
5038 4 : assert!(stable_eq(&base, &json!({"type": "Property", "value": 1})));
5039 4 : assert!(stable_eq(
5040 4 : &base,
5041 4 : &json!({"type": "Property", "value": 1,
5042 4 : "modifiedAt": "2021-06-06T00:00:00Z",
5043 4 : "instanceId": "urn:ngsi-ld:Instance:2"})
5044 : ));
5045 : // a member that is not volatile
5046 4 : assert!(!stable_eq(&base, &json!({"type": "Property", "value": 2})));
5047 4 : assert!(!stable_eq(
5048 4 : &base,
5049 4 : &json!({"type": "Property", "value": 1, "unitCode": "CEL"})
5050 4 : ));
5051 : // same count of non-volatile members, different names
5052 4 : assert!(!stable_eq(
5053 4 : &json!({"a": 1, "b": 2}),
5054 4 : &json!({"a": 1, "c": 2})
5055 4 : ));
5056 : // volatile only counts as a member name, never as a value
5057 4 : assert!(!stable_eq(
5058 4 : &json!({"observedAt": "createdAt"}),
5059 4 : &json!({"observedAt": "modifiedAt"})
5060 4 : ));
5061 4 : }
5062 :
5063 : #[test]
5064 4 : fn nesting_and_arrays_compare_element_by_element() {
5065 4 : assert!(stable_eq(
5066 4 : &json!({"value": {"a": [1, {"b": 2, "createdAt": "x"}]}}),
5067 4 : &json!({"value": {"a": [1, {"b": 2}]}})
5068 : ));
5069 4 : assert!(!stable_eq(
5070 4 : &json!({"value": {"a": [1, {"b": 2}]}}),
5071 4 : &json!({"value": {"a": [1, {"b": 3}]}})
5072 4 : ));
5073 : // order within an array is part of the value
5074 4 : assert!(!stable_eq(&json!([1, 2]), &json!([2, 1])));
5075 4 : assert!(!stable_eq(&json!([1, 2]), &json!([1, 2, 3])));
5076 : // scalars and mismatched kinds
5077 4 : assert!(stable_eq(&json!(null), &json!(null)));
5078 4 : assert!(!stable_eq(&json!({}), &json!([])));
5079 4 : assert!(!stable_eq(&json!(1), &json!("1")));
5080 4 : }
5081 : }
5082 :
5083 : /// How much of the notification width one tenant may hold.
5084 : #[cfg(all(test, not(target_arch = "wasm32")))]
5085 : mod delivery_share {
5086 : use super::*;
5087 :
5088 : /// A Subscription belongs to one tenant (5.2.12), and a delivery to an
5089 : /// endpoint that accepts and never answers holds its slot until the
5090 : /// endpoint's timeout expires — up to 30 s (Table 5.2.15-1). A tenant
5091 : /// that points more subscriptions than the whole width at such an
5092 : /// endpoint would, with one width shared broker-wide, hold every slot
5093 : /// for that long and stop every other tenant's notifications. A tenant
5094 : /// therefore takes a share of the width, never all of it.
5095 : #[tokio::test(flavor = "multi_thread")]
5096 4 : async fn one_tenant_takes_a_share_of_the_delivery_width_not_all_of_it() {
5097 4 : crate::allow_private();
5098 : // accepts and never answers: each connection is held for the whole
5099 : // endpoint timeout, so the connections open at once ARE the
5100 : // deliveries in flight
5101 4 : let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
5102 4 : .await
5103 4 : .expect("bind");
5104 4 : let addr = listener.local_addr().expect("addr");
5105 4 : let opened = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5106 4 : let counter = Arc::clone(&opened);
5107 4 : tokio::spawn(async move {
5108 4 : let mut held = Vec::new();
5109 36 : while let Ok((s, _)) = listener.accept().await {
5110 32 : held.push(s);
5111 32 : counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5112 32 : }
5113 0 : });
5114 4 : let st = AppState::new("antares-share-test".into());
5115 4 : let tenant = TenantId::new("hog").expect("tenant");
5116 4 : st.store
5117 4 : .create(
5118 4 : &tenant,
5119 4 : Kind::Entity,
5120 4 : "urn:ngsi-ld:Vehicle:share",
5121 4 : json!({
5122 4 : "id": "urn:ngsi-ld:Vehicle:share",
5123 4 : "type": ["https://uri.etsi.org/ngsi-ld/default-context/Vehicle"],
5124 4 : }),
5125 4 : )
5126 4 : .await
5127 4 : .expect("seed entity");
5128 : // more subscriptions than the whole width, so an unbounded tenant
5129 : // would hold every slot
5130 4 : let subs = *DELIVERY_WIDTH + *DELIVERY_WIDTH_PER_TENANT;
5131 288 : for i in 0..subs {
5132 288 : let id = format!("urn:ngsi-ld:Subscription:share-{i}");
5133 288 : st.store
5134 288 : .create(
5135 288 : &tenant,
5136 288 : Kind::Subscription,
5137 288 : &id,
5138 288 : json!({
5139 288 : "id": id,
5140 288 : "type": "Subscription",
5141 288 : "status": "active",
5142 288 : "timeInterval": 1,
5143 288 : "createdAt": "2020-01-01T00:00:00Z",
5144 288 : "entities": [{
5145 288 : "type": "https://uri.etsi.org/ngsi-ld/default-context/Vehicle"
5146 288 : }],
5147 288 : // long enough that nothing in the first round has
5148 288 : // timed out and freed its slot while this is counted
5149 288 : "notification": {"endpoint": {
5150 288 : "uri": format!("http://{addr}/notify"),
5151 288 : "timeout": 30_000,
5152 288 : }},
5153 288 : }),
5154 288 : )
5155 288 : .await
5156 288 : .expect("seed subscription");
5157 : }
5158 4 : let sweep = tokio::spawn(async move { interval_tick(&st).await });
5159 4 : let settle = 400 * crate::state::slow_factor();
5160 4 : tokio::time::sleep(std::time::Duration::from_millis(settle)).await;
5161 4 : let in_flight = opened.load(std::sync::atomic::Ordering::SeqCst);
5162 4 : sweep.abort();
5163 4 : assert!(in_flight > 0, "the sweep delivered nothing at all");
5164 4 : assert!(
5165 4 : in_flight <= *DELIVERY_WIDTH_PER_TENANT,
5166 4 : "one tenant held {in_flight} of the {} delivery slots",
5167 4 : *DELIVERY_WIDTH
5168 4 : );
5169 4 : }
5170 : }
5171 :
5172 : /// The drain's memo of resolved `@contexts`.
5173 : #[cfg(all(test, not(target_arch = "wasm32")))]
5174 : mod context_memo {
5175 : use super::*;
5176 :
5177 : /// 5.5.10 scopes an `@context` resolution to one Tenant, so the drain's
5178 : /// memo is keyed by Tenant before URL: a resolution one Tenant reached is
5179 : /// not reachable under another Tenant naming the same URL.
5180 : #[tokio::test]
5181 4 : async fn one_tenants_memoised_context_is_not_served_to_another() {
5182 4 : let st = AppState::new("antares-ctx-memo-tenant".into());
5183 4 : let url = "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld";
5184 4 : let sub = json!({"id": "urn:ngsi-ld:Subscription:1", "__context": url});
5185 4 : let planted = Arc::new(Context::default());
5186 4 : let mut memo = CtxMemo::new();
5187 4 : memo.entry("alpha".to_owned())
5188 4 : .or_default()
5189 4 : .insert(url.to_owned(), Arc::clone(&planted));
5190 :
5191 4 : let beta = TenantId::new_internal("beta").expect("tenant");
5192 4 : let got = sub_context_memo(&st, &beta, "beta", &sub, &mut memo).await;
5193 :
5194 4 : assert!(
5195 4 : !Arc::ptr_eq(&got, &planted),
5196 : "Tenant beta was served Tenant alpha's resolved @context"
5197 : );
5198 4 : assert!(
5199 4 : memo.get("alpha").and_then(|m| m.get(url)).is_some(),
5200 4 : "alpha's entry was overwritten by another Tenant's resolution"
5201 4 : );
5202 4 : }
5203 :
5204 : /// The memo exists to spare the resolver a lookup per candidate: a second
5205 : /// candidate naming the same `@context` in the same drain reuses the
5206 : /// resolution the first one reached rather than resolving again.
5207 : #[tokio::test]
5208 4 : async fn a_second_candidate_reuses_the_resolution_of_the_first() {
5209 4 : let st = AppState::new("antares-ctx-memo-reuse".into());
5210 4 : let url = "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld";
5211 4 : let tenant = TenantId::new_internal("alpha").expect("tenant");
5212 4 : let mut memo = CtxMemo::new();
5213 :
5214 4 : let first = sub_context_memo(
5215 4 : &st,
5216 4 : &tenant,
5217 4 : "alpha",
5218 4 : &json!({"id": "urn:ngsi-ld:Subscription:1", "__context": url}),
5219 4 : &mut memo,
5220 4 : )
5221 4 : .await;
5222 4 : let second = sub_context_memo(
5223 4 : &st,
5224 4 : &tenant,
5225 4 : "alpha",
5226 4 : &json!({"id": "urn:ngsi-ld:Subscription:2", "__context": url}),
5227 4 : &mut memo,
5228 4 : )
5229 4 : .await;
5230 :
5231 4 : assert!(
5232 4 : Arc::ptr_eq(&first, &second),
5233 4 : "the second candidate resolved its @context again"
5234 4 : );
5235 4 : }
5236 :
5237 : /// A Subscription carrying an inline `@context` rather than a URL has no
5238 : /// cheap identity to key on: it resolves as it did before the memo, and
5239 : /// leaves no entry behind that a later URL lookup could collide with.
5240 : #[tokio::test]
5241 4 : async fn an_inline_context_is_resolved_rather_than_memoised() {
5242 4 : let st = AppState::new("antares-ctx-memo-inline".into());
5243 4 : let tenant = TenantId::new_internal("alpha").expect("tenant");
5244 4 : let mut memo = CtxMemo::new();
5245 4 : let sub = json!({
5246 4 : "id": "urn:ngsi-ld:Subscription:1",
5247 4 : "__context": {"Vehicle": "https://example.org/Vehicle"},
5248 : });
5249 :
5250 4 : let ctx = sub_context_memo(&st, &tenant, "alpha", &sub, &mut memo).await;
5251 :
5252 4 : assert_eq!(ctx.expand_key("Vehicle"), "https://example.org/Vehicle");
5253 4 : assert!(memo.is_empty(), "an inline @context left a memo entry");
5254 4 : }
5255 : }
|