The shared flow that dropped every twelfth event
An Android analytics SDK exposes val events = MutableSharedFlow<Event>(replay = 0) and a non-suspend fun track(event: Event) { if (!events.tryEmit(event)) logDropped() }. Three collectors attach at different times: the debug logger immediately, the uploader after a 200 ms initialization, and a screen observer only while visible. The buffer uses the default extraBufferCapacity = 0 and onBufferOverflow = SUSPEND. In a 30-minute soak test, 12% of 48,000 events are reported dropped, the uploader misses the first 40 events after each screen rotation, and logDropped() is called from the main thread. A proposed fix changes replay to 64 and extraBufferCapacity to 256, but the team worries about memory and duplicate uploads. The SDK targets Kotlin 2.0, coroutines 1.8, minSdk 24, and must not block the caller.
1. Why does `tryEmit` return false, and why does the uploader miss the first 40 events after rotation? Explain the `SharedFlow` configuration.
Read the worked answer
MutableSharedFlow<Event>(replay = 0) with default extraBufferCapacity = 0 and onBufferOverflow = SUSPEND has no place to put an event that a collector cannot accept immediately. tryEmit is the non-suspending variant; when the shared buffer is full or a subscriber is not ready, it returns false instead of suspending. With zero extra capacity, any slow collector or main-thread jitter can cause a false return, so the 12% drop rate is the expected result of using tryEmit with a zero buffer. The uploader misses the first 40 events for a different reason: replay = 0 means a collector only receives emissions after it subscribes. The uploader attaches 200 ms after startup, and rotation cancels and re-subscribes the screen observer, creating a gap with no replay. logDropped() on the main thread confirms the caller is non-suspending. Increasing replay to 64 would replay old events to every new collector, causing duplicate uploads, and it does not fix slow-collector drops. extraBufferCapacity = 256 helps only if tryEmit can enqueue; with SUSPEND and multiple collectors, the slowest collector still controls the shared buffer. SharedFlow is a broadcast hot stream, not a queue.
2. The product requirement is no event loss for up to 5 seconds of collector absence and no blocking the main thread. Propose a design and justify buffer sizes and overflow policy.
Read the worked answer
Separate the two requirements. For the uploader, use a single-consumer Channel<Event>(capacity = 256) with trySend; 256 is about 9.6 seconds at the measured 26.7 events/s, covering the 5-second gap. If trySend fails, persist the event to a small disk queue and replay it; do not silently drop. For the debug logger, a separate MutableSharedFlow(replay = 0, extraBufferCapacity = 64, onBufferOverflow = DROP_OLDEST) is acceptable because debug logs are lossy. For UI observation, expose a StateFlow or a SharedFlow collected with repeatOnLifecycle(STARTED); never collect a hot flow from a lifecycle-unaware scope. If you must keep one SharedFlow, set replay = 64, extraBufferCapacity = 192, and onBufferOverflow = DROP_OLDEST, and accept that it is lossy-this contradicts the no-loss requirement. stateIn with SharingStarted.WhileSubscribed(5_000) solves the rotation gap for state, but conflates values and is wrong for an event log. The clean design is a durable queue for events plus a separate observable stream for UI state. Measure subscriptionCount to start producers only when needed, and make track return a boolean or suspend so callers can handle backpressure.
3. How would you test that no events are dropped when a collector subscribes 200 ms late, deterministically?
Read the worked answer
With runTest and a StandardTestDispatcher(testScheduler), create the flow with replay = 64 or the channel with capacity 256. Launch the collector after emitting 40 events using backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { flow.take(40).toList(received) }. Because UnconfinedTestDispatcher starts eagerly, the collector subscribes before the assertion. Then advanceUntilIdle() and assert received.size == 40 and the event IDs. For the gap, explicitly cancel the collector, emit 40 more, relaunch, and assert replay delivers them. Do not use Thread.sleep or wall-clock delay; runTest skips virtual delays, and advanceUntilIdle drains the scheduler. For trySend failure, use a collector that suspends on a CompletableDeferred and assert the fallback persistence was called exactly once. Turbine can make hot-flow tests clearer: flow.test { ... }, but it is an extra dependency; the standard library plus runTest is sufficient. Also test the drop counter is zero on the happy path, because a passing no-loss test that never exercises the buffer is meaningless.