LCOV - code coverage report
Current view: top level - antares-bus/src - lib.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 99.1 % 110 109
Test Date: 2026-09-21 10:31:06 Functions: 94.7 % 19 18

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Change-event bus.
       3              : //!
       4              : //! The event and its transport. `bus = local`, the default and what the
       5              : //! ETSI pipeline runs, carries changes in-process through the store's change
       6              : //! hook and needs nothing from here but `ChangeEvent`. `bus = nats` adds the
       7              : //! JetStream spine (`ANTARES_CHANGES`, durable pull consumers, KV
       8              : //! subscription mirror) that makes multi-instance roles possible, and
       9              : //! becomes mandatory only on scale-out. The composition root
      10              : //! (`antares-broker/src/wiring.rs`) is the only place that names either.
      11              : #![cfg_attr(not(test), warn(clippy::expect_used))]
      12              : 
      13              : pub mod nats;
      14              : pub mod subjects;
      15              : 
      16              : use antares_model::{EntityId, TenantId};
      17              : use serde::{Deserialize, Serialize};
      18              : 
      19              : /// Operation kind — mirrors Scorpio's requestType int registry as an enum.
      20              : #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
      21              : #[serde(rename_all = "camelCase")]
      22              : pub enum ChangeOp {
      23              :     Create,
      24              :     Update,
      25              :     Append,
      26              :     Merge,
      27              :     Replace,
      28              :     Delete,
      29              :     BatchCreate,
      30              :     BatchUpsert,
      31              :     BatchUpdate,
      32              :     BatchDelete,
      33              :     BatchMerge,
      34              : }
      35              : 
      36              : /// Claim-check reference: events whose payload exceeds
      37              : /// [`CLAIM_CHECK_BYTES`] carry this instead of the inline body. NATS caps
      38              : /// messages at ~1 MB and Antares never chunks, so the body travels out of
      39              : /// band: the publisher keeps the outbox row that holds the whole event, and
      40              : /// the consumer reads it back by the event's `seq`. The store's current row
      41              : /// is the after-image and can stand in for `payload` alone — a before-image
      42              : /// is not derivable from it, which is why the reference is not a document
      43              : /// lookup.
      44              : #[derive(Debug, Clone, Serialize, Deserialize)]
      45              : pub struct PayloadRef {
      46              :     pub entity_id: EntityId,
      47              :     pub version: i64,
      48              : }
      49              : 
      50              : /// Inline-payload ceiling before the claim check kicks in (256 KB).
      51              : pub const CLAIM_CHECK_BYTES: usize = 256 * 1024;
      52              : 
      53              : /// One entity change. Self-contained: carries payload AND prev_payload so
      54              : /// consumers (matcher, temporal recorder) never re-read the DB per event.
      55              : /// `version` is the entity row version bumped under the write lock —
      56              : /// state-projecting consumers apply last-writer-wins on
      57              : /// `(incarnation, version)`; `incarnation` is the row's created_at, which
      58              : /// disambiguates delete/recreate (the version restarts at 1).
      59              : #[derive(Debug, Clone, Serialize, Deserialize)]
      60              : pub struct ChangeEvent {
      61              :     pub tenant: TenantId,
      62              :     pub entity_id: EntityId,
      63              :     pub types: Vec<String>,
      64              :     pub op: ChangeOp,
      65              :     pub changed_attrs: Vec<String>,
      66              :     pub payload: Option<serde_json::Value>,
      67              :     pub prev_payload: Option<serde_json::Value>,
      68              :     pub version: i64,
      69              :     /// The row's created_at — the incarnation half of the ordering key.
      70              :     #[serde(default)]
      71              :     pub incarnation: String,
      72              :     /// Outbox row id — the `Nats-Msg-Id` dedup key. 0 = local bus.
      73              :     #[serde(default)]
      74              :     pub seq: i64,
      75              :     /// Claim-check: set when `payload` was stripped for size.
      76              :     #[serde(default, skip_serializing_if = "Option::is_none")]
      77              :     pub payload_ref: Option<PayloadRef>,
      78              :     #[serde(default, skip_serializing_if = "Option::is_none")]
      79              :     pub prev_payload_ref: Option<PayloadRef>,
      80              : }
      81              : 
      82              : /// A body's serialized size against the claim-check ceiling. An
      83              : /// unserializable body counts as zero: it cannot be published either way, and
      84              : /// treating it as oversized would retain a row nothing can resolve.
      85           34 : fn over(v: &Option<serde_json::Value>, limit: usize) -> bool {
      86           34 :     v.as_ref()
      87           34 :         .is_some_and(|p| serde_json::to_vec(p).map(|b| b.len()).unwrap_or(0) > limit)
      88           34 : }
      89              : 
      90              : impl ChangeEvent {
      91              :     /// True when [`ChangeEvent::claim_check`] would strip a body at `limit`.
      92              :     /// The publisher asks before it publishes: a stripped body has to stay
      93              :     /// readable somewhere the consumer can reach, and the drain is the last
      94              :     /// holder of the whole event.
      95            6 :     pub fn claim_checked_at(&self, limit: usize) -> bool {
      96            6 :         over(&self.payload, limit) || over(&self.prev_payload, limit)
      97            6 :     }
      98              : 
      99              :     /// Claim-check: replace any inline body over `limit` bytes with a
     100              :     /// reference. Oversized entities are rare; the common path is untouched.
     101           12 :     pub fn claim_check(mut self, limit: usize) -> Self {
     102           12 :         if over(&self.payload, limit) {
     103            4 :             self.payload = None;
     104            4 :             self.payload_ref = Some(PayloadRef {
     105            4 :                 entity_id: self.entity_id.clone(),
     106            4 :                 version: self.version,
     107            4 :             });
     108            8 :         }
     109           12 :         if over(&self.prev_payload, limit) {
     110            6 :             self.prev_payload = None;
     111            6 :             self.prev_payload_ref = Some(PayloadRef {
     112            6 :                 entity_id: self.entity_id.clone(),
     113            6 :                 // saturating: a decoded event's version is whatever the wire
     114            6 :                 // said, and wrapping would reference the wrong document
     115            6 :                 version: self.version.saturating_sub(1),
     116            6 :             });
     117            6 :         }
     118              :         // The ENVELOPE must fit too, or the publish is refused by the bus and
     119              :         // the outbox drain retries the same row forever. changed_attrs is the
     120              :         // one member that scales with the entity's width, and no consumer
     121              :         // reads it off the wire (process_change re-derives the diff from the
     122              :         // payloads); types stay — the publish subject is built from them.
     123           12 :         if serde_json::to_vec(&self)
     124           12 :             .map(|b| b.len())
     125           12 :             .unwrap_or(usize::MAX)
     126           12 :             > limit
     127            2 :         {
     128            2 :             self.changed_attrs = Vec::new();
     129           10 :         }
     130           12 :         self
     131           12 :     }
     132              : }
     133              : 
     134              : #[cfg(test)]
     135              : mod tests {
     136              :     use super::*;
     137              : 
     138           10 :     fn event(version: i64) -> ChangeEvent {
     139           10 :         ChangeEvent {
     140           10 :             tenant: TenantId::default(),
     141           10 :             entity_id: EntityId::new("urn:ngsi-ld:Vehicle:A1").expect("valid urn"),
     142           10 :             types: vec!["https://uri.etsi.org/ngsi-ld/default-context/Vehicle".into()],
     143           10 :             op: ChangeOp::Create,
     144           10 :             changed_attrs: vec![],
     145           10 :             payload: Some(serde_json::json!({"speed": 80})),
     146           10 :             prev_payload: None,
     147           10 :             version,
     148           10 :             incarnation: "2026-08-05T00:00:00Z".into(),
     149           10 :             seq: 0,
     150           10 :             payload_ref: None,
     151           10 :             prev_payload_ref: None,
     152           10 :         }
     153           10 :     }
     154              : 
     155              :     #[test]
     156            2 :     fn event_round_trips_through_serde() {
     157            2 :         let e = event(7);
     158            2 :         let bytes = serde_json::to_vec(&e).expect("serialize");
     159            2 :         let back: ChangeEvent = serde_json::from_slice(&bytes).expect("deserialize");
     160            2 :         assert_eq!(back.version, 7);
     161            2 :         assert_eq!(back.op, ChangeOp::Create);
     162            2 :         assert_eq!(back.incarnation, "2026-08-05T00:00:00Z");
     163            2 :     }
     164              : 
     165              :     #[test]
     166            2 :     fn claim_check_bounds_the_envelope_not_only_the_bodies() {
     167            2 :         let mut e = event(9);
     168            2 :         e.payload = Some(serde_json::json!({"small": true}));
     169              :         // the envelope itself outgrows the limit: thousands of attribute
     170              :         // IRIs from one wide entity, with both bodies tiny
     171            2 :         e.changed_attrs = (0..20_000)
     172        40000 :             .map(|i| format!("https://example.org/ngsi-ld/attributes/generated/a{i:05}"))
     173            2 :             .collect();
     174            2 :         let limit = 256 * 1024;
     175            2 :         let checked = e.claim_check(limit);
     176            2 :         let wire = serde_json::to_vec(&checked).expect("serialize");
     177            2 :         assert!(
     178            2 :             wire.len() <= limit,
     179              :             "the published message must fit the bus limit, got {} bytes",
     180            0 :             wire.len()
     181              :         );
     182            2 :         assert!(
     183            2 :             checked.payload.is_some(),
     184              :             "a small body is not the thing to strip for an oversized envelope"
     185              :         );
     186            2 :         assert!(
     187            2 :             checked.changed_attrs.is_empty(),
     188              :             "changed_attrs is re-derived by the consumer from the payloads, so it goes first"
     189              :         );
     190            2 :         assert!(
     191            2 :             !checked.types.is_empty(),
     192              :             "types must survive — the publish subject is built from them"
     193              :         );
     194            2 :     }
     195              : 
     196              :     /// The publisher asks `claim_checked_at` BEFORE it publishes and keeps
     197              :     /// the outbox row when the answer is yes. An answer that disagrees with
     198              :     /// the strip either keeps every row (the outbox never drains) or keeps
     199              :     /// none (the consumer resolves a reference to a row that is gone).
     200              :     #[test]
     201            2 :     fn claim_checked_at_answers_for_the_bodies_the_strip_takes() {
     202            2 :         let mut fits = event(3);
     203            2 :         fits.payload = Some(serde_json::json!({"small": true}));
     204            2 :         fits.prev_payload = Some(serde_json::json!({"small": false}));
     205            2 :         assert!(!fits.claim_checked_at(512));
     206            2 :         assert!(fits.clone().claim_check(512).payload_ref.is_none());
     207              : 
     208            2 :         let mut prev_only = fits.clone();
     209            2 :         prev_only.prev_payload = Some(serde_json::Value::String("x".repeat(1024)));
     210            2 :         assert!(
     211            2 :             prev_only.claim_checked_at(512),
     212              :             "a stripped before-image is the one the store cannot answer for"
     213              :         );
     214            2 :         assert!(prev_only.claim_check(512).prev_payload_ref.is_some());
     215              : 
     216            2 :         let mut both = fits;
     217            2 :         both.payload = Some(serde_json::Value::String("x".repeat(1024)));
     218            2 :         both.prev_payload = Some(serde_json::Value::String("y".repeat(1024)));
     219            2 :         assert!(both.claim_checked_at(512));
     220            2 :     }
     221              : 
     222              :     #[test]
     223            2 :     fn claim_check_strips_only_oversized_bodies() {
     224            2 :         let mut e = event(3);
     225            2 :         e.prev_payload = Some(serde_json::json!({"small": true}));
     226            2 :         e.payload = Some(serde_json::Value::String("x".repeat(1024)));
     227            2 :         let checked = e.claim_check(512);
     228            2 :         assert!(checked.payload.is_none(), "over-limit body stripped");
     229            2 :         let r = checked.payload_ref.as_ref().expect("ref set");
     230            2 :         assert_eq!(r.version, 3);
     231            2 :         assert!(
     232            2 :             checked.prev_payload.is_some() && checked.prev_payload_ref.is_none(),
     233              :             "under-limit body inline"
     234              :         );
     235            2 :     }
     236              : 
     237              :     /// A decoded event carries whatever `version` the wire said. The claim
     238              :     /// check derives the previous version from it, and that arithmetic must
     239              :     /// not overflow — an overflow is a panic in debug builds and a wrap in
     240              :     /// release, i.e. a reference to the wrong document.
     241              :     #[test]
     242            2 :     fn claim_check_does_not_underflow_on_an_extreme_version() {
     243            2 :         let mut e = event(i64::MIN);
     244            2 :         e.prev_payload = Some(serde_json::Value::String("x".repeat(1024)));
     245            2 :         let checked = e.claim_check(512);
     246            2 :         assert_eq!(
     247            2 :             checked.prev_payload_ref.expect("ref set").version,
     248              :             i64::MIN,
     249              :             "must saturate, never wrap to i64::MAX"
     250              :         );
     251            2 :     }
     252              : }
        

Generated by: LCOV version 2.0-1