Skip to main content

subetha_cxc/
priority_fanout.rs

1//! `PriorityFanout` - tiered work queue with O(1) priority selection.
2//!
3//! Composes K [`SharedRing`]s (one per priority
4//! level) with a single [`SharedAtomicU64`]
5//! bitmap of active priorities. Consumers find the highest non-empty
6//! priority in one CLZ instruction; producers route by priority tag.
7//!
8//! # Why O(1) priority selection matters
9//!
10//! Naive priority queues either:
11//! - Use ONE ring with a priority field and scan linearly: O(K) per drain.
12//! - Use a binary heap: O(log K) per submit AND drain, plus heap reorg
13//!   under contention is hard to make lock-free.
14//!
15//! PriorityFanout pays O(1) on both sides: one fetch_or to set a bit
16//! on submit, one CLZ + bit-clear on drain. The bitmap fits in a
17//! single cache line so it's contention-friendly under load.
18//!
19//! # Layout
20//!
21//! K+1 MMF files for K priority levels:
22//! - `<base>.bitmap.bin`            - SharedAtomicU64 active-priority bits
23//! - `<base>.prio0.bin`             - SharedRing for priority 0 (lowest)
24//! - `<base>.prio1.bin`             - SharedRing for priority 1
25//! - ...
26//! - `<base>.prio{K-1}.bin`         - SharedRing for priority K-1 (highest)
27//!
28//! # Protocol (the bitmap-as-hint pattern)
29//!
30//! - **Producer** `submit(prio, payload)`:
31//!   1. `ring[prio].try_push(payload)?`
32//!   2. `bitmap.fetch_or(1 << prio, AcqRel)` (idempotent; safe even
33//!      if already set)
34//!
35//! - **Consumer** `try_drain_highest`:
36//!   1. Load bitmap; if zero, return Empty.
37//!   2. `highest = 63 - bitmap.leading_zeros()` (one CLZ).
38//!   3. `ring[highest].try_pop(buf)`:
39//!      - Ok: return (highest, payload).
40//!      - Err(Empty): another consumer drained it first; clear bit and
41//!        retry the scan.
42//!
43//! The bitmap is a HINT (set after push, cleared on observed-empty
44//! pop), not a source of truth; the ring is authoritative.
45//!
46//! # Race analysis
47//!
48//! - Producer pushed but hasn't set bit: consumer transiently sees
49//!   Empty for that priority; next consumer call sees the bit
50//!   (producer sets it after push). Acceptable: weakly-consistent
51//!   fairness.
52//! - Consumer cleared bit; concurrent producer set it via fetch_or:
53//!   producer's set overrides the clear; consumer's clear was for a
54//!   real Empty observation at one point in time. No item is ever
55//!   lost; bit may be transiently inconsistent with ring state.
56//!
57//! # Capacity
58//!
59//! Up to 64 priorities (one u64 bit per priority). For more, switch
60//! to a `Vec<u64>` bitmap and walk the words.
61
62use std::path::{Path, PathBuf};
63use std::sync::atomic::Ordering;
64use std::sync::Arc;
65
66use crate::shared_atomic::{SharedAtomicError, SharedAtomicU64};
67use crate::shared_ring::{RingError, SharedRing, PAYLOAD_BYTES};
68
69/// Maximum number of priority levels (one bit per priority in the
70/// u64 bitmap).
71pub const MAX_PRIORITIES: usize = 64;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum FanoutError {
75    Ring(RingError),
76    Atomic(SharedAtomicError),
77    PriorityOutOfBounds,
78    NPrioritiesOutOfBounds,
79    Empty,
80}
81
82impl From<RingError> for FanoutError {
83    fn from(e: RingError) -> Self { Self::Ring(e) }
84}
85impl From<SharedAtomicError> for FanoutError {
86    fn from(e: SharedAtomicError) -> Self { Self::Atomic(e) }
87}
88
89fn bitmap_path(base: &Path) -> PathBuf {
90    let mut p = base.to_path_buf();
91    let stem = p.file_name().unwrap().to_string_lossy().to_string();
92    p.set_file_name(format!("{stem}.bitmap.bin"));
93    p
94}
95fn prio_path(base: &Path, prio: usize) -> PathBuf {
96    let mut p = base.to_path_buf();
97    let stem = p.file_name().unwrap().to_string_lossy().to_string();
98    p.set_file_name(format!("{stem}.prio{prio}.bin"));
99    p
100}
101
102pub struct PriorityFanout {
103    rings: Vec<Arc<SharedRing>>,
104    bitmap: Arc<SharedAtomicU64>,
105    n_priorities: usize,
106    header_sidecar: subetha_core::HandshakeHeader,
107    ring_sidecar: Box<subetha_core::ObservationRing>,
108}
109
110impl subetha_sidecar::AdaptiveInstance for PriorityFanout {
111    fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
112    fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
113    fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
114        Box::new(subetha_sidecar::NoMigrationPolicy)
115    }
116}
117
118impl PriorityFanout {
119    /// Create a fanout with `n_priorities` levels (0..n; 0 = lowest,
120    /// n-1 = highest), each ring sized to `ring_capacity` slots. All
121    /// rings have the same capacity; configure based on the expected
122    /// burst per priority.
123    pub fn create(
124        base_path: impl AsRef<Path>,
125        n_priorities: usize,
126        ring_capacity: usize,
127    ) -> Result<Self, FanoutError> {
128        if n_priorities == 0 || n_priorities > MAX_PRIORITIES {
129            return Err(FanoutError::NPrioritiesOutOfBounds);
130        }
131        let base = base_path.as_ref();
132        let mut rings = Vec::with_capacity(n_priorities);
133        for i in 0..n_priorities {
134            rings.push(Arc::new(SharedRing::create(prio_path(base, i), ring_capacity)?));
135        }
136        let bitmap = Arc::new(SharedAtomicU64::create(bitmap_path(base), 0)?);
137        Ok(Self {
138            rings, bitmap, n_priorities,
139            header_sidecar: subetha_core::HandshakeHeader::new(),
140            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
141        })
142    }
143
144    /// Open an existing fanout. Pass the SAME `n_priorities` and
145    /// `ring_capacity` the creator used.
146    pub fn open(
147        base_path: impl AsRef<Path>,
148        n_priorities: usize,
149        ring_capacity: usize,
150    ) -> Result<Self, FanoutError> {
151        if n_priorities == 0 || n_priorities > MAX_PRIORITIES {
152            return Err(FanoutError::NPrioritiesOutOfBounds);
153        }
154        let base = base_path.as_ref();
155        let mut rings = Vec::with_capacity(n_priorities);
156        for i in 0..n_priorities {
157            rings.push(Arc::new(SharedRing::open(prio_path(base, i), ring_capacity)?));
158        }
159        let bitmap = Arc::new(SharedAtomicU64::open(bitmap_path(base)).map_err(|_| {
160            // First touch may need create if a stale ring file exists; just
161            // surface the atomic error.
162            FanoutError::Atomic(SharedAtomicError::LayoutMismatch)
163        })?);
164        Ok(Self {
165            rings, bitmap, n_priorities,
166            header_sidecar: subetha_core::HandshakeHeader::new(),
167            ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
168        })
169    }
170
171    #[inline]
172    pub fn n_priorities(&self) -> usize { self.n_priorities }
173
174    /// Submit a payload at the given priority level. `priority` must
175    /// be in `0..n_priorities`. Returns `Err(Ring(Full))` when that
176    /// priority's ring is full.
177    pub fn submit(&self, priority: usize, payload: &[u8]) -> Result<(), FanoutError> {
178        if priority >= self.n_priorities {
179            self.ring_sidecar
180                .push_op(crate::sidecar_ops::priority_fanout::OP_SUBMIT, 1);
181            return Err(FanoutError::PriorityOutOfBounds);
182        }
183        let r = self.rings[priority].try_push(payload);
184        if r.is_ok() {
185            self.bitmap.fetch_or(1u64 << priority, Ordering::AcqRel);
186        }
187        self.ring_sidecar.push_op(
188            crate::sidecar_ops::priority_fanout::OP_SUBMIT,
189            if r.is_err() { 1 } else { 0 },
190        );
191        r?;
192        Ok(())
193    }
194
195    /// Drain ONE item from the highest non-empty priority. Returns
196    /// the priority of the item that was drained. Returns
197    /// `Err(Empty)` only when ALL rings are empty.
198    pub fn try_drain_highest(&self, out: &mut [u8]) -> Result<usize, FanoutError> {
199        // Bounded retry: in the absolute worst case we scan and clear
200        // every priority bit once. Don't loop forever; a malicious
201        // producer churning the bitmap is otherwise able to starve
202        // the caller.
203        for _ in 0..(self.n_priorities * 2) {
204            let bitmap = self.bitmap.load(Ordering::Acquire);
205            if bitmap == 0 {
206                self.ring_sidecar
207                    .push_op(crate::sidecar_ops::priority_fanout::OP_DRAIN_HIGHEST, 2);
208                return Err(FanoutError::Empty);
209            }
210            // highest = position of highest set bit (63 - leading_zeros for u64).
211            let highest = 63 - bitmap.leading_zeros() as usize;
212            if highest >= self.n_priorities {
213                // Spurious bit above our range; clear it.
214                self.bitmap.fetch_and(!(1u64 << highest), Ordering::AcqRel);
215                continue;
216            }
217            match self.rings[highest].try_pop(out) {
218                Ok(_) => {
219                    // Eager hint clear: when we just drained the last
220                    // observable item, clear the bit so observers see
221                    // a fresh bitmap without waiting for the next
222                    // empty-pop attempt.
223                    if self.rings[highest].approx_len() == 0 {
224                        self.bitmap.fetch_and(!(1u64 << highest), Ordering::AcqRel);
225                    }
226                    self.ring_sidecar
227                        .push_op(crate::sidecar_ops::priority_fanout::OP_DRAIN_HIGHEST, 0);
228                    return Ok(highest);
229                }
230                Err(RingError::Empty) => {
231                    // Bit was stale; clear and retry next-highest.
232                    self.bitmap.fetch_and(!(1u64 << highest), Ordering::AcqRel);
233                    continue;
234                }
235                Err(e) => {
236                    self.ring_sidecar
237                        .push_op(crate::sidecar_ops::priority_fanout::OP_DRAIN_HIGHEST, 1);
238                    return Err(FanoutError::Ring(e));
239                }
240            }
241        }
242        self.ring_sidecar
243            .push_op(crate::sidecar_ops::priority_fanout::OP_DRAIN_HIGHEST, 2);
244        Err(FanoutError::Empty)
245    }
246
247    /// Drain ONE item from a specific priority. Returns
248    /// `Err(Ring(Empty))` when that ring is empty. Useful for
249    /// dedicated workers that only handle a specific class.
250    pub fn try_drain_priority(
251        &self, priority: usize, out: &mut [u8]
252    ) -> Result<(), FanoutError> {
253        if priority >= self.n_priorities {
254            self.ring_sidecar
255                .push_op(crate::sidecar_ops::priority_fanout::OP_DRAIN_PRIORITY, 1);
256            return Err(FanoutError::PriorityOutOfBounds);
257        }
258        let r = self.rings[priority].try_pop(out);
259        // Best-effort hint update: if the ring is now empty, clear the bit.
260        // This is purely an optimization; bitmap-as-hint correctness doesn't
261        // require it.
262        if self.rings[priority].approx_len() == 0 {
263            self.bitmap.fetch_and(!(1u64 << priority), Ordering::AcqRel);
264        }
265        self.ring_sidecar.push_op(
266            crate::sidecar_ops::priority_fanout::OP_DRAIN_PRIORITY,
267            if matches!(&r, Err(RingError::Empty)) { 2 } else if r.is_err() { 1 } else { 0 },
268        );
269        r?;
270        Ok(())
271    }
272
273    /// Snapshot the current active-priority bitmap.
274    #[inline]
275    pub fn active_priorities(&self) -> u64 {
276        self.bitmap.load(Ordering::Acquire)
277    }
278
279    /// Highest currently-active priority (None when all empty).
280    pub fn highest_active_priority(&self) -> Option<usize> {
281        let b = self.active_priorities();
282        if b == 0 { return None; }
283        let h = 63 - b.leading_zeros() as usize;
284        if h < self.n_priorities { Some(h) } else { None }
285    }
286
287    /// Approximate pending count for a specific priority (each ring's
288    /// own approx_len).
289    pub fn approx_pending(&self, priority: usize) -> Option<usize> {
290        if priority >= self.n_priorities { return None; }
291        Some(self.rings[priority].approx_len())
292    }
293
294    /// Approximate total pending across all priorities.
295    pub fn approx_total_pending(&self) -> usize {
296        self.rings.iter().map(|r| r.approx_len()).sum()
297    }
298
299    pub const PAYLOAD_BYTES: usize = PAYLOAD_BYTES;
300
301    /// Sync the bitmap and all rings to disk.
302    pub fn flush(&self) -> Result<(), FanoutError> {
303        self.bitmap.flush()?;
304        for r in &self.rings { r.flush()?; }
305        Ok(())
306    }
307
308    /// Non-blocking flush of the bitmap and all rings. Delegates to
309    /// each inner primitive's flush_async.
310    /// Note: Windows is only partially async (sync to page cache,
311    /// not to disk).
312    pub fn flush_async(&self) -> Result<(), FanoutError> {
313        self.bitmap.flush_async()?;
314        for r in &self.rings { r.flush_async()?; }
315        Ok(())
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::sync::atomic::{AtomicU32, Ordering as O};
323    use std::sync::Barrier;
324    use std::thread;
325
326    fn tmp_base(name: &str) -> PathBuf {
327        let mut p = std::env::temp_dir();
328        let pid = std::process::id();
329        p.push(format!("subetha-fanout-{name}-{pid}"));
330        p
331    }
332
333    fn cleanup(base: &Path, n: usize) {
334        std::fs::remove_file(bitmap_path(base)).ok();
335        for i in 0..n {
336            std::fs::remove_file(prio_path(base, i)).ok();
337        }
338    }
339
340    fn payload_of(v: u32) -> [u8; PAYLOAD_BYTES] {
341        let mut b = [0u8; PAYLOAD_BYTES];
342        b[0..4].copy_from_slice(&v.to_le_bytes());
343        b
344    }
345    fn unpack(b: &[u8]) -> u32 {
346        u32::from_le_bytes(b[0..4].try_into().unwrap())
347    }
348
349    #[test]
350    fn create_initial_bitmap_is_zero() {
351        let base = tmp_base("init");
352        let f = PriorityFanout::create(&base, 4, 8).unwrap();
353        assert_eq!(f.active_priorities(), 0);
354        assert_eq!(f.highest_active_priority(), None);
355        cleanup(&base, 4);
356    }
357
358    #[test]
359    fn submit_sets_priority_bit() {
360        let base = tmp_base("sub");
361        let f = PriorityFanout::create(&base, 4, 8).unwrap();
362        f.submit(2, &payload_of(42)).unwrap();
363        assert_eq!(f.active_priorities() & 0b0100, 0b0100);
364        assert_eq!(f.highest_active_priority(), Some(2));
365        cleanup(&base, 4);
366    }
367
368    #[test]
369    fn drain_highest_returns_highest_first() {
370        let base = tmp_base("hi-first");
371        let f = PriorityFanout::create(&base, 4, 8).unwrap();
372        f.submit(0, &payload_of(10)).unwrap();
373        f.submit(2, &payload_of(30)).unwrap();
374        f.submit(1, &payload_of(20)).unwrap();
375        f.submit(3, &payload_of(40)).unwrap();
376        let mut buf = [0u8; PAYLOAD_BYTES];
377        let p3 = f.try_drain_highest(&mut buf).unwrap();
378        assert_eq!(p3, 3);
379        assert_eq!(unpack(&buf), 40);
380        let p2 = f.try_drain_highest(&mut buf).unwrap();
381        assert_eq!(p2, 2);
382        assert_eq!(unpack(&buf), 30);
383        let p1 = f.try_drain_highest(&mut buf).unwrap();
384        assert_eq!(p1, 1);
385        let p0 = f.try_drain_highest(&mut buf).unwrap();
386        assert_eq!(p0, 0);
387        assert_eq!(f.try_drain_highest(&mut buf).err(), Some(FanoutError::Empty));
388        cleanup(&base, 4);
389    }
390
391    #[test]
392    fn drain_within_priority_preserves_fifo() {
393        let base = tmp_base("fifo");
394        let f = PriorityFanout::create(&base, 2, 16).unwrap();
395        for i in 0..5u32 { f.submit(1, &payload_of(i)).unwrap(); }
396        for i in 0..5u32 {
397            let mut buf = [0u8; PAYLOAD_BYTES];
398            let p = f.try_drain_highest(&mut buf).unwrap();
399            assert_eq!(p, 1);
400            assert_eq!(unpack(&buf), i);
401        }
402        cleanup(&base, 2);
403    }
404
405    #[test]
406    fn priority_out_of_bounds_rejected() {
407        let base = tmp_base("oob");
408        let f = PriorityFanout::create(&base, 4, 8).unwrap();
409        assert_eq!(f.submit(4, &payload_of(0)).err(),
410            Some(FanoutError::PriorityOutOfBounds));
411        assert_eq!(f.submit(100, &payload_of(0)).err(),
412            Some(FanoutError::PriorityOutOfBounds));
413        cleanup(&base, 4);
414    }
415
416    #[test]
417    fn try_drain_priority_targets_specific_ring() {
418        let base = tmp_base("specific");
419        let f = PriorityFanout::create(&base, 3, 8).unwrap();
420        f.submit(0, &payload_of(100)).unwrap();
421        f.submit(2, &payload_of(300)).unwrap();
422        let mut buf = [0u8; PAYLOAD_BYTES];
423        f.try_drain_priority(0, &mut buf).unwrap();
424        assert_eq!(unpack(&buf), 100);
425        // Priority 1 is empty.
426        assert_eq!(f.try_drain_priority(1, &mut buf).err(),
427            Some(FanoutError::Ring(RingError::Empty)));
428        // Priority 2 still has the item.
429        f.try_drain_priority(2, &mut buf).unwrap();
430        assert_eq!(unpack(&buf), 300);
431        cleanup(&base, 3);
432    }
433
434    #[test]
435    fn full_ring_returns_error() {
436        let base = tmp_base("full");
437        let f = PriorityFanout::create(&base, 2, 4).unwrap();
438        for i in 0..4u32 { f.submit(0, &payload_of(i)).unwrap(); }
439        match f.submit(0, &payload_of(99)) {
440            Err(FanoutError::Ring(RingError::Full)) => {}
441            other => panic!("expected Ring(Full), got {other:?}"),
442        }
443        cleanup(&base, 2);
444    }
445
446    #[test]
447    fn cross_handle_priority_visible() {
448        let base = tmp_base("cross-handle");
449        let producer = PriorityFanout::create(&base, 4, 8).unwrap();
450        let consumer = PriorityFanout::open(&base, 4, 8).unwrap();
451        producer.submit(3, &payload_of(777)).unwrap();
452        assert_eq!(consumer.highest_active_priority(), Some(3));
453        let mut buf = [0u8; PAYLOAD_BYTES];
454        let p = consumer.try_drain_highest(&mut buf).unwrap();
455        assert_eq!(p, 3);
456        assert_eq!(unpack(&buf), 777);
457        // Producer sees consumer's drain.
458        assert_eq!(producer.highest_active_priority(), None);
459        cleanup(&base, 4);
460    }
461
462    #[test]
463    fn highest_priority_drained_first_under_interleaved_submits() {
464        let base = tmp_base("interleave");
465        let f = PriorityFanout::create(&base, 4, 16).unwrap();
466        // Interleave: drain after every submit; highest always wins.
467        f.submit(0, &payload_of(1)).unwrap();
468        f.submit(1, &payload_of(2)).unwrap();
469        let mut buf = [0u8; PAYLOAD_BYTES];
470        assert_eq!(f.try_drain_highest(&mut buf).unwrap(), 1);
471        f.submit(3, &payload_of(3)).unwrap();
472        assert_eq!(f.try_drain_highest(&mut buf).unwrap(), 3);
473        f.submit(2, &payload_of(4)).unwrap();
474        assert_eq!(f.try_drain_highest(&mut buf).unwrap(), 2);
475        assert_eq!(f.try_drain_highest(&mut buf).unwrap(), 0);
476        cleanup(&base, 4);
477    }
478
479    #[test]
480    fn concurrent_producers_route_to_correct_priorities() {
481        let base = tmp_base("concurrent-prod");
482        let f = Arc::new(PriorityFanout::create(&base, 4, 256).unwrap());
483        let n_threads = 4;
484        let per_thread = 32;
485        let barrier = Arc::new(Barrier::new(n_threads));
486        let mut handles = vec![];
487        for t in 0..n_threads {
488            let f = f.clone();
489            let barrier = barrier.clone();
490            handles.push(thread::spawn(move || {
491                barrier.wait();
492                // Each thread submits at priority = its index.
493                for i in 0..per_thread {
494                    while f.submit(t, &payload_of(i as u32)).is_err() {
495                        std::hint::spin_loop();
496                    }
497                }
498            }));
499        }
500        for h in handles { h.join().unwrap(); }
501        // Drain everything; count per priority.
502        let mut counts = [0u32; 4];
503        let mut buf = [0u8; PAYLOAD_BYTES];
504        while let Ok(p) = f.try_drain_highest(&mut buf) {
505            counts[p] += 1;
506        }
507        for c in counts.iter() {
508            assert_eq!(*c, per_thread as u32);
509        }
510        cleanup(&base, 4);
511    }
512
513    #[test]
514    fn observer_sees_bitmap_update_during_workload() {
515        let base = tmp_base("observer-bitmap");
516        let f = Arc::new(PriorityFanout::create(&base, 8, 64).unwrap());
517        // Submit synchronously BEFORE spawning the observer loop so
518        // we test the visibility property, not a scheduler race
519        // between two unsynchronised threads.
520        f.submit(5, &payload_of(1)).unwrap();
521        let f2 = f.clone();
522        let stop = Arc::new(AtomicU32::new(0));
523        let stop2 = stop.clone();
524        let producer = thread::spawn(move || {
525            for _ in 0..100 {
526                if stop2.load(O::Acquire) == 1 { break; }
527                // Producer keeps pumping; full-fanout errors are
528                // valid (consumer side may be slow).
529                f2.submit(5, &payload_of(1)).ok();
530                std::thread::yield_now();
531            }
532        });
533        // Observer should see bit 5 set immediately (it was set by
534        // the pre-spawn submit). The loop allows for transient
535        // unfairness on heavily-loaded CI runners.
536        let mut saw = false;
537        for _ in 0..10_000 {
538            if (f.active_priorities() & (1 << 5)) != 0 {
539                saw = true;
540                break;
541            }
542            std::thread::yield_now();
543        }
544        stop.store(1, O::Release);
545        producer.join().unwrap();
546        assert!(saw, "observer never saw bit 5 set");
547        cleanup(&base, 8);
548    }
549
550    #[test]
551    fn n_priorities_out_of_bounds_rejected_at_create() {
552        let base = tmp_base("oob-create");
553        assert_eq!(
554            PriorityFanout::create(&base, 0, 8).err(),
555            Some(FanoutError::NPrioritiesOutOfBounds),
556        );
557        assert_eq!(
558            PriorityFanout::create(&base, MAX_PRIORITIES + 1, 8).err(),
559            Some(FanoutError::NPrioritiesOutOfBounds),
560        );
561        // 64 (= MAX_PRIORITIES) is allowed.
562        let f = PriorityFanout::create(&base, MAX_PRIORITIES, 4).unwrap();
563        assert_eq!(f.n_priorities(), MAX_PRIORITIES);
564        cleanup(&base, MAX_PRIORITIES);
565    }
566
567    #[test]
568    fn disk_persistence_bitmap_and_rings_survive_reopen() {
569        let base = tmp_base("disk");
570        {
571            let f = PriorityFanout::create(&base, 4, 8).unwrap();
572            f.submit(2, &payload_of(2222)).unwrap();
573            f.submit(0, &payload_of(0000)).unwrap();
574            f.flush().unwrap();
575        }
576        let f2 = PriorityFanout::open(&base, 4, 8).unwrap();
577        assert_eq!(f2.highest_active_priority(), Some(2));
578        let mut buf = [0u8; PAYLOAD_BYTES];
579        let p = f2.try_drain_highest(&mut buf).unwrap();
580        assert_eq!(p, 2);
581        assert_eq!(unpack(&buf), 2222);
582        let p = f2.try_drain_highest(&mut buf).unwrap();
583        assert_eq!(p, 0);
584        assert_eq!(unpack(&buf), 0);
585        cleanup(&base, 4);
586    }
587}