LCOV - code coverage report
Current view: top level - antares-api/src/notify - throttle.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 88.0 % 274 241
Test Date: 2026-11-02 13:21:27 Functions: 58.6 % 174 102

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The change drain and its delivery lanes: batching, per-(tenant, subscription) folding, the byte ledger and the per-tenant delivery slots.
       3              : 
       4              : use super::*;
       5              : 
       6              : /// Groups dropped from a full delivery lane, since process start.
       7              : #[cfg(all(test, not(target_arch = "wasm32")))]
       8            8 : pub(crate) fn lane_drops() -> u64 {
       9            8 :     LANE_DROPS.load(std::sync::atomic::Ordering::Relaxed)
      10            8 : }
      11              : 
      12              : /// Changes one drain of the queue folds into one delivery pass — a batch
      13              : /// request's N writes arrive as N events back to back and leave as ONE
      14              : /// notification per matching subscription. Bounded so a flood cannot hold
      15              : /// the first notification back indefinitely.
      16              : pub(super) const CHANGE_BATCH: usize = 256;
      17              : 
      18              : /// The estimated heap one drain's groups may hold before they leave for their
      19              : /// lanes: an eighth of what the lanes hold together.
      20              : #[cfg(not(target_arch = "wasm32"))]
      21              : pub(super) static DRAIN_BYTES: std::sync::LazyLock<usize> =
      22           22 :     std::sync::LazyLock::new(|| *crate::bounds::DELIVERY_LANE_BYTES / 8);
      23              : 
      24              : /// One matched (subscription, entity) pair before delivery.
      25              : pub(super) struct Matched {
      26              :     pub(super) tenant: TenantId,
      27              :     /// Shared with the mirror that holds it: a change is evaluated against
      28              :     /// every candidate subscription, so owning a copy here made the cost of
      29              :     /// a change scale with the size of those documents.
      30              :     pub(super) sub: Arc<Value>,
      31              :     pub(super) ctx: Arc<Context>,
      32              :     pub(super) data: Vec<Value>,
      33              : }
      34              : 
      35            4 : pub async fn process_change(
      36            4 :     st: &AppState,
      37            4 :     tenant_str: &str,
      38            4 :     before: Option<Value>,
      39            4 :     after: Option<Value>,
      40            4 : ) {
      41            4 :     process_changes(st, vec![(tenant_str.to_owned(), before, after)]).await;
      42            4 : }
      43              : 
      44              : /// 5.8.6: "the Notification ... data ... shall contain the Entities that
      45              : /// match" — every change of one drain that matches the same subscription
      46              : /// travels in one notification, so a batch of N entities is one POST with N
      47              : /// data entries (and timesSent moves by one), never N POSTs.
      48            8 : pub(crate) async fn process_changes(st: &AppState, changes: Vec<Change>) {
      49            8 :     process_queued(
      50            8 :         st,
      51           24 :         changes.into_iter().map(|c| (u64::MAX, c)).collect(),
      52            8 :         None,
      53              :     )
      54            8 :     .await;
      55            8 : }
      56              : 
      57              : /// [`process_changes`] of changes each stamped with the [`crate::mirror::tick`]
      58              : /// they were queued at.
      59              : ///
      60              : /// With `done` (the matcher queue's drain), each group is handed to its
      61              : /// subscription's lane and the drain goes on to the next batch: a slow or
      62              : /// dead endpoint holds its own lane, never the matching of every other
      63              : /// change. `done` falls when the last group of the batch has left.
      64              : /// Without it (a bus event, a direct call), the groups are delivered before
      65              : /// this returns.
      66        89304 : pub(super) async fn process_queued(
      67        89304 :     st: &AppState,
      68        89304 :     changes: Vec<(u64, Change)>,
      69        89304 :     done: Option<Arc<BatchDone>>,
      70        89304 : ) {
      71        89304 :     let mut groups: Vec<Matched> = Vec::new();
      72              :     // (tenant, subscription id) → its group. A scan for the group would be
      73              :     // linear in the subscriptions already matched, and a drain of
      74              :     // CHANGE_BATCH changes over S matching subscriptions walks it S times per
      75              :     // change: quadratic in the one dimension this broker is built to grow
      76              :     // (100 000 subscriptions), on the notification hot path.
      77        89304 :     let mut index: std::collections::HashMap<(String, String), usize> =
      78        89304 :         std::collections::HashMap::new();
      79        89304 :     let mut ctx_memo = CtxMemo::new();
      80              :     // estimated heap of the groups this drain holds
      81              :     #[cfg(not(target_arch = "wasm32"))]
      82        89304 :     let mut held = 0usize;
      83       140905 :     for (queued, (tenant_str, before, after)) in changes {
      84       140905 :         let matched = matches_for(st, &tenant_str, before, after, queued, &mut ctx_memo);
      85              :         #[cfg(not(target_arch = "wasm32"))]
      86       140905 :         let matched = match tokio::time::timeout(STEP_DEADLINE, matched).await {
      87       140905 :             Ok(m) => m,
      88              :             Err(_) => {
      89            0 :                 note_stall("match", &tenant_str, "");
      90            0 :                 continue;
      91              :             }
      92              :         };
      93              :         #[cfg(target_arch = "wasm32")]
      94              :         let matched = matched.await;
      95       140905 :         for m in matched {
      96              :             #[cfg(not(target_arch = "wasm32"))]
      97              :             {
      98         9038 :                 held = m
      99         9038 :                     .data
     100         9038 :                     .iter()
     101         9062 :                     .fold(held, |n, e| n.saturating_add(heap_estimate(e)));
     102              :             }
     103         9038 :             let key = (
     104         9038 :                 m.tenant.as_str().to_owned(),
     105         9038 :                 m.sub
     106         9038 :                     .get("id")
     107         9038 :                     .and_then(Value::as_str)
     108         9038 :                     .unwrap_or_default()
     109         9038 :                     .to_owned(),
     110         9038 :             );
     111         9038 :             match index.get(&key) {
     112          153 :                 Some(&i) => {
     113          153 :                     if let Some(group) = groups.get_mut(i) {
     114          153 :                         group.data.extend(m.data);
     115          153 :                     }
     116              :                 }
     117         8885 :                 None => {
     118         8885 :                     index.insert(key, groups.len());
     119         8885 :                     groups.push(m);
     120         8885 :                 }
     121              :             }
     122              :         }
     123              :         // A drain of CHANGE_BATCH large changes over many subscriptions held
     124              :         // a shaped copy per (subscription, entity) until its end: 256 changes
     125              :         // of 3 KB entities times 20 subscriptions was 750 MB. Past
     126              :         // DRAIN_BYTES the groups so far leave for their lanes; a lane is
     127              :         // FIFO, so a subscription's changes still leave in drain order.
     128              :         #[cfg(not(target_arch = "wasm32"))]
     129       140905 :         if let Some(done) = &done {
     130       140881 :             if held > *DRAIN_BYTES {
     131            0 :                 for g in groups.drain(..) {
     132            0 :                     to_lane(st, g, done);
     133            0 :                 }
     134            0 :                 index.clear();
     135            0 :                 held = 0;
     136       140881 :             }
     137           24 :         }
     138              :     }
     139              :     // Groups are distinct subscriptions, so they leave concurrently; a
     140              :     // subscription's changes in this drain are already one group. On a lane
     141              :     // its groups leave in drain order; awaited, the next pass starts only
     142              :     // after this one. Either way per-subscription order holds.
     143              :     #[cfg(not(target_arch = "wasm32"))]
     144        89304 :     if let Some(done) = done {
     145        89296 :         for g in groups {
     146         8751 :             to_lane(st, g, &done);
     147         8751 :         }
     148        89296 :         return;
     149            8 :     }
     150              :     // Each group is its own task: a group's bookkeeping reads and writes
     151              :     // await the store, and one task's `for_each_concurrent` would still put
     152              :     // every group's round-trips on a single task's poll budget.
     153              :     #[cfg(not(target_arch = "wasm32"))]
     154              :     {
     155            8 :         let mut set = tokio::task::JoinSet::new();
     156          134 :         for g in groups {
     157          134 :             let st = st.clone();
     158          134 :             set.spawn(async move { deliver_group(&st, g).await });
     159              :         }
     160          142 :         while let Some(joined) = set.join_next().await {
     161          134 :             if joined.is_err() {
     162            0 :                 note_panic();
     163          134 :             }
     164              :         }
     165              :     }
     166              :     #[cfg(target_arch = "wasm32")]
     167              :     {
     168              :         drop(done);
     169              :         use futures_util::StreamExt;
     170              :         futures_util::stream::iter(groups)
     171              :             .for_each_concurrent(*DELIVERY_WIDTH, |g| async move {
     172              :                 deliver(st, &g.tenant, &g.sub, g.data, &g.ctx).await;
     173              :             })
     174              :             .await;
     175              :     }
     176        89304 : }
     177              : 
     178              : /// One group out: the delivery permits, then the delivery, each bounded by
     179              : /// [`STEP_DEADLINE`].
     180              : #[cfg(not(target_arch = "wasm32"))]
     181         4661 : pub(super) async fn deliver_group(st: &AppState, g: Matched) {
     182         4661 :     let id = g.sub.get("id").and_then(Value::as_str).unwrap_or_default();
     183              :     // The slots bound how many deliveries can wait on a peer. An in-process
     184              :     // sink has no peer to wait on, and its store calls join its tenant's
     185              :     // shared statements, so a fan-out to 1 000 sockets no longer
     186              :     // queues behind 8 slots for work that never leaves the process.
     187         4661 :     let _permits = if in_process_sink(st, &g.sub) {
     188          116 :         None
     189              :     } else {
     190         4545 :         match tokio::time::timeout(STEP_DEADLINE, delivery_permits(&g.tenant)).await {
     191         4545 :             Ok(permits) => Some(permits),
     192              :             Err(_) => {
     193            0 :                 note_stall("permits", g.tenant.as_str(), id);
     194            0 :                 tell_lost(st, &g);
     195            0 :                 return;
     196              :             }
     197              :         }
     198              :     };
     199         4661 :     let delivered = deliver(st, &g.tenant, &g.sub, g.data, &g.ctx);
     200         4661 :     if tokio::time::timeout(STEP_DEADLINE, delivered)
     201         4661 :         .await
     202         4610 :         .is_err()
     203            0 :     {
     204            0 :         note_stall("deliver", g.tenant.as_str(), id);
     205         4610 :     }
     206         4610 : }
     207              : 
     208              : /// A drained batch still owed to its subscribers: while any of its groups
     209              : /// waits on a lane, the batch counts as pending, so a shutdown drain that
     210              : /// waits for zero closes the pool only after the last delivery.
     211              : pub(crate) struct BatchDone {
     212              :     pub(super) pending: Arc<std::sync::atomic::AtomicUsize>,
     213              :     pub(super) taken: usize,
     214              : }
     215              : 
     216              : impl Drop for BatchDone {
     217        89340 :     fn drop(&mut self) {
     218        89340 :         self.pending
     219        89340 :             .fetch_sub(self.taken, std::sync::atomic::Ordering::SeqCst);
     220        89340 :     }
     221              : }
     222              : 
     223              : /// One group waiting on its subscription's lane, holding its batch pending
     224              : /// and its bytes on the lanes' ledger.
     225              : #[cfg(not(target_arch = "wasm32"))]
     226              : pub(super) struct LaneJob {
     227              :     pub(super) st: AppState,
     228              :     pub(super) group: Matched,
     229              :     pub(super) _batch: Arc<BatchDone>,
     230              :     pub(super) _held: Held,
     231              : }
     232              : 
     233              : /// The estimated heap bytes every delivery lane holds, against
     234              : /// [`crate::bounds::DELIVERY_LANE_BYTES`].
     235              : #[cfg(not(target_arch = "wasm32"))]
     236              : pub(super) struct Ledger(pub(super) std::sync::atomic::AtomicUsize);
     237              : 
     238              : #[cfg(not(target_arch = "wasm32"))]
     239              : pub(super) static LANE_LEDGER: Ledger = Ledger(std::sync::atomic::AtomicUsize::new(0));
     240              : 
     241              : #[cfg(not(target_arch = "wasm32"))]
     242              : impl Ledger {
     243              :     /// `bytes` more on the ledger, or `None` when they would take it past
     244              :     /// `budget`. A group larger than the whole budget is still taken on an
     245              :     /// empty ledger, so one big change is delivered rather than never.
     246         9075 :     pub(super) fn take(&'static self, bytes: usize, budget: usize) -> Option<Held> {
     247              :         use std::sync::atomic::Ordering;
     248         9075 :         self.0
     249         9075 :             .try_update(Ordering::AcqRel, Ordering::Acquire, |used| {
     250         9075 :                 let after = used.saturating_add(bytes);
     251         9075 :                 (used == 0 || after <= budget).then_some(after)
     252         9075 :             })
     253         9075 :             .ok()?;
     254         9067 :         metrics::gauge!("antares_notification_lane_bytes").increment(bytes as f64);
     255         9067 :         Some(Held {
     256         9067 :             ledger: self,
     257         9067 :             bytes,
     258         9067 :         })
     259         9075 :     }
     260              : 
     261              :     #[cfg(test)]
     262           12 :     pub(super) fn used(&self) -> usize {
     263           12 :         self.0.load(std::sync::atomic::Ordering::Acquire)
     264           12 :     }
     265              : }
     266              : 
     267              : /// Bytes a waiting group holds on the [`Ledger`], given back when the group
     268              : /// is delivered or dropped.
     269              : #[cfg(not(target_arch = "wasm32"))]
     270              : pub(super) struct Held {
     271              :     pub(super) ledger: &'static Ledger,
     272              :     pub(super) bytes: usize,
     273              : }
     274              : 
     275              : #[cfg(not(target_arch = "wasm32"))]
     276              : impl Drop for Held {
     277         9067 :     fn drop(&mut self) {
     278         9067 :         self.ledger
     279         9067 :             .0
     280         9067 :             .fetch_sub(self.bytes, std::sync::atomic::Ordering::AcqRel);
     281         9067 :         metrics::gauge!("antares_notification_lane_bytes").decrement(self.bytes as f64);
     282         9067 :     }
     283              : }
     284              : 
     285              : /// What `v` costs on the heap, estimated from how serde_json lays it out:
     286              : /// an object is a `BTreeMap`, whose every leaf node is about 640 bytes
     287              : /// however few of its eleven slots it fills, so the two-member `{"type",
     288              : /// "value"}` of an attribute instance costs a whole node (counting
     289              : /// a slot per member read 268 MB of lanes that held 620 MB); an array is its
     290              : /// capacity of slots; a string its bytes, rounded to the allocator's
     291              : /// 16-byte classes. Cheap next to the serialization a delivery does.
     292              : #[cfg(not(target_arch = "wasm32"))]
     293        18134 : pub(super) fn heap_estimate(v: &Value) -> usize {
     294        18134 :     std::mem::size_of::<Value>().saturating_add(owned_heap(v))
     295        18134 : }
     296              : 
     297              : /// The heap `v` owns beyond its own slot.
     298              : #[cfg(not(target_arch = "wasm32"))]
     299       113704 : pub(super) fn owned_heap(v: &Value) -> usize {
     300              :     const LEAF: usize = 640;
     301              :     const SLOT: usize = std::mem::size_of::<Value>();
     302       152832 :     let text = |len: usize| len.next_multiple_of(16);
     303       113704 :     match v {
     304        57742 :         Value::String(s) => text(s.len()),
     305          224 :         Value::Array(items) => items
     306          224 :             .iter()
     307          480 :             .fold(items.capacity().saturating_mul(SLOT), |n, item| {
     308          480 :                 n.saturating_add(owned_heap(item))
     309          480 :             }),
     310              :         // nodes fill to about two thirds once a map has more than one
     311        37648 :         Value::Object(map) => map.iter().fold(
     312        37648 :             LEAF.saturating_mul(map.len().div_ceil(8).max(1)),
     313        95090 :             |n, (k, item)| {
     314        95090 :                 n.saturating_add(text(k.len()))
     315        95090 :                     .saturating_add(owned_heap(item))
     316        95090 :             },
     317              :         ),
     318        18090 :         _ => 0,
     319              :     }
     320       113704 : }
     321              : 
     322              : /// (broker, tenant, subscription id) → the lane its groups wait on. An
     323              : /// entry lives while its lane has work: the lane's task removes it when it
     324              : /// finds the lane empty, under this lock, so a group is never sent to a lane
     325              : /// nobody serves. The broker part keeps two brokers of one process (tests
     326              : /// run one each, with the same tenants and subscription ids) on lanes of
     327              : /// their own, each served on its own runtime.
     328              : #[cfg(not(target_arch = "wasm32"))]
     329              : pub(super) type Lanes = std::sync::Mutex<HashMap<LaneKey, tokio::sync::mpsc::Sender<LaneJob>>>;
     330              : 
     331              : /// A lane's key: [`AppState::broker`], tenant, subscription id.
     332              : #[cfg(not(target_arch = "wasm32"))]
     333              : pub(super) type LaneKey = (usize, String, String);
     334              : #[cfg(not(target_arch = "wasm32"))]
     335              : pub(super) static LANES: std::sync::LazyLock<Lanes> = std::sync::LazyLock::new(Lanes::default);
     336              : 
     337              : /// Hand one group to its subscription's lane, opening the lane when there is
     338              : /// none. A full lane drops the group for that subscription alone, counted
     339              : /// and named: the drain never waits for a subscriber.
     340              : #[cfg(not(target_arch = "wasm32"))]
     341         9015 : pub(super) fn to_lane(st: &AppState, group: Matched, batch: &Arc<BatchDone>) {
     342              :     use tokio::sync::mpsc::error::TrySendError;
     343         9015 :     let key = (
     344         9015 :         st.broker(),
     345         9015 :         group.tenant.as_str().to_owned(),
     346         9015 :         group
     347         9015 :             .sub
     348         9015 :             .get("id")
     349         9015 :             .and_then(Value::as_str)
     350         9015 :             .unwrap_or_default()
     351         9015 :             .to_owned(),
     352         9015 :     );
     353         9015 :     let bytes = group
     354         9015 :         .data
     355         9015 :         .iter()
     356         9064 :         .fold(0usize, |n, entity| n.saturating_add(heap_estimate(entity)));
     357         9015 :     let Some(held) = LANE_LEDGER.take(bytes, *crate::bounds::DELIVERY_LANE_BYTES) else {
     358              :         // every lane together holds its budget: this subscription loses the
     359              :         // group, counted and named like a full lane's, and the process keeps
     360              :         // its memory
     361            0 :         note_lane_drop(&key, group.data.len());
     362            0 :         tell_lost(st, &group);
     363            0 :         return;
     364              :     };
     365         9015 :     let job = LaneJob {
     366         9015 :         st: st.clone(),
     367         9015 :         group,
     368         9015 :         _batch: batch.clone(),
     369         9015 :         _held: held,
     370         9015 :     };
     371              :     // A poisoned map is a panic in some past hand-off; what it holds is
     372              :     // senders, rebuilt on the next group either way.
     373         9015 :     let mut lanes = LANES.lock().unwrap_or_else(|p| p.into_inner());
     374         9015 :     let job = match lanes.get(&key) {
     375         4734 :         Some(lane) => match lane.try_send(job) {
     376          897 :             Ok(()) => return,
     377         3837 :             Err(TrySendError::Full(job)) => {
     378         3837 :                 note_lane_drop(&key, job.group.data.len());
     379         3837 :                 tell_lost(st, &job.group);
     380         3837 :                 return;
     381              :             }
     382              :             // its task is gone (a panic past the guard): open a new lane
     383            0 :             Err(TrySendError::Closed(job)) => job,
     384              :         },
     385         4281 :         None => job,
     386              :     };
     387         4281 :     let (lane, rx) = tokio::sync::mpsc::channel(DELIVERY_LANE);
     388         4281 :     if lane.try_send(job).is_err() {
     389              :         // a fresh channel always has room; nothing to serve otherwise
     390            0 :         return;
     391         4281 :     }
     392         4281 :     lanes.insert(key.clone(), lane);
     393         4281 :     drop(lanes);
     394         4281 :     tokio::spawn(run_lane(key, rx));
     395         9015 : }
     396              : 
     397              : /// Serve one subscription's lane in order until it is empty, then retire it.
     398              : #[cfg(not(target_arch = "wasm32"))]
     399         4281 : pub(super) async fn run_lane(key: LaneKey, mut rx: tokio::sync::mpsc::Receiver<LaneJob>) {
     400              :     // a group the last fold had no room for leads the next notification
     401         4277 :     let mut carry = None;
     402              :     loop {
     403         8753 :         let job = match carry.take().map_or_else(|| rx.try_recv().ok(), Some) {
     404         4523 :             Some(job) => job,
     405              :             None => {
     406              :                 // Looked at again under the map's lock, which every sender
     407              :                 // holds while it sends: a group cannot arrive between this
     408              :                 // look and the removal.
     409         4230 :                 let mut lanes = LANES.lock().unwrap_or_else(|p| p.into_inner());
     410         4230 :                 match rx.try_recv() {
     411            4 :                     Ok(job) => job,
     412              :                     Err(_) => {
     413         4226 :                         lanes.remove(&key);
     414         4226 :                         return;
     415              :                     }
     416              :                 }
     417              :             }
     418              :         };
     419         4527 :         let (st, group, batches, rest) = fold(job, &mut rx);
     420         4527 :         carry = rest;
     421         4527 :         guarded(async move { deliver_group(&st, group).await }).await;
     422         4476 :         drop(batches);
     423              :     }
     424         4226 : }
     425              : 
     426              : /// A fold: the state, the merged group, the batches and ledger bytes it
     427              : /// holds until delivered, and the job it had no room for.
     428              : #[cfg(not(target_arch = "wasm32"))]
     429              : pub(super) type Folded = (
     430              :     AppState,
     431              :     Matched,
     432              :     Vec<(Arc<BatchDone>, Held)>,
     433              :     Option<LaneJob>,
     434              : );
     435              : 
     436              : /// What waits behind `job` on its lane leaves in the same notification, as
     437              : /// a drain folds one subscription's changes into one group: a lane keeps up
     438              : /// by sending fewer, larger notifications, not by queueing one per change.
     439              : /// The fold stops before a group that would take the notification past
     440              : /// [`CHANGE_BATCH`] entities and hands that group back, so a backed-up lane
     441              : /// sends several bounded POSTs, never its whole depth in one. The newest
     442              : /// group carries the subscription as it reads now; the batches stay pending
     443              : /// until the delivery ends. A group of another state (a second broker in
     444              : /// the process, with the same tenant and subscription id) leads the next
     445              : /// notification: each leaves through its own state's sinks.
     446              : #[cfg(not(target_arch = "wasm32"))]
     447         4555 : pub(super) fn fold(job: LaneJob, rx: &mut tokio::sync::mpsc::Receiver<LaneJob>) -> Folded {
     448              :     let LaneJob {
     449         4555 :         st,
     450         4555 :         mut group,
     451         4555 :         _batch,
     452         4555 :         _held,
     453         4555 :     } = job;
     454         4555 :     let mut batches = vec![(_batch, _held)];
     455         4704 :     while let Ok(next) = rx.try_recv() {
     456          169 :         if group.data.len().saturating_add(next.group.data.len()) > CHANGE_BATCH
     457          157 :             || !next.st.same(&st)
     458              :         {
     459           20 :             return (st, group, batches, Some(next));
     460          149 :         }
     461          149 :         let mut data = std::mem::take(&mut group.data);
     462          149 :         data.extend(next.group.data);
     463          149 :         group = Matched { data, ..next.group };
     464          149 :         batches.push((next._batch, next._held));
     465              :     }
     466         4535 :     (st, group, batches, None)
     467         4555 : }
     468              : 
     469              : /// A group this process dropped, told to the subscription's binding, which
     470              : /// says so in band where it can (a socket's `x-gap`).
     471              : #[cfg(not(target_arch = "wasm32"))]
     472         3837 : pub(super) fn tell_lost(st: &AppState, g: &Matched) {
     473         3837 :     if let (Some(uri), Some(id)) = (
     474         3837 :         g.sub.as_object().and_then(super::endpoint_uri),
     475         3837 :         g.sub.get("id").and_then(Value::as_str),
     476              :     ) {
     477         3837 :         if let Some(sink) = st.sinks.sink_for_uri(uri) {
     478         3837 :             sink.lost(uri, g.tenant.as_str(), id);
     479         3837 :         }
     480            0 :     }
     481         3837 : }
     482              : 
     483              : /// Groups a full lane refused: counted by entity, and named by tenant and
     484              : /// subscription in the log, so an operator can tell which subscriber fell
     485              : /// behind and lost changes.
     486              : #[cfg(not(target_arch = "wasm32"))]
     487         3837 : pub(super) fn note_lane_drop(key: &LaneKey, entities: usize) {
     488         3837 :     let n = LANE_DROPS
     489         3837 :         .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
     490         3837 :         .saturating_add(1);
     491         3837 :     metrics::counter!("antares_notification_lane_dropped_total").increment(entities as u64);
     492         3837 :     if n == 1 || n.is_multiple_of(100) {
     493           40 :         tracing::warn!(
     494              :             "subscription {} of tenant {} is slower than its changes: {entities} entities dropped from its delivery lane ({n} groups so far, all subscriptions)",
     495              :             key.2,
     496              :             key.1
     497              :         );
     498         3797 :     }
     499         3837 : }
     500              : 
     501              : #[cfg(not(target_arch = "wasm32"))]
     502              : pub(super) static LANE_DROPS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
     503              : 
     504              : /// How long one step of a drain may take: matching one change, waiting for
     505              : /// one group's delivery permits, or that delivery. A drain waits for all of
     506              : /// its groups and the next drain for it, so a step that never ends stops
     507              : /// notifications for every Subscription of the process. A send is
     508              : /// at most 30 s (`endpoint_timeout_ms`), so a live step stays well inside.
     509              : #[cfg(not(target_arch = "wasm32"))]
     510              : pub(super) const STEP_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60);
     511              : 
     512              : /// A step abandoned at [`STEP_DEADLINE`]: counted and named, so the wait
     513              : /// that never ended is in the log.
     514              : #[cfg(not(target_arch = "wasm32"))]
     515            0 : pub(super) fn note_stall(step: &'static str, tenant: &str, sub: &str) {
     516            0 :     metrics::counter!("antares_notification_stalls_total", "step" => step).increment(1);
     517            0 :     tracing::warn!(
     518              :         "notification step {step} abandoned after {}s (tenant {tenant}, subscription {sub}); the drain goes on",
     519            0 :         STEP_DEADLINE.as_secs()
     520              :     );
     521            0 : }
     522              : 
     523              : /// A change the full queue refused: counted, and said once per thousand so
     524              : /// a 40 % delivery gap is visible in the log and not only on the counter.
     525            0 : pub(super) fn note_drop() {
     526            0 :     let n = CHANGES_DROPPED
     527            0 :         .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
     528            0 :         .saturating_add(1);
     529            0 :     metrics::counter!("antares_notification_changes_dropped_total").increment(1);
     530            0 :     if n == 1 || n.is_multiple_of(1000) {
     531            0 :         tracing::warn!("notification change queue full: {n} changes dropped so far (delivery slower than the write rate)");
     532            0 :     }
     533            0 : }
     534              : 
     535              : #[cfg(not(target_arch = "wasm32"))]
     536              : pub(super) static DELIVERY_SLOTS: std::sync::LazyLock<tokio::sync::Semaphore> =
     537           16 :     std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(*DELIVERY_WIDTH));
     538              : 
     539              : #[cfg(not(target_arch = "wasm32"))]
     540              : pub(super) type TenantSlots =
     541              :     std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>;
     542              : #[cfg(not(target_arch = "wasm32"))]
     543              : pub(super) static TENANT_SLOTS: std::sync::LazyLock<TenantSlots> =
     544              :     std::sync::LazyLock::new(TenantSlots::default);
     545              : 
     546              : /// The tenant's share, created on first use. An entry lives only while a
     547              : /// delivery is holding it: a lookup that has to create one first drops every
     548              : /// entry nothing else still references, so this map is bounded by the
     549              : /// delivery width and not by how many of the broker's 10 000 tenants have
     550              : /// ever had a subscription fire.
     551              : #[cfg(not(target_arch = "wasm32"))]
     552         4741 : pub(super) fn tenant_slots(tenant: &TenantId) -> Arc<tokio::sync::Semaphore> {
     553              :     // A poisoned map is a panic in some past delivery, not a reason to stop
     554              :     // delivering: what it holds is rebuildable and bounded either way.
     555         4741 :     let mut map = TENANT_SLOTS.lock().unwrap_or_else(|p| p.into_inner());
     556         4741 :     if let Some(s) = map.get(tenant.as_str()) {
     557         4673 :         return Arc::clone(s);
     558           68 :     }
     559           68 :     map.retain(|_, s| Arc::strong_count(s) > 1);
     560           68 :     let slots = Arc::new(tokio::sync::Semaphore::new(*DELIVERY_WIDTH_PER_TENANT));
     561           68 :     map.insert(tenant.as_str().to_owned(), Arc::clone(&slots));
     562           68 :     slots
     563         4741 : }
     564              : 
     565              : /// Both permits one delivery holds until it settles. The tenant's share is
     566              : /// taken first, so a tenant already at its share waits there instead of
     567              : /// waiting inside one of the broker's slots.
     568              : #[cfg(not(target_arch = "wasm32"))]
     569         4741 : pub(super) async fn delivery_permits(tenant: &TenantId) -> impl Sized {
     570         4741 :     let share = tenant_slots(tenant).acquire_owned().await;
     571         4729 :     (share, DELIVERY_SLOTS.acquire().await)
     572         4729 : }
     573              : 
     574              : /// Whether the Subscription's notifications stay in this process: its
     575              : /// binding opens no socket (an in-process one) or the forward transport takes them.
     576              : /// The same test `deliver_as` polices egress by.
     577              : #[cfg(not(target_arch = "wasm32"))]
     578         4661 : pub(super) fn in_process_sink(st: &AppState, sub: &Value) -> bool {
     579         4661 :     sub.as_object()
     580         4661 :         .and_then(super::endpoint_uri)
     581         4661 :         .is_some_and(|uri| {
     582         4661 :             in_process(st, uri).is_some()
     583         4655 :                 || st.sinks.sink_for_uri(uri).is_some_and(|s| !s.network())
     584         4661 :         })
     585         4661 : }
        

Generated by: LCOV version 2.0-1