Subscriptions & Resilience

Batch cleanup of subscriptions and protection against failing handlers.

The Subscriptions collector

Forgetting to unsubscribe is the most common source of leaks in reactive code. The Subscriptions class collects all your subscriptions and cleans them up in a single call:

import io.peekandpoke.ultra.streams.Subscriptions

val subs = Subscriptions()

// Subscribe via the collector
subs.subscribe(nameStream) { println("Name: $it") }
subs.subscribe(ageStream) { println("Age: $it") }

// Or use the += operator
subs += counterStream.subscribeToStream { println("Count: $it") }

println(subs.size)  // 3

// Clean up everything at once
subs.unsubscribeAll()

This is especially useful in UI components:

class UserDashboard {
    private val subs = Subscriptions()

    fun mount(userStream: Stream<User>, notificationsStream: Stream<List<Notification>>) {
        subs.subscribe(userStream) { renderHeader(it) }
        subs.subscribe(notificationsStream) { renderNotifications(it) }
    }

    fun unmount() {
        subs.unsubscribeAll()
    }
}

unsubscribeAll() can be called multiple times safely.

Error resilience

A failing subscriber never breaks other subscribers. If one handler throws during a publish cycle, the remaining handlers still receive the value:

val source = StreamSource(1)

source.subscribeToStream { throw RuntimeException("oops") }
source.subscribeToStream { println("Got: $it") }

source(2)  // prints "Got: 2"

The exception is logged as [Streams] ERROR: subscriber threw ... so it shows up in your console during development, but it doesn't take down the stream.

We debated whether to swallow exceptions silently. We didn't. They're logged. You'll see them. You just won't lose your other subscribers over it.

Lazy subscription chains

Operator chains are fully lazy. No work happens until someone subscribes to the end of the chain:

val source = StreamSource(1)

// No subscriptions created yet — this chain is inert
val result = source
    .map { it * 2 }
    .filter(0) { it > 5 }
    .distinct()

// NOW the chain subscribes upstream
val unsub = result.subscribeToStream { println(it) }

// Unsubscribing tears down the entire chain
unsub()
// source has zero subscribers again

Each operator only subscribes to its upstream when it gains its first subscriber, and unsubscribes when its last subscriber leaves. Unused branches cost nothing.

Permanent subscriptions

Sometimes you want a stream to stay subscribed to its upstream forever — for example, a derived stream that must always be ready. Use permanent() to pin a subscription that never tears down:

val source = StreamSource(0)

// Always-active derived stream with a side effect
val logged = source
    .map { it * 2 }
    .permanent { println("Value changed: $it") }

// Or just pin the subscription without a handler
val pinned = source.map { it + 1 }.permanent()

Use this sparingly — permanent subscriptions bypass the lazy teardown that keeps unused branches free.