Skip to main content

rs_matter/im/
subscriptions.rs

1/*
2 *
3 *    Copyright (c) 2024-2026 Project CHIP Authors
4 *
5 *    Licensed under the Apache License, Version 2.0 (the "License");
6 *    you may not use this file except in compliance with the License.
7 *    You may obtain a copy of the License at
8 *
9 *        http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *    Unless required by applicable law or agreed to in writing, software
12 *    distributed under the License is distributed on an "AS IS" BASIS,
13 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *    See the License for the specific language governing permissions and
15 *    limitations under the License.
16 */
17
18use core::num::NonZeroU8;
19
20use embassy_time::Instant;
21
22#[cfg(feature = "persistent-subscriptions")]
23use crate::error::Error;
24use crate::fabric::MAX_FABRICS;
25use crate::im::{AttrId, ClusterId, DeviceLoad, EndptId, EventId, EventNumber, IMBuffer, NodeId};
26#[cfg(feature = "persistent-subscriptions")]
27use crate::persist::{KvBlobStore, PERSISTENT_SUBSCRIPTIONS_START};
28#[cfg(feature = "persistent-subscriptions")]
29use crate::tlv::{FromTLV, OctetStr, Octets, TLVElement, TLVTag, ToTLV};
30use crate::utils::cell::RefCell;
31use crate::utils::init::{init, Init};
32use crate::utils::storage::pooled::Buffers;
33use crate::utils::storage::Vec;
34#[cfg(feature = "persistent-subscriptions")]
35use crate::utils::storage::WriteBuf;
36use crate::utils::sync::blocking::Mutex;
37use crate::utils::sync::{DynBase, Notification};
38
39/// The maximum number of subscriptions that can be tracked at the same time by default.
40///
41/// According to the Matter spec, at least 3 subscriptions per fabric should be supported.
42pub const DEFAULT_MAX_SUBSCRIPTIONS: usize = MAX_FABRICS * 3;
43
44/// The maximum number of changed-attribute entries tracked simultaneously.
45///
46/// When the table is full, entries are coalesced ("promoted") to coarser-grained
47/// wildcards so that new changes can always be recorded.
48pub const MAX_CHANGED_ATTRS: usize = 16;
49
50/// A struct for the RX buffers containing the read requests of the tracked subscriptions.
51// NOTE: `SubscriptionsBuffers` is a thin wrapper around a second
52// `Mutex<RefCell<Vec<..>>>` that is *always* locked in lockstep with
53// `Subscriptions::state` (see `Subscriptions::with`). As long as that lock
54// order is respected the pair is safe, but the two locks let someone (now or
55// in the future) lock only one of them and violate the invariant that
56// `subscriptions.len() == buffers.len()`. The cleanest fix is to move the
57// `Vec<B::Buffer<'a>, N>` *into* `SubscriptionsInner` behind the same mutex
58// so it cannot be locked independently. The current layout also forces all
59// public APIs to thread an extra `&SubscriptionsBuffers` argument everywhere,
60// which is why `remove` / `report` / `add` all grew a second ref parameter.
61pub struct SubscriptionsBuffers<'a, B, const N: usize = DEFAULT_MAX_SUBSCRIPTIONS>
62where
63    B: Buffers<IMBuffer> + 'a,
64{
65    buffers: Mutex<RefCell<SubscriptionsBuffersInner<'a, B, N>>>,
66}
67
68impl<'a, B, const N: usize> SubscriptionsBuffers<'a, B, N>
69where
70    B: Buffers<IMBuffer> + 'a,
71{
72    /// Create the instance.
73    pub const fn new() -> Self {
74        Self {
75            buffers: Mutex::new(RefCell::new(Vec::new())),
76        }
77    }
78
79    /// Return an in-place initializer for the instance.
80    pub fn init() -> impl Init<Self> {
81        init!(Self {
82            buffers <- Mutex::init(RefCell::init(Vec::init())),
83        })
84    }
85
86    fn with<F, R>(&self, f: F) -> R
87    where
88        F: FnOnce(&mut SubscriptionsBuffersInner<'a, B, N>) -> R,
89    {
90        self.buffers.lock(|buffers| f(&mut buffers.borrow_mut()))
91    }
92}
93
94impl<'a, B, const N: usize> Default for SubscriptionsBuffers<'a, B, N>
95where
96    B: Buffers<IMBuffer> + 'a,
97{
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// A type alias for the inner buffer vector of `SubscriptionsBuffers`.
104type SubscriptionsBuffersInner<'a, B, const N: usize> =
105    Vec<<B as Buffers<IMBuffer>>::Buffer<'a>, N>;
106
107/// A type for tracking subscriptions accepted by the data model.
108///
109/// The `N` type parameter specifies the maximum number of subscriptions that can be tracked at the same time.
110/// Additional subscriptions are rejected by the data model with a "resource exhausted" IM status message.
111pub struct Subscriptions<const N: usize = DEFAULT_MAX_SUBSCRIPTIONS> {
112    state: Mutex<RefCell<SubscriptionsInner<N>>>,
113    pub(crate) notification: Notification,
114}
115
116impl<const N: usize> Subscriptions<N> {
117    /// Create the instance.
118    #[inline(always)]
119    pub const fn new() -> Self {
120        Self {
121            state: Mutex::new(RefCell::new(SubscriptionsInner::new())),
122            notification: Notification::new(),
123        }
124    }
125
126    /// Create an in-place initializer for the instance.
127    pub fn init() -> impl Init<Self> {
128        init!(Self {
129            state <- Mutex::init(RefCell::init(SubscriptionsInner::init())),
130            notification <- Notification::init(),
131        })
132    }
133
134    /// Whether subscription persistence is compiled in.
135    ///
136    /// `true` only when the `persistent-subscriptions` feature is enabled; when it
137    /// is off, the persist/resume machinery is compiled out entirely and this is a
138    /// `const false` that lets the linker drop the callers.
139    pub const fn persist_enabled(&self) -> bool {
140        cfg!(feature = "persistent-subscriptions")
141    }
142
143    /// Notify the instance that the data of a specific attribute has changed and that it should re-evaluate the subscriptions
144    /// and report on those that are interested in the changed data.
145    ///
146    /// This method is supposed to be called by the application code whenever it changes the data of an attribute.
147    ///
148    /// # Arguments
149    /// - `endpoint_id`: The endpoint ID of the cluster that had changed.
150    /// - `cluster_id`: The cluster ID of the cluster that had changed.
151    /// - `attr_id`: The attribute ID of the attribute that changed.
152    pub(crate) fn notify_attr_changed(
153        &self,
154        endpoint_id: EndptId,
155        cluster_id: ClusterId,
156        attr_id: AttrId,
157    ) {
158        self.state.lock(|internal| {
159            internal
160                .borrow_mut()
161                .changed_attrs
162                .record(endpoint_id, cluster_id, attr_id);
163        });
164
165        // The per-subscription decision of whether anything needs to be reported is
166        // computed on-the-fly by `find_report_due` (and by the responder's filter)
167        // by consulting the live `changed_attrs` table, so there is no per-sub flag
168        // to flip here. We just wake the reporter task.
169        self.notification.notify();
170    }
171
172    /// Record a cluster-wide change. Every attribute of `(endpoint_id,
173    /// cluster_id)` is treated as changed for the purposes of subscription
174    /// reporting.
175    pub(crate) fn notify_cluster_changed(&self, endpoint_id: EndptId, cluster_id: ClusterId) {
176        self.state.lock(|internal| {
177            internal
178                .borrow_mut()
179                .changed_attrs
180                .record_wildcard(Some(endpoint_id), Some(cluster_id));
181        });
182
183        self.notification.notify();
184    }
185
186    /// Record an endpoint-wide change. Every attribute on every cluster of
187    /// `endpoint_id` is treated as changed for the purposes of subscription
188    /// reporting.
189    pub(crate) fn notify_endpoint_changed(&self, endpoint_id: EndptId) {
190        self.state.lock(|internal| {
191            internal
192                .borrow_mut()
193                .changed_attrs
194                .record_wildcard(Some(endpoint_id), None);
195        });
196
197        self.notification.notify();
198    }
199
200    /// Whether a live subscription exists on `fab_idx` whose subscriber node
201    /// matches `node_id`.
202    ///
203    /// This is the ICD "is this client subscribed?" predicate that decides
204    /// whether a registered client is nudged with a Check-In. Persisted
205    /// subscriptions are re-hydrated into this same table at startup
206    /// (`resume_subscriptions`), so a resumed subscription counts here too — no
207    /// separate "persisted subscription" lookup is needed.
208    pub fn has_subscription_for(&self, fab_idx: NonZeroU8, node_id: NodeId) -> bool {
209        self.state.lock(|internal| {
210            internal
211                .borrow()
212                .subscriptions
213                .iter()
214                .any(|s| s.ids.fab_idx == fab_idx && s.ids.peer_node_id == node_id)
215        })
216    }
217
218    /// A consistent snapshot of the subscription-related figures of
219    /// `GeneralDiagnostics::DeviceLoadStatus`.
220    ///
221    /// The Interaction Model message counters are left at their defaults -
222    /// this type knows nothing about them; see
223    /// [`crate::im::InteractionModel::load_stats`].
224    pub fn load_stats(&self, fab_idx: Option<NonZeroU8>) -> DeviceLoad {
225        self.state.lock(|internal| {
226            let internal = internal.borrow();
227
228            DeviceLoad {
229                // Counts the in-flight subscription too, if any.
230                current_subscriptions: internal.subscriptions_count as _,
231                // Walks the active list, so a subscription that is in-flight at
232                // this instant is not counted - it carries no fabric index
233                // while it sits in the `ReportContext`.
234                current_subscriptions_for_fabric: fab_idx
235                    .map(|fab_idx| {
236                        internal
237                            .subscriptions
238                            .iter()
239                            .filter(|s| s.ids.fab_idx == fab_idx)
240                            .count() as _
241                    })
242                    .unwrap_or(0),
243                // The ID counter is monotonic and hands out 1 first.
244                total_subscriptions_established: internal.next_subscription_id.saturating_sub(1),
245                ..Default::default()
246            }
247        })
248    }
249
250    /// Record a fully-global change. Every attribute on every cluster on
251    /// every endpoint is treated as changed for the purposes of subscription
252    /// reporting. Intended for coarse-grained reset / restart scenarios.
253    pub(crate) fn notify_all_changed(&self) {
254        self.state.lock(|internal| {
255            internal
256                .borrow_mut()
257                .changed_attrs
258                .record_wildcard(None, None);
259        });
260
261        self.notification.notify();
262    }
263
264    /// Notify the instance that a new event has been emitted and that it should
265    /// re-evaluate the subscriptions and report on those that are interested in the new event.
266    ///
267    /// Public for the integration tests.
268    pub fn notify_event_emitted(
269        &self,
270        _endpoint_id: EndptId,
271        _cluster_id: ClusterId,
272        _event_id: EventId,
273    ) {
274        // Events are filtered at report time by `min_event_number` + event path matching.
275        // Whether a subscription is due to report because of new events is recomputed on
276        // the fly in `find_report_due`, so here we only need to kick the reporter task.
277        self.notification.notify();
278    }
279
280    /// Clear all subscriptions and pending changes.
281    /// Used when initializing a new data model.
282    pub(crate) fn clear(&self) {
283        self.state.lock(|state| state.borrow_mut().clear());
284    }
285
286    /// Add a new subscription with the given parameters.
287    /// Returns a context for the initial report if successful, or `None` if the subscription table is full.
288    #[allow(clippy::too_many_arguments)]
289    pub(crate) fn add<'a, 's, B>(
290        &'s self,
291        now: Instant,
292        fabric_idx: NonZeroU8,
293        peer_node_id: u64,
294        min_int_secs: u16,
295        max_int_secs: u16,
296        event_numbers_watermark: EventNumber,
297        buffer: B::Buffer<'a>,
298        buffers: &'s SubscriptionsBuffers<'a, B, N>,
299    ) -> Option<ReportContext<'a, 's, B, N>>
300    where
301        B: Buffers<IMBuffer> + 'a,
302    {
303        let (sub, buf, next_max_seen_attr_change_id) = self.with(buffers, |state, buffers| {
304            let (sub, buf) = state.add::<B>(
305                fabric_idx,
306                peer_node_id,
307                min_int_secs,
308                max_int_secs,
309                buffer,
310                buffers,
311            )?;
312
313            // Mirror `report()`: commit the current watermark so the priming
314            // report's `set_keep` does not regress the subscription's `since`
315            // to 0 (which would cause every pre-`add` change to be replayed
316            // on the first incremental report).
317            Some((sub, buf, state.changed_attrs.watermark()))
318        })?;
319
320        Some(ReportContext {
321            subscriptions: self,
322            subscriptions_buffers: buffers,
323            subscription: Some(sub),
324            subscription_buffer: Some(buf),
325            next_max_seen_attr_change_id,
326            next_max_seen_event_number: event_numbers_watermark,
327            next_reported_at: now,
328            next_retry_at: Instant::MIN,
329            next_fail_count: 0,
330            keep: false,
331        })
332    }
333
334    /// Remove every subscription for which `f` returns `Some(reason)`.
335    ///
336    /// A subscription that is currently being reported on has been moved out
337    /// of `state.subscriptions` into its `ReportContext` (see
338    /// [`SubscriptionsInner::report`]). To keep such an in-flight subscription
339    /// observable, [`SubscriptionsInner::report`] also leaves a clone of it in
340    /// `state.reporting`. If the predicate matches that clone, we flip
341    /// `state.reporting_cancelled` so that [`SubscriptionsInner::report_complete`]
342    /// drops the subscription on `Drop` of its `ReportContext` instead of
343    /// re-inserting it. The count invariant is preserved: either the Vec path
344    /// decrements `subscriptions_count` now, or `report_complete` does it
345    /// later — never both for the same subscription.
346    pub(crate) fn remove<B, F>(&self, buffers: &SubscriptionsBuffers<'_, B, N>, mut f: F) -> bool
347    where
348        B: Buffers<IMBuffer>,
349        F: FnMut(&Subscription) -> Option<&'static str>,
350    {
351        let removed = self.with(buffers, |state, buffers| {
352            let mut removed = false;
353
354            loop {
355                let next = state
356                    .subscriptions
357                    .iter()
358                    .enumerate()
359                    .filter_map(|(index, subscription)| {
360                        f(subscription).map(|reason| (index, subscription.ids().clone(), reason))
361                    })
362                    .next();
363
364                let Some((index, ids, reason)) = next else {
365                    break;
366                };
367
368                state.subscriptions.swap_remove(index);
369                buffers.swap_remove(index);
370
371                state.subscriptions_count -= 1;
372
373                info!("Removed subscription {:?}, reason: {}", ids, reason);
374
375                removed = true;
376            }
377
378            // Consider the in-flight subscription (if any). It is not in
379            // `state.subscriptions`; only a snapshot clone lives in
380            // `state.reporting`. If the predicate matches and we have not
381            // already flagged it for cancellation, request that
382            // `report_complete` drop it.
383            if state.reporting_cancelled.is_none() {
384                if let Some(sub) = state.reporting.as_ref() {
385                    if let Some(reason) = f(sub) {
386                        info!(
387                            "Marked in-flight subscription {:?} for removal, reason: {}",
388                            sub.ids(),
389                            reason
390                        );
391                        state.reporting_cancelled = Some(reason);
392                        removed = true;
393                    }
394                }
395            }
396
397            removed
398        });
399
400        if removed {
401            self.notification.notify();
402        }
403
404        removed
405    }
406
407    /// Begin a report for the subscription with the given parameters.
408    /// Returns a context capturing the subscription's current state if successful, or `None`
409    /// if no subscription is currently reportable.
410    pub(crate) fn report<'a, 's, B>(
411        &'s self,
412        now: Instant,
413        event_numbers_watermark: EventNumber,
414        buffers: &'s SubscriptionsBuffers<'a, B, N>,
415    ) -> Option<ReportContext<'a, 's, B, N>>
416    where
417        B: Buffers<IMBuffer> + 'a,
418    {
419        let (sub, buf, next_max_seen_attr_change_id) = self.with(buffers, |state, buffers| {
420            let (sub, buf) = state.report::<B>(now, event_numbers_watermark, buffers)?;
421            let attr_change_ids_watermark = state.changed_attrs.watermark();
422
423            debug!("About to report on subscription {:?}, details: max_seen_attr_change_id: {}, max_seen_event_number: {}, attr_change_ids_watermark: {}, event_numbers_watermark: {}", sub.ids(), sub.max_seen_attr_change_id, sub.max_seen_event_number, attr_change_ids_watermark, event_numbers_watermark);
424
425            Some((sub, buf, attr_change_ids_watermark))
426        })?;
427
428        Some(ReportContext {
429            subscriptions: self,
430            subscriptions_buffers: buffers,
431            subscription: Some(sub),
432            subscription_buffer: Some(buf),
433            next_max_seen_attr_change_id,
434            next_max_seen_event_number: event_numbers_watermark,
435            next_reported_at: now,
436            next_retry_at: Instant::MIN,
437            next_fail_count: 0,
438            keep: false,
439        })
440    }
441
442    /// Earliest [`Instant`] at which any subscription will next need
443    /// servicing, or [`Instant::MAX`] if there are no (primed) subscriptions
444    /// and the reporter should simply wait to be notified.
445    ///
446    /// See [`Subscription::next_report_at`] for the per-subscription rule.
447    pub(crate) fn next_report_at<'a, B>(
448        &self,
449        event_numbers_watermark: EventNumber,
450        buffers: &SubscriptionsBuffers<'a, B, N>,
451    ) -> Instant
452    where
453        B: Buffers<IMBuffer> + 'a,
454    {
455        self.with(buffers, |state, buffers| {
456            state.next_report_at::<B>(event_numbers_watermark, buffers)
457        })
458    }
459
460    /// Remove entries that every subscription has already reported on.
461    pub(crate) fn purge_reported_changes(&self) {
462        self.state
463            .lock(|state| state.borrow_mut().purge_reported_changes())
464    }
465
466    /// Complete a report by updating the subscription's watermark and last-reported timestamp,
467    /// and re-inserting it into the table if the `keep` flag is set on the context.
468    fn report_complete<'a, B>(&self, report: &mut ReportContext<'a, '_, B, N>)
469    where
470        B: Buffers<IMBuffer> + 'a,
471    {
472        let mut sub = unwrap!(report.subscription.take());
473        let buf = unwrap!(report.subscription_buffer.take());
474
475        sub.max_seen_attr_change_id = report.next_max_seen_attr_change_id;
476        sub.max_seen_event_number = report.next_max_seen_event_number;
477        sub.reported_at = report.next_reported_at;
478        sub.retry_at = report.next_retry_at;
479        sub.fail_count = report.next_fail_count;
480
481        let keep = report.keep;
482
483        self.with(report.subscriptions_buffers, |state, buffers| {
484            state.report_complete::<B>(sub, buf, buffers, keep)
485        })
486    }
487
488    fn with<'a, B, F, R>(&self, buffers: &SubscriptionsBuffers<'a, B, N>, f: F) -> R
489    where
490        B: Buffers<IMBuffer> + 'a,
491        F: FnOnce(&mut SubscriptionsInner<N>, &mut SubscriptionsBuffersInner<'a, B, N>) -> R,
492    {
493        self.state.lock(|state| {
494            let mut state = state.borrow_mut();
495            buffers.with(|buffers| f(&mut state, buffers))
496        })
497    }
498}
499
500/// Subscription persistence: the whole table is mirrored to (and resumed from)
501/// the key-value store, one record per subscription. Gated as a unit so that a
502/// device that does not want persistence drops it — and the TLV serialization
503/// and key-value store traffic it pulls in — entirely.
504#[cfg(feature = "persistent-subscriptions")]
505impl<const N: usize> Subscriptions<N> {
506    /// Compile-time proof that every slot of this table maps to a key inside
507    /// the reserved persisted-subscription range - i.e. that persisting can
508    /// never run past it and into the vendor keys.
509    ///
510    /// Evaluated by every method below that maps a slot to a key, so an
511    /// oversized table is rejected at build time rather than corrupting
512    /// whatever lives past the range. Being a check on a generic parameter, it
513    /// is only evaluated once monomorphized - i.e. it surfaces on `cargo build`
514    /// / `cargo test`, not on `cargo check`.
515    // `::core::assert!` rather than the crate-wide `assert!`, which maps to
516    // `defmt::assert!` under the `defmt` feature and is not const-callable.
517    const SLOTS_FIT: () = ::core::assert!(
518        N <= crate::persist::MAX_PERSISTED_SUBSCRIPTIONS,
519        "the subscriptions table is larger than the reserved persisted-subscription key range"
520    );
521
522    /// Persist the whole subscription table to `kv`, one record per key.
523    ///
524    /// Each subscription is written under its own key
525    /// (`PERSISTENT_SUBSCRIPTIONS_START + slot`) so that no single value grows
526    /// beyond one subscribe request, and any keys past the current table length
527    /// are removed. Rewriting the full range keeps the on-disk set an exact
528    /// mirror of the live table without a per-subscription slot allocator;
529    /// subscriptions change rarely, so the write amplification is negligible.
530    ///
531    /// Persistence is best-effort and spec-optional: a subscription whose record does
532    /// not fit in `buf` is simply skipped (logged), not treated as an error.
533    pub(crate) fn persist_all<'a, B, S>(
534        &self,
535        buffers: &SubscriptionsBuffers<'a, B, N>,
536        mut kv: S,
537        buf: &mut [u8],
538    ) -> Result<(), Error>
539    where
540        B: Buffers<IMBuffer> + 'a,
541        S: KvBlobStore,
542    {
543        // Force the slot-range assertion to be evaluated.
544        let () = Self::SLOTS_FIT;
545
546        self.with(buffers, |state, buffers| {
547            for (slot, (sub, rx)) in state
548                .subscriptions
549                .iter()
550                .zip(buffers.iter())
551                .take(N)
552                .enumerate()
553            {
554                let key = PERSISTENT_SUBSCRIPTIONS_START + slot as u16;
555
556                let record = PersistedSubscription {
557                    fab_idx: sub.ids.fab_idx,
558                    peer_node_id: sub.ids.peer_node_id,
559                    min_int_secs: sub.min_int_secs,
560                    max_int_secs: sub.max_int_secs,
561                    subscribe_req: Octets(rx.as_ref()),
562                };
563
564                // Serialize into the front of `buf`, leaving the tail as the store's
565                // scratch (mirrors `Persist::store`). If it does not fit, skip it —
566                // persisting is optional, so a too-large subscription is simply not
567                // resumable across a reboot.
568                let mut wb = WriteBuf::new(buf);
569                if record.to_tlv(&TLVTag::Anonymous, &mut wb).is_err() {
570                    warn!(
571                        "Subscription {:?} too large to persist; skipping",
572                        sub.ids()
573                    );
574                    continue;
575                }
576
577                let len = wb.get_tail();
578                let (data, scratch) = buf.split_at_mut(len);
579                kv.store(key, data, scratch)?;
580            }
581
582            // Drop the tail keys that a now-shorter table no longer occupies.
583            for slot in state.subscriptions.len()..N {
584                let key = PERSISTENT_SUBSCRIPTIONS_START + slot as u16;
585                kv.remove(key, buf)?;
586            }
587
588            Ok(())
589        })
590    }
591
592    /// Re-hydrate the subscription table from `kv`, replaying each persisted record
593    /// through [`Self::add`] exactly as if the subscribe request had just arrived.
594    ///
595    /// Each reloaded subscription enters the table un-primed, so the reporter sends
596    /// it a prompt priming report (establishing a session on demand) rather than
597    /// waiting toward its max-interval deadline — see the priming note in the body
598    /// for why that timing matters. A record that cannot be re-added (e.g. no free
599    /// buffer) is skipped.
600    pub(crate) fn load_persist<'a, 's, B, S>(
601        &'s self,
602        pool: &'a B,
603        buffers: &'s SubscriptionsBuffers<'a, B, N>,
604        mut kv: S,
605        buf: &mut [u8],
606        now: Instant,
607        event_numbers_watermark: EventNumber,
608    ) -> Result<(), Error>
609    where
610        'a: 's,
611        B: Buffers<IMBuffer> + 'a,
612        S: KvBlobStore,
613    {
614        // Force the slot-range assertion to be evaluated.
615        let () = Self::SLOTS_FIT;
616
617        for slot in 0..N {
618            let key = PERSISTENT_SUBSCRIPTIONS_START + slot as u16;
619
620            let Some(data) = kv.load(key, buf)? else {
621                // The range is contiguous: the first empty slot ends the set.
622                break;
623            };
624
625            let record = PersistedSubscription::from_tlv(&TLVElement::new(data))?;
626
627            let Some(mut rx) = pool.get_immediate() else {
628                warn!("No free buffer to resume a persisted subscription; skipping");
629                continue;
630            };
631            rx.clear();
632            if rx.extend_from_slice(record.subscribe_req.0).is_err() {
633                warn!("Persisted subscription too large for an RX buffer; skipping");
634                continue;
635            }
636
637            let added = self.add(
638                now,
639                record.fab_idx,
640                record.peer_node_id,
641                record.min_int_secs,
642                record.max_int_secs,
643                event_numbers_watermark,
644                rx,
645                buffers,
646            );
647
648            match added {
649                Some(mut rctx) => {
650                    // Commit it into the table as an un-primed subscription so the
651                    // reporter sends a full priming report to the subscriber right
652                    // away (establishing a session on demand), rather than staying
653                    // silent until the max-interval liveness point.
654                    //
655                    // This is essential, not cosmetic: the *subscriber* measures its
656                    // own liveness timeout from the last report IT received, not from
657                    // our reboot. If we waited toward our own max-interval deadline,
658                    // the subscriber — whose clock has been running the whole time we
659                    // were down — could already have torn the subscription down. A
660                    // prompt priming report resets the subscriber's liveness clock and
661                    // re-syncs the state it missed while we were away.
662                    //
663                    // Leaving `reported_at` at the `Instant::MAX` priming sentinel
664                    // (rather than stamping it with `now`) is what marks it un-primed.
665                    rctx.next_reported_at = Instant::MAX;
666                    rctx.set_keep();
667                    info!(
668                        "Resumed persisted subscription {:?}",
669                        rctx.subscription().ids()
670                    );
671                }
672                None => {
673                    warn!("Subscription table full while resuming; dropping a persisted record")
674                }
675            }
676        }
677
678        Ok(())
679    }
680
681    /// Remove every persisted subscription record from `kv`.
682    ///
683    /// Used on a factory reset, and to clear the range before it is re-hydrated.
684    pub(crate) fn reset_persist<S>(&self, mut kv: S, buf: &mut [u8]) -> Result<(), Error>
685    where
686        S: KvBlobStore,
687    {
688        // Force the slot-range assertion to be evaluated.
689        let () = Self::SLOTS_FIT;
690
691        for slot in 0..N {
692            let key = PERSISTENT_SUBSCRIPTIONS_START + slot as u16;
693            kv.remove(key, buf)?;
694        }
695
696        Ok(())
697    }
698}
699
700impl<const N: usize> Default for Subscriptions<N> {
701    fn default() -> Self {
702        Self::new()
703    }
704}
705
706impl<const N: usize> DynBase for Subscriptions<N> {}
707
708/// The inner state of `Subscriptions`, protected by a mutex.
709/// See `Subscriptions` for the public API and invariants.
710struct SubscriptionsInner<const N: usize> {
711    /// Monotonically increasing ID assigned to every accepted subscription.
712    /// The first assigned ID is 1; `0` is reserved as the "no subscription" sentinel used by `reporting`.
713    next_subscription_id: u32,
714    /// The total number of accepted subscriptions, including any currently
715    /// in-flight one (i.e. one whose `Subscription` has been moved into a
716    /// `ReportContext` and is therefore temporarily not in `subscriptions`).
717    /// Used to enforce the `N` capacity bound in `add`.
718    subscriptions_count: usize,
719    /// The active subscriptions. Does NOT include a subscription that is
720    /// currently being reported on; see `reporting` for the snapshot of the
721    /// in-flight one.
722    subscriptions: Vec<Subscription, N>,
723    /// The changed attributes that subscriptions are consulting to decide whether and what they need to report.
724    changed_attrs: ChangedAttrs,
725    /// Snapshot of the subscription currently being reported on (i.e. the
726    /// one that has been `swap_remove`d into a `ReportContext`). `None` when
727    /// no report is in flight. This is a frozen clone captured at `report()`
728    /// time; mutations made by `ReportContext` (e.g. to
729    /// `max_seen_event_number`) are NOT visible here. The slot exists so
730    /// that `Subscriptions::remove` can still observe and cancel an
731    /// in-flight subscription.
732    reporting: Option<Subscription>,
733    /// Set by `Subscriptions::remove` when its predicate matched
734    /// `reporting`. Consumed by `report_complete`, which then drops the
735    /// subscription (and decrements `subscriptions_count`) regardless of the
736    /// `keep` flag on the `ReportContext`.
737    reporting_cancelled: Option<&'static str>,
738}
739
740impl<const N: usize> SubscriptionsInner<N> {
741    /// Create the instance.
742    #[inline(always)]
743    const fn new() -> Self {
744        Self {
745            next_subscription_id: 1,
746            subscriptions_count: 0,
747            subscriptions: Vec::new(),
748            changed_attrs: ChangedAttrs::new(),
749            reporting: None,
750            reporting_cancelled: None,
751        }
752    }
753
754    /// Create an in-place initializer for the instance.
755    fn init() -> impl Init<Self> {
756        init!(Self {
757            next_subscription_id: 1,
758            subscriptions_count: 0,
759            subscriptions <- Vec::init(),
760            changed_attrs <- ChangedAttrs::init(),
761            reporting: None,
762            reporting_cancelled: None,
763        })
764    }
765
766    fn clear(&mut self) {
767        self.subscriptions.clear();
768        self.subscriptions_count = 0;
769        // If a report is in flight, make sure `report_complete` drops it
770        // rather than pushing it back into an otherwise-empty table.
771        if self.reporting.is_some() {
772            self.reporting_cancelled = Some("subscriptions cleared");
773            // The in-flight subscription is still counted in
774            // `subscriptions_count` until `report_complete` runs; restore
775            // that so the decrement there balances.
776            self.subscriptions_count = 1;
777        }
778    }
779
780    /// Add a subscription with the given parameters.
781    ///
782    /// Returns the assigned subscription ID on success, or `None` if the subscription table is full.
783    #[allow(clippy::too_many_arguments)]
784    fn add<'a, B>(
785        &mut self,
786        fab_idx: NonZeroU8,
787        peer_node_id: u64,
788        min_int_secs: u16,
789        max_int_secs: u16,
790        buffer: B::Buffer<'a>,
791        _buffers: &mut SubscriptionsBuffersInner<'a, B, N>,
792    ) -> Option<(Subscription, B::Buffer<'a>)>
793    where
794        B: Buffers<IMBuffer> + 'a,
795    {
796        if self.subscriptions_count >= N {
797            return None;
798        }
799
800        self.subscriptions_count += 1;
801
802        let id = self.next_subscription_id;
803        self.next_subscription_id += 1;
804
805        // Start with the current watermark so that only changes happening AFTER the
806        // subscription was accepted will be reported as incremental updates.
807        let max_seen_attr_change_id = self.changed_attrs.watermark();
808
809        let subscription = Subscription {
810            ids: SubscriptionIds {
811                id,
812                fab_idx,
813                peer_node_id,
814            },
815            min_int_secs,
816            max_int_secs,
817            reported_at: Instant::MAX,
818            retry_at: Instant::MIN,
819            fail_count: 0,
820            max_seen_attr_change_id,
821            // Start at 0 so the priming report delivers every event that was
822            // already in the event buffer at subscribe time. The reader will
823            // advance this via `update_max_seen_event_number` once the
824            // priming report has consumed the events.
825            max_seen_event_number: 0,
826        };
827
828        info!("Added subscription {:?}", subscription.ids());
829
830        Some((subscription, buffer))
831    }
832
833    /// Begin a report for the subscription with the given ID.
834    ///
835    /// Returns a small [`ReportContext`] capturing the subscription's current
836    /// `since` watermark and the watermark to commit via [`Self::mark_reported`]
837    /// on success. Unlike a snapshot, the `changed_attrs` table itself is not
838    /// copied; the report uses a [`SubAttrChangeFilter`] that consults the
839    /// live table one attribute at a time.
840    ///
841    /// `priming = true` produces a context with filtering disabled; it is
842    /// used for the initial ("priming") report delivered right after a
843    /// subscription is accepted.
844    fn report<'a, B>(
845        &mut self,
846        now: Instant,
847        event_numbers_watermark: EventNumber,
848        buffers: &mut SubscriptionsBuffersInner<'a, B, N>,
849    ) -> Option<(Subscription, B::Buffer<'a>)>
850    where
851        B: Buffers<IMBuffer> + 'a,
852    {
853        // `reporting` must be vacant: callers only start a new report after
854        // the previous `ReportContext` has been dropped (which clears the
855        // slot via `report_complete`).
856        debug_assert!(self.reporting.is_none());
857        debug_assert!(self.reporting_cancelled.is_none());
858
859        if let Some(index) = self.find_reportable::<B>(now, event_numbers_watermark, buffers) {
860            let sub = self.subscriptions.swap_remove(index);
861            let buf = buffers.swap_remove(index);
862
863            debug!("About to report on subscription {:?}", sub.ids());
864
865            // Leave a snapshot clone behind so that `Subscriptions::remove`
866            // can still match and cancel this subscription while the report
867            // is in flight.
868            self.reporting = Some(sub.clone());
869
870            Some((sub, buf))
871        } else {
872            None
873        }
874    }
875
876    fn report_complete<'a, B>(
877        &mut self,
878        sub: Subscription,
879        buffer: B::Buffer<'a>,
880        buffers: &mut SubscriptionsBuffersInner<'a, B, N>,
881        keep: bool,
882    ) where
883        B: Buffers<IMBuffer> + 'a,
884    {
885        // Always clear the reporting slot; it was populated in `report()`.
886        self.reporting = None;
887        let cancelled = self.reporting_cancelled.take();
888
889        if let Some(reason) = cancelled {
890            info!(
891                "In-flight subscription {:?} cancelled during reporting: {}",
892                sub.ids(),
893                reason
894            );
895            self.subscriptions_count -= 1;
896        } else if keep {
897            debug!("Subscription {:?} kept after reporting; max-attr-change-id: {}, max-seen-event-number: {}", sub.ids(), sub.max_seen_attr_change_id, sub.max_seen_event_number);
898
899            unwrap!(self.subscriptions.push(sub));
900            unwrap!(buffers.push(buffer).map_err(|_| ()));
901        } else {
902            warn!("Subscription {:?} removed during reporting", sub.ids());
903            self.subscriptions_count -= 1;
904        }
905    }
906
907    fn find_reportable<'a, B>(
908        &self,
909        now: Instant,
910        event_numbers_watermark: EventNumber,
911        buffers: &SubscriptionsBuffersInner<'a, B, N>,
912    ) -> Option<usize>
913    where
914        B: Buffers<IMBuffer> + 'a,
915    {
916        self.subscriptions
917            .iter()
918            .enumerate()
919            .map(|(index, sub)| (sub, &buffers[index]))
920            .position(|(sub, rx)| {
921                sub.is_reportable(now, rx, &self.changed_attrs, event_numbers_watermark)
922            })
923    }
924
925    /// Remove entries that every subscription has already reported on.
926    fn purge_reported_changes(&mut self) {
927        if let Some(min_seen_attr_change_id) = self
928            .subscriptions
929            .iter()
930            .map(|s| s.max_seen_attr_change_id)
931            .min()
932        {
933            self.changed_attrs.purge_up_to(min_seen_attr_change_id);
934        } else {
935            self.changed_attrs.clear();
936        }
937    }
938
939    /// Earliest [`Instant`] at which any subscription will next need servicing,
940    /// or [`Instant::MAX`] if none has a wake point (empty table or all
941    /// not-yet-primed).
942    fn next_report_at<'a, B>(
943        &self,
944        event_numbers_watermark: EventNumber,
945        buffers: &SubscriptionsBuffersInner<'a, B, N>,
946    ) -> Instant
947    where
948        B: Buffers<IMBuffer> + 'a,
949    {
950        self.subscriptions
951            .iter()
952            .enumerate()
953            .map(|(index, sub)| {
954                sub.next_report_at(
955                    &buffers[index],
956                    &self.changed_attrs,
957                    event_numbers_watermark,
958                )
959            })
960            .min()
961            .unwrap_or(Instant::MAX)
962    }
963}
964
965/// The IDs of a subscription, used to identify it across the system and to route reports to it.
966#[derive(Clone, Debug)]
967#[cfg_attr(feature = "defmt", derive(defmt::Format))]
968pub struct SubscriptionIds {
969    /// The ID of the subscription. Uniquely identifies the subscription across all of them.
970    pub id: u32,
971    /// The fabric index of the subscriber. Used to route reports and to remove all subscriptions of a fabric when it gets removed.
972    pub fab_idx: NonZeroU8,
973    /// The node ID of the subscriber. Used to route reports and to remove all subscriptions of a peer when it gets removed.
974    pub peer_node_id: NodeId,
975}
976
977/// The on-disk form of a single subscription.
978///
979/// One record is stored per subscription under its own key (see
980/// [`PERSISTENT_SUBSCRIPTIONS_START`]), so the value never grows beyond a single
981/// subscribe request. Everything the reporter needs to resume the subscription
982/// is captured here: the routing `(fab_idx, peer_node_id)`, the negotiated
983/// intervals, and the raw `SubscribeReq` TLV — the same bytes the live
984/// subscription keeps in its RX buffer and re-parses on every report, so no
985/// separate path list is serialized.
986#[cfg(feature = "persistent-subscriptions")]
987#[derive(Debug, FromTLV, ToTLV)]
988#[tlvargs(lifetime = "'a")]
989struct PersistedSubscription<'a> {
990    fab_idx: NonZeroU8,
991    peer_node_id: NodeId,
992    min_int_secs: u16,
993    max_int_secs: u16,
994    /// The raw `SubscribeReq` TLV that created this subscription (its selected
995    /// attribute/event paths and fabric-filtered flag live inside it).
996    subscribe_req: OctetStr<'a>,
997}
998
999#[derive(Clone, Debug)]
1000#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1001pub struct Subscription {
1002    /// The IDs of the subscription
1003    ids: SubscriptionIds,
1004    /// The minimum interval in seconds. The subscription should not receive reports more frequently than this interval, but may receive them less frequently.
1005    /// We use u16 instead of embassy::Duration to save some storage
1006    min_int_secs: u16,
1007    /// The maximum interval in seconds. The subscription should receive reports at least this frequently, even if there are no changes to report (i.e. it is a liveness deadline).
1008    /// We use u16 instead of embassy::Duration to save some storage
1009    max_int_secs: u16,
1010    /// The timestamp of the last SUCCESSFUL report sent to this subscription. Used to decide when the next report is due based on the min/max intervals — and, crucially, when to give up: `is_expired` measures `max_int` from here, so a run of *failed* reports (which do NOT advance it) eventually expires the subscription rather than retrying forever.
1011    /// Set to `Instant::MAX` when the subscription is created to indicate that no report has been sent yet, so the first report is due immediately. After the first successful report, it is updated to the actual timestamp of that report.
1012    reported_at: Instant,
1013    /// Earliest instant at which a report may be attempted again after a *failed*
1014    /// send (see [`ReportContext::set_keep_retry`]). `Instant::MIN` means no retry
1015    /// is pending (normal operation). Gates the report-timing helpers so a peer we
1016    /// cannot reach is retried with a growing back-off instead of in a tight loop.
1017    retry_at: Instant,
1018    /// Number of consecutive failed report attempts, driving the retry back-off.
1019    /// Reset to `0` on any successful report.
1020    fail_count: u8,
1021    /// The largest attribute change ID from the [`ChangedAttributes`] table this subscription
1022    /// has already reported on. Entries with a larger change ID represent pending changes the subscription still needs to emit.
1023    max_seen_attr_change_id: u64,
1024    /// The largest event number this subscription has already reported on. Events with a larger event number represent pending events the subscription still needs to emit.
1025    max_seen_event_number: u64,
1026}
1027
1028impl Subscription {
1029    /// Return the IDs of the subscription.
1030    pub const fn ids(&self) -> &SubscriptionIds {
1031        &self.ids
1032    }
1033
1034    /// Return `true` if the subscription is expired and should be removed, or `false` if it is still active.
1035    pub fn is_expired(&self, now: Instant) -> bool {
1036        self.reported_at
1037            .checked_add(embassy_time::Duration::from_secs(self.max_int_secs as _))
1038            .map(|expiry| expiry <= now)
1039            .unwrap_or(false)
1040    }
1041
1042    /// The back-off (in seconds) to wait before the `fail_count`-th consecutive
1043    /// retry of a failed report.
1044    ///
1045    /// Exponential — `BASE * 2^(fail_count - 1)` — capped at `max_int`: it never
1046    /// helps to wait longer than the point at which [`Self::is_expired`] gives up
1047    /// on the subscription. The cap also means the subscription is guaranteed to
1048    /// expire (from the pinned `reported_at`) rather than back off indefinitely.
1049    fn retry_backoff_secs(fail_count: u8, max_int_secs: u16) -> u16 {
1050        /// First retry delay. Small enough to recover quickly from a transient
1051        /// blip, large enough not to hammer an unreachable peer.
1052        const BASE_SECS: u16 = 2;
1053
1054        let shift = fail_count.saturating_sub(1).min(15);
1055        let delay = (BASE_SECS as u32) << shift;
1056
1057        delay.min(max_int_secs.max(BASE_SECS) as u32) as u16
1058    }
1059
1060    /// Return `true` if the subscription is due for a report based on the given parameters, or `false` if it is not.
1061    fn is_reportable(
1062        &self,
1063        now: Instant,
1064        rx: &[u8],
1065        changed_attrs: &ChangedAttrs,
1066        event_numbers_watermark: EventNumber,
1067    ) -> bool {
1068        if !self.is_report_allowed(now) {
1069            return false;
1070        }
1071
1072        self.is_report_due(now)
1073            || self.is_affected_by_attr_changes(rx, changed_attrs)
1074            || self.is_affected_by_new_events(rx, event_numbers_watermark)
1075    }
1076
1077    /// Instant at which the min-interval quiet period ends — the earliest time
1078    /// a report is allowed ([`Self::is_report_allowed`] returns `true`).
1079    ///
1080    /// [`Instant::MIN`] when not yet primed (`reported_at == Instant::MAX`): a
1081    /// fresh subscription is allowed to report immediately (its priming report),
1082    /// and a point infinitely in the past reads correctly as "always allowed"
1083    /// for every consumer (the boolean gate below and `next_report_at`).
1084    fn report_allowed_at(&self) -> Instant {
1085        // A pending retry after a failed send is a hard floor on the next
1086        // attempt, even for a not-yet-primed subscription — otherwise a peer we
1087        // cannot reach would be retried in a tight loop.
1088        let min_int_gate = if self.reported_at == Instant::MAX {
1089            // Not yet primed: always allowed. This must be an explicit check, not
1090            // a reliance on `checked_add` saturating — with `min_int_secs == 0`
1091            // the add is `Instant::MAX + 0`, which does NOT overflow and would
1092            // wrongly yield `Instant::MAX` ("never allowed").
1093            Instant::MIN
1094        } else {
1095            self.reported_at
1096                .checked_add(embassy_time::Duration::from_secs(self.min_int_secs as _))
1097                .unwrap_or(Instant::MIN)
1098        };
1099
1100        min_int_gate.max(self.retry_at)
1101    }
1102
1103    /// Return `true` if the subscription is allowed to report based on the min interval, or `false` if it is still in the quiet period since the last report.
1104    fn is_report_allowed(&self, now: Instant) -> bool {
1105        self.report_allowed_at() <= now
1106    }
1107
1108    /// Instant at which the max-interval liveness window opens — the earliest
1109    /// time [`Self::is_report_due`] returns `true`.
1110    ///
1111    /// `reported_at + max_int - max_int / 2`, i.e. the half-interval mark.
1112    /// Waking before the negotiated maximum interval is this implementation's
1113    /// margin for completing the report in time.
1114    ///
1115    /// [`Instant::MIN`] when not yet primed (`reported_at == Instant::MAX`): a
1116    /// fresh subscription is immediately due for its priming report (see
1117    /// [`Self::report_allowed_at`] for why `MIN` is the right sentinel).
1118    fn report_due_at(&self) -> Instant {
1119        // Not yet primed: always due (explicit, for the same reason as
1120        // `report_allowed_at`).
1121        if self.reported_at == Instant::MAX {
1122            return Instant::MIN;
1123        }
1124
1125        self.reported_at
1126            .checked_add(embassy_time::Duration::from_secs(
1127                (self.max_int_secs - self.max_int_secs / 2) as _,
1128            ))
1129            .unwrap_or(Instant::MIN)
1130    }
1131
1132    /// Return `true` if the subscription is due for a report based on the max interval, or `false` if it is not yet due.
1133    fn is_report_due(&self, now: Instant) -> bool {
1134        self.report_due_at() <= now
1135    }
1136
1137    /// Return `true` if the subscription is affected by changes to the attribute triple `(endpoint, cluster, attr)` based on the subscription's RX and the given table of changed attributes, or `false` if it is not affected.
1138    fn is_affected_by_attr_changes(&self, _rx: &[u8], changes: &ChangedAttrs) -> bool {
1139        // NOTE: we could consult the subscription's RX here to skip the check if the subscription
1140        // is not interested in the changed path at all, but that would require parsing the RX at every report check,
1141        // which is anyway done later during reporting and the report is canceled if empty
1142        //
1143        // Therefore and for now do not to this here
1144        changes.any_since(self.max_seen_attr_change_id)
1145    }
1146
1147    /// Return `true` if the subscription is affected by new events based on the subscription's RX and the given event numbers watermark, or `false` if it is not affected.
1148    fn is_affected_by_new_events(&self, _rx: &[u8], event_numbers_watermark: EventNumber) -> bool {
1149        // NOTE: we could consult the subscription's RX here to skip the check if the subscription
1150        // is not interested in events at all, but that would require parsing the RX at every report check,
1151        // which is anyway done later during reporting and the report is canceled if empty
1152        //
1153        // Therefore and for now do not to this here
1154        self.max_seen_event_number < event_numbers_watermark
1155    }
1156
1157    /// Earliest [`Instant`] at which this subscription could next report.
1158    ///
1159    /// A not-yet-primed subscription (`reported_at == Instant::MAX`) yields
1160    /// [`Instant::MIN`] via both deadline helpers — "report now", so the reporter
1161    /// wakes immediately to deliver the priming report.
1162    ///
1163    /// Never earlier than the min-interval gate `reported_at + min_int`, before
1164    /// which a report SHALL NOT be sent (Matter spec). Subject to that
1165    /// gate, it is:
1166    /// - `reported_at + min_int` when a change or event is already pending, so
1167    ///   the wake lands at the end of the quiet period; otherwise
1168    /// - the liveness point ([`Self::report_due_at`]), when
1169    ///   [`Self::is_report_due`] flips — early enough for the report to be
1170    ///   received before `max_int` (the subscriber terminates otherwise).
1171    ///
1172    /// Clamping the liveness point up to the gate avoids a busy-spin when
1173    /// `min_int > max_int / 2`.
1174    fn next_report_at(
1175        &self,
1176        rx: &[u8],
1177        changed_attrs: &ChangedAttrs,
1178        event_numbers_watermark: EventNumber,
1179    ) -> Instant {
1180        let allowed_at = self.report_allowed_at();
1181
1182        // Use the same `rx` the report path feeds `is_reportable`, so this
1183        // prediction stays faithful if these checks ever start consulting it.
1184        let pending = self.is_affected_by_attr_changes(rx, changed_attrs)
1185            || self.is_affected_by_new_events(rx, event_numbers_watermark);
1186
1187        if pending {
1188            allowed_at
1189        } else {
1190            allowed_at.max(self.report_due_at())
1191        }
1192    }
1193}
1194
1195/// A table of recently-changed attribute triples, each tagged with an
1196/// ever-increasing `change_id`.
1197///
1198/// Subscriptions consult this table to decide which attributes they should
1199/// re-emit on their next report: only attributes with a matching entry whose
1200/// `change_id` is strictly greater than the subscription's own watermark
1201/// (`last_change_id`) need to be reported.
1202///
1203/// The table has a fixed capacity of [`MAX_CHANGED_ATTRS`] entries. When it
1204/// fills up, existing entries are coalesced to coarser-grained wildcards
1205/// (`(endpoint, cluster, *)` → `(endpoint, *, *)` → `(*, *, *)`) so that a new change can always
1206/// be recorded. A wildcard entry over-covers and will therefore cause the
1207/// affected subscriptions to emit a slightly wider set of attributes on their
1208/// next report, but this is a bounded loss of precision that preserves
1209/// correctness.
1210pub(crate) struct ChangedAttrs {
1211    /// Monotonically increasing ID assigned to every recorded change.
1212    /// The first assigned ID is 1; `0` is reserved as the "no change seen yet"
1213    /// sentinel used by fresh subscriptions.
1214    next_change_id: u64,
1215    /// The actual table of recent changes, ordered from oldest to newest.
1216    /// The newest change has `change_id == next_change_id - 1`.
1217    entries: Vec<ChangedAttr, MAX_CHANGED_ATTRS>,
1218}
1219
1220impl ChangedAttrs {
1221    /// Create the instance.
1222    #[inline(always)]
1223    const fn new() -> Self {
1224        Self {
1225            next_change_id: 1,
1226            entries: Vec::new(),
1227        }
1228    }
1229
1230    /// Return an in-place initializer for the instance.
1231    fn init() -> impl Init<Self> {
1232        init!(Self {
1233            next_change_id: 1,
1234            entries <- Vec::init(),
1235        })
1236    }
1237
1238    /// The largest change ID that has been assigned so far. A subscription
1239    /// whose max seen change ID is equal to the watermark has seen every change.
1240    #[inline]
1241    fn watermark(&self) -> u64 {
1242        self.next_change_id.wrapping_sub(1)
1243    }
1244
1245    /// Record a change to the attribute triple `(endpoint, cluster, attr)`.
1246    /// Returns the newly assigned change ID.
1247    fn record(&mut self, endpoint: EndptId, cluster: ClusterId, attr: AttrId) -> u64 {
1248        self.record_raw(ChangedAttr::concrete(endpoint, cluster, attr, 0))
1249    }
1250
1251    /// Record a cluster- or endpoint-wide wildcard change. `endpoint == None`
1252    /// together with `cluster == None` represents a global wildcard.
1253    /// Returns the newly assigned change ID.
1254    fn record_wildcard(&mut self, endpoint: Option<EndptId>, cluster: Option<ClusterId>) -> u64 {
1255        self.record_raw(ChangedAttr {
1256            endpoint: endpoint.unwrap_or(WILDCARD_ENDPOINT),
1257            cluster: cluster.unwrap_or(WILDCARD_CLUSTER),
1258            attr: WILDCARD_ATTR,
1259            change_id: 0,
1260        })
1261    }
1262
1263    /// Insert `new` into the table. The caller is expected to leave `new.change_id`
1264    /// at any value - it is overwritten by a freshly-assigned ID.
1265    fn record_raw(&mut self, mut new: ChangedAttr) -> u64 {
1266        let change_id = self.next_change_id;
1267        self.next_change_id = self.next_change_id.wrapping_add(1).max(1);
1268        new.change_id = change_id;
1269
1270        // If an existing entry already covers `new`, just refresh its change ID.
1271        if let Some(existing) = self.entries.iter_mut().find(|x| x.covers(&new)) {
1272            existing.change_id = change_id;
1273            return change_id;
1274        }
1275
1276        // `new` may itself subsume existing concrete entries - drop those to
1277        // keep the table compact and avoid wasting slots on redundant paths.
1278        let mut i = 0;
1279        while i < self.entries.len() {
1280            if new.covers(&self.entries[i]) {
1281                self.entries.swap_remove(i);
1282            } else {
1283                i += 1;
1284            }
1285        }
1286
1287        if let Err(new) = self.entries.push(new) {
1288            // The table is full - promote entries to coarser wildcards to free a slot.
1289            self.promote_and_insert(new);
1290        }
1291
1292        change_id
1293    }
1294
1295    /// Returns `true` if the table contains at least one entry covering
1296    /// `(endpoint, cluster, attr)` with `change_id > since`.
1297    fn contains_since(
1298        &self,
1299        endpoint: EndptId,
1300        cluster: ClusterId,
1301        attr: AttrId,
1302        since: u64,
1303    ) -> bool {
1304        self.entries
1305            .iter()
1306            .any(|x| x.change_id > since && x.matches(endpoint, cluster, attr))
1307    }
1308
1309    /// Returns `true` if the table contains at least one entry with
1310    /// `change_id > since` (of any path).
1311    fn any_since(&self, since: u64) -> bool {
1312        self.entries.iter().any(|x| x.change_id > since)
1313    }
1314
1315    /// Drop all entries with `change_id <= threshold`.
1316    fn purge_up_to(&mut self, threshold: u64) {
1317        if threshold == 0 {
1318            return;
1319        }
1320
1321        let mut i = 0;
1322        while i < self.entries.len() {
1323            if self.entries[i].change_id <= threshold {
1324                self.entries.swap_remove(i);
1325            } else {
1326                i += 1;
1327            }
1328        }
1329    }
1330
1331    /// Drop every recorded change. Used when no subscriptions exist.
1332    fn clear(&mut self) {
1333        self.entries.clear();
1334    }
1335
1336    /// Coalesce existing entries to coarser wildcards so that `new` can be inserted.
1337    ///
1338    /// The strategy is to promote as little as possible: on each iteration we
1339    /// collapse the single largest collapsible group at the finest available
1340    /// level into one coarser wildcard entry, freeing at least one slot. Only
1341    /// once no fine-grained group of two or more entries exists do we escalate
1342    /// to the next level, and finally to a global wildcard as a last resort.
1343    fn promote_and_insert(&mut self, new: ChangedAttr) {
1344        loop {
1345            // If an existing (possibly just-promoted) entry already covers `new`,
1346            // refresh its change ID and we're done.
1347            if let Some(existing) = self.entries.iter_mut().find(|x| x.covers(&new)) {
1348                existing.change_id = new.change_id;
1349                return;
1350            }
1351
1352            if self.entries.push(new.clone()).is_ok() {
1353                return;
1354            }
1355
1356            // Full - promote exactly one group at the finest granularity that
1357            // actually yields compaction. Levels:
1358            // - 1: (endpoint, cluster, *)
1359            // - 2: (endpoint, *, *)
1360            if !self.promote_largest_group(1) && !self.promote_largest_group(2) {
1361                // No collapsible group at either level - last-ditch fallback:
1362                // collapse the whole table into a single global wildcard entry.
1363                self.entries.clear();
1364
1365                unwrap!(self.entries.push(ChangedAttr {
1366                    endpoint: WILDCARD_ENDPOINT,
1367                    cluster: WILDCARD_CLUSTER,
1368                    attr: WILDCARD_ATTR,
1369                    change_id: new.change_id,
1370                }));
1371
1372                return;
1373            }
1374        }
1375    }
1376
1377    /// Find the largest group of entries (>= 2) that share the same key at the
1378    /// given promotion level, and collapse it into one coarser wildcard entry.
1379    ///
1380    /// Returns `true` if any promotion happened.
1381    fn promote_largest_group(&mut self, level: u8) -> bool {
1382        // Pick a pivot whose group is largest.
1383        let mut best_pivot: Option<ChangedAttr> = None;
1384        let mut best_count = 1usize;
1385
1386        for i in 0..self.entries.len() {
1387            let pivot = &self.entries[i];
1388            let Some(coarsened) = pivot.coarsen(level) else {
1389                continue;
1390            };
1391
1392            let count = self.entries.iter().filter(|e| coarsened.covers(e)).count();
1393            if count > best_count {
1394                best_count = count;
1395                best_pivot = Some(pivot.clone());
1396            }
1397        }
1398
1399        let Some(pivot) = best_pivot else {
1400            return false;
1401        };
1402        // `coarsen` already returned `Some` above for this pivot.
1403        let mut coarsened = pivot.coarsen(level).unwrap();
1404
1405        // Remove all entries covered by `coarsened`, keeping the largest
1406        // change_id to preserve recency.
1407        let mut max_change_id = 0u64;
1408        let mut i = 0;
1409        while i < self.entries.len() {
1410            if coarsened.covers(&self.entries[i]) {
1411                if self.entries[i].change_id > max_change_id {
1412                    max_change_id = self.entries[i].change_id;
1413                }
1414                self.entries.swap_remove(i);
1415            } else {
1416                i += 1;
1417            }
1418        }
1419        coarsened.change_id = max_change_id;
1420        // Safe: we just removed `best_count >= 2` entries, so there is room.
1421        unwrap!(self.entries.push(coarsened));
1422        true
1423    }
1424}
1425
1426/// Sentinel value for "any endpoint" inside a [`ChangedAttr`] entry.
1427///
1428/// Matter endpoint ids are `u16`; the Matter Core Specification caps practical
1429/// endpoint numbering well below `0xFFFF`, and the CHIP reference SDK
1430/// (`kInvalidEndpointId` in `src/lib/core/DataModelTypes.h`) adopts the same
1431/// convention, so we can repurpose `u16::MAX` as an internal "wildcard" marker.
1432const WILDCARD_ENDPOINT: EndptId = EndptId::MAX;
1433
1434/// Sentinel value for "any cluster" inside a [`ChangedAttr`] entry.
1435///
1436/// Matter cluster ids are Manufacturer Extensible Identifiers (MEIs, Core Spec):
1437/// `(vendor_prefix << 16) | suffix` with `0xFFFF` reserved as an
1438/// invalid vendor prefix. `0xFFFF_FFFF` therefore cannot be a legitimate
1439/// cluster id and is safe to use as an internal "wildcard" marker. The CHIP
1440/// reference SDK uses the same value as `kInvalidClusterId`.
1441const WILDCARD_CLUSTER: ClusterId = ClusterId::MAX;
1442
1443/// Sentinel value for "any attribute" inside a [`ChangedAttr`] entry.
1444///
1445/// Same MEI argument as [`WILDCARD_CLUSTER`]: `0xFFFF_FFFF` cannot be a
1446/// legitimate attribute id and matches CHIP's `kInvalidAttributeId`.
1447const WILDCARD_ATTR: AttrId = AttrId::MAX;
1448
1449/// A record of one recently changed attribute.
1450///
1451/// A field holding its corresponding `WILDCARD_*` sentinel acts as a wildcard
1452/// on that axis. Wildcards appear only as a result of "promotion" when the
1453/// `changed_attrs` table becomes full and several concrete entries need to be
1454/// coalesced into a coarser one.
1455///
1456/// Rust is free to reorder these fields under the default `repr(Rust)`, and
1457/// it does so to minimize size: on 64-bit targets `size_of::<ChangedAttr>()`
1458/// is 24 bytes (the `u64` change_id forces 8-byte alignment; the rest packs
1459/// into the remaining 16 bytes). The previous `Option<u16> / Option<u32> /
1460/// Option<u32>` encoding took 32 bytes per entry because `u16` / `u32` have
1461/// no niche for `Option`. See `changed_attr_size_is_compact`.
1462#[derive(Clone, Debug)]
1463#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1464struct ChangedAttr {
1465    endpoint: EndptId,
1466    cluster: ClusterId,
1467    attr: AttrId,
1468    change_id: u64,
1469}
1470
1471impl ChangedAttr {
1472    /// Create a concrete (non-wildcard) entry with the given parameters and change ID.
1473    const fn concrete(endpoint: EndptId, cluster: ClusterId, attr: AttrId, change_id: u64) -> Self {
1474        Self {
1475            endpoint,
1476            cluster,
1477            attr,
1478            change_id,
1479        }
1480    }
1481
1482    /// Return `true` if this entry is a wildcard on the endpoint axis, or `false` if it is concrete.
1483    #[inline]
1484    const fn is_endpoint_wildcard(&self) -> bool {
1485        self.endpoint == WILDCARD_ENDPOINT
1486    }
1487
1488    /// Return `true` if this entry is a wildcard on the cluster axis, or `false` if it is concrete.
1489    #[inline]
1490    const fn is_cluster_wildcard(&self) -> bool {
1491        self.cluster == WILDCARD_CLUSTER
1492    }
1493
1494    /// Return `true` if this entry is a wildcard on the attribute axis, or `false` if it is concrete.
1495    #[inline]
1496    const fn is_attr_wildcard(&self) -> bool {
1497        self.attr == WILDCARD_ATTR
1498    }
1499
1500    /// Whether this record covers the concrete attribute triple
1501    /// `(endpoint, cluster, attr)`.
1502    fn matches(&self, endpoint: EndptId, cluster: ClusterId, attr: AttrId) -> bool {
1503        (self.is_endpoint_wildcard() || self.endpoint == endpoint)
1504            && (self.is_cluster_wildcard() || self.cluster == cluster)
1505            && (self.is_attr_wildcard() || self.attr == attr)
1506    }
1507
1508    /// Whether `other` is semantically covered by `self` (i.e. `self` is as
1509    /// coarse as or coarser than `other` on every axis).
1510    fn covers(&self, other: &ChangedAttr) -> bool {
1511        #[inline]
1512        fn cov<T: Eq>(a: T, a_wild: bool, b: T, b_wild: bool) -> bool {
1513            if a_wild {
1514                true // self wildcard covers anything
1515            } else if b_wild {
1516                false // concrete doesn't cover wildcard
1517            } else {
1518                a == b
1519            }
1520        }
1521        cov(
1522            self.endpoint,
1523            self.is_endpoint_wildcard(),
1524            other.endpoint,
1525            other.is_endpoint_wildcard(),
1526        ) && cov(
1527            self.cluster,
1528            self.is_cluster_wildcard(),
1529            other.cluster,
1530            other.is_cluster_wildcard(),
1531        ) && cov(
1532            self.attr,
1533            self.is_attr_wildcard(),
1534            other.attr,
1535            other.is_attr_wildcard(),
1536        )
1537    }
1538
1539    /// Build the coarsened wildcard entry representing `pivot`'s group at the
1540    /// given level. Returns `None` if `pivot` cannot be promoted at that level
1541    /// (e.g. its endpoint is already a wildcard for level 1 or 2).
1542    fn coarsen(&self, level: u8) -> Option<Self> {
1543        match level {
1544            1 => {
1545                if self.is_endpoint_wildcard() || self.is_cluster_wildcard() {
1546                    return None;
1547                }
1548                Some(Self {
1549                    change_id: 0,
1550                    cluster: self.cluster,
1551                    attr: WILDCARD_ATTR,
1552                    endpoint: self.endpoint,
1553                })
1554            }
1555            2 => {
1556                if self.is_endpoint_wildcard() {
1557                    return None;
1558                }
1559                Some(Self {
1560                    change_id: 0,
1561                    cluster: WILDCARD_CLUSTER,
1562                    attr: WILDCARD_ATTR,
1563                    endpoint: self.endpoint,
1564                })
1565            }
1566            _ => unreachable!(),
1567        }
1568    }
1569}
1570
1571/// Per-subscription context for an in-progress report.
1572pub struct ReportContext<'a, 's, B, const N: usize>
1573where
1574    B: Buffers<IMBuffer> + 'a,
1575{
1576    /// A reference to the global subscriptions table, used to return the subscription on
1577    /// successful completion of the report
1578    subscriptions: &'s Subscriptions<N>,
1579    /// A reference to the global subscription buffers, used to return the subscription buffer on
1580    /// successful completion of the report
1581    subscriptions_buffers: &'s SubscriptionsBuffers<'a, B, N>,
1582    /// The subscription being reported on.
1583    subscription: Option<Subscription>,
1584    /// The RX buffer with report data associated with the subscription being reported on.
1585    subscription_buffer: Option<B::Buffer<'a>>,
1586    /// The next maximum seen attribute change ID for the subscription
1587    /// to be updated into it upon returning the subscription to the table.
1588    ///
1589    /// This is captured here because the subscription's own `max_seen_attr_change_id`
1590    /// is not updated until the report completes as it is until then still used.
1591    next_max_seen_attr_change_id: u64,
1592    /// The next maximum seen event number for the subscription
1593    /// to be updated into it upon returning the subscription to the table.
1594    ///
1595    /// This is captured here because the subscription's own `max_seen_event_number`
1596    /// is not updated until the report completes as it is until then still used.
1597    next_max_seen_event_number: EventNumber,
1598    /// The next reported timestamp for the subscription,
1599    /// to be updated into it upon returning the subscription to the table.
1600    ///
1601    /// This is captured here because the subscription's own `next_reported_at`
1602    /// is not updated until the report completes as it is until then still used.
1603    next_reported_at: Instant,
1604    /// The next retry-gate to commit onto the subscription on completion.
1605    /// `Instant::MIN` (the default) clears any pending retry; a failed send
1606    /// ([`Self::set_keep_retry`]) sets it to a backed-off future instant.
1607    next_retry_at: Instant,
1608    /// The next consecutive-failure count to commit. `0` (the default) clears the
1609    /// back-off; a failed send sets it to one more than the subscription's.
1610    next_fail_count: u8,
1611    /// Whether the subscription should be kept in the table after the report completes.
1612    /// Set by the report handler if the other peer acknowledges the data reported by the subscription.
1613    keep: bool,
1614}
1615
1616impl<'a, 's, B, const N: usize> ReportContext<'a, 's, B, N>
1617where
1618    B: Buffers<IMBuffer> + 'a,
1619{
1620    /// Return a reference to the subscription being reported on.
1621    pub fn subscription(&self) -> &Subscription {
1622        unwrap!(self.subscription.as_ref())
1623    }
1624
1625    /// Return a reference to the RX buffer associated with the subscription being reported on.
1626    pub fn rx(&self) -> &[u8] {
1627        unwrap!(self.subscription_buffer.as_ref()).as_ref()
1628    }
1629
1630    /// Return `true` if the report should be sent even if it turns out to be empty
1631    /// (i.e. no attributes or events to report), or `false` if it can be skipped in that case.
1632    pub fn should_send_if_empty(&self) -> bool {
1633        // A fresh subscription has `reported_at == Instant::MAX`, which makes
1634        // `report_due_at` saturate to `Instant::MIN` and `is_report_due` return
1635        // `true`, so priming reports are delivered unconditionally without a
1636        // separate `priming` flag.
1637        unwrap!(self.subscription.as_ref()).is_report_due(self.next_reported_at)
1638    }
1639
1640    /// Return `true` if the subscription should report the attribute
1641    /// identified by the given triple, or `false` if it can skip it.
1642    pub fn should_report_attr(
1643        &self,
1644        endpoint_id: EndptId,
1645        cluster_id: ClusterId,
1646        attr_id: AttrId,
1647    ) -> bool {
1648        let sub = self.subscription();
1649
1650        // A fresh subscription (priming report) has never reported anything
1651        // yet; its `reported_at` sentinel doubles as the "priming" marker and
1652        // means every selected attribute must be delivered, regardless of
1653        // whether it appears in `changed_attrs`.
1654        if sub.reported_at == Instant::MAX {
1655            return true;
1656        }
1657
1658        self.subscriptions.state.lock(|state| {
1659            state.borrow().changed_attrs.contains_since(
1660                endpoint_id,
1661                cluster_id,
1662                attr_id,
1663                sub.max_seen_attr_change_id,
1664            )
1665        })
1666    }
1667
1668    /// Return the maximum event number the subscription has seen so far.
1669    pub fn max_seen_event_number(&self) -> EventNumber {
1670        unwrap!(self.subscription.as_ref()).max_seen_event_number
1671    }
1672
1673    /// Return the next maximum event number to be updated into the subscription upon returning it to the table.
1674    pub fn next_max_seen_event_number(&self) -> EventNumber {
1675        self.next_max_seen_event_number
1676    }
1677
1678    /// Mark the subscription to be kept in the table after the report completes,
1679    /// meaning the other peer acknowledged our report.
1680    pub fn set_keep(&mut self) {
1681        self.keep = true;
1682    }
1683
1684    /// Keep the subscription in the table after a *failed* send to the peer, so it
1685    /// retries — with a back-off, and without advancing its watermarks or its
1686    /// last-success timestamp.
1687    ///
1688    /// Three things happen, each of which matters:
1689    /// - **Watermarks are preserved.** [`Self::set_keep`] is only correct after a
1690    ///   delivered report: `report()` captured the current change/event watermarks
1691    ///   as "what this report covers", and committing them would mark the
1692    ///   still-unsent changes/events as seen — they would then never be reported.
1693    ///   We restore the last actually-reported values so the pending data is
1694    ///   re-attempted.
1695    /// - **`reported_at` is preserved** (not advanced to now). `is_expired`
1696    ///   measures `max_int` from `reported_at`, so keeping it pinned to the last
1697    ///   *successful* report means a run of failures eventually expires the
1698    ///   subscription instead of retrying it forever.
1699    /// - **A back-off is scheduled.** `retry_at` is pushed to a growing (capped)
1700    ///   delay from now, so an unreachable peer is retried with exponential
1701    ///   back-off rather than in a tight loop that would flood the network.
1702    pub fn set_keep_retry(&mut self) {
1703        // Snapshot the values we need off the (borrowed) subscription first, so
1704        // the writes below don't conflict with the borrow.
1705        let sub = self.subscription();
1706        let (last_attr, last_event, last_reported_at, max_int_secs, fail_count) = (
1707            sub.max_seen_attr_change_id,
1708            sub.max_seen_event_number,
1709            sub.reported_at,
1710            sub.max_int_secs,
1711            sub.fail_count.saturating_add(1),
1712        );
1713
1714        // The report context's "now" — the instant of *this* (failed) attempt —
1715        // was captured into `next_reported_at` at construction. Read it before we
1716        // overwrite that field with the preserved last-success timestamp below.
1717        let now = self.next_reported_at;
1718
1719        // Preserve watermarks + last-success timestamp (so pending data is
1720        // re-attempted and `is_expired` still measures from the last success).
1721        self.next_max_seen_attr_change_id = last_attr;
1722        self.next_max_seen_event_number = last_event;
1723        self.next_reported_at = last_reported_at;
1724
1725        // Grow the back-off from now. The delay is capped at the subscription's
1726        // max interval: it never makes sense to wait longer than the point at
1727        // which `is_expired` gives up on the subscription anyway.
1728        self.next_fail_count = fail_count;
1729        let backoff_secs = Subscription::retry_backoff_secs(fail_count, max_int_secs);
1730        self.next_retry_at = now
1731            .checked_add(embassy_time::Duration::from_secs(backoff_secs as _))
1732            .unwrap_or(Instant::MAX);
1733
1734        self.keep = true;
1735    }
1736}
1737
1738impl<'a, 's, B, const N: usize> Drop for ReportContext<'a, 's, B, N>
1739where
1740    B: Buffers<IMBuffer> + 'a,
1741{
1742    fn drop(&mut self) {
1743        self.subscriptions.report_complete(self);
1744    }
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    use crate::utils::storage::pooled::PooledBuffers;
1750
1751    use super::*;
1752
1753    use embassy_time::Duration;
1754
1755    type TestPool<const N: usize> = PooledBuffers<IMBuffer, N>;
1756
1757    // ---------- ChangedAttributes ----------
1758
1759    #[test]
1760    fn changed_attrs_starts_empty() {
1761        let attrs = ChangedAttrs::new();
1762        assert_eq!(attrs.watermark(), 0);
1763        assert!(!attrs.any_since(0));
1764        assert!(!attrs.contains_since(1, 2, 3, 0));
1765    }
1766
1767    #[test]
1768    fn changed_attrs_record_assigns_monotonic_ids() {
1769        let mut attrs = ChangedAttrs::new();
1770        let id1 = attrs.record(1, 2, 3);
1771        let id2 = attrs.record(1, 2, 4);
1772        let id3 = attrs.record(2, 2, 3);
1773        assert_eq!(id1, 1);
1774        assert_eq!(id2, 2);
1775        assert_eq!(id3, 3);
1776        assert_eq!(attrs.watermark(), 3);
1777    }
1778
1779    #[test]
1780    fn changed_attrs_contains_since_and_any_since() {
1781        let mut attrs = ChangedAttrs::new();
1782        attrs.record(1, 2, 3);
1783        attrs.record(1, 2, 4);
1784
1785        assert!(attrs.any_since(0));
1786        assert!(attrs.any_since(1));
1787        assert!(!attrs.any_since(2));
1788
1789        assert!(attrs.contains_since(1, 2, 3, 0));
1790        assert!(attrs.contains_since(1, 2, 4, 1));
1791        // After watermark 2 there are no more changes
1792        assert!(!attrs.contains_since(1, 2, 3, 2));
1793        // A never-recorded triple is not covered
1794        assert!(!attrs.contains_since(9, 9, 9, 0));
1795    }
1796
1797    #[test]
1798    fn changed_attrs_duplicate_refreshes_change_id() {
1799        let mut attrs = ChangedAttrs::new();
1800        attrs.record(1, 2, 3);
1801        attrs.record(1, 2, 4);
1802        // Same triple as first record - should refresh, not add a new entry.
1803        let id3 = attrs.record(1, 2, 3);
1804        assert_eq!(id3, 3);
1805        assert_eq!(attrs.entries.len(), 2);
1806        // The (1, 2, 3) entry now has change_id 3, so it is visible from since=2
1807        assert!(attrs.contains_since(1, 2, 3, 2));
1808        // But it was originally at id=1, which is now lost - `since=0` still sees it
1809        // through the refreshed id.
1810        assert!(attrs.contains_since(1, 2, 3, 0));
1811    }
1812
1813    #[test]
1814    fn changed_attrs_record_wildcard_cluster_covers_every_attr() {
1815        let mut attrs = ChangedAttrs::new();
1816        let id = attrs.record_wildcard(Some(7), Some(42));
1817
1818        // Any concrete attribute on that (endpoint, cluster) is now covered.
1819        assert!(attrs.contains_since(7, 42, 0, 0));
1820        assert!(attrs.contains_since(7, 42, 1, 0));
1821        assert!(attrs.contains_since(7, 42, u32::MAX, 0));
1822        // Unrelated clusters / endpoints are not.
1823        assert!(!attrs.contains_since(7, 99, 0, 0));
1824        assert!(!attrs.contains_since(8, 42, 0, 0));
1825        assert_eq!(id, attrs.watermark());
1826    }
1827
1828    #[test]
1829    fn changed_attrs_record_wildcard_endpoint_covers_every_cluster() {
1830        let mut attrs = ChangedAttrs::new();
1831        attrs.record_wildcard(Some(5), None);
1832
1833        assert!(attrs.contains_since(5, 1, 1, 0));
1834        assert!(attrs.contains_since(5, 1000, 1000, 0));
1835        assert!(!attrs.contains_since(6, 1, 1, 0));
1836    }
1837
1838    #[test]
1839    fn changed_attrs_record_wildcard_absorbs_existing_concrete_entries() {
1840        let mut attrs = ChangedAttrs::new();
1841        // Seed three concrete attrs on (1, 2).
1842        attrs.record(1, 2, 10);
1843        attrs.record(1, 2, 11);
1844        attrs.record(1, 2, 12);
1845        // And one concrete on a different cluster - should survive.
1846        attrs.record(1, 3, 20);
1847        assert_eq!(attrs.entries.len(), 4);
1848
1849        // Recording a cluster-wide wildcard for (1, 2) must collapse the three
1850        // concrete (1, 2, *) entries into the single wildcard.
1851        attrs.record_wildcard(Some(1), Some(2));
1852
1853        assert_eq!(attrs.entries.len(), 2);
1854        assert!(attrs
1855            .entries
1856            .iter()
1857            .any(|e| e.endpoint == 1 && e.cluster == 2 && e.is_attr_wildcard()));
1858        assert!(attrs.contains_since(1, 3, 20, 0));
1859    }
1860
1861    #[test]
1862    fn changed_attrs_record_wildcard_is_refreshed_when_already_covered() {
1863        let mut attrs = ChangedAttrs::new();
1864        // Endpoint-wide wildcard covers any cluster on that endpoint.
1865        attrs.record_wildcard(Some(1), None);
1866        let before_len = attrs.entries.len();
1867
1868        // A cluster-wide wildcard for the same endpoint is already covered
1869        // by the endpoint-wide one - it must not grow the table and must
1870        // refresh the existing entry's change id.
1871        let id = attrs.record_wildcard(Some(1), Some(2));
1872        assert_eq!(attrs.entries.len(), before_len);
1873        assert_eq!(attrs.watermark(), id);
1874    }
1875
1876    #[test]
1877    fn changed_attrs_purge_up_to_removes_old_entries() {
1878        let mut attrs = ChangedAttrs::new();
1879        attrs.record(1, 2, 3); // id 1
1880        attrs.record(1, 2, 4); // id 2
1881        attrs.record(2, 2, 3); // id 3
1882
1883        attrs.purge_up_to(2);
1884
1885        assert!(!attrs.contains_since(1, 2, 3, 0));
1886        assert!(!attrs.contains_since(1, 2, 4, 0));
1887        assert!(attrs.contains_since(2, 2, 3, 0));
1888
1889        // Purging with 0 is a no-op.
1890        attrs.purge_up_to(0);
1891        assert!(attrs.contains_since(2, 2, 3, 0));
1892    }
1893
1894    #[test]
1895    fn changed_attrs_clear_empties_table_but_keeps_watermark() {
1896        let mut attrs = ChangedAttrs::new();
1897        attrs.record(1, 2, 3);
1898        attrs.record(1, 2, 4);
1899        let wm_before = attrs.watermark();
1900        attrs.clear();
1901        assert!(!attrs.any_since(0));
1902        // Watermark is preserved so subsequent records remain strictly monotonic.
1903        assert_eq!(attrs.watermark(), wm_before);
1904        let id = attrs.record(5, 5, 5);
1905        assert_eq!(id, wm_before + 1);
1906    }
1907
1908    #[test]
1909    fn changed_attrs_promotion_on_overflow_same_cluster() {
1910        let mut attrs = ChangedAttrs::new();
1911        // Fill the table with distinct concrete entries on the same (endpoint, cluster).
1912        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1913            attrs.record(1, 2, attr);
1914        }
1915        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1916
1917        // One more record must still succeed - the existing entries get promoted.
1918        let overflow_id = attrs.record(1, 2, 9999);
1919        assert_eq!(overflow_id as usize, MAX_CHANGED_ATTRS + 1);
1920
1921        // The table must never overflow its capacity.
1922        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1923
1924        // Every originally-recorded concrete attribute must still be reported as
1925        // "changed" when queried from since=0 (possibly via a coarser wildcard).
1926        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1927            assert!(
1928                attrs.contains_since(1, 2, attr, 0),
1929                "attr {} lost after promotion",
1930                attr
1931            );
1932        }
1933        assert!(attrs.contains_since(1, 2, 9999, 0));
1934
1935        // The new overflow entry is visible from the previous watermark.
1936        assert!(attrs.contains_since(1, 2, 9999, MAX_CHANGED_ATTRS as u64));
1937    }
1938
1939    #[test]
1940    fn changed_attrs_promotion_to_global_wildcard() {
1941        let mut attrs = ChangedAttrs::new();
1942        // Entries spread across many endpoints/clusters/attrs to force promotion
1943        // past the (endpoint, cluster, *) and (endpoint, *, *) levels.
1944        for i in 0..(MAX_CHANGED_ATTRS as u16 + 5) {
1945            attrs.record(i, i as u32, i as u32);
1946        }
1947        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1948        // All previously-recorded triples must still report as changed.
1949        for i in 0..(MAX_CHANGED_ATTRS as u16 + 5) {
1950            assert!(attrs.contains_since(i, i as u32, i as u32, 0));
1951        }
1952        // And an arbitrary never-recorded triple may or may not be covered
1953        // (over-reporting is allowed), but `any_since(0)` must be true.
1954        assert!(attrs.any_since(0));
1955    }
1956
1957    #[test]
1958    fn promotion_prefers_largest_level_1_group() {
1959        // 10 entries on (1, 1, *) and 5 singletons on (1, k, 0) for k=2..=6
1960        // (= 15 entries total). One extra record fills the table, then an
1961        // overflowing record forces exactly ONE level-1 promotion which must
1962        // collapse the big (1, 1, *) group while leaving singletons concrete.
1963        let mut attrs = ChangedAttrs::new();
1964        for attr in 0..10u32 {
1965            attrs.record(1, 1, attr);
1966        }
1967        for cluster in 2..=6u32 {
1968            attrs.record(1, cluster, 0);
1969        }
1970        // Fill exactly to capacity without overflow.
1971        attrs.record(1, 1, 100);
1972        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1973
1974        // Now overflow to trigger promotion.
1975        attrs.record(2, 2, 2);
1976        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1977
1978        // The big (1, 1, *) group became exactly one wildcard entry.
1979        let wild_11 = attrs
1980            .entries
1981            .iter()
1982            .filter(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard())
1983            .count();
1984        assert_eq!(wild_11, 1);
1985        // No concrete (1, 1, _) entries survived.
1986        let concrete_11 = attrs
1987            .entries
1988            .iter()
1989            .filter(|e| e.endpoint == 1 && e.cluster == 1 && !e.is_attr_wildcard())
1990            .count();
1991        assert_eq!(concrete_11, 0);
1992        // Singletons on (1, k, 0) for k=2..=6 remain concrete.
1993        for cluster in 2..=6u32 {
1994            let n = attrs
1995                .entries
1996                .iter()
1997                .filter(|e| e.endpoint == 1 && e.cluster == cluster && e.attr == 0)
1998                .count();
1999            assert_eq!(n, 1, "singleton (1, {}, 0) should remain concrete", cluster);
2000        }
2001        // The new (2, 2, 2) entry is present as a concrete entry.
2002        assert!(attrs
2003            .entries
2004            .iter()
2005            .any(|e| e.endpoint == 2 && e.cluster == 2 && e.attr == 2));
2006
2007        // All original triples still report as changed.
2008        for attr in 0..10u32 {
2009            assert!(attrs.contains_since(1, 1, attr, 0));
2010        }
2011        for cluster in 2..=6u32 {
2012            assert!(attrs.contains_since(1, cluster, 0, 0));
2013        }
2014        assert!(attrs.contains_since(1, 1, 100, 0));
2015        assert!(attrs.contains_since(2, 2, 2, 0));
2016    }
2017
2018    #[test]
2019    fn promotion_is_minimal_only_one_group_collapsed_per_overflow() {
2020        // Two big level-1 groups of equal size. A single overflow must collapse
2021        // only ONE of them, not both (minimal promotion).
2022        let mut attrs = ChangedAttrs::new();
2023        // Group A: (1, 1, 0..8) = 8 entries
2024        for attr in 0..8u32 {
2025            attrs.record(1, 1, attr);
2026        }
2027        // Group B: (2, 2, 0..8) = 8 entries
2028        for attr in 0..8u32 {
2029            attrs.record(2, 2, attr);
2030        }
2031        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
2032
2033        // Overflow with an unrelated entry.
2034        attrs.record(9, 9, 9);
2035
2036        // Exactly one of the groups got collapsed into a wildcard.
2037        let a_wild = attrs
2038            .entries
2039            .iter()
2040            .any(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard());
2041        let b_wild = attrs
2042            .entries
2043            .iter()
2044            .any(|e| e.endpoint == 2 && e.cluster == 2 && e.is_attr_wildcard());
2045        assert!(
2046            a_wild ^ b_wild,
2047            "expected exactly one of the groups to be collapsed (A: {}, B: {})",
2048            a_wild,
2049            b_wild
2050        );
2051        // The un-collapsed group still has all 8 concrete entries.
2052        let a_concrete = attrs
2053            .entries
2054            .iter()
2055            .filter(|e| e.endpoint == 1 && e.cluster == 1 && !e.is_attr_wildcard())
2056            .count();
2057        let b_concrete = attrs
2058            .entries
2059            .iter()
2060            .filter(|e| e.endpoint == 2 && e.cluster == 2 && !e.is_attr_wildcard())
2061            .count();
2062        assert!(
2063            (a_wild && a_concrete == 0 && b_concrete == 8)
2064                || (b_wild && b_concrete == 0 && a_concrete == 8)
2065        );
2066    }
2067
2068    #[test]
2069    fn promotion_falls_back_to_level_2_when_no_level_1_group() {
2070        // All (endpoint, cluster) pairs are unique (level-1 groups are all
2071        // singletons) but endpoints repeat, so level-2 groups are non-trivial.
2072        let mut attrs = ChangedAttrs::new();
2073        for cluster in 0..8u32 {
2074            attrs.record(1, cluster, 0);
2075        }
2076        for cluster in 0..8u32 {
2077            attrs.record(2, cluster, 0);
2078        }
2079        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
2080
2081        attrs.record(3, 9, 9);
2082        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
2083
2084        // No level-1 wildcard (endpoint, cluster, *) was produced.
2085        let lvl1_wild = attrs
2086            .entries
2087            .iter()
2088            .filter(|e| {
2089                !e.is_endpoint_wildcard() && !e.is_cluster_wildcard() && e.is_attr_wildcard()
2090            })
2091            .count();
2092        assert_eq!(lvl1_wild, 0);
2093        // Exactly one level-2 wildcard on endpoint 1 or 2 was produced.
2094        let ep1_wild = attrs
2095            .entries
2096            .iter()
2097            .any(|e| e.endpoint == 1 && e.is_cluster_wildcard() && e.is_attr_wildcard());
2098        let ep2_wild = attrs
2099            .entries
2100            .iter()
2101            .any(|e| e.endpoint == 2 && e.is_cluster_wildcard() && e.is_attr_wildcard());
2102        assert!(ep1_wild ^ ep2_wild);
2103        // No global wildcard was produced either.
2104        assert!(!attrs
2105            .entries
2106            .iter()
2107            .any(|e| e.is_endpoint_wildcard() && e.is_cluster_wildcard() && e.is_attr_wildcard()));
2108
2109        // All originals still visible.
2110        for cluster in 0..8u32 {
2111            assert!(attrs.contains_since(1, cluster, 0, 0));
2112            assert!(attrs.contains_since(2, cluster, 0, 0));
2113        }
2114        assert!(attrs.contains_since(3, 9, 9, 0));
2115    }
2116
2117    #[test]
2118    fn promotion_falls_back_to_global_only_when_no_lower_group() {
2119        // All-distinct endpoints AND (endpoint, cluster) pairs: no level-1 or
2120        // level-2 group has >=2 entries. Overflow must collapse everything to
2121        // a single global wildcard.
2122        let mut attrs = ChangedAttrs::new();
2123        for i in 0..MAX_CHANGED_ATTRS as u16 {
2124            attrs.record(i, i as u32, i as u32);
2125        }
2126        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
2127
2128        attrs.record(100, 200, 300);
2129        assert_eq!(attrs.entries.len(), 1);
2130        let only = &attrs.entries[0];
2131        assert!(
2132            only.is_endpoint_wildcard() && only.is_cluster_wildcard() && only.is_attr_wildcard()
2133        );
2134
2135        // Every previously-recorded triple is still covered.
2136        for i in 0..MAX_CHANGED_ATTRS as u16 {
2137            assert!(attrs.contains_since(i, i as u32, i as u32, 0));
2138        }
2139        assert!(attrs.contains_since(100, 200, 300, 0));
2140    }
2141
2142    #[test]
2143    fn promotion_preserves_max_change_id_in_coarsened_entry() {
2144        // After collapsing a (1, 1, *) group, the resulting wildcard's
2145        // change_id must equal the max change_id of the collapsed entries.
2146        let mut attrs = ChangedAttrs::new();
2147        for attr in 0..MAX_CHANGED_ATTRS as u32 {
2148            attrs.record(1, 1, attr);
2149        }
2150        let max_before = attrs.watermark();
2151
2152        attrs.record(2, 2, 2);
2153        let wild = attrs
2154            .entries
2155            .iter()
2156            .find(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard())
2157            .expect("(1, 1, *) wildcard was produced");
2158        assert_eq!(wild.change_id, max_before);
2159
2160        // contains_since respects that watermark exactly.
2161        assert!(attrs.contains_since(1, 1, 0, max_before - 1));
2162        assert!(!attrs.contains_since(1, 1, 0, max_before));
2163    }
2164
2165    #[test]
2166    fn promotion_with_existing_wildcard_refreshes_instead_of_promoting_again() {
2167        // Build a state where (1, 1, *) wildcard already exists via a forced
2168        // promotion. Recording another (1, 1, k) must refresh that wildcard's
2169        // change_id without producing any new entry.
2170        let mut attrs = ChangedAttrs::new();
2171        for attr in 0..MAX_CHANGED_ATTRS as u32 {
2172            attrs.record(1, 1, attr);
2173        }
2174        attrs.record(2, 2, 2); // forces (1, 1, *) promotion
2175
2176        // Now the table has 2 entries: (1, 1, *) and (2, 2, 2).
2177        assert_eq!(attrs.entries.len(), 2);
2178        let wm_after_promo = attrs.watermark();
2179
2180        let new_id = attrs.record(1, 1, 42);
2181        // No new entry: still 2 entries. Wildcard's change_id advanced.
2182        assert_eq!(attrs.entries.len(), 2);
2183        assert_eq!(new_id, wm_after_promo + 1);
2184        let wild = attrs
2185            .entries
2186            .iter()
2187            .find(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard())
2188            .unwrap();
2189        assert_eq!(wild.change_id, new_id);
2190    }
2191
2192    #[test]
2193    fn promotion_capacity_invariant_under_sustained_churn() {
2194        // Sustained mixed churn must never let the table exceed its capacity,
2195        // and every freshly-recorded triple must remain visible immediately
2196        // after recording.
2197        let mut attrs = ChangedAttrs::new();
2198        for i in 0..1000u32 {
2199            let endpoint = (i % 7) as u16;
2200            let cluster = i % 13;
2201            let attr = i;
2202            attrs.record(endpoint, cluster, attr);
2203            assert!(
2204                attrs.entries.len() <= MAX_CHANGED_ATTRS,
2205                "capacity exceeded at i={}",
2206                i
2207            );
2208            assert!(
2209                attrs.contains_since(endpoint, cluster, attr, 0),
2210                "just-recorded triple lost at i={}",
2211                i
2212            );
2213        }
2214    }
2215
2216    #[test]
2217    fn promotion_iterated_into_same_existing_wildcard() {
2218        // Once (1, 1, *) exists, repeated inserts on that group must never
2219        // grow the table, and never trigger further promotion.
2220        let mut attrs = ChangedAttrs::new();
2221        for attr in 0..MAX_CHANGED_ATTRS as u32 {
2222            attrs.record(1, 1, attr);
2223        }
2224        attrs.record(2, 2, 2); // -> [(1,1,*), (2,2,2)]
2225        assert_eq!(attrs.entries.len(), 2);
2226
2227        for attr in 100..200u32 {
2228            attrs.record(1, 1, attr);
2229            assert_eq!(attrs.entries.len(), 2);
2230        }
2231    }
2232
2233    #[test]
2234    fn promotion_escalates_when_level_1_group_still_insufficient() {
2235        // Pathological case: a single level-1 group of size 2 exists, the rest
2236        // are singletons. After the first overflow, that group collapses
2237        // (freeing 1 slot), but the table is still full once the new record
2238        // tries to be inserted on a fresh singleton location. Subsequent
2239        // overflows must escalate to level-2 / global.
2240        let mut attrs = ChangedAttrs::new();
2241        // 2 entries sharing (1, 1, *) -- a single level-1 group of size 2.
2242        attrs.record(1, 1, 0);
2243        attrs.record(1, 1, 1);
2244        // Fill the rest with unique (endpoint, cluster) pairs.
2245        for i in 0..(MAX_CHANGED_ATTRS as u16 - 2) {
2246            attrs.record(10 + i, 100 + i as u32, i as u32);
2247        }
2248        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
2249
2250        // First overflow: the only level-1 group collapses; then the new entry
2251        // gets inserted.
2252        attrs.record(50, 50, 50);
2253        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
2254        // The (1, 1, *) wildcard is present.
2255        assert!(attrs
2256            .entries
2257            .iter()
2258            .any(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard()));
2259
2260        // Keep feeding: eventually we must fall back to level-2 or global
2261        // without breaking correctness.
2262        for i in 0..200u32 {
2263            let endpoint = 200 + (i % 5) as u16;
2264            let cluster = 300 + (i % 3);
2265            let attr = i;
2266            attrs.record(endpoint, cluster, attr);
2267            assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
2268            assert!(attrs.contains_since(endpoint, cluster, attr, 0));
2269        }
2270        // Historical triples still covered.
2271        assert!(attrs.contains_since(1, 1, 0, 0));
2272        assert!(attrs.contains_since(1, 1, 1, 0));
2273        assert!(attrs.contains_since(50, 50, 50, 0));
2274    }
2275
2276    #[test]
2277    fn changed_attr_covers_wildcards() {
2278        let concrete = ChangedAttr::concrete(1, 2, 3, 1);
2279        let any_attr = ChangedAttr {
2280            endpoint: 1,
2281            cluster: 2,
2282            attr: WILDCARD_ATTR,
2283            change_id: 1,
2284        };
2285        let any_cluster = ChangedAttr {
2286            endpoint: 1,
2287            cluster: WILDCARD_CLUSTER,
2288            attr: WILDCARD_ATTR,
2289            change_id: 1,
2290        };
2291        let global = ChangedAttr {
2292            endpoint: WILDCARD_ENDPOINT,
2293            cluster: WILDCARD_CLUSTER,
2294            attr: WILDCARD_ATTR,
2295            change_id: 1,
2296        };
2297
2298        assert!(any_attr.covers(&concrete));
2299        assert!(any_cluster.covers(&concrete));
2300        assert!(global.covers(&concrete));
2301        // Concrete does not cover wildcards.
2302        assert!(!concrete.covers(&any_attr));
2303        assert!(!concrete.covers(&global));
2304        // Concrete matches itself.
2305        assert!(concrete.matches(1, 2, 3));
2306        assert!(!concrete.matches(1, 2, 4));
2307        // Wildcards match any concrete triple on the wildcarded axis.
2308        assert!(any_attr.matches(1, 2, 99));
2309        assert!(!any_attr.matches(1, 9, 99));
2310        assert!(global.matches(99, 99, 99));
2311    }
2312
2313    #[test]
2314    fn changed_attr_size_is_compact() {
2315        // `ChangedAttr` must stay at 24 bytes on 64-bit targets: `u64` change_id
2316        // forces 8-byte alignment, and the `(u32, u32, u16)` path tuple fits in
2317        // the remaining 16 bytes (4 + 4 + 2 + 6 padding). Regressing back to an
2318        // `Option<u16> / Option<u32> / Option<u32>` encoding would bump this to
2319        // 32 bytes per entry, i.e. +128 bytes per `Subscriptions` table.
2320        assert_eq!(core::mem::size_of::<ChangedAttr>(), 24);
2321    }
2322
2323    // ---------- Subscriptions ----------
2324
2325    fn fab(i: u8) -> NonZeroU8 {
2326        NonZeroU8::new(i).unwrap()
2327    }
2328
2329    #[test]
2330    fn add_returns_monotonic_ids_and_rejects_when_full() {
2331        let subs: Subscriptions<2> = Subscriptions::new();
2332        let pool = TestPool::<3>::new();
2333        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2334
2335        let now = Instant::now();
2336
2337        let rctx1 = subs
2338            .add(
2339                now,
2340                fab(1),
2341                10,
2342                1,
2343                60,
2344                0,
2345                pool.get_immediate().unwrap(),
2346                &subs_bufs,
2347            )
2348            .unwrap();
2349        let rctx2 = subs
2350            .add(
2351                now,
2352                fab(1),
2353                10,
2354                1,
2355                60,
2356                0,
2357                pool.get_immediate().unwrap(),
2358                &subs_bufs,
2359            )
2360            .unwrap();
2361        assert_eq!(rctx1.subscription().ids().id, 1);
2362        assert_eq!(rctx2.subscription().ids().id, 2);
2363
2364        // Third add exceeds N=2.
2365        assert!(subs
2366            .add(
2367                now,
2368                fab(1),
2369                10,
2370                1,
2371                60,
2372                0,
2373                pool.get_immediate().unwrap(),
2374                &subs_bufs
2375            )
2376            .is_none());
2377    }
2378
2379    #[test]
2380    fn begin_report_snapshots_watermark_and_pending() {
2381        let subs: Subscriptions<2> = Subscriptions::new();
2382        let pool = TestPool::<3>::new();
2383        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2384
2385        let now = Instant::now();
2386
2387        subs.notify_attr_changed(1, 2, 3);
2388        {
2389            let mut rctx = subs
2390                .add(
2391                    now,
2392                    fab(1),
2393                    10,
2394                    1,
2395                    60,
2396                    0,
2397                    pool.get_immediate().unwrap(),
2398                    &subs_bufs,
2399                )
2400                .unwrap();
2401
2402            // The priming report is un-filtered: every attribute is reported and
2403            // `should_send_if_empty` is true so that the snapshot is delivered
2404            // unconditionally.
2405            assert!(rctx.should_send_if_empty());
2406            assert!(rctx.should_report_attr(1, 2, 3));
2407            assert!(rctx.should_report_attr(42, 55555, 1234556677));
2408
2409            rctx.set_keep();
2410        }
2411
2412        // A new change bumps the watermark and becomes pending.
2413        subs.notify_attr_changed(1, 2, 4);
2414        // `min_int` = 1s has not elapsed at `now`, so the subscription is not
2415        // yet report-allowed; step past it.
2416        let later = now + Duration::from_secs(2);
2417        let rctx = subs.report(later, 0, &subs_bufs).unwrap();
2418        assert!(!rctx.should_send_if_empty());
2419        // The priming commit advanced the sub's `since` past the (1, 2, 3)
2420        // change, so only the new (1, 2, 4) is pending.
2421        assert!(!rctx.should_report_attr(1, 2, 3));
2422        assert!(rctx.should_report_attr(1, 2, 4));
2423    }
2424
2425    /// A failed report (`set_failed`) keeps the subscription but must NOT advance
2426    /// its watermark, so the changes it was carrying stay pending and are
2427    /// re-attempted on the next report. Contrast with `set_keep`, which commits
2428    /// the advanced watermark (correct only after a *delivered* report).
2429    #[test]
2430    fn failed_report_preserves_pending_changes() {
2431        let subs: Subscriptions<2> = Subscriptions::new();
2432        let pool = TestPool::<3>::new();
2433        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2434
2435        let now = Instant::now();
2436
2437        // Prime the subscription (delivered) so it is a normal, non-priming entry.
2438        {
2439            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2440            rctx.set_keep();
2441        }
2442
2443        // A change becomes pending.
2444        subs.notify_attr_changed(1, 2, 3);
2445
2446        let later = now + Duration::from_secs(2);
2447
2448        // First report attempt FAILS: the report was carrying (1, 2, 3) but never
2449        // reached the subscriber.
2450        {
2451            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2452            assert!(rctx.should_report_attr(1, 2, 3), "change is pending");
2453            rctx.set_keep_retry();
2454        }
2455
2456        // Because the failed report did not advance the watermark, (1, 2, 3) is
2457        // STILL pending on the next report — no silent data loss.
2458        let later2 = later + Duration::from_secs(2);
2459        {
2460            let rctx = subs.report(later2, 0, &subs_bufs).unwrap();
2461            assert!(
2462                rctx.should_report_attr(1, 2, 3),
2463                "a failed report must not consume the pending change"
2464            );
2465        }
2466    }
2467
2468    /// The success counterpart: `set_keep` after a delivered report DOES advance
2469    /// the watermark, so the same change is not reported again.
2470    #[test]
2471    fn delivered_report_consumes_pending_changes() {
2472        let subs: Subscriptions<2> = Subscriptions::new();
2473        let pool = TestPool::<3>::new();
2474        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2475
2476        let now = Instant::now();
2477
2478        {
2479            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2480            rctx.set_keep();
2481        }
2482
2483        subs.notify_attr_changed(1, 2, 3);
2484
2485        let later = now + Duration::from_secs(2);
2486        {
2487            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2488            assert!(rctx.should_report_attr(1, 2, 3));
2489            rctx.set_keep(); // delivered
2490        }
2491
2492        // The change was consumed by the delivered report and the liveness
2493        // deadline is not yet reached, so there is nothing left to report.
2494        let later2 = later + Duration::from_secs(2);
2495        assert!(
2496            subs.report(later2, 0, &subs_bufs).is_none(),
2497            "a delivered report consumes the pending change"
2498        );
2499    }
2500
2501    /// A failed report must not busy-loop: even with a change pending and
2502    /// `min_int` elapsed, the subscription is held back until its back-off
2503    /// (`retry_at`) elapses, and the reporter's wake deadline reflects that.
2504    #[test]
2505    fn failed_report_backs_off_instead_of_busy_looping() {
2506        let subs: Subscriptions<1> = Subscriptions::new();
2507        let pool = TestPool::<2>::new();
2508        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2509
2510        let now = Instant::now();
2511
2512        // Prime (delivered), then make a change pending, all with min_int = 1s.
2513        {
2514            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 600);
2515            rctx.set_keep();
2516        }
2517        subs.notify_attr_changed(1, 2, 3);
2518
2519        // min_int has elapsed and the change is pending — the report is due, but
2520        // it FAILS.
2521        let t1 = now + Duration::from_secs(2);
2522        {
2523            let mut rctx = subs.report(t1, 0, &subs_bufs).unwrap();
2524            rctx.set_keep_retry();
2525        }
2526
2527        // Immediately after the failure the subscription is NOT reportable again,
2528        // even though the change is still pending and min_int is long past: the
2529        // back-off gates it. This is the anti-busy-loop guarantee.
2530        assert!(
2531            subs.report(t1, 0, &subs_bufs).is_none(),
2532            "a failed report must not be retried in the same instant"
2533        );
2534        // Still gated a moment later (BASE back-off is 2s).
2535        assert!(subs
2536            .report(t1 + Duration::from_millis(500), 0, &subs_bufs)
2537            .is_none());
2538
2539        // The reporter's wake deadline is pushed to the back-off point, so the
2540        // loop sleeps rather than spinning.
2541        assert_eq!(
2542            subs.next_report_at(0, &subs_bufs),
2543            t1 + Duration::from_secs(2),
2544            "wake is scheduled at the back-off point, not immediately"
2545        );
2546
2547        // Once the back-off elapses the pending change is retried.
2548        let t2 = t1 + Duration::from_secs(2);
2549        {
2550            let rctx = subs.report(t2, 0, &subs_bufs).unwrap();
2551            assert!(
2552                rctx.should_report_attr(1, 2, 3),
2553                "pending change is retried"
2554            );
2555        }
2556    }
2557
2558    /// The back-off grows with consecutive failures (exponential from BASE),
2559    /// observable through the reporter's wake deadline.
2560    #[test]
2561    fn repeated_failures_grow_the_backoff() {
2562        let subs: Subscriptions<1> = Subscriptions::new();
2563        let pool = TestPool::<2>::new();
2564        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2565
2566        let now = Instant::now();
2567        // Large max_int so the cap never clips the small back-offs under test.
2568        {
2569            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 600);
2570            rctx.set_keep();
2571        }
2572        subs.notify_attr_changed(1, 2, 3);
2573
2574        // BASE = 2s, doubling each consecutive failure: 2s, 4s, 8s.
2575        let mut t = now + Duration::from_secs(2);
2576        for expected_backoff in [2u64, 4, 8] {
2577            let mut rctx = subs.report(t, 0, &subs_bufs).unwrap();
2578            rctx.set_keep_retry();
2579            drop(rctx);
2580
2581            assert_eq!(
2582                subs.next_report_at(0, &subs_bufs),
2583                t + Duration::from_secs(expected_backoff),
2584                "back-off after this failure",
2585            );
2586
2587            // Advance exactly to the back-off point for the next failed attempt.
2588            t += Duration::from_secs(expected_backoff);
2589        }
2590    }
2591
2592    /// The back-off never exceeds `max_int`: it makes no sense to wait past the
2593    /// point at which the subscription expires anyway.
2594    #[test]
2595    fn backoff_is_capped_at_max_int() {
2596        // With max_int = 5s, even a high fail count is clamped to 5s.
2597        assert_eq!(Subscription::retry_backoff_secs(1, 5), 2);
2598        assert_eq!(Subscription::retry_backoff_secs(3, 5), 5); // 8 -> capped to 5
2599        assert_eq!(Subscription::retry_backoff_secs(20, 5), 5); // huge -> capped
2600                                                                // With a generous max_int the exponential shows through, then saturates.
2601        assert_eq!(Subscription::retry_backoff_secs(1, 600), 2);
2602        assert_eq!(Subscription::retry_backoff_secs(4, 600), 16);
2603        assert_eq!(Subscription::retry_backoff_secs(100, 600), 600);
2604    }
2605
2606    /// `is_expired` measures `max_int` from the last *successful* report, not
2607    /// from the last attempt: a run of failures does NOT keep the subscription
2608    /// alive forever — it expires `max_int` after the last success.
2609    #[test]
2610    fn failures_do_not_postpone_expiry() {
2611        let subs: Subscriptions<1> = Subscriptions::new();
2612        let pool = TestPool::<2>::new();
2613        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2614
2615        let base = Instant::now();
2616        // max_int = 10s.
2617        {
2618            let mut rctx = add_sub(&subs, &subs_bufs, &pool, base, 1, 10, 1, 10);
2619            rctx.set_keep(); // last SUCCESS is at `base`
2620        }
2621        subs.notify_attr_changed(1, 2, 3);
2622
2623        // A failed report at base+2s must NOT move the expiry deadline.
2624        {
2625            let mut rctx = subs
2626                .report(base + Duration::from_secs(2), 0, &subs_bufs)
2627                .unwrap();
2628            rctx.set_keep_retry();
2629        }
2630
2631        // Just short of max_int-since-success: still alive.
2632        let before = base + Duration::from_secs(9);
2633        assert!(!subs.remove(&subs_bufs, |sub| sub
2634            .is_expired(before)
2635            .then_some("expired")));
2636
2637        // At max_int-since-success (measured from `base`, not from the failed
2638        // attempt at base+2s): expired.
2639        let after = base + Duration::from_secs(10);
2640        assert!(
2641            subs.remove(&subs_bufs, |sub| sub.is_expired(after).then_some("expired")),
2642            "expiry is measured from the last success, not the last attempt"
2643        );
2644    }
2645
2646    /// A delivered report after a run of failures clears the back-off: the next
2647    /// wake returns to the normal liveness schedule rather than a back-off floor.
2648    #[test]
2649    fn success_clears_the_backoff() {
2650        let subs: Subscriptions<1> = Subscriptions::new();
2651        let pool = TestPool::<2>::new();
2652        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2653
2654        let now = Instant::now();
2655        {
2656            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2657            rctx.set_keep();
2658        }
2659        subs.notify_attr_changed(1, 2, 3);
2660
2661        // Two failures build up a back-off.
2662        let t1 = now + Duration::from_secs(2);
2663        {
2664            let mut rctx = subs.report(t1, 0, &subs_bufs).unwrap();
2665            rctx.set_keep_retry();
2666        }
2667        let t2 = t1 + Duration::from_secs(2);
2668        {
2669            let mut rctx = subs.report(t2, 0, &subs_bufs).unwrap();
2670            rctx.set_keep_retry();
2671        }
2672
2673        // Now a delivered report at t3.
2674        let t3 = t2 + Duration::from_secs(4);
2675        {
2676            let mut rctx = subs.report(t3, 0, &subs_bufs).unwrap();
2677            assert!(rctx.should_report_attr(1, 2, 3));
2678            rctx.set_keep(); // delivered — resets fail_count and retry_at
2679        }
2680
2681        // The back-off is gone: the next wake is the plain liveness point
2682        // (reported_at + max_int - max_int/2 = t3 + 30s), with no retry floor.
2683        assert_eq!(
2684            subs.next_report_at(0, &subs_bufs),
2685            t3 + Duration::from_secs(30),
2686            "a delivered report clears the back-off"
2687        );
2688    }
2689
2690    // The following tests cover the public API of `Subscriptions` /
2691    // `SubscriptionsBuffers` / `ReportContext` post-refactor. A few of the
2692    // pre-refactor tests had no meaningful successor and were deleted:
2693    //
2694    //   * `sub_attr_change_filter_honors_since_watermark` — `SubAttrChangeFilter`
2695    //     is now dead code (see REVIEW above); the `since`-watermark logic is
2696    //     already covered by `changed_attrs_contains_since_and_any_since`.
2697    //   * `find_report_due_events_pending_receives_subscription_watermark` —
2698    //     the old `events_pending` callback no longer exists; the
2699    //     subscription's `max_seen_event_number` is now compared directly
2700    //     against the `event_numbers_watermark` passed to
2701    //     `Subscriptions::report`.
2702    //   * `find_removed_session_matches_predicate` — `session_id` tracking
2703    //     was dropped in the refactor (see REVIEW on `SubscriptionsInner::add`).
2704    //     Predicate-based removal is covered by `remove_invokes_predicate_*`.
2705
2706    /// Helper: add a subscription with sensible defaults and return its `ReportContext`.
2707    #[allow(clippy::too_many_arguments)]
2708    fn add_sub<'a, 's, const N: usize, const B: usize>(
2709        subs: &'s Subscriptions<N>,
2710        subs_bufs: &'s SubscriptionsBuffers<'a, TestPool<B>, N>,
2711        pool: &'a TestPool<B>,
2712        now: Instant,
2713        fab_idx: u8,
2714        peer_node_id: u64,
2715        min_int: u16,
2716        max_int: u16,
2717    ) -> ReportContext<'a, 's, TestPool<B>, N>
2718    where
2719        'a: 's,
2720    {
2721        subs.add(
2722            now,
2723            fab(fab_idx),
2724            peer_node_id,
2725            min_int,
2726            max_int,
2727            /* event_numbers_watermark */ 0,
2728            pool.get_immediate().unwrap(),
2729            subs_bufs,
2730        )
2731        .unwrap()
2732    }
2733
2734    #[test]
2735    fn priming_report_context_is_report_due_and_keeps_sub() {
2736        // A subscription returned from `add` is the "priming" report: it must be
2737        // report-due regardless of time (so the initial report is delivered
2738        // unconditionally) and, when dropped with `set_keep`, must survive in
2739        // the subscription table for subsequent incremental reports.
2740        let subs: Subscriptions<1> = Subscriptions::new();
2741        let pool = TestPool::<2>::new();
2742        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2743
2744        let now = Instant::now();
2745        {
2746            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2747            assert!(rctx.should_send_if_empty());
2748            assert_eq!(rctx.max_seen_event_number(), 0);
2749            rctx.set_keep();
2750        }
2751
2752        // After priming, a zero-delta report at the same instant finds nothing
2753        // pending (no attr changes, no new events, min_int not elapsed).
2754        assert!(subs.report(now, 0, &subs_bufs).is_none());
2755    }
2756
2757    #[test]
2758    fn report_without_keep_frees_the_slot() {
2759        // Dropping a `ReportContext` *without* `set_keep` must remove the
2760        // subscription from the table (and free its buffer), so a new
2761        // subscription can take its place up to the `N` capacity.
2762        let subs: Subscriptions<1> = Subscriptions::new();
2763        let pool = TestPool::<2>::new();
2764        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2765
2766        let now = Instant::now();
2767
2768        // Add then drop without keep.
2769        drop(add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60));
2770
2771        // The slot is free again: a second add succeeds even with N=1.
2772        let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 11, 1, 60);
2773        // IDs are still strictly monotonic across add/remove cycles.
2774        assert_eq!(rctx.subscription().ids().id, 2);
2775        rctx.set_keep();
2776    }
2777
2778    #[test]
2779    fn report_with_keep_advances_reported_at_and_watermark() {
2780        // After a "kept" report, the subscription must not be picked up again
2781        // at the same instant unless new changes arrive.
2782        let subs: Subscriptions<1> = Subscriptions::new();
2783        let pool = TestPool::<2>::new();
2784        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2785
2786        let now = Instant::now();
2787
2788        // Prime and keep.
2789        {
2790            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2791            rctx.set_keep();
2792        }
2793
2794        // Record one attribute change — watermark advances to 1.
2795        subs.notify_attr_changed(1, 2, 3);
2796
2797        // At the same instant, min_int (1s) has NOT elapsed so the sub is not
2798        // report-allowed: even though there is a pending change, `report()`
2799        // returns None.
2800        assert!(subs.report(now, 0, &subs_bufs).is_none());
2801
2802        // Past min_int: the pending change makes the sub reportable.
2803        let later = now + Duration::from_secs(2);
2804        {
2805            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2806            assert!(rctx.should_report_attr(1, 2, 3));
2807            // A fresh (never recorded) triple is NOT in the table and must
2808            // not be spuriously reported.
2809            assert!(!rctx.should_report_attr(9, 9, 9));
2810            rctx.set_keep();
2811        }
2812
2813        // Watermark has been committed — another call at `later` with no new
2814        // activity finds nothing.
2815        assert!(subs.report(later, 0, &subs_bufs).is_none());
2816    }
2817
2818    #[test]
2819    fn report_triggered_by_new_events() {
2820        // A bump in `event_numbers_watermark` (i.e. a newly emitted event)
2821        // makes the subscription reportable even without attribute changes.
2822        let subs: Subscriptions<1> = Subscriptions::new();
2823        let pool = TestPool::<2>::new();
2824        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2825
2826        let now = Instant::now();
2827        {
2828            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2829            rctx.set_keep();
2830        }
2831
2832        // Same instant, no new events (watermark = 0 same as sub's
2833        // max_seen), min_int not elapsed → nothing to report.
2834        assert!(subs.report(now, 0, &subs_bufs).is_none());
2835
2836        let later = now + Duration::from_secs(2);
2837
2838        // Still no new events at `later` (min_int elapsed though).
2839        assert!(subs.report(later, 0, &subs_bufs).is_none());
2840
2841        // A new event bumps the watermark → sub is reportable. The captured
2842        // `next_max_seen_event_number` mirrors the watermark and is the
2843        // value that will be committed on `set_keep`.
2844        {
2845            let mut rctx = subs.report(later, 5, &subs_bufs).unwrap();
2846            assert_eq!(rctx.max_seen_event_number(), 0);
2847            assert_eq!(rctx.next_max_seen_event_number(), 5);
2848            rctx.set_keep();
2849        }
2850
2851        // After reporting, watermark=5 is no longer "new" for this sub.
2852        assert!(subs.report(later, 5, &subs_bufs).is_none());
2853        // But a further bump does trigger again (past min_int is needed).
2854        let even_later = later + Duration::from_secs(2);
2855        {
2856            let mut rctx = subs.report(even_later, 6, &subs_bufs).unwrap();
2857            assert_eq!(rctx.max_seen_event_number(), 5);
2858            assert_eq!(rctx.next_max_seen_event_number(), 6);
2859            rctx.set_keep();
2860        }
2861    }
2862
2863    #[test]
2864    fn report_triggered_by_liveness_deadline() {
2865        // With no changes at all, a subscription still becomes reportable once
2866        // it enters the "liveness" window (within half of `max_int` of the
2867        // deadline).
2868        let subs: Subscriptions<1> = Subscriptions::new();
2869        let pool = TestPool::<2>::new();
2870        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2871
2872        let now = Instant::now();
2873        // max_int = 20s → half of max_int = 10s → becomes report-due at now+10s.
2874        {
2875            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 20);
2876            rctx.set_keep();
2877        }
2878
2879        // Short of the liveness window: not due.
2880        let short = now + Duration::from_secs(5);
2881        assert!(subs.report(short, 0, &subs_bufs).is_none());
2882
2883        // At the liveness window: due even without any attr/event change.
2884        let long = now + Duration::from_secs(11);
2885        {
2886            let mut rctx = subs.report(long, 0, &subs_bufs).unwrap();
2887            assert!(rctx.should_send_if_empty());
2888            rctx.set_keep();
2889        }
2890    }
2891
2892    #[test]
2893    fn next_report_at_max_when_empty() {
2894        // No subscriptions → no deadline (`Instant::MAX`); the timer never fires
2895        // and the reporter waits to be notified.
2896        let subs: Subscriptions<1> = Subscriptions::new();
2897        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2898        assert_eq!(subs.next_report_at(0, &subs_bufs), Instant::MAX);
2899    }
2900
2901    #[test]
2902    fn next_report_at_liveness_when_idle() {
2903        // No pending data → wake at the liveness point
2904        // `reported_at + max_int - max_int/2` (when `is_report_due` flips).
2905        let subs: Subscriptions<1> = Subscriptions::new();
2906        let pool = TestPool::<2>::new();
2907        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2908
2909        let now = Instant::now();
2910        // min_int = 1s, max_int = 60s → 60 - 30 = 30s.
2911        {
2912            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2913            rctx.set_keep();
2914        }
2915
2916        assert_eq!(
2917            subs.next_report_at(0, &subs_bufs),
2918            now + Duration::from_secs(30)
2919        );
2920    }
2921
2922    #[test]
2923    fn next_report_at_quiet_period_for_pending_attr_change() {
2924        // A change recorded inside the quiet period must schedule the wake at
2925        // the end of that period (`reported_at + min_int`), not the far-off
2926        // liveness point.
2927        let subs: Subscriptions<1> = Subscriptions::new();
2928        let pool = TestPool::<2>::new();
2929        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2930
2931        let now = Instant::now();
2932        // min_int = 5s, max_int = 60s (liveness would otherwise be at now+30s).
2933        {
2934            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 5, 60);
2935            rctx.set_keep();
2936        }
2937
2938        subs.notify_attr_changed(1, 2, 3);
2939
2940        // Held back by the quiet period, so still not reportable now...
2941        assert!(subs.report(now, 0, &subs_bufs).is_none());
2942        // ...but the wake is scheduled at min_int, not liveness.
2943        assert_eq!(
2944            subs.next_report_at(0, &subs_bufs),
2945            now + Duration::from_secs(5)
2946        );
2947    }
2948
2949    #[test]
2950    fn next_report_at_quiet_period_for_pending_event() {
2951        // Same as above, driven by a new event (watermark past the sub's
2952        // max_seen = 0) rather than an attribute change.
2953        let subs: Subscriptions<1> = Subscriptions::new();
2954        let pool = TestPool::<2>::new();
2955        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2956
2957        let now = Instant::now();
2958        {
2959            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 5, 60);
2960            rctx.set_keep();
2961        }
2962
2963        assert_eq!(
2964            subs.next_report_at(7, &subs_bufs),
2965            now + Duration::from_secs(5)
2966        );
2967    }
2968
2969    #[test]
2970    fn next_report_at_clamps_liveness_to_min_interval() {
2971        // Regression guard for the busy-spin: when `min_int > max_int/2`, the
2972        // liveness point (`reported_at + max_int - max_int/2`) precedes the
2973        // min-interval gate at which a report is first allowed. The wake MUST
2974        // be clamped to the gate; otherwise the reporter wakes early, finds the
2975        // sub still gated, re-arms the same past deadline, and spins.
2976        let subs: Subscriptions<1> = Subscriptions::new();
2977        let pool = TestPool::<2>::new();
2978        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2979
2980        let now = Instant::now();
2981        // min_int = 25s, max_int = 40s → liveness at now+20s, gate at now+25s.
2982        {
2983            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 25, 40);
2984            rctx.set_keep();
2985        }
2986
2987        // Clamped to the gate (now+25), not the earlier liveness point (now+20).
2988        assert_eq!(
2989            subs.next_report_at(0, &subs_bufs),
2990            now + Duration::from_secs(25)
2991        );
2992        // The scheduled instant matches actual reportability: gated before it,
2993        // reportable at it.
2994        assert!(subs
2995            .report(now + Duration::from_secs(24), 0, &subs_bufs)
2996            .is_none());
2997        assert!(subs
2998            .report(now + Duration::from_secs(25), 0, &subs_bufs)
2999            .is_some());
3000    }
3001
3002    #[test]
3003    fn next_report_at_returns_earliest_across_subs() {
3004        // The reporter must wake for whichever subscription is due first.
3005        let subs: Subscriptions<2> = Subscriptions::new();
3006        let pool = TestPool::<3>::new();
3007        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
3008
3009        let now = Instant::now();
3010        // Sub A: max_int = 60s → liveness now+30s.
3011        {
3012            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
3013            rctx.set_keep();
3014        }
3015        // Sub B: max_int = 40s → liveness now+20s (the earliest).
3016        {
3017            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 2, 11, 1, 40);
3018            rctx.set_keep();
3019        }
3020
3021        assert_eq!(
3022            subs.next_report_at(0, &subs_bufs),
3023            now + Duration::from_secs(20)
3024        );
3025    }
3026
3027    #[test]
3028    fn subscription_added_notification_wakes_reporter_to_recompute_deadline() {
3029        use core::pin::pin;
3030        use embassy_futures::select::{select, Either};
3031
3032        let subs: Subscriptions<2> = Subscriptions::new();
3033        let pool = TestPool::<3>::new();
3034        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
3035
3036        let now = Instant::now();
3037        {
3038            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
3039            rctx.set_keep();
3040        }
3041
3042        assert_eq!(
3043            subs.next_report_at(0, &subs_bufs),
3044            now + Duration::from_secs(30)
3045        );
3046
3047        let waiter = pin!(subs.notification.wait());
3048
3049        {
3050            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 11, 1, 10);
3051            rctx.set_keep();
3052        }
3053        subs.notification.notify();
3054
3055        let notified = embassy_futures::block_on(async {
3056            match select(waiter, pin!(core::future::ready(()))).await {
3057                Either::First(_) => true,
3058                Either::Second(_) => false,
3059            }
3060        });
3061
3062        assert!(notified);
3063        assert_eq!(
3064            subs.next_report_at(0, &subs_bufs),
3065            now + Duration::from_secs(5)
3066        );
3067    }
3068
3069    #[test]
3070    fn is_expired_uses_max_int() {
3071        // `Subscription::is_expired` returns true once `max_int` has elapsed
3072        // since the last reported_at.
3073        let subs: Subscriptions<1> = Subscriptions::new();
3074        let pool = TestPool::<2>::new();
3075        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
3076
3077        let base = Instant::now();
3078        {
3079            let mut rctx = add_sub(&subs, &subs_bufs, &pool, base, 1, 10, 1, 5);
3080            rctx.set_keep();
3081        }
3082
3083        // Before max_int: not expired. Use the `remove` predicate as a probe
3084        // because we have no other way to observe per-sub `is_expired` through
3085        // the public API.
3086        let before = base + Duration::from_secs(2);
3087        assert!(!subs.remove(&subs_bufs, |sub| sub
3088            .is_expired(before)
3089            .then_some("expired")));
3090
3091        // Past max_int: expired — removal fires.
3092        let after = base + Duration::from_secs(10);
3093        assert!(subs.remove(&subs_bufs, |sub| sub.is_expired(after).then_some("expired")));
3094    }
3095
3096    #[test]
3097    fn remove_invokes_predicate_and_frees_slots() {
3098        // `Subscriptions::remove` drains every matching entry (not just one),
3099        // returns whether anything was removed, and frees the slots so that
3100        // subsequent `add` calls succeed up to the capacity `N`.
3101        let subs: Subscriptions<3> = Subscriptions::new();
3102        let pool = TestPool::<4>::new();
3103        let subs_bufs: SubscriptionsBuffers<TestPool<4>, 3> = SubscriptionsBuffers::new();
3104
3105        let now = Instant::now();
3106        for peer in [100_u64, 101, 102] {
3107            let fab_idx = if peer == 102 { 2 } else { 1 };
3108            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, fab_idx, peer, 1, 60);
3109            rctx.set_keep();
3110        }
3111        // Table is full: a 4th add must be rejected.
3112        assert!(subs
3113            .add(
3114                now,
3115                fab(1),
3116                200,
3117                1,
3118                60,
3119                0,
3120                pool.get_immediate().unwrap(),
3121                &subs_bufs
3122            )
3123            .is_none());
3124
3125        // Remove every fab(1) subscription (2 of them).
3126        let mut seen_peers: std::vec::Vec<u64> = std::vec::Vec::new();
3127        let removed = subs.remove(&subs_bufs, |sub| {
3128            if sub.ids().fab_idx == fab(1) {
3129                seen_peers.push(sub.ids().peer_node_id);
3130                Some("fabric 1 removed")
3131            } else {
3132                None
3133            }
3134        });
3135        assert!(removed);
3136        seen_peers.sort();
3137        assert_eq!(seen_peers, std::vec![100_u64, 101]);
3138
3139        // A second identical remove is a no-op and returns false.
3140        assert!(!subs.remove(&subs_bufs, |sub| (sub.ids().fab_idx == fab(1))
3141            .then_some("fabric 1 removed")));
3142
3143        // Two slots were freed: we can add two more subs.
3144        {
3145            let mut r1 = add_sub(&subs, &subs_bufs, &pool, now, 3, 300, 1, 60);
3146            r1.set_keep();
3147            let mut r2 = add_sub(&subs, &subs_bufs, &pool, now, 3, 301, 1, 60);
3148            r2.set_keep();
3149        }
3150        // And a third add is rejected again (back at capacity).
3151        assert!(subs
3152            .add(
3153                now,
3154                fab(3),
3155                302,
3156                1,
3157                60,
3158                0,
3159                pool.get_immediate().unwrap(),
3160                &subs_bufs
3161            )
3162            .is_none());
3163    }
3164
3165    #[test]
3166    fn remove_on_empty_table_returns_false() {
3167        let subs: Subscriptions<2> = Subscriptions::new();
3168        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 2> = SubscriptionsBuffers::new();
3169        assert!(!subs.remove(&subs_bufs, |_| Some("never called on empty")));
3170    }
3171
3172    #[test]
3173    fn remove_cancels_in_flight_subscription() {
3174        // A subscription that has been moved into a `ReportContext` is still
3175        // observable to `remove` via `SubscriptionsInner::reporting`. Matching
3176        // it must cause `report_complete` to drop the subscription on Drop
3177        // rather than re-inserting it, even when `set_keep` was called.
3178        let subs: Subscriptions<2> = Subscriptions::new();
3179        let pool = TestPool::<3>::new();
3180        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
3181
3182        let now = Instant::now();
3183
3184        // Prime a subscription so it lives in the table.
3185        {
3186            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 100, 1, 60);
3187            rctx.set_keep();
3188        }
3189        assert_eq!(subs.state.lock(|s| s.borrow().subscriptions_count), 1);
3190
3191        // Start an incremental report and, while it is "in flight", issue
3192        // a `remove` that matches the in-flight subscription. Also flip
3193        // `set_keep` to verify the cancel flag wins over `keep`.
3194        subs.notify_attr_changed(1, 2, 3);
3195        let later = now + Duration::from_secs(2);
3196        {
3197            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
3198
3199            // The in-flight sub is currently absent from `state.subscriptions`
3200            // but must still be visible to `remove` through the `reporting`
3201            // slot.
3202            let mut matched_peers: std::vec::Vec<u64> = std::vec::Vec::new();
3203            let removed = subs.remove(&subs_bufs, |sub| {
3204                matched_peers.push(sub.ids().peer_node_id);
3205                (sub.ids().peer_node_id == 100).then_some("test-cancel")
3206            });
3207            assert!(removed);
3208            assert!(matched_peers.contains(&100));
3209
3210            // Even though we ask to keep, the cancel flag must force a drop.
3211            rctx.set_keep();
3212        }
3213
3214        // After `ReportContext::drop` the subscription must be gone and the
3215        // slot freed.
3216        subs.state.lock(|s| {
3217            let s = s.borrow();
3218            assert_eq!(s.subscriptions_count, 0);
3219            assert!(s.subscriptions.is_empty());
3220            assert!(s.reporting.is_none());
3221            assert!(s.reporting_cancelled.is_none());
3222        });
3223
3224        // Slot is free: a new sub can be added.
3225        let mut r = add_sub(&subs, &subs_bufs, &pool, now, 1, 101, 1, 60);
3226        r.set_keep();
3227    }
3228
3229    #[test]
3230    fn remove_not_matching_in_flight_leaves_it_intact() {
3231        // If `remove`'s predicate matches neither the in-flight subscription
3232        // nor anything in the table, the in-flight subscription must still
3233        // be re-inserted on `ReportContext::drop` when `set_keep` is called.
3234        let subs: Subscriptions<2> = Subscriptions::new();
3235        let pool = TestPool::<3>::new();
3236        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
3237
3238        let now = Instant::now();
3239        {
3240            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 100, 1, 60);
3241            rctx.set_keep();
3242        }
3243
3244        subs.notify_attr_changed(1, 2, 3);
3245        let later = now + Duration::from_secs(2);
3246        {
3247            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
3248            let removed = subs.remove(&subs_bufs, |sub| {
3249                (sub.ids().peer_node_id == 999).then_some("no-match")
3250            });
3251            assert!(!removed);
3252            rctx.set_keep();
3253        }
3254
3255        subs.state.lock(|s| {
3256            let s = s.borrow();
3257            assert_eq!(s.subscriptions_count, 1);
3258            assert_eq!(s.subscriptions.len(), 1);
3259            assert!(s.reporting.is_none());
3260            assert!(s.reporting_cancelled.is_none());
3261        });
3262    }
3263
3264    #[test]
3265    fn purge_reported_changes_keeps_entries_until_all_subs_catch_up() {
3266        // `purge_reported_changes` must only drop table entries every
3267        // subscription has already reported on: the slowest subscriber's
3268        // `max_seen_attr_change_id` acts as a floor.
3269        let subs: Subscriptions<2> = Subscriptions::new();
3270        let pool = TestPool::<3>::new();
3271        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
3272
3273        let base = Instant::now();
3274
3275        // Two priming adds. Both start at watermark = 0 (no changes yet).
3276        // `ReportContext::next_max_seen_attr_change_id` is captured as 0 by
3277        // `add`, so dropping either rctx with keep commits max_seen = 0.
3278        {
3279            let mut r1 = add_sub(&subs, &subs_bufs, &pool, base, 1, 100, 1, 60);
3280            r1.set_keep();
3281            let mut r2 = add_sub(&subs, &subs_bufs, &pool, base, 1, 101, 1, 60);
3282            r2.set_keep();
3283        }
3284
3285        // Record two changes. Watermark becomes 2.
3286        subs.notify_attr_changed(1, 2, 3); // id 1
3287        subs.notify_attr_changed(1, 2, 4); // id 2
3288
3289        // Advance both subs to watermark 2 via two `report` + keep cycles.
3290        let later = base + Duration::from_secs(2);
3291        for _ in 0..2 {
3292            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
3293            assert!(rctx.should_report_attr(1, 2, 3));
3294            assert!(rctx.should_report_attr(1, 2, 4));
3295            rctx.set_keep();
3296        }
3297
3298        // Both subs have max_seen = 2; purge is safe and removes the stale
3299        // entries. The next report should now find nothing pending (same
3300        // instant, no new changes, min_int elapsed but not half of max_int).
3301        subs.purge_reported_changes();
3302        assert!(subs.report(later, 0, &subs_bufs).is_none());
3303
3304        // A brand new change becomes pending again post-purge.
3305        subs.notify_attr_changed(5, 6, 7);
3306        let even_later = later + Duration::from_secs(2);
3307        {
3308            let mut rctx = subs.report(even_later, 0, &subs_bufs).unwrap();
3309            assert!(rctx.should_report_attr(5, 6, 7));
3310            // Previously-purged entries are no longer visible through the
3311            // sub's filter either.
3312            assert!(!rctx.should_report_attr(1, 2, 3));
3313            rctx.set_keep();
3314        }
3315    }
3316
3317    #[test]
3318    fn next_max_seen_event_number_captured_at_report_time() {
3319        // The captured `next_max_seen_event_number` reflects the
3320        // `event_numbers_watermark` passed to `add` / `report` and is what
3321        // gets committed to the subscription on `set_keep`. The committed
3322        // value advances even if no events were actually emitted during the
3323        // report — this is what prevents the "endless reporting loop" for
3324        // subscriptions that are not interested in events but receive an
3325        // event-triggered report.
3326        let subs: Subscriptions<1> = Subscriptions::new();
3327        let pool = TestPool::<2>::new();
3328        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
3329
3330        let now = Instant::now();
3331
3332        // Priming report sees the watermark passed to `add` (0 here).
3333        {
3334            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
3335            assert_eq!(rctx.max_seen_event_number(), 0);
3336            assert_eq!(rctx.next_max_seen_event_number(), 0);
3337            rctx.set_keep();
3338        }
3339
3340        let later = now + Duration::from_secs(2);
3341
3342        // First incremental report at watermark=7: the captured "next" is 7,
3343        // and the previous watermark (the sub's `max_seen_event_number`) is
3344        // still 0 until commit.
3345        {
3346            let mut rctx = subs.report(later, 7, &subs_bufs).unwrap();
3347            assert_eq!(rctx.max_seen_event_number(), 0);
3348            assert_eq!(rctx.next_max_seen_event_number(), 7);
3349            rctx.set_keep();
3350        }
3351
3352        // After commit the sub's `max_seen_event_number` has advanced to 7
3353        // — even though we never recorded a single emitted event during
3354        // this report. A second call at the same watermark is therefore a
3355        // no-op (no new events to deliver).
3356        assert!(subs.report(later, 7, &subs_bufs).is_none());
3357
3358        let even_later = later + Duration::from_secs(2);
3359
3360        // Bumping the watermark to 42 makes the sub reportable again; the
3361        // previous max-seen is the 7 we just committed, the captured next
3362        // is the new watermark.
3363        {
3364            let mut rctx = subs.report(even_later, 42, &subs_bufs).unwrap();
3365            assert_eq!(rctx.max_seen_event_number(), 7);
3366            assert_eq!(rctx.next_max_seen_event_number(), 42);
3367            rctx.set_keep();
3368        }
3369    }
3370
3371    // ---------- persistence ----------
3372
3373    // Gated as a unit: the persist/resume machinery under test only exists when
3374    // the `persistent-subscriptions` feature is enabled.
3375    #[cfg(feature = "persistent-subscriptions")]
3376    mod persistence {
3377        use super::*;
3378
3379        /// A minimal multi-key in-memory store, enough to test the
3380        /// one-record-per-key subscription persist/reload roundtrip.
3381        #[derive(Default)]
3382        struct MemKv {
3383            blobs: std::collections::HashMap<u16, std::vec::Vec<u8>>,
3384        }
3385
3386        impl KvBlobStore for &mut MemKv {
3387            fn load<'a>(&mut self, key: u16, buf: &'a mut [u8]) -> Result<Option<&'a [u8]>, Error> {
3388                Ok(self.blobs.get(&key).map(|v| {
3389                    buf[..v.len()].copy_from_slice(v);
3390                    &buf[..v.len()]
3391                }))
3392            }
3393
3394            fn store(&mut self, key: u16, data: &[u8], _buf: &mut [u8]) -> Result<(), Error> {
3395                self.blobs.insert(key, data.to_vec());
3396                Ok(())
3397            }
3398
3399            fn remove(&mut self, key: u16, _buf: &mut [u8]) -> Result<(), Error> {
3400                self.blobs.remove(&key);
3401                Ok(())
3402            }
3403        }
3404
3405        /// Add a subscription whose RX buffer carries `req` (standing in for a raw
3406        /// `SubscribeReq` TLV), and commit it into the table.
3407        #[allow(clippy::too_many_arguments)]
3408        fn add_sub_with_req<'a, 's, const N: usize, const B: usize>(
3409            subs: &'s Subscriptions<N>,
3410            subs_bufs: &'s SubscriptionsBuffers<'a, TestPool<B>, N>,
3411            pool: &'a TestPool<B>,
3412            now: Instant,
3413            fab_idx: u8,
3414            peer_node_id: u64,
3415            min_int: u16,
3416            max_int: u16,
3417            req: &[u8],
3418        ) where
3419            'a: 's,
3420        {
3421            let mut rx = pool.get_immediate().unwrap();
3422            rx.clear();
3423            rx.extend_from_slice(req).unwrap();
3424
3425            let mut rctx = subs
3426                .add(
3427                    now,
3428                    fab(fab_idx),
3429                    peer_node_id,
3430                    min_int,
3431                    max_int,
3432                    0,
3433                    rx,
3434                    subs_bufs,
3435                )
3436                .unwrap();
3437            // Commit it as a live subscription (`add` already stamped `reported_at`
3438            // with `now`, so this is a non-priming entry once kept).
3439            rctx.set_keep();
3440        }
3441
3442        /// A subscription persisted by one `Subscriptions` instance is faithfully
3443        /// re-hydrated — routing IDs, intervals, and the raw request bytes — into a
3444        /// fresh instance, exactly as a reboot would.
3445        #[test]
3446        fn persist_reload_roundtrip() {
3447            let mut kv = MemKv::default();
3448            let now = Instant::now();
3449
3450            // --- First "boot": build a table and persist it. ---
3451            {
3452                let subs: Subscriptions<4> = Subscriptions::new();
3453                let pool = TestPool::<5>::new();
3454                let subs_bufs: SubscriptionsBuffers<TestPool<5>, 4> = SubscriptionsBuffers::new();
3455
3456                add_sub_with_req(
3457                    &subs,
3458                    &subs_bufs,
3459                    &pool,
3460                    now,
3461                    1,
3462                    0xAABB,
3463                    1,
3464                    60,
3465                    &[1, 2, 3, 4],
3466                );
3467                add_sub_with_req(&subs, &subs_bufs, &pool, now, 2, 0xCCDD, 0, 120, &[9, 8, 7]);
3468
3469                let mut buf = [0u8; 512];
3470                subs.persist_all(&subs_bufs, &mut kv, &mut buf).unwrap();
3471            }
3472
3473            // Two records written; the rest of the reserved range is empty.
3474            assert!(kv.blobs.contains_key(&PERSISTENT_SUBSCRIPTIONS_START));
3475            assert!(kv.blobs.contains_key(&(PERSISTENT_SUBSCRIPTIONS_START + 1)));
3476            assert!(!kv.blobs.contains_key(&(PERSISTENT_SUBSCRIPTIONS_START + 2)));
3477
3478            // --- Second "boot": a fresh, empty table reloads from the same store. ---
3479            let subs2: Subscriptions<4> = Subscriptions::new();
3480            let pool2 = TestPool::<5>::new();
3481            let subs_bufs2: SubscriptionsBuffers<TestPool<5>, 4> = SubscriptionsBuffers::new();
3482
3483            let mut buf = [0u8; 512];
3484            subs2
3485                .load_persist(&pool2, &subs_bufs2, &mut kv, &mut buf, now, 0)
3486                .unwrap();
3487
3488            // Both subscriptions are back, with their routing + intervals intact...
3489            subs2.state.lock(|s| {
3490                let s = s.borrow();
3491                assert_eq!(s.subscriptions.len(), 2);
3492
3493                let a = s
3494                    .subscriptions
3495                    .iter()
3496                    .find(|x| x.ids.peer_node_id == 0xAABB)
3497                    .expect("first sub resumed");
3498                assert_eq!(a.ids.fab_idx, fab(1));
3499                assert_eq!(a.min_int_secs, 1);
3500                assert_eq!(a.max_int_secs, 60);
3501
3502                let b = s
3503                    .subscriptions
3504                    .iter()
3505                    .find(|x| x.ids.peer_node_id == 0xCCDD)
3506                    .expect("second sub resumed");
3507                assert_eq!(b.ids.fab_idx, fab(2));
3508                assert_eq!(b.min_int_secs, 0);
3509                assert_eq!(b.max_int_secs, 120);
3510
3511                // Resumed subscriptions must be un-primed so the reporter sends a
3512                // prompt priming report (resetting the subscriber's own liveness
3513                // clock before it can time the subscription out), NOT wait toward
3514                // our max-interval deadline.
3515                for sub in s.subscriptions.iter() {
3516                    assert!(
3517                        sub.is_report_due(now),
3518                        "resumed sub {:?} should be immediately report-due (primed)",
3519                        sub.ids()
3520                    );
3521                    assert!(sub.is_report_due(now + Duration::from_secs(1)));
3522                }
3523            });
3524
3525            // ...and the raw request bytes survived (they live in the parallel buffer
3526            // pool, indexed alongside the subscriptions).
3527            subs_bufs2.with(|bufs| {
3528                let mut reqs: std::vec::Vec<std::vec::Vec<u8>> =
3529                    bufs.iter().map(|b| b[..].to_vec()).collect();
3530                reqs.sort();
3531                assert_eq!(reqs, std::vec![std::vec![1, 2, 3, 4], std::vec![9, 8, 7]]);
3532            });
3533
3534            // The ICD predicate now sees the resumed subscriptions.
3535            assert!(subs2.has_subscription_for(fab(1), 0xAABB));
3536            assert!(subs2.has_subscription_for(fab(2), 0xCCDD));
3537            assert!(!subs2.has_subscription_for(fab(1), 0x9999));
3538        }
3539
3540        /// Removing a subscription and re-persisting drops exactly its record from
3541        /// the store (the table stays an exact mirror of what is on disk).
3542        #[test]
3543        fn persist_mirrors_removal() {
3544            let mut kv = MemKv::default();
3545            let now = Instant::now();
3546
3547            let subs: Subscriptions<4> = Subscriptions::new();
3548            let pool = TestPool::<5>::new();
3549            let subs_bufs: SubscriptionsBuffers<TestPool<5>, 4> = SubscriptionsBuffers::new();
3550
3551            add_sub_with_req(&subs, &subs_bufs, &pool, now, 1, 100, 1, 60, &[1]);
3552            add_sub_with_req(&subs, &subs_bufs, &pool, now, 1, 101, 1, 60, &[2]);
3553            add_sub_with_req(&subs, &subs_bufs, &pool, now, 2, 102, 1, 60, &[3]);
3554
3555            let mut buf = [0u8; 512];
3556            subs.persist_all(&subs_bufs, &mut kv, &mut buf).unwrap();
3557            assert_eq!(kv.blobs.len(), 3);
3558
3559            // Drop the two fab(1) subscriptions and re-persist.
3560            subs.remove(&subs_bufs, |sub| {
3561                (sub.ids().fab_idx == fab(1)).then_some("fabric 1 removed")
3562            });
3563            subs.persist_all(&subs_bufs, &mut kv, &mut buf).unwrap();
3564
3565            // Only the single surviving record remains on disk.
3566            assert_eq!(kv.blobs.len(), 1);
3567
3568            // And a fresh reload sees exactly that one.
3569            let subs2: Subscriptions<4> = Subscriptions::new();
3570            let pool2 = TestPool::<5>::new();
3571            let subs_bufs2: SubscriptionsBuffers<TestPool<5>, 4> = SubscriptionsBuffers::new();
3572            subs2
3573                .load_persist(&pool2, &subs_bufs2, &mut kv, &mut buf, now, 0)
3574                .unwrap();
3575            subs2.state.lock(|s| {
3576                let s = s.borrow();
3577                assert_eq!(s.subscriptions.len(), 1);
3578                assert_eq!(s.subscriptions[0].ids.peer_node_id, 102);
3579            });
3580        }
3581
3582        /// `reset_persist` wipes the whole reserved range.
3583        #[test]
3584        fn reset_persist_clears_all_records() {
3585            let mut kv = MemKv::default();
3586            let now = Instant::now();
3587
3588            let subs: Subscriptions<4> = Subscriptions::new();
3589            let pool = TestPool::<5>::new();
3590            let subs_bufs: SubscriptionsBuffers<TestPool<5>, 4> = SubscriptionsBuffers::new();
3591
3592            add_sub_with_req(&subs, &subs_bufs, &pool, now, 1, 100, 1, 60, &[1]);
3593            add_sub_with_req(&subs, &subs_bufs, &pool, now, 2, 101, 1, 60, &[2]);
3594
3595            let mut buf = [0u8; 512];
3596            subs.persist_all(&subs_bufs, &mut kv, &mut buf).unwrap();
3597            assert_eq!(kv.blobs.len(), 2);
3598
3599            subs.reset_persist(&mut kv, &mut buf).unwrap();
3600            assert!(kv.blobs.is_empty());
3601        }
3602    }
3603}