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
22use crate::fabric::MAX_FABRICS;
23use crate::im::{AttrId, ClusterId, EndptId, EventId, EventNumber, IMBuffer, NodeId};
24use crate::utils::cell::RefCell;
25use crate::utils::init::{init, Init};
26use crate::utils::storage::pooled::Buffers;
27use crate::utils::storage::Vec;
28use crate::utils::sync::blocking::Mutex;
29use crate::utils::sync::{DynBase, Notification};
30
31/// The maximum number of subscriptions that can be tracked at the same time by default.
32///
33/// According to the Matter spec, at least 3 subscriptions per fabric should be supported.
34pub const DEFAULT_MAX_SUBSCRIPTIONS: usize = MAX_FABRICS * 3;
35
36/// The maximum number of changed-attribute entries tracked simultaneously.
37///
38/// When the table is full, entries are coalesced ("promoted") to coarser-grained
39/// wildcards so that new changes can always be recorded.
40pub const MAX_CHANGED_ATTRS: usize = 16;
41
42/// A struct for the RX buffers containing the read requests of the tracked subscriptions.
43// NOTE: `SubscriptionsBuffers` is a thin wrapper around a second
44// `Mutex<RefCell<Vec<..>>>` that is *always* locked in lockstep with
45// `Subscriptions::state` (see `Subscriptions::with`). As long as that lock
46// order is respected the pair is safe, but the two locks let someone (now or
47// in the future) lock only one of them and violate the invariant that
48// `subscriptions.len() == buffers.len()`. The cleanest fix is to move the
49// `Vec<B::Buffer<'a>, N>` *into* `SubscriptionsInner` behind the same mutex
50// so it cannot be locked independently. The current layout also forces all
51// public APIs to thread an extra `&SubscriptionsBuffers` argument everywhere,
52// which is why `remove` / `report` / `add` all grew a second ref parameter.
53pub struct SubscriptionsBuffers<'a, B, const N: usize = DEFAULT_MAX_SUBSCRIPTIONS>
54where
55    B: Buffers<IMBuffer> + 'a,
56{
57    buffers: Mutex<RefCell<SubscriptionsBuffersInner<'a, B, N>>>,
58}
59
60impl<'a, B, const N: usize> SubscriptionsBuffers<'a, B, N>
61where
62    B: Buffers<IMBuffer> + 'a,
63{
64    /// Create the instance.
65    pub const fn new() -> Self {
66        Self {
67            buffers: Mutex::new(RefCell::new(Vec::new())),
68        }
69    }
70
71    /// Return an in-place initializer for the instance.
72    pub fn init() -> impl Init<Self> {
73        init!(Self {
74            buffers <- Mutex::init(RefCell::init(Vec::init())),
75        })
76    }
77
78    fn with<F, R>(&self, f: F) -> R
79    where
80        F: FnOnce(&mut SubscriptionsBuffersInner<'a, B, N>) -> R,
81    {
82        self.buffers.lock(|buffers| f(&mut buffers.borrow_mut()))
83    }
84}
85
86impl<'a, B, const N: usize> Default for SubscriptionsBuffers<'a, B, N>
87where
88    B: Buffers<IMBuffer> + 'a,
89{
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95/// A type alias for the inner buffer vector of `SubscriptionsBuffers`.
96type SubscriptionsBuffersInner<'a, B, const N: usize> =
97    Vec<<B as Buffers<IMBuffer>>::Buffer<'a>, N>;
98
99/// A type for tracking subscriptions accepted by the data model.
100///
101/// The `N` type parameter specifies the maximum number of subscriptions that can be tracked at the same time.
102/// Additional subscriptions are rejected by the data model with a "resource exhausted" IM status message.
103pub struct Subscriptions<const N: usize = DEFAULT_MAX_SUBSCRIPTIONS> {
104    state: Mutex<RefCell<SubscriptionsInner<N>>>,
105    pub(crate) notification: Notification,
106}
107
108impl<const N: usize> Subscriptions<N> {
109    /// Create the instance.
110    #[inline(always)]
111    pub const fn new() -> Self {
112        Self {
113            state: Mutex::new(RefCell::new(SubscriptionsInner::new())),
114            notification: Notification::new(),
115        }
116    }
117
118    /// Create an in-place initializer for the instance.
119    pub fn init() -> impl Init<Self> {
120        init!(Self {
121            state <- Mutex::init(RefCell::init(SubscriptionsInner::init())),
122            notification: Notification::new(),
123        })
124    }
125
126    /// Notify the instance that the data of a specific attribute has changed and that it should re-evaluate the subscriptions
127    /// and report on those that are interested in the changed data.
128    ///
129    /// This method is supposed to be called by the application code whenever it changes the data of an attribute.
130    ///
131    /// # Arguments
132    /// - `endpoint_id`: The endpoint ID of the cluster that had changed.
133    /// - `cluster_id`: The cluster ID of the cluster that had changed.
134    /// - `attr_id`: The attribute ID of the attribute that changed.
135    pub(crate) fn notify_attr_changed(
136        &self,
137        endpoint_id: EndptId,
138        cluster_id: ClusterId,
139        attr_id: AttrId,
140    ) {
141        self.state.lock(|internal| {
142            internal
143                .borrow_mut()
144                .changed_attrs
145                .record(endpoint_id, cluster_id, attr_id);
146        });
147
148        // The per-subscription decision of whether anything needs to be reported is
149        // computed on-the-fly by `find_report_due` (and by the responder's filter)
150        // by consulting the live `changed_attrs` table, so there is no per-sub flag
151        // to flip here. We just wake the reporter task.
152        self.notification.notify();
153    }
154
155    /// Record a cluster-wide change. Every attribute of `(endpoint_id,
156    /// cluster_id)` is treated as changed for the purposes of subscription
157    /// reporting.
158    pub(crate) fn notify_cluster_changed(&self, endpoint_id: EndptId, cluster_id: ClusterId) {
159        self.state.lock(|internal| {
160            internal
161                .borrow_mut()
162                .changed_attrs
163                .record_wildcard(Some(endpoint_id), Some(cluster_id));
164        });
165
166        self.notification.notify();
167    }
168
169    /// Record an endpoint-wide change. Every attribute on every cluster of
170    /// `endpoint_id` is treated as changed for the purposes of subscription
171    /// reporting.
172    pub(crate) fn notify_endpoint_changed(&self, endpoint_id: EndptId) {
173        self.state.lock(|internal| {
174            internal
175                .borrow_mut()
176                .changed_attrs
177                .record_wildcard(Some(endpoint_id), None);
178        });
179
180        self.notification.notify();
181    }
182
183    /// Record a fully-global change. Every attribute on every cluster on
184    /// every endpoint is treated as changed for the purposes of subscription
185    /// reporting. Intended for coarse-grained reset / restart scenarios.
186    pub(crate) fn notify_all_changed(&self) {
187        self.state.lock(|internal| {
188            internal
189                .borrow_mut()
190                .changed_attrs
191                .record_wildcard(None, None);
192        });
193
194        self.notification.notify();
195    }
196
197    /// Notify the instance that a new event has been emitted and that it should
198    /// re-evaluate the subscriptions and report on those that are interested in the new event.
199    ///
200    /// Public for the integration tests.
201    pub fn notify_event_emitted(
202        &self,
203        _endpoint_id: EndptId,
204        _cluster_id: ClusterId,
205        _event_id: EventId,
206    ) {
207        // Events are filtered at report time by `min_event_number` + event path matching.
208        // Whether a subscription is due to report because of new events is recomputed on
209        // the fly in `find_report_due`, so here we only need to kick the reporter task.
210        self.notification.notify();
211    }
212
213    /// Clear all subscriptions and pending changes.
214    /// Used when initializing a new data model.
215    pub(crate) fn clear(&self) {
216        self.state.lock(|state| state.borrow_mut().clear());
217    }
218
219    /// Add a new subscription with the given parameters.
220    /// Returns a context for the initial report if successful, or `None` if the subscription table is full.
221    #[allow(clippy::too_many_arguments)]
222    pub(crate) fn add<'a, 's, B>(
223        &'s self,
224        now: Instant,
225        fabric_idx: NonZeroU8,
226        peer_node_id: u64,
227        session_id: u32,
228        min_int_secs: u16,
229        max_int_secs: u16,
230        event_numbers_watermark: EventNumber,
231        buffer: B::Buffer<'a>,
232        buffers: &'s SubscriptionsBuffers<'a, B, N>,
233    ) -> Option<ReportContext<'a, 's, B, N>>
234    where
235        B: Buffers<IMBuffer> + 'a,
236    {
237        let (sub, buf, next_max_seen_attr_change_id) = self.with(buffers, |state, buffers| {
238            let (sub, buf) = state.add::<B>(
239                fabric_idx,
240                peer_node_id,
241                session_id,
242                min_int_secs,
243                max_int_secs,
244                buffer,
245                buffers,
246            )?;
247
248            // Mirror `report()`: commit the current watermark so the priming
249            // report's `set_keep` does not regress the subscription's `since`
250            // to 0 (which would cause every pre-`add` change to be replayed
251            // on the first incremental report).
252            Some((sub, buf, state.changed_attrs.watermark()))
253        })?;
254
255        Some(ReportContext {
256            subscriptions: self,
257            subscriptions_buffers: buffers,
258            subscription: Some(sub),
259            subscription_buffer: Some(buf),
260            next_max_seen_attr_change_id,
261            next_max_seen_event_number: event_numbers_watermark,
262            next_reported_at: now,
263            keep: false,
264        })
265    }
266
267    /// Remove every subscription for which `f` returns `Some(reason)`.
268    ///
269    /// A subscription that is currently being reported on has been moved out
270    /// of `state.subscriptions` into its `ReportContext` (see
271    /// [`SubscriptionsInner::report`]). To keep such an in-flight subscription
272    /// observable, [`SubscriptionsInner::report`] also leaves a clone of it in
273    /// `state.reporting`. If the predicate matches that clone, we flip
274    /// `state.reporting_cancelled` so that [`SubscriptionsInner::report_complete`]
275    /// drops the subscription on `Drop` of its `ReportContext` instead of
276    /// re-inserting it. The count invariant is preserved: either the Vec path
277    /// decrements `subscriptions_count` now, or `report_complete` does it
278    /// later — never both for the same subscription.
279    pub(crate) fn remove<B, F>(&self, buffers: &SubscriptionsBuffers<'_, B, N>, mut f: F) -> bool
280    where
281        B: Buffers<IMBuffer>,
282        F: FnMut(&Subscription) -> Option<&'static str>,
283    {
284        let removed = self.with(buffers, |state, buffers| {
285            let mut removed = false;
286
287            loop {
288                let next = state
289                    .subscriptions
290                    .iter()
291                    .enumerate()
292                    .filter_map(|(index, subscription)| {
293                        f(subscription).map(|reason| (index, subscription.ids().clone(), reason))
294                    })
295                    .next();
296
297                let Some((index, ids, reason)) = next else {
298                    break;
299                };
300
301                state.subscriptions.swap_remove(index);
302                buffers.swap_remove(index);
303
304                state.subscriptions_count -= 1;
305
306                info!("Removed subscription {:?}, reason: {}", ids, reason);
307
308                removed = true;
309            }
310
311            // Consider the in-flight subscription (if any). It is not in
312            // `state.subscriptions`; only a snapshot clone lives in
313            // `state.reporting`. If the predicate matches and we have not
314            // already flagged it for cancellation, request that
315            // `report_complete` drop it.
316            if state.reporting_cancelled.is_none() {
317                if let Some(sub) = state.reporting.as_ref() {
318                    if let Some(reason) = f(sub) {
319                        info!(
320                            "Marked in-flight subscription {:?} for removal, reason: {}",
321                            sub.ids(),
322                            reason
323                        );
324                        state.reporting_cancelled = Some(reason);
325                        removed = true;
326                    }
327                }
328            }
329
330            removed
331        });
332
333        if removed {
334            self.notification.notify();
335        }
336
337        removed
338    }
339
340    /// Begin a report for the subscription with the given parameters.
341    /// Returns a context capturing the subscription's current state if successful, or `None`
342    /// if no subscription is currently reportable.
343    pub(crate) fn report<'a, 's, B>(
344        &'s self,
345        now: Instant,
346        event_numbers_watermark: EventNumber,
347        buffers: &'s SubscriptionsBuffers<'a, B, N>,
348    ) -> Option<ReportContext<'a, 's, B, N>>
349    where
350        B: Buffers<IMBuffer> + 'a,
351    {
352        let (sub, buf, next_max_seen_attr_change_id) = self.with(buffers, |state, buffers| {
353            let (sub, buf) = state.report::<B>(now, event_numbers_watermark, buffers)?;
354            let attr_change_ids_watermark = state.changed_attrs.watermark();
355
356            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);
357
358            Some((sub, buf, attr_change_ids_watermark))
359        })?;
360
361        Some(ReportContext {
362            subscriptions: self,
363            subscriptions_buffers: buffers,
364            subscription: Some(sub),
365            subscription_buffer: Some(buf),
366            next_max_seen_attr_change_id,
367            next_max_seen_event_number: event_numbers_watermark,
368            next_reported_at: now,
369            keep: false,
370        })
371    }
372
373    /// Earliest [`Instant`] at which any subscription will next need
374    /// servicing, or [`Instant::MAX`] if there are no (primed) subscriptions
375    /// and the reporter should simply wait to be notified.
376    ///
377    /// See [`Subscription::next_report_at`] for the per-subscription rule.
378    pub(crate) fn next_report_at<'a, B>(
379        &self,
380        event_numbers_watermark: EventNumber,
381        buffers: &SubscriptionsBuffers<'a, B, N>,
382    ) -> Instant
383    where
384        B: Buffers<IMBuffer> + 'a,
385    {
386        self.with(buffers, |state, buffers| {
387            state.next_report_at::<B>(event_numbers_watermark, buffers)
388        })
389    }
390
391    /// Remove entries that every subscription has already reported on.
392    pub(crate) fn purge_reported_changes(&self) {
393        self.state
394            .lock(|state| state.borrow_mut().purge_reported_changes())
395    }
396
397    /// Complete a report by updating the subscription's watermark and last-reported timestamp,
398    /// and re-inserting it into the table if the `keep` flag is set on the context.
399    fn report_complete<'a, B>(&self, report: &mut ReportContext<'a, '_, B, N>)
400    where
401        B: Buffers<IMBuffer> + 'a,
402    {
403        let mut sub = unwrap!(report.subscription.take());
404        let buf = unwrap!(report.subscription_buffer.take());
405
406        sub.max_seen_attr_change_id = report.next_max_seen_attr_change_id;
407        sub.max_seen_event_number = report.next_max_seen_event_number;
408        sub.reported_at = report.next_reported_at;
409
410        let keep = report.keep;
411
412        self.with(report.subscriptions_buffers, |state, buffers| {
413            state.report_complete::<B>(sub, buf, buffers, keep)
414        })
415    }
416
417    fn with<'a, B, F, R>(&self, buffers: &SubscriptionsBuffers<'a, B, N>, f: F) -> R
418    where
419        B: Buffers<IMBuffer> + 'a,
420        F: FnOnce(&mut SubscriptionsInner<N>, &mut SubscriptionsBuffersInner<'a, B, N>) -> R,
421    {
422        self.state.lock(|state| {
423            let mut state = state.borrow_mut();
424            buffers.with(|buffers| f(&mut state, buffers))
425        })
426    }
427}
428
429impl<const N: usize> Default for Subscriptions<N> {
430    fn default() -> Self {
431        Self::new()
432    }
433}
434
435impl<const N: usize> DynBase for Subscriptions<N> {}
436
437/// The inner state of `Subscriptions`, protected by a mutex.
438/// See `Subscriptions` for the public API and invariants.
439struct SubscriptionsInner<const N: usize> {
440    /// Monotonically increasing ID assigned to every accepted subscription.
441    /// The first assigned ID is 1; `0` is reserved as the "no subscription" sentinel used by `reporting`.
442    next_subscription_id: u32,
443    /// The total number of accepted subscriptions, including any currently
444    /// in-flight one (i.e. one whose `Subscription` has been moved into a
445    /// `ReportContext` and is therefore temporarily not in `subscriptions`).
446    /// Used to enforce the `N` capacity bound in `add`.
447    subscriptions_count: usize,
448    /// The active subscriptions. Does NOT include a subscription that is
449    /// currently being reported on; see `reporting` for the snapshot of the
450    /// in-flight one.
451    subscriptions: Vec<Subscription, N>,
452    /// The changed attributes that subscriptions are consulting to decide whether and what they need to report.
453    changed_attrs: ChangedAttrs,
454    /// Snapshot of the subscription currently being reported on (i.e. the
455    /// one that has been `swap_remove`d into a `ReportContext`). `None` when
456    /// no report is in flight. This is a frozen clone captured at `report()`
457    /// time; mutations made by `ReportContext` (e.g. to
458    /// `max_seen_event_number`) are NOT visible here. The slot exists so
459    /// that `Subscriptions::remove` can still observe and cancel an
460    /// in-flight subscription.
461    reporting: Option<Subscription>,
462    /// Set by `Subscriptions::remove` when its predicate matched
463    /// `reporting`. Consumed by `report_complete`, which then drops the
464    /// subscription (and decrements `subscriptions_count`) regardless of the
465    /// `keep` flag on the `ReportContext`.
466    reporting_cancelled: Option<&'static str>,
467}
468
469impl<const N: usize> SubscriptionsInner<N> {
470    /// Create the instance.
471    #[inline(always)]
472    const fn new() -> Self {
473        Self {
474            next_subscription_id: 1,
475            subscriptions_count: 0,
476            subscriptions: Vec::new(),
477            changed_attrs: ChangedAttrs::new(),
478            reporting: None,
479            reporting_cancelled: None,
480        }
481    }
482
483    /// Create an in-place initializer for the instance.
484    fn init() -> impl Init<Self> {
485        init!(Self {
486            next_subscription_id: 1,
487            subscriptions_count: 0,
488            subscriptions <- Vec::init(),
489            changed_attrs <- ChangedAttrs::init(),
490            reporting: None,
491            reporting_cancelled: None,
492        })
493    }
494
495    fn clear(&mut self) {
496        self.subscriptions.clear();
497        self.subscriptions_count = 0;
498        // If a report is in flight, make sure `report_complete` drops it
499        // rather than pushing it back into an otherwise-empty table.
500        if self.reporting.is_some() {
501            self.reporting_cancelled = Some("subscriptions cleared");
502            // The in-flight subscription is still counted in
503            // `subscriptions_count` until `report_complete` runs; restore
504            // that so the decrement there balances.
505            self.subscriptions_count = 1;
506        }
507    }
508
509    /// Add a subscription with the given parameters.
510    ///
511    /// Returns the assigned subscription ID on success, or `None` if the subscription table is full.
512    #[allow(clippy::too_many_arguments)]
513    fn add<'a, B>(
514        &mut self,
515        fab_idx: NonZeroU8,
516        peer_node_id: u64,
517        session_id: u32,
518        min_int_secs: u16,
519        max_int_secs: u16,
520        buffer: B::Buffer<'a>,
521        _buffers: &mut SubscriptionsBuffersInner<'a, B, N>,
522    ) -> Option<(Subscription, B::Buffer<'a>)>
523    where
524        B: Buffers<IMBuffer> + 'a,
525    {
526        if self.subscriptions_count >= N {
527            return None;
528        }
529
530        self.subscriptions_count += 1;
531
532        let id = self.next_subscription_id;
533        self.next_subscription_id += 1;
534
535        // Start with the current watermark so that only changes happening AFTER the
536        // subscription was accepted will be reported as incremental updates.
537        let max_seen_attr_change_id = self.changed_attrs.watermark();
538
539        let subscription = Subscription {
540            ids: SubscriptionIds {
541                id,
542                fab_idx,
543                peer_node_id,
544            },
545            session_id,
546            min_int_secs,
547            max_int_secs,
548            reported_at: Instant::MAX,
549            max_seen_attr_change_id,
550            // Start at 0 so the priming report delivers every event that was
551            // already in the event buffer at subscribe time. The reader will
552            // advance this via `update_max_seen_event_number` once the
553            // priming report has consumed the events.
554            max_seen_event_number: 0,
555        };
556
557        info!("Added subscription {:?}", subscription.ids());
558
559        Some((subscription, buffer))
560    }
561
562    /// Begin a report for the subscription with the given ID.
563    ///
564    /// Returns a small [`ReportContext`] capturing the subscription's current
565    /// `since` watermark and the watermark to commit via [`Self::mark_reported`]
566    /// on success. Unlike a snapshot, the `changed_attrs` table itself is not
567    /// copied; the report uses a [`SubAttrChangeFilter`] that consults the
568    /// live table one attribute at a time.
569    ///
570    /// `priming = true` produces a context with filtering disabled; it is
571    /// used for the initial ("priming") report delivered right after a
572    /// subscription is accepted.
573    fn report<'a, B>(
574        &mut self,
575        now: Instant,
576        event_numbers_watermark: EventNumber,
577        buffers: &mut SubscriptionsBuffersInner<'a, B, N>,
578    ) -> Option<(Subscription, B::Buffer<'a>)>
579    where
580        B: Buffers<IMBuffer> + 'a,
581    {
582        // `reporting` must be vacant: callers only start a new report after
583        // the previous `ReportContext` has been dropped (which clears the
584        // slot via `report_complete`).
585        debug_assert!(self.reporting.is_none());
586        debug_assert!(self.reporting_cancelled.is_none());
587
588        if let Some(index) = self.find_reportable::<B>(now, event_numbers_watermark, buffers) {
589            let sub = self.subscriptions.swap_remove(index);
590            let buf = buffers.swap_remove(index);
591
592            debug!("About to report on subscription {:?}", sub.ids());
593
594            // Leave a snapshot clone behind so that `Subscriptions::remove`
595            // can still match and cancel this subscription while the report
596            // is in flight.
597            self.reporting = Some(sub.clone());
598
599            Some((sub, buf))
600        } else {
601            None
602        }
603    }
604
605    fn report_complete<'a, B>(
606        &mut self,
607        sub: Subscription,
608        buffer: B::Buffer<'a>,
609        buffers: &mut SubscriptionsBuffersInner<'a, B, N>,
610        keep: bool,
611    ) where
612        B: Buffers<IMBuffer> + 'a,
613    {
614        // Always clear the reporting slot; it was populated in `report()`.
615        self.reporting = None;
616        let cancelled = self.reporting_cancelled.take();
617
618        if let Some(reason) = cancelled {
619            info!(
620                "In-flight subscription {:?} cancelled during reporting: {}",
621                sub.ids(),
622                reason
623            );
624            self.subscriptions_count -= 1;
625        } else if keep {
626            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);
627
628            unwrap!(self.subscriptions.push(sub));
629            unwrap!(buffers.push(buffer).map_err(|_| ()));
630        } else {
631            warn!("Subscription {:?} removed during reporting", sub.ids());
632            self.subscriptions_count -= 1;
633        }
634    }
635
636    fn find_reportable<'a, B>(
637        &self,
638        now: Instant,
639        event_numbers_watermark: EventNumber,
640        buffers: &SubscriptionsBuffersInner<'a, B, N>,
641    ) -> Option<usize>
642    where
643        B: Buffers<IMBuffer> + 'a,
644    {
645        self.subscriptions
646            .iter()
647            .enumerate()
648            .map(|(index, sub)| (sub, &buffers[index]))
649            .position(|(sub, rx)| {
650                sub.is_reportable(now, rx, &self.changed_attrs, event_numbers_watermark)
651            })
652    }
653
654    /// Remove entries that every subscription has already reported on.
655    fn purge_reported_changes(&mut self) {
656        if let Some(min_seen_attr_change_id) = self
657            .subscriptions
658            .iter()
659            .map(|s| s.max_seen_attr_change_id)
660            .min()
661        {
662            self.changed_attrs.purge_up_to(min_seen_attr_change_id);
663        } else {
664            self.changed_attrs.clear();
665        }
666    }
667
668    /// Earliest [`Instant`] at which any subscription will next need servicing,
669    /// or [`Instant::MAX`] if none has a wake point (empty table or all
670    /// not-yet-primed).
671    fn next_report_at<'a, B>(
672        &self,
673        event_numbers_watermark: EventNumber,
674        buffers: &SubscriptionsBuffersInner<'a, B, N>,
675    ) -> Instant
676    where
677        B: Buffers<IMBuffer> + 'a,
678    {
679        self.subscriptions
680            .iter()
681            .enumerate()
682            .map(|(index, sub)| {
683                sub.next_report_at(
684                    &buffers[index],
685                    &self.changed_attrs,
686                    event_numbers_watermark,
687                )
688            })
689            .min()
690            .unwrap_or(Instant::MAX)
691    }
692}
693
694/// The IDs of a subscription, used to identify it across the system and to route reports to it.
695#[derive(Clone, Debug)]
696#[cfg_attr(feature = "defmt", derive(defmt::Format))]
697pub struct SubscriptionIds {
698    /// The ID of the subscription. Uniquely identifies the subscription across all of them.
699    pub id: u32,
700    /// The fabric index of the subscriber. Used to route reports and to remove all subscriptions of a fabric when it gets removed.
701    pub fab_idx: NonZeroU8,
702    /// The node ID of the subscriber. Used to route reports and to remove all subscriptions of a peer when it gets removed.
703    pub peer_node_id: NodeId,
704}
705
706#[derive(Clone, Debug)]
707#[cfg_attr(feature = "defmt", derive(defmt::Format))]
708pub struct Subscription {
709    /// The IDs of the subscription
710    ids: SubscriptionIds,
711    /// The ID of the session on which the subscription was accepted. Used by
712    /// the reporter task to route outgoing reports back to the exact session
713    /// the subscriber established, rather than picking any secure session
714    /// matching `(fab_idx, peer_node_id)` (which may not be the one the peer
715    /// is actually listening on, breaking at least HomeKit and chip-tool).
716    session_id: u32,
717    /// The minimum interval in seconds. The subscription should not receive reports more frequently than this interval, but may receive them less frequently.
718    /// We use u16 instead of embassy::Duration to save some storage
719    min_int_secs: u16,
720    /// 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).
721    /// We use u16 instead of embassy::Duration to save some storage
722    max_int_secs: u16,
723    /// The timestamp of the last report sent to this subscription. Used to decide when the next report is due based on the min/max intervals.
724    /// 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 report, it is updated to the actual timestamp of the last report.
725    reported_at: Instant,
726    /// The largest attribute change ID from the [`ChangedAttributes`] table this subscription
727    /// has already reported on. Entries with a larger change ID represent pending changes the subscription still needs to emit.
728    max_seen_attr_change_id: u64,
729    /// 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.
730    max_seen_event_number: u64,
731}
732
733impl Subscription {
734    /// Return the IDs of the subscription.
735    pub const fn ids(&self) -> &SubscriptionIds {
736        &self.ids
737    }
738
739    /// Return the session ID on which this subscription was accepted.
740    pub const fn session_id(&self) -> u32 {
741        self.session_id
742    }
743
744    /// Return `true` if the subscription is expired and should be removed, or `false` if it is still active.
745    pub fn is_expired(&self, now: Instant) -> bool {
746        self.reported_at
747            .checked_add(embassy_time::Duration::from_secs(self.max_int_secs as _))
748            .map(|expiry| expiry <= now)
749            .unwrap_or(false)
750    }
751
752    /// Return `true` if the subscription is due for a report based on the given parameters, or `false` if it is not.
753    fn is_reportable(
754        &self,
755        now: Instant,
756        rx: &[u8],
757        changed_attrs: &ChangedAttrs,
758        event_numbers_watermark: EventNumber,
759    ) -> bool {
760        if !self.is_report_allowed(now) {
761            return false;
762        }
763
764        self.is_report_due(now)
765            || self.is_affected_by_attr_changes(rx, changed_attrs)
766            || self.is_affected_by_new_events(rx, event_numbers_watermark)
767    }
768
769    /// Instant at which the min-interval quiet period ends — the earliest time
770    /// a report is allowed ([`Self::is_report_allowed`] returns `true`).
771    ///
772    /// [`Instant::MIN`] when not yet primed (`reported_at == Instant::MAX`): a
773    /// fresh subscription is allowed to report immediately (its priming report),
774    /// and a point infinitely in the past reads correctly as "always allowed"
775    /// for every consumer (the boolean gate below and `next_report_at`).
776    fn report_allowed_at(&self) -> Instant {
777        self.reported_at
778            .checked_add(embassy_time::Duration::from_secs(self.min_int_secs as _))
779            .unwrap_or(Instant::MIN)
780    }
781
782    /// 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.
783    fn is_report_allowed(&self, now: Instant) -> bool {
784        self.report_allowed_at() <= now
785    }
786
787    /// Instant at which the max-interval liveness window opens — the earliest
788    /// time [`Self::is_report_due`] returns `true`.
789    ///
790    /// `reported_at + max_int - max_int / 2`, i.e. the half-interval mark.
791    /// Waking before the negotiated maximum interval is this implementation's
792    /// margin for completing the report in time.
793    ///
794    /// [`Instant::MIN`] when not yet primed (`reported_at == Instant::MAX`): a
795    /// fresh subscription is immediately due for its priming report (see
796    /// [`Self::report_allowed_at`] for why `MIN` is the right sentinel).
797    fn report_due_at(&self) -> Instant {
798        self.reported_at
799            .checked_add(embassy_time::Duration::from_secs(
800                (self.max_int_secs - self.max_int_secs / 2) as _,
801            ))
802            .unwrap_or(Instant::MIN)
803    }
804
805    /// Return `true` if the subscription is due for a report based on the max interval, or `false` if it is not yet due.
806    fn is_report_due(&self, now: Instant) -> bool {
807        self.report_due_at() <= now
808    }
809
810    /// 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.
811    fn is_affected_by_attr_changes(&self, _rx: &[u8], changes: &ChangedAttrs) -> bool {
812        // NOTE: we could consult the subscription's RX here to skip the check if the subscription
813        // is not interested in the changed path at all, but that would require parsing the RX at every report check,
814        // which is anyway done later during reporting and the report is canceled if empty
815        //
816        // Therefore and for now do not to this here
817        changes.any_since(self.max_seen_attr_change_id)
818    }
819
820    /// 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.
821    fn is_affected_by_new_events(&self, _rx: &[u8], event_numbers_watermark: EventNumber) -> bool {
822        // NOTE: we could consult the subscription's RX here to skip the check if the subscription
823        // is not interested in events at all, but that would require parsing the RX at every report check,
824        // which is anyway done later during reporting and the report is canceled if empty
825        //
826        // Therefore and for now do not to this here
827        self.max_seen_event_number < event_numbers_watermark
828    }
829
830    /// Earliest [`Instant`] at which this subscription could next report.
831    ///
832    /// A not-yet-primed subscription (`reported_at == Instant::MAX`) yields
833    /// [`Instant::MIN`] via both deadline helpers — "report now", so the reporter
834    /// wakes immediately to deliver the priming report.
835    ///
836    /// Never earlier than the min-interval gate `reported_at + min_int`, before
837    /// which a report SHALL NOT be sent (Matter spec). Subject to that
838    /// gate, it is:
839    /// - `reported_at + min_int` when a change or event is already pending, so
840    ///   the wake lands at the end of the quiet period; otherwise
841    /// - the liveness point ([`Self::report_due_at`]), when
842    ///   [`Self::is_report_due`] flips — early enough for the report to be
843    ///   received before `max_int` (the subscriber terminates otherwise).
844    ///
845    /// Clamping the liveness point up to the gate avoids a busy-spin when
846    /// `min_int > max_int / 2`.
847    fn next_report_at(
848        &self,
849        rx: &[u8],
850        changed_attrs: &ChangedAttrs,
851        event_numbers_watermark: EventNumber,
852    ) -> Instant {
853        let allowed_at = self.report_allowed_at();
854
855        // Use the same `rx` the report path feeds `is_reportable`, so this
856        // prediction stays faithful if these checks ever start consulting it.
857        let pending = self.is_affected_by_attr_changes(rx, changed_attrs)
858            || self.is_affected_by_new_events(rx, event_numbers_watermark);
859
860        if pending {
861            allowed_at
862        } else {
863            allowed_at.max(self.report_due_at())
864        }
865    }
866}
867
868/// A table of recently-changed attribute triples, each tagged with an
869/// ever-increasing `change_id`.
870///
871/// Subscriptions consult this table to decide which attributes they should
872/// re-emit on their next report: only attributes with a matching entry whose
873/// `change_id` is strictly greater than the subscription's own watermark
874/// (`last_change_id`) need to be reported.
875///
876/// The table has a fixed capacity of [`MAX_CHANGED_ATTRS`] entries. When it
877/// fills up, existing entries are coalesced to coarser-grained wildcards
878/// (`(endpoint, cluster, *)` → `(endpoint, *, *)` → `(*, *, *)`) so that a new change can always
879/// be recorded. A wildcard entry over-covers and will therefore cause the
880/// affected subscriptions to emit a slightly wider set of attributes on their
881/// next report, but this is a bounded loss of precision that preserves
882/// correctness.
883pub(crate) struct ChangedAttrs {
884    /// Monotonically increasing ID assigned to every recorded change.
885    /// The first assigned ID is 1; `0` is reserved as the "no change seen yet"
886    /// sentinel used by fresh subscriptions.
887    next_change_id: u64,
888    /// The actual table of recent changes, ordered from oldest to newest.
889    /// The newest change has `change_id == next_change_id - 1`.
890    entries: Vec<ChangedAttr, MAX_CHANGED_ATTRS>,
891}
892
893impl ChangedAttrs {
894    /// Create the instance.
895    #[inline(always)]
896    const fn new() -> Self {
897        Self {
898            next_change_id: 1,
899            entries: Vec::new(),
900        }
901    }
902
903    /// Return an in-place initializer for the instance.
904    fn init() -> impl Init<Self> {
905        init!(Self {
906            next_change_id: 1,
907            entries <- Vec::init(),
908        })
909    }
910
911    /// The largest change ID that has been assigned so far. A subscription
912    /// whose max seen change ID is equal to the watermark has seen every change.
913    #[inline]
914    fn watermark(&self) -> u64 {
915        self.next_change_id.wrapping_sub(1)
916    }
917
918    /// Record a change to the attribute triple `(endpoint, cluster, attr)`.
919    /// Returns the newly assigned change ID.
920    fn record(&mut self, endpoint: EndptId, cluster: ClusterId, attr: AttrId) -> u64 {
921        self.record_raw(ChangedAttr::concrete(endpoint, cluster, attr, 0))
922    }
923
924    /// Record a cluster- or endpoint-wide wildcard change. `endpoint == None`
925    /// together with `cluster == None` represents a global wildcard.
926    /// Returns the newly assigned change ID.
927    fn record_wildcard(&mut self, endpoint: Option<EndptId>, cluster: Option<ClusterId>) -> u64 {
928        self.record_raw(ChangedAttr {
929            endpoint: endpoint.unwrap_or(WILDCARD_ENDPOINT),
930            cluster: cluster.unwrap_or(WILDCARD_CLUSTER),
931            attr: WILDCARD_ATTR,
932            change_id: 0,
933        })
934    }
935
936    /// Insert `new` into the table. The caller is expected to leave `new.change_id`
937    /// at any value - it is overwritten by a freshly-assigned ID.
938    fn record_raw(&mut self, mut new: ChangedAttr) -> u64 {
939        let change_id = self.next_change_id;
940        self.next_change_id = self.next_change_id.wrapping_add(1).max(1);
941        new.change_id = change_id;
942
943        // If an existing entry already covers `new`, just refresh its change ID.
944        if let Some(existing) = self.entries.iter_mut().find(|x| x.covers(&new)) {
945            existing.change_id = change_id;
946            return change_id;
947        }
948
949        // `new` may itself subsume existing concrete entries - drop those to
950        // keep the table compact and avoid wasting slots on redundant paths.
951        let mut i = 0;
952        while i < self.entries.len() {
953            if new.covers(&self.entries[i]) {
954                self.entries.swap_remove(i);
955            } else {
956                i += 1;
957            }
958        }
959
960        if let Err(new) = self.entries.push(new) {
961            // The table is full - promote entries to coarser wildcards to free a slot.
962            self.promote_and_insert(new);
963        }
964
965        change_id
966    }
967
968    /// Returns `true` if the table contains at least one entry covering
969    /// `(endpoint, cluster, attr)` with `change_id > since`.
970    fn contains_since(
971        &self,
972        endpoint: EndptId,
973        cluster: ClusterId,
974        attr: AttrId,
975        since: u64,
976    ) -> bool {
977        self.entries
978            .iter()
979            .any(|x| x.change_id > since && x.matches(endpoint, cluster, attr))
980    }
981
982    /// Returns `true` if the table contains at least one entry with
983    /// `change_id > since` (of any path).
984    fn any_since(&self, since: u64) -> bool {
985        self.entries.iter().any(|x| x.change_id > since)
986    }
987
988    /// Drop all entries with `change_id <= threshold`.
989    fn purge_up_to(&mut self, threshold: u64) {
990        if threshold == 0 {
991            return;
992        }
993
994        let mut i = 0;
995        while i < self.entries.len() {
996            if self.entries[i].change_id <= threshold {
997                self.entries.swap_remove(i);
998            } else {
999                i += 1;
1000            }
1001        }
1002    }
1003
1004    /// Drop every recorded change. Used when no subscriptions exist.
1005    fn clear(&mut self) {
1006        self.entries.clear();
1007    }
1008
1009    /// Coalesce existing entries to coarser wildcards so that `new` can be inserted.
1010    ///
1011    /// The strategy is to promote as little as possible: on each iteration we
1012    /// collapse the single largest collapsible group at the finest available
1013    /// level into one coarser wildcard entry, freeing at least one slot. Only
1014    /// once no fine-grained group of two or more entries exists do we escalate
1015    /// to the next level, and finally to a global wildcard as a last resort.
1016    fn promote_and_insert(&mut self, new: ChangedAttr) {
1017        loop {
1018            // If an existing (possibly just-promoted) entry already covers `new`,
1019            // refresh its change ID and we're done.
1020            if let Some(existing) = self.entries.iter_mut().find(|x| x.covers(&new)) {
1021                existing.change_id = new.change_id;
1022                return;
1023            }
1024
1025            if self.entries.push(new.clone()).is_ok() {
1026                return;
1027            }
1028
1029            // Full - promote exactly one group at the finest granularity that
1030            // actually yields compaction. Levels:
1031            // - 1: (endpoint, cluster, *)
1032            // - 2: (endpoint, *, *)
1033            if !self.promote_largest_group(1) && !self.promote_largest_group(2) {
1034                // No collapsible group at either level - last-ditch fallback:
1035                // collapse the whole table into a single global wildcard entry.
1036                self.entries.clear();
1037
1038                unwrap!(self.entries.push(ChangedAttr {
1039                    endpoint: WILDCARD_ENDPOINT,
1040                    cluster: WILDCARD_CLUSTER,
1041                    attr: WILDCARD_ATTR,
1042                    change_id: new.change_id,
1043                }));
1044
1045                return;
1046            }
1047        }
1048    }
1049
1050    /// Find the largest group of entries (>= 2) that share the same key at the
1051    /// given promotion level, and collapse it into one coarser wildcard entry.
1052    ///
1053    /// Returns `true` if any promotion happened.
1054    fn promote_largest_group(&mut self, level: u8) -> bool {
1055        // Pick a pivot whose group is largest.
1056        let mut best_pivot: Option<ChangedAttr> = None;
1057        let mut best_count = 1usize;
1058
1059        for i in 0..self.entries.len() {
1060            let pivot = &self.entries[i];
1061            let Some(coarsened) = pivot.coarsen(level) else {
1062                continue;
1063            };
1064
1065            let count = self.entries.iter().filter(|e| coarsened.covers(e)).count();
1066            if count > best_count {
1067                best_count = count;
1068                best_pivot = Some(pivot.clone());
1069            }
1070        }
1071
1072        let Some(pivot) = best_pivot else {
1073            return false;
1074        };
1075        // `coarsen` already returned `Some` above for this pivot.
1076        let mut coarsened = pivot.coarsen(level).unwrap();
1077
1078        // Remove all entries covered by `coarsened`, keeping the largest
1079        // change_id to preserve recency.
1080        let mut max_change_id = 0u64;
1081        let mut i = 0;
1082        while i < self.entries.len() {
1083            if coarsened.covers(&self.entries[i]) {
1084                if self.entries[i].change_id > max_change_id {
1085                    max_change_id = self.entries[i].change_id;
1086                }
1087                self.entries.swap_remove(i);
1088            } else {
1089                i += 1;
1090            }
1091        }
1092        coarsened.change_id = max_change_id;
1093        // Safe: we just removed `best_count >= 2` entries, so there is room.
1094        unwrap!(self.entries.push(coarsened));
1095        true
1096    }
1097}
1098
1099/// Sentinel value for "any endpoint" inside a [`ChangedAttr`] entry.
1100///
1101/// Matter endpoint ids are `u16`; the Matter Core Specification caps practical
1102/// endpoint numbering well below `0xFFFF`, and the CHIP reference SDK
1103/// (`kInvalidEndpointId` in `src/lib/core/DataModelTypes.h`) adopts the same
1104/// convention, so we can repurpose `u16::MAX` as an internal "wildcard" marker.
1105const WILDCARD_ENDPOINT: EndptId = EndptId::MAX;
1106
1107/// Sentinel value for "any cluster" inside a [`ChangedAttr`] entry.
1108///
1109/// Matter cluster ids are Manufacturer Extensible Identifiers (MEIs, Core Spec):
1110/// `(vendor_prefix << 16) | suffix` with `0xFFFF` reserved as an
1111/// invalid vendor prefix. `0xFFFF_FFFF` therefore cannot be a legitimate
1112/// cluster id and is safe to use as an internal "wildcard" marker. The CHIP
1113/// reference SDK uses the same value as `kInvalidClusterId`.
1114const WILDCARD_CLUSTER: ClusterId = ClusterId::MAX;
1115
1116/// Sentinel value for "any attribute" inside a [`ChangedAttr`] entry.
1117///
1118/// Same MEI argument as [`WILDCARD_CLUSTER`]: `0xFFFF_FFFF` cannot be a
1119/// legitimate attribute id and matches CHIP's `kInvalidAttributeId`.
1120const WILDCARD_ATTR: AttrId = AttrId::MAX;
1121
1122/// A record of one recently changed attribute.
1123///
1124/// A field holding its corresponding `WILDCARD_*` sentinel acts as a wildcard
1125/// on that axis. Wildcards appear only as a result of "promotion" when the
1126/// `changed_attrs` table becomes full and several concrete entries need to be
1127/// coalesced into a coarser one.
1128///
1129/// Rust is free to reorder these fields under the default `repr(Rust)`, and
1130/// it does so to minimize size: on 64-bit targets `size_of::<ChangedAttr>()`
1131/// is 24 bytes (the `u64` change_id forces 8-byte alignment; the rest packs
1132/// into the remaining 16 bytes). The previous `Option<u16> / Option<u32> /
1133/// Option<u32>` encoding took 32 bytes per entry because `u16` / `u32` have
1134/// no niche for `Option`. See `changed_attr_size_is_compact`.
1135#[derive(Clone, Debug)]
1136#[cfg_attr(feature = "defmt", derive(defmt::Format))]
1137struct ChangedAttr {
1138    endpoint: EndptId,
1139    cluster: ClusterId,
1140    attr: AttrId,
1141    change_id: u64,
1142}
1143
1144impl ChangedAttr {
1145    /// Create a concrete (non-wildcard) entry with the given parameters and change ID.
1146    const fn concrete(endpoint: EndptId, cluster: ClusterId, attr: AttrId, change_id: u64) -> Self {
1147        Self {
1148            endpoint,
1149            cluster,
1150            attr,
1151            change_id,
1152        }
1153    }
1154
1155    /// Return `true` if this entry is a wildcard on the endpoint axis, or `false` if it is concrete.
1156    #[inline]
1157    const fn is_endpoint_wildcard(&self) -> bool {
1158        self.endpoint == WILDCARD_ENDPOINT
1159    }
1160
1161    /// Return `true` if this entry is a wildcard on the cluster axis, or `false` if it is concrete.
1162    #[inline]
1163    const fn is_cluster_wildcard(&self) -> bool {
1164        self.cluster == WILDCARD_CLUSTER
1165    }
1166
1167    /// Return `true` if this entry is a wildcard on the attribute axis, or `false` if it is concrete.
1168    #[inline]
1169    const fn is_attr_wildcard(&self) -> bool {
1170        self.attr == WILDCARD_ATTR
1171    }
1172
1173    /// Whether this record covers the concrete attribute triple
1174    /// `(endpoint, cluster, attr)`.
1175    fn matches(&self, endpoint: EndptId, cluster: ClusterId, attr: AttrId) -> bool {
1176        (self.is_endpoint_wildcard() || self.endpoint == endpoint)
1177            && (self.is_cluster_wildcard() || self.cluster == cluster)
1178            && (self.is_attr_wildcard() || self.attr == attr)
1179    }
1180
1181    /// Whether `other` is semantically covered by `self` (i.e. `self` is as
1182    /// coarse as or coarser than `other` on every axis).
1183    fn covers(&self, other: &ChangedAttr) -> bool {
1184        #[inline]
1185        fn cov<T: Eq>(a: T, a_wild: bool, b: T, b_wild: bool) -> bool {
1186            if a_wild {
1187                true // self wildcard covers anything
1188            } else if b_wild {
1189                false // concrete doesn't cover wildcard
1190            } else {
1191                a == b
1192            }
1193        }
1194        cov(
1195            self.endpoint,
1196            self.is_endpoint_wildcard(),
1197            other.endpoint,
1198            other.is_endpoint_wildcard(),
1199        ) && cov(
1200            self.cluster,
1201            self.is_cluster_wildcard(),
1202            other.cluster,
1203            other.is_cluster_wildcard(),
1204        ) && cov(
1205            self.attr,
1206            self.is_attr_wildcard(),
1207            other.attr,
1208            other.is_attr_wildcard(),
1209        )
1210    }
1211
1212    /// Build the coarsened wildcard entry representing `pivot`'s group at the
1213    /// given level. Returns `None` if `pivot` cannot be promoted at that level
1214    /// (e.g. its endpoint is already a wildcard for level 1 or 2).
1215    fn coarsen(&self, level: u8) -> Option<Self> {
1216        match level {
1217            1 => {
1218                if self.is_endpoint_wildcard() || self.is_cluster_wildcard() {
1219                    return None;
1220                }
1221                Some(Self {
1222                    change_id: 0,
1223                    cluster: self.cluster,
1224                    attr: WILDCARD_ATTR,
1225                    endpoint: self.endpoint,
1226                })
1227            }
1228            2 => {
1229                if self.is_endpoint_wildcard() {
1230                    return None;
1231                }
1232                Some(Self {
1233                    change_id: 0,
1234                    cluster: WILDCARD_CLUSTER,
1235                    attr: WILDCARD_ATTR,
1236                    endpoint: self.endpoint,
1237                })
1238            }
1239            _ => unreachable!(),
1240        }
1241    }
1242}
1243
1244/// Per-subscription context for an in-progress report.
1245pub struct ReportContext<'a, 's, B, const N: usize>
1246where
1247    B: Buffers<IMBuffer> + 'a,
1248{
1249    /// A reference to the global subscriptions table, used to return the subscription on
1250    /// successful completion of the report
1251    subscriptions: &'s Subscriptions<N>,
1252    /// A reference to the global subscription buffers, used to return the subscription buffer on
1253    /// successful completion of the report
1254    subscriptions_buffers: &'s SubscriptionsBuffers<'a, B, N>,
1255    /// The subscription being reported on.
1256    subscription: Option<Subscription>,
1257    /// The RX buffer with report data associated with the subscription being reported on.
1258    subscription_buffer: Option<B::Buffer<'a>>,
1259    /// The next maximum seen attribute change ID for the subscription
1260    /// to be updated into it upon returning the subscription to the table.
1261    ///
1262    /// This is captured here because the subscription's own `max_seen_attr_change_id`
1263    /// is not updated until the report completes as it is until then still used.
1264    next_max_seen_attr_change_id: u64,
1265    /// The next maximum seen event number for the subscription
1266    /// to be updated into it upon returning the subscription to the table.
1267    ///
1268    /// This is captured here because the subscription's own `max_seen_event_number`
1269    /// is not updated until the report completes as it is until then still used.
1270    next_max_seen_event_number: EventNumber,
1271    /// The next reported timestamp for the subscription,
1272    /// to be updated into it upon returning the subscription to the table.
1273    ///
1274    /// This is captured here because the subscription's own `next_reported_at`
1275    /// is not updated until the report completes as it is until then still used.
1276    next_reported_at: Instant,
1277    /// Whether the subscription should be kept in the table after the report completes.
1278    /// Set by the report handler if the other peer acknowledges the data reported by the subscription.
1279    keep: bool,
1280}
1281
1282impl<'a, 's, B, const N: usize> ReportContext<'a, 's, B, N>
1283where
1284    B: Buffers<IMBuffer> + 'a,
1285{
1286    /// Return a reference to the subscription being reported on.
1287    pub fn subscription(&self) -> &Subscription {
1288        unwrap!(self.subscription.as_ref())
1289    }
1290
1291    /// Return a reference to the RX buffer associated with the subscription being reported on.
1292    pub fn rx(&self) -> &[u8] {
1293        unwrap!(self.subscription_buffer.as_ref()).as_ref()
1294    }
1295
1296    /// Return `true` if the report should be sent even if it turns out to be empty
1297    /// (i.e. no attributes or events to report), or `false` if it can be skipped in that case.
1298    pub fn should_send_if_empty(&self) -> bool {
1299        // A fresh subscription has `reported_at == Instant::MAX`, which makes
1300        // `report_due_at` saturate to `Instant::MIN` and `is_report_due` return
1301        // `true`, so priming reports are delivered unconditionally without a
1302        // separate `priming` flag.
1303        unwrap!(self.subscription.as_ref()).is_report_due(self.next_reported_at)
1304    }
1305
1306    /// Return `true` if the subscription should report the attribute
1307    /// identified by the given triple, or `false` if it can skip it.
1308    pub fn should_report_attr(
1309        &self,
1310        endpoint_id: EndptId,
1311        cluster_id: ClusterId,
1312        attr_id: AttrId,
1313    ) -> bool {
1314        let sub = self.subscription();
1315
1316        // A fresh subscription (priming report) has never reported anything
1317        // yet; its `reported_at` sentinel doubles as the "priming" marker and
1318        // means every selected attribute must be delivered, regardless of
1319        // whether it appears in `changed_attrs`.
1320        if sub.reported_at == Instant::MAX {
1321            return true;
1322        }
1323
1324        self.subscriptions.state.lock(|state| {
1325            state.borrow().changed_attrs.contains_since(
1326                endpoint_id,
1327                cluster_id,
1328                attr_id,
1329                sub.max_seen_attr_change_id,
1330            )
1331        })
1332    }
1333
1334    /// Return the maximum event number the subscription has seen so far.
1335    pub fn max_seen_event_number(&self) -> EventNumber {
1336        unwrap!(self.subscription.as_ref()).max_seen_event_number
1337    }
1338
1339    /// Return the next maximum event number to be updated into the subscription upon returning it to the table.
1340    pub fn next_max_seen_event_number(&self) -> EventNumber {
1341        self.next_max_seen_event_number
1342    }
1343
1344    /// Mark the subscription to be kept in the table after the report completes,
1345    /// meaning the other peer acknowledged our report.
1346    pub fn set_keep(&mut self) {
1347        self.keep = true;
1348    }
1349}
1350
1351impl<'a, 's, B, const N: usize> Drop for ReportContext<'a, 's, B, N>
1352where
1353    B: Buffers<IMBuffer> + 'a,
1354{
1355    fn drop(&mut self) {
1356        self.subscriptions.report_complete(self);
1357    }
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use crate::utils::storage::pooled::PooledBuffers;
1363
1364    use super::*;
1365
1366    use embassy_time::Duration;
1367
1368    type TestPool<const N: usize> = PooledBuffers<IMBuffer, N>;
1369
1370    // ---------- ChangedAttributes ----------
1371
1372    #[test]
1373    fn changed_attrs_starts_empty() {
1374        let attrs = ChangedAttrs::new();
1375        assert_eq!(attrs.watermark(), 0);
1376        assert!(!attrs.any_since(0));
1377        assert!(!attrs.contains_since(1, 2, 3, 0));
1378    }
1379
1380    #[test]
1381    fn changed_attrs_record_assigns_monotonic_ids() {
1382        let mut attrs = ChangedAttrs::new();
1383        let id1 = attrs.record(1, 2, 3);
1384        let id2 = attrs.record(1, 2, 4);
1385        let id3 = attrs.record(2, 2, 3);
1386        assert_eq!(id1, 1);
1387        assert_eq!(id2, 2);
1388        assert_eq!(id3, 3);
1389        assert_eq!(attrs.watermark(), 3);
1390    }
1391
1392    #[test]
1393    fn changed_attrs_contains_since_and_any_since() {
1394        let mut attrs = ChangedAttrs::new();
1395        attrs.record(1, 2, 3);
1396        attrs.record(1, 2, 4);
1397
1398        assert!(attrs.any_since(0));
1399        assert!(attrs.any_since(1));
1400        assert!(!attrs.any_since(2));
1401
1402        assert!(attrs.contains_since(1, 2, 3, 0));
1403        assert!(attrs.contains_since(1, 2, 4, 1));
1404        // After watermark 2 there are no more changes
1405        assert!(!attrs.contains_since(1, 2, 3, 2));
1406        // A never-recorded triple is not covered
1407        assert!(!attrs.contains_since(9, 9, 9, 0));
1408    }
1409
1410    #[test]
1411    fn changed_attrs_duplicate_refreshes_change_id() {
1412        let mut attrs = ChangedAttrs::new();
1413        attrs.record(1, 2, 3);
1414        attrs.record(1, 2, 4);
1415        // Same triple as first record - should refresh, not add a new entry.
1416        let id3 = attrs.record(1, 2, 3);
1417        assert_eq!(id3, 3);
1418        assert_eq!(attrs.entries.len(), 2);
1419        // The (1, 2, 3) entry now has change_id 3, so it is visible from since=2
1420        assert!(attrs.contains_since(1, 2, 3, 2));
1421        // But it was originally at id=1, which is now lost - `since=0` still sees it
1422        // through the refreshed id.
1423        assert!(attrs.contains_since(1, 2, 3, 0));
1424    }
1425
1426    #[test]
1427    fn changed_attrs_record_wildcard_cluster_covers_every_attr() {
1428        let mut attrs = ChangedAttrs::new();
1429        let id = attrs.record_wildcard(Some(7), Some(42));
1430
1431        // Any concrete attribute on that (endpoint, cluster) is now covered.
1432        assert!(attrs.contains_since(7, 42, 0, 0));
1433        assert!(attrs.contains_since(7, 42, 1, 0));
1434        assert!(attrs.contains_since(7, 42, u32::MAX, 0));
1435        // Unrelated clusters / endpoints are not.
1436        assert!(!attrs.contains_since(7, 99, 0, 0));
1437        assert!(!attrs.contains_since(8, 42, 0, 0));
1438        assert_eq!(id, attrs.watermark());
1439    }
1440
1441    #[test]
1442    fn changed_attrs_record_wildcard_endpoint_covers_every_cluster() {
1443        let mut attrs = ChangedAttrs::new();
1444        attrs.record_wildcard(Some(5), None);
1445
1446        assert!(attrs.contains_since(5, 1, 1, 0));
1447        assert!(attrs.contains_since(5, 1000, 1000, 0));
1448        assert!(!attrs.contains_since(6, 1, 1, 0));
1449    }
1450
1451    #[test]
1452    fn changed_attrs_record_wildcard_absorbs_existing_concrete_entries() {
1453        let mut attrs = ChangedAttrs::new();
1454        // Seed three concrete attrs on (1, 2).
1455        attrs.record(1, 2, 10);
1456        attrs.record(1, 2, 11);
1457        attrs.record(1, 2, 12);
1458        // And one concrete on a different cluster - should survive.
1459        attrs.record(1, 3, 20);
1460        assert_eq!(attrs.entries.len(), 4);
1461
1462        // Recording a cluster-wide wildcard for (1, 2) must collapse the three
1463        // concrete (1, 2, *) entries into the single wildcard.
1464        attrs.record_wildcard(Some(1), Some(2));
1465
1466        assert_eq!(attrs.entries.len(), 2);
1467        assert!(attrs
1468            .entries
1469            .iter()
1470            .any(|e| e.endpoint == 1 && e.cluster == 2 && e.is_attr_wildcard()));
1471        assert!(attrs.contains_since(1, 3, 20, 0));
1472    }
1473
1474    #[test]
1475    fn changed_attrs_record_wildcard_is_refreshed_when_already_covered() {
1476        let mut attrs = ChangedAttrs::new();
1477        // Endpoint-wide wildcard covers any cluster on that endpoint.
1478        attrs.record_wildcard(Some(1), None);
1479        let before_len = attrs.entries.len();
1480
1481        // A cluster-wide wildcard for the same endpoint is already covered
1482        // by the endpoint-wide one - it must not grow the table and must
1483        // refresh the existing entry's change id.
1484        let id = attrs.record_wildcard(Some(1), Some(2));
1485        assert_eq!(attrs.entries.len(), before_len);
1486        assert_eq!(attrs.watermark(), id);
1487    }
1488
1489    #[test]
1490    fn changed_attrs_purge_up_to_removes_old_entries() {
1491        let mut attrs = ChangedAttrs::new();
1492        attrs.record(1, 2, 3); // id 1
1493        attrs.record(1, 2, 4); // id 2
1494        attrs.record(2, 2, 3); // id 3
1495
1496        attrs.purge_up_to(2);
1497
1498        assert!(!attrs.contains_since(1, 2, 3, 0));
1499        assert!(!attrs.contains_since(1, 2, 4, 0));
1500        assert!(attrs.contains_since(2, 2, 3, 0));
1501
1502        // Purging with 0 is a no-op.
1503        attrs.purge_up_to(0);
1504        assert!(attrs.contains_since(2, 2, 3, 0));
1505    }
1506
1507    #[test]
1508    fn changed_attrs_clear_empties_table_but_keeps_watermark() {
1509        let mut attrs = ChangedAttrs::new();
1510        attrs.record(1, 2, 3);
1511        attrs.record(1, 2, 4);
1512        let wm_before = attrs.watermark();
1513        attrs.clear();
1514        assert!(!attrs.any_since(0));
1515        // Watermark is preserved so subsequent records remain strictly monotonic.
1516        assert_eq!(attrs.watermark(), wm_before);
1517        let id = attrs.record(5, 5, 5);
1518        assert_eq!(id, wm_before + 1);
1519    }
1520
1521    #[test]
1522    fn changed_attrs_promotion_on_overflow_same_cluster() {
1523        let mut attrs = ChangedAttrs::new();
1524        // Fill the table with distinct concrete entries on the same (endpoint, cluster).
1525        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1526            attrs.record(1, 2, attr);
1527        }
1528        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1529
1530        // One more record must still succeed - the existing entries get promoted.
1531        let overflow_id = attrs.record(1, 2, 9999);
1532        assert_eq!(overflow_id as usize, MAX_CHANGED_ATTRS + 1);
1533
1534        // The table must never overflow its capacity.
1535        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1536
1537        // Every originally-recorded concrete attribute must still be reported as
1538        // "changed" when queried from since=0 (possibly via a coarser wildcard).
1539        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1540            assert!(
1541                attrs.contains_since(1, 2, attr, 0),
1542                "attr {} lost after promotion",
1543                attr
1544            );
1545        }
1546        assert!(attrs.contains_since(1, 2, 9999, 0));
1547
1548        // The new overflow entry is visible from the previous watermark.
1549        assert!(attrs.contains_since(1, 2, 9999, MAX_CHANGED_ATTRS as u64));
1550    }
1551
1552    #[test]
1553    fn changed_attrs_promotion_to_global_wildcard() {
1554        let mut attrs = ChangedAttrs::new();
1555        // Entries spread across many endpoints/clusters/attrs to force promotion
1556        // past the (endpoint, cluster, *) and (endpoint, *, *) levels.
1557        for i in 0..(MAX_CHANGED_ATTRS as u16 + 5) {
1558            attrs.record(i, i as u32, i as u32);
1559        }
1560        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1561        // All previously-recorded triples must still report as changed.
1562        for i in 0..(MAX_CHANGED_ATTRS as u16 + 5) {
1563            assert!(attrs.contains_since(i, i as u32, i as u32, 0));
1564        }
1565        // And an arbitrary never-recorded triple may or may not be covered
1566        // (over-reporting is allowed), but `any_since(0)` must be true.
1567        assert!(attrs.any_since(0));
1568    }
1569
1570    #[test]
1571    fn promotion_prefers_largest_level_1_group() {
1572        // 10 entries on (1, 1, *) and 5 singletons on (1, k, 0) for k=2..=6
1573        // (= 15 entries total). One extra record fills the table, then an
1574        // overflowing record forces exactly ONE level-1 promotion which must
1575        // collapse the big (1, 1, *) group while leaving singletons concrete.
1576        let mut attrs = ChangedAttrs::new();
1577        for attr in 0..10u32 {
1578            attrs.record(1, 1, attr);
1579        }
1580        for cluster in 2..=6u32 {
1581            attrs.record(1, cluster, 0);
1582        }
1583        // Fill exactly to capacity without overflow.
1584        attrs.record(1, 1, 100);
1585        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1586
1587        // Now overflow to trigger promotion.
1588        attrs.record(2, 2, 2);
1589        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1590
1591        // The big (1, 1, *) group became exactly one wildcard entry.
1592        let wild_11 = attrs
1593            .entries
1594            .iter()
1595            .filter(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard())
1596            .count();
1597        assert_eq!(wild_11, 1);
1598        // No concrete (1, 1, _) entries survived.
1599        let concrete_11 = attrs
1600            .entries
1601            .iter()
1602            .filter(|e| e.endpoint == 1 && e.cluster == 1 && !e.is_attr_wildcard())
1603            .count();
1604        assert_eq!(concrete_11, 0);
1605        // Singletons on (1, k, 0) for k=2..=6 remain concrete.
1606        for cluster in 2..=6u32 {
1607            let n = attrs
1608                .entries
1609                .iter()
1610                .filter(|e| e.endpoint == 1 && e.cluster == cluster && e.attr == 0)
1611                .count();
1612            assert_eq!(n, 1, "singleton (1, {}, 0) should remain concrete", cluster);
1613        }
1614        // The new (2, 2, 2) entry is present as a concrete entry.
1615        assert!(attrs
1616            .entries
1617            .iter()
1618            .any(|e| e.endpoint == 2 && e.cluster == 2 && e.attr == 2));
1619
1620        // All original triples still report as changed.
1621        for attr in 0..10u32 {
1622            assert!(attrs.contains_since(1, 1, attr, 0));
1623        }
1624        for cluster in 2..=6u32 {
1625            assert!(attrs.contains_since(1, cluster, 0, 0));
1626        }
1627        assert!(attrs.contains_since(1, 1, 100, 0));
1628        assert!(attrs.contains_since(2, 2, 2, 0));
1629    }
1630
1631    #[test]
1632    fn promotion_is_minimal_only_one_group_collapsed_per_overflow() {
1633        // Two big level-1 groups of equal size. A single overflow must collapse
1634        // only ONE of them, not both (minimal promotion).
1635        let mut attrs = ChangedAttrs::new();
1636        // Group A: (1, 1, 0..8) = 8 entries
1637        for attr in 0..8u32 {
1638            attrs.record(1, 1, attr);
1639        }
1640        // Group B: (2, 2, 0..8) = 8 entries
1641        for attr in 0..8u32 {
1642            attrs.record(2, 2, attr);
1643        }
1644        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1645
1646        // Overflow with an unrelated entry.
1647        attrs.record(9, 9, 9);
1648
1649        // Exactly one of the groups got collapsed into a wildcard.
1650        let a_wild = attrs
1651            .entries
1652            .iter()
1653            .any(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard());
1654        let b_wild = attrs
1655            .entries
1656            .iter()
1657            .any(|e| e.endpoint == 2 && e.cluster == 2 && e.is_attr_wildcard());
1658        assert!(
1659            a_wild ^ b_wild,
1660            "expected exactly one of the groups to be collapsed (A: {}, B: {})",
1661            a_wild,
1662            b_wild
1663        );
1664        // The un-collapsed group still has all 8 concrete entries.
1665        let a_concrete = attrs
1666            .entries
1667            .iter()
1668            .filter(|e| e.endpoint == 1 && e.cluster == 1 && !e.is_attr_wildcard())
1669            .count();
1670        let b_concrete = attrs
1671            .entries
1672            .iter()
1673            .filter(|e| e.endpoint == 2 && e.cluster == 2 && !e.is_attr_wildcard())
1674            .count();
1675        assert!(
1676            (a_wild && a_concrete == 0 && b_concrete == 8)
1677                || (b_wild && b_concrete == 0 && a_concrete == 8)
1678        );
1679    }
1680
1681    #[test]
1682    fn promotion_falls_back_to_level_2_when_no_level_1_group() {
1683        // All (endpoint, cluster) pairs are unique (level-1 groups are all
1684        // singletons) but endpoints repeat, so level-2 groups are non-trivial.
1685        let mut attrs = ChangedAttrs::new();
1686        for cluster in 0..8u32 {
1687            attrs.record(1, cluster, 0);
1688        }
1689        for cluster in 0..8u32 {
1690            attrs.record(2, cluster, 0);
1691        }
1692        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1693
1694        attrs.record(3, 9, 9);
1695        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1696
1697        // No level-1 wildcard (endpoint, cluster, *) was produced.
1698        let lvl1_wild = attrs
1699            .entries
1700            .iter()
1701            .filter(|e| {
1702                !e.is_endpoint_wildcard() && !e.is_cluster_wildcard() && e.is_attr_wildcard()
1703            })
1704            .count();
1705        assert_eq!(lvl1_wild, 0);
1706        // Exactly one level-2 wildcard on endpoint 1 or 2 was produced.
1707        let ep1_wild = attrs
1708            .entries
1709            .iter()
1710            .any(|e| e.endpoint == 1 && e.is_cluster_wildcard() && e.is_attr_wildcard());
1711        let ep2_wild = attrs
1712            .entries
1713            .iter()
1714            .any(|e| e.endpoint == 2 && e.is_cluster_wildcard() && e.is_attr_wildcard());
1715        assert!(ep1_wild ^ ep2_wild);
1716        // No global wildcard was produced either.
1717        assert!(!attrs
1718            .entries
1719            .iter()
1720            .any(|e| e.is_endpoint_wildcard() && e.is_cluster_wildcard() && e.is_attr_wildcard()));
1721
1722        // All originals still visible.
1723        for cluster in 0..8u32 {
1724            assert!(attrs.contains_since(1, cluster, 0, 0));
1725            assert!(attrs.contains_since(2, cluster, 0, 0));
1726        }
1727        assert!(attrs.contains_since(3, 9, 9, 0));
1728    }
1729
1730    #[test]
1731    fn promotion_falls_back_to_global_only_when_no_lower_group() {
1732        // All-distinct endpoints AND (endpoint, cluster) pairs: no level-1 or
1733        // level-2 group has >=2 entries. Overflow must collapse everything to
1734        // a single global wildcard.
1735        let mut attrs = ChangedAttrs::new();
1736        for i in 0..MAX_CHANGED_ATTRS as u16 {
1737            attrs.record(i, i as u32, i as u32);
1738        }
1739        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1740
1741        attrs.record(100, 200, 300);
1742        assert_eq!(attrs.entries.len(), 1);
1743        let only = &attrs.entries[0];
1744        assert!(
1745            only.is_endpoint_wildcard() && only.is_cluster_wildcard() && only.is_attr_wildcard()
1746        );
1747
1748        // Every previously-recorded triple is still covered.
1749        for i in 0..MAX_CHANGED_ATTRS as u16 {
1750            assert!(attrs.contains_since(i, i as u32, i as u32, 0));
1751        }
1752        assert!(attrs.contains_since(100, 200, 300, 0));
1753    }
1754
1755    #[test]
1756    fn promotion_preserves_max_change_id_in_coarsened_entry() {
1757        // After collapsing a (1, 1, *) group, the resulting wildcard's
1758        // change_id must equal the max change_id of the collapsed entries.
1759        let mut attrs = ChangedAttrs::new();
1760        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1761            attrs.record(1, 1, attr);
1762        }
1763        let max_before = attrs.watermark();
1764
1765        attrs.record(2, 2, 2);
1766        let wild = attrs
1767            .entries
1768            .iter()
1769            .find(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard())
1770            .expect("(1, 1, *) wildcard was produced");
1771        assert_eq!(wild.change_id, max_before);
1772
1773        // contains_since respects that watermark exactly.
1774        assert!(attrs.contains_since(1, 1, 0, max_before - 1));
1775        assert!(!attrs.contains_since(1, 1, 0, max_before));
1776    }
1777
1778    #[test]
1779    fn promotion_with_existing_wildcard_refreshes_instead_of_promoting_again() {
1780        // Build a state where (1, 1, *) wildcard already exists via a forced
1781        // promotion. Recording another (1, 1, k) must refresh that wildcard's
1782        // change_id without producing any new entry.
1783        let mut attrs = ChangedAttrs::new();
1784        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1785            attrs.record(1, 1, attr);
1786        }
1787        attrs.record(2, 2, 2); // forces (1, 1, *) promotion
1788
1789        // Now the table has 2 entries: (1, 1, *) and (2, 2, 2).
1790        assert_eq!(attrs.entries.len(), 2);
1791        let wm_after_promo = attrs.watermark();
1792
1793        let new_id = attrs.record(1, 1, 42);
1794        // No new entry: still 2 entries. Wildcard's change_id advanced.
1795        assert_eq!(attrs.entries.len(), 2);
1796        assert_eq!(new_id, wm_after_promo + 1);
1797        let wild = attrs
1798            .entries
1799            .iter()
1800            .find(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard())
1801            .unwrap();
1802        assert_eq!(wild.change_id, new_id);
1803    }
1804
1805    #[test]
1806    fn promotion_capacity_invariant_under_sustained_churn() {
1807        // Sustained mixed churn must never let the table exceed its capacity,
1808        // and every freshly-recorded triple must remain visible immediately
1809        // after recording.
1810        let mut attrs = ChangedAttrs::new();
1811        for i in 0..1000u32 {
1812            let endpoint = (i % 7) as u16;
1813            let cluster = i % 13;
1814            let attr = i;
1815            attrs.record(endpoint, cluster, attr);
1816            assert!(
1817                attrs.entries.len() <= MAX_CHANGED_ATTRS,
1818                "capacity exceeded at i={}",
1819                i
1820            );
1821            assert!(
1822                attrs.contains_since(endpoint, cluster, attr, 0),
1823                "just-recorded triple lost at i={}",
1824                i
1825            );
1826        }
1827    }
1828
1829    #[test]
1830    fn promotion_iterated_into_same_existing_wildcard() {
1831        // Once (1, 1, *) exists, repeated inserts on that group must never
1832        // grow the table, and never trigger further promotion.
1833        let mut attrs = ChangedAttrs::new();
1834        for attr in 0..MAX_CHANGED_ATTRS as u32 {
1835            attrs.record(1, 1, attr);
1836        }
1837        attrs.record(2, 2, 2); // -> [(1,1,*), (2,2,2)]
1838        assert_eq!(attrs.entries.len(), 2);
1839
1840        for attr in 100..200u32 {
1841            attrs.record(1, 1, attr);
1842            assert_eq!(attrs.entries.len(), 2);
1843        }
1844    }
1845
1846    #[test]
1847    fn promotion_escalates_when_level_1_group_still_insufficient() {
1848        // Pathological case: a single level-1 group of size 2 exists, the rest
1849        // are singletons. After the first overflow, that group collapses
1850        // (freeing 1 slot), but the table is still full once the new record
1851        // tries to be inserted on a fresh singleton location. Subsequent
1852        // overflows must escalate to level-2 / global.
1853        let mut attrs = ChangedAttrs::new();
1854        // 2 entries sharing (1, 1, *) -- a single level-1 group of size 2.
1855        attrs.record(1, 1, 0);
1856        attrs.record(1, 1, 1);
1857        // Fill the rest with unique (endpoint, cluster) pairs.
1858        for i in 0..(MAX_CHANGED_ATTRS as u16 - 2) {
1859            attrs.record(10 + i, 100 + i as u32, i as u32);
1860        }
1861        assert_eq!(attrs.entries.len(), MAX_CHANGED_ATTRS);
1862
1863        // First overflow: the only level-1 group collapses; then the new entry
1864        // gets inserted.
1865        attrs.record(50, 50, 50);
1866        assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1867        // The (1, 1, *) wildcard is present.
1868        assert!(attrs
1869            .entries
1870            .iter()
1871            .any(|e| e.endpoint == 1 && e.cluster == 1 && e.is_attr_wildcard()));
1872
1873        // Keep feeding: eventually we must fall back to level-2 or global
1874        // without breaking correctness.
1875        for i in 0..200u32 {
1876            let endpoint = 200 + (i % 5) as u16;
1877            let cluster = 300 + (i % 3);
1878            let attr = i;
1879            attrs.record(endpoint, cluster, attr);
1880            assert!(attrs.entries.len() <= MAX_CHANGED_ATTRS);
1881            assert!(attrs.contains_since(endpoint, cluster, attr, 0));
1882        }
1883        // Historical triples still covered.
1884        assert!(attrs.contains_since(1, 1, 0, 0));
1885        assert!(attrs.contains_since(1, 1, 1, 0));
1886        assert!(attrs.contains_since(50, 50, 50, 0));
1887    }
1888
1889    #[test]
1890    fn changed_attr_covers_wildcards() {
1891        let concrete = ChangedAttr::concrete(1, 2, 3, 1);
1892        let any_attr = ChangedAttr {
1893            endpoint: 1,
1894            cluster: 2,
1895            attr: WILDCARD_ATTR,
1896            change_id: 1,
1897        };
1898        let any_cluster = ChangedAttr {
1899            endpoint: 1,
1900            cluster: WILDCARD_CLUSTER,
1901            attr: WILDCARD_ATTR,
1902            change_id: 1,
1903        };
1904        let global = ChangedAttr {
1905            endpoint: WILDCARD_ENDPOINT,
1906            cluster: WILDCARD_CLUSTER,
1907            attr: WILDCARD_ATTR,
1908            change_id: 1,
1909        };
1910
1911        assert!(any_attr.covers(&concrete));
1912        assert!(any_cluster.covers(&concrete));
1913        assert!(global.covers(&concrete));
1914        // Concrete does not cover wildcards.
1915        assert!(!concrete.covers(&any_attr));
1916        assert!(!concrete.covers(&global));
1917        // Concrete matches itself.
1918        assert!(concrete.matches(1, 2, 3));
1919        assert!(!concrete.matches(1, 2, 4));
1920        // Wildcards match any concrete triple on the wildcarded axis.
1921        assert!(any_attr.matches(1, 2, 99));
1922        assert!(!any_attr.matches(1, 9, 99));
1923        assert!(global.matches(99, 99, 99));
1924    }
1925
1926    #[test]
1927    fn changed_attr_size_is_compact() {
1928        // `ChangedAttr` must stay at 24 bytes on 64-bit targets: `u64` change_id
1929        // forces 8-byte alignment, and the `(u32, u32, u16)` path tuple fits in
1930        // the remaining 16 bytes (4 + 4 + 2 + 6 padding). Regressing back to an
1931        // `Option<u16> / Option<u32> / Option<u32>` encoding would bump this to
1932        // 32 bytes per entry, i.e. +128 bytes per `Subscriptions` table.
1933        assert_eq!(core::mem::size_of::<ChangedAttr>(), 24);
1934    }
1935
1936    // ---------- Subscriptions ----------
1937
1938    fn fab(i: u8) -> NonZeroU8 {
1939        NonZeroU8::new(i).unwrap()
1940    }
1941
1942    #[test]
1943    fn add_returns_monotonic_ids_and_rejects_when_full() {
1944        let subs: Subscriptions<2> = Subscriptions::new();
1945        let pool = TestPool::<3>::new();
1946        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
1947
1948        let now = Instant::now();
1949
1950        let rctx1 = subs
1951            .add(
1952                now,
1953                fab(1),
1954                10,
1955                100,
1956                1,
1957                60,
1958                0,
1959                pool.get_immediate().unwrap(),
1960                &subs_bufs,
1961            )
1962            .unwrap();
1963        let rctx2 = subs
1964            .add(
1965                now,
1966                fab(1),
1967                10,
1968                100,
1969                1,
1970                60,
1971                0,
1972                pool.get_immediate().unwrap(),
1973                &subs_bufs,
1974            )
1975            .unwrap();
1976        assert_eq!(rctx1.subscription().ids().id, 1);
1977        assert_eq!(rctx2.subscription().ids().id, 2);
1978
1979        // Third add exceeds N=2.
1980        assert!(subs
1981            .add(
1982                now,
1983                fab(1),
1984                10,
1985                100,
1986                1,
1987                60,
1988                0,
1989                pool.get_immediate().unwrap(),
1990                &subs_bufs
1991            )
1992            .is_none());
1993    }
1994
1995    #[test]
1996    fn begin_report_snapshots_watermark_and_pending() {
1997        let subs: Subscriptions<2> = Subscriptions::new();
1998        let pool = TestPool::<3>::new();
1999        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2000
2001        let now = Instant::now();
2002
2003        subs.notify_attr_changed(1, 2, 3);
2004        {
2005            let mut rctx = subs
2006                .add(
2007                    now,
2008                    fab(1),
2009                    10,
2010                    100,
2011                    1,
2012                    60,
2013                    0,
2014                    pool.get_immediate().unwrap(),
2015                    &subs_bufs,
2016                )
2017                .unwrap();
2018
2019            // The priming report is un-filtered: every attribute is reported and
2020            // `should_send_if_empty` is true so that the snapshot is delivered
2021            // unconditionally.
2022            assert!(rctx.should_send_if_empty());
2023            assert!(rctx.should_report_attr(1, 2, 3));
2024            assert!(rctx.should_report_attr(42, 55555, 1234556677));
2025
2026            rctx.set_keep();
2027        }
2028
2029        // A new change bumps the watermark and becomes pending.
2030        subs.notify_attr_changed(1, 2, 4);
2031        // `min_int` = 1s has not elapsed at `now`, so the subscription is not
2032        // yet report-allowed; step past it.
2033        let later = now + Duration::from_secs(2);
2034        let rctx = subs.report(later, 0, &subs_bufs).unwrap();
2035        assert!(!rctx.should_send_if_empty());
2036        // The priming commit advanced the sub's `since` past the (1, 2, 3)
2037        // change, so only the new (1, 2, 4) is pending.
2038        assert!(!rctx.should_report_attr(1, 2, 3));
2039        assert!(rctx.should_report_attr(1, 2, 4));
2040    }
2041
2042    // The following tests cover the public API of `Subscriptions` /
2043    // `SubscriptionsBuffers` / `ReportContext` post-refactor. A few of the
2044    // pre-refactor tests had no meaningful successor and were deleted:
2045    //
2046    //   * `sub_attr_change_filter_honors_since_watermark` — `SubAttrChangeFilter`
2047    //     is now dead code (see REVIEW above); the `since`-watermark logic is
2048    //     already covered by `changed_attrs_contains_since_and_any_since`.
2049    //   * `find_report_due_events_pending_receives_subscription_watermark` —
2050    //     the old `events_pending` callback no longer exists; the
2051    //     subscription's `max_seen_event_number` is now compared directly
2052    //     against the `event_numbers_watermark` passed to
2053    //     `Subscriptions::report`.
2054    //   * `find_removed_session_matches_predicate` — `session_id` tracking
2055    //     was dropped in the refactor (see REVIEW on `SubscriptionsInner::add`).
2056    //     Predicate-based removal is covered by `remove_invokes_predicate_*`.
2057
2058    /// Helper: add a subscription with sensible defaults and return its `ReportContext`.
2059    #[allow(clippy::too_many_arguments)]
2060    fn add_sub<'a, 's, const N: usize, const B: usize>(
2061        subs: &'s Subscriptions<N>,
2062        subs_bufs: &'s SubscriptionsBuffers<'a, TestPool<B>, N>,
2063        pool: &'a TestPool<B>,
2064        now: Instant,
2065        fab_idx: u8,
2066        peer_node_id: u64,
2067        min_int: u16,
2068        max_int: u16,
2069    ) -> ReportContext<'a, 's, TestPool<B>, N>
2070    where
2071        'a: 's,
2072    {
2073        subs.add(
2074            now,
2075            fab(fab_idx),
2076            peer_node_id,
2077            /* session_id */ 0,
2078            min_int,
2079            max_int,
2080            /* event_numbers_watermark */ 0,
2081            pool.get_immediate().unwrap(),
2082            subs_bufs,
2083        )
2084        .unwrap()
2085    }
2086
2087    #[test]
2088    fn priming_report_context_is_report_due_and_keeps_sub() {
2089        // A subscription returned from `add` is the "priming" report: it must be
2090        // report-due regardless of time (so the initial report is delivered
2091        // unconditionally) and, when dropped with `set_keep`, must survive in
2092        // the subscription table for subsequent incremental reports.
2093        let subs: Subscriptions<1> = Subscriptions::new();
2094        let pool = TestPool::<2>::new();
2095        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2096
2097        let now = Instant::now();
2098        {
2099            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2100            assert!(rctx.should_send_if_empty());
2101            assert_eq!(rctx.max_seen_event_number(), 0);
2102            rctx.set_keep();
2103        }
2104
2105        // After priming, a zero-delta report at the same instant finds nothing
2106        // pending (no attr changes, no new events, min_int not elapsed).
2107        assert!(subs.report(now, 0, &subs_bufs).is_none());
2108    }
2109
2110    #[test]
2111    fn report_without_keep_frees_the_slot() {
2112        // Dropping a `ReportContext` *without* `set_keep` must remove the
2113        // subscription from the table (and free its buffer), so a new
2114        // subscription can take its place up to the `N` capacity.
2115        let subs: Subscriptions<1> = Subscriptions::new();
2116        let pool = TestPool::<2>::new();
2117        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2118
2119        let now = Instant::now();
2120
2121        // Add then drop without keep.
2122        drop(add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60));
2123
2124        // The slot is free again: a second add succeeds even with N=1.
2125        let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 11, 1, 60);
2126        // IDs are still strictly monotonic across add/remove cycles.
2127        assert_eq!(rctx.subscription().ids().id, 2);
2128        rctx.set_keep();
2129    }
2130
2131    #[test]
2132    fn report_with_keep_advances_reported_at_and_watermark() {
2133        // After a "kept" report, the subscription must not be picked up again
2134        // at the same instant unless new changes arrive.
2135        let subs: Subscriptions<1> = Subscriptions::new();
2136        let pool = TestPool::<2>::new();
2137        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2138
2139        let now = Instant::now();
2140
2141        // Prime and keep.
2142        {
2143            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2144            rctx.set_keep();
2145        }
2146
2147        // Record one attribute change — watermark advances to 1.
2148        subs.notify_attr_changed(1, 2, 3);
2149
2150        // At the same instant, min_int (1s) has NOT elapsed so the sub is not
2151        // report-allowed: even though there is a pending change, `report()`
2152        // returns None.
2153        assert!(subs.report(now, 0, &subs_bufs).is_none());
2154
2155        // Past min_int: the pending change makes the sub reportable.
2156        let later = now + Duration::from_secs(2);
2157        {
2158            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2159            assert!(rctx.should_report_attr(1, 2, 3));
2160            // A fresh (never recorded) triple is NOT in the table and must
2161            // not be spuriously reported.
2162            assert!(!rctx.should_report_attr(9, 9, 9));
2163            rctx.set_keep();
2164        }
2165
2166        // Watermark has been committed — another call at `later` with no new
2167        // activity finds nothing.
2168        assert!(subs.report(later, 0, &subs_bufs).is_none());
2169    }
2170
2171    #[test]
2172    fn report_triggered_by_new_events() {
2173        // A bump in `event_numbers_watermark` (i.e. a newly emitted event)
2174        // makes the subscription reportable even without attribute changes.
2175        let subs: Subscriptions<1> = Subscriptions::new();
2176        let pool = TestPool::<2>::new();
2177        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2178
2179        let now = Instant::now();
2180        {
2181            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2182            rctx.set_keep();
2183        }
2184
2185        // Same instant, no new events (watermark = 0 same as sub's
2186        // max_seen), min_int not elapsed → nothing to report.
2187        assert!(subs.report(now, 0, &subs_bufs).is_none());
2188
2189        let later = now + Duration::from_secs(2);
2190
2191        // Still no new events at `later` (min_int elapsed though).
2192        assert!(subs.report(later, 0, &subs_bufs).is_none());
2193
2194        // A new event bumps the watermark → sub is reportable. The captured
2195        // `next_max_seen_event_number` mirrors the watermark and is the
2196        // value that will be committed on `set_keep`.
2197        {
2198            let mut rctx = subs.report(later, 5, &subs_bufs).unwrap();
2199            assert_eq!(rctx.max_seen_event_number(), 0);
2200            assert_eq!(rctx.next_max_seen_event_number(), 5);
2201            rctx.set_keep();
2202        }
2203
2204        // After reporting, watermark=5 is no longer "new" for this sub.
2205        assert!(subs.report(later, 5, &subs_bufs).is_none());
2206        // But a further bump does trigger again (past min_int is needed).
2207        let even_later = later + Duration::from_secs(2);
2208        {
2209            let mut rctx = subs.report(even_later, 6, &subs_bufs).unwrap();
2210            assert_eq!(rctx.max_seen_event_number(), 5);
2211            assert_eq!(rctx.next_max_seen_event_number(), 6);
2212            rctx.set_keep();
2213        }
2214    }
2215
2216    #[test]
2217    fn report_triggered_by_liveness_deadline() {
2218        // With no changes at all, a subscription still becomes reportable once
2219        // it enters the "liveness" window (within half of `max_int` of the
2220        // deadline).
2221        let subs: Subscriptions<1> = Subscriptions::new();
2222        let pool = TestPool::<2>::new();
2223        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2224
2225        let now = Instant::now();
2226        // max_int = 20s → half of max_int = 10s → becomes report-due at now+10s.
2227        {
2228            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 20);
2229            rctx.set_keep();
2230        }
2231
2232        // Short of the liveness window: not due.
2233        let short = now + Duration::from_secs(5);
2234        assert!(subs.report(short, 0, &subs_bufs).is_none());
2235
2236        // At the liveness window: due even without any attr/event change.
2237        let long = now + Duration::from_secs(11);
2238        {
2239            let mut rctx = subs.report(long, 0, &subs_bufs).unwrap();
2240            assert!(rctx.should_send_if_empty());
2241            rctx.set_keep();
2242        }
2243    }
2244
2245    #[test]
2246    fn next_report_at_max_when_empty() {
2247        // No subscriptions → no deadline (`Instant::MAX`); the timer never fires
2248        // and the reporter waits to be notified.
2249        let subs: Subscriptions<1> = Subscriptions::new();
2250        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2251        assert_eq!(subs.next_report_at(0, &subs_bufs), Instant::MAX);
2252    }
2253
2254    #[test]
2255    fn next_report_at_liveness_when_idle() {
2256        // No pending data → wake at the liveness point
2257        // `reported_at + max_int - max_int/2` (when `is_report_due` flips).
2258        let subs: Subscriptions<1> = Subscriptions::new();
2259        let pool = TestPool::<2>::new();
2260        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2261
2262        let now = Instant::now();
2263        // min_int = 1s, max_int = 60s → 60 - 30 = 30s.
2264        {
2265            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2266            rctx.set_keep();
2267        }
2268
2269        assert_eq!(
2270            subs.next_report_at(0, &subs_bufs),
2271            now + Duration::from_secs(30)
2272        );
2273    }
2274
2275    #[test]
2276    fn next_report_at_quiet_period_for_pending_attr_change() {
2277        // A change recorded inside the quiet period must schedule the wake at
2278        // the end of that period (`reported_at + min_int`), not the far-off
2279        // liveness point.
2280        let subs: Subscriptions<1> = Subscriptions::new();
2281        let pool = TestPool::<2>::new();
2282        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2283
2284        let now = Instant::now();
2285        // min_int = 5s, max_int = 60s (liveness would otherwise be at now+30s).
2286        {
2287            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 5, 60);
2288            rctx.set_keep();
2289        }
2290
2291        subs.notify_attr_changed(1, 2, 3);
2292
2293        // Held back by the quiet period, so still not reportable now...
2294        assert!(subs.report(now, 0, &subs_bufs).is_none());
2295        // ...but the wake is scheduled at min_int, not liveness.
2296        assert_eq!(
2297            subs.next_report_at(0, &subs_bufs),
2298            now + Duration::from_secs(5)
2299        );
2300    }
2301
2302    #[test]
2303    fn next_report_at_quiet_period_for_pending_event() {
2304        // Same as above, driven by a new event (watermark past the sub's
2305        // max_seen = 0) rather than an attribute change.
2306        let subs: Subscriptions<1> = Subscriptions::new();
2307        let pool = TestPool::<2>::new();
2308        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2309
2310        let now = Instant::now();
2311        {
2312            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 5, 60);
2313            rctx.set_keep();
2314        }
2315
2316        assert_eq!(
2317            subs.next_report_at(7, &subs_bufs),
2318            now + Duration::from_secs(5)
2319        );
2320    }
2321
2322    #[test]
2323    fn next_report_at_clamps_liveness_to_min_interval() {
2324        // Regression guard for the busy-spin: when `min_int > max_int/2`, the
2325        // liveness point (`reported_at + max_int - max_int/2`) precedes the
2326        // min-interval gate at which a report is first allowed. The wake MUST
2327        // be clamped to the gate; otherwise the reporter wakes early, finds the
2328        // sub still gated, re-arms the same past deadline, and spins.
2329        let subs: Subscriptions<1> = Subscriptions::new();
2330        let pool = TestPool::<2>::new();
2331        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2332
2333        let now = Instant::now();
2334        // min_int = 25s, max_int = 40s → liveness at now+20s, gate at now+25s.
2335        {
2336            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 25, 40);
2337            rctx.set_keep();
2338        }
2339
2340        // Clamped to the gate (now+25), not the earlier liveness point (now+20).
2341        assert_eq!(
2342            subs.next_report_at(0, &subs_bufs),
2343            now + Duration::from_secs(25)
2344        );
2345        // The scheduled instant matches actual reportability: gated before it,
2346        // reportable at it.
2347        assert!(subs
2348            .report(now + Duration::from_secs(24), 0, &subs_bufs)
2349            .is_none());
2350        assert!(subs
2351            .report(now + Duration::from_secs(25), 0, &subs_bufs)
2352            .is_some());
2353    }
2354
2355    #[test]
2356    fn next_report_at_returns_earliest_across_subs() {
2357        // The reporter must wake for whichever subscription is due first.
2358        let subs: Subscriptions<2> = Subscriptions::new();
2359        let pool = TestPool::<3>::new();
2360        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2361
2362        let now = Instant::now();
2363        // Sub A: max_int = 60s → liveness now+30s.
2364        {
2365            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2366            rctx.set_keep();
2367        }
2368        // Sub B: max_int = 40s → liveness now+20s (the earliest).
2369        {
2370            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 2, 11, 1, 40);
2371            rctx.set_keep();
2372        }
2373
2374        assert_eq!(
2375            subs.next_report_at(0, &subs_bufs),
2376            now + Duration::from_secs(20)
2377        );
2378    }
2379
2380    #[test]
2381    fn subscription_added_notification_wakes_reporter_to_recompute_deadline() {
2382        use core::pin::pin;
2383        use embassy_futures::select::{select, Either};
2384
2385        let subs: Subscriptions<2> = Subscriptions::new();
2386        let pool = TestPool::<3>::new();
2387        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2388
2389        let now = Instant::now();
2390        {
2391            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2392            rctx.set_keep();
2393        }
2394
2395        assert_eq!(
2396            subs.next_report_at(0, &subs_bufs),
2397            now + Duration::from_secs(30)
2398        );
2399
2400        let waiter = pin!(subs.notification.wait());
2401
2402        {
2403            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 11, 1, 10);
2404            rctx.set_keep();
2405        }
2406        subs.notification.notify();
2407
2408        let notified = embassy_futures::block_on(async {
2409            match select(waiter, pin!(core::future::ready(()))).await {
2410                Either::First(_) => true,
2411                Either::Second(_) => false,
2412            }
2413        });
2414
2415        assert!(notified);
2416        assert_eq!(
2417            subs.next_report_at(0, &subs_bufs),
2418            now + Duration::from_secs(5)
2419        );
2420    }
2421
2422    #[test]
2423    fn is_expired_uses_max_int() {
2424        // `Subscription::is_expired` returns true once `max_int` has elapsed
2425        // since the last reported_at.
2426        let subs: Subscriptions<1> = Subscriptions::new();
2427        let pool = TestPool::<2>::new();
2428        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2429
2430        let base = Instant::now();
2431        {
2432            let mut rctx = add_sub(&subs, &subs_bufs, &pool, base, 1, 10, 1, 5);
2433            rctx.set_keep();
2434        }
2435
2436        // Before max_int: not expired. Use the `remove` predicate as a probe
2437        // because we have no other way to observe per-sub `is_expired` through
2438        // the public API.
2439        let before = base + Duration::from_secs(2);
2440        assert!(!subs.remove(&subs_bufs, |sub| sub
2441            .is_expired(before)
2442            .then_some("expired")));
2443
2444        // Past max_int: expired — removal fires.
2445        let after = base + Duration::from_secs(10);
2446        assert!(subs.remove(&subs_bufs, |sub| sub.is_expired(after).then_some("expired")));
2447    }
2448
2449    #[test]
2450    fn remove_invokes_predicate_and_frees_slots() {
2451        // `Subscriptions::remove` drains every matching entry (not just one),
2452        // returns whether anything was removed, and frees the slots so that
2453        // subsequent `add` calls succeed up to the capacity `N`.
2454        let subs: Subscriptions<3> = Subscriptions::new();
2455        let pool = TestPool::<4>::new();
2456        let subs_bufs: SubscriptionsBuffers<TestPool<4>, 3> = SubscriptionsBuffers::new();
2457
2458        let now = Instant::now();
2459        for peer in [100_u64, 101, 102] {
2460            let fab_idx = if peer == 102 { 2 } else { 1 };
2461            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, fab_idx, peer, 1, 60);
2462            rctx.set_keep();
2463        }
2464        // Table is full: a 4th add must be rejected.
2465        assert!(subs
2466            .add(
2467                now,
2468                fab(1),
2469                200,
2470                0,
2471                1,
2472                60,
2473                0,
2474                pool.get_immediate().unwrap(),
2475                &subs_bufs
2476            )
2477            .is_none());
2478
2479        // Remove every fab(1) subscription (2 of them).
2480        let mut seen_peers: std::vec::Vec<u64> = std::vec::Vec::new();
2481        let removed = subs.remove(&subs_bufs, |sub| {
2482            if sub.ids().fab_idx == fab(1) {
2483                seen_peers.push(sub.ids().peer_node_id);
2484                Some("fabric 1 removed")
2485            } else {
2486                None
2487            }
2488        });
2489        assert!(removed);
2490        seen_peers.sort();
2491        assert_eq!(seen_peers, std::vec![100_u64, 101]);
2492
2493        // A second identical remove is a no-op and returns false.
2494        assert!(!subs.remove(&subs_bufs, |sub| (sub.ids().fab_idx == fab(1))
2495            .then_some("fabric 1 removed")));
2496
2497        // Two slots were freed: we can add two more subs.
2498        {
2499            let mut r1 = add_sub(&subs, &subs_bufs, &pool, now, 3, 300, 1, 60);
2500            r1.set_keep();
2501            let mut r2 = add_sub(&subs, &subs_bufs, &pool, now, 3, 301, 1, 60);
2502            r2.set_keep();
2503        }
2504        // And a third add is rejected again (back at capacity).
2505        assert!(subs
2506            .add(
2507                now,
2508                fab(3),
2509                302,
2510                0,
2511                1,
2512                60,
2513                0,
2514                pool.get_immediate().unwrap(),
2515                &subs_bufs
2516            )
2517            .is_none());
2518    }
2519
2520    #[test]
2521    fn remove_on_empty_table_returns_false() {
2522        let subs: Subscriptions<2> = Subscriptions::new();
2523        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 2> = SubscriptionsBuffers::new();
2524        assert!(!subs.remove(&subs_bufs, |_| Some("never called on empty")));
2525    }
2526
2527    #[test]
2528    fn remove_cancels_in_flight_subscription() {
2529        // A subscription that has been moved into a `ReportContext` is still
2530        // observable to `remove` via `SubscriptionsInner::reporting`. Matching
2531        // it must cause `report_complete` to drop the subscription on Drop
2532        // rather than re-inserting it, even when `set_keep` was called.
2533        let subs: Subscriptions<2> = Subscriptions::new();
2534        let pool = TestPool::<3>::new();
2535        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2536
2537        let now = Instant::now();
2538
2539        // Prime a subscription so it lives in the table.
2540        {
2541            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 100, 1, 60);
2542            rctx.set_keep();
2543        }
2544        assert_eq!(subs.state.lock(|s| s.borrow().subscriptions_count), 1);
2545
2546        // Start an incremental report and, while it is "in flight", issue
2547        // a `remove` that matches the in-flight subscription. Also flip
2548        // `set_keep` to verify the cancel flag wins over `keep`.
2549        subs.notify_attr_changed(1, 2, 3);
2550        let later = now + Duration::from_secs(2);
2551        {
2552            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2553
2554            // The in-flight sub is currently absent from `state.subscriptions`
2555            // but must still be visible to `remove` through the `reporting`
2556            // slot.
2557            let mut matched_peers: std::vec::Vec<u64> = std::vec::Vec::new();
2558            let removed = subs.remove(&subs_bufs, |sub| {
2559                matched_peers.push(sub.ids().peer_node_id);
2560                (sub.ids().peer_node_id == 100).then_some("test-cancel")
2561            });
2562            assert!(removed);
2563            assert!(matched_peers.contains(&100));
2564
2565            // Even though we ask to keep, the cancel flag must force a drop.
2566            rctx.set_keep();
2567        }
2568
2569        // After `ReportContext::drop` the subscription must be gone and the
2570        // slot freed.
2571        subs.state.lock(|s| {
2572            let s = s.borrow();
2573            assert_eq!(s.subscriptions_count, 0);
2574            assert!(s.subscriptions.is_empty());
2575            assert!(s.reporting.is_none());
2576            assert!(s.reporting_cancelled.is_none());
2577        });
2578
2579        // Slot is free: a new sub can be added.
2580        let mut r = add_sub(&subs, &subs_bufs, &pool, now, 1, 101, 1, 60);
2581        r.set_keep();
2582    }
2583
2584    #[test]
2585    fn remove_not_matching_in_flight_leaves_it_intact() {
2586        // If `remove`'s predicate matches neither the in-flight subscription
2587        // nor anything in the table, the in-flight subscription must still
2588        // be re-inserted on `ReportContext::drop` when `set_keep` is called.
2589        let subs: Subscriptions<2> = Subscriptions::new();
2590        let pool = TestPool::<3>::new();
2591        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2592
2593        let now = Instant::now();
2594        {
2595            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 100, 1, 60);
2596            rctx.set_keep();
2597        }
2598
2599        subs.notify_attr_changed(1, 2, 3);
2600        let later = now + Duration::from_secs(2);
2601        {
2602            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2603            let removed = subs.remove(&subs_bufs, |sub| {
2604                (sub.ids().peer_node_id == 999).then_some("no-match")
2605            });
2606            assert!(!removed);
2607            rctx.set_keep();
2608        }
2609
2610        subs.state.lock(|s| {
2611            let s = s.borrow();
2612            assert_eq!(s.subscriptions_count, 1);
2613            assert_eq!(s.subscriptions.len(), 1);
2614            assert!(s.reporting.is_none());
2615            assert!(s.reporting_cancelled.is_none());
2616        });
2617    }
2618
2619    #[test]
2620    fn purge_reported_changes_keeps_entries_until_all_subs_catch_up() {
2621        // `purge_reported_changes` must only drop table entries every
2622        // subscription has already reported on: the slowest subscriber's
2623        // `max_seen_attr_change_id` acts as a floor.
2624        let subs: Subscriptions<2> = Subscriptions::new();
2625        let pool = TestPool::<3>::new();
2626        let subs_bufs: SubscriptionsBuffers<TestPool<3>, 2> = SubscriptionsBuffers::new();
2627
2628        let base = Instant::now();
2629
2630        // Two priming adds. Both start at watermark = 0 (no changes yet).
2631        // `ReportContext::next_max_seen_attr_change_id` is captured as 0 by
2632        // `add`, so dropping either rctx with keep commits max_seen = 0.
2633        {
2634            let mut r1 = add_sub(&subs, &subs_bufs, &pool, base, 1, 100, 1, 60);
2635            r1.set_keep();
2636            let mut r2 = add_sub(&subs, &subs_bufs, &pool, base, 1, 101, 1, 60);
2637            r2.set_keep();
2638        }
2639
2640        // Record two changes. Watermark becomes 2.
2641        subs.notify_attr_changed(1, 2, 3); // id 1
2642        subs.notify_attr_changed(1, 2, 4); // id 2
2643
2644        // Advance both subs to watermark 2 via two `report` + keep cycles.
2645        let later = base + Duration::from_secs(2);
2646        for _ in 0..2 {
2647            let mut rctx = subs.report(later, 0, &subs_bufs).unwrap();
2648            assert!(rctx.should_report_attr(1, 2, 3));
2649            assert!(rctx.should_report_attr(1, 2, 4));
2650            rctx.set_keep();
2651        }
2652
2653        // Both subs have max_seen = 2; purge is safe and removes the stale
2654        // entries. The next report should now find nothing pending (same
2655        // instant, no new changes, min_int elapsed but not half of max_int).
2656        subs.purge_reported_changes();
2657        assert!(subs.report(later, 0, &subs_bufs).is_none());
2658
2659        // A brand new change becomes pending again post-purge.
2660        subs.notify_attr_changed(5, 6, 7);
2661        let even_later = later + Duration::from_secs(2);
2662        {
2663            let mut rctx = subs.report(even_later, 0, &subs_bufs).unwrap();
2664            assert!(rctx.should_report_attr(5, 6, 7));
2665            // Previously-purged entries are no longer visible through the
2666            // sub's filter either.
2667            assert!(!rctx.should_report_attr(1, 2, 3));
2668            rctx.set_keep();
2669        }
2670    }
2671
2672    #[test]
2673    fn next_max_seen_event_number_captured_at_report_time() {
2674        // The captured `next_max_seen_event_number` reflects the
2675        // `event_numbers_watermark` passed to `add` / `report` and is what
2676        // gets committed to the subscription on `set_keep`. The committed
2677        // value advances even if no events were actually emitted during the
2678        // report — this is what prevents the "endless reporting loop" for
2679        // subscriptions that are not interested in events but receive an
2680        // event-triggered report.
2681        let subs: Subscriptions<1> = Subscriptions::new();
2682        let pool = TestPool::<2>::new();
2683        let subs_bufs: SubscriptionsBuffers<TestPool<2>, 1> = SubscriptionsBuffers::new();
2684
2685        let now = Instant::now();
2686
2687        // Priming report sees the watermark passed to `add` (0 here).
2688        {
2689            let mut rctx = add_sub(&subs, &subs_bufs, &pool, now, 1, 10, 1, 60);
2690            assert_eq!(rctx.max_seen_event_number(), 0);
2691            assert_eq!(rctx.next_max_seen_event_number(), 0);
2692            rctx.set_keep();
2693        }
2694
2695        let later = now + Duration::from_secs(2);
2696
2697        // First incremental report at watermark=7: the captured "next" is 7,
2698        // and the previous watermark (the sub's `max_seen_event_number`) is
2699        // still 0 until commit.
2700        {
2701            let mut rctx = subs.report(later, 7, &subs_bufs).unwrap();
2702            assert_eq!(rctx.max_seen_event_number(), 0);
2703            assert_eq!(rctx.next_max_seen_event_number(), 7);
2704            rctx.set_keep();
2705        }
2706
2707        // After commit the sub's `max_seen_event_number` has advanced to 7
2708        // — even though we never recorded a single emitted event during
2709        // this report. A second call at the same watermark is therefore a
2710        // no-op (no new events to deliver).
2711        assert!(subs.report(later, 7, &subs_bufs).is_none());
2712
2713        let even_later = later + Duration::from_secs(2);
2714
2715        // Bumping the watermark to 42 makes the sub reportable again; the
2716        // previous max-seen is the 7 we just committed, the captured next
2717        // is the new watermark.
2718        {
2719            let mut rctx = subs.report(even_later, 42, &subs_bufs).unwrap();
2720            assert_eq!(rctx.max_seen_event_number(), 7);
2721            assert_eq!(rctx.next_max_seen_event_number(), 42);
2722            rctx.set_keep();
2723        }
2724    }
2725}