With 95% reads and twelve workers I'd start with Arc<RwLock<HashMap>> using the async RwLock only if you hold it across awaits, and the std one if you don't. The channel-to-owner design is lovely for complex invariants but it serialises every read through one task and adds a round trip per lookup, which is the exact thing you said you care about. Measure before you get clever: at 50k entries a read lock plus a hash lookup is sub-microsecond, and if that turns out to be your bottleneck you'll have learned something surprising.
Ben
@borrowck_ben
Writes systems code, still reads compiler errors out loud, still learns something.
Fair, though you can also express that as a single write-locked method on a wrapper type rather than a message. Same atomicity, no round trip.
Then use std::sync::RwLock and keep the critical section to the lookup only. Async mutexes are slower and you don't need what they offer.
The trick is to end the immutable borrow before you start the mutable one, and the cleanest way is to compute a small decision value inside a scope and then act on it outside. Something like let should_merge = matches!(events.last(), Some(e) if e.can_merge(&item)); then if should_merge { events.last_mut().unwrap().merge(item) } else { events.push(item) }. The last() borrow ends at the end of that statement because the bool you kept doesn't hold a reference, so the last_mut() is free to take a fresh mutable borrow. No clone, no unsafe, and it reads fine.
It moves the same conflict to runtime, where it becomes a panic instead of a compile error, and it costs a check on every access in a hot loop. There's no aliasing need here, so it's strictly worse than restructuring two lines.
You've already found it: Rc<Config> is the problem, and it doesn't need to be held across the await you're looking at, it only needs to be alive in the generated future's state at any await point. Swap it for Arc<Config> and the error usually vanishes on the spot. If you want the systematic version, the error text has a 'required because it appears within' chain, and reading it bottom to top names the exact field and the exact await, but the shortcut is that a non-Send type in a spawned future is almost always Rc, RefCell, or a MutexGuard from a non-async mutex.
fn matching(&self, kind: Kind) -> impl Iterator<Item = &Record> + '_ is what you want. The '_ ties the returned iterator to the borrow of self, which is the piece that's usually missing when you get 'doesn't live long enough'. Inside, self.records.iter().filter(move |r| r.kind == kind) and the move on the closure captures kind by value so the closure doesn't borrow a local. Box<dyn Iterator> isn't wrong but you pay an allocation and lose the concrete type for no reason here.
It can see it, it just won't assume it, because for impl Trait in return position the captured lifetimes have to be spelled out or elided explicitly. Newer editions loosened this, which is why half the examples you find online don't have it.
Around three months for me before I stopped being surprised, and the thing that flipped it was learning to decide ownership before writing the struct rather than after. Concretely: for every field, ask who owns this, who needs to see it, and does the seeing outlive the owner. If the answer to the last one is yes, you need an Arc or an index, and you know that before you write a line rather than after the compiler tells you. Once the design carries the answer, the errors mostly stop.
Strongly agree with this as a learning strategy. The compiler is teaching you a real constraint but you don't have to solve it optimally on day one.