rust 124 lines · 3 tabs

Order-Preserving Stream Deduplication by Key in Rust with a HashSet Guard

Shared by codesnips Aug 2026
3 tabs
use std::collections::HashSet;
use std::hash::Hash;

pub struct DedupByKey<I, K, F> {
    inner: I,
    key_fn: F,
    seen: HashSet<K>,
}

impl<I, K, F> Iterator for DedupByKey<I, K, F>
where
    I: Iterator,
    K: Eq + Hash,
    F: FnMut(&I::Item) -> K,
{
    type Item = I::Item;

    fn next(&mut self) -> Option<Self::Item> {
        loop {
            let item = self.inner.next()?;
            let key = (self.key_fn)(&item);
            if self.seen.insert(key) {
                return Some(item);
            }
        }
    }
}

pub trait DedupByKeyExt: Iterator + Sized {
    fn dedup_by_key<K, F>(self, key_fn: F) -> DedupByKey<Self, K, F>
    where
        K: Eq + Hash,
        F: FnMut(&Self::Item) -> K,
    {
        DedupByKey {
            inner: self,
            key_fn,
            seen: HashSet::new(),
        }
    }
}

impl<I: Iterator> DedupByKeyExt for I {}
3 files · rust Explain with highlit

This snippet shows two complementary ways to deduplicate a stream of records by a key while keeping the original arrival order: a synchronous Iterator adapter and an asynchronous Stream adapter built on futures. The core idea in both is a first-wins policy — the first record seen for a given key passes through, and every later record with the same key is dropped. Order is preserved because filtering happens inline as items flow past, never by sorting or buffering the whole sequence.

In dedup_by_key.rs, DedupByKey wraps any inner Iterator and owns a HashSet<K> of keys already emitted. The key_fn closure extracts a hashable, ownable key from each borrowed item. On every next(), it pulls from the inner iterator and calls seen.insert(key); because HashSet::insert returns true only when the value was newly added, that boolean doubles as the keep/skip decision. The surrounding loop skips duplicates without recursion, so a long run of repeats costs constant stack space. The DedupByKeyExt trait then bolts a dedup_by_key method onto every iterator, matching the ergonomics of the standard .filter() or .map() combinators.

The async side in dedup_stream.rs mirrors that design against futures::Stream. DedupStream is pinned and delegates to the inner stream via poll_next, using Poll::Ready(Some(item)) to apply the same seen.insert guard and Poll::Pending to propagate backpressure untouched. Wrapping the inner stream in Pin<Box<S>> keeps the adapter usable with unnamed async stream types. The #[pin_project] alternative is avoided here to keep the pinning explicit and dependency-light.

main.rs exercises both paths: a plain vector run and a tokio-driven async run over stream::iter, printing that only the first event per user survives. The trade-off is memory — the HashSet grows with the number of distinct keys, which is unbounded for an infinite stream, so this fits bounded key spaces or windowed use. For last-wins semantics a reversed pass or a HashMap staging buffer would be needed instead, sacrificing streaming. This pattern is the right reach whenever duplicates must be collapsed cheaply without reordering, such as event ingestion or log compaction.


Related snips

Share this code

Here's the card — post it anywhere.

Order-Preserving Stream Deduplication by Key in Rust with a HashSet Guard — share card
Link copied