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