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}