Project Reactor – Flux<T>
Una guía visual y ejecutable de los operadores de Project Reactor: diagramas de canicas animados, ejemplos en Kotlin listos para correr y retos para predecir la salida.
A Flux is a Reactive Streams Publisher that emits 0 to N elements, and then completes (successfully or with an error). It is the multi-element reactive type in Project Reactor, analogous to Observable in RxJava or Flow in Kotlin Coroutines.
Flux is intended to be used in implementations and return types. Input parameters should keep using raw Publisher as much as possible. If you know the upstream will emit 0 or 1 element, use Mono instead.
In Kotlin with Spring WebFlux, Flux is typically used as the return type for endpoints that stream multiple items, while Mono is used for single-value responses.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
// Simulate a reactive microservice pipeline
data class Order(val id: String, val product: String, val quantity: Int)
fun fetchOrders(): Flux<Order> = Flux.just(
Order("ORD-1", "Laptop", 1),
Order("ORD-2", "Mouse", 5),
Order("ORD-3", "Monitor", 2)
)
fun main() {
// Build a reactive pipeline: fetch → filter → transform → subscribe
fetchOrders()
.filter { it.quantity > 1 } // keep bulk orders
.map { "${it.id}: ${it.quantity}x ${it.product}" } // format
.doOnNext { println("Processing: $it") } // side-effect logging
.subscribe(
{ value -> println("Fulfilled: $value") },
{ error -> println("Pipeline error: ${error.message}") },
{ println("All orders processed!") }
)
}Crear un Flux
Todo pipeline reactivo empieza con una fuente. Reactor trae una familia completa de métodos de fábrica que convierten lo que ya tienes — valores literales, colecciones, Streams de Java, rangos de números, un reloj, callbacks imperativos, incluso "nada en absoluto" — en un Flux. Este capítulo recorre cada fábrica con un ejemplo ejecutable, la salida exacta que produce y los detalles que muerden en código real.
just
Flux.just(vararg data: T): Flux<T> // also Flux.just(data: T): Flux<T>Emite cada valor proporcionado de forma sincrona y en el orden de los argumentos a cada suscriptor, y luego envia onComplete. Es una fuente fria, asi que los mismos valores fijos se reproducen de forma independiente para cada nueva suscripcion. Los valores se capturan de inmediato al llamar a just(), no al suscribirse, y la emision ocurre en el hilo del suscriptor sin asincronia ni retraso integrados.
- Datos simulados o iniciales en pruebas y ejemplos
- Construir el inicio de un pipeline fijo para encadenar map/filter
- Proveer respaldos constantes con switchIfEmpty u onErrorResume
- Emitir unos pocos literales ya conocidos en el momento de la llamada
Los argumentos se evaluan de inmediato en tiempo de ensamblaje, asi que Flux.just(llamadaCostosa()) ejecuta la llamada al instante aunque nadie se suscriba, y el mismo valor capturado se reemite a cada suscriptor en lugar de recalcularse. Para evaluacion perezosa o por suscripcion usa Flux.defer o Mono.fromCallable; just() no puede representar una fuente vacia ni calculada al suscribirse.
Flux.just("A", "B", "C")
.subscribe { value -> println("got " + value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux al suscriptor, en orden, antes de completar?
Flux.just es la puerta de entrada de Reactor: le entregas valores que ya tienes a la mano y los envuelve en un Flux que emite cada uno, en orden, y luego completa. No hay cómputo, ni espera, ni I/O — solo un guion fijo que se reproduce para cada suscriptor.
Úsalo cuando los datos se conocen en el punto de llamada: sembrar datos en pruebas, proveer respaldos constantes o construir la cabeza de un pipeline pequeño sobre el cual encadenar operadores.
import reactor.core.publisher.Flux
fun main() {
// A fixed, already-known set of order lifecycle states
Flux.just("PENDING", "PAID", "SHIPPED", "DELIVERED")
.map { status -> "Order #4711 is now $status" }
.subscribe { println(it) }
}Tenemos cuatro estados del ciclo de vida de una orden, conocidos antes de que el stream siquiera exista. just los emite uno por uno en el orden declarado; map decora cada estado con el número de orden; al final nos suscribimos e imprimimos cada línea. Después de DELIVERED, el Flux emite onComplete.
Order #4711 is now PENDING Order #4711 is now PAID Order #4711 is now SHIPPED Order #4711 is now DELIVERED
Los argumentos se capturan de forma ansiosa, en tiempo de ensamblaje: Flux.just(llamadaCostosa()) ejecuta llamadaCostosa() de inmediato — aunque nadie se suscriba jamás — y cada suscriptor ve ese mismo valor capturado. Para evaluación perezosa o por suscriptor usa Flux.defer o Mono.fromCallable.
fromIterable / fromArray / fromStream / from
Flux.fromIterable(it: Iterable<T>) · fromArray(arr: Array<T>) · fromStream(s: Stream<T>) · from(p: Publisher<T>): Flux<T>Las cuatro adaptan datos existentes a un Flux que emite cada elemento en orden y luego completa. fromIterable y fromArray recorren su fuente de forma perezosa y son totalmente frias: se pide un iterador nuevo por suscripcion, asi que cada suscriptor reproduce la coleccion completa. fromStream drena un Stream de Java (de un solo uso salvo que pases un Supplier<Stream>). from(Publisher) adapta cualquier Publisher de Reactive Streams — Flowables de RxJava, resultados de R2DBC — y devuelve la misma instancia cuando ya es un Flux. La emision respeta el backpressure en todas las variantes.
- Emitir una List, Set o arreglo que ya tienes en memoria
- Drenar un pipeline de Stream de Java (files.lines(), colectores) de forma reactiva
- Adaptar un Publisher de otra biblioteca reactiva (RxJava, R2DBC) con from()
- Repartir una coleccion en trabajo asincrono por elemento con flatMap/concatMap
fromIterable captura una referencia, no una copia: modificar la coleccion durante una suscripcion perezosa emite el contenido modificado o lanza ConcurrentModificationException. Y un Stream de Java se consume exactamente una vez, asi que fromStream(stream) admite un solo suscriptor — una segunda suscripcion falla con IllegalStateException; usa fromStream { construirStream() } (la sobrecarga con Supplier) para una fuente fria y re-suscribible.
val letters = listOf("A", "B", "C", "D")
Flux.fromIterable(letters)
.subscribe(
{ value -> println("next: " + value) },
{ error -> println("error") },
{ println("done") }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dada una lista de letras, ¿qué emite este Flux (y cómo termina)?
La mayoría de los streams no nacen de literales — nacen de datos que ya tienes: una List cargada de un repositorio, un arreglo, un Stream de Java o un Publisher producido por otra biblioteca reactiva. Esta familia de fábricas convierte cada uno de ellos en un Flux: fromIterable para colecciones, fromArray para arreglos, fromStream para Streams de Java y from para cualquier Publisher.
Las cuatro se comportan igual aguas abajo: cada elemento se vuelve un onNext, en orden de iteración, seguido de un único onComplete.
import reactor.core.publisher.Flux
import java.util.stream.Stream
fun main() {
// 1. fromIterable — any List, Set or other Iterable
val cart = listOf("Keyboard", "Mouse", "Monitor")
Flux.fromIterable(cart)
.subscribe { println("In cart: $it") }
// 2. fromArray — a plain array of prices
val prices = arrayOf(49.99, 19.99, 229.0)
Flux.fromArray(prices)
.subscribe { println("Price: $it") }
// 3. fromStream — a Java Stream (consumable only ONCE!)
Flux.fromStream(Stream.of("card", "paypal", "transfer"))
.subscribe { println("Payment method: $it") }
// 4. from — adapt any existing Publisher (here another Flux)
val publisher = Flux.just("EUR", "USD")
Flux.from(publisher)
.subscribe { println("Currency: $it") }
}Tenemos un carrito de compras como List, precios de productos como Array, métodos de pago como Stream de Java y un feed de monedas que ya es un Publisher. Cada fábrica recorre su fuente y emite cada elemento como una señal propia — la lambda del suscriptor imprime cada línea al llegar, y cada mini-stream completa justo después de su último elemento.
In cart: Keyboard In cart: Mouse In cart: Monitor Price: 49.99 Price: 19.99 Price: 229.0 Payment method: card Payment method: paypal Payment method: transfer Currency: EUR Currency: USD
Un Stream de Java solo puede consumirse una vez, así que Flux.fromStream(stream) admite exactamente un suscriptor — una segunda suscripción falla. Cuando necesitas una fuente fría y re-suscribible, usa la variante con proveedor fromStream { construirStream() } para que cada suscriptor reciba un Stream nuevo. Además, Flux.from(publisher) es listo para la interoperabilidad: si le pasas algo que ya es un Flux, lo devuelve tal cual.
range
Flux.range(start: Int, count: Int): Flux<Int>En cada suscripcion, range emite de forma sincrona una secuencia contigua de `count` enteros comenzando en `start` (start, start+1, ..., start+count-1) en estricto orden ascendente, y luego envia onComplete. Es una fuente fria, asi que cada suscriptor recibe su propia secuencia nueva desde el inicio, y la emision respeta la demanda/backpressure del consumidor. Si count es 0 no emite nada y completa de inmediato.
- Generar bucles de tamano fijo o secuencias de indices de forma reactiva en lugar de un for
- Disparar llamadas repetidas, p. ej. obtener N paginas con range(1, pages).flatMap { fetchPage(it) }
- Crear flujos de datos de prueba/demo con valores predecibles
- Producir offsets o IDs para combinar con zip contra otro flujo
El segundo argumento es una CANTIDAD, no un limite final/inclusivo: range(1, 5) emite 1..5, pero range(5, 5) emite 5..9, no 5. Una cantidad negativa lanza IllegalArgumentException, y una cantidad de 0 completa vacio sin error.
Flux.range(1, 5)
.subscribe { println("got " + it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux antes de completar?
range es el for-loop reactivo: dale un entero inicial y una cantidad, y emite esa cantidad de enteros consecutivos, y luego completa. No hay ninguna colección detrás — los números se generan bajo demanda, a medida que el suscriptor los solicita.
Brilla donde necesitas N de algo: números de página por consultar, contadores de reintentos, o índices de asientos y lotes para construir etiquetas.
import reactor.core.publisher.Flux
fun main() {
// Generate the 5 seat numbers of row A: no list needed, just start + count
Flux.range(1, 5)
.map { n -> "A$n" }
.subscribe { seat -> println("Seat $seat reserved") }
}Pedimos 5 enteros comenzando en 1. map convierte cada número pelado en una etiqueta de asiento de la fila A, y el suscriptor imprime una reserva por asiento. Después del asiento A5 el stream completa por sí solo.
Seat A1 reserved Seat A2 reserved Seat A3 reserved Seat A4 reserved Seat A5 reserved
El segundo argumento es una CANTIDAD, no un límite final: range(1, 5) emite 1..5, pero range(5, 5) emite 5..9 — cinco números empezando en 5. Una cantidad de 0 completa vacío sin error; una cantidad negativa lanza IllegalArgumentException.
interval
Flux.interval(period: Duration): Flux<Long> // also interval(delay, period)Inicia un temporizador frio de un solo suscriptor que emite 0L y luego incrementa en 1 cada periodo fijo, de forma indefinida. Por si solo nunca emite onComplete ni onError, asi que el flujo corre para siempre hasta que lo canceles o compongas un operador que lo termine. Los ticks se producen en el Scheduler parallel por defecto, por lo que el trabajo posterior corre fuera del hilo de suscripcion salvo que uses publishOn en otro lado.
- Hacer polling de una API o base de datos con cadencia fija
- Impulsar refrescos periodicos de UI, heartbeats o keep-alives
- Emitir una secuencia de contadores/timestamps para muestreo o throttling
- Simular un reloj o fuente de eventos constante en pruebas
Nunca completa, asi que olvidar take()/takeUntil() deja un temporizador corriendo (fuga). Ademas, si un suscriptor posterior no alcanza el ritmo de los ticks, el comportamiento de backpressure por defecto lanza un OverflowException en lugar de ralentizar el temporizador.
val ticks: Flux<Long> = Flux.interval(Duration.ofSeconds(1))
ticks.take(5)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que emite este Flux?
interval es un reloj convertido en stream: tras cada periodo emite el siguiente número de una secuencia infinita 0, 1, 2, 3… Es la forma estándar de hacer polling a una API, refrescar un dashboard o impulsar un heartbeat.
Dos cosas lo distinguen de todas las fábricas anteriores: es asíncrono (los ticks se producen en el Scheduler parallel, no en tu hilo) y nunca completa por sí solo — casi siempre lo combinas con take(n) o takeUntil.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// Poll a service heartbeat every 200ms, but only 3 times
Flux.interval(Duration.ofMillis(200))
.take(3)
.map { tick -> "heartbeat #${tick + 1}" }
.doOnNext { println(it) }
.blockLast() // interval runs on another thread: wait for it here
}Arrancamos un reloj de 200 ms. take(3) deja pasar solo los tres primeros ticks (0, 1, 2) y luego cancela el temporizador; map renombra cada tick a un número de heartbeat legible. Como los ticks llegan en un hilo del temporizador, main terminaría antes del primer latido — blockLast() detiene el hilo principal hasta que el stream finaliza.
heartbeat #1 heartbeat #2 heartbeat #3
interval nunca completa por sí solo — olvidar take()/takeUntil() deja un temporizador goteando para siempre. Y si el consumidor no alcanza el ritmo del periodo, el backpressure falla rápido con un OverflowException en lugar de frenar el reloj.
empty / error / never
Mono.empty(): Mono<T> · Flux.empty(): Flux<T> · Mono.error(t: Throwable): Mono<T> · Flux.never(): Flux<T>Son publishers degenerados que omiten onNext por completo. empty() emite un unico onComplete de inmediato al suscribirse, sin items; error(e) emite un unico onError(e) de inmediato y propaga ese throwable aguas abajo; never() se suscribe pero nunca emite onNext, onComplete ni onError, dejando el flujo abierto para siempre. error() tambien acepta un Supplier<Throwable> para crear la excepcion de forma perezosa por suscriptor; los tres son frios y repiten su comportamiento fijo a cada suscriptor.
- Devolver empty() desde switchIfEmpty/flatMap para indicar 'sin valor, solo completar'
- Inyectar error() en tests o guardas para simular fallos
- Usar never() en tests para verificar manejo de timeout/cancelacion
- Proveer un placeholder neutro en operadores que esperan un Publisher
never() nunca termina, asi que un suscriptor que la espera (p.ej. block() o una cadena sincrona) se colgara indefinidamente — combinala siempre con timeout() en tests. Ademas, Mono.error(new RuntimeException()) construye la excepcion de forma ansiosa en tiempo de ensamblaje aunque nunca se suscriba; usa Mono.error(() -> ...) (Supplier) para diferir su creacion y evitar compartir un mismo stacktrace entre suscripciones.
Flux.empty<Int>()
.concatWith(Flux.error(RuntimeException("boom")))
.subscribe(
{ println("next: " + it) },
{ println("err: " + it.message) },
{ println("done") }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que imprime este fragmento?
No todo stream tiene datos. empty() completa de inmediato sin emitir nada; error(e) falla de inmediato con tu excepción; never() no hace absolutamente nada — ni valor, ni completado, ni error. Suenan triviales, pero son el vocabulario para "sin resultado", "falló" y "sigue pendiente" en código reactivo.
Los devolverás desde funciones todo el tiempo: empty() como el "not found" del mundo reactivo, error() para señalar fallos sin lanzar excepciones, y never() en pruebas para verificar el manejo de timeouts y cancelaciones.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
fun lookup(id: Int): Mono<String> = when {
id < 0 -> Mono.error(IllegalArgumentException("bad id")) // failed lookup
id == 0 -> Mono.empty() // no result
else -> Mono.just("user-$id")
}
// error -> fallback, empty -> default; never() would hang, so we never subscribe to it
Flux.just(-1, 0, 2)
.flatMap { id ->
lookup(id)
.onErrorResume { Mono.just("<error>") }
.defaultIfEmpty("<none>")
}
.subscribe { println(it) }
}lookup devuelve un publisher degenerado distinto según el id: un error para -1, un vacío para 0 y un valor real para 2. flatMap se suscribe a cada Mono interno: onErrorResume convierte el fallo en "<error>", defaultIfEmpty llena el vacío con "<none>", y user-2 pasa intacto.
<error> <none> user-2
never() nunca termina: cualquier cosa que lo espere bloqueando (block(), una prueba esperando el completado) se cuelga para siempre — en pruebas combínalo siempre con timeout(). Nota además que Flux.error(RuntimeException("boom")) construye la excepción de forma ansiosa en tiempo de ensamblaje; usa la sobrecarga con Supplier error { ... } para crearla perezosamente, una por suscriptor.
create / push
Flux.create(consumer: (FluxSink<T>) -> Unit, backpressure: OverflowStrategy = BUFFER): Flux<T>Recibes un FluxSink y llamas a sink.next(v) para emitir valores, y sink.error(e) o sink.complete() para terminar; aqui next(10), next(20) y next(30) bajan en el orden de llamada y complete() cierra el flujo. create() permite que varios hilos llamen al sink de forma segura (las emisiones se serializan), mientras que push() asume un unico hilo productor. Por defecto ambos almacenan en buffer los items emitidos (OverflowStrategy.BUFFER), asi que un consumidor lento no pierde datos salvo que elijas otra estrategia.
- Conectar APIs de callbacks o listeners (WebSocket, JMS, bus de eventos) a un Flux
- Envolver una fuente push heredada que te entrega valores de forma asincrona
- Emitir desde varios hilos a un mismo stream (usa create, no push)
- Adaptar bucles imperativos de un solo hilo con push para menor sobrecarga
DEBES llamar finalmente a sink.complete() o sink.error(): si lo olvidas, el Flux nunca termina y los suscriptores quedan colgados para siempre. Ademas registra sink.onCancel/onDispose para liberar tu recurso subyacente, ya que create/push no limpian tu fuente de callbacks automaticamente.
val flux: Flux<Int> = Flux.create { sink ->
sink.next(10)
sink.next(20)
sink.next(30)
sink.complete()
}
flux.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este fragmento de Flux.create y como termina?
Tarde o temprano tendrás que conectar una API no reactiva — un listener de WebSocket, un consumidor JMS, un manejador de eventos de GUI — a un Flux. create() te entrega un FluxSink: un control remoto imperativo donde sink.next(v) emite un valor y sink.complete() / sink.error(e) terminan el stream.
push() es la misma API con una restricción: solo un hilo puede llamar al sink. create() serializa emisiones provenientes de cualquier cantidad de hilos; push() se salta esa protección a cambio de un poco menos de sobrecarga.
import reactor.core.publisher.Flux
// A tiny callback-based API — think of a message-broker or WebSocket client
class PriceFeed {
private var listener: ((String) -> Unit)? = null
fun onPrice(callback: (String) -> Unit) { listener = callback }
fun start() {
listOf("BTC = 67,100", "BTC = 67,240", "BTC = 67,180")
.forEach { listener?.invoke(it) }
}
}
fun main() {
val feed = PriceFeed()
// create() hands you a FluxSink: every callback becomes an emission
val prices = Flux.create<String> { sink ->
feed.onPrice { price -> sink.next(price) } // callback -> onNext
feed.start()
sink.complete() // don't forget the terminal!
}
prices.subscribe { println("Tick: $it") }
}PriceFeed es una API de callbacks clásica: registras un listener y ella te llama de vuelta. Dentro de create registramos un listener que reenvía cada callback a sink.next, arrancamos el feed y señalamos complete cuando termina. El suscriptor ve los tres callbacks como un Flux de ticks perfectamente normal.
Tick: BTC = 67,100 Tick: BTC = 67,240 Tick: BTC = 67,180
Debes llamar en algún momento a sink.complete() o sink.error(): si lo olvidas, el Flux nunca termina. Registra también sink.onCancel/onDispose para desconectar tu listener cuando el suscriptor se vaya — create/push no limpian el recurso subyacente automáticamente. Si solo necesitas un handle independiente al cual emitir (sin una API de callbacks que envolver), mira Sinks.many más abajo.
Sinks.many / asFlux
Sinks.many().unicast() / .multicast() / .replay() -> Sinks.Many<T> // tryEmitNext(t): EmitResult · asFlux(): Flux<T>Sinks.many() construye un sink independiente y thread-safe que es a la vez mango productor y stream: el codigo imperativo llama a tryEmitNext / tryEmitComplete / tryEmitError desde cualquier parte, y los consumidores se suscriben a sink.asFlux(). El builder elige el contrato de entrega — unicast() sirve exactamente a un suscriptor y almacena todo hasta que llega; multicast().onBackpressureBuffer() sirve a muchos suscriptores en vivo, almacenando los valores previos a la suscripcion solo para el primero; replay().all()/.limit(n)/.latest() re-entregan el historial a cada suscriptor tardio. Cada tryEmit* devuelve un EmitResult en lugar de lanzar excepciones, haciendo explicito el manejo de fallos.
- Exponer un stream de eventos a nivel de servicio al que empujan handlers REST o listeners
- Reemplazar los Processors deprecados (EmitterProcessor, DirectProcessor, ReplayProcessor)
- Cachear el ultimo estado para suscriptores tardios con replay().latest()
- Conectar codigo imperativo a un Flux cuando no hay una API de callbacks que create() pueda envolver
tryEmitNext nunca lanza excepciones — devuelve un EmitResult, e ignorarlo descarta valores en silencio (FAIL_ZERO_SUBSCRIBER en sinks directos, FAIL_OVERFLOW con buffers llenos, FAIL_TERMINATED tras completar). Productores concurrentes tambien pueden recibir FAIL_NON_SERIALIZED: los sinks detectan hilos en carrera en lugar de bloquear. Revisa el resultado, o usa emitNext(valor, EmitFailureHandler) con una politica de reintento — FAIL_FAST convierte los fallos en excepciones.
val sink = Sinks.many().multicast().onBackpressureBuffer<String>()
sink.tryEmitNext("e1") // before anyone subscribes
sink.asFlux().subscribe { println("A: " + it) } // subscriber A
sink.tryEmitNext("e2")
sink.asFlux().subscribe { println("B: " + it) } // subscriber B (late)
sink.tryEmitNext("e3")🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un sink multicast recibe emisiones antes y después de que cada suscriptor se une. ¿Qué imprime el suscriptor B?
create() envuelve el registro de un callback, pero muchas veces solo quieres un handle independiente al que tu servicio pueda emitir desde cualquier parte: un handler REST empuja una notificación, un listener de Kafka empuja un evento, y quien esté suscrito lo ve. Eso es Sinks — un sink thread-safe que construyes directamente, alimentas con tryEmitNext y expones como stream con asFlux(). Es el reemplazo moderno de las clases Processor deprecadas (EmitterProcessor, ReplayProcessor y compañía).
El builder define el contrato de entrega de forma explícita. Sinks.many().unicast() permite exactamente un suscriptor y almacena todo hasta que llega. .multicast() permite muchos suscriptores en vivo: los valores emitidos antes del primero se almacenan, pero los suscriptores tardíos solo ven lo emitido después de unirse. .replay().all() / .limit(n) / .latest() re-entregan el historial a cada rezagado.
import reactor.core.publisher.Sinks
fun main() {
// A multicast sink: one imperative producer, many live subscribers
val sink = Sinks.many().multicast().onBackpressureBuffer<String>()
val notifications = sink.asFlux()
sink.tryEmitNext("order-1 placed") // no subscriber yet: buffered (warm-up)
notifications.subscribe { println("A got: $it") }
sink.tryEmitNext("order-2 placed") // A is live -> delivered immediately
notifications.subscribe { println("B got: $it") }
sink.tryEmitNext("order-3 placed") // both A and B are live
sink.tryEmitComplete()
// replay().limit(1) keeps history for late subscribers instead
val lastPrice = Sinks.many().replay().limit<Int>(1)
lastPrice.tryEmitNext(101)
lastPrice.tryEmitNext(105)
lastPrice.asFlux().subscribe { println("late subscriber sees: $it") }
}order-1 se emite cuando nadie escucha: multicast().onBackpressureBuffer() lo aparca en un buffer de calentamiento, así que el suscriptor A lo recibe en cuanto se suscribe. order-2 encuentra a A en vivo y se entrega de inmediato. B llega tarde — se perdió las dos primeras señales — así que cuando se emite order-3 tanto A como B lo imprimen, pero B nunca ve order-1 ni order-2. El sink replay().limit(1) guarda el último valor como historial: el suscriptor tardío igual recibe 105.
A got: order-1 placed A got: order-2 placed A got: order-3 placed B got: order-3 placed late subscriber sees: 105
Los métodos tryEmit* nunca lanzan excepciones — devuelven un EmitResult que se supone debes revisar (OK, FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_ZERO_SUBSCRIBER…). Un tryEmitNext lanzado al aire descarta el valor en silencio si falla. Si varios productores compiten puedes recibir FAIL_NON_SERIALIZED: serializa tus productores o usa emitNext(valor, handler) con una política de reintento — el handler incorporado FAIL_FAST convierte los fallos en excepciones.
generate
fun <T, S> Flux.generate(stateSupplier: () -> S, generator: (S, SynchronousSink<T>) -> S): Flux<T>generate construye un Flux frio a partir de un valor de estado mutable y un SynchronousSink: cada peticion (request) del consumidor invoca tu funcion generadora una vez, y puedes llamar a sink.next() como maximo una vez por invocacion, devolviendo opcionalmente el siguiente estado. El diagrama muestra el estado s=0..4 produciendo cada uno un elemento 0..4 en orden estricto, gobernado por backpressure (una emision por peticion), y el flujo termina cuando llamas a sink.complete() (o sink.error()), produciendo la senal terminal C. Como la emision es sincrona y de uno en uno, generate nunca desborda y el hilo lo decide quien suscribe/pide.
- Generar secuencias respetando backpressure (contadores, rangos, cursores paginados)
- Envolver una fuente sincrona basada en pull como un Iterator o un cursor JDBC
- Producir flujos potencialmente infinitos de forma segura sin desbordar
- Mantener estado entre emisiones (p. ej. Fibonacci, acumuladores)
Llamar a sink.next() mas de una vez en una sola invocacion del generador lanza IllegalStateException: generate es estrictamente una senal por llamada, a diferencia de create() que puede emitir muchas. Si tu estado es mutable (una coleccion, un cursor) debes usar la sobrecarga con stateSupplier/consumer para que cada suscriptor obtenga su propio estado fresco; reutilizar estado mutable compartido rompe el contrato de flujo frio/repetible.
val flux: Flux<Int> = Flux.generate({ 0 }) { state, sink ->
sink.next(state)
if (state == 4) sink.complete()
state + 1
}
flux.subscribe { print("$it ") }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que emite este Flux cuando se suscribe?
generate es el hermano disciplinado de create: en lugar de un sink abierto al que puedes martillar a gusto, llama a tu función una vez por cada solicitud del consumidor, y puedes emitir como máximo un valor por llamada. Eso lo hace perfectamente seguro ante backpressure — físicamente no puede rebasar al suscriptor.
Su superpoder es el parámetro de estado: cada invocación recibe el estado devuelto por la anterior, que es exactamente lo que necesitas para contadores, cursores de paginación y secuencias matemáticas.
import reactor.core.publisher.Flux
fun main() {
// Stateful generate: a lazy Fibonacci sequence, one value per invocation
Flux.generate<Long, Pair<Long, Long>>({ 0L to 1L }) { state, sink ->
val (a, b) = state
sink.next(a) // at most ONE next() per call
b to (a + b) // return the state for the next round
}
.take(8) // generate is infinite: demand decides when to stop
.subscribe { println(it) }
}El estado arranca en (0, 1). En cada solicitud la lambda desestructura el par, emite el primer componente y devuelve (b, a + b) como estado para la siguiente ronda. La secuencia es infinita a propósito: take(8) solicita exactamente ocho valores y luego cancela, así que solo se calculan los primeros ocho números de Fibonacci.
0 1 1 2 3 5 8 13
Llamar a sink.next() más de una vez por invocación lanza IllegalStateException — una señal por llamada es todo el contrato (create() es el que puede emitir muchas). Si tu estado es mutable (un cursor, una conexión), usa la sobrecarga (stateSupplier, generator, stateConsumer) para que cada suscriptor reciba estado fresco y el consumidor final pueda cerrar el recurso.
defer
Flux.defer(supplier: () -> Publisher<T>): Flux<T>defer no hace nada hasta la suscripcion: la lambda proveedora se ejecuta una vez por cada Subscriber, construyendo una fuente totalmente nueva para ese suscriptor en el momento de suscribirse, y luego retransmite sus senales de forma transparente. En el diagrama, el suscriptor A obtiene su propia instancia que emite 10, 20, complete; un suscriptor posterior B dispara la lambda de nuevo y recibe una instancia fresca que emite 30, 50, complete. Nada se captura ni se comparte en el momento del ensamblado, asi que cada suscripcion ve valores evaluados de forma independiente y su propia senal terminal.
- Capturar estado por suscriptor como la marca de tiempo actual, el contexto de la peticion o un UUID nuevo al momento de suscribirse
- Envolver la construccion de una fuente ansiosa o bloqueante para que solo corra cuando realmente haya suscripcion
- Forzar la reevaluacion en retry() o repeat() para que cada intento reconstruya la fuente
- Evitar compartir una instancia de fuente mutable entre varios suscriptores
defer solo difiere la construccion del Publisher, no la evaluacion de sus argumentos. Si escribes defer { Mono.just(calcular()) }, calcular() se ejecuta de forma ansiosa cada vez que se dispara la lambda, pero cualquier valor calculado ANTES de defer (fuera de la lambda) se captura una sola vez y se comparte — un error clasico es poner el trabajo fuera de la lambda y obtener el mismo valor obsoleto para cada suscriptor.
var counter = 0
val flux = Flux.defer { Flux.just("v" + (++counter)) }
flux.subscribe { println(it) } // subscriber A
flux.subscribe { println(it) } // subscriber B🧠 Reto: predice la salida
0/1 · 0/1 answered1. Cada suscriptor se vuelve a suscribir al mismo Flux de abajo. Que imprime el SEGUNDO suscriptor, y por que?
defer vuelve perezosas las fuentes: guarda un proveedor y lo ejecuta una vez por suscriptor, en el momento de la suscripción. El Publisher que devuelva el proveedor es el que consume ese suscriptor en particular — así cada suscripción puede ver valores recién calculados.
Es la solución estándar a la trampa de captura ansiosa de just: todo lo que se calcula dentro de la lambda de defer ocurre por suscripción, no una sola vez al ensamblar.
import reactor.core.publisher.Flux
import java.time.LocalTime
fun main() {
// Eager: the timestamp is captured ONCE, when the Flux is assembled
val eager = Flux.just(LocalTime.now().toString())
// Lazy: the supplier runs again for EVERY subscriber
val lazy = Flux.defer { Flux.just(LocalTime.now().toString()) }
eager.subscribe { println("Eager 1: $it") }
Thread.sleep(1000)
eager.subscribe { println("Eager 2: $it") } // same value!
lazy.subscribe { println("Lazy 1: $it") }
Thread.sleep(1000)
lazy.subscribe { println("Lazy 2: $it") } // one second later!
}Las dos suscripciones ansiosas imprimen la misma marca de tiempo: LocalTime.now() corrió exactamente una vez, cuando se ensambló Flux.just. El Flux perezoso ejecuta su proveedor en cada subscribe, así que Lazy 1 y Lazy 2 — con un segundo de diferencia — imprimen horas distintas. La misma forma de código, un modelo de evaluación completamente diferente.
Eager 1: 11:51:16.169791 Eager 2: 11:51:16.169791 Lazy 1: 11:51:17.243813 Lazy 2: 11:51:18.248844
defer difiere la construcción del Publisher, no la evaluación de tus argumentos: defer { Flux.just(calcular()) } ejecuta calcular() de nuevo por cada suscriptor, pero un valor calculado FUERA de la lambda se captura una sola vez y se comparte — reintroduciendo en silencio la obsolescencia que querías evitar. defer además combina de maravilla con retry()/repeat(), que se resuscriben y por lo tanto reconstruyen la fuente en cada intento.
Transforming
Los operadores de transformación remodelan los elementos que fluyen por un Flux: convierten strings crudos en objetos de dominio, despliegan un usuario en sus pedidos o pliegan un flujo de transacciones en un saldo acumulado. Son el pan de cada día de la programación reactiva: casi todo pipeline real empieza con alguno de ellos.
La familia de map se presta a confusiones, así que este es el modelo mental desde el inicio:
- map — síncrono y 1 a 1: entra un valor, sale exactamente uno.
- flatMap — despliegue asíncrono: fusiona los publishers internos a medida que emiten; el más rápido, pero sin garantía de orden.
- concatMap — asíncrono, un interno a la vez: orden estricto de la fuente, a costa de latencia.
- flatMapSequential — despliegue asíncrono con salida ordenada: se suscribe con anticipación y usa buffers para restaurar el orden de la fuente.
- switchMap — gana el último: cada valor nuevo cancela el publisher interno anterior.
map / mapNotNull
map es el operador que vas a usar con más frecuencia: toma cada valor que fluye por el Flux y le aplica una función síncrona común y corriente — entra un valor, sale exactamente uno, en el mismo orden. Piensa en una banda transportadora donde a cada caja se le pega una etiqueta al pasar. mapNotNull es el hermano pragmático: aplica la función y descarta en silencio los resultados null, fusionando un map y un filter en un solo paso.
fun <T, R> Flux<T>.map(mapper: (T) -> R): Flux<R>map aplica una funcion sincrona 1-a-1 a cada valor onNext, emitiendo exactamente una salida por cada entrada y en el mismo orden ([1,2,3,4,5] -> [10,20,30,40,50]). Nunca agrega, descarta ni reordena elementos, asi que la cantidad se conserva y la senal terminal onComplete pasa intacta. El mapper se ejecuta en linea sobre el hilo emisor; si lanza una excepcion, esta se convierte en una senal onError que termina la secuencia.
- Convertir DTOs a objetos de dominio/entidad en un flujo
- Extraer o transformar un campo de cada elemento emitido
- Aplicar transformaciones puras de calculo o formato (p. ej. * 10, toUpperCase)
- Normalizar tipos antes de un operador posterior
map es solo para transformaciones sincronas — si tu mapper devuelve un Publisher (Mono/Flux) o hace E/S asincrona o bloqueante, usa flatMap/concatMap en su lugar; de lo contrario obtendras un Flux<Mono<R>> o bloquearas el event loop. Si el mapper devuelve null, map lanza un NullPointerException (usa mapNotNull para filtrar nulls de forma segura).
Flux.just(1, 2, 3, 4, 5)
.map { it * 10 }
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux, en que orden y como termina?
import reactor.core.publisher.Flux
data class User(val name: String, val age: Int)
fun main() {
// 1-to-1 transform: square each number
Flux.just(1, 2, 3, 4, 5)
.map { it * it }
.subscribe { println("Squared: $it") }
// Map raw lines into domain objects
Flux.just("Alice:30", "Bob:25", "Charlie:35")
.map { line ->
val (name, age) = line.split(":")
User(name, age.toInt())
}
.subscribe { println("User: ${it.name}, Age: ${it.age}") }
// mapNotNull: transform and drop nulls in one step
Flux.just("1", "two", "3", "four", "5")
.mapNotNull { it.toIntOrNull() }
.subscribe { println("Parsed: $it") }
}Corremos tres pipelines pequeños. El primero eleva cada número al cuadrado: entran del 1 al 5 y salen sus cuadrados, uno por uno y en orden. El segundo toma strings crudos "nombre:edad" y mapea cada línea a un objeto User tipado — la clásica conversión de DTO a dominio. El tercero intenta parsear cada string con toIntOrNull(): "two" y "four" producen null, así que mapNotNull los descarta y solo sobreviven 1, 3 y 5. Al final nos suscribimos e imprimimos cada resultado:
Squared: 1 Squared: 4 Squared: 9 Squared: 16 Squared: 25 User: Alice, Age: 30 User: Bob, Age: 25 User: Charlie, Age: 35 Parsed: 1 Parsed: 3 Parsed: 5
map es solo para transformaciones síncronas. Si tu función devuelve un Mono o un Flux (una llamada HTTP, una consulta a la base de datos), map te entregaría un incómodo Flux<Mono<R>> — usa flatMap en su lugar. Y si el mapper devuelve null, map lanza un NullPointerException: usa mapNotNull siempre que esperes nulls.
flatMap
flatMap es la forma de hacer trabajo asíncrono por elemento: mapea cada valor a un Publisher (normalmente una llamada de red o de base de datos), se suscribe a todos esos publishers internos con anticipación y fusiona sus resultados en un solo Flux a medida que llegan. Es el más rápido de los operadores de mapeo asíncrono precisamente porque nunca espera: el que responde primero se emite primero.
Dos extras útiles: un segundo argumento opcional limita cuántos internos corren a la vez — flatMap(mapper, 4) mantiene como máximo cuatro llamadas en vuelo — y la variante flatMapDelayError sigue fusionando los internos restantes cuando uno falla, entregando el error recién al final.
fun <T, R> Flux<T>.flatMap(mapper: (T) -> Publisher<R>): Flux<R>flatMap mapea cada elemento de la fuente a un Publisher interno y se suscribe a esos internos de forma anticipada y concurrente (hasta una concurrencia configurable, 256 por defecto). Los valores de los publishers internos se fusionan en un solo Flux a medida que llegan, por lo que las emisiones pueden intercalarse y el orden de salida NO sigue el orden de la fuente. El resultado completa solo cuando la fuente y todos los internos completan; cualquier error interno se propaga y (por defecto) termina todo el flujo.
- Lanzar muchas llamadas async independientes (HTTP/DB) en paralelo y fusionar resultados
- Maximizar el rendimiento cuando el orden por elemento no importa
- Expandir cada item en un flujo de items hijos (uno a muchos)
- Limitar la concurrencia en vuelo con el parametro concurrency
El orden NO se conserva: como los internos corren concurrentemente y se intercalan, el orden de salida es no determinista. Si necesitas el orden de la fuente usa concatMap (secuencial) o flatMapSequential (descarga concurrente, emision ordenada). Ademas, el limite de concurrencia puede frenar el trabajo interno, causando backpressure inesperado.
fun inner(x: String): Flux<String> = Flux.just(x + "1", x + "2")
Flux.just("A", "B")
.flatMap { inner(it) }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado que inner(x) emite dos elementos, ¿qué emite este pipeline?
import reactor.core.publisher.Flux
import java.time.Duration
// Each user's orders arrive from a (simulated) remote service
fun fetchOrders(user: String, latencyMs: Long): Flux<String> =
Flux.just("$user-order-1", "$user-order-2")
.delayElements(Duration.ofMillis(latencyMs))
fun main() {
Flux.just("ana", "luis", "sofia")
.flatMap { user ->
val latency = when (user) {
"ana" -> 120L // slowest service
"luis" -> 80L
else -> 50L // fastest service
}
fetchOrders(user, latency)
}
.doOnNext { println(it) }
.blockLast()
}Desplegamos tres usuarios hacia un servicio de pedidos simulado donde cada uno tiene una latencia distinta: sofia responde cada 50 ms, luis cada 80 ms y ana cada 120 ms. flatMap se suscribe a los tres Flux internos de inmediato, así que la salida fusionada queda ordenada por tiempo de llegada, no por el orden de la fuente — el primer pedido de sofia le gana a todos aunque ella fue la última usuaria emitida. Bloqueamos en el último elemento para que main no termine antes de tiempo. Una ejecución real imprimió:
sofia-order-1 luis-order-1 sofia-order-2 ana-order-1 luis-order-2 ana-order-2
Si el código aguas abajo depende de recibir los resultados en el orden de la fuente, flatMap es la herramienta equivocada: usa concatMap (estrictamente secuencial) o flatMapSequential (descarga en paralelo, emisión ordenada). Elige flatMap cuando el rendimiento importe más que el orden.
flatMapSequential
flatMapSequential responde a la pregunta "¿puedo tener la velocidad de flatMap con el orden de concatMap?". Se suscribe a cada publisher interno con anticipación, así que el trabajo asíncrono corre en paralelo — pero guarda en buffer los resultados de los internos que terminan antes y lo reproduce todo estrictamente en el orden de la fuente. Lo mejor de ambos mundos, pagado con memoria.
fun <T, R> Flux<T>.flatMapSequential(mapper: (T) -> Publisher<R>): Flux<R>Se suscribe a todos los publishers internos de forma anticipada y concurrente (como flatMap), por lo que el trabajo lento corre en paralelo, pero almacena en buffer las emisiones de cada inner y las reproduce estrictamente en el orden en que la fuente emitio los elementos originales. Asi, inner(1) siempre se vacia por completo antes que inner(2), aunque inner(2) haya terminado primero. El flujo completa solo cuando la fuente y todos los inners completaron, y cualquier error de un inner se propaga.
- Lanzar I/O en paralelo (llamadas HTTP/DB) pero necesitar resultados en el orden de entrada
- Enriquecer una lista ordenada de IDs de forma concurrente sin reordenar la salida
- Querer la garantia de orden de concatMap pero sin poder pagar su latencia secuencial
- Procesamiento por lotes donde importa la velocidad pero lo de abajo asume el orden de la fuente
El orden se preserva a costa de buffering: si un inner temprano es lento, los valores de inners posteriores que ya terminaron se retienen en memoria y no pueden emitirse hacia abajo hasta que el anterior complete, lo que puede aumentar la memoria y agregar latencia. NO reduce la concurrencia como concatMap, asi que no serializa el trabajo real de suscripcion.
fun inner(n: Int): Flux<String> =
Flux.just(n.toString() + "a", n.toString() + "b")
Flux.just(1, 2, 3)
.flatMapSequential { inner(it) }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado el fragmento, que imprime el suscriptor y en que orden?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun main() {
val start = System.currentTimeMillis()
Flux.just("slow", "fast", "medium")
.flatMapSequential { item ->
val delay = when (item) {
"slow" -> 300L
"fast" -> 50L
else -> 150L
}
Mono.just(item.uppercase()).delayElement(Duration.ofMillis(delay))
}
.doOnNext { println("$it after ${System.currentTimeMillis() - start} ms") }
.blockLast()
println("total: ${System.currentTimeMillis() - start} ms")
}Tres tareas con latencias muy distintas: slow (300 ms), fast (50 ms) y medium (150 ms). Los tres Monos con retardo arrancan al mismo tiempo. FAST está listo a los ~50 ms, pero flatMapSequential lo retiene hasta que SLOW — el primer elemento de la fuente — haya emitido. Por eso las tres líneas se imprimen juntas alrededor de los 300 ms, y el pipeline completo tarda unos 300 ms en vez de 500 ms (la suma), demostrando que el trabajo sí corrió en paralelo:
SLOW after 369 ms FAST after 370 ms MEDIUM after 370 ms total: 370 ms
La garantía de orden cuesta memoria: mientras un interno temprano sigue corriendo, los resultados de internos posteriores que ya terminaron esperan en un buffer. Con muchos elementos y latencias desparejas ese buffer puede crecer bastante — vigílalo en despliegues grandes.
concatMap
concatMap es el secuenciador estricto: mapea cada elemento a un publisher interno pero se suscribe a ellos de a uno, vaciando por completo cada interno antes de tocar el siguiente. Sin entrelazado y con orden de la fuente garantizado — ideal cuando cada paso debe completarse antes de que empiece el siguiente, como escrituras ordenadas en base de datos o llamadas a APIs que dependen una de otra.
fun <T, V> Flux<T>.concatMap(mapper: (T) -> Publisher<out V>): Flux<V>Por cada elemento de la fuente, concatMap se suscribe al Publisher interno que devuelve el mapper, lo consume por completo y recien entonces se suscribe al interno del siguiente elemento. Como los internos se ejecutan estrictamente de a uno, el orden de salida refleja exactamente el orden de la fuente y no hay entrelazado (a1,a2 de A salen antes que b1,b2 de B). El onComplete hacia abajo se emite solo cuando la fuente termina y el ultimo interno finaliza; un error en cualquier interno aborta la secuencia de inmediato (usa concatMapDelayError para diferir los errores al final).
- Trabajo asincrono ordenado donde el elemento N debe terminar antes que N+1 (llamadas HTTP secuenciales, paginacion)
- Escrituras o migraciones en base de datos que deben aplicarse en orden estricto de la fuente
- Evitar el entrelazado cuando cada interno emite varios valores
- Limitar la carga garantizando que solo haya una suscripcion interna activa a la vez
concatMap no tiene concurrencia: cada interno bloquea al siguiente, asi que un solo interno lento frena todo y la latencia total es la suma de las latencias de cada interno. Si necesitas resultados ordenados pero ejecucion en paralelo, usa flatMapSequential (corre los internos en paralelo y reordena la salida al orden de la fuente).
fun inner(name: String): Flux<String> =
Flux.just(name + "1", name + "2")
Flux.just("a", "b")
.concatMap { inner(it) }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dada esta tuberia de Reactor, que imprime el suscriptor y en que orden?
import reactor.core.publisher.Flux
fun main() {
// Each order goes through two warehouse steps, strictly one order at a time
Flux.just("burger", "fries")
.concatMap { item ->
Flux.just("$item: picked", "$item: packed")
}
.subscribe { println(it) }
}Tenemos un flujo con dos pedidos de cocina. concatMap mapea cada uno a un Flux interno con sus dos pasos de bodega: picked y luego packed. Como concatMap vacía cada interno por completo antes de suscribirse al siguiente, la hamburguesa se recoge y se empaca antes de siquiera empezar con las papas — las emisiones nunca pueden entrelazarse:
burger: picked burger: packed fries: picked fries: packed
La garantía de orden tiene un precio: la latencia se suma. Aquí cada llamada de enriquecimiento tarda 100 ms y concatMap las ejecuta estrictamente una tras otra:
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun enrich(sku: String): Mono<String> =
Mono.just("$sku [enriched]").delayElement(Duration.ofMillis(100))
fun main() {
val start = System.currentTimeMillis()
Flux.just("A", "B", "C")
.concatMap { enrich(it) }
.doOnNext { println(it) }
.blockLast()
println("total: ${System.currentTimeMillis() - start} ms")
}A [enriched] B [enriched] C [enriched] total: 470 ms
flatMapIterable
A veces cada elemento ya trae a sus hijos en memoria — un pedido con sus líneas, un post con sus etiquetas. flatMapIterable toma cada valor, le pide un Iterable y aplana todos sus elementos dentro del flujo de forma síncrona y en orden. Sin publishers internos, sin concurrencia, sin sorpresas. concatMapIterable es un alias con la nomenclatura de concat — para iterables en memoria el comportamiento es el mismo.
fun <T, R> Flux<T>.flatMapIterable(mapper: (T) -> Iterable<R>): Flux<R>Cada valor de la fuente se pasa al mapper, que devuelve un Iterable; ese iterable se recorre de forma sincrona y todos sus elementos se emiten aguas abajo antes de procesar el siguiente valor. Como el aplanado es secuencial y sincrono, el orden de salida sigue estrictamente el orden de la fuente (los items de A y luego los de B): no hay concurrencia, ni entrelazado, ni un Publisher interno al que suscribirse. Las senales terminales de la fuente se propagan — el onComplete solo se emite tras agotar el iterable del ultimo valor, y un onError (de la fuente o lanzado dentro del mapper o la iteracion) termina el Flux.
- Expandir cada entidad en su coleccion de hijos en memoria (order.items, post.tags)
- Aplanar un Flux de listas o arrays en un Flux de elementos individuales
- Dividir un valor sincrono (un String en caracteres, una fila CSV en campos) sin sobrecarga asincrona
- Desplegar hacia una coleccion cuando los hijos ya estan cargados, no obtenidos reactivamente
El mapper debe devolver un Iterable plano, no un Publisher — si tus hijos provienen de una llamada asincrona o reactiva (BD, HTTP), flatMapIterable no se suscribira a nada; usa flatMap o concatMap en su lugar. Ademas, el iterable completo de cada valor se extrae y almacena de forma anticipada, asi que un Iterable enorme o infinito puede acaparar la emision e inflar la memoria.
data class Order(val items: List<String>)
Flux.just(Order(listOf("a1", "a2")), Order(listOf("b1", "b2")))
.flatMapIterable { it.items }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado el siguiente fragmento, ¿qué emite el Flux resultante (en orden)?
import reactor.core.publisher.Flux
data class Order(val id: Int, val items: List<String>)
fun main() {
Flux.just(
Order(1, listOf("book", "pen")),
Order(2, listOf("lamp"))
)
.flatMapIterable { it.items }
.subscribe { println("item: $it") }
// Works with any Iterable: split sentences into words
Flux.just("hello world", "reactive kotlin")
.flatMapIterable { it.split(" ") }
.map { it.uppercase() }
.subscribe { println(it) }
}El primer pipeline es un flujo de dos pedidos, cada uno con sus líneas en un List común. flatMapIterable extrae los items de cada pedido y los emite uno por uno: los dos items del pedido 1 salen antes que los del pedido 2, siempre. El segundo pipeline muestra que funciona con cualquier Iterable — cada oración se divide en palabras, que luego fluyen por el resto de la cadena como elementos individuales:
item: book item: pen item: lamp HELLO WORLD REACTIVE KOTLIN
El mapper debe devolver un Iterable plano, no un Publisher. Si los hijos provienen de una llamada asíncrona (base de datos, HTTP), flatMapIterable es el operador equivocado — usa flatMap o concatMap para que el publisher interno realmente reciba una suscripción.
switchMap
switchMap modela "solo importa lo último": cada nuevo valor de la fuente cancela el publisher interno lanzado por el anterior y cambia a un interno nuevo. Es EL operador para búsqueda mientras escribes, autocompletado y filtrado en vivo — en cualquier lugar donde los resultados de una consulta vieja sean peores que no tener resultados.
Su primo estático switchOnNext hace el mismo cambio sobre un Flux de publishers que ya tienes; y si lo que necesitas es bifurcar todo el pipeline según el primer elemento, eso es switchOnFirst, que viene a continuación.
fun <T, V> Flux<T>.switchMap(mapper: (T) -> Publisher<out V>): Flux<V>Por cada elemento de la fuente, el mapper produce un Publisher interno y switchMap se suscribe a el, pero en cuanto llega el siguiente elemento cancela el interno actual antes de suscribirse al nuevo. Solo los valores del interno mas reciente llegan aguas abajo, asi que las emisiones siguen el orden de la fuente pero se descartan los valores en vuelo (aun no emitidos) de los internos reemplazados. El flujo externo completa solo cuando la fuente completa Y el ultimo interno completa; un error de cualquiera de los dos se propaga aguas abajo.
- Busqueda mientras se escribe donde solo importa la ultima consulta
- Autocompletado o filtrado en vivo ligado a entrada rapida del usuario
- Recargar datos segun la seleccion mas reciente (pestana, ruta, filtro)
- Cualquier flujo 'gana el ultimo' donde hay que descartar resultados obsoletos
switchMap cancela el interno anterior en el instante en que llega un nuevo elemento, asi que los internos lentos pueden no emitir nada y sus efectos secundarios (llamadas HTTP, escrituras a BD) pueden abortarse a mitad de camino — nunca lo uses cuando cada interno deba completarse; usa flatMap o concatMap en su lugar.
fun inner(n: Int): Flux<String> =
Flux.just(n.toString() + "a", n.toString() + "b").delayElements(Duration.ofMillis(50))
Flux.just(1, 2, 3)
.switchMap { inner(it) }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Cada valor de la fuente se mapea a un Flux interno que emite "<n>a" y luego "<n>b" con un retardo. Que imprime el pipeline?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun search(query: String): Mono<String> =
Mono.just("results for '$query'").delayElement(Duration.ofMillis(150))
fun main() {
// Three keystrokes: two in quick succession, then a pause before the last
val keystrokes = Flux.concat(
Mono.just("re"),
Mono.just("rea").delayElement(Duration.ofMillis(80)),
Mono.just("react").delayElement(Duration.ofMillis(300))
)
keystrokes
.switchMap { query -> search(query) }
.doOnNext { println(it) }
.blockLast()
}Simulamos tres teclas: "re" de inmediato, "rea" 80 ms después, y una pausa de 300 ms antes de "react". Cada tecla dispara una búsqueda de 150 ms. La búsqueda de "re" sigue en vuelo cuando "rea" llega a los 80 ms, así que switchMap la cancela — "re" nunca produce resultados. La búsqueda de "rea" termina a los ~230 ms, antes de que "react" llegue a los ~380 ms, así que sus resultados SÍ pasan. Al final, "react" ejecuta su búsqueda sin interrupciones:
results for 'rea' results for 'react'
La cancelación aborta los efectos secundarios del interno a mitad de camino. Nunca uses switchMap para trabajo que debe completarse — pagos, escrituras, publicación de mensajes — porque un siguiente elemento más rápido lo matará en silencio. Para eso, usa flatMap o concatMap.
switchOnFirst
fun <T, V> Flux<T>.switchOnFirst(transformer: (Signal<T>, Flux<T>) -> Publisher<V>): Flux<V>Cuando llega la primera señal de la secuencia (onNext, onComplete u onError), switchOnFirst invoca tu transformador exactamente una vez, pasándole esa Signal más un Flux que representa la secuencia COMPLETA — primer elemento incluido. El Publisher que devuelva el transformador se convierte en el pipeline aguas abajo: suscríbete al Flux recibido para seguir procesando los elementos (saltando o reutilizando el primero según te convenga), o devuelve algo totalmente distinto para abortar, degradar o redirigir. Desde ahí los valores fluyen por la rama elegida sin buffering adicional.
- Parsear un flujo CSV/NDJSON cuyo encabezado define cómo leer el resto
- Enrutar por byte de protocolo/versión: el primer frame decide el decodificador de la conexión
- Fallar rápido o degradar cuando el primer elemento revela un feed vacío o inválido
- Aplicar configuración por flujo (esquema, contexto de auth) derivada del primer mensaje
La primera señal no siempre es un valor: para una fuente vacía es onComplete y para un fallo inmediato es onError, así que revisa signal.hasValue() antes de llamar a get(). Además, el transformador debe devolver un pipeline que se suscriba al Flux recibido como máximo una vez — y recuerda que la secuencia que recibe INCLUYE el primer elemento, así que aplícale skip(1) si ya lo consumiste desde la Signal.
Flux.just("id,name", "1,Ana", "2,Luis")
.switchOnFirst { first, rows ->
if (first.get()?.startsWith("id") == true)
rows.skip(1).map { "row: $it" }
else
rows.map { "raw: $it" }
}
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. El primer elemento decide la rama. ¿Qué imprime este pipeline?
switchOnFirst te deja mirar la primera señal de una secuencia y decidir, ahí mismo, cómo debe ser el resto del pipeline. Tu transformador recibe dos cosas: la primera Signal (un valor, una finalización o un error) y la secuencia completa — primer elemento incluido — y devuelve el Publisher con el que continuar. El caso de uso clásico: un flujo CSV donde la fila de encabezado define cómo parsear cada fila siguiente.
import reactor.core.publisher.Flux
fun main() {
// A CSV file as a stream: first the header row, then the data rows
val csv = Flux.just(
"id,name,city",
"1,Ana,Bogota",
"2,Luis,Lima"
)
csv.switchOnFirst { first, rows ->
// `first` is the header signal; `rows` is the WHOLE sequence (header included)
val columns = first.get()?.split(",") ?: emptyList()
rows.skip(1).map { line ->
columns.zip(line.split(","))
.joinToString(", ") { (col, value) -> "$col=$value" }
}
}.subscribe { println(it) }
}El primer elemento del flujo es el encabezado "id,name,city". switchOnFirst le entrega a nuestra lambda esa primera señal más la secuencia completa de filas. Dividimos el encabezado en nombres de columna y construimos el pipeline bifurcado: skip(1) descarta el propio encabezado, y map combina cada fila de datos con los nombres de columna para producir registros etiquetados. La decisión ocurre una sola vez, cuando llega el primer elemento — después de eso las filas de datos fluyen directo por el nuevo pipeline:
id=1, name=Ana, city=Bogota id=2, name=Luis, city=Lima
El transformador se ejecuta una vez por suscripción, cuando llega la primera señal — no una vez por elemento. Esa primera señal no siempre es un valor: si la fuente completa vacía o falla de inmediato, first.hasValue() es false, así que cúbrete (aquí el respaldo ?: emptyList()). Y si solo necesitas saber "¿es este el primer elemento?", con index() o una bandera simple alcanza — switchOnFirst es para cuando la FORMA del pipeline depende del primer valor.
handle
handle es la transformación navaja suiza: por cada elemento recibes un SynchronousSink y decides qué pasa — llamas a sink.next(v) para emitir un valor (posiblemente transformado), no haces nada para descartarlo en silencio, o llamas a sink.complete() / sink.error() para terminar el flujo ahí mismo. Es map, filter y terminación anticipada fusionados en un solo operador.
fun <T, R> Flux<T>.handle(handler: (T, SynchronousSink<R>) -> Unit): Flux<R>Por cada valor del upstream se ejecuta tu BiConsumer, que puede llamar a sink.next(x) para emitir un valor (posiblemente transformado), no hacer nada para descartarlo en silencio, o llamar a sink.complete()/sink.error() para terminar la secuencia. Es uno-a-cero-o-uno y estrictamente secuencial: los valores se procesan en orden en el hilo del upstream, y el sink puede emitir como mucho un elemento por invocacion. En el marble, 1,2,3,4,5 se mapean a 10,30,50 (los pares 2 y 4 se descartan) y un sink.complete() explicito termina el flujo antes de tiempo.
- Mapear y filtrar en un solo paso cuando la decision de mantener/descartar y la transformacion van juntas
- Emitir un valor solo cuando el parseo/validacion tiene exito, descartando entradas invalidas
- Terminar el flujo antes de tiempo ante un valor centinela con sink.complete()
- Convertir un fallo recuperable en onError sin lanzar excepciones desde un map
El sink es sincrono y de un solo uso por llamada: puedes invocar sink.next() como mucho una vez por elemento (una segunda llamada lanza excepcion), y no puede pasarse a otro hilo para emitir de forma asincrona. Para emitir cero/uno de forma asincrona, usa flatMap con un Mono en su lugar.
Flux.just(1, 2, 3, 4, 5)
.handle<Int> { v, sink ->
if (v % 2 != 0) sink.next(v * 10)
}
.subscribe { println("got " + it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux (en orden) antes de completar?
import reactor.core.publisher.Flux
fun main() {
// handle: map + filter + early completion in a single operator
Flux.range(1, 10)
.handle<String> { value, sink ->
when {
value > 7 -> sink.complete() // stop the stream early
value % 2 == 0 -> sink.next("Even: $value") // emit transformed
// odd values below 7: no sink.next -> silently dropped
}
}
.subscribe { println(it) }
}Tenemos los números del 1 al 10. Para cada uno el sink decide: los valores mayores a 7 disparan sink.complete(), terminando el flujo antes de que 8, 9 y 10 lleguen a emitirse; los pares se transforman y se emiten; los impares menores a 7 simplemente atraviesan el when sin un sink.next, así que se descartan. El suscriptor solo ve los pares transformados:
Even: 2 Even: 4 Even: 6
handle brilla en pipelines de "parsear o descartar", donde una transformación fallida simplemente significa "salta este":
import reactor.core.publisher.Flux
fun main() {
Flux.just("12.50", "abc", "8.00", "xyz")
.handle<Double> { raw, sink ->
val price = raw.toDoubleOrNull()
if (price != null) sink.next(price) // parse or silently drop
}
.subscribe { println("price: $$it") }
}price: $12.5 price: $8.0
El sink es síncrono y de un solo disparo: puedes llamar a sink.next() como máximo una vez por elemento (una segunda llamada lanza excepción), y no puedes pasar el sink a otro hilo para emitir de forma asíncrona. Si necesitas emitir cero-o-uno de forma asíncrona, usa flatMap devolviendo un Mono.
cast / ofType
fun <U> Flux<*>.ofType(clazz: Class<U>): Flux<U> // cast(clazz): Flux<U>ofType(type) examina cada valor onNext con type.isInstance() y reemite solo los elementos asignables al tipo dado, descartando el resto en silencio y conservando el orden original; aqui "a" y "b" se filtran y 1, 2, 3 pasan. Las senales terminales onComplete y onError siempre se propagan sin cambios, y no se modifica nada del scheduling ni del hilo. cast(type) es el hermano sin verificacion: deja pasar todos los elementos pero reinterpreta su tipo, lanzando ClassCastException en tiempo de ejecucion ante cualquier discrepancia.
- Reducir un Flux<Object> o flujo de eventos heterogeneo a un subtipo concreto
- Reaccionar a clases de evento especificas en event-sourcing o un bus de mensajes
- Hacer downcast seguro tras una fuente polimorfica en vez de filter + cast manual
- Descartar elementos no relacionados por tipo sin escribir un predicado
ofType descarta en silencio los elementos que no coinciden, asi que un tipo equivocado puede dejar el flujo misteriosamente vacio sin error. cast hace lo contrario: nunca filtra y revienta con ClassCastException en el primer elemento que no coincide, por eso conviene ofType cuando la fuente es realmente mixta. Ademas, ninguno gestiona sorpresas de autoboxing (p. ej. ofType(Integer.class) no coincide con un Long).
Flux.just(1, "a", 2, "b", 3)
.ofType(Integer::class.java)
.subscribe { value -> println(value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado este Flux de tipos mezclados, ¿qué recibe el suscriptor?
Cuando un Flux transporta tipos mezclados — un bus de eventos polimórfico, un Flux<Any> heredado — ofType(clazz) conserva solo los elementos que son instancias de la clase dada y los castea por ti, descartando en silencio todo lo demás. cast(clazz) es el hermano sin verificación: deja pasar todos los elementos y solo reinterpreta el tipo, lanzando un ClassCastException ante la primera discrepancia.
import reactor.core.publisher.Flux
open class Animal(val name: String)
class Dog(name: String, val breed: String) : Animal(name)
class Cat(name: String, val indoor: Boolean) : Animal(name)
fun main() {
val animals: Flux<Animal> = Flux.just(
Dog("Rex", "Shepherd"),
Cat("Whiskers", true),
Dog("Buddy", "Labrador"),
Cat("Luna", false)
)
// ofType: filter and cast in one safe step
animals.ofType(Dog::class.java)
.subscribe { println("Dog: ${it.name}, Breed: ${it.breed}") }
animals.ofType(Cat::class.java)
.subscribe { println("Cat: ${it.name}, Indoor: ${it.indoor}") }
}Un flujo de refugio mezcla Dogs y Cats detrás del tipo común Animal. La primera suscripción lo reduce a Dogs — las dos Cats se descartan, y el código aguas abajo puede leer .breed sin ningún cast manual. Como animals es un Flux frío, la segunda suscripción reproduce los cuatro animales de nuevo y lo reduce a Cats. Cada suscriptor ve solo su propia especie, en el orden de la fuente:
Dog: Rex, Breed: Shepherd Dog: Buddy, Breed: Labrador Cat: Whiskers, Indoor: true Cat: Luna, Indoor: false
El mismo truco ordena flujos de eventos sin tipar — extrae solo los comandos e ignora el ruido numérico:
import reactor.core.publisher.Flux
fun main() {
val events: Flux<Any> = Flux.just(42, "deploy", 3.14, "rollback", 7)
// Keep only the String commands — everything else is silently dropped
events.ofType(String::class.java)
.map { it.uppercase() }
.subscribe { println("command: $it") }
}command: DEPLOY command: ROLLBACK
ofType descarta en silencio, así que un tipo objetivo equivocado deja el flujo misteriosamente vacío y sin error — mientras que cast nunca filtra y revienta con ClassCastException en la primera discrepancia. Prefiere ofType para fuentes realmente mixtas, y cast solo cuando sepas que todos los elementos ya tienen el tipo objetivo. Ojo también con el boxing: ofType(Integer::class.java) no coincide con un Long.
scan
scan es un reduce con comentarista en vivo: pliega el flujo con una función acumuladora pero emite cada resultado intermedio, no solo el final. Eso lo hace perfecto para totales acumulados, estadísticas en vivo o flujos de estado estilo reducer. Con semilla, la propia semilla se emite primero; la variante scanWith recibe un supplier, así cada suscriptor obtiene una semilla nueva creada de forma perezosa.
fun <T, A> Flux<T>.scan(initial: A, accumulator: BiFunction<A, in T, A>): Flux<A>scan emite de inmediato el valor inicial (la semilla) y luego, por cada valor de la fuente, aplica accumulator(acumuladorActual, valor) y emite el nuevo acumulador. Así una fuente de 3 elementos produce la semilla mas 3 emisiones (4 en total con semilla). Con semilla 0 y acc+x sobre [1,2,3] emite 1, 3, 6 — cada total acumulado en orden — y luego propaga onComplete. Un error de la fuente o de la funcion acumuladora termina la secuencia con onError. Es puramente secuencial y con estado, ejecutandose en el hilo que entrega las senales de upstream.
- Totales acumulados o sumas en vivo de un flujo de valores
- Acumular estado de UI desde un flujo de eventos/acciones (patron reducer)
- Construir progresivamente una lista creciente o un snapshot agregado
- Seguir metricas acumuladas o moviles a lo largo del tiempo
Con semilla, scan emite primero el valor de la semilla incluso si la fuente esta vacia — por eso la salida siempre tiene un elemento mas que la fuente (el marble muestra el 0 apareciendo antes de que llegue cualquier entrada). Usa scanWith para una semilla perezosa por suscriptor, y evita mutar un objeto acumulador compartido porque se reutiliza entre emisiones.
Flux.just(1, 2, 3)
.scan(0) { acc, x -> acc + x }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux?
import reactor.core.publisher.Flux
fun main() {
// A stream of account transactions: deposits and withdrawals
Flux.just(100, -30, -20, 50)
.scan(0) { balance, tx -> balance + tx }
.subscribe { println("balance: $it") }
}Plegamos un flujo de cuatro transacciones bancarias sobre la semilla 0. scan emite la semilla de inmediato — ese es el primer "balance: 0" — y luego un saldo actualizado por transacción: +100 da 100, −30 da 70, −20 da 50, y +50 lo devuelve a 100. El suscriptor ve el saldo evolucionar en vez de enterarse solo de la cifra final:
balance: 0 balance: 100 balance: 70 balance: 50 balance: 100
Sin semilla, el propio primer elemento se convierte en el acumulador inicial y pasa tal cual:
import reactor.core.publisher.Flux
fun main() {
// Without a seed, the first element passes through as-is
Flux.just(3, 1, 4, 1, 5, 9, 2, 6)
.scan { a, b -> maxOf(a, b) }
.subscribe { println("max so far: $it") }
}max so far: 3 max so far: 3 max so far: 4 max so far: 4 max so far: 5 max so far: 9 max so far: 9 max so far: 9
Con semilla, scan emite la semilla incluso cuando la fuente está vacía, así que la salida siempre tiene un elemento más que la entrada. Evita además mutar un objeto acumulador compartido — se arrastra entre emisiones; produce un valor nuevo cada vez.
expand / expandDeep
expand y expandDeep hacen crecer un flujo de forma recursiva: cada valor emitido se reinyecta en tu función para producir sus hijos, hasta que una rama devuelve un publisher vacío. expand recorre el grafo en anchura (nivel por nivel), expandDeep va en profundidad (baja por cada rama antes de pasar a la siguiente). Son la respuesta reactiva a los árboles, los organigramas y el "trae la siguiente página hasta que no haya más".
fun <T> Flux<T>.expand(expander: (T) -> Publisher<out T>): Flux<T>expand emite cada valor de la fuente y luego aplica la funcion expander para producir un Publisher de hijos, que se vuelven a fusionar en el flujo y se expanden recursivamente. El recorrido es en anchura (breadth-first): se emite un valor y luego se visitan todos sus hijos directos antes que los nietos. El expander se ejecuta una vez por elemento emitido; la recursion se detiene cuando devuelve un Publisher vacio, y el Flux completa cuando todas las ramas se agotan.
- Recorrer arboles o grafos como sistemas de archivos u organigramas
- Seguir APIs paginadas donde cada pagina produce el token de la siguiente
- Rastrear enlaces nivel por nivel desde un nodo raiz
- Aplanar jerarquias anidadas de categorias o comentarios
expand no detecta ciclos: si el grafo tiene bucles o el expander nunca devuelve un Publisher vacio, la recursion es infinita y puede agotar la memoria. Asegurate siempre de que las ramas terminen, y usa expandDeep solo cuando realmente necesites orden en profundidad (depth-first).
Flux.just(1)
.expand { n -> if (n < 100) Flux.just(n * 10 + 1, n * 10 + 2) else Flux.empty() }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dada esta tuberia de Reactor, que secuencia imprime subscribe?
import reactor.core.publisher.Flux
data class TreeNode(val name: String, val children: List<TreeNode> = emptyList())
fun main() {
val tree = TreeNode("root", listOf(
TreeNode("A", listOf(TreeNode("A1"), TreeNode("A2"))),
TreeNode("B", listOf(TreeNode("B1")))
))
// expand: breadth-first (level by level)
println("Breadth-first:")
Flux.just(tree)
.expand { Flux.fromIterable(it.children) }
.map { it.name }
.subscribe { println(" $it") }
// expandDeep: depth-first (down each branch)
println("Depth-first:")
Flux.just(tree)
.expandDeep { Flux.fromIterable(it.children) }
.map { it.name }
.subscribe { println(" $it") }
}Un árbol: root tiene como hijos a A y B; A tiene A1 y A2, y B tiene B1. Con expand, root se emite primero, luego todo el siguiente nivel (A, B) y después los nietos (A1, A2, B1) — nivel por nivel. Con expandDeep el recorrido se sumerge: root, luego A y todo lo que hay bajo A, y recién entonces B con su subárbol. Los mismos nodos, dos órdenes distintos:
Breadth-first: root A B A1 A2 B1 Depth-first: root A A1 A2 B B1
expand no detecta ciclos: si el grafo tiene bucles o el expander nunca devuelve un publisher vacío, la recursión es infinita y terminará agotando la memoria. Asegúrate de que cada rama termine — en paginación, eso significa devolver Flux.empty() (o Mono.empty()) cuando no haya página siguiente.
index
index decora cada elemento con su posición: convierte un Flux<T> en un Flux de Tuple2<Long, T>, donde T1 es un contador basado en 0 y T2 el valor original. No filtra ni reordena nada — simplemente hace explícito el orden de llegada, útil para numerar filas, trocear o tratar el primer elemento de forma especial. Una sobrecarga, index { i, v -> ... }, mapea el par directamente a tu propio tipo.
fun <T> Flux<T>.index(): Flux<Tuple2<Long, T>>index() conserva y expone el orden de la fuente envolviendo cada valor emitido en un Tuple2 cuyo T1 es un Long de indice basado en 0 que se incrementa de forma monotona, y T2 es el valor original. Mapea uno a uno y de forma sincrona en el hilo del upstream, sin reordenar, filtrar ni descartar elementos, por lo que el elemento N siempre lleva el indice N. Las senales terminales (onComplete/onError) pasan sin cambios despues del ultimo valor indexado. Existe una sobrecarga index(indexMapper) para transformar el par (indice, valor) en un tipo propio.
- Numerar filas o elementos para mostrar, registrar o generar CSV/reportes
- Implementar paginacion o division en bloques segun la posicion del elemento
- Detectar el primer elemento (index == 0) para encabezados o inicializacion
- Correlacionar cada valor con su orden de llegada antes de un flatMap que puede reordenar
El indice cuenta solo las emisiones dentro de esta suscripcion al Flux; se reinicia a 0 en cada resuscripcion/retry y no es un ID global estable. Si colocas index() despues de operadores que alteran el orden como flatMap, el Long refleja el orden de emision (posiblemente intercalado), no el orden original de la fuente; ubica index() justo antes de esos operadores para capturar la posicion real.
Flux.just("a", "b", "c")
.index()
.subscribe { (i, v) -> println("$i:$v") }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que emite este Flux?
import reactor.core.publisher.Flux
fun main() {
Flux.just("alpha", "beta", "gamma", "delta")
.index()
.subscribe { (i, value) -> println("#$i: $value") }
// Custom index mapper (1-based numbering)
Flux.just("first", "second", "third")
.index { i, v -> "${i + 1}. $v" }
.subscribe { println(it) }
}El primer pipeline indexa cuatro palabras: cada una sale como un Tuple2 que Kotlin desestructura en (i, value), imprimiendo del #0 al #3 en orden. El segundo pipeline usa la sobrecarga con mapper para construir etiquetas en base 1 directamente, saltándose la tupla por completo. Nos suscribimos e imprimimos ambas numeraciones:
#0: alpha #1: beta #2: gamma #3: delta 1. first 2. second 3. third
Filtrado
Los operadores de filtrado deciden qué elementos de un Flux llegan realmente a tu suscriptor. Algunos juzgan cada valor con un predicado (filter, takeWhile), otros cuentan posiciones (take, skip, elementAt), otros recuerdan lo que ya vieron (distinct), otros miran el reloj (sample) y un par impone cuántos elementos pueden existir siquiera (single, ignoreElements). Todos comparten un rasgo: nunca transforman valores — solo los dejan pasar o los descartan.
Además se combinan de maravilla. Aquí tienes un pequeño pipeline de monitoreo que junta tres de ellos: un sensor de caldera repite lecturas, silenciamos las repeticiones, conservamos solo las temperaturas alarmantes y paramos tras dos alertas.
import reactor.core.publisher.Flux
fun main() {
data class Reading(val sensor: String, val celsius: Int)
Flux.just(
Reading("boiler", 71), Reading("boiler", 71),
Reading("boiler", 84), Reading("boiler", 95), Reading("boiler", 84)
)
.distinctUntilChanged() // drop repeated readings
.filter { it.celsius > 80 } // keep only alarming temps
.take(2) // two alerts are enough
.subscribe { println("ALERT: ${it.sensor} at ${it.celsius}°C") }
}ALERT: boiler at 84°C ALERT: boiler at 95°C
filter / filterWhen
fun <T> Flux<T>.filter(predicate: (T) -> Boolean): Flux<T>filter aplica el predicado sincrono a cada valor onNext a medida que llega; los valores que devuelven true se reemiten aguas abajo en su orden original, mientras que los que devuelven false se descartan en silencio (sin marcador ni error). Las senales de completado y error pasan intactas, y el filtrado corre en el hilo que esta emitiendo, asi que no anade planificacion. Con in=[1,2,3,4,5,6,|] solo sobreviven los pares, dando out=[2,4,6,|].
- Descartar nulos, vacios o elementos invalidos antes del procesamiento
- Aplicar una regla de negocio barata en memoria (status == ACTIVE, amount > 0)
- Reducir un flujo a un subconjunto relevante antes de mapear o agregar
- Proteger de valores que romperian un operador posterior
El predicado debe ser sincrono y no bloqueante; si tu prueba necesita una llamada a BD o HTTP que devuelve un Publisher<Boolean>, usa filterWhen, ya que llamar a .block() dentro de filter congela el pipeline reactivo. Ademas, filter puede descartar todos los elementos y dejar un Flux vacio (pero completado), asi que no asumas que sobrevive al menos un valor.
Flux.just(1, 2, 3, 4, 5, 6)
.filter { it % 2 == 0 }
.subscribe { println("got " + it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux?
filter() es el caballo de batalla de esta categoría: evalúa cada elemento contra un predicado síncrono y reenvía solo los que devuelven true, en su orden original. Si has usado filter con colecciones de Kotlin, la idea es la misma — solo que aquí los elementos llegan con el tiempo en lugar de estar en una lista.
import reactor.core.publisher.Flux
fun main() {
data class Order(val id: String, val total: Double)
Flux.just(
Order("A-1041", 250.0),
Order("A-1042", 40.0),
Order("A-1043", 500.0),
Order("A-1044", 99.99)
)
.filter { it.total >= 100.0 }
.subscribe { println("High-value order ${it.id}: $${it.total}") }
}Tenemos un flujo de cuatro pedidos. filter() prueba cada uno a medida que llega: A-1041 (250.0) pasa, A-1042 (40.0) se descarta en silencio, A-1043 (500.0) pasa y A-1044 se queda fuera por un centavo. Al final nos suscribimos e imprimimos cada pedido sobreviviente:
High-value order A-1041: $250.0 High-value order A-1043: $500.0
¿Y si la prueba en sí es asíncrona — una consulta a base de datos, una llamada HTTP, una revisión de caché? Para eso existe filterWhen(): su predicado devuelve un Publisher<Boolean>, y el primer booleano que emite decide el destino del elemento.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun isInStock(item: String): Mono<Boolean> =
Mono.just(item.length > 3) // stand-in for an async inventory lookup
fun main() {
Flux.just("TV", "Laptop", "USB", "Monitor", "SSD")
.filterWhen { item -> isInStock(item) }
.subscribe { println("Available: $it") }
}Tenemos un flujo de cinco nombres de producto. Para cada uno, filterWhen() se suscribe al Mono que devuelve isInStock() y espera su veredicto — aquí solo "Laptop" y "Monitor" reciben un true. Un publisher interno vacío cuenta como false, así que "sin respuesta" significa "descártalo". La salida es:
Available: Laptop Available: Monitor
Nunca bloquees dentro de filter(): llamar a .block() o hacer I/O en su predicado congela todo el pipeline. Si la decisión necesita un viaje a base de datos o HTTP, ese es justamente el trabajo de filterWhen — reserva filter() para chequeos baratos en memoria.
distinct / distinctUntilChanged
fun <T> Flux<T>.distinct(): Flux<T>distinct() mantiene un conjunto que crece sin limite con cada valor ya emitido y reenvia un elemento aguas abajo solo la primera vez que lo ve, descartando cualquier duplicado posterior sin importar la distancia entre ellos en el flujo. Se conserva el orden de llegada de los elementos que sobreviven (los vistos por primera vez) y la senal terminal onComplete/onError pasa intacta. Hay sobrecargas que aceptan un keySelector e incluso un proveedor de Collection personalizado para controlar la igualdad.
- Eliminar IDs o eventos duplicados en todo el flujo
- Quitar registros repetidos de una lectura por lotes antes de persistir
- Recolectar el conjunto unico de categorias/etiquetas emitidas
- Filtrar valores ya procesados antes en la tuberia
distinct() retiene en memoria cada clave distinta durante toda la suscripcion, asi que en un flujo ilimitado o muy grande/caliente el conjunto crece sin limite y puede provocar fugas de memoria; usa distinctUntilChanged() (solo descarta duplicados consecutivos, memoria constante) cuando no necesitas deduplicacion global.
Flux.just(1, 2, 2, 3, 1)
.distinct()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux (en orden) antes de completar?
distinct() es tu deduplicador: recuerda cada valor que ya dejó pasar y descarta cualquier repetición posterior, sin importar qué tan separados estén los duplicados. Su hermano más barato, distinctUntilChanged(), solo compara cada valor con el inmediatamente anterior, así que colapsa las rachas de repeticiones consecutivas pero permite que un valor regrese más tarde.
import reactor.core.publisher.Flux
fun main() {
// Page visits during one session — people refresh and come back
Flux.just("ana", "luis", "ana", "sofia", "luis", "ana")
.distinct()
.subscribe { println("unique visitor: $it") }
}Tenemos un flujo de seis visitas a la página, pero solo tres personas. distinct() deja pasar a "ana", "luis" y "sofia" la primera vez que las ve y se traga en silencio las tres visitas repetidas. Al final nos suscribimos e imprimimos cada visitante nuevo:
unique visitor: ana unique visitor: luis unique visitor: sofia
Dos variantes que vale la pena conocer: distinct(keySelector) te deja definir qué significa "igual" (abajo: nombres sin distinguir mayúsculas), y distinctUntilChanged() es la herramienta clásica para flujos de sensores — suprime lecturas repetidas pero vuelve a emitir un valor cuando cambia de nuevo.
import reactor.core.publisher.Flux
fun main() {
// distinct with a key selector: case-insensitive dedup
Flux.just("Alice", "Bob", "ALICE", "Charlie", "BOB")
.distinct { it.lowercase() }
.subscribe { print("$it ") }
println()
// distinctUntilChanged: only drops CONSECUTIVE duplicates
Flux.just(20, 20, 21, 21, 21, 19, 19, 20)
.distinctUntilChanged()
.subscribe { print("$it ") }
println()
}En el primer flujo, "ALICE" y "BOB" se mapean a claves ya vistas, así que se descartan. En el segundo, las lecturas de temperatura 20, 20, 21, 21, 21, 19, 19, 20 se colapsan a cada punto de cambio — y fíjate que el 20 final SÍ se emite de nuevo, porque distinctUntilChanged solo recuerda el valor anterior, no todo el historial:
Alice Bob Charlie 20 21 19 20
distinct() guarda en memoria cada clave que ha visto durante toda la suscripción. En un flujo infinito o muy grande ese conjunto crece sin límite — una fuga de memoria lenta. Cuando solo necesitas silenciar repeticiones consecutivas (el caso habitual con sensores, estados, precios), prefiere distinctUntilChanged(): almacena exactamente un elemento.
take / takeLast / takeUntil / takeWhile
fun <T> Flux<T>.take(n: Long): Flux<T>take(n) reemite los primeros n elementos de la fuente en su orden original y luego envía un onComplete y cancela la suscripción aguas arriba, de modo que no se producen más elementos. Con take(2) sobre [1,2,3,4,5] obtienes 1, 2 y luego complete; 3, 4 y 5 nunca se solicitan. Por defecto (Reactor 3.5+) el límite se aplica restringiendo la demanda; la variante take(n, limitRequest=false) deja correr la fuente y solo deja de retransmitir tras n.
- Previsualizar los primeros resultados de un flujo grande o costoso
- Limitar una fuente infinita o caliente (Flux.interval, feed de eventos) a N elementos
- Implementar límites tipo 'primera página' de paginación
- Cortar pronto cuando ya se reunieron suficientes datos
take completa normalmente (onComplete) tras n elementos aunque la fuente tenga más; nunca lanza error ni espera a que la fuente termine. take(0) completa de inmediato sin emitir nada, y en una fuente caliente/compartida take cancela aguas arriba, lo que puede detener efectos secundarios de los que dependen otros.
Flux.just(1, 2, 3, 4, 5)
.take(2)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux al suscriptor?
take(n) responde una pregunta muy común: "solo necesito los primeros N — ¿podemos parar después?". Reemite los primeros n elementos, luego envía onComplete y cancela la suscripción aguas arriba, de modo que no se produce nada más. Esa cancelación es su superpoder: convierte fuentes infinitas o costosas en fuentes acotadas y baratas.
import reactor.core.publisher.Flux
fun main() {
// The search service streams matches as it finds them — we only show the top 3
Flux.just(
"Reactor in Action", "Reactive Spring", "Kotlin in Depth",
"RxJava 3 Cookbook", "Spring Boot Recipes"
)
.take(3)
.subscribe { println("result: $it") }
}Tenemos un flujo de cinco resultados de búsqueda que llegan en orden de relevancia. take(3) reenvía los tres primeros sin tocarlos, luego completa hacia abajo y cancela hacia arriba — los últimos dos libros nunca se solicitan. Al final nos suscribimos e imprimimos la vista previa:
result: Reactor in Action result: Reactive Spring result: Kotlin in Depth
La familia tiene tres miembros más. takeLast(n) conserva solo los últimos n elementos (debe esperar el completado para saber cuáles son). takeWhile(predicado) emite mientras la condición se cumple y se detiene en el primer fallo — exclusivo: el elemento que falla se descarta. takeUntil(predicado) emite hasta que la condición coincide — inclusivo: el elemento que coincide es el último en salir.
import reactor.core.publisher.Flux
fun main() {
val source = Flux.range(1, 10)
// take: first N
source.take(3).subscribe { print("$it ") }
println()
// takeLast: last N (needs the source to complete)
source.takeLast(3).subscribe { print("$it ") }
println()
// takeWhile: while the predicate holds — EXCLUSIVE, 5 is not emitted
source.takeWhile { it < 5 }.subscribe { print("$it ") }
println()
// takeUntil: until the predicate matches — INCLUSIVE, 5 is emitted
source.takeUntil { it == 5 }.subscribe { print("$it ") }
println()
}La misma fuente, cuatro cortes distintos. Ojo con la clásica trampa exclusivo/inclusivo en las dos últimas líneas: takeWhile { it < 5 } se detiene ANTES del 5, takeUntil { it == 5 } se detiene EN el 5:
1 2 3 8 9 10 1 2 3 4 1 2 3 4 5
También existe takeUntilOther(publisher): en lugar de un predicado, toma valores hasta que otro Publisher emite algo — perfecto para "transmite eventos hasta que llegue la señal de apagado" o "junta resultados hasta que el Mono de timeout dispare".
Desde Reactor 3.5, take(n) además LIMITA LA DEMANDA AGUAS ARRIBA a n por defecto (el comportamiento del viejo operador limitRequest, hoy deprecado). Antes de 3.5 solicitaba demanda ilimitada y simplemente cancelaba después de n. Si dependías del prefetch anterior, usa take(n, false).
skip / skipLast / skipUntil / skipWhile
fun <T> Flux<T>.skip(n: Long): Flux<T>skip(n) descarta los primeros n elementos emitidos por la fuente y luego retransmite cada elemento siguiente en orden, sin modificarlo. La fuente se suscribe igualmente de inmediato y esos primeros n elementos se consumen y se desechan (no se almacenan), por lo que no se reordena nada. La señal terminal (onComplete u onError) siempre se propaga, incluso si se emitieron menos de n elementos; en ese caso el resultado simplemente se completa vacío.
- Descartar una fila de encabezado o primer registro antes de procesar un flujo
- Ignorar un valor inicial de calentamiento u obsoleto (junto a skip + take para paginar)
- Desechar los primeros N elementos de una secuencia ya tratada en otro lugar
- Implementar paginación por offset simple junto con take(n)
skip NO reduce el trabajo aguas arriba: la fuente igual produce y el operador solicita/consume los primeros n elementos, solo que los descarta; no sirve para evitar emisiones costosas. Además, con skip(Duration) basado en tiempo el descarte depende del reloj en un scheduler paralelo, no del conteo de elementos, así que la cantidad descartada es no determinista.
Flux.just(1, 2, 3, 4, 5)
.skip(2)
.subscribe { println("got=" + it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux, en orden?
La familia skip es el espejo de take: en lugar de quedarse con el inicio del flujo, lo tira. skip(n) descarta los primeros n elementos y retransmite todo lo demás; skipLast(n) recorta la cola; skipWhile y skipUntil descartan según un predicado. Un trabajo clásico del mundo real: saltarse las líneas de encabezado de un archivo exportado.
import reactor.core.publisher.Flux
fun main() {
val lines = listOf(
"#exported 2026-07-06", "#format v2",
"alice,admin", "bob,editor", "carol,viewer"
)
Flux.fromIterable(lines)
.skipWhile { it.startsWith("#") }
.subscribe { println("record: $it") }
}Tenemos un flujo de líneas de archivo donde las dos primeras son comentarios de metadatos. skipWhile { it.startsWith("#") } descarta líneas mientras la condición se cumpla; en cuanto "alice,admin" falla la prueba, la compuerta se abre para siempre — incluso una línea "#" posterior pasaría. Al final nos suscribimos e imprimimos los registros reales:
record: alice,admin record: bob,editor record: carol,viewer
Aquí están las cuatro variantes lado a lado, más el clásico combo skip + take que te da paginación offset/limit sobre cualquier flujo:
import reactor.core.publisher.Flux
fun main() {
val source = Flux.range(1, 10)
// skip: drop the first N
source.skip(7).subscribe { print("$it ") }
println()
// skipLast: drop the last N
source.skipLast(7).subscribe { print("$it ") }
println()
// skipWhile: drop while the predicate holds
source.skipWhile { it < 5 }.subscribe { print("$it ") }
println()
// skipUntil: drop until the predicate matches (the match IS emitted)
source.skipUntil { it == 8 }.subscribe { print("$it ") }
println()
// skip + take = offset/limit pagination: page 2, page size 3
source.skip(3).take(3).subscribe { print("$it ") }
println()
}De nuevo la misma fuente 1..10. skipUntil es inclusivo desde el elemento que coincide (el 8 sí se emite), y la última línea se lee como el OFFSET 3 LIMIT 3 de SQL — produce la segunda página, 4 5 6:
8 9 10 1 2 3 5 6 7 8 9 10 8 9 10 4 5 6
Aquí también existe la variante con publisher acompañante: skipUntilOther(publisher) descarta todo hasta que otro Publisher emite — piensa en "ignora los ticks del mercado hasta que suene la campana de apertura".
skip NO ahorra trabajo aguas arriba. La fuente igual produce los primeros n elementos y skip los consume y descarta — es un colador aguas abajo, no un optimizador aguas arriba. Si producir elementos es costoso, lleva el offset a la propia fuente (p. ej. la consulta a la base de datos).
elementAt
fun <T> Flux<T>.elementAt(index: Int): Mono<T> // also elementAt(index, defaultValue): Mono<T>elementAt(n) se suscribe a la fuente, cuenta las emisiones en orden (base 0), descarta cada valor anterior al índice n y, en cuanto llega el valor de la posición n, emite exactamente ese elemento y luego completa, cancelando el flujo aguas arriba. De [A,B,C,D,E], elementAt(2) produce C seguido de onComplete, ignorando por completo D y E. Si la fuente completa antes de alcanzar el índice n, emite onError(IndexOutOfBoundsException) en lugar de completar vacío.
- Tomar el N-ésimo elemento de un flujo ordenado (p. ej. la 3.ª página o registro)
- Obtener una posición fija conocida sin almacenar toda la secuencia
- Cortocircuitar un flujo en cuanto se alcanza el índice deseado
- Usar la sobrecarga con valor por defecto cuando la secuencia puede ser demasiado corta
Sin valor por defecto, una secuencia más corta o igual que el índice n NO completa vacía: termina con IndexOutOfBoundsException. Además el índice es estrictamente posicional (base 0) sobre el orden de emisión, por lo que depende del orden aguas arriba; con operadores como flatMap que intercalan resultados, el 'índice' puede no ser el elemento que esperas.
val result: Mono<String> = Flux.just("A", "B", "C", "D", "E")
.elementAt(2)
result.subscribe { value -> println(value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que emite este pipeline de Reactor?
elementAt(índice) extrae exactamente un elemento del flujo por posición — base 0, como al indexar un arreglo — y te lo entrega como un Mono. Cuenta las emisiones a medida que pasan, ignora todo lo anterior al índice buscado y, en el instante en que llega el elemento deseado, lo emite, completa y cancela el flujo aguas arriba.
import reactor.core.publisher.Flux
fun main() {
val finishers = listOf("Ana", "Bruno", "Carla", "Diego", "Elena")
// Index 2 = third across the line = bronze
Flux.fromIterable(finishers)
.elementAt(2)
.subscribe { println("Bronze goes to: $it") }
// A default value saves you from IndexOutOfBoundsException
Flux.fromIterable(finishers)
.elementAt(9, "nobody")
.subscribe { println("9th place: $it") }
}Tenemos un flujo de cinco corredores en orden de llegada. El primer pipeline espera el índice 2 — Ana es 0, Bruno es 1, Carla es 2 — emite "Carla" y completa de inmediato, así que Diego y Elena nunca se consumen. El segundo pipeline pide el índice 9, que el flujo de cinco elementos nunca alcanza, así que se emite el valor por defecto "nobody" en lugar de un error. La salida:
Bronze goes to: Carla 9th place: nobody
Sin valor por defecto, una secuencia demasiado corta no completa vacía — falla con IndexOutOfBoundsException. Y el "índice" es orden de emisión, no una clave propia del dato: tras operadores que reordenan o intercalan (como flatMap), la posición 2 puede no ser el elemento que crees.
single / singleOrEmpty
fun <T> Flux<T>.single(): Mono<T>single() se suscribe a la fuente y espera que emita exactamente un onNext y luego complete. Re-emite ese unico elemento y completa el Mono resultante. Si la fuente completa vacia, emite NoSuchElementException; si llega un segundo onNext, emite IndexOutOfBoundsException de inmediato y cancela la fuente. La variante single(T default) sustituye un valor por defecto para fuentes vacias, mientras que singleOrEmpty() simplemente completa vacio (y aun asi falla con 2 o mas).
- Afirmar que una consulta o busqueda devuelve exactamente una fila/registro
- Imponer un contrato estricto donde 0 o resultados duplicados son un error
- Adaptar un Flux que sabes de un solo valor en un Mono
- Fallar rapido ante cardinalidad inesperada en vez de tomar el primero en silencio
single() es estricto: una fuente vacia falla con NoSuchElementException y dos o mas emisiones fallan con IndexOutOfBoundsException — NO toma el primer elemento como next()/take(1). Usa singleOrEmpty() para tolerar vacio, o single(default) para dar un valor por defecto, pero ninguno tolera 2 o mas.
val mono: Mono<Int> = Flux.just(7).single()
mono.subscribe(
{ value -> println("onNext: " + value) },
{ err -> println("onError: " + err) },
{ println("onComplete") }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este fragmento cuando se suscribe?
single() es una aserción disfrazada de operador: convierte un Flux en un Mono garantizando que la fuente emita EXACTAMENTE un elemento. ¿Cero elementos? NoSuchElementException. ¿Dos o más? IndexOutOfBoundsException, señalada en cuanto aparece el segundo valor. Úsalo cuando "más de uno" o "ninguno" es un bug que quieres ver estallar, no absorber en silencio.
import reactor.core.publisher.Flux
fun main() {
// Exactly one match: the value flows through as a Mono
Flux.just("root@corp.io", "dev@corp.io", "guest@mail.com")
.filter { it == "root@corp.io" }
.single()
.subscribe(
{ println("the one admin: $it") },
{ println("error: ${it.message}") }
)
// Empty source: singleOrEmpty completes without error
Flux.empty<String>()
.singleOrEmpty()
.subscribe(
{ println("got: $it") },
{ println("error: ${it.message}") },
{ println("empty is fine") }
)
// More than one element: single() fails fast
Flux.just(1, 2, 3)
.single()
.subscribe(
{ println("value: $it") },
{ println("error: ${it.message}") }
)
}Tres escenarios. En el primero, el filtro deja exactamente un admin, así que single() lo emite feliz. En el segundo, la fuente está vacía, pero singleOrEmpty() lo acepta y completa sin valor. En el tercero, la fuente tiene tres elementos — single() falla apenas llega el 2, y nuestro callback de error imprime la razón:
the one admin: root@corp.io empty is fine error: Source emitted more than one item
No confundas single() con next(): next() toma el primer elemento y cancela el resto sin quejarse, mientras que single() consume la fuente vigilando su cardinalidad. También existe single(valorPorDefecto), que sustituye el caso vacío con un respaldo — pero nada perdona un segundo elemento.
next / last
fun <T> Flux<T>.next(): Mono<T> // first element, or empty Mono if source is empty
fun <T> Flux<T>.last(): Mono<T> // last element; ERRORS (NoSuchElementException) on empty
fun <T> Flux<T>.last(defaultValue: T): Mono<T> // last, or default if emptynext() se suscribe, emite el primer valor onNext como un Mono y cancela de inmediato el upstream, asi que para [A,B,C,|] devuelve A y nunca ve B ni C. last() en cambio recorre toda la secuencia, guardando solo el valor mas reciente, y lo emite (C) en el momento en que la fuente completa, por lo que debe esperar al onComplete terminal. Ambos colapsan un Flux de varios elementos en un Mono de un solo valor y propagan onError sin cambios.
- next(): tomar solo el primer resultado de una consulta/stream y detenerse pronto (corto-circuito)
- next(): convertir un Flux del que esperas al menos un elemento en un Mono sin importar el resto
- last(): obtener el estado final tras una secuencia de actualizaciones que completa (p.ej. ultimo tick de precio)
- last(default): valor terminal seguro cuando la fuente podria estar vacia
Se comportan muy distinto con fuentes vacias e infinitas. Con un Flux vacio, next() emite un Mono vacio (sin error), pero last() (sin argumento) lanza NoSuchElementException; usa last(default) para evitarlo. Con un Flux infinito/caliente, last() nunca completa (espera el onComplete para siempre), mientras que next() devuelve la primera emision al instante.
Flux.just("A", "B", "C")
.last()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que imprime este fragmento?
next() y last() son los extremos de un Flux. next() te da un Mono con el primer elemento y cancela el flujo aguas arriba de inmediato — no le importa lo que venga después. last() es el paciente: acompaña el flujo hasta el final, recordando solo el valor más reciente, y lo emite cuando la fuente completa.
import reactor.core.publisher.Flux
fun main() {
val ticks = listOf(101.2, 101.9, 102.4, 101.7)
// next(): the first quote, then upstream is cancelled
Flux.fromIterable(ticks)
.next()
.subscribe { println("first quote: $it") }
// last(): waits for completion, then emits the final value
Flux.fromIterable(ticks)
.last()
.subscribe { println("closing price: $it") }
// last(default): safe when the stream might be empty
Flux.empty<Double>()
.last(0.0)
.subscribe { println("no trades today: $it") }
}Tenemos un flujo de cuatro ticks de precio. next() atrapa 101.2 en cuanto llega y deja de escuchar. last() consume los cuatro ticks y solo cuando el flujo completa emite el 101.7 final. El tercer pipeline muestra por qué existe last(default): en un día sin operaciones, last() a secas fallaría, pero el valor por defecto nos da un elegante 0.0. La salida:
first quote: 101.2 closing price: 101.7 no trades today: 0.0
Sus casos límite difieren mucho. Con un Flux vacío, next() simplemente completa vacío, pero last() sin argumento falla con NoSuchElementException. Con un Flux infinito, next() responde al instante mientras que last() NUNCA completa — espera un onComplete que jamás llega.
sample / sampleFirst / sampleTimeout
fun <T> Flux<T>.sample(timespan: Duration): Flux<T> // also sample(Publisher<U>)sample() muestrea la fuente periodicamente en ventanas recurrentes y emite solo el valor mas reciente visto desde el tick anterior, descartando los valores anteriores que llegaron dentro de esa misma ventana. Cada limite de muestreo lo marca un Duration (un intervalo interno que corre en el parallel Scheduler) o las senales onNext de un Publisher companero en la sobrecarga sample(Publisher). Cuando la fuente completa, el ultimo valor se emite (si aun no fue muestreado) antes de propagar la senal de completado; por eso in=[1..7,|] produce out=[2,4,7,|]: 2 y 4 son los ultimos valores en los dos primeros ticks y 7 se descarga al completar.
- Limitar streams de sensores o telemetria de alta frecuencia a una cadencia fija de reporte
- Actualizar la UI a una tasa de refresco constante sin renderizar cada valor intermedio
- Tomar snapshots periodicos del ultimo precio/cotizacion de un feed de mercado rapido
- Limitar la tasa de actualizaciones de progreso o posicion para no saturar al consumidor
sample() pierde datos por diseno: descarta silenciosamente todos los valores excepto el mas reciente de cada ventana, asi que nunca debe usarse donde cada elemento importa. Ademas, si no llega un valor nuevo durante una ventana, ese tick no emite nada (no reemite el valor anterior), y la variante temporizada senala en el parallel Scheduler, moviendo el trabajo posterior fuera del hilo de la fuente.
val sampled: Flux<Int> = Flux.just(1, 2, 3, 4, 5, 6, 7)
.sample(tick) // tick fires 3 times: after 2, after 4, after 7
.doOnNext { println(it) }
// what gets printed, and how does it terminate?🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado el timing del diagrama de canicas donde la fuente emite 1..7 y el muestreador dispara tres ticks capturando el ultimo valor cada vez, que emite este Flux?
Los operadores de muestreo descartan datos a propósito, para flujos que producen más rápido de lo que quieres consumir. sample(periodo) mira la fuente cada periodo y emite solo el ÚLTIMO valor visto desde la mirada anterior. sampleFirst(periodo) es el ánimo contrario: emite el PRIMER valor y luego se hace el sordo durante el periodo — el clásico throttling de clics. sampleTimeout(fn) emite un valor solo si no llega otro más nuevo dentro de su ventana acompañante (el patrón debounce).
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// A sensor pushes a reading every 100 ms; we only report every 300 ms
Flux.interval(Duration.ofMillis(100))
.sample(Duration.ofMillis(300))
.take(4)
.subscribe { println("report: $it") }
Thread.sleep(2000)
}Tenemos un sensor que emite 0, 1, 2, 3... cada 100 ms. Cada 300 ms el muestreador despierta y reenvía solo la lectura más nueva, así que sobrevive más o menos uno de cada tres valores. take(4) limita el experimento y Thread.sleep mantiene viva la JVM mientras corren los temporizadores (interval emite en un scheduler de fondo). Una ejecución real imprimió:
report: 1 report: 4 report: 7 report: 10
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// Rate-limit clicks: accept the first, ignore repeats for 500 ms
Flux.interval(Duration.ofMillis(100))
.sampleFirst(Duration.ofMillis(500))
.take(3)
.subscribe { println("accepted click: $it") }
Thread.sleep(2200)
}Aquí los papeles se invierten: sampleFirst acepta el clic 0 de inmediato, luego descarta todo durante 500 ms, acepta el siguiente cuando la ventana se reabre, y así sucesivamente. Protege a un backend de envíos dobles y de aporrear botones. Una ejecución real imprimió:
accepted click: 0 accepted click: 5 accepted click: 11
Todo muestreo pierde datos por contrato — nunca lo pongas en un pipeline donde cada elemento importa (pagos, comandos, confirmaciones). Además, una ventana sin actividad no emite nada: sample no repite el valor anterior cuando no llegó uno nuevo.
ignoreElements
fun <T> Flux<T>.ignoreElements(): Mono<T> // sibling: then(): Mono<Void>ignoreElements() se suscribe a la fuente con demanda ilimitada y se traga cada señal onNext, retransmitiendo solo el evento terminal: cuando la fuente completa, el Mono<T> resultante completa vacío; cuando falla, el error se propaga sin cambios. Lo clave es que los valores se descartan en ESTE operador, no en la fuente — cada elemento sigue viajando por todos los operadores de arriba, así que los efectos secundarios como doOnNext, guardados y logs corren para cada uno. Con in=[A,B,C,|] la salida es solo [|].
- Ejecutar un flujo solo por sus efectos secundarios y esperar únicamente su completado
- Adaptar un Flux<T> a la forma Mono<T> que exige una API, sin importar los valores
- Condicionar un paso posterior a que "todo terminó" (combínalo con then(siguienteMono))
- Convertir un pipeline de escritura/importación por lotes en una sola señal de éxito o fallo
El Mono<T> resultante NUNCA emite un valor, así que encadenarle map/flatMap es código muerto — solo tienen sentido doOnSuccess (tolerante a null), el encadenado estilo then o el callback de completado. Si de verdad necesitas el valor final además del completado, usa last() o collectList(); y si solo quieres el tipo Mono<Void> de "listo", then() lo expresa de forma más idiomática.
Flux.just("a", "b", "c")
.doOnNext { println("seen " + it) }
.ignoreElements()
.subscribe(
{ v -> println("value: " + v) },
{ e -> println("error") },
{ println("done") }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué imprime este fragmento?
ignoreElements() es el filtro más radical de todos: descarta TODOS los valores y conserva solo la señal terminal, devolviendo un Mono<T> que completa cuando la fuente completa (o falla cuando ella falla). ¿Para qué querrías eso? Porque muy a menudo ejecutas un flujo solo por sus efectos secundarios — escribir filas, enviar correos, calentar una caché — y lo único que el llamador necesita saber es "terminó" o "explotó".
import reactor.core.publisher.Flux
fun main() {
// Import three records — we only care that the whole import finished
Flux.just("row-1", "row-2", "row-3")
.doOnNext { println("importing $it") }
.ignoreElements()
.subscribe(
{ println("value: $it") }, // never called
{ println("error: ${it.message}") },
{ println("import finished") }
)
}Tenemos un flujo de tres filas importándose. Cada fila sigue viajando por el pipeline aguas arriba — doOnNext registra las tres, prueba de que el trabajo ocurre — pero ignoreElements() se traga cada onNext, así que el callback de valor nunca se dispara. Cuando la fuente completa, ese completado sí pasa y nuestro onComplete anuncia que la importación terminó:
importing row-1 importing row-2 importing row-3 import finished
Su socio natural es then(), que hace el mismo trabajo pero devuelve un Mono<Void> — el tipo idiomático en Reactor para "terminé, ahora encadena el siguiente paso". Usa ignoreElements() cuando una API exige un Mono<T> del tipo de la fuente; usa then() cuando solo quieres secuenciar lo que viene después.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
val saveAll: Mono<Void> = Flux.just("user-1", "user-2")
.doOnNext { println("saving $it") }
.then() // like ignoreElements(), but typed Mono<Void>
saveAll.subscribe(
{ },
{ println("error: ${it.message}") },
{ println("all users saved — safe to run the next step") }
)
}La misma idea, distinto tipo: saveAll es un Mono<Void> que puedes entregar a los llamadores, encadenar con .then(siguienteMono) o devolver desde un handler de WebFlux. Cuando completa, ambos usuarios están garantizados como guardados:
saving user-1 saving user-2 all users saved — safe to run the next step
El Mono<T> que devuelve ignoreElements() NUNCA emite un valor — hacerle map o flatMap es código muerto. Si necesitas el completado Y un valor, mira last(), collectList() o reduce().
Combinación
Los sistemas reales casi nunca tienen un solo flujo. Tienes una caché y una base de datos; dos sensores reportando a la vez; el perfil de un usuario y sus órdenes llegando desde servicios distintos. Los operadores de combinación toman varios Publishers y los tejen en un único Flux — y cada miembro de la familia responde dos preguntas de forma distinta: ¿cuándo se suscribe cada fuente, y en qué orden llegan sus valores a la salida?
Un mapa rápido de la familia antes de entrar en detalle:
- concat — una fuente a la vez, orden garantizado
- merge — todas las fuentes a la vez, los valores se intercalan por orden de llegada
- mergeSequential — se suscribe como merge, emite como concat
- zip / combineLatest / withLatestFrom — combinan valores entre fuentes: por índice, por frescura, o dirigidos por un solo lado
- firstWithSignal / firstWithValue — ponen a competir las fuentes y se quedan solo con la más rápida
- startWith / switchIfEmpty — anteponen valores al inicio, o cambian a un plan B cuando la fuente resulta vacía
concat / concatWith
concat es el combinador educado: pega las fuentes una detrás de otra. Se suscribe a la primera fuente, reenvía todo lo que emite, espera su señal de complete — y recién entonces se suscribe a la siguiente. Las fuentes nunca están activas al mismo tiempo, así que el orden de salida es exactamente el orden de declaración. Las variantes de instancia concatWith(publisher) y concatWithValues(v1, v2, …) agregan al final de un Flux existente en lugar de partir de la fábrica estática.
fun <T> Flux.concat(vararg sources: Publisher<out T>): Flux<T>Flux.concat se suscribe a cada fuente estrictamente de una en una y en orden de declaracion: agota por completo la fuente A y espera su onComplete antes incluso de suscribirse a la fuente B, de modo que las fuentes internas nunca estan activas a la vez. Cada elemento se reenvia en secuencia, preservando el orden global, y el Flux resultante solo se completa cuando termina la ultima fuente. Como las fuentes se suscriben de forma perezosa bajo demanda, los efectos secundarios de una fuente no se ejecutan hasta que la anterior haya terminado.
- Emitir primero un snapshot en cache/local y luego anadir resultados frescos de red en ese orden exacto
- Ejecutar pasos dependientes o secuenciados donde la etapa B no debe empezar hasta que termine la etapa A
- Construir streams paginados concatenando pagina 1, pagina 2, ... en orden estricto
- Anteponer un encabezado/valor inicial antes de un stream principal (concatWith / startWith)
concat es totalmente secuencial, asi que una primera fuente lenta o infinita bloquea para siempre a las demas: B nunca se suscribe hasta que A se completa, y cualquier error en una fuente aborta toda la cadena de inmediato (usa concatDelayError para aplazar los errores hasta el final). Para concurrencia usa merge/flatMap; para concurrencia ordenada usa concatMap o flatMapSequential.
val a = Flux.just(1, 2, 3)
val b = Flux.just(4, 5)
Flux.concat(a, b)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dados dos fuentes, ¿qué emite este Flux (en orden)?
Un uso clásico: servir primero lo que ya está en caché y después anexar lo que devuelva la base de datos:
import reactor.core.publisher.Flux
fun main() {
val cachedUsers = Flux.just("user:1 (cache)", "user:2 (cache)")
val dbUsers = Flux.just("user:3 (db)", "user:4 (db)")
Flux.concat(cachedUsers, dbUsers)
.subscribe { println("resolved: $it") }
// Instance variants: append a Publisher, or literal values
Flux.just("A", "B")
.concatWith(Flux.just("C", "D"))
.concatWithValues("E")
.subscribe { print("$it ") }
println()
}Tenemos dos fuentes: una caché que resuelve user:1 y user:2, y una consulta a base de datos con user:3 y user:4. Flux.concat agota la caché por completo y espera su finalización antes de tocar la fuente de base de datos, así que los suscriptores siempre ven primero las filas cacheadas. El segundo pipeline muestra la variante de instancia: a A, B le sigue el publisher C, D vía concatWith, y concatWithValues remata con una E literal al final:
resolved: user:1 (cache) resolved: user:2 (cache) resolved: user:3 (db) resolved: user:4 (db) A B C D E
concat es totalmente secuencial: una primera fuente lenta o infinita bloquea para siempre a todas las siguientes, y un error en cualquier fuente mata la cadena completa al instante (concatDelayError aplaza los errores hasta el final). ¿Necesitas concurrencia? merge. ¿Concurrencia Y orden? mergeSequential o concatMap.
merge / mergeWith
merge es el hermano impaciente de concat. Se suscribe a todas las fuentes de inmediato, y el valor que aparezca primero — de cualquier fuente — va directo a la salida. El orden relativo dentro de cada fuente se conserva, pero entre fuentes todo se intercala por puro orden de llegada. La versión de instancia, a.mergeWith(b), hace lo mismo para dos publishers.
fun <T> Flux.merge(vararg sources: Publisher<out T>): Flux<T>merge se suscribe de forma eager (anticipada) a todos los Publishers a la vez y reenvia cada valor hacia abajo en el momento en que llega, de modo que las emisiones de distintas fuentes se entrelazan segun su orden real de llegada y no por orden de fuente. No almacena una fuente esperando a otra, y conserva el orden relativo de los valores dentro de una misma fuente. El Flux combinado solo se completa cuando todas las fuentes se han completado; si alguna fuente emite un error, ese error termina el flujo combinado de inmediato y las demas se cancelan.
- Combinar flujos de eventos independientes (p. ej. clics y mensajes de websocket) en un solo feed
- Unir resultados de varias llamadas en paralelo cuando importa el orden de llegada y no el de la fuente
- Agregar actualizaciones en vivo de varios sensores o topicos de forma concurrente
- Ejecutar varios Publishers ya construidos juntos sin secuenciarlos
Como la suscripcion es eager y concurrente, el orden de salida es no determinista entre fuentes, asi que nunca confies en el para preservar el orden de las fuentes; usa concat para eso. Ademas, por defecto merge tiene una concurrencia limitada (Queues.SMALL_BUFFER_SIZE, 256), por lo que suscribirse a mas fuentes que ese limite encolara las restantes en lugar de iniciarlas todas a la vez.
val a = Flux.interval(Duration.ofMillis(50)).map { "a$it" }.take(3)
val b = Flux.interval(Duration.ofMillis(80)).map { "b$it" }.take(3)
Flux.merge(a, b)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Se fusionan dos fuentes basadas en interval. ¿Qué afirmación sobre la salida está GARANTIZADA?
Dos sensores reportan a ritmos distintos; merge los convierte en un único feed de eventos en vivo:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val sensorA = Flux.interval(Duration.ofMillis(50))
.map { "sensorA-$it" }
.take(3)
val sensorB = Flux.interval(Duration.ofMillis(80))
.map { "sensorB-$it" }
.take(3)
Flux.merge(sensorA, sensorB)
.doOnNext { println("event: $it") }
.blockLast()
}sensorA emite cada 50 ms y sensorB cada 80 ms; ambos se suscriben en el mismo instante. Cada lectura cae en el flujo combinado en cuanto se produce, así que la salida mezcla los dos carriles por marca de tiempo, no por fuente. El Flux combinado completa solo cuando ambos sensores entregaron sus tres lecturas:
event: sensorA-0 event: sensorB-0 event: sensorA-1 event: sensorB-1 event: sensorA-2 event: sensorB-2
mergeSequential
mergeSequential cumple un deseo muy específico: «arranca todas mis fuentes YA, pero no me desordenes la salida». Como merge, se suscribe a todas las fuentes con avidez, así que las fuentes lentas se calientan en paralelo. Como concat, emite en orden de suscripción: todos los valores de la fuente 1, luego todos los de la fuente 2 — los valores que llegan temprano de una fuente posterior esperan en un buffer hasta su turno.
Flux.mergeSequential(p1: Publisher<T>, p2: Publisher<T>): Flux<T>mergeSequential se suscribe a todas las fuentes de forma anticipada y concurrente (como merge), por lo que su trabajo se ejecuta en paralelo, pero almacena y reemite los valores estrictamente en el orden de suscripción: cada elemento de A se emite antes que cualquier elemento de B. El resultado completa solo cuando todas las fuentes han completado, y los errores se propagan segun la configuracion de delayError. Como los valores internos se encolan hasta que llega su turno, se conserva el orden a costa de mantener elementos en memoria.
- Solicitar varias paginas o shards remotos en paralelo pero presentar los resultados en un orden determinista
- Precalentar fuentes lentas de forma concurrente y aun asi emitirlas ordenadas para el consumidor
- Combinar llamadas a una API paginada de forma anticipada sin entremezclar los limites de pagina
- Reemplazar concat cuando necesitas su orden pero no puedes pagar la latencia de su suscripcion secuencial
A diferencia de merge, el orden de salida esta garantizado, pero a diferencia de concat las fuentes se suscriben de inmediato, por lo que una fuente posterior rapida (B) debe almacenarse en memoria mientras una fuente anterior lenta (A) sigue produciendo. Esto puede causar un crecimiento de memoria no acotado y presion de back-pressure si A es lenta y B es grande; concat lo evita suscribiendose de forma perezosa una a la vez.
val a = Flux.just("a1", "a2", "a3")
val b = Flux.just("b1", "b2", "b3")
Flux.mergeSequential(a, b)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué imprime este fragmento, y en qué orden?
Este ejemplo hace visibles ambos comportamientos a la vez: el doOnNext de cada página registra cuándo se produce un valor, mientras que row: registra cuándo el flujo combinado realmente lo emite:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val page1 = Flux.just("order-1", "order-2")
.delayElements(Duration.ofMillis(100))
.doOnNext { println(" (page1 produced $it)") }
val page2 = Flux.just("order-3", "order-4")
.delayElements(Duration.ofMillis(30))
.doOnNext { println(" (page2 produced $it)") }
Flux.mergeSequential(page1, page2)
.doOnNext { println("row: $it") }
.blockLast()
}page2 es más rápida (30 ms contra 100 ms por elemento), así que produce order-3 y order-4 antes de que page1 haya emitido su primera fila — esa es la suscripción ávida en acción. Pero mergeSequential retiene esos valores tempranos: la salida emite primero las filas de page1, y solo cuando page1 completa se vuelcan los order-3 y order-4 almacenados:
(page2 produced order-3) (page2 produced order-4) (page1 produced order-1) row: order-1 (page1 produced order-2) row: order-2 row: order-3 row: order-4
La garantía de orden cuesta memoria: una fuente posterior rápida queda entera en el buffer mientras una anterior lenta sigue produciendo. Si las fuentes son grandes o no acotadas, prefiere concat — se suscribe de forma perezosa, una fuente a la vez, y no almacena nada.
mergeComparing / mergePriority
mergeComparing es el paso de fusión del merge-sort, en edición reactiva: entrégale varias fuentes que ya vienen ordenadas, más un Comparator, y las intercala en un único Flux globalmente ordenado. En cada paso mira el siguiente valor pendiente de cada fuente y emite el menor. mergePriority es el primo impaciente: mismo comparador, pero elige entre los valores disponibles en ese momento en lugar de esperar uno de cada carril.
fun <T> mergeComparing(comparator: Comparator<T>, vararg sources: Publisher<out T>): Flux<T>mergeComparing se suscribe a todas las fuentes de forma anticipada pero, en cada paso, almacena un valor pendiente por fuente y emite el menor segun el comparador, de modo que mientras cada fuente siga produciendo, la salida combinada queda globalmente ordenada. Necesita tener un elemento pendiente de cada fuente viva antes de poder elegir, asi que una fuente lenta o silenciosa bloquea la emision. Solo completa cuando todas las fuentes completan, y propaga el error en cuanto alguna fuente falla.
- Fusionar varios flujos ya ordenados (p.ej. logs de eventos por tiempo) en uno solo ordenado
- Combinar paginas ordenadas de varios shards o particiones manteniendo el orden
- Fan-in ordenado desde fuentes por clave donde cada una crece monotonamente
- Merge de K vias de cursores ordenados de base de datos o ficheros
Solo produce un resultado globalmente ordenado si CADA fuente ya esta ordenada por el mismo comparador; no ordena dentro de una fuente. Como espera un valor de cada fuente antes de elegir, una fuente lenta o que nunca emite bloquea toda la salida (bloqueo de cabeza de linea); usa mergePriorityComparing/mergePriority si quieres emitir conforme llegan los valores sin esperar.
val a = Flux.just(1, 4, 7, 9)
val b = Flux.just(2, 3, 6, 8)
Flux.mergeComparing(Comparator.naturalOrder(), a, b)
.subscribe { print(it.toString() + " ") }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dadas dos lanes Flux ya ordenadas, ¿qué imprime este fragmento?
Dos proveedores emiten sus ofertas en orden ascendente de precio; queremos un único flujo ascendente:
import reactor.core.publisher.Flux
fun main() {
val cheapVendor = Flux.just(3, 8, 15) // each feed is already sorted
val premiumVendor = Flux.just(1, 9, 12)
Flux.mergeComparing(compareBy<Int> { it }, cheapVendor, premiumVendor)
.subscribe { println("next best price: \$$it") }
}Cada feed viene ordenado por su cuenta: 3, 8, 15 y 1, 9, 12. mergeComparing compara las dos cabezas, emite la menor ($1 de premiumVendor le gana a $3 de cheapVendor), avanza ese carril y repite — tejiendo ambos carriles en una escalera de precios totalmente ordenada que completa cuando los dos feeds se agotan:
next best price: $1 next best price: $3 next best price: $8 next best price: $9 next best price: $12 next best price: $15
La salida solo queda globalmente ordenada si CADA entrada ya viene ordenada por el mismo comparador — mergeComparing nunca ordena dentro de una fuente. Y como debe retener un valor pendiente por carril antes de elegir, una fuente lenta o silenciosa frena toda la salida (bloqueo de cabeza de línea); mergePriority sacrifica el orden estricto para evitar justamente esa espera.
zip / zipWith
zip es el combinador de emparejamiento: toma el 1.er elemento de cada fuente y los combina, luego el 2.º de cada fuente, y así sucesivamente — estrictamente por índice, como un cierre que se sube. Cada combinación sale como una Tuple, o como lo que construya tu lambda combinadora. El ritmo lo marca la fuente más lenta, y el flujo completo termina en cuanto se agota la fuente más corta. La forma de instancia es names.zipWith(ages); y cuando un lado es solo una colección en memoria, zipWithIterable empareja contra ella directamente sin envolverla en un Flux.
fun <T1, T2> zip(p1: Publisher<T1>, p2: Publisher<T2>): Flux<Tuple2<T1, T2>>zip emite de forma sincronizada: almacena un elemento de cada fuente y solo emite la N-esima Tupla combinada cuando todas las fuentes han producido su N-esimo elemento, por lo que el ritmo lo marca la fuente mas lenta. Los elementos se emparejan estrictamente por indice (a1+b1, a2+b2), sin intercalarse ni reordenarse, y se puede usar una funcion combinadora en lugar de la Tupla por defecto. El Flux resultante completa en cuanto cualquier fuente completa, descartando los elementos ya almacenados pero sin pareja de las fuentes mas rapidas; un error de cualquier fuente se propaga de inmediato.
- Unir dos llamadas API paralelas en un unico objeto de resultado combinado
- Emparejar valores por posicion, p. ej. combinar un flujo de items con un flujo de indices o timestamps
- Coordinar fuentes asincronas independientes que deben avanzar paso a paso juntas
- Combinar un flujo de peticiones con una fuente de retardo para limitar el ritmo (zipWith Flux.interval)
zip va al ritmo de la fuente mas lenta y completa cuando la mas CORTA completa, asi que con fuentes de distinta longitud se descartan silenciosamente los elementos sobrantes de la mas larga — la longitud del resultado es la de la entrada mas corta. No es combineLatest: nunca reemite por el nuevo valor de una sola fuente, siempre espera un elemento nuevo de cada fuente por cada salida.
val letters = Flux.just("a", "b", "c")
val numbers = Flux.just(1, 2)
Flux.zip(letters, numbers) { l, n -> l + n }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux?
Tres flujos paralelos describen a las mismas tres personas — zip vuelve a coser las columnas en filas:
import reactor.core.publisher.Flux
fun main() {
val names = Flux.just("Alice", "Bob", "Charlie")
val ages = Flux.just(30, 25, 35)
val cities = Flux.just("NYC", "London", "Tokyo")
// Two sources -> Tuple2 (destructured in Kotlin)
Flux.zip(names, ages)
.subscribe { (name, age) -> println("$name is $age years old") }
// Three sources -> Tuple3
Flux.zip(names, ages, cities)
.map { t -> "${t.t1} (${t.t2}) from ${t.t3}" }
.subscribe { println(it) }
// zipWith: instance method with a combinator lambda
names.zipWith(ages) { name, age -> "$name: $age" }
.subscribe { println(it) }
}names, ages y cities emiten de forma independiente. Flux.zip(names, ages) espera hasta tener un nombre Y una edad, y entonces emite el par — Kotlin desestructura la Tuple2 directamente en la lambda. La sobrecarga de tres fuentes produce Tuple3, que formateamos con map. Por último, zipWith muestra la forma de instancia donde una lambda combinadora reemplaza por completo a las tuplas:
Alice is 30 years old Bob is 25 years old Charlie is 35 years old Alice (30) from NYC Bob (25) from London Charlie (35) from Tokyo Alice: 30 Bob: 25 Charlie: 35
Fuentes de distinta longitud pierden datos en silencio: zip completa cuando la fuente más corta completa y descarta los sobrantes sin pareja de las fuentes más largas — el resultado siempre mide lo que la entrada más corta. Si quieres seguir combinando después de que un lado se calle, ese es el trabajo de combineLatest, no de zip.
combineLatest
combineLatest mantiene una casilla de «último valor» por cada fuente, y cada vez que CUALQUIER fuente emite, recombina ese valor fresco con el último valor cacheado de todas las demás. No emite nada hasta que cada fuente haya hablado al menos una vez, y de ahí en adelante sigue el ritmo de la fuente más activa. Eso lo vuelve el operador para dashboards en vivo y estado derivado — «recalcular cada vez que algo cambie».
fun <T1, T2, V> combineLatest(source1: Publisher<T1>, source2: Publisher<T2>, combinator: (T1, T2) -> V): Flux<V>combineLatest guarda el ultimo valor emitido por cada fuente y, cada vez que CUALQUIER fuente emite, vuelve a ejecutar el combinador con ese valor nuevo mas el ultimo valor en cache de las demas, por lo que la frecuencia de salida sigue a la fuente mas activa. No produce nada hasta que cada fuente haya emitido al menos una vez, y solo completa cuando TODAS las fuentes completan, mientras que un error de cualquier fuente se propaga de inmediato. En el marble in=[1,2,3] / out=[1A,2A,2B,3B,3C], la fuente1 emite 1,2,3 y la fuente2 emite A,B,C intercalados; cada nueva llegada se combina con lo ultimo visto del otro lado.
- Recalcular un valor derivado cuando cambia cualquier entrada (validacion de formulario en vivo)
- Fusionar las ultimas lecturas de sensores/streams independientes en una instantanea
- Estado reactivo de UI/configuracion donde importa el valor mas reciente de cada dependencia
- Combinar un ajuste de cambio lento con un stream de eventos rapido
A diferencia de zip, NO empareja valores uno a uno: las emisiones de una fuente rapida se combinan repetidamente con el mismo valor en cache (ya viejo) de una fuente lenta, por lo que puedes descartar y a la vez duplicar emparejamientos; y si alguna fuente nunca emite, el Flux combinado no emite nada (queda vacio hasta que cada fuente produzca al menos un valor).
val nums = Flux.interval(Duration.ofMillis(300))
.map { it + 1 }.take(3) // 1, 2, 3
val letters = Flux.interval(Duration.ofMillis(500))
.map { "ABC"[it.toInt()] }.take(3) // A, B, C
Flux.combineLatest(nums, letters) { n, l -> "$n$l" }
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. nums emite 1, 2, 3 cada 300 ms; letters emite A, B, C cada 500 ms. ¿Qué imprime combineLatest?
Una sonda de temperatura reporta cada 300 ms y una de humedad cada 500 ms; el dashboard debe refrescarse cada vez que cualquiera se actualice:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val temperature = Flux.interval(Duration.ofMillis(300))
.map { "${20 + it}C" }
.take(4)
val humidity = Flux.interval(Duration.ofMillis(500))
.map { "${40 + it * 5}%" }
.take(3)
Flux.combineLatest(temperature, humidity) { t, h -> "$t / $h" }
.doOnNext { println("dashboard: $it") }
.blockLast()
}La primera temperatura (20C a los 300 ms) tiene que esperar — la humedad todavía no habló, así que no se emite nada. A los 500 ms la humedad reporta 40% y el dashboard pinta su primer cuadro, 20C / 40%. De ahí en adelante cada lectura nueva de cualquiera de las sondas vuelve a disparar con el valor cacheado de la otra: 21C y 22C reutilizan el 40% viejo, luego llega 45% y se empareja con el 22C cacheado, y así hasta que ambas sondas completan:
dashboard: 20C / 40% dashboard: 21C / 40% dashboard: 22C / 40% dashboard: 22C / 45% dashboard: 23C / 45% dashboard: 23C / 50%
firstWithSignal / firstWithValue
A veces no quieres combinar fuentes en absoluto — quieres la más rápida, sola. Flux.firstWithSignal(a, b, …) se suscribe a todas las candidatas, espera la primerísima señal de cualquier tipo (un valor, una finalización, incluso un error), y entonces refleja exactamente a esa fuente ganadora mientras cancela todas las demás. firstWithValue corre la misma carrera pero solo un valor real puede ganar — las fuentes que completan vacías o explotan se saltan. También existe un alias de instancia: a.or(b) es exactamente firstWithSignal para dos fuentes.
fun <T> Flux.firstWithSignal(vararg sources: Publisher<out T>): Flux<T> // also firstWithValue(...)firstWithSignal se suscribe a todas las fuentes a la vez y elige la primera que produzca CUALQUIER señal (un onNext, onComplete u onError). Esa ganadora se refleja tal cual: todas sus señales posteriores se reemiten aguas abajo, mientras que el resto de fuentes se cancelan de inmediato. firstWithValue es más estricto: una fuente solo gana si emite un valor onNext real, de modo que las que completan vacías o fallan primero se descartan en lugar de ganar. El método de instancia a.or(b) es un alias de dos fuentes para la carrera por señal.
- Competir un endpoint primario contra uno de respaldo y quedarse con el que responda primero
- Peticiones redundantes a replicas o regiones para reducir la latencia de cola
- Elegir la cache o mirror mas rapida para el mismo dato
- Usar firstWithValue cuando una fuente vacia o con error no debe ganar la carrera
Con firstWithSignal la ganadora se decide por la PRIMERA senal de cualquier tipo, asi que una fuente que falla o completa vacia mas rapido gana y ese error o vacio se vuelve el resultado completo: la fuente mas lenta que habria dado datos se cancela. Si quieres ignorar las fuentes vacias o con error y competir solo por datos reales, usa firstWithValue (que solo falla si todas las fuentes fallan).
val a = Flux.just("A1", "A2", "A3")
val b = Flux.just("B1", "B2")
Flux.firstWithSignal(a, b)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. A y B se suscriben al mismo tiempo. A emite su primer valor A1 un instante antes de que B emita B1. ¿Qué emite el Flux resultante?
Pon a competir dos réplicas del mismo catálogo y sirve la que responda primero:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val euMirror = Flux.just("EU: catalog", "EU: prices")
.delayElements(Duration.ofMillis(300))
val usMirror = Flux.just("US: catalog", "US: prices")
.delayElements(Duration.ofMillis(100))
Flux.firstWithSignal(euMirror, usMirror)
.doOnNext { println("served -> $it") }
.blockLast()
}Ambas réplicas se suscriben en simultáneo. El primer elemento de la réplica US llega a los ~100 ms, bastante antes que los ~300 ms de la europea, así que la réplica US gana: el resultado la refleja tal cual — sus dos valores y luego su finalización — y la suscripción de la EU se cancela en el acto:
served -> US: catalog served -> US: prices
La trampa de firstWithSignal está en su nombre: CUALQUIER señal gana, incluida una inútil. Una fuente que completa vacía de inmediato «gana» con su señal de complete, y el resultado entero queda vacío — aunque otra fuente tuviera datos reales en camino. firstWithValue existe precisamente para eso:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val slowButFull = Flux.just("backup-1", "backup-2")
.delayElements(Duration.ofMillis(200))
val instantButEmpty = Flux.empty<String>()
// The empty complete is the FIRST signal -> it wins, result is empty
Flux.firstWithSignal(slowButFull, instantButEmpty)
.doOnNext { println("signal race: $it") }
.blockLast()
println("firstWithSignal finished with no values")
// firstWithValue only lets real values win -> the backup gets through
Flux.firstWithValue(slowButFull, instantButEmpty)
.doOnNext { println("value race: $it") }
.blockLast()
}En la primera carrera, el complete de la fuente vacía llega al instante y le gana al respaldo demorado — blockLast retorna sin que se haya emitido valor alguno. En la segunda, firstWithValue ignora la finalización vacía y sigue esperando un valor real, así que los dos elementos del respaldo terminan ganando:
firstWithSignal finished with no values value race: backup-1 value race: backup-2
startWith
startWith antepone valores al frente de un Flux: los suscriptores reciben el prefijo de inmediato, y luego los elementos propios de la fuente en su orden original. Es la herramienta para sembrar estado inicial — un handshake de protocolo antes de las tramas de datos, un «cargando…» antes de que lleguen los datos, el valor actual antes de un flujo de actualizaciones. Puedes anteponer valores literales, un Iterable o un Publisher completo.
fun <T> Flux<T>.startWith(vararg values: T): Flux<T> // also startWith(Iterable<T>), startWith(Publisher<T>)startWith antepone el valor o valores (o un Publisher) al inicio de la fuente: primero emite esos elementos y solo despues reproduce en orden cada elemento del Flux original. La señal terminal de la fuente se conserva: la secuencia combinada completa (o falla) justo cuando lo hace la fuente, una vez entregados los elementos antepuestos y todos los de la fuente. Si antepones un Publisher, este se emite por completo antes de suscribirse a la fuente, por lo que equivale a un concat con el prefijo a la izquierda.
- Inicializar un stream con un valor por defecto para que los suscriptores tengan estado inmediato
- Emitir un valor de carga o placeholder antes de que lleguen los datos asincronos
- Sembrar una tuberia con scan/reduce con un acumulador inicial
- Dar un valor por defecto antes de una fuente posiblemente vacia o lenta
startWith siempre emite el prefijo, incluso si la fuente luego falla o está vacía: no reemplaza ni suprime las emisiones de la fuente, solo agrega elementos delante. Recuerda que antepone; su operador espejo es concatWith, que añade al final — para inyectar valores al cierre del flujo usa ese, no startWith.
val flux: Flux<Int> = Flux.just(1, 2, 3, 4)
.startWith(0)
flux.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux (en orden) al suscribirse?
Un mini protocolo de red — toda conexión debe anunciarse antes de que fluyan los datos — más el patrón caché-y-luego-en-vivo con un Publisher como prefijo:
import reactor.core.publisher.Flux
fun main() {
Flux.just("payload-a", "payload-b")
.startWith("HELLO", "AUTH-OK") // handshake frames go out first
.subscribe { println(it) }
// Prepend a whole Publisher: cached rows before live rows
val cached = Flux.just("cached-1", "cached-2")
Flux.just("live-1", "live-2")
.startWith(cached)
.subscribe { println(it) }
}El Flux fuente solo sabe de tramas de datos; startWith("HELLO", "AUTH-OK") inyecta el handshake por delante, así que el suscriptor ve las dos tramas de saludo antes de cualquier dato. El segundo pipeline antepone un Publisher entero: cada fila cacheada se reproduce hasta completarse antes de que empiecen las filas en vivo — startWith(publisher) es en la práctica un concat con el prefijo a la izquierda:
HELLO AUTH-OK payload-a payload-b cached-1 cached-2 live-1 live-2
Los startWith encadenados se apilan en reversa: flux.startWith(1).startWith(0) emite 0, 1, … porque cada llamada antepone por delante de todo lo anterior. Y startWith siempre emite su prefijo — aunque la fuente después falle o resulte vacía; agrega, nunca reemplaza.
switchIfEmpty
switchIfEmpty es la cláusula reactiva de «si no hay resultados…». Mientras la fuente emita algo, es un pasamanos transparente. Pero si la fuente completa sin un solo elemento, se suscribe a tu Publisher de respaldo y retransmite ese en su lugar. A diferencia de defaultIfEmpty (que inyecta un único valor por defecto), aquí el respaldo es un Publisher completo — otra consulta, una llamada remota, o un Mono.error que convierte el vacío en un error de dominio.
fun <T> Flux<T>.switchIfEmpty(alternate: Publisher<out T>): Flux<T>Mientras la fuente emite, switchIfEmpty es transparente: cada elemento y un onError pasan directamente. Solo cuando la fuente termina con onComplete sin haber emitido ningún elemento, Reactor se suscribe al Publisher alternativo y retransmite sus señales en su lugar. El fallback es perezoso y solo se suscribe en el caso vacío, así que con una fuente no vacía nunca se toca.
- Devolver datos cacheados o por defecto cuando una consulta no arroja filas
- Recurrir a un servicio remoto tras una búsqueda local vacía
- Emitir un valor por defecto en lugar de un flujo vacío
- Lanzar un error de dominio con Mono.error cuando no se encuentra nada
switchIfEmpty solo se dispara con una finalización realmente vacía: si la fuente emite aunque sea un elemento y luego falla, el alternativo NO se usa y el error se propaga. Además, como la expresión del fallback se evalúa al construir la cadena, una llamada eager como switchIfEmpty(Mono.just(fetchFromDb())) ejecuta fetchFromDb() de antemano siempre; envuelve fallbacks con efectos secundarios en Mono.defer para mantenerlos perezosos.
val empty: Flux<String> = Flux.empty()
val alt = Flux.just("A", "B", "C")
empty.switchIfEmpty(alt)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux al suscriptor?
Una búsqueda de productos con un mensaje amigable para el estado vacío:
import reactor.core.publisher.Flux
fun main() {
val products = Flux.just("laptop", "tablet", "phone")
// A search that matches nothing -> the fallback kicks in
products
.filter { it.startsWith("z") }
.switchIfEmpty(Flux.just("no products found"))
.subscribe { println("search 'z': $it") }
// A search that matches -> the fallback is never subscribed
products
.filter { it.startsWith("t") }
.switchIfEmpty(Flux.just("no products found"))
.subscribe { println("search 't': $it") }
}La primera búsqueda filtra nombres que empiecen con «z» — nada coincide, así que el Flux filtrado completa vacío y switchIfEmpty cambia al respaldo, emitiendo no products found. La segunda búsqueda sí coincide (tablet), así que la fuente emite con normalidad y el respaldo ni siquiera llega a suscribirse:
search 'z': no products found search 't': tablet
El ARGUMENTO del respaldo se evalúa con avidez al armar la cadena: switchIfEmpty(Mono.just(llamadaCostosa())) ejecuta llamadaCostosa() en cada armado, incluso cuando la fuente sí tiene datos. Envuelve los respaldos con efectos secundarios en Mono.defer { … } para mantenerlos perezosos. Además, solo una finalización realmente vacía dispara el cambio — una fuente que emite una vez y luego falla conserva su error.
withLatestFrom
withLatestFrom es un combineLatest con volante: solo UN lado — el Flux sobre el que lo llamas — puede disparar salida. Cada vez que ese conductor emite, su valor se combina con el valor más reciente visto del otro Publisher; el otro lado por sí solo nunca dispara nada, solo mantiene fresca su casilla de «último valor». El uso clásico es enriquecer cada evento de usuario con el estado actual de algo — la configuración activa, el contenido del formulario, un feature flag — en el momento exacto del evento.
fun <T, U, R> Flux<T>.withLatestFrom(other: Publisher<U>, combinator: (T, U) -> R): Flux<R>El Flux fuente A marca el ritmo: cada vez que A emite, se combina con el valor más reciente de B y el combinador produce una salida. Los valores de B se muestrean, no se encolan: si B emite varias veces entre dos valores de A, solo se conserva el último; si B aún no ha emitido cuando A dispara, ese valor de A se descarta en silencio (sin salida). La salida se completa cuando A se completa; que B se complete no termina el flujo, y un error de cualquiera de los lados se propaga.
- Etiquetar cada evento con el estado actual, p. ej. un clic con el último valor del formulario
- Enriquecer un flujo de peticiones con la instantánea más reciente de configuración/feature-flag
- Reducir un flujo secundario rápido a la cadencia de un disparador primario
- Anotar eventos de sensor/UI con el último valor de búsqueda en caché
Los primeros valores de A se descartan: hasta que B haya emitido al menos una vez, cada emisión de A no produce nada, por lo que la salida puede empezar más tarde (y ser más corta) que la fuente. Es asimétrico: el ritmo y la finalización de B no importan; solo A impulsa y termina el resultado.
val clicks = Flux.interval(Duration.ofMillis(300))
.map { "click-$it" }.take(4)
val mode = Flux.just("A", "B")
.delayElements(Duration.ofMillis(500))
.concatWith(Flux.never())
clicks.withLatestFrom(mode) { c, m -> "$c/$m" }
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Los clics llegan cada 300 ms; el flujo de modo emite A a los ~500 ms y B a los ~1000 ms, y luego queda abierto. ¿Qué se imprime?
Etiqueta cada clic con el modo de UI que esté activo en el momento del clic:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val clicks = Flux.interval(Duration.ofMillis(300))
.map { "click-$it" }
.take(5)
val mode = Flux.just("mode-A", "mode-B")
.delayElements(Duration.ofMillis(500))
.concatWith(Flux.never()) // keep the latest value alive
clicks.withLatestFrom(mode) { click, m -> "$click in $m" }
.doOnNext { println(it) }
.blockLast()
}Los clics llegan cada 300 ms; el flujo de modo produce mode-A a los 500 ms y mode-B a los 1000 ms, y luego queda en silencio pero vivo gracias a Flux.never, de modo que su último valor sigue disponible. click-0 (a los 300 ms) se descarta — todavía no hay ningún modo con qué emparejarlo. click-1 y click-2 quedan sellados con mode-A, los clics 3 y 4 con mode-B, y la salida completa cuando el conductor (los clics) completa — el ciclo de vida del flujo de modo no importa:
click-1 in mode-A click-2 in mode-A click-3 in mode-B click-4 in mode-B
Manejo de Errores
En Reactor los errores son terminales: en cuanto una excepción se escapa de un operador, la secuencia se detiene, la suscripción se cancela aguas arriba y la señal onError baja disparada hasta tu suscriptor. No llegará ningún elemento más. Suena drástico, pero mantiene el manejo de fallos honesto — y Reactor te da toda una familia de operadores para decidir, de forma declarativa, qué pasa después.
Piénsalos como los equivalentes reactivos de un bloque try/catch, cada uno codificando una estrategia de recuperación distinta:
- onErrorReturn — reemplaza el error con un único valor de respaldo y completa
- onErrorResume — cambia a un Publisher de respaldo completo (una caché, un servicio secundario)
- onErrorMap — traduce el error a una excepción más clara y propia del dominio
- onErrorContinue / onErrorStop — descarta el elemento que falla y sigue adelante (con letra chica)
- onErrorComplete — absorbe el error y termina el flujo normalmente
- retry / retryWhen — se vuelve a suscribir desde cero, opcionalmente con backoff exponencial
onErrorResume
fun <T> Flux<T>.onErrorResume(fallback: (Throwable) -> Publisher<out T>): Flux<T>Todos los valores onNext emitidos antes del error pasan sin cambios (1, 2, 3). Cuando el upstream emite onError, el error se captura, se invoca la funcion de fallback con el Throwable y el operador se suscribe al Publisher devuelto, emitiendo sus elementos (A, B) de forma continua en la secuencia. La senal terminal del fallback se convierte en la del flujo: una finalizacion normal reemplaza al error original, por lo que el downstream nunca ve la excepcion original.
- Devolver datos en cache o por defecto cuando falla una llamada remota
- Recuperarse segun el tipo de error (recuperable vs fatal) con comprobaciones instanceof
- Recurrir a un servicio secundario o respuesta degradada
- Convertir una llamada reactiva fallida en un flujo elegante sin error
Solo intercepta onError, nunca onComplete, y los valores ya emitidos antes del fallo NO se revierten ni se reenvian, por lo que los consumidores pueden ver una salida parcial (1, 2, 3) seguida del fallback. Si solo quieres un unico valor estatico usa onErrorReturn; onErrorResume requiere un Publisher completo. El lambda de fallback puede construir el Publisher de forma anticipada aunque no ocurra error, salvo que envuelvas la creacion en Mono.defer/Flux.defer.
val fallback = Flux.just("A", "B")
Flux.just(1, 2, 3)
.concatWith(Flux.error(RuntimeException("boom")))
.onErrorResume { fallback }
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado este pipeline de Reactor, ¿qué recibe el suscriptor?
onErrorResume es el plan B de los pipelines reactivos. Le das una función que recibe el error y devuelve un Publisher de respaldo; cuando la fuente falla, el operador la cancela, invoca tu función y suscribe al consumidor, sin costuras, a lo que esta devolvió. El suscriptor nunca ve el error original — simplemente sigue recibiendo valores, ahora desde el respaldo. Es el primo reactivo de un catch que devuelve un resultado alternativo: «si el feed de precios en vivo se cae, sirve los precios de la caché».
import reactor.core.publisher.Flux
fun main() {
val livePrices = Flux.just(101.5, 102.25)
.concatWith(Flux.error(IllegalStateException("price feed down")))
val cachedPrices = Flux.just(99.0, 99.5)
livePrices
.onErrorResume { e ->
println("switching to cache: ${e.message}")
cachedPrices
}
.subscribe(
{ println("price: $it") },
{ println("error: ${it.message}") },
{ println("stream completed") }
)
}Tenemos un feed de precios en vivo que emite dos cotizaciones y luego muere con una IllegalStateException, más un Flux de precios cacheados en espera. Los dos primeros precios pasan intactos. Cuando llega la señal de error, onErrorResume la captura, imprime un mensaje y se suscribe a cachedPrices — el suscriptor recibe 99.0 y 99.5 como si nada hubiera pasado, y como el respaldo completa normalmente, todo el flujo también completa normalmente.
price: 101.5 price: 102.25 switching to cache: price feed down price: 99.0 price: 99.5 stream completed
También hay una sobrecarga que recibe un tipo de excepción (o un predicado), para recuperarte solo de los fallos que esperas y dejar que todo lo demás se propague:
import reactor.core.publisher.Flux
fun main() {
Flux.just("valid", "BOOM", "also-valid")
.map {
if (it == "BOOM") throw IllegalArgumentException("bad input")
it
}
.onErrorResume(IllegalArgumentException::class.java) {
Flux.just("recovered-from-bad-input")
}
.subscribe { println(it) }
}valid recovered-from-bad-input
Para elegir respaldo: onErrorReturn cuando basta un valor estático; onErrorResume cuando necesitas un Publisher completo — una consulta a caché, un segundo servicio o incluso Flux.empty() para terminar en silencio.
onErrorReturn
fun <T> Flux<T>.onErrorReturn(fallbackValue: T): Flux<T>Deja pasar cada onNext sin cambios hasta que llega un onError. Cuando la fuente falla, la señal de error se descarta y se reemplaza por una única emisión del valor por defecto, seguida de inmediato por onComplete, de modo que el flujo termina de forma normal en lugar de fallar. Los elementos emitidos antes del error se conservan; nada posterior al error se produce.
- Devolver un valor por defecto seguro (0, lista vacía, valor en caché) cuando una llamada falla
- Hacer que un pipeline no crítico degrade con elegancia en vez de propagar el error
- Entregar un valor centinela aguas abajo para que los suscriptores nunca vean onError
- Proteger pasos opcionales de enriquecimiento donde cualquier fallo debe dar un resultado neutro
Reemplaza el error con un único valor fijo calculado de antemano: no puede inspeccionar la excepción ni invocar otro publisher. El valor por defecto solo se dispara ante un error, nunca ante una fuente vacía (para eso usa defaultIfEmpty/switchIfEmpty). La sobrecarga con filtro por Class/Predicate relanza los errores que no coinciden en lugar de devolver el valor por defecto.
Flux.just(1, 2, 3)
.concatWith(Flux.error(RuntimeException("boom")))
.onErrorReturn(0)
.subscribe { value -> println("emitted=" + value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux al suscriptor?
onErrorReturn es la herramienta de recuperación más simple de la caja: cuando llega cualquier error, emite un único valor de respaldo predefinido y completa. No hay lambda ni publisher de respaldo — solo una constante que decidiste de antemano. Es ideal para los problemas del tipo «si este cálculo falla, que valga -1 / cero / vacío».
import reactor.core.publisher.Flux
fun main() {
Flux.just(200, 50, 0, 25)
.map { 1000 / it } // 1000 / 0 throws ArithmeticException
.onErrorReturn(-1)
.subscribe(
{ println("units per pallet: $it") },
{ println("error: ${it.message}") },
{ println("done") }
)
}Estamos repartiendo 1.000 unidades de stock entre distintos tamaños de pallet. Las dos primeras divisiones funcionan (5 y 20), pero el tercer tamaño es 0 y la división lanza ArithmeticException. onErrorReturn la absorbe, emite el centinela -1 y completa el flujo. Fíjate en que el cuarto tamaño (25) nunca se procesa — el error ya había terminado la fuente; el respaldo solo reemplaza la señal terminal, no reanuda la secuencia.
units per pallet: 5 units per pallet: 20 units per pallet: -1 done
Igual que onErrorResume, tiene sobrecargas filtradas por clase o predicado — el respaldo solo se dispara para los errores que coinciden; cualquier otro se propaga intacto:
import reactor.core.publisher.Flux
fun main() {
Flux.just("ok", "fail")
.map {
if (it == "fail") throw IllegalStateException("boom")
it
}
.onErrorReturn(IllegalStateException::class.java, "fallback")
.subscribe { println(it) }
}ok fallback
onErrorMap
fun <T> Flux<T>.onErrorMap(mapper: (Throwable) -> Throwable): Flux<T>onErrorMap intercepta la senal terminal onError y ejecuta tu mapper para reemplazar el Throwable original por uno nuevo, y luego reemite onError con esa nueva excepcion. Todos los valores onNext ya emitidos antes del error pasan intactos y en orden; solo cambia el tipo o mensaje del error, asi que la secuencia sigue terminando con error (nunca se recupera ni emite un valor de respaldo). Una sobrecarga acepta una Class o Predicate para remapear solo los tipos que coinciden y dejar pasar los demas sin cambios.
- Envolver excepciones de bajo nivel (IOException, SQLException) en una AppException de dominio
- Anadir contexto o un mensaje mas claro antes de que el error llegue a los suscriptores
- Traducir errores de librerias externas a tu propia jerarquia de excepciones
- Normalizar tipos de error para que onErrorResume/retry los puedan reconocer aguas abajo
onErrorMap NO recupera: el flujo siempre termina con error. Si quieres continuar con un valor o stream de respaldo, usa onErrorReturn/onErrorResume. Ademas, si tu propio mapper lanza una excepcion, esa pasa a ser el error propagado; y siempre encadena la causa original (new AppException(e)) para no perder el stack trace.
Flux.just(1, 2, 3)
.concatWith(Mono.error(IOException("disk")))
.onErrorMap { e -> AppException(e) }
.subscribe(
{ v -> println("next: " + v) },
{ err -> println("error: " + err.javaClass.simpleName) }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un Flux emite 1, 2, 3 y luego falla con una IOException. Con .onErrorMap envolviendo el error en AppException, que emite el stream?
onErrorMap no se recupera de nada: traduce. La secuencia sigue fallando, pero en lugar de filtrar una IllegalStateException o SQLException de bajo nivel a cada consumidor, la envuelves en una excepción que tu dominio entiende. Es el equivalente reactivo de catch (e) { throw OrderException(..., e) }, y mantiene limpios tus contratos de error en los límites de cada módulo.
import reactor.core.publisher.Flux
class OrderException(msg: String, cause: Throwable) : RuntimeException(msg, cause)
fun main() {
Flux.just("ord-1", "ord-2", "bad-row")
.map { if (it == "bad-row") throw IllegalStateException("corrupt row") else it }
.onErrorMap { e -> OrderException("order import failed: ${e.message}", e) }
.subscribe(
{ println("processed $it") },
{ println("caught ${it::class.simpleName}: ${it.message} (cause: ${it.cause?.let { c -> c::class.simpleName }})") }
)
}Importamos tres filas de pedidos; la tercera está corrupta, así que map lanza una IllegalStateException. onErrorMap intercepta la señal de error en su camino hacia abajo y reemplaza la excepción por una OrderException, conservando la original como causa — nunca descartes la causa, o pierdes el stack trace. Las dos filas ya emitidas pasan intactas y luego se dispara el manejador de error del suscriptor con la excepción de dominio, no con la de bajo nivel.
processed ord-1 processed ord-2 caught OrderException: order import failed: corrupt row (cause: IllegalStateException)
onErrorContinue / onErrorStop
fun <T> Flux<T>.onErrorContinue(errorConsumer: (Throwable, Any?) -> Unit): Flux<T> // fun <T> Flux<T>.onErrorStop(): Flux<T>Cuando un operador anterior (como map o filter) lanza una excepción al procesar un elemento concreto, onErrorContinue la captura, descarta ese elemento problemático, invoca tu BiConsumer con el error y el valor que lo causó, y deja que el flujo siga emitiendo los siguientes elementos en lugar de terminar. En el diagrama, 2 lanza dentro de un operador anterior, así que se descarta y se notifica al consumidor, mientras que 1, 3 y 4 pasan y el flujo completa con normalidad. No reintenta ni sustituye el valor: el elemento simplemente desaparece de la salida. Su compañero onErrorStop hace lo contrario: colocado en la cadena, restablece el gancho para que los operadores por encima recuperen la semántica normal de error terminal aunque haya un onErrorContinue más abajo.
- Procesamiento por lotes donde unos pocos registros malformados no deben abortar toda la tubería
- Parsear o mapear un flujo de datos de calidad mixta, registrando los elementos malos sobre la marcha
- Transformaciones de mejor esfuerzo donde descartar fallos es aceptable
- onErrorStop: proteger segmentos internos de una librería del onErrorContinue de quien la llama
onErrorContinue es un operador inusual y algo inseguro: cambia el comportamiento de los operadores ANTERIORES en lugar de actuar en su propia posición, y solo funciona con operadores que lo soportan explícitamente (map, filter, flatMap, etc.). Los operadores que envuelven fuentes internas pueden no propagarlo como se espera, y ese acoplamiento implícito hacia arriba vuelve las tuberías frágiles y difíciles de razonar. Los mantenedores de Reactor recomiendan manejar los errores localmente (onErrorResume devolviendo Mono.empty() dentro del publisher interno de un flatMap) — y proteger las secuencias expuestas con onErrorStop cuando no quieres que quien llama las altere.
Flux.just(1, 2, 3, 4)
.map { n -> if (n == 2) throw RuntimeException("bad") else n }
.onErrorStop()
.onErrorContinue { _, _ -> }
.subscribe(
{ println(it) },
{ println("error: " + it.message) }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Este pipeline mezcla onErrorStop con un onErrorContinue más abajo. ¿Qué ve el suscriptor?
Todo lo anterior trata los errores como terminales: un elemento malo mata el flujo y tú solo eliges qué pasa después. onErrorContinue es distinto: se mete en los operadores compatibles aguas arriba (map, filter, flatMap…) y cambia cómo fallan, de modo que un elemento que lanza se descarta, se reporta a tu callback y el flujo simplemente sigue. Para un lote de datos de calidad mixta — digamos, parsear precios de un CSV — esa tolerancia por elemento es justo lo que quieres.
import reactor.core.publisher.Flux
fun main() {
Flux.just("12", "7", "x9", "20", "3z")
.map { it.toInt() } // "x9" and "3z" throw NumberFormatException
.onErrorContinue { err, bad ->
println("skipping bad token '$bad': ${err.message}")
}
.reduce(0) { acc, n -> acc + n }
.subscribe { println("total = $it") }
}Parseamos cinco tokens a enteros. «x9» y «3z» lanzan NumberFormatException dentro de map, pero el onErrorContinue de más abajo le indica a map que entregue cada fallo a nuestro callback en lugar de terminar, y los tokens fallidos se descartan. Los números válidos (12, 7, 20) siguen bajando hasta reduce, que los suma en 39.
skipping bad token 'x9': For input string: "x9" skipping bad token '3z': For input string: "3z" total = 39
La magia tiene su lado B: onErrorContinue actúa a distancia. Cualquier operador aguas arriba — incluidos los que viven en código que no es tuyo — puede cambiar de comportamiento en silencio. onErrorStop es el antídoto: colocado en la cadena, restablece la semántica para que los operadores por encima vuelvan a fallar con normalidad, sin importar lo que diga un onErrorContinue más abajo.
import reactor.core.publisher.Flux
fun main() {
// Without onErrorStop: the downstream onErrorContinue reaches back
// into map, so the bad element is dropped and the stream survives.
Flux.just(1, 2, 0, 4)
.map { 10 / it }
.onErrorContinue { e, v -> println("A: dropped $v (${e.message})") }
.subscribe({ println("A: $it") }, { println("A: error ${it.message}") })
// With onErrorStop: the segment above it keeps normal error semantics —
// the division by zero terminates the stream despite onErrorContinue.
Flux.just(1, 2, 0, 4)
.map { 10 / it }
.onErrorStop()
.onErrorContinue { e, v -> println("B: dropped $v (${e.message})") }
.subscribe({ println("B: $it") }, { println("B: error ${it.message}") })
}Ambos pipelines dividen 10 entre cada elemento y chocan con una división por cero. En el pipeline A, onErrorContinue alcanza a map: el 0 se descarta y el 4 todavía produce 2. En el pipeline B, el onErrorStop intermedio actúa como cortafuegos — map conserva su semántica normal, así que el error termina el flujo y el 4 nunca se procesa.
A: 10 A: 5 A: dropped 0 (/ by zero) A: 2 B: 10 B: 5 B: error / by zero
Advertencia: onErrorContinue depende del soporte de cada operador — solo funciona donde el operador de arriba lo implementa explícitamente — y cambia en silencio el comportamiento aguas arriba, incluso dentro de código de terceros. La recomendación del equipo de Reactor es manejar los errores localmente con onErrorResume dentro de flatMap. Y si construyes un Flux dentro de una librería, considera protegerlo con onErrorStop para que el onErrorContinue de quien te llama no pueda reescribir tus internals.
Aquí está ese patrón preferido en acción — el mismo resultado de «salta los malos», pero con recuperación local y explícita que funciona con cualquier operador:
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// Same "skip the bad ones" result, but the recovery is local and explicit:
// each element gets its own tiny pipeline with its own error handling.
Flux.just("12", "7", "x9", "20", "3z")
.flatMap { token ->
Mono.fromCallable { token.toInt() }
.doOnError { println("could not parse '$token': ${it.message}") }
.onErrorResume { Mono.empty() } // skip just this element
}
.subscribe { println("parsed: $it") }
}Cada token se convierte en su propio mini-pipeline interno: Mono.fromCallable intenta el parseo, doOnError registra el fallo y onErrorResume cambia el Mono fallido por Mono.empty(), de modo que el elemento simplemente desaparece de la salida combinada. Nada cruza los límites entre operadores — cada pieza de la recuperación está a la vista, justo donde ocurre.
parsed: 12 parsed: 7 could not parse 'x9': For input string: "x9" parsed: 20 could not parse '3z': For input string: "3z"
onErrorComplete
fun <T> Flux<T>.onErrorComplete(): Flux<T> // also onErrorComplete(Class<? extends Throwable>) / onErrorComplete(Predicate<? super Throwable>)Todos los valores onNext pasan sin cambios y en orden. En el instante en que llega una senal onError, onErrorComplete descarta el throwable y emite onComplete en su lugar, de modo que el consumidor ve una terminacion normal y exitosa en vez del error. Las sobrecargas permiten completar solo para errores que coincidan con un tipo de excepcion o un predicado dados; los errores que no coinciden siguen propagandose.
- Tratar un fallo esperado o recuperable como un fin de flujo limpio
- Detener en silencio un bucle de sondeo o reintentos ante una excepcion concreta
- Hacer que pipelines de efectos secundarios best-effort terminen normalmente ante errores benignos
- Completar solo ante un tipo de excepcion conocido y dejar que los errores fatales se propaguen
Oculta el error por completo: el consumidor nunca se entera de que hubo un fallo y no emites ningun valor de respaldo, asi que quien espere al menos un elemento puede recibir en silencio una terminacion vacia. Usa las sobrecargas con tipo o predicado para no enmascarar errores fatales inesperados.
Flux.just(1, 2, 3)
.concatWith(Mono.error(RuntimeException("boom")))
.onErrorComplete()
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux a su suscriptor?
A veces la forma más elegante de fallar es fingir que terminaste. onErrorComplete absorbe la señal de error y la reemplaza por onComplete, así el suscriptor ve un final de flujo limpio en lugar de una excepción. Encaja en flujos secundarios best-effort — heartbeats, notificaciones, métricas — donde un fallo debería cerrar la tubería en silencio en vez de romper nada.
import reactor.core.publisher.Flux
fun main() {
Flux.just("ping", "ping", "FAIL", "ping")
.map { if (it == "FAIL") throw IllegalStateException("sensor lost") else it }
.onErrorComplete() // swallow the error, complete gracefully
.subscribe(
{ println("heartbeat: $it") },
{ println("error: $it") },
{ println("stream closed cleanly") }
)
}El sensor emite dos latidos y luego lanza una excepción cuando se pierde la conexión. onErrorComplete intercepta el error: el manejador de error del suscriptor nunca se dispara y el de completado imprime «stream closed cleanly». Fíjate en que el cuarto «ping» nunca se ve — la fuente ya había terminado; onErrorComplete solo reescribe la señal terminal, no resucita el flujo. Las sobrecargas filtradas por tipo o predicado te dejan completar solo ante errores esperados mientras los fatales siguen saliendo a la superficie.
heartbeat: ping heartbeat: ping stream closed cleanly
retry / retryWhen
fun <T> Flux<T>.retry(numRetries: Long): Flux<T> // fun <T> Flux<T>.retryWhen(spec: Retry): Flux<T>Ante una senal onError, retry descarta el error y se vuelve a suscribir a la fuente desde cero, reemitiendo todo desde el principio (la fuente es fria, asi que los valores ya vistos se reproducen de nuevo). Permite hasta N resuscripciones; si la fuente falla mas de N veces, el error final se propaga aguas abajo. Un onComplete normal pasa intacto y nunca dispara un reintento.
- Recuperarse de fallos transitorios de red o I/O (timeouts, llamadas HTTP inestables)
- Reintentar operaciones idempotentes como peticiones GET o lecturas de BD
- Envolver servicios upstream poco fiables donde un reintento rapido suele tener exito
- Usar retryWhen con Retry.backoff para reintentos espaciados bajo carga
retry reproduce desde la fuente, asi que cualquier valor ya emitido antes del error se vuelve a emitir — aguas abajo se ven duplicados (in=[1,x,1,2,ok] produce out=[1,1,2,ok]). Solo funciona en publishers frios; en una fuente caliente resuscribirse no reproduce los datos pasados. Ademas, reintentos inmediatos sin retardo pueden saturar un servicio que falla — prefiere retryWhen(Retry.backoff(...)) para backoff.
val attempt = AtomicInteger(0)
Flux.defer {
if (attempt.getAndIncrement() == 0)
Flux.concat(Flux.just(1), Flux.error(RuntimeException("boom")))
else
Flux.just(1, 2)
}.retry(1)
.subscribe(
{ println("next=" + it) },
{ println("error") },
{ println("complete") }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que imprime este Flux y como termina?
retry encarna la estrategia más optimista ante el error: simplemente intentarlo de nuevo. Ante un error, descarta la suscripción fallida y se vuelve a suscribir a la fuente desde cero, hasta N veces. Como la fuente arranca desde el principio, cualquier valor que emitió antes de fallar se emite otra vez — algo a tener presente con todo lo que no sea idempotente.
import reactor.core.publisher.Flux
var attempt = 0
fun flakyOrderFeed(): Flux<String> = Flux.defer {
attempt++
println("connecting to order feed (attempt $attempt)")
if (attempt < 3) Flux.error(RuntimeException("connection reset"))
else Flux.just("order-701", "order-702")
}
fun main() {
flakyOrderFeed()
.retry(3) // up to 3 extra subscriptions
.subscribe(
{ println("received: $it") },
{ println("gave up: ${it.message}") },
{ println("feed completed") }
)
}Flux.defer construye un feed nuevo por cada suscripción; el contador compartido hace que los dos primeros intentos fallen con «connection reset». retry(3) consume ambos errores en silencio y se re-suscribe cada vez. El tercer intento funciona, transmite los dos pedidos y completa. Si el feed hubiera fallado más de tres veces, el último error habría llegado al manejador «gave up» del suscriptor.
connecting to order feed (attempt 1) connecting to order feed (attempt 2) connecting to order feed (attempt 3) received: order-701 received: order-702 feed completed
Reintentar de inmediato puede castigar a un servicio que ya está sufriendo. retryWhen acepta una especificación Retry que controla cuántas veces y — sobre todo — cuándo reintentar: Retry.backoff te da backoff exponencial con jitter opcional, un retardo máximo y un filtro de errores.
import reactor.core.publisher.Flux
import reactor.util.retry.Retry
import java.time.Duration
var attempt = 0
fun flakyOrderFeed(): Flux<String> = Flux.defer {
attempt++
println("connecting (attempt $attempt) at ${System.currentTimeMillis() - t0}ms")
if (attempt < 3) Flux.error(RuntimeException("connection reset"))
else Flux.just("order-701", "order-702")
}
val t0 = System.currentTimeMillis()
fun main() {
flakyOrderFeed()
.retryWhen(
Retry.backoff(5, Duration.ofMillis(100)) // 100ms, 200ms, 400ms...
.maxBackoff(Duration.ofSeconds(2))
.filter { it is RuntimeException } // only retry these
)
.subscribe(
{ println("received: $it") },
{ println("gave up: ${it.message}") },
{ println("feed completed") }
)
Thread.sleep(2000) // keep the JVM alive while the backoff timers run
}El mismo feed inestable, pero ahora cada reintento espera — unos 100 ms antes del segundo intento, unos 200 ms antes del tercero — hasta que funciona. El filtro limita los reintentos a RuntimeException; cualquier otra cosa se propagaría de inmediato. Cuando el presupuesto de reintentos se agota, retryWhen envuelve el último fallo en un error de «retries exhausted» en lugar de reintentar para siempre.
connecting (attempt 1) at 183ms connecting (attempt 2) at 302ms connecting (attempt 3) at 510ms received: order-701 received: order-702 feed completed
Peeking / Efectos secundarios
Todos los operadores que has visto hasta ahora cambian el flujo de alguna forma: transforman valores, los filtran, combinan fuentes. La familia doOn* hace lo contrario: te deja observar un pipeline sin tocarlo. No importa qué registres, cuentes o midas dentro de estos hooks, exactamente los mismos valores siguen fluyendo hacia el suscriptor, en exactamente el mismo orden.
Hay un hook para cada evento del ciclo de vida: doOnSubscribe (alguien se suscribió), doOnRequest (llegó demanda), doOnNext (pasó un valor), doOnComplete / doOnError (cómo terminó), doOnCancel (alguien colgó) y doFinally (se acabó, sin importar cómo). Juntos forman el kit de observabilidad de Reactor: logging, métricas, trazas y limpieza de recursos viven aquí.
import reactor.core.publisher.Flux
fun main() {
Flux.just("pay-001", "pay-002", "pay-003")
.doOnSubscribe { println("[lifecycle] payment stream started") }
.doOnNext { println("[audit] processing $it") }
.doOnComplete { println("[lifecycle] all payments processed") }
.subscribe { println("charged $it") }
}Nos suscribimos a un flujo de tres pagos. doOnSubscribe se dispara una vez cuando se establece la suscripción, doOnNext se dispara por cada pago justo antes de que el suscriptor lo reciba, y doOnComplete se dispara una vez al final. El suscriptor solo imprime "charged …": los hooks lo observaron todo y no alteraron nada.
[lifecycle] payment stream started [audit] processing pay-001 charged pay-001 [audit] processing pay-002 charged pay-002 [audit] processing pay-003 charged pay-003 [lifecycle] all payments processed
doOnNext
fun <T> Flux<T>.doOnNext(onNext: (T) -> Unit): Flux<T>doOnNext registra un Consumer de efecto secundario que se ejecuta en cada senal onNext mientras viaja hacia abajo. Para cada valor el Consumer se invoca primero y luego ese mismo elemento se propaga sin cambios a los suscriptores: los valores nunca se modifican, descartan ni reordenan. Es transparente a las senales terminales: onComplete y onError pasan directos sin disparar el callback (usa doOnComplete/doOnError para eso), y el callback se ejecuta en el hilo que emite el valor.
- Registrar o trazar cada valor emitido durante un pipeline
- Actualizar metricas o contadores por elemento sin alterar el flujo
- Depurar para inspeccionar que fluye por una etapa
- Disparar efectos secundarios sin retorno (p. ej. cacheo, auditoria)
doOnNext es solo para efectos secundarios: ignora su valor de retorno, asi que no puede transformar elementos (usa map para eso). Si el Consumer lanza una excepcion, el error se propaga como senal onError que cancela la secuencia, asi que manten el callback seguro y no bloqueante; trabajo bloqueante aqui detiene el hilo emisor.
Flux.just(1, 2, 3, 4)
.doOnNext { log(it) }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado este pipeline de Reactor, ¿qué emite el Flux hacia abajo?
doOnNext es el caballo de batalla de la familia: ejecuta un callback por cada elemento que pasa por ese punto del pipeline y luego reenvía exactamente el mismo elemento aguas abajo. Piénsalo como una escucha telefónica: ideal para registrar una orden camino a persistirse, incrementar una métrica o alimentar una pista de auditoría.
Como se ubica entre dos operadores, la posición importa: un doOnNext antes de un map ve los valores crudos; uno después ve los transformados. Eso también lo convierte en una excelente sonda de depuración para cualquier etapa de la cadena.
import reactor.core.publisher.Flux
fun main() {
Flux.just("order-1", "order-2", "order-3")
.doOnNext { println("Processing: $it") }
.map { it.uppercase() }
.doOnNext { println("Transformed: $it") }
.subscribe { println("Final: $it") }
}Tenemos un flujo de tres ids de orden. El primer doOnNext ve cada id crudo, map lo pasa a mayúsculas, y el segundo doOnNext ve el valor transformado justo antes de que el suscriptor imprima el resultado final. Los mismos tres elementos en cada etapa: solo observados, nunca modificados.
Processing: order-1 Transformed: ORDER-1 Final: ORDER-1 Processing: order-2 Transformed: ORDER-2 Final: ORDER-2 Processing: order-3 Transformed: ORDER-3 Final: ORDER-3
Un caso más realista: auditar cada evento de usuario mientras solo reaccionamos a uno de ellos.
import reactor.core.publisher.Flux
fun main() {
Flux.just("login", "view_cart", "checkout", "logout")
.doOnNext { event -> println("[audit] user event -> $event") }
.filter { it == "checkout" }
.subscribe { println("triggering payment for: $it") }
}Cada evento pasa por el hook de auditoría, incluidos los que filter descarta después. El hook está por encima del filtro, así que logout también se audita aunque el suscriptor solo llegue a ver checkout.
[audit] user event -> login [audit] user event -> view_cart [audit] user event -> checkout triggering payment for: checkout [audit] user event -> logout
doOnNext ignora el valor de retorno de la lambda, así que no puede transformar elementos: para eso usa map. Y si el callback lanza una excepción, esta se propaga como un onError que termina la secuencia, así que mantén los hooks baratos, seguros y no bloqueantes.
doOnError
fun <T> Flux<T>.doOnError(onError: (Throwable) -> Unit): Flux<T>doOnError registra una devolucion de llamada de efecto secundario que se ejecuta justo cuando la fuente termina con una senal onError, recibiendo el Throwable un instante antes de que se propague aguas abajo. Es una observacion transparente: nunca se traga, reemplaza ni recupera del error, por lo que la misma excepcion sigue fluyendo al siguiente operador y finalmente al manejador de error del suscriptor. La devolucion se ejecuta de forma sincrona en el hilo que emitio el error, y las senales onNext y onComplete pasan sin alterarse.
- Registrar o anotar la excepcion antes de que suba
- Emitir metricas o incrementar contadores de error
- Disparar limpieza o alertas como efecto secundario ante un fallo
- Agregar trazas sin alterar el flujo del error
doOnError solo observa; NO maneja ni recupera. El error sigue terminando el flujo y llega al suscriptor, asi que una excepcion no controlada lanzada dentro de la propia devolucion es fatal y se combina (via Exceptions.addThrowable) con la original. Usa onErrorResume/onErrorReturn/onErrorComplete para recuperarte de verdad.
Flux.just(1, 2)
.concatWith(Flux.error(RuntimeException("boom")))
.doOnError { log(it) }
.subscribe(
{ v -> println("next=" + v) },
{ e -> println("error=" + e.message) }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado el Flux de abajo, ¿qué observa el suscriptor?
doOnError es la escucha del lado de los errores: cuando la secuencia termina con una excepción, el callback ve el Throwable un instante antes de que continúe aguas abajo. Nunca se traga el error: después de tu línea de log o tu métrica, exactamente la misma excepción sigue cayendo hacia el suscriptor (o hacia un operador de recuperación como onErrorReturn).
También existe una sobrecarga filtrada por clase — doOnError(IllegalStateException::class.java) { … } — que solo observa errores del tipo indicado.
import reactor.core.publisher.Flux
fun main() {
Flux.just(1, 2, 0, 4)
.map { 10 / it }
.doOnError { println("Error detected: ${it.message}") }
.onErrorReturn(-1)
.subscribe { println("Result: $it") }
// Filter by error type
Flux.error<String>(IllegalStateException("bad state"))
.doOnError(IllegalStateException::class.java) {
println("IllegalState caught: ${it.message}")
}
.onErrorReturn("recovered")
.subscribe { println(it) }
}El primer pipeline divide 10 entre cada elemento y explota con el 0. doOnError registra la ArithmeticException, y luego onErrorReturn(-1) — ubicado debajo del hook — convierte el fallo en un valor de respaldo. El segundo pipeline muestra la variante filtrada por clase observando solo IllegalStateException antes de recuperarse.
Result: 10 Result: 5 Error detected: / by zero Result: -1 IllegalState caught: bad state recovered
En producción la combinación típica es métricas + respaldo: registra el fallo y deja que un operador de recuperación mantenga vivo el flujo.
import reactor.core.publisher.Flux
fun main() {
Flux.just(100, 50, 0, 25)
.map { 1000 / it } // divide-by-zero on the 0
.doOnError { err -> println("[metrics] recording failure: ${err.message}") }
.onErrorReturn(-1)
.subscribe { println("quota = $it") }
}Calculamos 1000 / n por cada entrada de cuota. Cuando llega el 0, map lanza la excepción, doOnError registra el fallo para métricas y onErrorReturn emite -1 — así el suscriptor ve dos cuotas sanas y luego el respaldo. Fíjate en que el último elemento (25) nunca se procesa: el error ya había terminado la fuente.
quota = 10 quota = 20 [metrics] recording failure: / by zero quota = -1
Observar no es recuperar: después de doOnError el flujo sigue muerto. Para continuar necesitas onErrorReturn / onErrorResume / retry — y deben estar aguas abajo del hook; de lo contrario el error nunca llega a él.
doOnComplete / doOnTerminate / doAfterTerminate
fun <T> Flux<T>.doOnComplete(onComplete: () -> Unit): Flux<T> // also doOnTerminate(() -> Unit), doAfterTerminate(() -> Unit)doOnComplete registra un callback de efecto secundario que se ejecuta exactamente una vez cuando el flujo upstream emite su señal onComplete, después de que el último valor haya pasado. Es puramente observacional: cada valor fluye hacia abajo sin cambios y la señal de completado se propaga tal cual. Se omite por completo si la secuencia termina con onError o es cancelada. Sus dos hermanos amplían el disparador: doOnTerminate se ejecuta en completado O error, justo antes de reenviar la señal terminal aguas abajo, mientras que doAfterTerminate se ejecuta en ambos finales pero solo después de que el suscriptor ya recibió la señal terminal.
- Registrar logs o metricas cuando un stream finaliza con exito
- Liberar o cerrar un recurso solo al completar limpiamente
- Disparar una accion posterior tras emitir todos los elementos
- Depurar para confirmar que una secuencia realmente completo
doOnComplete NO se ejecuta ante un error ni una cancelacion, asi que no es fiable para limpieza. Si necesitas logica en cada via de terminacion, usa doFinally (cubre completado, error y cancelacion); usa doOnTerminate solo para completado-o-error. Ademas, un Flux vacio igual completa, asi que el callback se dispara aunque no se emita ningun valor.
Flux.just("A", "B", "C", "D")
.doOnComplete { log("done") }
.subscribe { value -> log("next: " + value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que emite este Flux a su suscriptor, y que hace doOnComplete?
Tres hooks observan cómo termina una secuencia. doOnComplete se dispara solo en una finalización limpia y exitosa. doOnTerminate se dispara tanto en completado como en error, justo antes de que la señal terminal se reenvíe aguas abajo. doAfterTerminate también cubre ambos finales, pero corre después de que el suscriptor ya recibió la señal terminal — útil cuando el efecto secundario no debe retrasar la notificación.
Ninguno de los tres se dispara ante una cancelación. Si tu lógica debe correr cuando alguien cuelga a mitad del flujo, lo que buscas es doOnCancel o doFinally.
import reactor.core.publisher.Flux
fun main() {
Flux.just("invoice-1", "invoice-2")
.doOnComplete { println("1. doOnComplete — success only") }
.doOnTerminate { println("2. doOnTerminate — before the terminal signal is forwarded") }
.doAfterTerminate { println("3. doAfterTerminate — after the subscriber saw it") }
.subscribe(
{ println("processed $it") },
{ err -> println("failed: ${err.message}") },
{ println("subscriber onComplete") }
)
}Se procesan ambas facturas y luego la fuente completa. Observa el orden en la salida: doOnComplete y doOnTerminate corren antes del manejador de completado del suscriptor, mientras que doAfterTerminate corre después — la señal terminal ya fue entregada cuando se dispara.
processed invoice-1 processed invoice-2 1. doOnComplete — success only 2. doOnTerminate — before the terminal signal is forwarded subscriber onComplete 3. doAfterTerminate — after the subscriber saw it
El caso de uso clásico de doOnComplete es una acción única cuando un lote termina con éxito:
import reactor.core.publisher.Flux
fun main() {
Flux.just("row1.csv", "row2.csv", "row3.csv")
.doOnNext { println("importing $it") }
.doOnComplete { println("import finished, flushing to DB") }
.subscribe()
}Importamos tres filas de CSV; cuando la fuente completa, doOnComplete ejecuta el flush único. Si alguna fila hubiera fallado, el flush se habría omitido por completo: los hooks de completado son solo-éxito, que es justo lo que quieres para un paso tipo commit.
importing row1.csv importing row2.csv importing row3.csv import finished, flushing to DB
doOnSubscribe / doFirst
fun <T> Flux<T>.doOnSubscribe(onSubscribe: (Subscription) -> Unit): Flux<T> // also doFirst(runnable: () -> Unit): Flux<T>doOnSubscribe ejecuta tu callback exactamente una vez, en el momento en que un suscriptor aguas abajo establece la Subscription, antes de que cualquier onNext/onComplete/onError fluya de vuelta; recibe la Subscription para que puedas inspeccionarla o incluso solicitar (request) desde ella. Es puramente un vistazo con efecto secundario: cada señal original (A, B, C, complete/error) pasa intacta y en orden. doFirst es parecido pero se dispara aún antes: es lo primero que se ejecuta cuando comienza la suscripción, de forma sincrónica en el hilo que suscribe; encadenar varios doFirst los ejecuta en orden inverso (de abajo hacia arriba), mientras que varios doOnSubscribe se ejecutan de arriba hacia abajo.
- Registrar logs o arrancar un cronómetro/métrica justo cuando el flujo se activa
- Inicializar o adquirir un recurso (abrir conexión, activar una bandera) al momento de suscribir
- Trazar 'quién se suscribió y cuándo' para depurar el comportamiento de publishers fríos
- Llamar manualmente a subscription.request(n) para demanda de backpressure personalizada
doOnSubscribe se ejecuta en el hilo que dispara la suscripción (el hilo que suscribe), NO en el hilo donde luego se emiten los elementos; subscribeOn afecta el threading de emisión/request, pero el orden del callback en el momento de suscribir aún puede sorprenderte. Además, al ser un hook de efecto secundario nunca debe lanzar excepciones ni bloquear; una excepción aquí se trata como un error fatal/burbujeado en vez de un onError normal.
Flux.just("A", "B", "C")
.doOnSubscribe { log("go") }
.subscribe { value -> log(value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dada esta tuberia de Reactor, que emite hacia abajo y cuando se registra "go"?
En un Flux frío no pasa nada hasta que alguien se suscribe — y estos dos hooks te dejan marcar ese momento exacto. doOnSubscribe corre cuando se establece la Subscription (incluso recibe el objeto Subscription), lo que lo vuelve el lugar clásico para registrar "consulta iniciada" o arrancar un cronómetro de latencia. doFirst corre aún antes: antes de que la señal de suscripción viaje aguas arriba, así que es literalmente el primer código ejecutado de la cadena.
Una particularidad famosa: varios doFirst se ejecutan de abajo hacia arriba (orden inverso de declaración), mientras que varios doOnSubscribe corren de arriba hacia abajo. El ejemplo lo hace visible.
import reactor.core.publisher.Flux
fun main() {
Flux.just(1, 2, 3)
.doFirst { println("3. doFirst (closest to subscribe)") }
.doFirst { println("2. doFirst (middle)") }
.doFirst { println("1. doFirst (furthest)") }
.doOnSubscribe { println("4. Subscribed!") }
.subscribe { println("Value: $it") }
}La suscripción recorre la cadena de abajo hacia arriba, así que el doFirst declarado al final ("1. furthest") se ejecuta primero y el más cercano a la fuente corre al último. Luego doOnSubscribe se dispara una vez establecida la Subscription, y solo después de todo eso empiezan a fluir los valores.
1. doFirst (furthest) 2. doFirst (middle) 3. doFirst (closest to subscribe) 4. Subscribed! Value: 1 Value: 2 Value: 3
En un sistema real, la pareja se lee como un guion de arranque: abre la conexión, luego anuncia la suscripción y después transmite.
import reactor.core.publisher.Flux
fun main() {
Flux.just("AAPL", "GOOG", "MSFT")
.doFirst { println("opening market-data connection") }
.doOnSubscribe { sub -> println("subscribed, requesting quotes") }
.subscribe { println("quote for $it") }
}Primero se abre la conexión de datos de mercado (doFirst), después se anuncia la suscripción (doOnSubscribe), y solo entonces fluyen las tres cotizaciones hacia el suscriptor — intactas, como siempre con esta familia.
opening market-data connection subscribed, requesting quotes quote for AAPL quote for GOOG quote for MSFT
doOnCancel / doOnRequest
fun <T> Flux<T>.doOnCancel(onCancel: () -> Unit): Flux<T> // also doOnRequest(consumer: (Long) -> Unit): Flux<T>doOnCancel registra un callback que se dispara cuando aguas abajo cancelan la suscripción en ese punto de la cadena — el tercer final silencioso que provocan operadores como take, timeout y switchMap, o una llamada a Disposable.dispose(). El hook solo observa: la cancelación sigue propagándose aguas arriba y detiene la fuente, y ninguna señal de datos se agrega ni se elimina. doOnRequest es el hook espejo para la otra señal de control: ejecuta tu consumer con cada demanda request(n) que sube por la cadena (incluida la solicitud inicial hecha al suscribirse), lo que hace visible el backpressure sin alterarlo.
- Cerrar cursores, conexiones o descriptores cuando un consumidor limitado (take, timeout) cuelga antes de tiempo
- Registrar o contar cancelaciones para detectar clientes que abandonan solicitudes
- Depurar por qué una fuente deja de producir: ¿completó, falló o fue cancelada?
- Inspeccionar la demanda request(n) con doOnRequest para diagnosticar backpressure y prefetch
doOnCancel se dispara SOLO ante una cancelación — guarda silencio tanto en onComplete como en onError, así que no es un hook de limpieza general; combínalo con doFinally cuando el recurso deba liberarse en todos los finales. La posición también importa: los hooks debajo del operador que cancela (p. ej. debajo de take) nunca ven el cancel, porque take lo convierte en un onComplete limpio para su aguas abajo.
Flux.range(1, 100)
.doOnCancel { println("cursor closed") }
.take(2)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Este Flux es un rango de 100 elementos con un hook doOnCancel, limitado por take(2). ¿Qué se imprime?
La cancelación es la tercera forma en que un flujo puede terminar — y la menos visible. take, timeout, switchMap y Disposable.dispose() cancelan suscripciones aguas arriba en silencio: sin onComplete, sin onError, solo una suscripción que se detiene. doOnCancel te da un hook sobre ese evento exacto, para cerrar el cursor, liberar la conexión o registrar quién colgó. Su hermano doOnRequest vuelve observable la otra señal invisible: la demanda request(n) que el backpressure envía aguas arriba.
Ambos hooks observan señales de control en lugar de datos, lo que los convierte en las herramientas de depuración predilectas cuando un pipeline se detiene antes de tiempo o solicita menos de lo que esperabas.
import reactor.core.publisher.Flux
fun main() {
Flux.range(1, 100) // pretend this is a big DB cursor
.doOnRequest { n -> println("[demand] downstream requested $n rows") }
.doOnCancel { println("[cancel] closing DB cursor early") }
.limitRate(4)
.take(5) // cancels upstream after 5 rows
.subscribe { println("row $it") }
}La fuente simula ser un cursor de base de datos con 100 filas. limitRate(4) divide la demanda en lotes: doOnRequest registra la solicitud inicial de 4 filas y, cuando se agotan, una recarga de 3 (limitRate repone al 75% por defecto). take(5) reenvía cinco filas y luego cancela la suscripción aguas arriba — las 95 filas restantes nunca se producen, y doOnCancel cierra el cursor.
[demand] downstream requested 4 rows row 1 row 2 row 3 row 4 [demand] downstream requested 3 rows row 5 [cancel] closing DB cursor early
doOnCancel se dispara solo ante una cancelación — guarda silencio cuando el flujo completa o falla. Si tu limpieza debe correr en todos los finales, usa doFinally: recibe el SignalType (ON_COMPLETE, ON_ERROR o CANCEL), así que igual puedes distinguir los caminos.
doOnEach
fun <T> Flux<T>.doOnEach(signalConsumer: (Signal<T>) -> Unit): Flux<T>doOnEach invoca tu consumidor una vez por cada senal que emite el flujo de origen, envolviendo cada una como un objeto Signal: cada onNext, mas la senal terminal unica de onComplete u onError. Los valores y las senales terminales pasan hacia abajo totalmente sin alterar — es un peek de efecto secundario puro que nunca modifica, filtra ni reordena la secuencia. El callback se ejecuta de forma sincrona en el hilo que emite la senal, y consultas el Signal mediante isOnNext()/isOnComplete()/isOnError() para distinguir el tipo.
- Logging o trazado unificado de cada evento (valores, completado, error) en un solo callback
- Capturar o restaurar el Reactor Context, que Signal expone mediante getContextView()
- Metricas que necesitan contar tanto las emisiones como el resultado terminal juntos
- Depurar un pipeline donde quieres un unico punto de observacion para todos los tipos de senal
El callback recibe un envoltorio Signal, no un valor crudo — llamar a get() sobre una senal onComplete u onError devuelve null, asi que debes protegerte con signal.isOnNext() antes de leer el valor o registraras nulls espurios. Ademas, doOnEach es puramente observacional: lanzar una excepcion dentro o hacer trabajo bloqueante interrumpira el pipeline, y no te permite transformar la senal.
Flux.just("A", "B", "C", "D")
.doOnEach { s -> log(s) }
.subscribe { v -> print(v) }
// log is called for each onNext signal plus the onComplete signal🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux aguas abajo, y que hace doOnEach?
doOnEach es el vistazo todo-en-uno: un único callback que recibe cada señal — cada onNext, más el onComplete u onError terminal — envuelta en un objeto Signal que puedes inspeccionar. En lugar de apilar doOnNext + doOnComplete + doOnError, tienes un solo punto de observación, que es justo lo que las capas de logging estructurado y trazado necesitan.
import reactor.core.publisher.Flux
fun main() {
Flux.just(1, 2, 3)
.doOnEach { signal ->
when {
signal.isOnNext -> println("Signal: onNext(${signal.get()})")
signal.isOnComplete -> println("Signal: onComplete")
signal.isOnError -> println("Signal: onError(${signal.throwable?.message})")
}
}
.subscribe()
}Por cada uno de los tres valores el callback recibe un Signal onNext; cuando la fuente termina recibe un último Signal onComplete. Distinguimos con isOnNext / isOnComplete / isOnError para imprimir cada tipo: tres señales de valor y una terminal, cuatro invocaciones del callback en total.
Signal: onNext(1) Signal: onNext(2) Signal: onNext(3) Signal: onComplete
Imprimir signal.get() directamente muestra por qué conviene distinguir primero: las señales terminales no llevan valor.
import reactor.core.publisher.Flux
fun main() {
Flux.just("a", "b")
.doOnEach { signal ->
println("signal=${signal.type} value=${signal.get()}")
}
.subscribe()
}Los dos valores se imprimen con su contenido, pero la última línea dice value=null: la señal onComplete no lleva nada dentro. Protege siempre signal.get() con isOnNext antes de usar el valor.
signal=onNext value=a signal=onNext value=b signal=onComplete value=null
Signal también expone el Context del suscriptor mediante signal.contextView — por eso doOnEach es el puente estándar para logging contextual estilo MDC en aplicaciones Reactor.
doFinally
fun <T> Flux<T>.doFinally(onFinally: (SignalType) -> Unit): Flux<T>doFinally registra un callback que se ejecuta exactamente una vez después de que la secuencia termina por CUALQUIER motivo, recibiendo un SignalType de ON_COMPLETE, ON_ERROR o CANCEL. Todos los valores de upstream pasan sin modificarse y el callback se dispara DESPUÉS de que la señal terminal se propaga hacia abajo — en el marble, A, B, C se emiten normal y 'fin' corre una vez que llega la barra de complete. Es el equivalente reactivo de un bloque finally, ideal para limpieza garantizada de recursos sin importar cómo termine el flujo.
- Cerrar conexiones, archivos o cursores de BD al terminar el flujo
- Detener temporizadores o decrementar metricas al terminar
- Liberar un recurso que debe cerrarse en complete, error Y cancel
- Limpieza ligada al ciclo de vida de la suscripcion y no a una senal concreta
doFinally tambien se dispara en CANCEL, algo que doOnComplete y doOnError NO capturan, asi que usalo justo cuando necesitas limpieza ante cancelacion. Con varios operadores el callback corre en el subscriber mas cercano por Subscription; si asumes mal que siempre significa 'exito' puedes liberar dos veces o filtrar recursos. Ademas, una excepcion lanzada dentro del callback se descarta al hook global, no se propaga hacia abajo.
Flux.just("A", "B", "C", "D")
.doFinally { type -> println("finally: " + type) }
.take(2)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. El hook doFinally está AGUAS ARRIBA de take(2). ¿Qué imprime este pipeline?
doFinally es el try/finally reactivo: corre exactamente una vez después de que la secuencia termina, sin importar cómo — completado, error o cancelación — y recibe un SignalType que te dice cuál fue. Eso lo convierte en el lugar más seguro para liberar recursos: conexiones, descriptores de archivo, semáforos, métricas.
A diferencia de doOnTerminate, se dispara después de que la señal terminal ya se propagó aguas abajo. Y dónde lo coloques decide qué final ve, porque la cancelación solo viaja aguas arriba:
import reactor.core.publisher.Flux
import reactor.core.publisher.SignalType
fun main() {
Flux.just("frame-1", "frame-2", "frame-3", "frame-4", "frame-5")
.doFinally { type: SignalType -> println("upstream doFinally: $type") }
.take(3) // cancels upstream after 3 frames
.doFinally { type: SignalType -> println("downstream doFinally: $type") }
.subscribe { println("rendering $it") }
}take(3) reenvía tres cuadros, luego cancela su suscripción aguas arriba y envía onComplete aguas abajo. Por eso el hook de arriba reporta cancel — desde su punto de vista alguien colgó — mientras el de abajo reporta onComplete, porque recibió la finalización limpia de take. Un operador, dos finales distintos, según dónde estés parado.
rendering frame-1 rendering frame-2 rendering frame-3 upstream doFinally: cancel downstream doFinally: onComplete
El uso de todos los días es la limpieza garantizada de recursos:
import reactor.core.publisher.Flux
import reactor.core.publisher.SignalType
fun main() {
Flux.just("config.yaml", "secrets.env")
.doOnNext { println("reading $it") }
.doFinally { signal: SignalType ->
println("closing file handle (reason=$signal)")
}
.subscribe { println("loaded $it") }
}Ambos archivos cargan con normalidad, así que el hook reporta reason=onComplete — pero exactamente la misma línea habría corrido ante un error o una cancelación, que es justamente el sentido de usar doFinally para la limpieza.
reading config.yaml loaded config.yaml reading secrets.env loaded secrets.env closing file handle (reason=onComplete)
Un primo cercano — para valores en lugar de suscripciones — es doOnDiscard. Operadores como filter, skip y distinct descartan elementos como parte normal de su trabajo; si esos elementos retienen recursos (buffers de un pool, descriptores abiertos), descartarlos en silencio provocaría fugas. doOnDiscard registra un hook que observa cada valor descartado por los operadores aguas arriba para que puedas liberarlo.
import reactor.core.publisher.Flux
fun main() {
Flux.just("order-11", "order-12", "cancelled-13", "order-14")
.filter { !it.startsWith("cancelled") }
.doOnDiscard(String::class.java) { println("[discard] releasing buffer for $it") }
.subscribe { println("processing $it") }
}filter descarta la orden cancelada, y doOnDiscard — instalado aguas abajo del filtro — ve el elemento descartado de inmediato y libera su buffer, mientras las órdenes sobrevivientes fluyen intactas.
processing order-11 processing order-12 [discard] releasing buffer for cancelled-13 processing order-14
Los callbacks de doFinally no deben lanzar excepciones: una excepción ahí se envía al hook global de errores, no se propaga aguas abajo. Y doOnDiscard solo cubre elementos descartados sincrónicamente por operadores aguas arriba — trátalo como un canal de limpieza de mejor esfuerzo, no como una garantía.
log
fun <T> Flux<T>.log(): Flux<T> // also log(category), log(category, Level, SignalType...)log() es un operador de paso: observa cada senal de Reactive Streams — onSubscribe, request(n), onNext, onComplete, onError y cancel — y escribe una linea de traza por cada una, reenviando luego la senal original intacta y en el mismo orden. No modifica nada del flujo de datos; los valores, las senales terminales y los tiempos llegan aguas abajo igual que antes, asi que es transparente para la correccion. El registro se ejecuta en el hilo que emite cada senal, por lo que el nombre del hilo en cada linea refleja el Scheduler activo en ese punto de la cadena.
- Depurar por que una cadena reactiva no emite nada, se cuelga o completa antes de tiempo
- Inspeccionar el backpressure viendo la demanda real de request(n) que sube por la cadena
- Confirmar en que Scheduler/hilo corre un operador tras publishOn/subscribeOn
- Rastrear la senal terminal exacta (onComplete vs onError) y su posicion
log() no es gratis: formatea y escribe una linea por cada senal, asi que dejarlo en una ruta caliente puede inundar los logs y ralentizar de forma notable flujos de alto rendimiento — es una herramienta de depuracion, no instrumentacion de produccion. Su posicion importa: ponerlo antes o despues de un operador muestra senales distintas, y en TRACE/DEBUG tambien reporta eventos request y cancel que la mayoria de operadores peek (como doOnNext) nunca exponen.
Flux.just("A", "B", "C")
.log()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Que emite este Flux hacia abajo?
log() es la forma más rápida de ver qué está haciendo realmente una cadena reactiva: traza cada evento de Reactive Streams en ese punto — onSubscribe, request(n), onNext, onComplete, onError y cancel — imprimiendo una línea por señal mientras reenvía todo intacto. Cuando un pipeline no emite nada, se cuelga o completa antes de tiempo, poner un log() en el punto sospechoso suele responder la pregunta.
import reactor.core.publisher.Flux
fun main() {
Flux.range(1, 3)
.log("my.flux") // logs with category "my.flux"
.map { it * 2 }
.log("after.map")
.subscribe()
}Dos etapas de log rodean el map y la salida se intercala: my.flux traza los valores de la fuente (1, 2, 3) mientras after.map traza los duplicados (2, 4, 6), y cada etapa registra primero su propio onSubscribe y request. La posición determina lo que ves — la misma cadena se lee completamente distinto desde los dos puntos de observación.
[ INFO] (main) my.flux - onSubscribe(FluxShim.ShimSubscription) [ INFO] (main) my.flux - request(unbounded) [ INFO] (main) after.map - onSubscribe(FluxShim.ShimSubscription) [ INFO] (main) after.map - request(unbounded) [ INFO] (main) my.flux - onNext(1) [ INFO] (main) after.map - onNext(2) [ INFO] (main) my.flux - onNext(2) [ INFO] (main) after.map - onNext(4) [ INFO] (main) my.flux - onNext(3) [ INFO] (main) after.map - onNext(6) [ INFO] (main) my.flux - onComplete() [ INFO] (main) after.map - onComplete()
Con una categoría con nombre, la traza se integra directo en tu configuración de logging, así puedes subir o silenciar un solo pipeline:
import reactor.core.publisher.Flux
fun main() {
Flux.just("eur", "usd", "jpy")
.map { it.uppercase() }
.log("fx.rates") // logs subscribe/request/onNext/onComplete
.blockLast()
}Convertimos tres códigos de moneda a mayúsculas y bloqueamos hasta el último. La categoría fx.rates registra la suscripción, el request ilimitado que emite blockLast, cada valor en mayúsculas y la finalización — la vida completa del flujo en seis líneas.
[ INFO] (main) fx.rates - onSubscribe(FluxShim.ShimSubscription) [ INFO] (main) fx.rates - request(unbounded) [ INFO] (main) fx.rates - onNext(EUR) [ INFO] (main) fx.rates - onNext(USD) [ INFO] (main) fx.rates - onNext(JPY) [ INFO] (main) fx.rates - onComplete()
log() formatea y escribe una línea por cada señal, así que dejarlo en una ruta caliente inunda los logs y ralentiza flujos de alto rendimiento. Es una herramienta de depuración — para observabilidad en producción prefiere name()/tap() con Micrometer.
materialize / dematerialize
fun <T> Flux<T>.materialize(): Flux<Signal<T>>materialize() convierte cada evento reactivo en una emision onNext normal que transporta un Signal<T>. Cada onNext(a) de la fuente se vuelve un Signal de tipo ON_NEXT que envuelve el valor, en orden; el onComplete u onError terminal se transforma en un Signal de tipo ON_COMPLETE u ON_ERROR emitido como un valor normal, y solo DESPUES de eso el Flux materializado completa normalmente. Asi, los errores ya no terminan el flujo como errores, sino que viajan aguas abajo como datos, permitiendote inspeccionar o transformar las senales terminales como cualquier otro elemento.
- Inspeccionar o registrar senales terminales (complete vs error) dentro del propio pipeline
- Almacenar o grabar el historial completo de eventos para reproducirlo o testearlo
- Tratar los errores como datos para filtrarlos, contarlos o transformarlos sin abortar el flujo
- Combinar con dematerialize() para reconstruir las senales tras un procesamiento intermedio
Tras materialize() el flujo aguas abajo nunca recibe un onError real: un error de la fuente llega como un Signal normal y luego el Flux completa con exito, asi que onErrorResume/retry colocados DESPUES de materialize() jamas se activaran. Debes llamar a dematerialize() para restaurar la semantica original de onNext/onComplete/onError, y los operadores de manejo de errores deben ir antes de materialize o despues de dematerialize.
Flux.just("a", "b")
.materialize()
.doOnNext { signal -> println(signal) }
.subscribe()🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux (como elementos onNext) antes de completar?
materialize() cosifica el flujo: cada señal — cada valor, e incluso la finalización o el error terminal — se convierte en un objeto Signal emitido como un elemento normal. El resultado es un Flux<Signal<T>> que siempre completa con éxito, porque los errores ahora son solo datos. dematerialize() es el inverso exacto: convierte los elementos Signal de vuelta en señales reales onNext / onComplete / onError.
La pareja habilita trucos estilo event-sourcing: grabar el historial completo de un flujo, transportar señales a través de una frontera o contar errores sin morir en el intento.
import reactor.core.publisher.Flux
fun main() {
// materialize: signals become data
Flux.just(1, 2, 3)
.materialize()
.subscribe { signal ->
println("Signal: $signal (type=${signal.type})")
}
// dematerialize: turn signals back into real signals
Flux.just(1, 2, 3)
.materialize()
.dematerialize<Int>()
.subscribe(
{ println("Value: $it") },
{ println("Error: $it") },
{ println("Complete") }
)
}El primer pipeline imprime cuatro elementos ordinarios: tres Signals onNext y un Signal(onComplete) — prueba de que hasta el evento terminal se volvió dato. El segundo pipeline hace el viaje de ida y vuelta con materialize + dematerialize, restaurando los valores originales y la finalización para un suscriptor clásico.
Signal: onNext(1) (type=onNext) Signal: onNext(2) (type=onNext) Signal: onNext(3) (type=onNext) Signal: onComplete() (type=onComplete) Value: 1 Value: 2 Value: 3 Complete
Después de materialize() el flujo aguas abajo nunca ve un onError real: un fallo de la fuente llega como un valor Signal normal y el Flux luego completa con éxito. onErrorResume / retry colocados después de materialize() jamás se activarán — el manejo de errores va antes de materialize o después de dematerialize.
Buffers y ventanas
Hasta ahora la mayoría de los operadores trabajan con un elemento a la vez. Los pipelines reales necesitan todo el tiempo lo contrario: "inserta estas filas en lotes de 500", "vacía lo que llegó en el último segundo", "corta este log en cada COMMIT". Eso es exactamente lo que hace esta familia: agrupa un flujo plano en bloques.
La familia se divide limpiamente en dos. Los operadores buffer* juntan elementos en Lists — fáciles de consumir, pero cada lote se materializa en memoria antes de emitirse. Los operadores window* (y groupBy) en cambio cortan el flujo en sub-Fluxes en vivo que consumes con flatMap o concatMap. Ambos lados ofrecen los mismos límites:
- Tamaño fijo → buffer(n) / window(n)
- Tamaño O tiempo, lo que ocurra primero → bufferTimeout / windowTimeout
- Un predicado sobre los datos → bufferUntil, bufferWhile / windowUntil, windowWhile
- Un cambio de clave entre vecinos → bufferUntilChanged / windowUntilChanged
- Publishers acompañantes que abren y cierran límites → bufferWhen / windowWhen
- Una clave por elemento, sin importar la posición → groupBy
buffer
buffer es el caballito de batalla del batching: junta los valores que van llegando en una List y pasa cada List llena aguas abajo como una sola emisión, convirtiendo un Flux<T> en un Flux<List<T>>. El caso de uso clásico es trocear trabajo para sistemas que prefieren lo masivo — bases de datos, índices de búsqueda, brokers de mensajes — donde "un insert de 500 filas" le gana a "500 inserts de una fila".
fun <T> Flux<T>.buffer(maxSize: Int): Flux<List<T>>buffer(N) acumula los valores de la fuente en una List y emite esa List hacia abajo cada vez que se llena hasta N elementos, conservando el orden de la fuente. Cuando la fuente completa, cualquier buffer parcialmente lleno (con menos de N elementos) se emite como lista final antes de propagar la senal de complete. Un error de la fuente se reenvia de inmediato y el buffer en curso se descarta, no se emite.
- Agrupar registros antes de un insert masivo en BD o escritura HTTP
- Agrupar eventos en bloques de tamano fijo para procesar por lotes
- Reducir la frecuencia de llamadas enviando N elementos a la vez
- Trocear un stream grande para procesar lotes en paralelo
Al completar, buffer emite la lista parcial restante (p. ej. 5 elementos con buffer(2) produce [1,2],[3,4],[5]), asi que no asumas que toda lista emitida tiene exactamente N elementos. Ademas, con una fuente basada en tiempo o que nunca completa, un buffer parcial atascado podria no vaciarse nunca salvo que uses bufferTimeout.
Flux.just(1, 2, 3, 4)
.buffer(2)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux antes de completar?
import reactor.core.publisher.Flux
fun main() {
Flux.just("ORD-1", "ORD-2", "ORD-3", "ORD-4", "ORD-5", "ORD-6", "ORD-7")
.buffer(3)
.subscribe { batch -> println("bulk insert (${batch.size}): $batch") }
}Tenemos un flujo de siete IDs de órdenes. buffer(3) los junta en silencio — no se emite nada mientras un lote se está llenando — y en cuanto llega el tercer elemento, la List completa sale como una sola canica. Al final nos suscribimos e imprimimos cada lote: dos lotes llenos de tres y, cuando la fuente completa, el ORD-7 restante se vacía como un último lote más pequeño.
bulk insert (3): [ORD-1, ORD-2, ORD-3] bulk insert (3): [ORD-4, ORD-5, ORD-6] bulk insert (1): [ORD-7]
buffer(size, skip) habilita dos formas más. skip significa "cuántos elementos pasan hasta que se abre el siguiente buffer": con skip menor que size los buffers se solapan (los vecinos comparten elementos — ventanas deslizantes), y con skip mayor que size quedan huecos (los elementos entre buffers se descartan).
import reactor.core.publisher.Flux
fun main() {
// Overlapping buffers: size=3, skip=2 (a new buffer opens every 2 elements)
Flux.range(1, 6)
.buffer(3, 2)
.subscribe { println("Overlap: $it") }
// Dropping buffers: size=2, skip=4 (elements between buffers are skipped)
Flux.range(1, 10)
.buffer(2, 4)
.subscribe { println("Drop: $it") }
}Overlap: [1, 2, 3] Overlap: [3, 4, 5] Overlap: [5, 6] Drop: [1, 2] Drop: [5, 6] Drop: [9, 10]
No asumas que cada lista tiene exactamente N elementos: el buffer parcial final se emite al completar ([ORD-7] arriba). Si la fuente falla, el buffer en curso se descarta, no se emite. Y con fuentes que pueden quedarse calladas sin completar, prefiere bufferTimeout para que un lote a medio llenar no quede colgado para siempre.
bufferTimeout
bufferTimeout es micro-batching con garantía de latencia: vacía el buffer actual cuando alcanza maxSize O cuando transcurre maxTime — lo que ocurra primero. Es el operador que quieres entre un productor parlanchín y un destino costoso (eventos de analítica, envío de logs, métricas). El tamaño solo puede dejar varado un lote a medio llenar en períodos tranquilos; el tiempo solo puede inundarte durante ráfagas. bufferTimeout acota ambos.
fun <T> Flux<T>.bufferTimeout(maxSize: Int, maxTime: Duration): Flux<List<T>>Acumula los elementos de upstream en una List y emite ese buffer en cuanto se cumple LO PRIMERO de dos condiciones: alcanzar maxSize O que transcurra maxTime desde que se abrió el buffer. El disparo por tamaño reinicia el temporizador, de modo que un volcado por conteo reinicia la ventana para el siguiente lote; una ventana vacía no emite una lista vacía. Al completarse, cualquier buffer parcialmente lleno se emite antes de onComplete, y un error se propaga de inmediato (el buffer en curso se descarta). El timeout corre por defecto en Schedulers.parallel(), así que downstream puede recibir los buffers en un hilo del temporizador.
- Micro-batching de escrituras a BD o API externa para reducir round-trips
- Acotar la latencia en flujos en ráfagas (volcar al llegar a N o cada X ms)
- Agrupar registros de logs/métricas/eventos antes de un flush masivo
- Suavizar productores muy frecuentes en bloques regulares y acotados
El temporizador es por buffer y se reinicia tras cada volcado: NO es un tick fijo de reloj. Como la emisión puede ocurrir en el hilo del temporizador del scheduler parallel, el trabajo de downstream corre fuera del hilo de la fuente salvo que uses publishOn; y valores muy pequeños de maxTime generan muchos buffers pequeños, añadiendo sobrecarga en lugar de ahorrarla.
Flux.just("a", "b", "c", "d", "e", "f")
.bufferTimeout(3, Duration.ofSeconds(1))
.subscribe { batch -> println(batch) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux cuando se ejecuta el println?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(80))
.map { "evt-$it" }
.bufferTimeout(5, Duration.ofMillis(250))
.take(3)
.doOnNext { println("flushed batch: $it") }
.blockLast()
}Tenemos un evento cada 80 ms y un buffer que admite hasta 5 elementos o 250 ms, lo que se cumpla primero. Como en cada ventana de 250 ms solo caben tres eventos, el temporizador gana cada ronda y cada lote vaciado lleva tres eventos. take(3) detiene la demo tras tres lotes, y blockLast() mantiene vivo el main hasta entonces.
flushed batch: [evt-0, evt-1, evt-2] flushed batch: [evt-3, evt-4, evt-5] flushed batch: [evt-6, evt-7, evt-8]
El temporizador es por buffer: se reinicia cada vez que un buffer se vacía, ya sea por conteo o por tiempo — no es un tick fijo de reloj. Además, los vaciados disparados por timeout corren en Schedulers.parallel() por defecto, así que el código aguas abajo puede encontrarse de repente en un hilo del temporizador.
bufferUntil / bufferWhile
A veces el límite no es un conteo ni un reloj — vive en los datos mismos. bufferUntil cierra el lote actual cuando un predicado devuelve true, conservando el elemento disparador como último item de ese lote. bufferWhile mantiene un lote abierto mientras el predicado se cumple, y el elemento que lo rompe se descarta por completo — nunca aparece en ninguna lista. Piensa en datos con delimitadores: un marcador COMMIT que termina una transacción, un registro END que cierra una página, una lectura en cero que separa ráfagas de mediciones.
fun <T> Flux<T>.bufferUntil(predicate: (T) -> Boolean): Flux<List<T>> // also bufferWhile(predicate)bufferUntil acumula los valores en una List y la emite en cuanto el predicado devuelve true, INCLUYENDO el elemento que disparó el cierre como último item del buffer; luego comienza un buffer nuevo. bufferWhile, en cambio, solo agrega elementos mientras el predicado es true y cierra (emite) el buffer cuando devuelve false, DESCARTANDO por completo ese elemento false. Al completarse, cualquier buffer pendiente no vacío se emite antes del onComplete; un onError se propaga de inmediato y descarta el buffer en curso.
- Agrupar líneas de log o eventos hasta que aparezca un registro delimitador/centinela
- Agrupar registros en streaming por un marcador de 'commit' o 'fin de registro'
- Dividir un stream plano en bloques delimitados por una regla de dominio (p. ej. cada múltiplo de N)
- Reensamblar mensajes multiparte donde un flag marca el fragmento final
La diferencia clave es la inclusividad: bufferUntil CONSERVA el elemento límite (cierra de forma inclusiva), mientras que bufferWhile DESCARTA el elemento que no cumple el predicado, por lo que ese valor no aparece en ninguna lista de salida. Además, si el predicado nunca se cumple, todos los valores se acumulan en un único buffer que solo se emite al completar (riesgo de memoria sin límite); bufferUntil tiene una sobrecarga cutBefore que inicia el nuevo buffer con el elemento disparador.
Flux.just(1, 2, 3, 4, 5, 6, 7)
.bufferUntil { it % 3 == 0 }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux?
import reactor.core.publisher.Flux
fun main() {
Flux.just("wake up", "coffee", "COMMIT", "code", "review", "COMMIT", "deploy")
.bufferUntil { it == "COMMIT" }
.subscribe { println("Until: $it") }
Flux.just(4, 8, 15, 0, 16, 23, 0, 42)
.bufferWhile { it != 0 }
.subscribe { println("While: $it") }
}El primer flujo es una bitácora de trabajo donde COMMIT termina cada transacción: bufferUntil emite cada lote con el marcador COMMIT incluido, y el "deploy" final se vacía al completar. El segundo flujo usa el 0 como separador: bufferWhile junta valores mientras sean distintos de cero y se traga cada 0 en silencio — fíjate que ningún cero aparece en la salida.
Until: [wake up, coffee, COMMIT] Until: [code, review, COMMIT] Until: [deploy] While: [4, 8, 15] While: [16, 23] While: [42]
¿Necesitas que el delimitador INICIE el siguiente lote en vez de terminar el actual? bufferUntil tiene una sobrecarga cutBefore: bufferUntil(predicate, true) abre un buffer nuevo con cada elemento que coincide — útil cuando los registros empiezan con una línea de encabezado. Y recuerda la asimetría: until conserva el elemento límite, while lo descarta.
bufferUntilChanged / windowUntilChanged
fun <T, V> Flux<T>.bufferUntilChanged(keySelector: (T) -> V): Flux<List<T>> // also windowUntilChangedCalcula una clave por elemento y la compara con la clave del elemento anterior: mientras las claves sean iguales (por equals, o por una sobrecarga opcional con keyComparator) los valores se acumulan en la misma List; el primer elemento con una clave DISTINTA vacía la racha anterior aguas abajo y se convierte en el primer item de un buffer nuevo. Al completar, la racha pendiente se vacía antes del onComplete; un error se propaga de inmediato y descarta el buffer en curso. La variante sin argumentos usa el propio elemento como clave, y windowUntilChanged emite cada racha como un Flux interno en vivo en lugar de una List.
- Trocear un export ordenado por clave en lotes por clave sin retener todo el stream
- Colapsar estados repetidos (niveles de log, estado de sensores) en rachas para resumir
- Agrupar actividad contigua — puntos GPS por tramo de ruta, ticks por símbolo en un feed
- Codificar por rachas (run-length) señales ruidosas antes de guardarlas o transmitirlas
Solo agrupa claves iguales CONSECUTIVAS: una clave que reaparece después de otra distinta inicia un buffer totalmente nuevo (a diferencia de groupBy, que la enruta de vuelta a su grupo existente). Eso acota la memoria a una racha — pero el riesgo inverso persiste: una racha que nunca cambia de clave crece sin límite, así que asegúrate de que otra cosa (bufferTimeout, take) acote las rachas patológicas.
Flux.just("a", "a", "b", "b", "b", "a")
.bufferUntilChanged()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux antes de completar?
bufferUntilChanged ni siquiera te pide describir el límite — el límite es "cambió la clave". Compara la clave de cada elemento con la del anterior y, mientras coincidan, sigue apilando valores en la misma lista; el primer elemento con una clave distinta vacía la racha anterior y empieza una nueva. Es agrupación por rachas para streams, y brilla con datos que llegan ordenados o naturalmente agrupados: cotizaciones de bolsa, niveles de log, puntos GPS por tramo de ruta.
import reactor.core.publisher.Flux
data class Tick(val symbol: String, val price: Double)
fun main() {
Flux.just(
Tick("AAPL", 191.2), Tick("AAPL", 191.4),
Tick("MSFT", 415.0),
Tick("AAPL", 191.1), Tick("AAPL", 191.3)
)
.bufferUntilChanged { it.symbol }
.subscribe { run ->
println("${run.first().symbol} run of ${run.size}: ${run.map { it.price }}")
}
}Transmitimos cinco ticks de mercado. Los primeros dos comparten la clave AAPL, así que se apilan en una racha. El tick de MSFT trae otra clave — eso vacía [191.2, 191.4] y abre una racha MSFT. Cuando AAPL vuelve, la racha MSFT de un solo tick se vacía, y el par final de AAPL se emite al completar. Fíjate que las dos rachas de AAPL quedan separadas: solo se agrupan elementos consecutivos.
AAPL run of 2: [191.2, 191.4] MSFT run of 1: [415.0] AAPL run of 2: [191.1, 191.3]
Cada operador buffer tiene un gemelo de ventanas, y este no es la excepción: windowUntilChanged emite cada racha como un Flux en vivo en lugar de una List materializada.
import reactor.core.publisher.Flux
fun main() {
Flux.just("info", "info", "error", "error", "error", "info")
.windowUntilChanged()
.concatMap { window -> window.collectList() }
.subscribe { println("run: $it") }
}run: [info, info] run: [error, error, error] run: [info]
bufferUntilChanged NO es groupBy: cuando una clave reaparece más tarde, inicia un buffer totalmente nuevo en vez de reunirse con el anterior. Justo eso lo hace seguro para espacios de claves sin límite — solo mantiene una racha en memoria a la vez. Pero cuidado con la otra dirección: una racha que nunca termina crece sin límite.
bufferWhen
bufferWhen le entrega el control de los límites a otros publishers: un acompañante abre buffers, y por cada valor de apertura un segundo acompañante derivado decide cuándo se cierra ese buffer en particular. Es el miembro más flexible — y más traicionero — de la familia: los buffers pueden solaparse (un elemento cae en varias listas) o dejar huecos (un elemento que llega cuando no hay nada abierto simplemente se descarta).
fun <T, V> Flux<T>.bufferWhen(bucketOpening: Publisher<out U>, closeSelector: (U) -> Publisher<V>): Flux<List<T>>Cada vez que el Publisher de apertura (bucketOpening) emite un valor, se abre un nuevo buffer; ese valor se pasa a closeSelector para derivar un Publisher de cierre, y el buffer acumula cada elemento de la fuente hasta que ese Publisher de cierre emite (o completa), momento en el que se emite la List acumulada. Los buffers pueden solaparse o dejar huecos segun el momento de apertura y cierre, por lo que un mismo elemento puede caer en varios buffers abiertos a la vez o en ninguno. Cuando la fuente completa, los buffers aun abiertos se emiten antes de la senal de completado; un error de la fuente o de cualquier companion se propaga y descarta los buffers en curso.
- Crear ventanas dinamicas, posiblemente solapadas, cuya duracion depende del valor de apertura
- Agrupar eventos en sesiones que inician con actividad del usuario y cierran con un timeout de inactividad
- Muestrear rafagas donde cada apertura dispara una ventana de recoleccion de tamano distinto
- Agrupar datos usando flujos de control externos en vez de tamanos o tiempos fijos
Cada valor de apertura deriva su PROPIO Publisher de cierre, asi que los buffers pueden solaparse y un elemento puede duplicarse en varias listas (o perderse si ningun buffer esta abierto) — no es una particion simple sin solapamiento. Ademas, el Publisher de cierre solo necesita emitir UNA senal (o completar) para cerrar; si nunca emite, ese buffer queda abierto y solo se vacia cuando la fuente termina.
Flux.just(1, 2, 3, 4, 5)
.bufferWhen({ openSignals }) { Mono.delay(Duration.ofMillis(20)) }
.subscribe { batch -> println(batch) }
// closing signals fall after [1,2] and [3,4]🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dada la linea de tiempo de marbles (items 1..5 con senales de cierre tras 1,2 y luego 3,4), que emite este Flux antes de completar?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(50))
.map { "tick-$it" }
.bufferWhen(
Flux.interval(Duration.ofMillis(120)), // opens a buffer
java.util.function.Function { Flux.interval(Duration.ofMillis(90)) } // closes it 90ms later
)
.take(3)
.doOnNext { println("captured: $it") }
.blockLast()
}Los ticks llegan cada 50 ms. Cada 120 ms el intervalo de apertura inicia un buffer nuevo, y cada buffer se cierra 90 ms después de abrirse. Ese ritmo de 120/90 deja huecos de ~30 ms entre buffers — mira con atención la salida: falta tick-6, porque llegó en un hueco cuando no había ningún buffer abierto.
captured: [tick-2, tick-3] captured: [tick-4, tick-5] captured: [tick-7]
Si un publisher de cierre nunca emite, su buffer queda abierto hasta que la fuente termina (los buffers aún abiertos se vacían al completar). Y como cada apertura genera su propio acompañante de cierre, nada impide que los buffers se solapen — bufferWhen es un superconjunto del buffering por límites, no solo un bufferTimeout más elegante.
window
window es el gemelo streaming de buffer: en vez de juntar elementos en Lists, corta la fuente en Fluxes internos — Flux<T> se convierte en Flux<Flux<T>>. Cada ventana es una vista en vivo: los elementos fluyen a través de ella a medida que llegan, nada espera a que el bloque se llene. Usa window cuando los bloques son grandes (no materialices una List de un millón de elementos) o cuando cada bloque merece su propio sub-pipeline — reducirlo, limitarle la tasa, transmitirlo a otro lado.
fun <T> Flux<T>.window(maxSize: Int): Flux<Flux<T>>window(n) reemite la fuente como un Flux de Flux internos, cada uno con como mucho n elementos; los primeros n valores van a la ventana 1, los siguientes n a la ventana 2, y asi sucesivamente. Cada Flux interno se abre, emite su porcion y luego completa, y el Flux externo completa cuando termina la ultima ventana interna. A diferencia de buffer, las ventanas son vistas vivas y unicast: los elementos fluyen a medida que llegan en lugar de acumularse en una List, asi que el suscriptor debe consumir cada Flux interno sin demora.
- Procesar un stream grande o infinito en trozos de tamano fijo sin acumular cada trozo en memoria
- Aplicar operadores por trozo (reduce, collectList, sum) sobre cada ventana interna de forma independiente
- Lotes amigables con backpressure donde transmites el contenido del trozo en vez de materializar Listas
- Pipelines con limite de tasa o paginados que actuan sobre cada N elementos
Las ventanas internas son calientes, unicast y vivas: si no te suscribes a cada Flux interno (por ejemplo con flatMap/concatMap) antes de que se abra la siguiente ventana, puedes perder sus datos o bloquear el pipeline, y no puedes volver a suscribirte a una ventana. Prefiere concatMap sobre flatMap al consumir ventanas para que los trozos mantengan el orden y solo uno este activo a la vez.
Flux.just(1, 2, 3, 4, 5, 6)
.window(2)
.concatMap { win -> win.collectList() }
.subscribe { batch -> println("batch=" + batch) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Flux.window(2) divide la fuente en Flux internos. Dados seis valores, que emite el Flux externo y como se consume cada bloque en el codigo de abajo?
import reactor.core.publisher.Flux
fun main() {
Flux.just(10, 20, 30, 40, 50, 60, 70)
.window(3)
.index()
.concatMap { tuple ->
val i = tuple.t1
tuple.t2.reduce(0) { a, b -> a + b }.map { "window $i sum=$it" }
}
.subscribe { println(it) }
}Siete lecturas se dividen en ventanas de tres. index() numera cada ventana, y luego corremos un pipeline real por ventana: reduce suma los elementos mientras fluyen — nunca se construye una List. La ventana 0 contiene 10+20+30, así que imprimimos 60, luego 150, y la última ventana corta suma 70. Como consumimos con concatMap, las ventanas se procesan estrictamente en orden, de a una.
window 0 sum=60 window 1 sum=150 window 2 sum=70
Las ventanas son unicast y no se pueden reproducir de nuevo: suscríbete a cada Flux interno exactamente una vez, sin demora (flatMap/concatMap lo hacen por ti). Quedarse sentado sobre ventanas sin consumir bloquea el pipeline. Prefiere concatMap cuando el orden importa — mantiene exactamente una ventana en vuelo.
windowTimeout
windowTimeout es a window lo que bufferTimeout es a buffer: cierra el Flux interno actual tras maxSize elementos O maxTime, lo que ocurra primero. Úsalo cuando necesitas bloques acotados por tiempo pero no quieres Lists materializadas — un dashboard que se repinta cada 200 ms, agregación por segundo sobre un flujo de requests.
fun <T> Flux<T>.windowTimeout(maxSize: Int, maxTime: Duration): Flux<Flux<T>>Divide la fuente en una secuencia de Flux internos (ventanas), abriendo una nueva ventana y cerrando la actual cada vez que el conteo de elementos alcanza maxSize O transcurre maxTime desde que se abrio la ventana, lo que ocurra primero. El orden se conserva por completo: los elementos entran a la ventana actual en orden y las ventanas se emiten en orden. El temporizador corre en el Scheduler parallel por defecto; una finalizacion o error aguas arriba cierra la ventana abierta y luego termina el Flux externo con la misma senal.
- Micro-batching donde vacias por tamano o tras una latencia maxima, lo que ocurra primero
- Agregar eventos en rafagas en bloques acotados por tiempo o conteo antes de escribir en BD
- Buffering con limite de tasa para envios de red/archivo con tope de latencia
- Construir dashboards que emiten como mucho N elementos o cada cierto intervalo
Emite ventanas VACIAS cuando se cumple el limite de tiempo sin elementos en buffer, asi que aguas abajo veras ventanas de tamano 0 en periodos inactivos. Ademas, cada Flux interno DEBE consumirse (suscribirse) a tiempo; si no te suscribes a una ventana antes de que se abra la siguiente, puedes perder datos o provocar errores de backpressure. Usa windowTimeout, no bufferTimeout, solo cuando necesitas ventanas en streaming en vez de Listas materializadas.
Flux.just(1, 2, 3, 4, 5, 6)
.windowTimeout(3, Duration.ofSeconds(1))
.flatMap { window -> window.collectList() }
.subscribe { batch -> println(batch) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado Flux.just(1, 2, 3, 4, 5, 6) emitido instantaneamente, que produce windowTimeout(3, Duration.ofSeconds(1)) aguas abajo?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(60))
.map { "req-$it" }
.windowTimeout(4, Duration.ofMillis(200))
.concatMap { window -> window.collectList() }
.take(3)
.doOnNext { println("served window: $it") }
.blockLast()
}Los requests llegan cada 60 ms; cada ventana puede contener como máximo 4 o el equivalente a 200 ms. Las primeras ventanas se cierran por el temporizador con tres requests cada una; cuando el timing se corre lo suficiente para que entre un cuarto request, el límite de tamaño cierra la ventana en su lugar — la última ventana de esta ejecución lleva cuatro. Juntamos cada ventana en una lista solo para imprimirla.
served window: [req-0, req-1, req-2] served window: [req-3, req-4, req-5] served window: [req-6, req-7, req-8, req-9]
windowTimeout emite ventanas VACÍAS cuando el temporizador dispara sin nada en el buffer — un flujo inactivo produce un pulso constante de ventanas de cero elementos aguas abajo. Protege tus agregaciones por ventana (salta los resultados vacíos de collectList) o procesarán bloques vacíos tan campantes.
windowUntil / windowWhile / windowWhen
Cada límite por predicado o acompañante del lado buffer tiene su gemelo de ventanas. windowUntil cierra la ventana actual cuando el predicado coincide, conservando el delimitador dentro (y una sobrecarga cutBefore inicia la ventana nueva con el delimitador en su lugar). windowWhile mantiene una ventana abierta mientras el predicado se cumple y descarta el delimitador. windowWhen abre ventanas desde un publisher y cierra cada una a través de un acompañante derivado, exactamente como bufferWhen.
fun <T> Flux<T>.windowUntil(predicate: (T) -> Boolean): Flux<Flux<T>> // also windowWhile / windowWhenwindowUntil enruta cada elemento hacia el Flux interno actual y, cuando el predicado devuelve true, completa esa ventana con el elemento coincidente como su ÚLTIMO item (la sobrecarga cutBefore en cambio completa la ventana ANTES de la coincidencia, que pasa a abrir la siguiente). windowWhile mantiene la ventana abierta mientras el predicado se cumple y DESCARTA el elemento que lo rompe. windowWhen recibe un Publisher de apertura más un Publisher de cierre por apertura — el espejo con ventanas en vivo de bufferWhen, con solapamientos y huecos incluidos. En todas las variantes, la finalización de upstream cierra las ventanas abiertas antes de completar el Flux externo, y un error termina tanto las ventanas como el flujo externo.
- Dividir flujos de protocolo o logs en registros delimitadores mientras transmites las líneas de cada registro
- Separar ráfagas de mediciones con windowWhile cuando los separadores en sí son ruido
- Iniciar registros en líneas de encabezado con windowUntil(predicate, cutBefore = true)
- Construir ventanas de sesión con windowWhen: la actividad del usuario abre, el timeout de inactividad cierra
windowUntil y windowWhile pueden emitir ventanas VACÍAS — delimitadores seguidos o un flujo que empieza con uno producen ventanas de cero elementos aguas abajo, así que las agregaciones por ventana deben tolerar bloques vacíos. Y como con todo operador de ventanas, las ventanas internas son unicast y vivas: consume cada una sin demora, exactamente una vez, o el pipeline se bloquea.
Flux.just(1, 2, 0, 3, 0, 4)
.windowWhile { it != 0 }
.concatMap { w -> w.collectList() }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. windowWhile divide el flujo según un predicado. ¿Qué imprime esto?
import reactor.core.publisher.Flux
fun main() {
// windowUntil: the delimiter CLOSES the window and is INCLUDED in it
Flux.just("line 1", "line 2", "END", "line 3", "END", "line 4")
.windowUntil { it == "END" }
.concatMap { window -> window.collectList() }
.subscribe { println("Until: $it") }
// windowWhile: the delimiter closes the window and is DROPPED
Flux.just("a", "b", "-", "c", "-", "d")
.windowWhile { it != "-" }
.concatMap { window -> window.collectList() }
.subscribe { println("While: $it") }
}Dos flujos, dos cortes. windowUntil parte el log en cada END, y los END se quedan dentro de sus ventanas; el "line 4" final recibe su propia ventana cuando la fuente completa. windowWhile trata el "-" como separador puro: cierra ventanas pero desaparece de la salida. Cada ventana es un Flux en vivo, así que la juntamos con concatMap { it.collectList() } solo para imprimirla.
Until: [line 1, line 2, END] Until: [line 3, END] Until: [line 4] While: [a, b] While: [c] While: [d]
Y cuando los límites vienen del mundo exterior — actividad del usuario, mensajes de control, temporizadores — windowWhen replica a bufferWhen con ventanas en vivo:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(50))
.map { "pkt-$it" }
.windowWhen(
Flux.interval(Duration.ofMillis(120)), // opens a window
java.util.function.Function { Flux.interval(Duration.ofMillis(90)) } // closes it 90ms later
)
.concatMap { window -> window.collectList() }
.take(3)
.doOnNext { println("session window: $it") }
.blockLast()
}session window: [pkt-2, pkt-3] session window: [pkt-4, pkt-5] session window: [pkt-7]
En Reactor real, windowUntil y windowWhile pueden emitir ventanas VACÍAS — por ejemplo cuando dos delimitadores llegan seguidos, o cuando el flujo empieza con uno. Si agregas cada ventana, asegúrate de que un bloque vacío no rompa las cuentas. Y como con todo operador window: consume cada Flux interno sin demora, exactamente una vez.
groupBy
Todo lo anterior corta el flujo por posición en el tiempo. groupBy es distinto: particiona por contenido. Cada elemento se enruta según una función de clave hacia el GroupedFlux de su grupo, y el Flux externo emite cada grupo la primera vez que aparece su clave. Los grupos son vivos, concurrentes y permanentes — cuando una clave reaparece, el elemento se reincorpora a su grupo existente (a diferencia de bufferUntilChanged, que empezaría una racha nueva).
fun <T, K> Flux<T>.groupBy(keyMapper: (T) -> K): Flux<GroupedFlux<K, T>>Cada elemento de origen se enruta segun su clave calculada hacia un GroupedFlux<K, T>, y se emite un nuevo GroupedFlux en el flujo principal la primera vez que aparece una clave. Los grupos existentes siguen recibiendo los elementos que coinciden en el orden de origen; la finalizacion o el error externo se propaga a todos los grupos abiertos. Es clave que cada GroupedFlux DEBE suscribirse y consumirse, de lo contrario el operador aplica contrapresion y toda la tuberia puede bloquearse.
- Particionar eventos por categoria, tenant o id de usuario para procesarlos por clave
- Distribuir hacia agregacion, ventaneo o limitacion de tasa por clave
- Enrutar un flujo mixto a distintos destinos segun el tipo
- Aplicar distinta concurrencia de flatMap por grupo de elementos relacionados
groupBy esta pensado para BAJA cardinalidad. Cada grupo es un Flux vivo que debes consumir; un grupo no consumido (o consumido lento) ejerce contrapresion y puede bloquear el origen. Una cardinalidad alta o ilimitada de claves tambien provoca fugas de memoria porque los grupos quedan abiertos hasta que el origen termina.
Flux.just(1, 2, 3, 4, 5, 6)
.groupBy { it % 2 }
.flatMap { group -> group.map { n -> "key=" + group.key() + " val=" + n } }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Que emite este Flux cuando se suscribe?
import reactor.core.publisher.Flux
data class Event(val type: String, val data: String)
fun main() {
val events = Flux.just(
Event("INFO", "App started"),
Event("ERROR", "Connection failed"),
Event("INFO", "Processing..."),
Event("WARN", "Low memory"),
Event("ERROR", "Timeout"),
Event("INFO", "Done")
)
events.groupBy { it.type }
.flatMap { group ->
group.collectList().map { "${group.key()}: ${it.map { e -> e.data }}" }
}
.subscribe { println(it) }
}Seis eventos de log traen tres tipos distintos. groupBy { it.type } crea un GroupedFlux en cuanto un tipo aparece por primera vez — INFO, luego ERROR, luego WARN. Dentro del flatMap cada grupo junta sus propios eventos en una lista etiquetada con group.key(). Todos los grupos completan cuando lo hace la fuente, así que las tres listas se imprimen en el orden en que se crearon los grupos.
INFO: [App started, Processing..., Done] ERROR: [Connection failed, Timeout] WARN: [Low memory]
groupBy está hecho para BAJA cardinalidad de claves. Cada grupo debe consumirse: un grupo ignorado o lento aplica contrapresión a toda la fuente y puede bloquearla, y los grupos quedan abiertos hasta que la fuente termina, así que los espacios de claves sin límite fugan memoria. Para rachas consecutivas sobre claves sin límite, usa bufferUntilChanged / windowUntilChanged.
Reducción / Agregación
La mayoría de los operadores transforman valores mientras pasan volando. Los operadores de reducción hacen lo contrario: esperan, absorben todo el flujo y lo condensan en un solo valor o colección. El resultado siempre es un Mono — un único resumen, emitido en el momento en que la fuente completa. Esa última parte es el rasgo distintivo de la familia: nada llega a tu subscriber hasta que el upstream anuncia que terminó.
Como una muestra de toda la familia, este ejemplo combina groupBy con collectList para armar un índice de frutas por letra inicial:
import reactor.core.publisher.Flux
fun main() {
Flux.just("apple", "avocado", "banana", "cherry", "blueberry", "apricot")
.groupBy { it.first() }
.flatMap { group -> group.collectList().map { "${group.key()}: $it" } }
.subscribe { println(it) }
}Tenemos un flujo de seis nombres de frutas. groupBy { it.first() } lo divide en tres sub-flux con clave, uno por letra inicial. Para cada grupo, collectList() reúne sus frutas en una sola List y map le antepone la clave del grupo. Al final nos suscribimos e imprimimos una línea agregada por letra:
a: [apple, avocado, apricot] b: [banana, blueberry] c: [cherry]
reduce
reduce es la versión reactiva de un fold: recorre el flujo con una función acumuladora, arrastrando un resultado parcial, y emite únicamente el valor final cuando la fuente completa. Si alguna vez sumaste una lista con fold o reduce de Kotlin, es exactamente la misma idea — solo que los valores llegan con el tiempo en lugar de estar en memoria.
fun <T, A> Flux<T>.reduce(initial: A, accumulator: (A, T) -> A): Mono<A>reduce parte del valor semilla (aquí 0) y aplica el acumulador a cada elemento en orden de emisión, arrastrando el resultado parcial: 0+1=1, 1+2=3, 3+3=6, 6+4=10. No emite nada mientras la fuente está activa; solo cuando la fuente completa, el Mono resultante emite el único valor plegado (10) seguido de onComplete. Si la fuente falla a mitad, el acumulador parcial se descarta y se propaga el error en su lugar. reduce no cambia de hilo por sí mismo, así que el plegado corre en el hilo que entrega cada onNext.
- Sumar, contar o promediar un stream finito en un solo valor
- Construir un agregado como total, máximo o cadena concatenada
- Colapsar un Flux en un Mono antes de retornar desde un servicio
- Plegar eventos en una única instantánea de estado al completar
reduce solo emite cuando la fuente completa, así que en un stream infinito o que nunca completa no emite nada y se cuelga para siempre. La sobrecarga con semilla siempre emite al menos el valor inicial (una fuente vacía produce la semilla, no un Mono vacío), mientras que reduce(BiFunction) sin semilla emite vacío ante una fuente vacía.
Flux.just(1, 2, 3, 4)
.reduce(0) { a, x -> a + x }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este pipeline de Reactor (y cuántos ítems) al suscribirse?
Supongamos que cada valor del flujo es el monto de una orden realizada hoy, y queremos el ingreso total del día:
import reactor.core.publisher.Flux
fun main() {
// Amounts (in USD) of every order placed today
Flux.just(120, 45, 99, 15)
.reduce { total, amount -> total + amount }
.subscribe { println("Daily revenue: $$it") }
}Tenemos un flujo de cuatro montos. reduce combina los dos primeros valores (120 y 45) en 165, y luego pliega cada monto siguiente en ese total parcial: 165 + 99 = 264, y 264 + 15 = 279. Nada llega al subscriber mientras siguen llegando valores — solo cuando la fuente completa, el Mono emite el total único, que imprimimos:
Daily revenue: $279
Una segunda sobrecarga, reduce(seed) { acc, next -> … }, arranca el plegado desde un valor inicial en lugar del primer elemento — lo que además permite que el acumulador tenga un tipo distinto al de los elementos del flujo. (También existe reduceWith { seed } para cuando la semilla debe construirse de forma diferida, una vez por subscriber.) Aquí plegamos los pasos de un checkout en una sola cadena legible, partiendo de un StringBuilder vacío:
import reactor.core.publisher.Flux
fun main() {
Flux.just("cart", "checkout", "pay", "ship")
.reduce(StringBuilder()) { acc, step -> acc.append(step).append(" > ") }
.map { it.toString().trimEnd(' ', '>') }
.subscribe { println("pipeline: $it") }
}pipeline: cart > checkout > pay > ship
El comportamiento con fuente vacía cambia según la sobrecarga: reduce sin semilla completa vacío cuando la fuente está vacía, mientras que reduce(seed) siempre emite al menos la semilla. Y como reduce solo dispara al completar, apuntarlo a un flujo infinito hace que el Mono nunca emita — si quieres ver el total parcial en cada paso, usa scan.
collectList / collectMap / collectMultimap
collectList() es el operador al que recurres cuando necesitas todo junto: acumula cada elemento que emite el Flux y, al completar, te entrega un único Mono<List<T>> en el orden original de emisión — perfecto para una inserción por lotes o una única respuesta JSON. collectMap() hace el mismo trabajo pero construye un Map en el camino, aplicando un extractor de clave (y opcionalmente uno de valor) a cada elemento.
fun <T> Flux<T>.collectList(): Mono<List<T>> // also collectMap(keyFn[, valueFn]): Mono<Map<K, V>>, collectMultimap(keyFn[, valueFn]): Mono<Map<K, Collection<V>>>collectList() acumula cada valor onNext del Flux de origen en una sola List, conservando el orden de emisión, y emite esa List una única vez solo después de que el origen completa. El Mono resultante emite la lista completa seguida de onComplete; si el origen está vacío emite una lista vacía (nunca un Mono vacío), y cualquier error del origen se propaga sin emitir una lista parcial. collectMap() y collectMultimap() usan la misma maquinaria de acumular-hasta-completar pero indexan cada elemento con un extractor: collectMap conserva un valor por clave (gana el último), collectMultimap conserva todos los valores que comparten clave.
- Agregar un stream acotado en una List para una inserción por lotes o una única respuesta de API
- Reunir todos los resultados antes de retornar desde un método que espera una List
- Consolidar resultados de flatMap cuando los necesitas juntos en vez de en streaming
- Conectar código reactivo con una API basada en List al borde de tu pipeline
La familia collect* debe mantener cada elemento en memoria y no emite nada hasta que el origen completa, así que sobre un Flux infinito o muy grande/caliente nunca emite y puede causar crecimiento de memoria sin límite u OOM — úsala solo en streams acotados. Y recuerda que collectMap sobrescribe en silencio las claves duplicadas: cuando los duplicados importan, usa collectMultimap.
Flux.just("apple", "avocado", "banana")
.collectMap { it.first() }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dos frutas empiezan con la misma letra. ¿Qué imprime este pipeline?
import reactor.core.publisher.Flux
data class Product(val id: String, val name: String, val price: Double)
fun main() {
val products = Flux.just(
Product("1", "Laptop", 999.99),
Product("2", "Mouse", 29.99),
Product("3", "Keyboard", 79.99)
)
// collectList: Mono<List<Product>>
products.collectList()
.subscribe { println("All: $it") }
// collectMap with key extractor only: Mono<Map<String, Product>>
products.collectMap { it.id }
.subscribe { map -> println("By ID: ${map.keys}") }
// collectMap with key AND value extractors: Mono<Map<String, String>>
products.collectMap({ it.id }, { it.name })
.subscribe { println("Names: $it") }
}Tenemos un catálogo de tres productos. collectList() espera los tres y los emite como una sola List. El primer collectMap indexa cada producto por su id, así que las claves del mapa son 1, 2, 3 y los valores son los productos completos. El segundo agrega un extractor de valor, quedándose solo con el nombre de cada producto. Las tres suscripciones imprimen una sola vez, cuando la fuente completa:
All: [Product(id=1, name=Laptop, price=999.99), Product(id=2, name=Mouse, price=29.99), Product(id=3, name=Keyboard, price=79.99)]
By ID: [1, 2, 3]
Names: {1=Laptop, 2=Mouse, 3=Keyboard}collectMap conserva exactamente un valor por clave: si dos elementos producen la misma clave, el más reciente sobrescribe al anterior en silencio. Cuando los duplicados importan, esa es tu señal para pasar a collectMultimap.
collectMultimap() es el hermano amistoso con las colisiones: en lugar de un valor por clave, acumula todos los valores que comparten una clave, produciendo un Mono<Map<K, Collection<V>>>. Piensa en "agrupar y quedarse con todos" — tickets de soporte por equipo, órdenes por cliente, líneas de log por nivel. Igual que collectMap, acepta solo un extractor de clave o un par clave-valor:
import reactor.core.publisher.Flux
data class Ticket(val team: String, val id: Int)
fun main() {
Flux.just(
Ticket("billing", 101),
Ticket("auth", 102),
Ticket("billing", 103),
Ticket("auth", 104),
Ticket("billing", 105)
)
.collectMultimap { it.team }
.subscribe { byTeam ->
byTeam.forEach { (team, tickets) ->
println("$team -> ${tickets.map { it.id }}")
}
}
}Tenemos cinco tickets de soporte etiquetados con el equipo responsable. collectMultimap { it.team } agrupa cada ticket bajo la clave de su equipo, conservando todos los tickets y no solo el último. Cuando la fuente completa, el Mono emite un solo mapa con dos entradas, que imprimimos equipo por equipo:
billing -> [101, 103, 105] auth -> [102, 104]
Para cualquier cosa más exótica existe el genérico collect(Collector), que acepta cualquier java.util.stream.Collector — Collectors.joining(", "), Collectors.groupingBy(…) o uno escrito por ti. Toda la familia collect* no es más que collectors preempaquetados y amigables con lo reactivo sobre la misma maquinaria de acumular-hasta-completar.
collectSortedList
collectSortedList() acumula todo el flujo, lo ordena y emite una sola List ordenada cuando la fuente completa. Es el operador para publicar rankings: los puntajes llegan en el orden en que terminan los jugadores, pero la interfaz quiere una tabla de posiciones. Sin argumentos usa el orden natural; pásale un Comparator para cualquier criterio propio.
fun <T> Flux<T>.collectSortedList(comparator: Comparator<T>? = null): Mono<List<T>>collectSortedList se suscribe al flujo de origen con demanda ilimitada y almacena cada elemento emitido en una lista interna, sin emitir nada hasta que la fuente se completa. Al recibir la señal onComplete ordena la lista acumulada (orden natural o con el Comparator indicado) y emite exactamente un List<T>, luego completa el Mono. Si la fuente falla antes de completarse, el buffer se descarta y el error se propaga sin emitir lista; una fuente vacía produce una lista vacía.
- Generar un ranking o tabla de posiciones a partir de un flujo finito de puntajes
- Ordenar un conjunto acotado de resultados antes de devolverlo desde un servicio
- Recolectar y ordenar elementos para una única respuesta o reporte agrupado
- Materializar un flujo asíncrono pequeño en una sola instantánea ordenada para la UI
No tiene límite: almacena todo el flujo en memoria y no emite nada hasta completarse, así que en una fuente infinita o muy grande/caliente nunca emite y puede agotar la memoria. Además necesita la señal terminal onComplete para disparar, y los elementos deben ser Comparable si no pasas un Comparator, o lanzará ClassCastException.
Flux.just(5, 3, 1, 4, 2)
.collectSortedList()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este pipeline de Reactor?
import reactor.core.publisher.Flux
data class Score(val player: String, val points: Int)
fun main() {
// Natural order
Flux.just(5, 3, 8, 1, 9, 2)
.collectSortedList()
.subscribe { println("Sorted: $it") }
// Leaderboard: highest score first, via a custom Comparator
Flux.just(Score("nina", 740), Score("leo", 980), Score("sam", 610), Score("ada", 860))
.collectSortedList(compareByDescending { it.points })
.subscribe { board ->
board.forEachIndexed { i, s -> println("#${i + 1} ${s.player} — ${s.points}") }
}
}El primer pipeline recolecta seis números en desorden y los ordena de forma natural. El segundo recolecta cuatro puntajes de jugadores y los ordena con compareByDescending { it.points }, así que el mejor puntaje queda primero; luego imprimimos la tabla final con sus posiciones:
Sorted: [1, 2, 3, 5, 8, 9] #1 leo — 980 #2 ada — 860 #3 nina — 740 #4 sam — 610
Ordenar exige ver todos los elementos, así que collectSortedList retiene el flujo completo en memoria y no emite nada hasta completar — nunca lo apuntes a una fuente infinita o sin límite. Sin un Comparator, los elementos deben implementar Comparable, o el pipeline falla con ClassCastException.
count
count() responde la pregunta de agregación más simple — ¿cuántos? — como un Mono<Long> emitido cuando la fuente completa. Por sí solo es casi trivial; se vuelve realmente útil combinado con filter (contar solo lo que importa) o groupBy (contar por categoría), que es el pan de cada día del código de monitoreo y alertas.
fun <T> Flux<T>.count(): Mono<Long>count se suscribe a la fuente y consume cada elemento, descartando los valores en sí mientras incrementa un contador interno. No emite nada hasta que la fuente se completa; en ese momento emite exactamente un Long (el total de elementos) y luego se completa. Si la fuente falla antes de completarse, count propaga ese error y no emite ningún conteo.
- Informar cuántos registros procesó un flujo
- Verificar un número esperado de emisiones en pruebas
- Decidir lógica condicional según el tamaño de una fuente finita
- Registrar totales de rendimiento tras completar un lote
count solo emite cuando la fuente se completa, así que aplicarlo a un flujo infinito o caliente que nunca termina hace que el Mono nunca produzca un valor. Además consume por completo la fuente, por lo que los elementos originales ya no están disponibles cuando obtienes el conteo.
Flux.just("A", "B", "C", "D")
.count()
.subscribe { n -> println(n) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este pipeline de Reactor?
import reactor.core.publisher.Flux
data class LogEntry(val level: String, val message: String)
fun main() {
val logs = Flux.just(
LogEntry("INFO", "App started"),
LogEntry("ERROR", "Connection refused"),
LogEntry("WARN", "Slow query: 2.3s"),
LogEntry("ERROR", "Timeout on /api/users"),
LogEntry("INFO", "Request completed"),
LogEntry("ERROR", "NullPointerException in OrderService"),
LogEntry("WARN", "Memory usage > 80%")
)
// Count errors for alerting
logs.filter { it.level == "ERROR" }
.count()
.subscribe { errorCount ->
println("Errors: $errorCount")
if (errorCount > 2) println("ALERT: Error threshold exceeded!")
}
// Count by level using groupBy + count
logs.groupBy { it.level }
.flatMap { group ->
group.count().map { "${group.key()}: $it" }
}
.subscribe { println(it) }
}Tenemos siete entradas de log. El primer pipeline filtra las entradas ERROR y las cuenta: sobreviven tres, así que el subscriber imprime el conteo y — como cruza el umbral — una alerta. El segundo pipeline agrupa todas las entradas por nivel y cuenta cada grupo por separado, imprimiendo un total por nivel en orden de primera aparición:
Errors: 3 ALERT: Error threshold exceeded! INFO: 2 ERROR: 3 WARN: 2
all / any / hasElements
Estos tres colapsan todo un flujo en un único Mono<Boolean>. all(predicate) pregunta "¿todos los elementos pasan?", any(predicate) pregunta "¿pasa al menos uno?" y hasElements() pregunta "¿llegó algo siquiera?". También existe el atajo hasElement(value) — literalmente any { it == value } — para chequeos de pertenencia. Son lo bastante listos para detenerse temprano: any emite true en el instante en que aparece una coincidencia y cancela el resto del flujo, y all emite false en cuanto un elemento falla.
fun <T> Flux<T>.all(predicate: (T) -> Boolean): Mono<Boolean> // also any(predicate): Mono<Boolean>, hasElements(): Mono<Boolean>, hasElement(value: T): Mono<Boolean>Son reducciones con cortocircuito que colapsan todo un Flux en un único Mono<Boolean>. all() se suscribe, evalúa cada elemento con el predicado y, en cuanto uno falla, emite false y cancela el upstream; si la fuente completa con todos los elementos cumpliendo (o está vacía) emite true. any() es el reflejo: emite true y cancela ante la primera coincidencia, o false al completar. hasElements() ignora los valores y emite true en el primer onNext (luego cancela) o false si la fuente completa vacía — y hasElement(value) es simplemente any { it == value }. Todos emiten exactamente un Boolean seguido de onComplete, y propagan un onError del upstream en lugar de un Boolean.
- Validar que todos los elementos de un lote cumplen una regla antes de confirmar (all)
- Comprobar si al menos un registro cumple una condición, p. ej. permiso o flag (any)
- Detectar si una consulta/stream produjo algún resultado (hasElements / hasElement)
- Condicionar lógica posterior con switchIfEmpty o filterWhen usando el Boolean
Con una fuente vacía all() devuelve true (verdad vacua) mientras que any() y hasElements() devuelven false; es fácil confundirlo. Además, como cortocircuitan y cancelan el upstream antes de tiempo, los efectos secundarios en elementos posteriores (logs, doOnNext) no se ejecutan; nunca asumas que se consume todo el stream.
Flux.empty<Int>()
.all { it > 100 }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. La fuente está VACÍA. ¿Qué imprime este fragmento?
import reactor.core.publisher.Flux
data class Payment(val id: String, val amount: Double, val verified: Boolean)
fun main() {
val payments = Flux.just(
Payment("p1", 250.0, true),
Payment("p2", 40.0, true),
Payment("p3", 3200.0, true)
)
// all: is every payment in the batch verified?
payments.all { it.verified }
.subscribe { println("All verified: $it") }
// any: does at least one payment exceed the fraud-review threshold?
payments.any { it.amount > 1000 }
.subscribe { println("Needs fraud review: $it") }
// hasElements: did the stream emit anything at all?
Flux.empty<Payment>().hasElements()
.subscribe { println("Has payments: $it") }
// hasElement: does the stream contain this exact value?
Flux.just("USD", "EUR", "MXN").hasElement("EUR")
.subscribe { println("Supports EUR: $it") }
}Tenemos un lote de tres pagos. all { it.verified } los examina y, como ninguno falla, emite true cuando la fuente completa. any { it.amount > 1000 } cortocircuita: en el momento en que p3 supera el umbral, emite true y cancela el upstream. hasElements() sobre un Flux vacío emite false al completar, y hasElement("EUR") emite true apenas aparece la moneda buscada:
All verified: true Needs fraud review: true Has payments: false Supports EUR: true
Las fuentes vacías son la trampa clásica: all() devuelve true sobre un flujo vacío (todos los elementos de ninguno pasan — verdad vacua), mientras que any() y hasElements() devuelven false. Y como los tres cortocircuitan y cancelan el upstream, nunca dependas de que los efectos secundarios de elementos posteriores se ejecuten.
Tiempo y retrasos
Todo en un stream reactivo ocurre sobre una línea de tiempo — y estos operadores te permiten doblarla. Puedes espaciar una ráfaga de valores, empujar todo el stream a un arranque más tardío, retener cada elemento hasta que termine algún trabajo asíncrono ligado a él, ponerle una fecha límite a una fuente silenciosa o simplemente medir cuándo ocurrió cada cosa.
- delayElements / delaySequence — espacia una ráfaga de manera uniforme, o desplaza todo el stream conservando su ritmo
- delaySubscription — arranca más tarde: la fuente ni siquiera se suscribe hasta que el temporizador se dispara
- delayUntil — retiene cada elemento hasta que completa una acción asíncrona derivada de él
- timeout — falla rápido (o cambia a un respaldo) cuando la fuente queda en silencio demasiado tiempo
- elapsed / timestamp / timed — anota en cada valor cuándo ocurrió
Un detalle que sorprende a más de uno: todos estos operadores mueven el trabajo al Scheduler parallel de Reactor por defecto. En cuanto hay un delay o un temporizador de por medio, tus valores dejan de llegar en el hilo que se suscribió — y por eso los ejemplos de abajo bloquean con blockLast(), manteniendo vivo el main mientras corren los temporizadores.
delayElements / delaySequence
fun <T> Flux<T>.delayElements(delay: Duration): Flux<T>delayElements emite cada onNext en un scheduler paralelo después de que transcurra la Duration indicada desde que ese elemento fue recibido, de modo que cada valor se desplaza individualmente en el tiempo conservando el orden estricto de la fuente. No se descarta ni se reordena ningún elemento, y la señal terminal onComplete/onError pasa después del último elemento retrasado (la terminal no se retrasa en sí misma). Como el trabajo se reprograma, el flujo aguas abajo corre en un worker de Schedulers.parallel() en lugar del hilo de la fuente.
- Espaciar emisiones hacia una API aguas abajo con límite de tasa
- Simular streaming constante o actualizaciones de UI a ritmo fijo desde una fuente rápida
- Agregar una pausa uniforme por elemento al reproducir eventos
- Suavizar fuentes a ráfagas en elementos con tiempos uniformes
delayElements retrasa cada elemento respecto a cuándo llega, no acumulando desfases; con una fuente rápida espacia las emisiones a un delay de distancia, pero NO conserva el ritmo original entre elementos de una fuente lenta (usa delaySequence para eso). Además mueve la emisión a Schedulers.parallel(), por lo que aguas abajo deja de correr en el hilo de la fuente.
Flux.just("A", "B", "C")
.delayElements(Duration.ofMillis(100))
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux y cómo se ve afectado el tiempo?
Imagina una app de estado de vuelos que recibe tres notificaciones push en el mismo instante. Dispararlas todas a la vez se siente como spam; espaciarlas se lee como una historia. delayElements(d) hace exactamente eso: retiene cada elemento por la duración d, de modo que una fuente en ráfaga sale con ritmo — cada valor llega al menos d después del anterior, en el orden original.
Su hermano delaySequence(d) responde otra pregunta: desplaza el stream completo d hacia el futuro conservando la separación original entre elementos. Usa delayElements para cambiar el ritmo, y delaySequence para conservarlo pero empezar más tarde.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val t0 = System.currentTimeMillis()
fun stamp() = "+${System.currentTimeMillis() - t0}ms"
println("delayElements — every notification paced 300ms apart:")
Flux.just("boarding pass ready", "gate changed to B12", "boarding started")
.delayElements(Duration.ofMillis(300))
.doOnNext { println(" ${stamp()} $it") }
.blockLast()
println("delaySequence — same 200ms rhythm, whole stream shifted 500ms:")
val t1 = System.currentTimeMillis()
Flux.interval(Duration.ofMillis(200))
.take(3)
.delaySequence(Duration.ofMillis(500))
.doOnNext { println(" +${System.currentTimeMillis() - t1}ms tick $it") }
.blockLast()
}Tenemos tres notificaciones de vuelo listas al mismo tiempo. delayElements(300ms) las libera una cada ~300 ms — los timestamps muestran ~300, ~600 y ~900 ms. Después, una fuente interval emite un tick cada 200 ms; delaySequence(500ms) empuja el primer tick de 200 ms a ~700 ms, pero conserva intactos los 200 ms entre ticks. Al final, blockLast() espera a que cada stream termine para que main no salga antes de tiempo.
delayElements — every notification paced 300ms apart: +346ms boarding pass ready +639ms gate changed to B12 +938ms boarding started delaySequence — same 200ms rhythm, whole stream shifted 500ms: +708ms tick 0 +911ms tick 1 +1115ms tick 2
delayElements retrasa cada elemento respecto a su llegada y conserva el orden, así que una ráfaga sale espaciada cada d — pero NO reproduce la separación original de una fuente lenta; ese es el trabajo de delaySequence. Ambos operadores mueven las emisiones a Schedulers.parallel(), por lo que el código aguas abajo deja de correr en el hilo que se suscribió.
delaySubscription
fun <T> Flux<T>.delaySubscription(delay: Duration): Flux<T>delaySubscription espera la Duration indicada antes de propagar la señal de subscribe hacia arriba, de modo que la fuente ni siquiera se suscribe hasta que el temporizador se dispara. Cuando transcurre el retraso, la fuente se ejecuta con normalidad y cada elemento, más la señal terminal onComplete/onError, fluye sin cambios: todo el stream simplemente se desplaza hacia la derecha en el tiempo. El retraso corre en el Scheduler parallel por defecto (o en el que le pases), no en el hilo que se suscribe.
- Escalonar el arranque para que una dependencia esté lista antes de que comience la fuente
- Introducir un calentamiento o backoff fijo antes de sondear una API o recurso
- Condicionar un publisher frío a otra señal (variante delaySubscription(Publisher))
- Crear retrasos deliberados en tests o demos sin alterar el ritmo de emisión
Retrasa solo la suscripción, no los elementos: una vez que la fuente arranca, los ítems se emiten a su ritmo natural, así que esto NO agrega un espacio entre elementos (usa delayElements para eso). En una fuente caliente el retraso puede hacer que te pierdas todo lo emitido durante la espera, porque te suscribes tarde.
Flux.just("A", "B", "C")
.delaySubscription(Duration.ofSeconds(1))
.subscribe { v -> println("got " + v) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux, y cuándo?
A veces el stream en sí está bien — solo arranca demasiado pronto. Quizás quieres que un health check comience recién cuando la app tuvo medio segundo para calentar, o estás escalonando varios pollers para que no golpeen una API todos a la vez. delaySubscription(d) pospone el momento en que la fuente se suscribe: nada aguas arriba corre, conecta ni emite hasta que el temporizador se dispara.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val t0 = System.currentTimeMillis()
fun stamp() = "+${System.currentTimeMillis() - t0}ms"
println("${stamp()} subscribe() called — health check scheduled...")
Flux.just("db: ok", "cache: ok", "queue: ok")
.doOnSubscribe { println("${stamp()} source subscribed, checks begin") }
.delaySubscription(Duration.ofMillis(500))
.doOnNext { println("${stamp()} $it") }
.blockLast()
}Llamamos a subscribe (vía blockLast) en +0ms, pero doOnSubscribe — colocado sobre la fuente, antes de delaySubscription — muestra que la fuente no se toca hasta ~500 ms después. Cuando ocurre la suscripción real, los tres chequeos fluyen de inmediato, uno tras otro: el retraso movió el inicio del stream, no los espacios internos.
+0ms subscribe() called — health check scheduled... +546ms source subscribed, checks begin +547ms db: ok +547ms cache: ok +547ms queue: ok
Sobre un publisher frío esto es un desplazamiento puro en el tiempo. Sobre una fuente caliente (un ticker en vivo, un bus compartido) suscribirse tarde significa perderse todo lo emitido durante la espera — delaySubscription no te guarda nada en buffer.
delayUntil
fun <T> Flux<T>.delayUntil(triggerProvider: (T) -> Publisher<*>): Flux<T>delayUntil deriva un Publisher acompañante de cada elemento mediante la lambda triggerProvider y retiene ese elemento hasta que el acompañante termina — lo que el acompañante emita se ignora; solo importa su completado. El elemento continúa entonces aguas abajo sin cambios. Los acompañantes se suscriben de a uno, en el orden de la fuente: el trigger del siguiente elemento arranca recién cuando el anterior fue liberado, así que el orden siempre se conserva y los triggers nunca se solapan. Si un acompañante falla, ese error se propaga aguas abajo y el elemento nunca se emite.
- Ejecutar una acción asíncrona por elemento (auditoría, correo, precalentar caché) sin cambiar el valor
- Emitir una entidad recién cuando su llamada a save()/persist() completó
- Esperar una precondición por elemento — un lock, un token de rate limit, un paso de saga — antes de liberarlo
- Mantener el orden estricto de la fuente donde flatMap { work(it).thenReturn(it) } podría entrelazar
delayUntil es estrictamente secuencial: no se suscribe al siguiente acompañante hasta que el elemento actual fue liberado, así que el tiempo total es la SUMA de las duraciones de todos los triggers — un trigger lento frena el stream completo. Si las acciones pueden correr en paralelo (y puedes ceder el orden), la herramienta correcta es flatMap; delayUntil cambia deliberadamente rendimiento por orden.
Flux.just("order-1", "order-2")
.delayUntil { o -> Mono.just(o.uppercase()).delayElement(Duration.ofMillis(100)) }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. El Mono trigger emite un valor transformado. ¿Qué imprime el suscriptor?
Con frecuencia necesitas hacer algo asíncrono por cada elemento sin cambiarlo: enviar el recibo de cada orden por correo, persistir una fila de auditoría por evento, esperar un lock por ítem. El reflejo tentador — y equivocado — es flatMap { doSideThing(it).thenReturn(it) }: funciona, pero entierra una idea simple entre plomería, y con flatMap hasta puede reordenar tus elementos. delayUntil es la respuesta idiomática.
flux.delayUntil { trigger(it) } deriva un publisher acompañante de cada elemento y retiene ese elemento hasta que el acompañante completa. El valor sale intacto, el orden se conserva y los triggers corren en secuencia: el recibo de order-2 se envía recién después de que order-1 fue liberada aguas abajo.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
fun main() {
val t0 = System.currentTimeMillis()
fun stamp() = "+${System.currentTimeMillis() - t0}ms"
// Simulated async side action: e-mail a receipt, takes ~250ms
fun sendReceipt(order: String): Mono<Long> =
Mono.delay(Duration.ofMillis(250))
.doOnNext { println(" ${stamp()} receipt e-mailed for $order") }
Flux.just("order-1", "order-2", "order-3")
.delayUntil { order -> sendReceipt(order) }
.doOnNext { println("${stamp()} $it forwarded downstream") }
.blockLast()
}Tenemos tres órdenes. Para cada una, delayUntil llama a sendReceipt(order) — un Mono que tarda ~250 ms, simulando el envío de un correo — y espera a que complete antes de dejar pasar la orden. El log muestra la secuencia estricta: recibo de order-1, luego order-1 aguas abajo, luego el recibo de order-2, y así sucesivamente. El tiempo total es ~3 × 250 ms, porque delayUntil es deliberadamente secuencial.
+310ms receipt e-mailed for order-1 +310ms order-1 forwarded downstream +565ms receipt e-mailed for order-2 +565ms order-2 forwarded downstream +819ms receipt e-mailed for order-3 +819ms order-3 forwarded downstream
delayUntil vs flatMap: si no necesitas el resultado de la acción asíncrona y quieres conservar el orden, delayUntil lo dice en una sola palabra. Si SÍ quieres mezclar los resultados (y puedes aceptar el entrelazado), ese es el trabajo de flatMap. Y si un trigger falla, delayUntil propaga el error — ese elemento nunca se libera.
timeout
fun <T> Flux<T>.timeout(timeout: Duration): Flux<T>timeout arranca un temporizador por elemento que se reinicia cada vez que la fuente emite. Si no llega ningún onNext (ni una señal terminal) dentro de la Duration indicada después de la emisión anterior, cancela la fuente y propaga una TimeoutException aguas abajo, terminando la secuencia; aquí A, B y C pasan, luego el silencio tras C supera d y el flujo falla (X). El primer temporizador también cubre el hueco antes del primerísimo elemento. El temporizador corre en Schedulers.parallel() por defecto salvo que se indique otro scheduler.
- Acotar la latencia de llamadas lentas a red o base de datos
- Detectar un flujo estancado o silencioso y fallar rápido
- Disparar un Publisher de respaldo cuando la fuente enmudece
- Imponer SLAs por elemento en pipelines de streaming
timeout mide el hueco ENTRE emisiones consecutivas, no la duración total del flujo: una fuente que emite de forma constante cada intervalo menor a d nunca expira aunque corra para siempre. Además, sin un fallback la TimeoutException termina la secuencia y cancela la fuente, por lo que se pierde cualquier trabajo en curso.
val flux = Flux.just("A", "B", "C", "D")
.concatMap { v -> Mono.just(v).delayElement(if (v == "D") Duration.ofSeconds(2) else Duration.ofMillis(50)) }
.timeout(Duration.ofSeconds(1))
flux.subscribe(::println) { err -> println("error: " + err) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado este Flux donde la fuente retrasa el 4.º elemento más allá de la ventana de timeout, ¿qué recibe el suscriptor?
Un stream que falla es fácil de manejar; uno que se queda callado es mucho más peligroso — tu pipeline simplemente se queda ahí para siempre. timeout(d) arma un temporizador después de cada emisión (y en la suscripción para la primera). Si la siguiente señal no llega dentro de d, cancela la fuente y falla con una TimeoutException. Si le pasas un publisher de respaldo, cambia a él en lugar de fallar.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
import java.util.concurrent.CountDownLatch
fun main() {
// 1) Without fallback: silence beyond 400ms becomes a TimeoutException
val done = CountDownLatch(1)
Flux.just("heartbeat-1", "heartbeat-2")
.delayElements(Duration.ofMillis(150))
.concatWith(Flux.never()) // the monitored service goes silent
.timeout(Duration.ofMillis(400))
.subscribe(
{ println("received: $it") },
{ e -> println("alert: ${e.message}"); done.countDown() }
)
done.await()
// 2) With fallback: switch to a cached quote instead of failing
Flux.just("live price: 101.30")
.concatWith(Flux.never()) // the exchange feed stalls
.timeout(Duration.ofMillis(300), Mono.just("cached price: 99.80"))
.doOnNext { println(it) }
.blockLast()
}El primer pipeline es un monitor de latidos: dos heartbeats llegan con 150 ms de separación — muy dentro de la ventana de 400 ms — y luego concatWith(Flux.never()) deja el servicio en silencio. 400 ms después, timeout cancela la fuente y nuestro manejador de error imprime la alerta. El segundo pipeline consulta un feed de precios que se congela de inmediato: en lugar de fallar, timeout(300ms, fallback) mete la cotización cacheada y el stream completa con normalidad.
received: heartbeat-1 received: heartbeat-2 alert: Did not observe any item or terminal signal within 400ms (and no fallback has been configured) live price: 101.30 cached price: 99.80
timeout mide el espacio entre señales consecutivas, no la duración total del stream: una fuente que emite cada 100 ms pasa por timeout(Duration.ofMillis(200)) para siempre. Para acotar el tiempo total, usa take(Duration) o aplica el timeout a un agregado como collectList().
elapsed / timestamp / timed
fun <T> Flux<T>.elapsed(): Flux<Tuple2<Long, T>> // also timestamp(): Flux<Tuple2<Long, T>>, timed(): Flux<Timed<T>>elapsed() transforma cada onNext en un Tuple2 donde T1 son los milisegundos transcurridos desde la emisión anterior (o desde la suscripción para el primer valor) y T2 es el valor original, así [A,B,C,D] se convierte en [[150,A],[250,B],[220,C],[200,D]]. timestamp() en cambio empareja cada valor con el epoch-millis absoluto del momento de emisión, y timed() envuelve el valor en un objeto Timed que lleva tanto elapsed como timestamp con resolución de nanosegundos. Las tres son pasarelas transparentes: preservan el orden y reenvían la señal terminal de complete/error sin cambios, y miden el tiempo en el Scheduler parallel por defecto (configurable mediante una sobrecarga).
- Medir latencia o intervalos entre eventos de un flujo
- Logging o métricas con tiempos por elemento para diagnóstico
- Detectar productores lentos o pausas por backpressure
- Agregar timestamps de reloj para auditoría o event sourcing
elapsed() reinicia su reloj de referencia en la suscripción, por lo que el tiempo del PRIMER valor se mide desde el subscribe, no desde alguna señal previa; además mide en el Scheduler parallel por defecto, lo que puede sesgar las mediciones salvo que pases un Scheduler explícito. No confundas elapsed() (intervalo relativo, ms) con timestamp() (epoch absoluto, ms): devuelven el mismo Tuple2<Long,T> pero el Long significa cosas totalmente distintas.
Flux.just("A", "B", "C", "D")
.delayElements(Duration.ofMillis(200))
.elapsed()
.subscribe { pair -> println(pair.t1.toString() + " -> " + pair.t2) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado el tiempo del diagrama, ¿qué emite este Flux?
Cuando un pipeline se siente lento, la primera pregunta es a dónde se va el tiempo. elapsed() convierte un Flux<T> en un Flux<Tuple2<Long, T>> donde el Long son los milisegundos desde la emisión anterior (medidos desde la suscripción para el primer valor). timestamp() empareja cada valor con el epoch-millis absoluto en que fue emitido, y timed() envuelve ambos — con precisión de nanosegundos — en un objeto Timed<T>.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// elapsed: how many ms passed since the previous reading?
Flux.interval(Duration.ofMillis(200))
.take(3)
.elapsed()
.doOnNext { (gap, n) -> println("reading $n arrived ${gap}ms after the previous one") }
.blockLast()
// timestamp: at what wall-clock instant did each user event happen?
Flux.just("login", "add-to-cart", "checkout")
.delayElements(Duration.ofMillis(100))
.timestamp()
.doOnNext { (at, event) -> println("$event at epoch $at") }
.blockLast()
}Primero, un sensor emite una lectura cada 200 ms; elapsed() reporta el intervalo de cada lectura — aquí 210, 200 y 202 ms, porque los timers reales nunca son exactos. Luego, la sesión de compras de un usuario, espaciada con delayElements(100ms), pasa por timestamp(): cada evento sale emparejado con el instante epoch en que cruzó el operador.
reading 0 arrived 210ms after the previous one reading 1 arrived 200ms after the previous one reading 2 arrived 202ms after the previous one login at epoch 1783371424258 add-to-cart at epoch 1783371424361 checkout at epoch 1783371424461
La misma forma Tuple2<Long, T> significa cosas opuestas: el Long de elapsed() es un intervalo relativo en ms, mientras que el de timestamp() es un instante epoch absoluto. Cuando necesitas ambos con precisión de nanosegundos, usa timed() y los accesores de Timed<T> — elapsed(), timestamp() y elapsedSinceSubscription().
Backpressure (contrapresión)
Todo suscriptor de Reactive Streams controla su propio caudal: le dice al publisher cuántos elementos está listo para recibir llamando request(n) sobre su Subscription. Esa señal de demanda — que fluye aguas arriba, en contra de la dirección de los datos — es el backpressure. Mientras el consumidor sigue el ritmo, ni lo notas. La pregunta interesante es qué debería pasar cuando un productor emite más rápido de lo que el consumidor puede procesar: un feed de precios que emite cada milisegundo frente a una UI que repinta treinta veces por segundo, o una ráfaga de sensores golpeando una base de datos que inserta fila por fila.
import reactor.core.publisher.Flux
fun main() {
Flux.range(1, 4)
.doOnRequest { n -> println("upstream sees: request($n)") }
.subscribe { println("received: $it") }
}Tenemos un flujo de cuatro valores y una sonda doOnRequest que imprime cada señal de demanda que recibe la fuente. Un subscribe { } normal no limita nada: solicita Long.MAX_VALUE de entrada — "mándame todo lo que tengas". Al final llegan los cuatro valores. Ese default ilimitado es exactamente la razón de que exista esta familia de operadores: cuando el consumidor es más lento que el productor, alguien tiene que decidir qué pasa con el excedente.
upstream sees: request(9223372036854775807) received: 1 received: 2 received: 3 received: 4
Cada operador de esta categoría elige un compromiso distinto para el desbordamiento:
- Conservar todo y entregarlo más tarde → onBackpressureBuffer
- Conservar lo que quepa y descartar el resto → onBackpressureDrop
- Conservar solo el valor más fresco → onBackpressureLatest
- Tratar el desbordamiento como un bug y fallar rápido → onBackpressureError
- No perder nada — remodelar la demanda en lotes pequeños → limitRate
onBackpressureBuffer
fun <T> Flux<T>.onBackpressureBuffer(): Flux<T> // also (maxSize), (maxSize, BufferOverflowStrategy), (maxSize, onOverflow)Cuando el suscriptor pide menos elementos de los que emite la fuente, onBackpressureBuffer guarda el excedente en una cola FIFO y lo reenvía conforme llega la demanda, conservando el orden original. Sin argumentos el buffer es ilimitado; con un maxSize es acotado y, al llenarse, aplica una estrategia de desbordamiento (por defecto ERROR, que emite una IllegalStateException; o DROP_LATEST / DROP_OLDEST). Las señales terminales (onComplete/onError) se reenvían solo después de drenar todos los elementos almacenados.
- Productor rápido en ráfagas con un consumidor más lento (picos de sensores/eventos)
- Fuentes calientes que no respetan el backpressure (eventos de UI, websockets)
- Suavizar desajustes temporales de rendimiento sin perder datos
- Acotar memoria con maxSize más un callback DROP u onOverflow
El buffer ilimitado por defecto puede crecer sin límite y provocar OutOfMemoryError si la fuente sigue superando al consumidor; siempre considera un maxSize acotado. Un buffer acotado con la estrategia por defecto lanza un error (IllegalStateException) en cuanto se desborda, terminando el flujo — usa DROP_OLDEST/DROP_LATEST si prefieres descartar datos en vez de fallar.
Flux.just(1, 2, 3, 4, 5, 6)
.onBackpressureBuffer()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Una fuente rápida emite 1..6 y luego completa, con un consumidor lento. ¿Qué emite este Flux?
onBackpressureBuffer es la estrategia de "no perder nada": cuando el consumidor se atrasa, los elementos que desbordan van a una cola FIFO y se reproducen — en su orden original — a medida que vuelve la demanda. Úsalo cuando cada elemento importa (órdenes, pagos, lecturas que alimentan una auditoría) y el desajuste de velocidad es una ráfaga temporal, no una diferencia permanente.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(20)) // sensor: one reading every 20 ms
.map { "reading-$it" }
.take(10)
.onBackpressureBuffer() // queue whatever the writer can't take yet
.delayElements(Duration.ofMillis(60)) // database writer: one insert every 60 ms
.doOnNext { println("persisted: $it") }
.blockLast()
}Tenemos un sensor que produce una lectura cada 20 ms y un escritor de base de datos que solo logra un insert cada 60 ms — un consumidor tres veces más lento que su productor. onBackpressureBuffer() estaciona cada lectura que el escritor todavía no ha pedido, y delayElements hace el papel del escritor lento. Al final nos suscribimos e imprimimos cada insert: las diez lecturas se persisten, en el orden exacto en que se produjeron, solo que más tarde de lo que llegaron.
persisted: reading-0 persisted: reading-1 persisted: reading-2 persisted: reading-3 persisted: reading-4 persisted: reading-5 persisted: reading-6 persisted: reading-7 persisted: reading-8 persisted: reading-9
La versión sin argumentos almacena sin límite, y ahí está también su peligro: si el consumidor nunca se pone al día, la cola crece hasta que la JVM se queda sin memoria. En producción normalmente se pasa un maxSize más una BufferOverflowStrategy que decide qué hacer cuando la cola está llena: ERROR (el default) termina el flujo con una IllegalStateException, DROP_OLDEST expulsa el elemento más viejo del buffer para hacer espacio, y DROP_LATEST rechaza al recién llegado para proteger lo que ya está encolado.
import reactor.core.publisher.Flux
import reactor.core.publisher.BufferOverflowStrategy
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(2)) // ~500 readings per second
.map { "reading-$it" }
.take(50)
.onBackpressureBuffer(
5, // keep at most 5 waiting readings
{ dropped -> println(" rejected: $dropped") },
BufferOverflowStrategy.DROP_LATEST // full? reject the newcomer
)
.delayElements(Duration.ofMillis(15)) // consumer: ~66 per second
.doOnNext { println("stored: $it") }
.blockLast()
}Tenemos ~500 lecturas por segundo corriendo hacia un consumidor que almacena ~66 por segundo, con espacio para solo cinco lecturas en espera. Las primeras ~40 lecturas pasan mientras el prefetch del consumidor las absorbe; después la pequeña cola se llena y DROP_LATEST empieza a rechazar a los recién llegados (de reading-42 en adelante) para proteger a los que ya esperan — cada rechazo se entrega al callback de overflow. Cuando se libera un hueco en el momento justo, un recién llegado (reading-46) se cuela. Todo lo que entró al buffer termina almacenado, en orden.
stored: reading-0 stored: reading-1 stored: reading-2 stored: reading-3 stored: reading-4 rejected: reading-42 rejected: reading-43 rejected: reading-44 rejected: reading-45 stored: reading-5 rejected: reading-47 rejected: reading-48 rejected: reading-49 stored: reading-6 stored: reading-7 stored: reading-8 stored: reading-9 stored: reading-10 stored: reading-11 stored: reading-12 stored: reading-13 stored: reading-14 stored: reading-15 stored: reading-16 stored: reading-17 stored: reading-18 stored: reading-19 stored: reading-20 stored: reading-21 stored: reading-22 stored: reading-23 stored: reading-24 stored: reading-25 stored: reading-26 stored: reading-27 stored: reading-28 stored: reading-29 stored: reading-30 stored: reading-31 stored: reading-32 stored: reading-33 stored: reading-34 stored: reading-35 stored: reading-36 stored: reading-37 stored: reading-38 stored: reading-39 stored: reading-40 stored: reading-41 stored: reading-46
El default ilimitado puede terminar en OutOfMemoryError si el consumidor nunca se pone al día — prefiere un maxSize acotado. Y recuerda que un buffer acotado sin estrategia explícita usa ERROR: mata todo el flujo con una IllegalStateException en el instante en que desborda. Si descartar datos es aceptable, dilo explícitamente con DROP_OLDEST o DROP_LATEST.
onBackpressureDrop
fun <T> Flux<T>.onBackpressureDrop(onDropped: (T) -> Unit = {}): Flux<T>onBackpressureDrop entrega los elementos aguas abajo mientras exista demanda pendiente; cuando llega un elemento pero la demanda del consumidor es cero, ese elemento se descarta en lugar de almacenarse, por lo que el operador nunca se desborda y solicita al origen de forma efectivamente ilimitada. Los elementos entregados conservan su orden original (1, 4, 7 en el marble — los intermedios se descartaron mientras el consumidor estaba ocupado) y las señales terminales (onComplete/onError) se propagan con normalidad al alcanzarse. El callback opcional onDropped se invoca de forma síncrona, en el hilo productor, por cada elemento descartado para que puedas registrarlo o liberar recursos.
- Telemetría en tiempo real, sensores o ticks de bolsa donde solo importa el dato más reciente y los antiguos no sirven
- Fuentes calientes que no puedes frenar (movimientos de mouse, eventos de UI) alimentando un procesador más lento
- Proteger a un consumidor lento del OOM cuando las ráfagas superan su capacidad
- Pipelines de métricas/logs donde un muestreo con pérdidas bajo carga es aceptable
Pierde datos de forma silenciosa — tiene pérdidas por diseño, así que es incorrecto cuando cada elemento debe procesarse (pagos, persistencia, exactly-once). Además, como solicita sin límite al upstream, una fuente rápida ilimitada junto a un callback onDropped rápido puede saturar un núcleo de CPU igualmente; el descarte ocurre en este operador, no en el origen.
Flux.just(1, 2, 3, 4, 5, 6, 7)
.onBackpressureDrop { dropped -> println("dropped " + dropped) }
.subscribe { value -> println("got " + value) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un Flux rápido se consume lentamente, así que onBackpressureDrop() descarta los elementos sobrantes. Según el diagrama de canicas (fuente 1..7, solo sobreviven 1, 4 y 7), ¿qué recibe realmente el consumidor?
onBackpressureDrop es la estrategia de "soltar carga": si el consumidor no tiene demanda pendiente cuando llega un elemento, ese elemento se descarta en el acto — opcionalmente entregándolo a un callback — y el flujo simplemente sigue. Brilla con datos en vivo que caducan al instante: tickers de precios, posiciones del mouse, actualizaciones de progreso. Pintar tarde el precio de hace un segundo es peor que no pintarlo nunca.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(3)) // price ticks, ~330 per second
.map { "tick-$it" }
.take(45)
.onBackpressureDrop { println(" dropped: $it") }
.delayElements(Duration.ofMillis(40)) // UI renders ~25 per second
.doOnNext { println("rendered: $it") }
.blockLast()
}Tenemos un feed de precios que emite cada 3 ms y una UI que renderiza un tick cada 40 ms. Los primeros ~34 ticks caben en el buffer de prefetch del renderizador; a partir de ahí, lo que llega mientras la UI está ocupada se descarta y el callback lo registra. Fíjate en tick-41 colándose en medio de los descartes: un hueco se liberó en el momento justo. Cuando la fuente completa, el flujo completa con normalidad — descartar es una política, no un error.
rendered: tick-0 rendered: tick-1 dropped: tick-34 dropped: tick-35 dropped: tick-36 dropped: tick-37 dropped: tick-38 dropped: tick-39 dropped: tick-40 rendered: tick-2 dropped: tick-42 dropped: tick-43 dropped: tick-44 rendered: tick-3 rendered: tick-4 rendered: tick-5 rendered: tick-6 rendered: tick-7 rendered: tick-8 rendered: tick-9 rendered: tick-10 rendered: tick-11 rendered: tick-12 rendered: tick-13 rendered: tick-14 rendered: tick-15 rendered: tick-16 rendered: tick-17 rendered: tick-18 rendered: tick-19 rendered: tick-20 rendered: tick-21 rendered: tick-22 rendered: tick-23 rendered: tick-24 rendered: tick-25 rendered: tick-26 rendered: tick-27 rendered: tick-28 rendered: tick-29 rendered: tick-30 rendered: tick-31 rendered: tick-32 rendered: tick-33 rendered: tick-41
Este operador pierde datos por diseño — nunca lo pongas en un flujo donde cada elemento debe procesarse (pagos, persistencia, pipelines exactly-once). Ten en cuenta además que el callback onDropped corre de forma síncrona en el hilo productor: mantenlo barato (un contador o una métrica), no otra operación lenta.
onBackpressureLatest
fun <T> Flux<T>.onBackpressureLatest(): Flux<T>Cuando el consumidor pide más lento de lo que el productor emite, onBackpressureLatest mantiene un buffer de un solo elemento con el valor más reciente; cada nuevo elemento sobrescribe al anterior, así que los valores antiguos no entregados se descartan en silencio. En la siguiente petición del consumidor este recibe el último valor cacheado en ese instante, conservando el orden de los elementos que sí se entregan. Las señales terminales (onComplete/onError) pasan directamente una vez emitido el valor en buffer — en el marble A se entrega de inmediato, B y C son sobrescritos por D, y luego D y E llegan al consumidor lento antes del complete.
- Feeds en vivo de sensores, precios o telemetría donde solo importa el dato más fresco
- Actualizaciones de UI/estado que deben saltar a lo más reciente, omitiendo intermedios obsoletos
- Productores hot rápidos alimentando un consumidor lento que no puede permitirse buffering ilimitado
- Dashboards o medidores que pintan el último tick en vez de cada valor histórico
NO frena la fuente — el upstream sigue a máxima velocidad y pierdes en silencio todos los valores salvo el último, así que es incorrecto si cada elemento debe procesarse (para eso usa onBackpressureBuffer). El descarte además tiene condiciones de carrera: un valor puede descartarse justo antes de que llegue una petición, y los elementos perdidos no pasan por onNext (sin callback por elemento salvo que uses el hook de discard).
val source = Flux.just("A", "B", "C", "D", "E")
.onBackpressureLatest()
// slow subscriber requests items one at a time
source.subscribe(slowConsumer)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un Flux rápido alimenta a un suscriptor lento a través de .onBackpressureLatest(). Dados los valores A, B, C, D, E y luego complete, ¿qué secuencia recibe finalmente el consumidor lento?
onBackpressureLatest mantiene un buffer de exactamente un elemento: el más nuevo. Cada llegada sobrescribe el valor pendiente anterior, y cuando el consumidor por fin pide más, recibe solo el estado más fresco. Es el ajuste natural para posiciones GPS, lecturas de medidores, feeds de "estado actual" — cualquier lugar donde un valor intermedio queda obsoleto en cuanto existe uno más nuevo.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(3)) // a GPS fix every 3 ms
.map { "position-$it" }
.take(100)
.onBackpressureLatest() // keep only the freshest position
.delayElements(Duration.ofMillis(40)) // map redraw, ~25 per second
.doOnNext { println("map shows: $it") }
.blockLast()
}Tenemos una posición GPS cada 3 ms y un mapa que se repinta cada 40 ms. Las primeras ~34 posiciones pasan directo mientras dura el prefetch del renderizador; después el mapa queda permanentemente atrasado, así que cada repintado muestra la posición más fresca de ese instante — mira la secuencia saltar de 33 a 40, luego 53, 67, 80, 94. Una garantía resalta al final: el último elemento (position-99) siempre se entrega, porque nada más nuevo llega a sobrescribirlo.
map shows: position-0 map shows: position-1 map shows: position-2 map shows: position-3 map shows: position-4 map shows: position-5 map shows: position-6 map shows: position-7 map shows: position-8 map shows: position-9 map shows: position-10 map shows: position-11 map shows: position-12 map shows: position-13 map shows: position-14 map shows: position-15 map shows: position-16 map shows: position-17 map shows: position-18 map shows: position-19 map shows: position-20 map shows: position-21 map shows: position-22 map shows: position-23 map shows: position-24 map shows: position-25 map shows: position-26 map shows: position-27 map shows: position-28 map shows: position-29 map shows: position-30 map shows: position-31 map shows: position-32 map shows: position-33 map shows: position-40 map shows: position-53 map shows: position-67 map shows: position-80 map shows: position-94 map shows: position-99
onBackpressureError
fun <T> Flux<T>.onBackpressureError(): Flux<T>Reenvía los elementos aguas abajo mientras haya demanda pendiente (request(n)). En el instante en que la fuente emite un elemento con demanda cero, termina el flujo con un onError que lleva una IllegalStateException ("The receiver is overrunning its capacity"), como se ve cuando [1,2] pasan antes de la X. Es de fallo rápido: no hace buffer ni invoca callback de descarte, solo emite un error inmediato que cancela el upstream.
- Cuando un desbordamiento indica un bug real que prefieres ver explotar en vez de descartar en silencio
- Sistemas estrictos donde perder datos es peor que cortar la tubería
- Probar o diagnosticar si un consumidor realmente aguanta el ritmo del productor
- Contratos de fallo rápido donde el downstream debe respetar la contrapresión o abortar
Es la estrategia más estricta: un solo desbordamiento derriba todo el flujo con un error, así que el downstream deja de recibir todo, no solo el elemento problemático. El error es una IllegalStateException, no un error de dominio, así que asegúrate de que tu manejo de onError no lo confunda con una falla normal.
Flux.just(1, 2, 3, 4, 5)
.onBackpressureError()
.subscribe(
{ value -> println("next=" + value) },
{ err -> println("error=" + err) }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un suscriptor lento solo solicita 2 elementos, pero la fuente empuja 1,2,3,4,5 más rápido de lo que se consumen. ¿Qué emite este Flux al suscriptor?
onBackpressureError trata el desbordamiento como un defecto, no como una condición a gestionar: el primer elemento que llega sin demanda del consumidor termina el flujo con una IllegalStateException. Elígelo cuando perder datos en silencio sería peor que fallar — pagos, movimientos de inventario, eventos de facturación — o cuando quieres que un desajuste de carga despierte a un operador en vez de esconderse dentro de un buffer.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
import java.time.Duration
fun main() {
Flux.interval(Duration.ofMillis(1)) // transactions arrive every 1 ms
.map { "TXN-${1000 + it}" }
.onBackpressureError() // overflow must never be silent
.publishOn(Schedulers.single())
.doOnNext { Thread.sleep(30) } // each commit takes 30 ms
.subscribe(
{ println("committed: $it") },
{ err -> println("ALERT ${err::class.simpleName}: ${err.message}") }
)
Thread.sleep(600)
}Tenemos transacciones llegando cada milisegundo y un committer que tarda 30 ms por cada una, movido a su propio hilo por publishOn. Los primeros commits pasan mientras la cola de prefetch de publishOn absorbe la diferencia, pero el productor le saca ventaja sin descanso al consumidor; en el momento en que un elemento aparece con demanda cero, onBackpressureError cancela la fuente y empuja un onError aguas abajo. Al final nuestro manejador de error imprime la alerta — y después de ella no se entrega nada más, porque el error es terminal.
committed: TXN-1000 committed: TXN-1001 committed: TXN-1002 committed: TXN-1003 committed: TXN-1004 committed: TXN-1005 committed: TXN-1006 committed: TXN-1007 ALERT IllegalStateException: The receiver is overrun by more signals than expected (bounded queue...)
Un solo desbordamiento derriba el flujo completo — se pierden todos los elementos siguientes, no solo el que desbordó. Si usas onBackpressureError como canario, acompáñalo de lógica de retry/resume antes de tu suscriptor, y asegúrate de que tu manejo de onError no confunda la IllegalStateException con una falla de negocio.
limitRate
fun <T> Flux<T>.limitRate(prefetchRate: Int): Flux<T> // also limitRate(highTide, lowTide)limitRate es un operador transparente para los valores: cada elemento del upstream (1,2,3,4,5,6) y la señal terminal de complete/error pasan al downstream sin cambios y en el mismo orden. Lo que modifica es el protocolo de backpressure: en lugar de propagar tal cual la petición del downstream (a menudo ilimitada), pide en bloques fijos de `prefetchRate` y repone volviendo a pedir cuando se ha consumido el 75% del lote, suavizando la demanda sobre la fuente.
- Regular una fuente que pagina o trae datos por bloques (p. ej. un cursor de BD o una API HTTP)
- Limitar el prefetch para que un downstream rápido e ilimitado no lo pida todo de golpe
- Ajustar el uso de memoria acotando cuántos elementos en vuelo se almacenan en buffer
- Dosificar las peticiones a un sistema externo que rinde mejor con lotes pequeños
limitRate NO descarta ni limita elementos en el tiempo como un debounce/sample: todos los elementos siguen llegando; solo remodela las peticiones de backpressure. Por defecto repone en la marea baja del 75% (usa la sobrecarga limitRate(highTide, lowTide) para cambiarlo), y poner lowTide en 0 hace que vuelva a pedir solo tras vaciar por completo cada lote. Para limitar la demanda TOTAL, no uses el deprecado limitRequest(n) — usa take(n, true).
Flux.just(1, 2, 3, 4, 5, 6)
.limitRate(2)
.subscribe { print(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux al suscriptor?
limitRate no descarta, no retrasa y no transforma nada — cada elemento sigue llegando, en orden. Lo que cambia es la demanda que tu pipeline envía aguas arriba: en lugar de reenviar tal cual una petición enorme (a menudo ilimitada), pide los elementos en bloques de prefetchRate y repone cuando se ha consumido el 75% del lote actual. Úsalo para evitar que un consumidor ansioso le pida todo de golpe a una API paginada, un cursor de base de datos o un broker de mensajes.
import reactor.core.publisher.Flux
// A paginated API: doOnRequest shows every demand signal it receives
fun paginatedApi(): Flux<String> = Flux.range(1, 12)
.doOnRequest { n -> println(" API sees: request($n)") }
.map { "page-$it" }
fun main() {
println("Without limitRate:")
paginatedApi().subscribe { println(" got $it") }
println("With limitRate(4):")
paginatedApi()
.limitRate(4)
.subscribe { println(" got $it") }
}Tenemos una API paginada instrumentada con doOnRequest para ver llegar la demanda. Suscribirse directo envía request(9223372036854775807) — Long.MAX_VALUE, ilimitado. A través de limitRate(4) el mismísimo suscriptor se vuelve educado: la API ve primero request(4) y luego, cada vez que se consume el 75% de un lote, un request(3) de reposición. Al final las doce páginas salen en ambos casos, intactas y en orden — solo cambia el patrón de peticiones.
Without limitRate: API sees: request(9223372036854775807) got page-1 got page-2 got page-3 got page-4 got page-5 got page-6 got page-7 got page-8 got page-9 got page-10 got page-11 got page-12 With limitRate(4): API sees: request(4) got page-1 got page-2 got page-3 got page-4 API sees: request(3) got page-5 got page-6 got page-7 API sees: request(3) got page-8 got page-9 got page-10 API sees: request(3) got page-11 got page-12
Nota de deprecación: limitRequest(n) — que limitaba la demanda TOTAL en vez de trocearla — está deprecado; usa take(n, true) en su lugar. Y desde Reactor 3.5, el take(n) normal también limita la petición aguas arriba por defecto (antes pedía ilimitado y simplemente cancelaba después de n). Regla práctica: limitRate remodela la demanda en lotes; take(n, true) le pone un techo duro.
Planificación de hilos (Scheduling)
Por diseño, Reactor es agnóstico a la concurrencia: un Flux no es dueño de ningún hilo. Nada se ejecuta mientras armas el pipeline, y cuando por fin te suscribes, cada paso — desde las emisiones de la fuente hasta el callback de tu suscriptor — corre en el hilo que haya llamado a subscribe(). Eso es una ventaja, no una limitación: en lugar de que la librería te imponga un modelo de hilos, tú eliges uno, exactamente donde lo necesitas, con un puñado de operadores de scheduling.
import reactor.core.publisher.Flux
fun main() {
println("running on: ${Thread.currentThread().name}")
Flux.just("order-1", "order-2")
.map { "$it charged on ${Thread.currentThread().name}" }
.subscribe { println(it) }
println("subscribe() returned on: ${Thread.currentThread().name}")
}Cobramos dos pedidos e imprimimos el hilo en ejecución en cada paso. Como no interviene ningún scheduler, todo el pipeline corre de forma síncrona sobre el hilo llamante: el map y el callback del suscriptor se ejecutan en main, y el println final solo corre cuando el flujo ya completó.
running on: main order-1 charged on main order-2 charged on main subscribe() returned on: main
Cuando sí quieres mover trabajo, eliges un Scheduler — la abstracción de Reactor sobre un pool de hilos — y colocas en la cadena alguno de los operadores de abajo. Los schedulers integrados cubren los sospechosos de siempre:
- Schedulers.parallel() — un pool fijo del tamaño de tus núcleos de CPU, para cómputo rápido y no bloqueante
- Schedulers.boundedElastic() — un pool acotado que crece bajo demanda, hecho para envolver I/O bloqueante (JDBC, archivos, SDKs legados)
- Schedulers.single() — un único hilo compartido, para trabajo de bajo volumen que debe mantenerse ordenado
- Schedulers.immediate() — sin salto de hilo: te quedas en el hilo actual (un placeholder no-op)
El modelo mental: subscribeOn decide dónde empieza a emitir la fuente (su posición en la cadena no importa), publishOn reenruta cada señal por debajo de él (la posición lo es todo), y parallel().runOn() es el único de los tres que da un verdadero fan-out multinúcleo.
publishOn
fun <T> Flux<T>.publishOn(scheduler: Scheduler): Flux<T>publishOn no altera los valores ni su orden: [A,B,C,D] llegan idénticos seguidos del mismo onComplete (o onError). Lo que cambia es el hilo: cada señal emitida hacia abajo (onNext, onComplete, onError) se reprograma sobre un worker del Scheduler indicado, y esa afinidad de hilo se propaga a todos los operadores siguientes hasta el próximo publishOn. Los elementos se transfieren por una cola acotada, así que la frontera afectada corre de forma secuencial en un worker del Scheduler aunque el upstream se ejecute en otro lado.
- Mover trabajo aguas abajo lento o bloqueante (parseo, mapeo, envoltorios de I/O) fuera del hilo productor
- Publisher rápido, consumidor lento: desacoplar la tasa de emisión del procesamiento
- Saltar transformaciones intensivas de CPU a los hilos worker de Schedulers.parallel()
- Salir de un hilo de event-loop de Netty antes de hacer procesamiento por elemento más pesado
publishOn solo afecta a los operadores colocados DESPUÉS de él (aguas abajo); para controlar el hilo de suscripción/origen necesitas subscribeOn. Además, un único worker de Schedulers.parallel() procesa toda la frontera de forma secuencial, así que publishOn NO paraleliza por elemento, solo reubica el hilo; usa flatMap con publishOn/subscribeOn internos o parallel().runOn() para un fan-out real.
val flux = Flux.just("A", "B", "C", "D", "OK")
.publishOn(Schedulers.parallel())
.doOnNext { v -> println(v + " on " + Thread.currentThread().name) }
flux.subscribe()🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux, y qué cambia al aplicar publishOn?
publishOn es el operador al que recurres cuando un stream se produce en un hilo que debe seguir respondiendo — un event loop de Netty, el hilo de UI — pero lo consume trabajo demasiado pesado para correr ahí. Desde el punto exacto donde aparece en la cadena, publishOn reenvía cada señal (onNext, onComplete, onError) a un worker del Scheduler que le pases, así que todo lo que está aguas abajo se ejecuta allí hasta el siguiente publishOn. Los valores, el orden y las señales terminales quedan intactos; solo cambia el hilo que ejecuta.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
// publishOn switches the thread for every operator placed AFTER it
Flux.just("btc", "eth", "sol")
.map { "$it mapped on ${Thread.currentThread().name}" }
.publishOn(Schedulers.parallel())
.map { "$it, then on ${Thread.currentThread().name}" }
.doOnNext { println(it) }
.blockLast()
}Mapeamos tres símbolos de criptomonedas dos veces, imprimiendo el hilo en cada paso. El primer map está por encima de publishOn, así que corre en main. Luego publishOn(Schedulers.parallel()) reenruta el flujo: el segundo map y el doOnNext de abajo corren en el worker parallel-1. Mismos valores, mismo orden — solo cambió el hilo a mitad de cadena.
btc mapped on main, then on parallel-1 eth mapped on main, then on parallel-1 sol mapped on main, then on parallel-1
En un servicio real el patrón suele tener dos saltos: salir del event loop para hacer I/O bloqueante en boundedElastic, y luego saltar de nuevo a parallel para el posprocesamiento intensivo en CPU. Cada publishOn manda sobre el segmento entre él y el siguiente:
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
// Real-world pattern: HTTP request arrives on Netty's event-loop thread.
// Heavy computation must NOT block the event loop — use publishOn to offload.
fun main() {
Flux.just("user-request-1", "user-request-2", "user-request-3")
.doOnNext {
// This runs on the calling thread (e.g., Netty event-loop)
println("[$it] Received on: ${Thread.currentThread().name}")
}
.publishOn(Schedulers.boundedElastic()) // offload to elastic pool
.map { request ->
// Simulate heavy DB query — now on boundedElastic, not event-loop!
Thread.sleep(50)
println("[$request] DB query on: ${Thread.currentThread().name}")
"$request → result"
}
.publishOn(Schedulers.parallel()) // switch to parallel for CPU work
.map { result ->
println("[$result] Serialization on: ${Thread.currentThread().name}")
"{ \"data\": \"$result\" }"
}
.blockLast()
}Las tres solicitudes se reciben en main, porque ese doOnNext está por encima de cualquier frontera. El primer publishOn mueve la consulta simulada de 50 ms a boundedElastic-1; el segundo mueve la serialización a parallel-1. Observa cómo se entrelazan las líneas: mientras la solicitud 1 se serializa en el worker parallel, la consulta de la solicitud 2 ya corre en el elastic — la cadena se volvió una pequeña línea de ensamblaje.
[user-request-1] Received on: main [user-request-2] Received on: main [user-request-3] Received on: main [user-request-1] DB query on: boundedElastic-1 [user-request-1 → result] Serialization on: parallel-1 [user-request-2] DB query on: boundedElastic-1 [user-request-2 → result] Serialization on: parallel-1 [user-request-3] DB query on: boundedElastic-1 [user-request-3 → result] Serialization on: parallel-1
publishOn no paraleliza. Toda la frontera aguas abajo la procesa en orden UN solo worker a la vez — reubica el trabajo, no lo reparte. Para concurrencia por elemento usa flatMap con un subscribeOn interno, o parallel().runOn() (abajo). Y recuerda la colocación: a los operadores por encima de un publishOn no les afecta en absoluto.
subscribeOn
fun <T> Flux<T>.subscribeOn(scheduler: Scheduler): Flux<T>subscribeOn no altera qué valores fluyen: el marble de entrada [1,2,3,4,done] sale idéntico como [1,2,3,4,done], conservando el orden, cada elemento y la señal terminal de complete. Lo que cambia es el hilo: la suscripción, onSubscribe y las señales de request corren en un worker del Scheduler dado, así que la fuente empieza a emitir desde ese hilo. Como la suscripción se propaga de abajo hacia arriba hasta la fuente, su posición en la cadena no importa: afecta a todo el upstream desde la fuente (hasta que un publishOn posterior vuelva a cambiar de hilo).
- Envolver una fuente bloqueante o síncrona (JDBC, ficheros, I/O legado) para que corra fuera del hilo llamante
- Descargar un publisher lento o intensivo en CPU a Schedulers.boundedElastic() o parallel()
- Mantener libre el event loop / hilo principal al suscribirse a un generador bloqueante
- Elegir el hilo donde arranca la emisión sin importar dónde esté el operador
Solo el primer subscribeOn de la cadena tiene efecto: los siguientes se ignoran para la colocación de la fuente. No es el espejo de publishOn: subscribeOn afecta a todo el upstream sin importar su posición, mientras que publishOn solo mueve las señales que van por debajo de él. Un publishOn posterior reenrutará todo lo que venga después de él a su propio Scheduler.
val scheduler = Schedulers.boundedElastic()
Flux.just(1, 2, 3, 4)
.subscribeOn(scheduler)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux y en qué hilo se ejecuta la suscripción?
Algunos publishers nacen bloqueantes: leer un archivo, llamar a JDBC, envolver un SDK legado. El trabajo ocurre al momento de la suscripción, dentro de la propia fuente — ningún publishOn colocado después puede ayudar, porque cuando las señales llegan a esa frontera la llamada bloqueante ya se ejecutó. subscribeOn lo resuelve de raíz: mueve el acto de suscribirse (y por lo tanto todo lo que la fuente hace para producir datos) al Scheduler que le indiques. Y como la señal de suscripción viaja desde el fondo de la cadena hacia arriba hasta la fuente, no importa dónde lo pongas — siempre afecta a la fuente.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
// subscribeOn dictates the thread the whole source chain runs on
Flux.fromIterable(listOf("invoice-1", "invoice-2", "invoice-3"))
.doOnSubscribe { println("subscribed on ${Thread.currentThread().name}") }
.map { "$it processed on ${Thread.currentThread().name}" }
.subscribeOn(Schedulers.boundedElastic())
.doOnNext { println(it) }
.blockLast()
}El Flux se arma y se suscribe desde main, y aun así subscribeOn(Schedulers.boundedElastic()) reubica la suscripción: doOnSubscribe ya se dispara en boundedElastic-1, y cada factura se emite y se mapea ahí también. Fíjate en que subscribeOn está por debajo del map en la cadena y aun así lo movió — la posición no importa.
subscribed on boundedElastic-1 invoice-1 processed on boundedElastic-1 invoice-2 processed on boundedElastic-1 invoice-3 processed on boundedElastic-1
La pareja clásica en producción es subscribeOn para la fuente bloqueante más publishOn para el procesamiento de abajo:
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
// subscribeOn moves the SOURCE to a different thread pool.
// Unlike publishOn, placement doesn't matter — it always affects upstream.
// Use for blocking I/O sources that shouldn't run on the event loop.
fun blockingFileReader(): Flux<String> = Flux.create { sink ->
println("Reading file on: ${Thread.currentThread().name}")
// Simulate blocking file read
listOf("line-1: config=production", "line-2: port=8080", "line-3: debug=false")
.forEach { sink.next(it) }
sink.complete()
}
fun main() {
blockingFileReader()
.subscribeOn(Schedulers.boundedElastic()) // blocking I/O → elastic
.publishOn(Schedulers.parallel()) // processing → parallel
.filter { !it.contains("debug") }
.map { line ->
println("Parsing on: ${Thread.currentThread().name}")
val (key, value) = line.substringAfter(": ").split("=")
key to value
}
.doOnNext { (k, v) -> println("Config: $k = $v") }
.blockLast()
// Key insight: subscribeOn(elastic) ensures the file read doesn't
// block the caller's thread, while publishOn(parallel) offloads parsing.
}blockingFileReader simula una lectura bloqueante dentro de Flux.create. subscribeOn(boundedElastic) manda esa lectura a boundedElastic-1, así que el hilo llamante nunca se bloquea. De publishOn(parallel) hacia abajo, el filtrado y el parseo corren en parallel-1. La línea de debug se filtra, y por eso solo dos líneas se parsean a pares de configuración.
Reading file on: boundedElastic-1 Parsing on: parallel-1 Config: config = production Parsing on: parallel-1 Config: port = 8080
Una regla más que vale la pena memorizar: si dos subscribeOn terminan en la misma cadena — el tuyo más uno escondido dentro de una librería, digamos — gana el más cercano a la fuente. La señal de suscripción viaja hacia arriba, así que el salto más próximo a la fuente ocurre al final y decide dónde corre realmente la fuente:
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
// Two subscribeOn calls: only the one closest to the source wins
Flux.just("payment-1", "payment-2")
.map { "$it validated on ${Thread.currentThread().name}" }
.subscribeOn(Schedulers.boundedElastic()) // closest to the source → wins
.subscribeOn(Schedulers.parallel()) // ignored for source placement
.doOnNext { println(it) }
.blockLast()
}payment-1 validated on boundedElastic-1 payment-2 validated on boundedElastic-1
Chuleta: subscribeOn = dónde nace el flujo (afecta a la fuente, independiente de la posición, gana el más cercano a la fuente). publishOn = por dónde fluye de ahí en adelante (afecta solo aguas abajo, la posición lo es todo, puede aparecer varias veces).
parallel
fun <T> Flux<T>.parallel(parallelism: Int = cpuCount): ParallelFlux<T>; ParallelFlux<T>.runOn(scheduler: Scheduler): ParallelFlux<T>; ParallelFlux<T>.sequential(): Flux<T>parallel() divide la fuente en N 'rieles' repartiendo cada onNext por turnos (round-robin) entre los rieles (1->riel0, 2->riel1, ...), pero por sí solo NO cambia de hilo. runOn(scheduler) es lo que realmente mueve cada riel a un hilo worker del scheduler, de modo que los operadores por riel (aquí un map x10) se ejecutan en paralelo. sequential() vuelve a unir los rieles en un solo Flux, drenándolos de forma equitativa, por lo que los valores pueden quedar reordenados respecto a la fuente; el onComplete fusionado solo se emite cuando todos los rieles han completado.
- Transformaciones intensivas en CPU sobre un stream grande (parseo, hashing, cálculo numérico/imágenes)
- Saturar varios núcleos cuando el trabajo por elemento es pesado e independiente
- I/O bloqueante en paralelo con runOn(Schedulers.boundedElastic()) entre rieles
- Repartir, calcular por riel y luego sequential() para alimentar un downstream que espera un solo Flux
Llamar a parallel() por sí solo no paraleliza nada: sin runOn() todos los rieles se ejecutan en el hilo original que invoca; runOn() es obligatorio para concurrencia real. Además, el procesamiento paralelo rompe el orden de la fuente: sequential() NO restaura el orden original, así que [1,2,3,4,5,6] puede salir entrelazado. Si necesitas paralelismo Y orden, usa flatMapSequential o flatMap(..., concurrency) sobre un scheduler.
val par = Schedulers.parallel()
Flux.just(1, 2, 3, 4, 5, 6)
.parallel()
.runOn(par)
.map { it * 10 }
.sequential()
.subscribe { println("got " + it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este fragmento cuando se recolecta?
publishOn y subscribeOn mueven el trabajo a otro hilo, pero el flujo sigue siendo secuencial — un elemento tras otro sobre un único worker. Cuando cada elemento trae trabajo de CPU de verdad (hashing, redimensionar imágenes, calcular puntajes), lo que quieres es todos los núcleos trabajando a la vez. Eso es parallel(): divide el Flux en N 'rieles' independientes, runOn() le da a cada riel su propio worker, y sequential() vuelve a unir los rieles en un Flux normal cuando terminas.
import reactor.core.publisher.Flux
import reactor.core.scheduler.Schedulers
fun main() {
Flux.range(1, 10)
.parallel(4) // 4 rails
.runOn(Schedulers.parallel()) // run on parallel scheduler
.map { value ->
println("Processing $value on ${Thread.currentThread().name}")
value * value
}
.sequential() // merge back to single Flux
.collectSortedList()
.subscribe { println("Results: $it") }
Thread.sleep(1000)
}Elevamos al cuadrado los números del 1 al 10 en cuatro rieles. parallel(4) reparte los elementos por turnos — el riel 0 recibe 1, 5, 9; el riel 1 recibe 2, 6, 10; y así sucesivamente — y runOn(Schedulers.parallel()) le da a cada riel su propio worker. La salida muestra exactamente esa asignación: parallel-1 procesó 1, 5 y 9 mientras parallel-4 procesaba 4 y 8, todo al mismo tiempo. Después, collectSortedList() reúne los cuadrados en orden ascendente.
Processing 1 on parallel-1 Processing 2 on parallel-2 Processing 3 on parallel-3 Processing 4 on parallel-4 Processing 5 on parallel-1 Processing 9 on parallel-1 Processing 6 on parallel-2 Processing 10 on parallel-2 Processing 7 on parallel-3 Processing 8 on parallel-4 Results: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Dos trampas. parallel() por sí solo no crea ninguna concurrencia — hasta que llamas a runOn(), cada riel sigue corriendo en el hilo original, uno tras otro. Y sequential() no restaura el orden de la fuente: los rieles se drenan según producen, así que los valores de rieles distintos se entrelazan. ¿Necesitas paralelismo conservando el orden? Usa flatMapSequential, u ordena después de la unión como hace aquí collectSortedList().
Multicasting & Caching
Por defecto un Flux es frío: cada suscriptor obtiene su propia ejecución privada de la fuente. Suscribe tres componentes a un Flux respaldado por una llamada HTTP y disparas tres llamadas HTTP. Los operadores de esta sección cambian ese contrato: permiten que muchos suscriptores compartan una sola suscripción upstream, convirtiendo el Flux en un flujo caliente que difunde datos en vivo a quien esté escuchando.
Las cuatro herramientas se diferencian en dos preguntas: ¿cuándo arranca la fuente compartida? y ¿qué ve un suscriptor tardío?
- share — multicast caliente con arranque automático: el primer suscriptor enciende la fuente y el último en irse la apaga. Los suscriptores tardíos se pierden el pasado.
- publish — devuelve un ConnectableFlux: los suscriptores se forman en fila, pero nada fluye hasta que tú mismo llamas a connect().
- refCount / autoConnect — políticas de conexión automática para un ConnectableFlux; refCount(1) es exactamente cómo funciona share() por dentro.
- replay / cache — multicast más memoria: los suscriptores tardíos primero reciben el historial reciente reproducido y luego continúan en vivo.
share
share() es la forma de una sola línea de volver caliente una fuente. El primer suscriptor dispara la única suscripción upstream; todo el que se suscriba después se engancha al mismo flujo en vivo en lugar de reiniciarlo. Imagina un feed de precios en vivo: quieres una sola conexión a la bolsa sin importar cuántos widgets de la página estén mirando, y quien sintoniza tarde simplemente arranca desde el precio actual.
Por dentro, share() es literalmente publish().refCount(1) — la mecánica se explica en la sección de refCount más abajo. Su hermano con sabor a Mono, shareNext(), comparte la fuente de la misma manera pero le entrega a cada suscriptor solo el primer valor producido, como un Mono<T>.
fun <T> Flux<T>.share(): Flux<T>share() convierte un Flux frío en uno caliente envolviéndolo en un ConnectableFlux que se auto-conecta con el primer suscriptor — es literalmente publish().refCount(1). La única suscripción al origen se multidifunde a todos los suscriptores actuales, que ven el mismo flujo en curso desde que se unen; los suscriptores tardíos se pierden los elementos ya emitidos. Cuando el último suscriptor cancela, se cierra la suscripción al origen, y una suscripción nueva reinicia el origen frío. Las señales terminales (complete/error) se difunden a todos los suscritos en ese momento. shareNext() es la variante Mono: una suscripción compartida, y cada suscriptor recibe el primer valor que produce la fuente.
- Compartir una llamada costosa (HTTP, consulta a BD) entre varios suscriptores concurrentes
- Multidifundir un feed de datos en vivo en lugar de repetir el trabajo por suscriptor
- Repartir un único origen hacia varias tuberías sin duplicar efectos secundarios
- Evitar resuscribir a un origen que solo debe ejecutarse una vez mientras hay consumidores activos
share() NO cachea: los suscriptores tardíos pierden todo lo emitido antes de unirse, y si todos se van el conteo de referencias llega a 0, así que el siguiente suscriptor reejecuta el origen frío desde cero en vez de reproducir. Si necesitas reproducir elementos pasados, usa cache() o replay().
val hot = Flux.just("A", "B", "C", "D").share()
// A and B are already emitted to the first subscriber.
// A late subscriber attaches just before C and D:
hot.subscribe { println("late -> " + it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dada una fuente caliente que ya emitió A y B, un suscriptor tardío se conecta mediante share() justo antes de que se produzcan C y D. ¿Qué recibe ese suscriptor tardío?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// A live price feed: one connection to the exchange, shared by everyone
val prices = Flux.interval(Duration.ofMillis(100))
.map { 100 + it }
.doOnSubscribe { println("feed: connected to exchange") }
.share()
prices.subscribe { println("dashboard: $it") } // first subscriber starts the feed
Thread.sleep(250)
prices.subscribe { println("logger: $it") } // joins late, misses early prices
Thread.sleep(300)
}Simulamos un feed de precios con interval, que late cada 100 ms y mapea el número de tick a un precio. El dashboard se suscribe primero — eso es lo que realmente conecta con la bolsa, y doOnSubscribe demuestra que la conexión ocurre exactamente una vez. Para cuando el logger se une 250 ms después, los precios 100 y 101 ya pasaron; no recibe nada retroactivamente y simplemente se sube al tren desde 102. A partir de ahí, ambos suscriptores ven cada precio al mismo tiempo:
feed: connected to exchange dashboard: 100 dashboard: 101 dashboard: 102 logger: 102 dashboard: 103 logger: 103 dashboard: 104 logger: 104
share() no cachea nada: un suscriptor tardío se pierde todo lo emitido antes de unirse. Y como se basa en refCount, cuando el último suscriptor cancela, el upstream se desconecta — el siguiente suscriptor reinicia la fuente fría desde cero. Si los suscriptores necesitan historial, usa cache() o replay().
publish / ConnectableFlux
publish() te da control manual sobre cuándo arranca un flujo compartido. Devuelve un ConnectableFlux: los suscriptores pueden formarse libremente, pero la fuente permanece dormida hasta que das el disparo de salida con connect(). Eso lo hace perfecto para el patrón preparar-y-arrancar: conecta primero a todos los consumidores y luego suelta los datos una sola vez, con la certeza de que nadie se perdió el comienzo.
fun <T> Flux<T>.publish(): ConnectableFlux<T>publish() convierte un Flux frío en un ConnectableFlux que mantiene una única suscripción compartida hacia el origen. No se solicita ni emite nada hasta que llamas a connect(); en ese instante el origen arranca y todos los suscriptores ya conectados reciben las mismas emisiones y la señal terminal de completado/error juntos y en orden. Los suscriptores que se unan después de connect() se pierden lo emitido antes de unirse, porque el flujo ya es caliente. connect() devuelve un Disposable con el que puedes derribar la conexión compartida.
- Compartir un origen costoso (llamada HTTP, consulta a BD) entre varios suscriptores
- Coordinar para que todos los suscriptores se conecten antes de emitir, usando connect()
- Evitar reejecutar efectos secundarios que un Flux frío repetiría por suscriptor
- Construir flujos calientes donde los suscriptores tardíos no deben repetir el historial
Es caliente tras conectar: los suscriptores agregados después de connect() solo ven los elementos futuros (o solo la señal terminal si el origen ya terminó). Si olvidas llamar a connect(), el origen nunca arranca y los suscriptores esperan para siempre; al revés, connect() sin suscriptores consume y pierde todos los datos. Usa autoConnect()/refCount() para conectar automáticamente.
val shared = Flux.just("A", "B", "C").publish()
shared.subscribe { print("s1=" + it + " ") }
shared.subscribe { print("s2=" + it + " ") }
shared.connect()🧠 Reto: predice la salida
0/1 · 0/1 answered1. Se crea un ConnectableFlux a partir de Flux.just("A", "B", "C") con publish(). Tras adjuntar ambos suscriptores, se llama a connect() una vez. ¿Qué recibe cada suscriptor?
import reactor.core.publisher.Flux
fun main() {
// ConnectableFlux waits: nothing is emitted until connect() is called
val events = Flux.just("login", "click", "purchase")
.doOnNext { println("emitting $it") }
.publish()
events.subscribe { println("analytics: $it") }
events.subscribe { println("audit: $it") }
println("-- both subscribers ready, connecting --")
events.connect()
}Tenemos un flujo de eventos de usuario — login, click, purchase — que les interesa a dos consumidores independientes: analytics y audit. Ambos se suscriben al ConnectableFlux, y observa que la sonda doOnNext permanece en silencio: todavía no se emitió nada. Solo cuando se llama a connect() la fuente corre, exactamente una vez, empujando cada evento a ambos suscriptores al unísono. La única línea 'emitting' por evento demuestra que el trabajo no se duplica:
-- both subscribers ready, connecting -- emitting login analytics: login audit: login emitting click analytics: click audit: click emitting purchase analytics: purchase audit: purchase
Dos trampas clásicas: si olvidas llamar a connect(), nada fluye jamás — los suscriptores esperan para siempre; si llamas a connect() antes de adjuntar suscriptores, los datos se van al vacío — con Flux.just completaría antes de que alguien escuche algo. Cuando prefieras no manejar connect() a mano, para eso existen precisamente refCount y autoConnect.
refCount / autoConnect
fun <T> ConnectableFlux<T>.refCount(minSubscribers: Int = 1): Flux<T> // autoConnect(n): connect at n, never disconnectAmbos operadores convierten un ConnectableFlux de vuelta en un Flux normal automatizando connect(). refCount(n) mantiene un conteo vivo de suscriptores: cuando llega a n conecta el upstream compartido, y cuando vuelve a cero lo cancela; el siguiente suscriptor después de eso inicia un ciclo de conexión totalmente nuevo, reejecutando la fuente fría desde el principio. autoConnect(n) conecta en el mismo umbral pero es de disparo único: una vez conectado, el upstream sigue suscrito aunque no quede ningún suscriptor. share() es exactamente publish().refCount(1).
- Ejecutar un feed en vivo costoso solo mientras al menos un consumidor lo mira (refCount)
- Retener los datos hasta que N consumidores coordinados estén conectados y arrancar exactamente una vez (refCount(n) / autoConnect(n))
- Mantener un flujo vivo aunque los suscriptores vayan y vengan — p. ej. un poller de métricas que no debe reiniciarse (autoConnect)
- Reemplazar la contabilidad manual de connect() por una política de conexión declarativa
refCount derriba la conexión al llegar a cero suscriptores, así que una pausa breve reejecuta la fuente desde cero — el Reactor real ofrece refCount(n, gracePeriod) para sobrevivir huecos cortos. autoConnect nunca desconecta: en una fuente infinita eso es una fuga deliberada, a menos que captures el Disposable de la conexión con la sobrecarga autoConnect(n, consumer) y lo deseches tú mismo.
val ticks = Flux.interval(Duration.ofMillis(100))
.publish()
.refCount(1)
val s1 = ticks.subscribe { println("S1: " + it) } // sees 0, 1
Thread.sleep(250)
s1.dispose() // subscriber count drops to 0
Thread.sleep(300)
ticks.subscribe { println("S2: " + it) } // what comes first?🧠 Reto: predice la salida
0/1 · 0/1 answered1. Una fuente interval se comparte con publish().refCount(1). El suscriptor S1 recibe 0 y 1, y luego se desecha. 300 ms después se suscribe S2. ¿Cuál es el primer valor que recibe S2?
Manejar connect() a mano cansa rápido. refCount y autoConnect convierten un ConnectableFlux de vuelta en un Flux normal con una política de conexión automática. refCount(n) es conteo de referencias, igual que en la gestión de memoria: conecta cuando el número de suscriptores llega a n y desconecta el upstream cuando vuelve a cero. autoConnect(n) conecta en el mismo umbral pero nunca suelta: la fuente sigue corriendo aunque todos los suscriptores se hayan ido.
Este es también el secreto detrás de share(): no es más que publish().refCount(1). Usa la forma explícita cuando necesites otro umbral — publish().refCount(2) para esperar a un par de consumidores, por ejemplo — o el ciclo de vida opuesto con autoConnect.
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// A temperature sensor that should only run while someone is watching
val sensor = Flux.interval(Duration.ofMillis(100))
.map { 20 + it }
.doOnSubscribe { println("sensor: ON") }
.doOnCancel { println("sensor: OFF") }
.publish()
.refCount(1)
val dashboard = sensor.subscribe { println("dashboard: $it C") }
Thread.sleep(250)
dashboard.dispose() // last subscriber leaves -> refCount cancels the sensor
Thread.sleep(150)
println("-- someone opens the dashboard again --")
sensor.subscribe { println("alerts: $it C") } // fresh cycle, restarts at 20
Thread.sleep(250)
}Modelamos un sensor de temperatura que solo debe estar encendido mientras alguien lo mira. El dashboard se suscribe, el conteo pasa de 0 → 1 y refCount conecta: 'sensor: ON'. Las lecturas fluyen hasta que el dashboard desecha su suscripción — el conteo vuelve a cero y refCount cancela el upstream: 'sensor: OFF'. Cuando más tarde aparece un suscriptor nuevo, se trata de un ciclo de conexión totalmente nuevo, así que el interval arranca de cero y el consumidor de alertas vuelve a ver 20, no una continuación:
sensor: ON dashboard: 20 C dashboard: 21 C sensor: OFF -- someone opens the dashboard again -- sensor: ON alerts: 20 C alerts: 21 C
autoConnect invierte la segunda mitad de la política. Es ideal cuando quieres retener el flujo hasta que todos estén sentados — y luego dejarlo correr durante toda la vida de la aplicación:
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// autoConnect(2): wait for 2 subscribers, then connect — and never let go
val feed = Flux.interval(Duration.ofMillis(100))
.map { "tick-$it" }
.doOnSubscribe { println("feed: started") }
.doOnCancel { println("feed: cancelled") }
.publish()
.autoConnect(2)
val a = feed.subscribe { println("A: $it") }
println("A subscribed - nothing flows yet")
Thread.sleep(150)
val b = feed.subscribe { println("B: $it") } // 2nd subscriber -> connect!
Thread.sleep(250)
a.dispose(); b.dispose()
Thread.sleep(200)
println("both left - no 'feed: cancelled', the feed is still running")
}A solo no recibe nada — autoConnect(2) sigue esperando. En el momento en que B se suscribe, se alcanza el umbral y el feed arranca; ambos reciben tick-0 y tick-1 juntos. Luego ambos se desechan… y el detalle crucial es lo que no se imprime: no hay ningún 'feed: cancelled'. autoConnect nunca desconecta, así que el interval sigue latiendo para una sala vacía:
A subscribed - nothing flows yet feed: started A: tick-0 B: tick-0 A: tick-1 B: tick-1 both left - no 'feed: cancelled', the feed is still running
Regla práctica: refCount para 'correr solo mientras alguien mira' — aceptando que una pausa con cero suscriptores reinicia la fuente — y autoConnect para 'arrancar una vez y seguir corriendo'. Dos salidas de emergencia que vale conocer: refCount(n, gracePeriod) mantiene viva la conexión durante huecos cortos entre suscriptores, y autoConnect(n, consumer) te entrega el Disposable de la conexión para que igual puedas apagar el flujo a mano.
replay / cache
replay y cache agregan el ingrediente que le faltaba al multicasting: memoria. cache() vuelve caliente la fuente y recuerda lo que emitió, de modo que un suscriptor tardío primero recibe el historial reproducido — tanto como hayas configurado — y luego continúa con el flujo en vivo. Es el arreglo clásico para 'cada componente que se suscribe vuelve a disparar mi llamada HTTP'.
Tú eliges cuánta memoria: cache() guarda todo, cache(n) guarda los últimos n valores y cache(ttl) los conserva durante una ventana de tiempo. replay(n) es la misma maquinaria expuesta como un ConnectableFlux — de hecho cache(n) es simplemente replay(n).autoConnect(): conectar con el primer suscriptor, no desconectar nunca y reproducir el historial a quien llegue tarde.
fun <T> Flux<T>.cache(history: Int): Flux<T> // replay(n) returns ConnectableFlux<T>cache(n) convierte un Flux frío en una fuente caliente y multicast que se suscribe una sola vez al upstream y reproduce los últimos n valores onNext (más la señal terminal) a cada suscriptor tardío. Los nuevos suscriptores reciben de inmediato el historial almacenado en su orden original y luego siguen con las emisiones en vivo; el onComplete/onError terminal también se cachea y se reemite. replay(n) es la misma maquinaria como ConnectableFlux que debes connect() (o autoConnect/refCount) para arrancar — cache(n) es simplemente replay(n).autoConnect(). cache() sin argumento guarda todo; cache(ttl) conserva una ventana acotada por tiempo.
- Cachear una llamada HTTP/BD costosa para que suscriptores repetidos reutilicen el resultado
- Reproducir las últimas N actualizaciones de estado a componentes que se suscriben tarde
- Compartir un upstream entre muchos consumidores sin re-disparar la fuente
- Dar a suscriptores tardíos de un stream de eventos el historial reciente
cache(n) solo guarda los últimos n valores, así que un suscriptor tardío se pierde todo lo anterior a la ventana ([A,B,C,D] con cache(2) reproduce solo C, D). La variante cache() sin límite retiene todos los valores, lo que puede causar fugas de memoria en fuentes infinitas o de larga vida; los errores también se cachean, así que todos los suscriptores tardíos reproducen el mismo fallo para siempre.
val cached = Flux.just("A", "B", "C", "D").cache(2)
cached.subscribe() // eager subscriber drains everything
// later, after completion:
cached.subscribe { v -> println(v) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux al suscriptor tardío que se suscribe después de que la fuente ya se completó?
import reactor.core.publisher.Flux
fun main() {
// An expensive lookup that several parts of the app need
val rates = Flux.just("USD -> 0.92", "GBP -> 1.17", "JPY -> 0.0062")
.doOnSubscribe { println("hitting the exchange-rate API...") }
.cache()
rates.subscribe { println("checkout: $it") } // triggers the one real call
rates.subscribe { println("invoices: $it") } // served entirely from the cache
}La consulta de tipos de cambio es nuestra llamada costosa — la sonda doOnSubscribe nos avisa cada vez que realmente se ejecuta. El checkout se suscribe primero: la API se llama una vez y los tres tipos fluyen. Cuando invoices se suscribe un momento después, la sonda queda en silencio — no hay segunda llamada a la API — y aun así recibe los tres tipos, directo desde la caché:
hitting the exchange-rate API... checkout: USD -> 0.92 checkout: GBP -> 1.17 checkout: JPY -> 0.0062 invoices: USD -> 0.92 invoices: GBP -> 1.17 invoices: JPY -> 0.0062
import reactor.core.publisher.Flux
fun main() {
// Keep only the last 2 alerts for anyone who tunes in late
val alerts = Flux.just("disk 81%", "cpu 95%", "mem 72%", "disk 93%")
.cache(2)
alerts.subscribe() // first subscriber drains the whole source
println("-- a new dashboard opens after the fact --")
alerts.subscribe(
{ println("dashboard: $it") },
{ e -> println("error: $e") },
{ println("dashboard: up to date") }
)
}Aquí cuatro alertas atraviesan un cache(2) y el primer suscriptor drena la fuente hasta completarla. Cuando el dashboard se abre después, la fuente ya terminó hace rato — pero el suscriptor no se queda con las manos vacías: recibe las dos últimas alertas, mem 72% y disk 93%, seguidas de la señal de completado cacheada. Las dos alertas más viejas quedaron fuera de la ventana de historial y se perdieron:
-- a new dashboard opens after the fact -- dashboard: mem 72% dashboard: disk 93% dashboard: up to date
cache() sin argumentos guarda todos los valores para siempre — en una fuente infinita o de larga vida eso es una fuga de memoria por diseño. Y la señal terminal también se cachea: si la fuente falló, cada suscriptor futuro reproduce el mismo onError. Para una caché que puedas invalidar, mira cacheInvalidateIf / cacheInvalidateWhen de Mono.
Operaciones bloqueantes
Todo en Reactor es asíncrono: declaras un pipeline, te suscribes y los valores llegan cuando están listos. Pero tarde o temprano el mundo reactivo tiene que entregar un resultado a código plano y síncrono: un método main() que quiere imprimir un total, un test de JUnit que necesita hacer un assert sobre un valor, un proceso batch que alimenta una API heredada. La familia bloqueante — block, blockFirst, blockLast, toIterable y toStream — es ese puente: cada uno se suscribe por ti y detiene el hilo llamante hasta que el dato prometido realmente está ahí.
La regla de oro tiene que ver con dónde vive el hilo que llama. Bloquear está perfectamente bien en un hilo que no tiene nada mejor que hacer: el hilo main de una herramienta CLI, un runner de tests, un job batch programado. Está estrictamente prohibido en los hilos que impulsan las aplicaciones reactivas — los workers del event-loop de Netty, Schedulers.parallel() — porque un solo hilo detenido ahí deja sin servicio a todos los demás flujos que atendía. Reactor incluso se defiende: llamar block* en un hilo marcado como no bloqueante lanza IllegalStateException en vez de caer en un deadlock.
import reactor.core.publisher.Flux
fun main() {
// Inside: a fully reactive pipeline. At the very edge: ONE blocking call,
// because main() is a plain synchronous method with nothing better to do.
val receipt = Flux.just("bread", "milk", "eggs")
.map { it.uppercase() }
.collectList() // Mono<List<String>>
.block() // bridge to the imperative world
println("purchased: $receipt")
}Tenemos un flujo de tres artículos del súper. map pasa cada uno a mayúsculas y collectList los junta en un Mono<List<String>>. Luego viene el puente: block() se suscribe a ese Mono y detiene el hilo main hasta que la lista está lista, devolviéndola como un valor ordinario que println puede usar. Fíjate en la forma del código: toda la transformación es reactiva, y el bloqueo ocurre exactamente una vez, en la frontera.
purchased: [BREAD, MILK, EGGS]
En una aplicación WebFlux casi nunca necesitas estos operadores: los controladores pueden devolver Mono/Flux directamente y el framework se suscribe por ti. Si te descubres bloqueando en medio de una cadena reactiva, la solución suele ser un operador de composición (flatMap, zip, then) — no un hilo detenido.
blockFirst / blockLast
fun <T> Flux<T>.blockLast(): T? // also blockFirst(): T?, plus timeout overloads e.g. blockLast(d: Duration): T?blockFirst/blockLast se suscriben al Flux y bloquean el hilo actual hasta que llega una señal terminal, devolviendo un único valor: blockFirst devuelve el primer onNext (cancelando el resto), mientras que blockLast consume todos los elementos y devuelve el último antes de onComplete. Para in=[1,2,3,4,C], blockLast recorre 1,2,3,4 y devuelve 4; blockFirst devolvería 1. Si la secuencia termina vacía devuelven null, y cualquier onError se relanza de forma síncrona a quien llamó.
- Conectar código reactivo con un contexto bloqueante como un método main(), un test o una API síncrona heredada
- Obtener el resultado final agregado de un pipeline (p. ej. el último acumulado)
- Scripts rápidos, demos o herramientas CLI donde solo necesitas el valor ya
- blockFirst para tomar solo la primera emisión y cancelar la fuente cuanto antes
Estos métodos bloquean el hilo que los llama, así que nunca los uses dentro de un operador ni en un hilo reactivo/event-loop (p. ej. Netty, Schedulers.parallel()) — Reactor lanzará IllegalStateException para evitar el deadlock, y aun fuera del event loop anulan el propósito de ser no bloqueante. Una secuencia vacía devuelve null en silencio (fácil de confundir con un resultado real), y la sobrecarga sin argumentos espera indefinidamente, así que conviene usar la sobrecarga con Duration para no quedar colgado.
val result = Flux.just(3, 5, 7)
.filter { it % 2 == 0 }
.blockLast()
println(result)🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué imprime este fragmento?
blockFirst y blockLast se suscriben a un Flux y detienen el hilo llamante hasta poder entregarte un único valor. blockFirst regresa apenas llega el primer elemento — y de inmediato cancela el resto de la secuencia, porque ya tiene lo que buscaba. blockLast es el hermano paciente: consume todos los elementos y solo regresa cuando onComplete confirma cuál fue realmente el último.
Ambos devuelven null si la secuencia termina vacía, y ambos relanzan un error del upstream como una excepción normal en el hilo llamante. Las sobrecargas sin argumentos esperan para siempre, así que en cualquier código que importe usa las sobrecargas con Duration: si nada llega a tiempo lanzan una excepción en vez de dejar tu hilo colgado indefinidamente. (En un Mono, blockOptional() esquiva por completo la ambigüedad del null envolviendo el resultado en un Optional.)
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
val readings = Flux.just(18.2, 18.4, 19.1) // temperature samples
// blockFirst: return the first reading and cancel the rest
val first = readings.blockFirst()
println("First reading: $first")
// blockLast: wait for onComplete, return the final reading
val last = readings.blockLast()
println("Last reading: $last")
// In real code, always prefer the timeout overload
val tick = Flux.interval(Duration.ofMillis(100))
.blockFirst(Duration.ofSeconds(1))
println("First tick: $tick")
}Tenemos un flujo de tres lecturas de temperatura. blockFirst se suscribe, toma 18.2 en cuanto se emite y cancela las muestras restantes. blockLast se suscribe de nuevo (cada llamada es su propia suscripción a este Flux frío), deja pasar las tres lecturas y devuelve 19.1 cuando la fuente completa. La tercera llamada muestra el patrón seguro: Flux.interval es infinito, pero blockFirst(Duration.ofSeconds(1)) solo necesita el primer tick — el valor 0 — y lanzaría una excepción después de un segundo en vez de esperar para siempre.
First reading: 18.2 Last reading: 19.1 First tick: 0
Un uso típico en el mundo real es tomar los eventos frontera de un pipeline — y estar preparado para el caso vacío, que devuelve null silenciosamente.
import reactor.core.publisher.Flux
fun main() {
// Grab the boundary events of a deployment pipeline
val stages = Flux.just("start", "build", "test", "deploy")
println("first stage = ${stages.blockFirst()}")
println("last stage = ${stages.blockLast()}")
// An empty sequence quietly returns null - always be ready for it
val none = Flux.empty<String>().blockLast()
println("empty gives: $none")
}El pipeline de despliegue emite cuatro etapas. blockFirst devuelve "start" y cancela; blockLast espera las cuatro y devuelve "deploy". La última línea es la trampa que vale la pena memorizar: blockLast sobre un Flux vacío no lanza excepción — te entrega un null fácil de confundir con un resultado real.
first stage = start last stage = deploy empty gives: null
Nunca llames blockFirst/blockLast dentro de un operador ni en un hilo de event-loop (workers de Netty, Schedulers.parallel()) — Reactor lanza IllegalStateException para prevenir el deadlock. Recuerda además que blockLast debe consumir toda la secuencia: sobre un Flux infinito simplemente nunca regresa, así que combínalo con take(...) o con un timeout de Duration.
toIterable / toStream
fun <T> Flux<T>.toIterable(): Iterable<T> // also toStream(): Stream<T>, with optional batchSize / prefetchtoIterable() / toStream() convierten un Flux frío en un puente bloqueante: los elementos emitidos A,B,C,D se almacenan en una cola interna y se entregan de forma perezosa, en el orden original, uno por cada iterator.next() (o por cada elemento del Stream). Cada llamada a next() BLOQUEA el hilo actual hasta que llegue el siguiente elemento, el onComplete (|) o un onError; la señal de completado termina la iteración (hasNext() devuelve false / el Stream finaliza). Un error se relanza como excepción en tiempo de ejecución mientras iteras.
- Conectar la salida reactiva con código heredado/bloqueante que espera un Iterable o Stream
- Alimentar un Flux a un bucle for-each de Java o a un pipeline de Stream (filter/map/collect)
- Pruebas o scripts rápidos donde quieres resultados de forma síncrona sin subscribe()
- Consumir un Flux acotado desde una capa no reactiva (herramienta CLI, proceso batch)
BLOQUEA el hilo actual, así que nunca lo llames dentro de un operador reactivo ni en un hilo de event-loop / Netty: puede causar deadlock o disparar la detección de bloqueo de Reactor. Además, si dejas de iterar antes de tiempo debes cerrar el Stream (o agotar el Iterable por completo) para cancelar la suscripción upstream; de lo contrario la fuente queda con una fuga.
val letters = Flux.just("A", "B", "C", "D")
letters.toStream().forEach { print(it) }
// what is printed?🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado este Flux convertido en un Stream bloqueante, ¿qué se imprime?
Donde blockFirst y blockLast eligen un valor de un Flux, toIterable y toStream te los entregan todos — como un Iterable o un java.util.stream.Stream perezoso y bloqueante. Nada se precalcula: cada llamada a next() del iterador detiene el hilo llamante hasta que llega el siguiente elemento, lo entrega en el orden original, y cuando aterriza onComplete, hasNext() devuelve false y el bucle termina limpiamente. Un error del upstream se relanza como excepción justo donde estabas iterando.
Este es el puente ideal cuando el consumidor es imperativo pero quiere todos los elementos: un for-loop común, una API heredada que acepta un Iterable, o un pipeline de java.util.stream con filtros y colectores.
import reactor.core.publisher.Flux
fun main() {
// toIterable: a Flux you can walk with a plain for-loop
val pages = Flux.just("home", "cart", "checkout").toIterable()
for (page in pages) {
println("visited: $page")
}
// toStream: plug a Flux straight into the java.util.stream API
val sum = Flux.range(1, 10)
.toStream()
.mapToInt { it }
.sum()
println("Stream sum: $sum")
}Tenemos un flujo de páginas visitadas. toIterable lo envuelve para que un for-loop ordinario pueda recorrerlo: cada iteración bloquea brevemente, recibe la siguiente página y la imprime. El segundo pipeline conecta un Flux de números del 1 al 10 con la API de Stream, donde mapToInt y sum hacen su trabajo síncrono habitual — 55, calculado por java.util.stream a partir de datos reactivos.
visited: home visited: cart visited: checkout Stream sum: 55
El escenario clásico: una función heredada que solo entiende Iterable, alimentada por una fuente reactiva de la que nunca tiene que enterarse.
import reactor.core.publisher.Flux
// A legacy report function that only understands Iterable
fun printReport(rows: Iterable<String>) {
rows.forEach { println("row: $it") }
}
fun main() {
val rows = Flux.just("Q1;102", "Q2;97", "Q3;120")
printReport(rows.toIterable()) // the legacy code never learns it was reactive
}printReport es código bloqueante ordinario: recibe un Iterable e imprime cada fila. Le pasamos rows.toIterable() y consume las tres filas trimestrales una por una, bloqueando imperceptiblemente entre extracciones. La tubería reactiva permanece invisible del lado heredado del puente.
row: Q1;102 row: Q2;97 row: Q3;120
Dos precauciones: estos puentes bloquean el hilo llamante en cada extracción, así que siguen la misma regla de nunca-en-un-event-loop que block*. Y si dejas de iterar antes de tiempo, debes cerrar el Stream (.use { } en Kotlin / try-with-resources en Java) o terminar de consumir el Iterable — de lo contrario la suscripción upstream nunca se cancela y la fuente se fuga.
Operadores utilitarios
Los operadores utilitarios son la navaja suiza del API de Flux: un respaldo para flujos que terminan vacíos, ordenamiento en memoria, bucles de re-suscripción, composición de pipelines reutilizables y manejo de recursos sin fugas. A diferencia de map o filter, rara vez tocan valores individuales — actúan sobre el flujo completo, y justo por eso resuelven los problemas que las demás categorías dejan abiertos.
import reactor.core.publisher.Flux
fun main() {
// Convert a Flux into a Java Stream to reuse stream terminal ops
val total = Flux.just(19.99, 5.49, 12.00)
.toStream()
.mapToDouble { it }
.sum()
println("cart total = $total")
}Como calentamiento, un pequeño puente al mundo no reactivo: tenemos un carrito con tres precios y toStream() convierte el Flux en un java.util.stream.Stream común y bloqueante, lo que nos permite reutilizar operaciones terminales de Stream como sum(). Bloquea el hilo que lo llama, así que trátalo como una salida de emergencia para tests y scripts, no como algo para meter dentro de un pipeline reactivo:
cart total = 37.48
defaultIfEmpty
Tarde o temprano alguna consulta vuelve sin nada: la búsqueda no encontró productos, el filter descartó todos los elementos, la caché estaba fría. defaultIfEmpty es la póliza de seguro más simple para ese caso — si la fuente completa sin haber emitido un solo elemento, emite un único valor de respaldo predefinido y luego completa. Si la fuente sí emite, el operador es completamente transparente y cada valor pasa intacto.
Fíjate en que recibe un valor ya construido, no un lambda: el respaldo queda fijado al ensamblar y no cuesta nada producirlo. Cuando el respaldo debe calcularse de forma perezosa — o necesita ser todo un publisher alternativo, como una segunda fuente de datos — su hermano mayor switchIfEmpty es la herramienta correcta.
fun <T> Flux<T>.defaultIfEmpty(defaultV: T): Flux<T>defaultIfEmpty reenvía de forma transparente cada onNext y cualquier onError del origen sin modificarlos. Solo si el origen completa (onComplete) sin haber emitido ningún elemento, emite en su lugar el valor por defecto suministrado y luego completa. El respaldo se emite en el mismo hilo que transportó la señal terminal del origen, y nunca se dispara si pasó aunque sea un elemento real.
- Garantizar que el consumidor siempre reciba al menos un valor (p. ej. un conteo o total de 0)
- Dar un respaldo neutro ante una consulta a BD vacía o un resultado filtrado sin elementos
- Evitar ramas de flujo vacío al mapear a una respuesta de UI o API
- Aportar un valor centinela antes de un reduce/collect que nunca debe quedar sin emitir
Solo reacciona ante una completación VACÍA, no ante errores: si el origen falla con onError, el valor por defecto nunca se emite y el error se propaga. Además se dispara con exactamente cero elementos, no con valores null, así que un origen que emite elementos reales (aunque sea uno) lo omite por completo. Para un respaldo calculado/perezoso o para sustituir por otro publisher, usa switchIfEmpty en su lugar.
Flux.just(1, 2, 3)
.filter { it > 10 }
.defaultIfEmpty(0)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux al suscriptor?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
// Common REST API pattern: return default response when query yields nothing
data class Product(val id: String, val name: String, val price: Double)
fun searchProducts(query: String): Flux<Product> =
if (query.length < 3) Flux.empty()
else Flux.just(
Product("P1", "Kotlin in Action", 39.99),
Product("P2", "Reactive Spring", 44.99)
).filter { it.name.lowercase().contains(query.lowercase()) }
fun main() {
// Search with results
println("Search 'kotlin':")
searchProducts("kotlin")
.defaultIfEmpty(Product("N/A", "No products found", 0.0))
.subscribe { println(" ${it.name} - $${it.price}") }
// Search with no results → default kicks in
println("\nSearch 'python':")
searchProducts("python")
.defaultIfEmpty(Product("N/A", "No products found", 0.0))
.subscribe { println(" ${it.name} - $${it.price}") }
// Too-short query → empty → default
println("\nSearch 'ab':")
searchProducts("ab")
.defaultIfEmpty(Product("N/A", "Query too short", 0.0))
.subscribe { println(" ${it.name}") }
}Simulamos un pequeño endpoint de búsqueda de productos. searchProducts devuelve un Flux vacío para consultas de menos de tres caracteres y, si no, filtra un catálogo de dos libros. La primera búsqueda encuentra 'Kotlin in Action', así que el respaldo nunca se dispara y el producto real fluye. La segunda no encuentra nada: la fuente completa vacía y defaultIfEmpty inyecta el marcador 'No products found'. La tercera es demasiado corta para siquiera consultar, así que sale el centinela 'Query too short'. Al final nos suscribimos e imprimimos cada resultado:
Search 'kotlin': Kotlin in Action - $39.99 Search 'python': No products found - $0.0 Search 'ab': Query too short
El mismo patrón en su forma mínima — un filtro de VIP que no encuentra a nadie, así que el string de respaldo es lo único que el suscriptor llega a ver:
import reactor.core.publisher.Flux
fun main() {
val premiumCustomers = listOf("Ada", "Linus", "Grace")
Flux.fromIterable(premiumCustomers)
.filter { it.startsWith("Z") } // no VIP whose name starts with Z
.defaultIfEmpty("-- no VIP found --")
.subscribe { println(it) }
}-- no VIP found --
defaultIfEmpty reacciona solo ante una completación VACÍA, nunca ante errores: si la fuente falla, el error se propaga y el valor por defecto no se emite (ese es el trabajo de onErrorReturn). Y como el valor por defecto se construye de forma anticipada al ensamblar, usa switchIfEmpty para un respaldo calculado perezosamente o para todo un Flux alternativo.
sort
sort acumula cada elemento que emite la fuente en un buffer interno y, solo cuando la fuente completa, los libera de nuevo en orden — orden natural ascendente por defecto, o el que dicte un Comparator personalizado. Hasta que llega esa señal de completado, nada fluye aguas abajo.
Ese buffering es el detalle crucial: sort pertenece a flujos cortos y finitos — el último tramo antes de renderizar un ranking o un reporte — nunca en medio de un feed infinito de eventos, donde acumularía para siempre sin emitir nada.
fun <T> Flux<T>.sort(): Flux<T> // also sort(comparator: Comparator<in T>)sort se suscribe al flujo superior y va almacenando cada elemento emitido en una lista interna, sin emitir nada mientras la fuente siga activa. Solo cuando la fuente envía onComplete, ordena los elementos almacenados (orden natural o según un Comparator dado) y los reemite en orden ascendente, para luego propagar onComplete. Los elementos iguales conservan su orden relativo de entrada (la ordenación es estable), y un onError del flujo superior se propaga de inmediato sin emitir ninguno de los valores parcialmente acumulados.
- Presentar un conjunto de resultados finito y acotado en orden de ranking
- Ordenar un lote pequeño por una clave mediante un Comparator personalizado
- Producir salida determinista antes de un render o un reporte
- Reordenar resultados combinados de varias fuentes asíncronas una vez completados
sort bloquea hasta la finalización: debe almacenar TODO el flujo en memoria antes de emitir un solo elemento, por lo que nunca emite con una fuente infinita o caliente sin límite y puede agotar la memoria con flujos grandes. Además introduce latencia, ya que nada fluye hasta onComplete.
Flux.just(3, 1, 4, 1, 5, 2)
.sort()
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este Flux, y en qué orden?
import reactor.core.publisher.Flux
fun main() {
// Natural order
Flux.just(5, 3, 8, 1, 9, 2)
.sort()
.subscribe { print("$it ") }
println() // 1 2 3 5 8 9
// Custom comparator
Flux.just("banana", "apple", "cherry", "date")
.sort(Comparator.comparingInt { it.length })
.subscribe { println(it) }
}Dos corridas rápidas. La primera ordena números por orden natural: 5, 3, 8, 1, 9, 2 entran desordenados y, solo después de que la fuente completa, salen como 1 2 3 5 8 9 en una sola línea. La segunda le pasa a sort un Comparator que compara longitudes de string: date (4 letras) gana, apple (5) sigue, y banana y cherry (6 cada una) conservan su orden relativo original porque el ordenamiento es estable:
1 2 3 5 8 9 date apple banana cherry
Con objetos de dominio casi siempre vas a aportar un comparator. Aquí clasificamos pedidos por total, el más grande primero, con compareByDescending de Kotlin:
import reactor.core.publisher.Flux
data class Order(val id: String, val total: Double)
fun main() {
Flux.just(Order("A", 42.0), Order("B", 12.5), Order("C", 99.9))
.sort(compareByDescending { it.total }) // biggest orders first
.subscribe { println("order ${it.id} -> $${it.total}") }
}Tres pedidos llegan en orden arbitrario; sort los acumula todos, aplica el comparator descendente por total al completar, y libera primero C ($99.9), luego A y luego B:
order C -> $99.9 order A -> $42.0 order B -> $12.5
Nunca pongas sort sobre una fuente infinita o caliente sin límite: debe acumular la secuencia COMPLETA en memoria antes de emitir algo, así que crecería sin límite y jamás emitiría. Si lo que realmente quieres es una List ordenada al final, collectSortedList te da un Mono<List<T>> en un solo paso.
repeat / repeatWhen
repeat se vuelve a suscribir a la fuente después de que completa, reproduciendo toda la secuencia desde cero. repeat(n) permite n pasadas extra (n + 1 ejecuciones en total), repeat { predicado } continúa mientras se cumpla una condición, y repeatWhen delega la decisión de '¿cuándo volvemos a ir?' a un publisher compañero — el truco estándar para hacer polling con una pausa entre rondas.
Piénsalo como el gemelo optimista de retry: retry se re-suscribe cuando la fuente FALLA, repeat se re-suscribe cuando TERMINA BIEN. Ambos solo tienen sentido con fuentes frías, que reinician su trabajo en cada nueva suscripción.
fun <T> Flux<T>.repeat(times: Long = Long.MAX_VALUE): Flux<T>En cada onComplete de la fuente, repeat se vuelve a suscribir a la fuente (fría) desde cero, reproduciendo toda su secuencia; repeat(n) agrega n suscripciones EXTRA, de modo que la fuente se ejecuta n+1 veces en total. Los onComplete intermedios se descartan y no se propagan aguas abajo — solo la finalización final (tras la última pasada) termina el flujo. Un onError en cualquier momento se propaga de inmediato y detiene la repetición.
- Re-consultar una fuente finita (HTTP/consulta a BD) varias veces para construir un flujo de snapshots
- Re-ejecutar una secuencia que completa normalmente (no por error)
- Generar datos de prueba/carga reproduciendo un publisher frío N veces
- Usar repeatWhen() para agregar backoff/retraso entre re-suscripciones
repeat solo reacciona ante onComplete, NO ante onError — para re-ejecutar tras fallos usa retry() en su lugar. En una fuente caliente no hay nada que reproducir (la re-suscripción solo continúa en vivo), y repeat() sin argumento itera para siempre (Long.MAX_VALUE), lo que puede generar un flujo infinito si la fuente siempre completa.
Flux.just(1, 2)
.repeat(1)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué secuencia emite este Flux?
import reactor.core.publisher.Flux
import java.time.Duration
fun main() {
// repeat: re-subscribe N times
Flux.just("ping")
.repeat(2) // original + 2 repeats = 3 total
.subscribe { println(it) }
// repeat with predicate
var count = 0
Flux.just("tick")
.doOnNext { count++ }
.repeat { count < 5 }
.subscribe { println("$it #$count") }
// repeatWhen: delayed repeat
Flux.just("poll")
.repeatWhen { it.delayElements(Duration.ofSeconds(1)) }
.take(3)
.subscribe { println(it) }
Thread.sleep(4000)
}Tres mini-demos en un solo programa. Primero, repeat(2): 'ping' se emite una vez más dos repeticiones — tres en total. Segundo, repeat con predicado: doOnNext incrementa un contador y la fuente se re-suscribe mientras count < 5, imprimiendo de tick #1 a tick #5. Tercero, repeatWhen con delayElements vuelve a consultar un segundo después de cada completado; take(3) corta el bucle tras tres emisiones de 'poll', y el Thread.sleep final mantiene viva la JVM mientras esas repeticiones retrasadas se disparan:
ping ping ping tick #1 tick #2 tick #3 tick #4 tick #5 poll poll poll
repeat reproduce la SUSCRIPCIÓN, no una grabación — así que combínalo con Flux.defer para re-ejecutar efectos y producir valores nuevos en cada pasada:
import reactor.core.publisher.Flux
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val poll = AtomicInteger()
Flux.defer { Flux.just("heartbeat #${poll.incrementAndGet()}") }
.repeat(2) // emit once, then re-subscribe 2 more times
.subscribe { println(it) }
}Sin defer, Flux.just capturaría un único valor al ensamblar y repeat imprimiría el mismo latido tres veces. defer re-ejecuta la fábrica en cada suscripción, así que el AtomicInteger avanza y cada pasada produce un número de latido nuevo:
heartbeat #1 heartbeat #2 heartbeat #3
repeat reacciona solo ante onComplete: un error detiene el bucle de inmediato y se propaga — usa retry/retryWhen para re-ejecutar tras fallos. En una fuente caliente no hay nada que reproducir (re-suscribirse solo sigue escuchando el feed en vivo), y repeat() sin argumento itera en la práctica para siempre (Long.MAX_VALUE).
then / thenMany / thenEmpty
A veces solo te importa QUE un trabajo terminó, no qué produjo: escribir todas las filas y luego responder 'ok'. La familia then descarta cada elemento de la fuente y reacciona solo a su señal terminal. then() convierte la completación en un Mono<Void>; then(mono) reproduce un Mono de seguimiento cuando la fuente completa; thenMany(publisher) reproduce todo un Flux de seguimiento; y thenEmpty(publisher) espera un Publisher<Void> — 'ejecuta este efecto y luego completa'.
Los errores siempre se saltan la fila: si la fuente falla, el publisher de seguimiento nunca se suscribe y el error se propaga aguas abajo de inmediato.
fun <V> Flux<*>.then(other: Mono<V>): Mono<V>then(Mono) ignora y descarta cada valor onNext del Flux fuente, esperando únicamente a que termine. Cuando la fuente se completa con éxito, se suscribe al Mono suministrado y lo reproduce, emitiendo su valor único (aquí 'done') seguido de la señal de completado. Si la fuente emite un error, el Mono suministrado nunca se suscribe y el error se propaga de inmediato, por lo que el valor final solo aparece tras una completación limpia del flujo anterior.
- Ejecutar un flujo con efectos (guardados, borrados) y devolver un resultado final
- Encadenar una secuencia: hacer todo el trabajo y luego emitir un valor de confirmación
- Secuenciar pasos reactivos donde las emisiones intermedias son irrelevantes
- Devolver un Mono de estado/resumen tras completar un lote de operaciones
El Mono suministrado se evalúa de forma anticipada al llamar a then(), no de forma diferida al completar: si escribes then(Mono.just(compute())) el compute() se ejecuta de inmediato en tiempo de ensamblado, aunque la fuente nunca se complete. Usa then(Mono.defer { ... }) para diferir el trabajo hasta que la fuente realmente termine.
val result: Mono<String> = Flux.just(1, 2, 3)
.then(Mono.just("done"))
result.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué emite este pipeline de Flux cuando se suscribe?
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// then: wait for completion, return Mono<Void>
Flux.just(1, 2, 3)
.doOnNext { println("Processing: $it") }
.then()
.doOnSuccess { println("All done!") }
.subscribe()
// then(Mono): play a Mono after completion
Flux.just("save1", "save2", "save3")
.doOnNext { println("Saving: $it") }
.then(Mono.just("All saved successfully"))
.subscribe { println(it) }
// thenMany: play another Publisher after completion
Flux.just("init1", "init2")
.doOnNext { println("Init: $it") }
.thenMany(Flux.just("ready1", "ready2"))
.subscribe { println("After init: $it") }
}Tres variantes seguidas. En el primer pipeline el efecto doOnNext imprime 1, 2, 3 mientras then() se traga los valores; cuando el Flux completa, el Mono<Void> resultante tiene éxito y doOnSuccess imprime 'All done!'. El segundo ejecuta tres guardados y luego reproduce un Mono de confirmación. El tercero corre dos pasos de inicialización y luego emite todo un Flux de seguimiento con thenMany — su suscriptor solo llega a ver ready1 y ready2:
Processing: 1 Processing: 2 Processing: 3 All done! Saving: save1 Saving: save2 Saving: save3 All saved successfully Init: init1 Init: init2 After init: ready1 After init: ready2
La forma clásica del mundo real: persistir un lote de filas y, solo cuando cada escritura completó, emitir las señales de seguimiento:
import reactor.core.publisher.Flux
fun main() {
Flux.just("row-1", "row-2", "row-3")
.doOnNext { println("writing $it") }
.thenMany(Flux.just("COMMIT", "DONE")) // runs only after all writes complete
.subscribe { println(it) }
}El suscriptor nunca recibe row-1, row-2 ni row-3 — esas líneas salen del efecto doOnNext. Solo después de que completa la tercera escritura, thenMany se suscribe al Flux de seguimiento y entrega COMMIT y DONE:
writing row-1 writing row-2 writing row-3 COMMIT DONE
then(Mono.just(compute())) ejecuta compute() DE INMEDIATO en tiempo de ensamblado, no después de que la fuente complete — el Mono se construye de forma anticipada aunque se suscriba tarde. Envuelve el trabajo en Mono.defer { } o Mono.fromCallable { } para posponerlo hasta que la fuente realmente termine. Relacionado: ignoreElements() es el hermano de then() que conserva el tipo del elemento en lugar de cambiar a Void.
transform / transformDeferred
fun <T, V> Flux<T>.transform(transformer: (Flux<T>) -> Publisher<V>): Flux<V>transform() ejecuta la función UNA sola vez en tiempo de ensamblado, pasando todo el Flux upstream a una cadena de operadores empaquetada e insertando el resultado — así [1,2,3,4] se convierten en [20,40] a través de ese pipeline reutilizable antes de que pase el onComplete original. Como corre en el ensamblado, la misma instancia de Flux y su estado capturado se comparten entre todos los suscriptores; el orden de los elementos y las señales terminales (onComplete/onError) se preservan tal como las emite la cadena interna. Usa transformDeferred() (antes compose) para ejecutar la función UNA vez POR suscriptor, permitiendo que cada suscripción arme su propio pipeline y capture estado específico del suscriptor.
- Extraer una cadena repetida de operadores (filter+map+log) en una función nombrada y reutilizable
- Construir helpers transversales como un decorador de métricas/logging o de retry aplicado a varios flujos
- Mantener pipelines largos legibles componiéndolos desde segmentos nombrados más pequeños
- Usar transformDeferred() cuando el comportamiento debe variar por suscriptor (p. ej. contexto por petición)
transform() invoca la función solo UNA vez en el ensamblado, así que cualquier lógica con estado o por suscripción (contadores, contexto de petición, estado temporal) se comparte entre todos los suscriptores y queda 'congelada' al construir — si necesitas estado fresco o comportamiento específico por suscriptor en cada subscribe, debes usar transformDeferred() en su lugar.
val pipeline = { f: Flux<Int> -> f.filter { it % 2 == 0 }.map { it * 10 } }
Flux.just(1, 2, 3, 4)
.transform(pipeline)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dado el pipeline empaquetado, ¿qué emite este Flux?
transform es el refactor de 'extraer método' para pipelines reactivos: recibe una función que toma el Flux COMPLETO — no cada valor — y devuelve un Publisher remodelado, y luego empalma esa función en la cadena. En vez de copiar y pegar el mismo trío filter-map-log en diez pipelines, lo nombras una vez y usas .transform(it) en todas partes.
Su hermano transformDeferred (antes compose) hace el mismo cableado pero ejecuta la función una vez por SUSCRIPTOR en lugar de una vez al ensamblar. Esa diferencia solo importa cuando la función captura estado — el tercer ejemplo de abajo la hace visible.
import reactor.core.publisher.Flux
import org.reactivestreams.Publisher
// Reusable operator composition
fun <T> addLogging(): (Flux<T>) -> Publisher<T> = { flux ->
flux.doOnNext { println(" [LOG] $it") }
.doOnError { println(" [ERROR] ${it.message}") }
.doOnComplete { println(" [COMPLETE]") }
}
fun main() {
// transform: applies once at assembly
Flux.just(1, 2, 3)
.transform(addLogging())
.subscribe()
// transformDeferred: applies per subscriber
Flux.just("A", "B", "C")
.transformDeferred(addLogging())
.subscribe()
}addLogging() empaqueta tres doOn* de observación en una unidad nombrada y reutilizable. La aplicamos a un Flux de números con transform y a un Flux de letras con transformDeferred. Con una función sin estado como esta, ambos se comportan igual — cada valor se registra y luego la completación:
[LOG] 1 [LOG] 2 [LOG] 3 [COMPLETE] [LOG] A [LOG] B [LOG] C [COMPLETE]
Un pipeline con sabor a dominio: aplicar 10% de descuento y quedarse solo con lo que aún cuesta menos de $50:
import reactor.core.publisher.Flux
fun main() {
val discounted = { prices: Flux<Double> ->
prices.map { it * 0.9 }.filter { it < 50.0 } // reusable 10%-off pipeline
}
Flux.just(30.0, 60.0, 45.0)
.transform(discounted)
.subscribe { println("final: $it") }
}La función discounted multiplica cada precio por 0.9 y descarta lo que quede en $50 o más. 30.0 se convierte en 27.0 y pasa; 60.0 se convierte en 54.0 y se filtra; 45.0 se convierte en 40.5 y pasa:
final: 27.0 final: 40.5
Entonces, ¿cuándo importa de verdad transform frente a transformDeferred? Cuando la función captura estado. Aquí cada función marca los valores con un número de corrida tomado de un contador que se incrementa cada vez que la función se ejecuta:
import reactor.core.publisher.Flux
import java.util.concurrent.atomic.AtomicInteger
fun main() {
// transform: the function runs ONCE, when the pipeline is assembled
val assembleCount = AtomicInteger()
val once = Flux.just("a", "b").transform { f ->
val run = assembleCount.incrementAndGet()
f.map { "run $run: $it" }
}
once.subscribe { println("sub1 -> $it") }
once.subscribe { println("sub2 -> $it") }
// transformDeferred: the function runs once PER subscriber
val subscribeCount = AtomicInteger()
val perSubscriber = Flux.just("a", "b").transformDeferred { f ->
val run = subscribeCount.incrementAndGet()
f.map { "run $run: $it" }
}
perSubscriber.subscribe { println("sub1 -> $it") }
perSubscriber.subscribe { println("sub2 -> $it") }
}Con transform el contador se incrementa exactamente una vez — al ensamblar — así que ambos suscriptores ven 'run 1'. Con transformDeferred la función se ejecuta en cada subscribe: el primer suscriptor obtiene run 1 y el segundo run 2. Esta ejecución por suscriptor es la base de comportamientos como contexto por petición o métricas frescas en cada suscripción:
sub1 -> run 1: a sub1 -> run 1: b sub2 -> run 1: a sub2 -> run 1: b sub1 -> run 1: a sub1 -> run 1: b sub2 -> run 2: a sub2 -> run 2: b
Relacionado: as(). Mientras que la función de transform debe devolver un Publisher, flux.as(f) entrega el Flux completo a una función que puede devolver CUALQUIER tipo — útil para conversiones de un solo paso, como tender un puente a otra librería (flux.as(RxJava3Adapter::fluxToFlowable)) o envolver un pipeline en un helper de pruebas como StepVerifier. En Kotlin, as es palabra reservada, así que se invoca con backticks: `as`.
using / usingWhen
Los flujos suelen apoyarse en un recurso externo — una conexión a base de datos, un handle de archivo, un lock — que debe liberarse sin importar cómo termine el flujo. using es el try-with-resources reactivo: adquiere el recurso de forma perezosa en cada suscripción, construye el Flux real a partir de él y ejecuta una limpieza al completar, fallar o cancelar.
usingWhen es el hermano mayor asíncrono: el recurso mismo proviene de un Publisher y la limpieza también devuelve un Publisher, con manejadores separados para completación, error y cancelación — la forma commit/rollback sobre la que se construyen las transacciones reactivas (como R2DBC).
Flux.using(Callable<R> resourceSupplier, Function<R, Publisher<T>> sourceFactory, Consumer<R> resourceCleanup): Flux<T>En cada suscripción, using() llama de forma perezosa a resourceSupplier para adquirir un recurso, luego construye el Publisher fuente a partir de él con sourceFactory y reenvía sus valores. Cuando la fuente termina (complete O error) o se cancela la suscripción, ejecuta resourceCleanup sobre ese recurso exactamente una vez. Por defecto la limpieza es eager, es decir corre ANTES de propagar la señal terminal aguas abajo (igual que el marble: open, valores, luego close y después complete); con eager=false se libera después de la señal terminal.
- Envolver una conexión o statement JDBC/JOOQ para que se cierre al terminar el stream
- Emitir líneas de un archivo o InputStream abierto y garantizar el cierre del handle
- Mantener un lock, permiso de semáforo o ámbito transaccional durante todo el pipeline
- Recursos por suscriptor en un Flux frío que deben re-adquirirse en cada suscripción
El recurso se adquiere por suscripción, no una sola vez en el ensamblado, y la limpieza es SÍNCRONA y bloqueante en el hilo que termina, así que no uses using() para recursos cuyo close() haga I/O o devuelva un Publisher; usa usingWhen() para liberación asíncrona. Además, con eager=true por defecto el recurso ya está cerrado antes de que aguas abajo vea onComplete/onError, lo que puede sorprender a código que aún lo toca en la señal terminal.
val flux = Flux.using(
{ "conn" }, // acquire on subscribe
{ res -> Flux.just(res + "-r1", res + "-r2", res + "-r3") },
{ res -> println("close " + res) } // release on terminal
)
flux.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un recurso se adquiere al suscribirse y se construye un Flux a partir de él. ¿Qué emite este stream y cuándo se libera el recurso?
import reactor.core.publisher.Flux
class DatabaseConnection(val name: String) : AutoCloseable {
init { println("Opening connection: $name") }
fun query(sql: String): List<String> = listOf("row1", "row2", "row3")
override fun close() { println("Closing connection: $name") }
}
fun main() {
// using: manages resource lifecycle
Flux.using(
{ DatabaseConnection("mydb") }, // resource supplier
{ conn -> Flux.fromIterable(conn.query("SELECT *")) }, // source built from it
{ conn -> conn.close() } // cleanup on terminal
).subscribe { println("Row: $it") }
// AutoCloseable version (cleanup is automatic)
Flux.using(
{ DatabaseConnection("auto-db") },
{ conn -> Flux.fromIterable(conn.query("SELECT 1")) }
).subscribe { println("Auto: $it") }
}DatabaseConnection anuncia su apertura y cierre para que podamos observar el ciclo de vida. El primer using recibe los tres argumentos clásicos — adquirir, construir un Flux desde el recurso, liberar. El segundo aprovecha AutoCloseable: omite el tercer argumento y close() se invoca por ti. Ambas corridas abren una conexión, emiten tres filas y cierran — exactamente en ese orden:
Opening connection: mydb Row: row1 Row: row2 Row: row3 Closing connection: mydb Opening connection: auto-db Auto: row1 Auto: row2 Auto: row3 Closing connection: auto-db
La razón de ser de using es el camino infeliz. Aquí el Flux derivado falla después de dos valores:
import reactor.core.publisher.Flux
fun main() {
Flux.using(
{ "session-42" }, // acquire
{ s -> Flux.just(1, 2).concatWith(Flux.error(IllegalStateException("boom in $s"))) },
{ s -> println("released $s") } // release, even on error
).subscribe(
{ println("got $it") },
{ e -> println("error: ${e.message}") }
)
}El flujo emite 1 y 2 y luego falla. Observa el orden en la salida: el recurso se libera ANTES de que el error llegue al suscriptor, porque la limpieza de using es eager por defecto (una sobrecarga acepta eager = false para liberar después de la señal terminal):
got 1 got 2 released session-42 error: boom in session-42
La limpieza es síncrona y corre en el hilo que termina — si liberar el recurso hace I/O o devuelve un Publisher, no bloquees dentro de using: usa usingWhen, que se suscribe a un Publisher de limpieza asíncrono y puede distinguir complete, error y cancel con manejadores dedicados.
Puentes Mono ↔ Flux
Mono promete a lo sumo un valor; Flux promete un stream de ellos. Los pipelines reales cruzan esa frontera todo el tiempo: buscas un usuario y luego quieres sus muchas órdenes, corres un paso de setup y luego sirves un stream, tienes un solo endpoint de estado pero necesitas consultarlo para siempre. Esta categoría es el mapa de esa frontera — los pocos operadores que te llevan de Mono a Flux, y la familia que te trae de vuelta.
- Mono → Flux: flatMapMany — usa el único valor para abrir un stream (busca el usuario, transmite sus órdenes)
- Mono → Flux: thenMany — espera la finalización, descarta el valor y luego transmite (corre el setup, luego sirve); Mono.flux() es el adaptador de tipo sin lógica
- Mono → Flux: repeat — corre el mismo Mono una y otra vez (el corazón de todo loop de polling)
- Flux → Mono: next / last / single / collectList / reduce / count / all / any / then / ignoreElements — colapsa un stream en una sola respuesta (mapeados en la última sección de abajo)
La regla práctica: ¿necesitas el VALOR del Mono para construir el stream? flatMapMany. ¿Solo necesitas saber que el Mono TERMINÓ? thenMany. ¿Necesitas que el Mono corra MUCHAS VECES? repeat. Y cuando una API simplemente exige un Flux, Mono.flux() convierte el tipo sin tocar el comportamiento.
Mono.flatMapMany
fun <R> Mono<T>.flatMapMany(mapper: (T) -> Publisher<R>): Flux<R>flatMapMany se suscribe al Mono y, cuando llega su único valor, se lo pasa al mapper; el Publisher que el mapper devuelve se suscribe de inmediato y se convierte en el Flux de salida — sus valores, su finalización, sus errores. Si el Mono completa vacío, el mapper nunca se invoca y la salida simplemente completa; si el Mono falla, el error se propaga intacto. Como un Mono tiene a lo sumo un valor, hay exactamente un Publisher interno, así que ninguna de las preocupaciones de concurrencia o entrelazado de Flux.flatMap aplica.
- Buscar una entidad y luego transmitir sus hijos: usuario → órdenes, cuenta → transacciones, dispositivo → lecturas
- Convertir el resultado de una sola llamada asíncrona en la semilla de un stream en vivo
- Encadenar una consulta de repositorio que devuelve Mono con una query que devuelve Flux
- Producir con elegancia un stream vacío cuando la búsqueda no encuentra nada
No uses flatMapMany cuando el valor en sí es irrelevante — ese es el trabajo de thenMany, y usar flatMapMany ahí te obliga a escribir una lambda que ignora su argumento. Y si tus 'muchos' son una simple colección en memoria, flatMapIterable evita el costo de envolverla en un Publisher.
Mono.empty<String>()
.flatMapMany { Flux.just("$it-1", "$it-2") }
.subscribe(
{ println(it) },
{ println("error") },
{ println("done") }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. El Mono está vacío — no se encontró ningún usuario. ¿Qué se imprime?
Este es EL operador de Mono a Flux, y la forma del problema está en todas partes: una consulta asíncrona cuyo resultado necesitas para arrancar un stream. Carga el usuario, luego transmite sus órdenes. Resuelve la cuenta, luego suscríbete a sus transacciones. Encuentra el dispositivo, luego sigue sus lecturas de sensores. flatMapMany espera el único valor del Mono, se lo entrega a tu lambda, y el Publisher que tu lambda devuelve se convierte en el Flux de salida.
Piénsalo como un Mono.flatMap sin la tapa: flatMap te mantiene en el mundo Mono (un valor entra, a lo sumo uno sale), mientras que flatMapMany abre el abanico — un valor entra, salen los que sean. Si los "muchos" hacia los que te abres son solo una colección en memoria y no otra fuente asíncrona, flatMapIterable es el hermano más barato.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
data class User(val id: Int, val name: String)
// One user comes back from the database... (Mono = at most one value)
fun findUser(id: Int): Mono<User> =
Mono.just(User(id, "Ana"))
.doOnNext { println("user loaded: ${it.name} (#${it.id})") }
// ...and their order history arrives as a stream (Flux = 0..N values)
fun findOrders(user: User): Flux<String> =
Flux.just(
"${user.name}: 2x espresso",
"${user.name}: 1x croissant",
"${user.name}: 1x latte"
)
fun main() {
findUser(42)
.flatMapMany { user -> findOrders(user) }
.subscribe(
{ println(" $it") },
{ e -> println("error: $e") },
{ println("all orders delivered") }
)
}Partimos de un Mono: findUser(42) se resuelve en exactamente un User, Ana. flatMapMany toma a esa usuaria y llama a findOrders(user), y el Flux de tres elementos que devuelve simplemente se convierte en nuestra salida — fíjate cómo cada línea de orden se construyó A PARTIR del objeto user, que es exactamente la razón por la que necesitábamos el valor antes de que el stream pudiera existir. Al final nos suscribimos con tres callbacks: cada orden se imprime con sangría, y el callback de finalización se dispara cuando el Flux interno termina.
user loaded: Ana (#42) Ana: 2x espresso Ana: 1x croissant Ana: 1x latte all orders delivered
El caso vacío es una ventaja: si el Mono completa vacío, la lambda nunca se invoca y el Flux de salida simplemente completa — sin órdenes para un usuario que no existe, sin NullPointerException, sin casos especiales. Un error en el Mono igualmente fluye directo. Como solo hay UN Publisher interno, ninguna de las preguntas habituales de entrelazado de flatMap aplica aquí.
Mono.thenMany / Mono.flux()
fun <V> Mono<T>.thenMany(other: Publisher<V>): Flux<V>thenMany se suscribe al Mono fuente y lo deja correr hasta completar, descartando cualquier valor que emita. Solo cuando llega el onComplete del Mono se suscribe al otro Publisher, cuyas señales se convierten en el Flux de salida de punta a punta. Si el Mono falla, el error se reenvía y other nunca se suscribe. Mono.flux() es el primo degenerado: sin secuenciación, sin segundo publisher — solo re-tipa la misma fuente de 0..1 elementos como Flux.
- Correr un paso de setup cuyo valor es ruido, y luego transmitir: limpiar caché → transmitir precios, migrar → servir
- Secuenciar 'espera a que termine esta escritura, luego lee la colección'
- Condicionar un stream a un acuse de recibo, un lock o un chequeo de permisos
- Mono.flux(): satisfacer una API que exige un Flux cuando tienes un Mono
El argumento se ensambla ansiosamente: thenMany(buildFlux()) llama a buildFlux() en cuanto se arma la cadena, aunque su SUSCRIPCIÓN espere. Si la construcción tiene efectos secundarios o debe observar los resultados del setup, pasa Flux.defer { buildFlux() }. Recuerda además que el valor se descarta pero el TRABAJO igual corre — thenMany no es una forma de saltarse el Mono.
Mono.fromCallable { println("setup ran"); "ignored" }
.thenMany(Flux.just(1, 2))
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué imprime esto, y en qué orden?
A veces el valor del primer paso no importa — solo el hecho de que terminó. Limpia el caché, luego transmite precios frescos. Corre la migración, luego atiende requests. Toma el lock, luego procesa la cola. thenMany(other) se suscribe a tu Mono, espera a que complete, tira su valor a la basura y entonces cambia al Flux que le pasaste.
Su primo diminuto Mono.flux() no secuencia nada: es un adaptador de tipo puro que re-envuelve la misma fuente de 0 o 1 elementos como un Flux, para esos momentos en que una API exige un Flux y lo único que tienes es un Mono.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// Step 1: a Mono that clears the price cache (its value does not matter)
val clearCache: Mono<String> = Mono.fromCallable {
println("cache cleared")
"OK" // thenMany throws this value away
}
// Step 2: once it COMPLETES, stream the fresh prices
clearCache
.thenMany(Flux.just("AAPL: 189.90", "MSFT: 402.10", "NVDA: 118.30"))
.subscribe { println(it) }
// Mono.flux(): a pure type adapter — a Flux of 0 or 1 elements
Mono.just("single value").flux()
.subscribe { println("from flux(): $it") }
}Tenemos un Mono de setup que limpia un caché y devuelve "OK". thenMany se suscribe a él primero: se imprime "cache cleared", el "OK" se descarta, y recién entonces el Flux de precios empieza a fluir — las tres cotizaciones se imprimen en orden después de la línea de setup, nunca antes. Las últimas dos líneas muestran flux(): el valor único pasa sin cambios, solo que ahora con tipo Flux.
cache cleared AAPL: 189.90 MSFT: 402.10 NVDA: 118.30 from flux(): single value
Dos cosas muerden aquí. Primero, los errores: si el Mono de setup falla, thenMany propaga el error y el segundo publisher nunca se suscribe — tu stream nunca arranca, por diseño. Segundo, ensamblado vs suscripción: el ARGUMENTO de thenMany se construye ansiosamente al armar la cadena (solo su suscripción se difiere), así que si construir ese Flux tiene efectos secundarios, envuélvelo en Flux.defer { ... }.
Mono.repeat
fun Mono<T>.repeat(): Flux<T> / fun Mono<T>.repeat(numRepeat: Long): Flux<T>repeat se re-suscribe a la fuente cada vez que completa, concatenando las corridas en un solo Flux. La forma sin argumentos repite indefinidamente; repeat(n) hace n suscripciones ADICIONALES después de la primera, para un total de n+1 corridas. Cada ronda es una suscripción genuinamente fresca, así que las fuentes diferidas (fromCallable, defer) rehacen su trabajo mientras que las fuentes de valor capturado (just) repiten el mismo valor. Los errores no se repiten — un onError termina todo el loop de inmediato.
- Loops de polling: re-preguntar a un endpoint de estado hasta que el job esté DONE (agrega delayElement para el ritmo, takeUntil para frenar)
- Refrescar una lectura periódicamente: valores de sensores, profundidad de colas, tipos de cambio
- Generar carga o heartbeats re-corriendo un Mono barato
- repeat(n): correr un número fijo de rondas, p. ej. muestrear una métrica exactamente 10 veces
repeat() sin condición de corte es un stream infinito — siempre acompáñalo con takeUntil/take o una cancelación aguas abajo. Un Mono VACÍO que completa al instante más repeat() es un spin-loop caliente salvo que agregues delayElement/delaySubscription. Y no lo confundas con retry: repeat se dispara con onComplete, retry con onError.
var n = 0
Mono.fromCallable { ++n }
.repeat(2)
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Cuántos valores imprime esto, y cuáles?
Un Mono corre una vez, entrega una vez y se acabó. Pero mucho trabajo real es una misma pregunta hecha muchas veces: ¿ya terminó el job de exportación? ¿qué marca ahora el sensor de temperatura? ¿qué tan llena está la cola? repeat() convierte un Mono en ese loop — cada vez que la fuente completa, se vuelve a suscribir y arranca una corrida fresca. Entra un Mono, sale un Flux interminable de resultados.
La receta clásica de polling apila cuatro piezas pequeñas: Mono.fromCallable (haz el trabajo desde cero en cada suscripción), delayElement (respira entre rondas), repeat() (repite para siempre) y takeUntil (detente cuando llegue la respuesta que esperabas).
import reactor.core.publisher.Mono
import java.time.Duration
import java.util.concurrent.atomic.AtomicInteger
fun main() {
val t0 = System.currentTimeMillis()
val calls = AtomicInteger(0)
// One "HTTP call": ask the job service how the export is going
val pollStatus: Mono<String> = Mono.fromCallable {
val n = calls.incrementAndGet()
if (n < 4) "poll #$n: PROCESSING" else "poll #$n: DONE"
}.delayElement(Duration.ofMillis(200)) // breathing room between polls
pollStatus
.repeat() // re-subscribe after each completion...
.takeUntil { it.endsWith("DONE") } // ...until the job finishes
.doOnNext { println("+${System.currentTimeMillis() - t0}ms $it") }
.blockLast()
}Cada suscripción a pollStatus hace una "llamada": el callable corre, reporta PROCESSING o DONE, y delayElement retiene el resultado 200 ms para no martillar el servicio. Cuando ese pequeño stream completa, repeat() se suscribe otra vez — ese es el loop. takeUntil mira pasar los resultados y corta el stream tras el primer DONE, cancelando cualquier repetición futura. blockLast() mantiene vivo el main mientras los timers corren en el scheduler parallel de Reactor.
+331ms poll #1: PROCESSING +537ms poll #2: PROCESSING +739ms poll #3: PROCESSING +943ms poll #4: DONE
Tres trampas. repeat(n) significa n suscripciones ADICIONALES — repeat(2) produce 3 corridas en total. repeat solo reacciona a onComplete; su gemelo para onError es retry. Y Mono.just(x).repeat() re-entrega el MISMO valor capturado para siempre — para trabajo fresco en cada ronda necesitas una fuente que compute al suscribirse, como fromCallable o defer.
Flux → Mono: el puente de vuelta
Flux<T>.next(): Mono<T> · .last() · .single() · .collectList(): Mono<List<T>> · .reduce() · .count() · .all() · .any() · .then(): Mono<Void> · .ignoreElements()Todo puente Flux→Mono colapsa un stream en un publisher único, y solo difieren en QUÉ conservan y CUÁNDO pueden responder. next() emite el primer elemento y cancela la fuente; last(), single(), collectList(), reduce(), count(), all() y any() en general deben observar el stream hasta onComplete antes de emitir (all/any pueden cortocircuitar temprano ante un elemento decisivo); then() e ignoreElements() descartan todos los valores y reenvían solo la señal terminal. Todos propagan los errores sin cambios.
- next(): 'dame el primer resultado y deja de buscar' — chequeos baratos de existencia con valor
- collectList()/reduce()/count(): armar un reporte, un total o un tamaño a partir de un stream finito
- all()/any(): validar un stream entero en un solo veredicto booleano
- then()/ignoreElements(): 'corre este stream por sus efectos y avísame cuando termine'
Los que esperan la finalización (collectList, reduce, count, all, last…) nunca emiten sobre un Flux infinito — acota el stream primero con take o una ventana. single() falla tanto con cero como con múltiples elementos; si 'vacío' es válido, usa singleOrEmpty()/next(). Y recuerda que collectList guarda TODO en memoria — un millón de elementos significa una lista de un millón de entradas.
Mono.just(3)
.flatMapMany { x -> Flux.range(1, x) }
.reduce { a, b -> a + b }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Un viaje completo de ida y vuelta — de Mono a Flux y de regreso a Mono. ¿Qué se imprime?
El puente corre en ambas direcciones, y el camino de vuelta es en realidad el carril más transitado: cada vez que necesitas UNA respuesta sobre TODO un stream — un total, un veredicto, la primera coincidencia, una señal de finalización — estás cruzando de Flux a Mono. A diferencia del puñado de operadores de arriba, la dirección de regreso es una familia entera, y cada miembro ya tiene su propia sección en esta referencia. Este es el mapa, agrupado por la pregunta que responden:
- Elegir un elemento → next() toma el primero y cancela el resto; last() espera al final; single() exige exactamente uno y falla si no
- Agregarlos todos → collectList() los junta en una List, reduce() los pliega en uno solo, count() simplemente cuenta
- Responder sí/no → all(predicado) y any(predicado) cortocircuitan en un Mono<Boolean>
- Quedarse solo con el final → then() da un Mono<Void> que se dispara al completar; ignoreElements() es la misma idea pero conserva el tipo del elemento
- …y para volver a cruzar: flatMapMany, thenMany y repeat, cubiertos arriba
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
data class Order(val item: String, val amount: Double)
fun findOrders(user: String): Flux<Order> =
Flux.just(
Order("espresso", 2.50),
Order("croissant", 3.20),
Order("latte", 4.10)
)
fun main() {
// Mono -> Flux: fan one user out into their stream of orders
val orders: Flux<Order> = Mono.just("ana").flatMapMany { findOrders(it) }
// Flux -> Mono: collapse the stream back into single answers
orders.next()
.subscribe { println("next(): first order is ${it.item}") }
orders.count()
.subscribe { println("count(): $it orders placed") }
orders.reduce(0.0) { total, o -> total + o.amount }
.subscribe { println("reduce(): receipt total $" + "%.2f".format(it)) }
orders.all { it.amount < 10.0 }
.subscribe { println("all(): every item under \$10? $it") }
orders.map { it.item }.collectList()
.subscribe { println("collectList(): $it") }
orders.then()
.subscribe({ }, { }, { println("then(): stream finished — values dropped") })
}Primero cruzamos el puente de ida: un nombre de usuario se abre en un Flux de tres órdenes vía flatMapMany. Luego cruzamos de vuelta seis veces, colapsando cada vez el mismo stream en una respuesta única distinta: la primera orden, el conteo de órdenes, el total plegado, un veredicto de todo-bajo-$10, la lista completa y, al final, una señal pura de finalización con then(). Como orders es un publisher frío, cada subscribe lo vuelve a correr desde el inicio — seis suscripciones, seis repeticiones, y por eso las seis respuestas se imprimen en el orden en que nos suscribimos.
next(): first order is espresso count(): 3 orders placed reduce(): receipt total $9.80 all(): every item under $10? true collectList(): [espresso, croissant, latte] then(): stream finished — values dropped
Lo que hay que internalizar es el timing: next() responde apenas llega el PRIMER elemento y cancela el resto de la fuente; all() y any() también pueden resolverse temprano, en cuanto un elemento decide el veredicto. Los agregadores — collectList, reduce, count, last — deben esperar onComplete, así que sobre un Flux infinito esperan para siempre. Y single() es estricto: cero elementos o más de uno producen error. Cada uno de estos operadores se cubre a fondo en las categorías de Filtrado y Reducción.
Propagación de Contexto
Toda petición real carga equipaje invisible: quién es el usuario, a qué tenant pertenece, el trace id que une veinte líneas de log en una sola historia. En las apps servlet clásicas ese equipaje vive en ThreadLocals — un hilo por petición, así que un holder estático simplemente funciona. Los pipelines reactivos rompen esa suposición: una sola petición puede tocar cuatro hilos antes de responder, y un ThreadLocal seteado en el primero queda silenciosamente vacío en los demás. La respuesta de Reactor es el Context del suscriptor: un pequeño mapa clave/valor inmutable que viaja sobre la propia suscripción, y por eso acompaña a la petición a donde sea que vaya el pipeline.
La parte que al principio le dobla el cerebro a cualquiera: el Context fluye HACIA ARRIBA. Lo escribes al final de la cadena — justo donde ocurre subscribe() y los detalles de la petición se conocen — y lo lees arriba, cerca de la fuente. Eso funciona porque la señal de suscripción es la única que viaja del suscriptor hacia la fuente, visitando cada operador antes de que fluya un solo valor; el Context simplemente se sube a ese viaje.
- contextWrite — enriquece el Context en su camino hacia arriba: tokens de auth, tenant ids, correlation ids escritos una sola vez al final de la cadena
- deferContextual / transformDeferredContextual — el lado lector: construye una fuente, o recablea un pipeline, a partir del ContextView
- contextCapture — el puente de Reactor 3.5+ que copia ThreadLocals (MDC, tracing) al Context automáticamente
contextWrite
fun <T> Flux<T>.contextWrite(transform: (Context) -> Context): Flux<T>contextWrite intercepta la señal de subscribe en su camino del suscriptor a la fuente y aplica tu transformación al Context que viaja sobre ella. Como la suscripción fluye de abajo hacia arriba, solo los operadores POR ENCIMA del contextWrite ven el Context enriquecido; el Context en sí es inmutable, así que put/delete devuelven una instancia nueva que debes retornar desde la lambda. La transformación corre exactamente una vez por suscriptor, al momento de la suscripción, y las señales de datos (onNext/onError/onComplete) pasan completamente intactas. Una sobrecarga corta, contextWrite(ContextView), fusiona un contexto prearmado con putAll.
- Llevar el principal autenticado o el token de auth a repositorios aguas arriba (el mecanismo de Spring Security WebFlux)
- Propagar un tenant id en servicios multi-tenant sin pasar parámetros por cada capa
- Adjuntar correlation / trace ids para que los operadores de arriba etiqueten logs y eventos por petición
- Inyectar feature flags o locale por petición que las fábricas de arriba leen vía deferContextual
La ubicación lo es todo: un contextWrite solo afecta a los operadores POR ENCIMA de él, así que un lector debajo (o en una rama hermana) no ve nada — el síntoma clásico es una NoSuchElementException de ctx.get. Y cuando dos escrituras apuntan a la misma clave, gana la más cercana al lector, porque la de abajo se aplica primero y las de arriba la sobrescriben mientras la suscripción sube.
Mono.deferContextual { ctx -> Mono.just("Hello " + ctx.get<String>("who")) }
.contextWrite { it.put("who", "Reactor") }
.contextWrite { it.put("who", "World") }
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Dos llamadas a contextWrite escriben la misma clave. ¿Qué imprime el suscriptor?
Imagina una tienda multi-tenant: la capa web conoce al tenant autenticado, pero el repositorio que realmente busca las órdenes está cinco capas más arriba. El arreglo imperativo es pasar un parámetro tenantId por cada firma de método intermedia. contextWrite ofrece un trato más limpio: escribe el valor una sola vez, al final de la cadena, y cada operador POR ENCIMA de ese punto puede leerlo del Context del suscriptor.
Mecánicamente, contextWrite recibe una función de Context a Context. El Context es inmutable — ctx.put(clave, valor) devuelve un Context nuevo, nunca muta el anterior — y toda la transformación corre exactamente una vez por suscriptor, en el momento de la suscripción, cuando la señal de subscribe pasa en su camino hacia la fuente. Las señales de datos jamás se tocan: valores, errores y completado pasan sin cambios.
import reactor.core.publisher.Flux
fun main() {
// A "repository" with no tenant parameter: it reads WHO is asking
// from the subscriber Context instead of from an argument.
fun findOrders(): Flux<String> =
Flux.deferContextual { ctx ->
val tenant = ctx.getOrDefault("tenantId", "unknown")
Flux.just("$tenant/order-1", "$tenant/order-2")
}
// The web layer, far away at the END of the chain, knows the tenant.
// It writes it once and the value travels UP to the repository.
findOrders()
.map { it.uppercase() }
.contextWrite { ctx -> ctx.put("tenantId", "acme") }
.subscribe { println("received: $it") }
// Same pipeline without the write: the repository sees no tenant.
findOrders()
.map { it.uppercase() }
.subscribe { println("received: $it") }
}Tenemos un repositorio, findOrders(), que no recibe ningún argumento de tenant — lee el tenant del Context vía deferContextual. En el primer pipeline, contextWrite está entre map y subscribe; al suscribirnos, la suscripción sube, contextWrite estampa tenantId=acme en el Context durante el trayecto, y para cuando la suscripción llega al repositorio el valor ya está ahí — ambas órdenes salen con el prefijo ACME. El segundo pipeline es idéntico pero sin la escritura: el repositorio no encuentra nada en el Context y cae al valor por defecto "unknown".
received: ACME/ORDER-1 received: ACME/ORDER-2 received: UNKNOWN/ORDER-1 received: UNKNOWN/ORDER-2
Este es exactamente el mecanismo sobre el que viajan los frameworks en producción: el ReactiveSecurityContextHolder de Spring Security guarda el principal autenticado en el Context del suscriptor, y las librerías de tracing reactivo llevan los span/correlation ids de la misma manera. Cuando dos contextWrite escriben la misma clave, gana el más cercano al lector — la suscripción aplica primero la escritura de abajo y luego la de arriba la sobrescribe en su camino hacia arriba.
deferContextual / transformDeferredContextual
fun <T> Flux.deferContextual(supplier: (ContextView) -> Publisher<out T>): Flux<T>deferContextual es defer con el Context del suscriptor entregado: al momento de la suscripción, el supplier recibe el ContextView de solo lectura — ya enriquecido por cada contextWrite de abajo — y devuelve el publisher que realmente atenderá a este suscriptor. La fábrica corre una vez por suscripción, así que cada suscriptor puede recibir un stream construido distinto. Su hermano transformDeferredContextual((Flux<T>, ContextView) -> Publisher<R>) recibe también el upstream, permitiéndote remodelar un pipeline existente por suscriptor en lugar de crear una fuente. Ambos existen también en Mono.
- Construir una fuente que depende de quién se suscribe: saludos por usuario, consultas por tenant
- Leer el principal de seguridad / trace id en la cima de la cadena, donde nacen los datos
- Cambiar la forma del pipeline por petición (logging detallado, política de retry) con transformDeferredContextual
- Probar la plomería de contexto: combínalo con contextWrite para verificar que los valores llegan arriba
ctx.get(clave) lanza NoSuchElementException si la clave falta — y las claves faltan siempre que el contextWrite correspondiente esté POR ENCIMA del lector o nunca se haya agregado. Usa getOrDefault/getOrEmpty en fronteras que no controlas. Recuerda que recibes un ContextView: no hay put — el código lector no puede colar escrituras.
Flux.deferContextual { ctx ->
Flux.just("tenant: " + ctx.getOrDefault("tenant", "public"))
}
.map { it.uppercase() }
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. No hay ningún contextWrite en toda la cadena. ¿Qué se imprime?
Escribir en el Context es solo la mitad de la historia — algo aguas arriba tiene que leerlo. deferContextual es el hermano consciente del contexto de defer: en lugar de una fábrica simple, le das una fábrica que recibe el ContextView (la cara de solo lectura del Context), y en el momento de la suscripción construye el publisher real con lo que encuentra ahí. Es LA manera de hacer que una fuente dependa de quién se suscribe — saludar al usuario logueado, consultar la base del tenant correcto, etiquetar cada evento con el trace id de la petición.
Su primo más pesado, transformDeferredContextual, le entrega a tu función tanto el Flux de arriba como el ContextView, y espera de vuelta un pipeline reconstruido. Úsalo cuando el Context deba cambiar la FORMA de la cadena — activar logging detallado para una petición marcada, elegir otra política de retry según el tier del cliente — y no solo alimentarle un valor.
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
fun main() {
// 1) deferContextual — build the source FROM the Context
val greeting: Mono<String> =
Mono.deferContextual { ctx ->
val user = ctx.get<String>("user")
Mono.just("Welcome back, $user!")
}
greeting
.contextWrite { it.put("user", "ana") }
.subscribe { println(it) }
// 2) transformDeferredContextual — rewire the pipeline per subscriber
val prices = Flux.just(100, 250, 400)
.transformDeferredContextual { flux, ctx ->
if (ctx.getOrDefault("debug", false))
flux.map { "[debug] price=$it cents" }
else
flux.map { "price: $it" }
}
prices
.contextWrite { it.put("debug", true) }
.subscribe { println(it) }
prices.subscribe { println(it) } // no write -> normal rendering
}El Mono del saludo no existe hasta que alguien se suscribe: la fábrica recibe entonces el ContextView, encuentra user=ana (escrito una línea más abajo) y acuña un Mono personalizado. El pipeline de precios va más lejos — transformDeferredContextual inspecciona la bandera debug por suscriptor y devuelve una cadena con forma distinta cada vez: la primera suscripción escribió debug=true y recibe el formato [debug]; la segunda no escribió nada, getOrDefault devuelve false, y el mismo Flux sirve el formato normal. Una sola definición de pipeline, dos comportamientos, elegidos al momento de suscribirse.
Welcome back, ana! [debug] price=100 cents [debug] price=250 cents [debug] price=400 cents price: 100 price: 250 price: 400
ctx.get(clave) lanza NoSuchElementException cuando la clave no existe — un crash clásico de la primera semana, porque el lector corrió antes de que agregaras el contextWrite (o lo pusiste POR ENCIMA del lector, donde es invisible). Prefiere getOrDefault o getOrEmpty en fronteras de confianza. Nota además que recibes un ContextView: el código lector no puede hacer put ni delete — escribir es tarea exclusiva de contextWrite.
contextCapture
fun <T> Flux<T>.contextCapture(): Flux<T> // Reactor 3.5+contextCapture conecta el mundo ThreadLocal (MDC, spans de tracing, holders de seguridad) con el Context del suscriptor. Cuando la suscripción pasa, le pide a la librería context-propagation de Micrometer cada ThreadLocalAccessor registrado y copia el valor actual de cada ThreadLocal — visto en el hilo que se suscribe, en ese instante — al Context bajo la clave del accessor. Los operadores de arriba leen entonces esos valores como cualquier salida de contextWrite. Desde 3.5.3, Hooks.enableAutomaticContextPropagation() completa el viaje de ida y vuelta restaurando los valores del Context en los ThreadLocals alrededor de los callbacks del usuario.
- Llevar valores del MDC de SLF4J (request id, user id) a cadenas reactivas para que los logs sigan correlacionados
- Propagar el contexto de tracing / observación de Micrometer a través de saltos de scheduler
- Migrar código legacy basado en ThreadLocal a WebFlux sin reescribir primero a los llamadores
- Reemplazar plomería contextWrite escrita a mano cuando ya hay accessors registrados
Necesita io.micrometer:context-propagation en el classpath Y un ThreadLocalAccessor registrado por clave — sin ninguno registrado no captura nada, silenciosamente. La foto se toma una vez, al momento de la suscripción, en el hilo que se suscribe: los valores seteados después o en otros hilos se pierden. La captura es unidireccional (ThreadLocal → Context); restaurar valores alrededor de tus lambdas es trabajo de la propagación automática (o de handle/tap).
val USER = ThreadLocal.withInitial { "guest" }
USER.set("ana") // set by a filter on the request thread
Mono.deferContextual { ctx -> Mono.just("user: " + ctx.getOrDefault("user", "missing")) }
.contextCapture()
.subscribe(::println)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Reactor 3.5+, io.micrometer:context-propagation está en el classpath y hay un ThreadLocalAccessor registrado bajo la clave "user". ¿Qué se imprime?
La mayoría de las bases de código reales son mitad y mitad: el MDC de SLF4J guarda el request id para logging, los agentes de tracing estacionan spans en ThreadLocals — mientras tu código nuevo es una cadena reactiva que salta de hilo cuando quiere. contextCapture (Reactor 3.5+) es el puente oficial entre esos mundos: en el momento de la suscripción toma una foto de los valores ThreadLocal del hilo actual y los copia al Context del suscriptor, usando la librería context-propagation de Micrometer — cada ThreadLocal que registraste mediante un ThreadLocalAccessor entra bajo su clave.
Conceptualmente es un contextWrite que no tuviste que escribir: en lugar de nombrar tú las claves y valores, los accessors registrados deciden qué se captura. Desde Reactor 3.5.3 puedes ir más lejos con Hooks.enableAutomaticContextPropagation(), que además restaura esos valores del Context de vuelta en sus ThreadLocals alrededor de tus callbacks — así un log basado en MDC dentro de una lambda de map imprime el request id correcto incluso en un hilo de scheduler.
import reactor.core.publisher.Mono
// Legacy code talks through a ThreadLocal — MDC-style request tracking
val REQUEST_ID: ThreadLocal<String> = ThreadLocal.withInitial { "none" }
fun main() {
REQUEST_ID.set("req-4711") // set by a servlet filter, far from Reactor
Mono.deferContextual { ctx ->
Mono.just("charging card for ${ctx.get<String>("requestId")}")
}
.contextCapture() // 3.5+: snapshot ThreadLocals -> Context
.contextWrite { it.put("requestId", REQUEST_ID.get()) } // what contextCapture automates
.subscribe { println(it) }
}Un filtro de la era servlet seteó REQUEST_ID en el hilo de la petición, mucho antes de que corriera cualquier código Reactor. Nuestro pipeline de pagos lee ese id aguas arriba mediante deferContextual. En una app 3.5+ real con un ThreadLocalAccessor registrado para "requestId", la línea contextCapture() por sí sola copiaría el valor del ThreadLocal al Context en el momento de la suscripción; el contextWrite explícito de abajo deletrea exactamente lo que esa captura hace por dentro. De cualquier forma, el valor sube con la suscripción y el cobro queda registrado contra req-4711.
charging card for req-4711
La foto se toma UNA vez, en el momento de la suscripción, en el hilo que se suscribe. Un ThreadLocal seteado después de subscribe(), o seteado en otro hilo, no se captura. Y la captura es unidireccional (ThreadLocal → Context): sacar los valores de vuelta a ThreadLocals para tus lambdas es trabajo de la propagación automática (Hooks.enableAutomaticContextPropagation(), 3.5.3+) o de handle/tap, que restauran los ThreadLocals alrededor de sus callbacks.
Debugging y Observabilidad
Tarde o temprano todo equipo reactivo conoce el stack trace del infierno: un error de producción cuyas cuarenta líneas son puro interior de reactor.core.publisher — suscriptores llamando a suscriptores — sin una sola línea que apunte a código que alguien del equipo haya escrito. La razón es estructural, no mala suerte: un pipeline se ENSAMBLA en un lugar, pero se EJECUTA después, muchas veces en otro hilo. Para cuando un valor explota dentro de un map, la pila de llamadas solo recuerda la maquinaria de ejecución; el sitio de ensamblado — la línea de tu servicio donde ese map fue declarado, la que de verdad necesitas — desapareció hace rato.
Esta categoría es la caja de herramientas de Reactor para exactamente ese hueco, más los ganchos que alimentan tus dashboards cuando los pipelines llegan a producción. Aproximadamente de lo quirúrgico a lo global:
- checkpoint — planta un letrero con nombre en el pipeline, para que los errores digan QUÉ segmento falló
- name / tag — dale a una secuencia una identidad estable y dimensiones para métricas
- tap — refleja cada señal en un SignalListener; con reactor-core-micrometer eso significa métricas reales (el reemplazo del metrics() deprecado)
- Hooks.onOperatorDebug / ReactorDebugAgent — trazas de ensamblado globales, para desarrollo y para producción
- hide — oculta la identidad de una fuente y desactiva la fusión; una herramienta de nicho para tests y diagnóstico
checkpoint
fun <T> Flux<T>.checkpoint(): Flux<T> // checkpoint(description: String) // checkpoint(description: String, forceStackTrace: Boolean)checkpoint inserta un marcador de ensamblado en la cadena. En el plano de datos es un paso directo puro; actúa solo sobre errores: cualquier onError que lo cruza se enriquece con una OnAssemblyException suprimida que identifica el sitio. Con descripción es un checkpoint 'ligero' — solo el string, sin costo de captura. Sin argumentos captura un stack trace completo al ensamblarse el pipeline (pagado por ensamblado, o sea por request en código de servidor típico). La forma de dos argumentos registra la descripción y la traza completa.
- Precisar QUÉ segmento de un pipeline largo produjo un error críptico
- Anotar la frontera después de cada flatMap que envuelve una llamada remota tuya
- Obtener sitios de falla legibles en producción, donde los hooks globales cuestan demasiado
- Escalar un segmento sospechoso a traza completa con checkpoint(desc, true)
Un checkpoint solo describe errores que pasan A TRAVÉS de él: un error manejado aguas arriba (digamos, por un onErrorResume encima) nunca le llega, y uno lanzado aguas abajo nunca lo cruzó — un checkpoint mal ubicado produce silencio, no datos incorrectos. checkpoint() sin argumentos paga una captura completa de pila por ensamblado, así que evita regarlo por pipelines por-request; prefiere las descripciones.
Flux.just(4, 2, 0)
.map { 8 / it }
.checkpoint("ratios")
.subscribe(
{ println(it) },
{ e -> println("failed: " + e.message) }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. Este pipeline divide por cero en su último elemento. ¿Qué imprime?
Imagina un servicio de checkout donde el paso de pricing divide el total de una orden por su cantidad. Una orden malformada después, una ArithmeticException aparece a las 2 a.m. — y el stack trace nombra a FluxMap y una cadena de suscriptores, pero ni una línea de tu código de checkout. checkpoint() es el arreglo quirúrgico: plantas un letrero justo después del segmento sospechoso, y cualquier error que fluya a través de él se lleva ese letrero engrapado a su stack trace, así el log por fin dice DÓNDE en el pipeline se torció la cosa.
La variante con descripción, checkpoint("pricing step"), es un checkpoint ligero: no cuesta nada en runtime más allá de cargar el string, y cuando un error lo cruza Reactor le agrega una OnAssemblyException suprimida cuyo mensaje nombra tu descripción. La variante sin argumentos, checkpoint(), en cambio captura un stack trace completo al momento del ensamblado — mucho más precisa, y mucho más cara, porque los pipelines típicos de servidor se ensamblan por request, así que esa captura se paga en cada request. checkpoint("desc", true) fuerza ambas cosas: tu descripción más la traza completa.
import reactor.core.publisher.Flux
fun main() {
val step = "pricing: unit price = total / quantity"
Flux.just("ord-1:300:3", "ord-2:120:2", "ord-3:500:0")
.map { line ->
val (id, total, qty) = line.split(":")
"$id -> ${total.toInt() / qty.toInt()} per unit"
}
.checkpoint(step)
.subscribe(
{ println(it) },
{ e -> println("FAILED at checkpoint [$step]: $e") }
)
}Tenemos un stream de líneas de orden crudas con forma id:total:cantidad. map parsea cada una y calcula el precio unitario — 300/3 y 120/2 salen bien — hasta que llega ord-3 con cantidad 0 y la división lanza. La excepción se convierte en una señal onError, baja a través de checkpoint(step) y llega a nuestro consumidor de error, que imprime la falla junto con la descripción del checkpoint. Fíjate que las dos primeras órdenes ya habían sido entregadas: checkpoint diagnostica, nunca revierte nada.
ord-1 -> 100 per unit ord-2 -> 60 per unit FAILED at checkpoint [pricing: unit price = total / quantity]: java.lang.ArithmeticException: / by zero
La ubicación lo es todo: un checkpoint solo describe errores que lo cruzan — un error ya manejado aguas arriba nunca le llega, y uno lanzado más abajo nunca pasó por él. El hábito que funciona: pon un checkpoint con descripción justo DESPUÉS de cada segmento que te pertenece, típicamente después de cada flatMap que envuelve una llamada remota. Y prefiere las descripciones — regar checkpoint() sin argumentos por rutas calientes paga una captura completa de pila por ensamblado, por checkpoint.
name / tag
fun <T> Flux<T>.name(name: String): Flux<T> // fun <T> Flux<T>.tag(key: String, value: String): Flux<T>Ambos adjuntan metadata de ensamblado a la secuencia, legible mediante la interfaz Scannable de Reactor, y reenvían cada señal intacta. La instrumentación aguas abajo — tap con el listener de Micrometer — los lee: el nombre se vuelve la familia de métricas (checkout.payments.flow.duration, .onNext.delay, …) y cada tag se vuelve una dimensión. Sin nombre, las métricas caen en la familia anónima por defecto 'reactor'.
- Darle a cada pipeline de negocio una familia de métricas estable (checkout.payments, search.suggestions)
- Agregar dimensiones por las que los dashboards puedan filtrar: región, tenant, tier
- Distinguir secuencias en la salida de debugging y en recorridos de Scannable
- Como prerrequisito para que tap(Micrometer.metrics(…)) produzca métricas con sentido
La metadata la lee la instrumentación AGUAS ABAJO de ella: .name("x").tap(…) funciona, .tap(…).name("x") deja las métricas anónimas. Y los valores de tag obedecen la regla universal de Micrometer — cardinalidad baja; un valor de tag por usuario o por orden multiplica tus series de tiempo hasta tumbar el backend de métricas.
Flux.just("a", "b")
.name("letters")
.tag("env", "prod")
.subscribe { println(it.uppercase()) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué imprime el suscriptor?
Un servicio con cuarenta pipelines y un solo dashboard de Grafana tiene un problema de nombres: si cada secuencia reporta como el anónimo por defecto "reactor", el dashboard es un promedio sin significado. name("checkout.payments") le da a una secuencia una identidad estable, y tag("region", "us-east") agrega dimensiones clave/valor — exactamente como los tags de Micrometer, porque en eso precisamente se convierten.
Ninguno de los dos operadores cambia una sola señal. Adjuntan metadata de ensamblado a la secuencia — legible a través de la interfaz Scannable de Reactor — y la instrumentación aguas abajo la recoge: con el listener de Micrometer de la siguiente sección, el nombre se vuelve la familia de métricas (checkout.payments.flow.duration, checkout.payments.onNext.delay, …) y cada tag cae en esas métricas como una dimensión por la que tus dashboards pueden filtrar. Omite el nombre y todos tus pipelines se amontonan en el bucket por defecto "reactor", indistinguibles entre sí.
import reactor.core.publisher.Flux
fun main() {
val payments = Flux.just(120, 80, 310)
.name("checkout.payments") // meter family: checkout.payments.*
.tag("region", "us-east") // extra dimension on every meter
.tag("tier", "gold")
.map { cents -> "captured ${cents} cents" }
payments.subscribe(
{ println(it) },
{ e -> println("error: $e") },
{ println("checkout.payments completed") }
)
}Tenemos un stream de montos de pago capturados. El nombre y ambos tags se declaran una vez, directo sobre la secuencia; map luego formatea cada monto y el suscriptor lo imprime, con un mensaje de completado al final. Ahora mira la salida: es exactamente lo que imprimiría el mismo pipeline SIN name ni tag. Ese es justamente el punto — estos dos operadores existen solo para la maquinaria que lee metadata de la cadena: el listener de métricas de la siguiente sección, y la salida de debugging que nombra secuencias.
captured 120 cents captured 80 cents captured 310 cents checkout.payments completed
El orden importa: la instrumentación de métricas busca su nombre y tags AGUAS ARRIBA, así que .name("x").tap(Micrometer.metrics(registry)) funciona, mientras que .tap(…).name("x") deja las métricas anónimas. Y a los valores de tag les aplica la regla de siempre de Micrometer: mantén baja la cardinalidad — tag("tier", "gold") es una dimensión, tag("userId", …) es una explosión de series de tiempo.
tap + Micrometer
fun <T> Flux<T>.tap(listenerFactory: SignalListenerFactory<T, *>): Flux<T> // Micrometer.metrics(registry), Micrometer.observation(registry)En cada suscripción, tap le pide a la fábrica un SignalListener fresco y luego refleja en él toda la vida de la suscripción — doFirst, doOnSubscription, doOnNext, doOnComplete, doOnError, doOnCancel, doOnRequest, doFinally — mientras reenvía cada señal aguas abajo sin cambios. Micrometer.metrics(registry) (reactor-core-micrometer) es la fábrica canónica: registra conteos de suscripción, un timer de duración del flujo separado por desenlace, y demoras de onNext, nombrados según el .name() de aguas arriba y etiquetados con sus .tag()s.
- Métricas de producción por pipeline: throughput, latencia, proporción de desenlaces en un MeterRegistry
- Tracing distribuido de una secuencia vía Micrometer.observation(registry)
- Reemplazar cada uso del operador deprecado .metrics()
- Instrumentación transversal propia: escribe tu SignalListener una vez, conéctalo con tap donde quieras
El viejo operador .metrics() está DEPRECADO desde Reactor 3.5 (acoplaba reactor-core a Micrometer 1) — tap(Micrometer.metrics(registry)) es su reemplazo métrica por métrica; no escribas código nuevo contra metrics(). Recuerda además que los callbacks del listener corren en línea sobre la ruta de la señal: mantenlos rápidos, sin bloquear y sin lanzar — un listener que lanza envenena la secuencia que debía observar.
Flux.just(1, 2, 3)
.name("demo")
.tap(Micrometer.metrics(registry))
.map { it * 10 }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. Con un listener de Micrometer conectado vía tap, ¿qué le llega al suscriptor?
Puedes armar observabilidad a mano con contadores en doOnNext y timers en doFinally, pero se degrada rápido: cinco pipelines después tienes lambdas copy-pasteadas, cero conteo de suscripciones, cero percentiles de latencia, y estado mutable compartido corriendo carreras entre suscripciones. tap() (Reactor 3.5+) es la respuesta estructurada. Le entregas una SignalListenerFactory; por cada suscripción nueva la fábrica acuña un SignalListener fresco, y Reactor llama a ese listener para CADA evento en la vida de la suscripción — doFirst, doOnSubscription, doOnNext, doOnComplete, doOnError, doOnCancel, doOnRequest, doFinally — mientras el plano de datos fluye completamente intacto.
La fábrica insignia vive en el módulo aparte reactor-core-micrometer: .tap(Micrometer.metrics(registry)) registra las métricas clásicas de Reactor — conteos de suscripción, un timer de duración del flujo separado por desenlace (completado / error / cancelado), demoras entre onNext — bajo el nombre y los tags que declaraste aguas arriba. Su hermano Micrometer.observation(registry) envuelve cada suscripción en una Observation, el lado de tracing de la misma moneda. Este par es el reemplazo moderno del viejo operador .metrics(), que está deprecado — más sobre eso abajo.
import reactor.core.publisher.Flux
import java.util.concurrent.atomic.AtomicInteger
// The shape of a SignalListener: one instance per subscription,
// one hook per event in the sequence's life.
class PaymentListener {
private val seen = AtomicInteger()
fun doOnNext(v: String) { seen.incrementAndGet() }
fun doOnComplete() =
println("[listener] checkout.payments: recorded ${seen.get()} onNext, outcome=completed")
fun doOnError(e: Throwable) =
println("[listener] checkout.payments: outcome=error (${e.message})")
}
fun main() {
val listener = PaymentListener()
Flux.just("pay-101", "pay-102", "pay-103")
.name("checkout.payments")
.tap { listener } // real Reactor: .tap(Micrometer.metrics(registry))
.doOnNext(listener::doOnNext) // this playground wires the hooks by hand —
.doOnComplete(listener::doOnComplete) // a real tap() drives them for you
.subscribe { println("captured $it") }
}Tenemos tres capturas de pago. PaymentListener juega el rol de un SignalListener: cuenta las señales onNext y reporta el desenlace terminal. En el pipeline, tap { listener } marca dónde se enchufa la instrumentación real — y aquí va la parte honesta de este playground: su tap() es un stub que nunca invoca al listener, así que las dos líneas doOn… de abajo cablean los mismos hooks a mano. En Reactor 3.5+ real borrarías esas dos líneas y dejarías solo .tap(Micrometer.metrics(registry)): el operador mismo dispara cada callback del listener. El suscriptor imprime cada captura, y al completar, el listener reporta lo que midió.
captured pay-101 captured pay-102 captured pay-103 [listener] checkout.payments: recorded 3 onNext, outcome=completed
metrics() está DEPRECADO: desde Reactor 3.5 el viejo operador .metrics() está deprecado y marcado para eliminación — acoplaba reactor-core a una versión específica de Micrometer. No lo uses en código nuevo. Su reemplazo es .tap(Micrometer.metrics(registry)) de reactor-core-micrometer: las mismas métricas clásicas, una API de listener enchufable, y un SignalListener fresco por suscripción, o sea, sin estado compartido que corra carreras. Dos precauciones: los callbacks del listener corren en línea sobre la ruta de la señal, así que mantenlos rápidos, y no deben lanzar excepciones.
Hooks.onOperatorDebug / ReactorDebugAgent
Hooks.onOperatorDebug() // ReactorDebugAgent.init(); ReactorDebugAgent.processExistingClasses() (io.projectreactor:reactor-tools)Hooks.onOperatorDebug() activa un interruptor global de la JVM: cada operador ensamblado después de la llamada captura un stack trace de su sitio de declaración, y cualquier error que luego fluya por esos operadores se envuelve con una traza de ensamblado — 'Flux.map ⇢ at YourService.method(File.kt:42)' por cada operador de la cadena que falló, más el camino de propagación del error. ReactorDebugAgent produce las mismas trazas instrumentando los sitios de llamada al cargar las clases, con lo que el costo de captura en runtime desaparece.
- Diagnóstico en desarrollo cuando no puedes adivinar dónde poner un checkpoint
- ReactorDebugAgent en producción para trazas de ensamblado siempre activas y casi gratis
- Corridas de CI: habilita el hook en un listener de test para que las fallas se autoubiquen
- Debugging en el IDE — el modo debug de Reactor de IntelliJ IDEA usa este mismo mecanismo
onOperatorDebug() multiplica varias veces el costo de ensamblado — nunca lo despliegues activo, y recuerda que no puede retro-instrumentar pipelines ensamblados antes de la llamada (habilítalo en la primera línea del main()). El agente debe inicializarse antes de que carguen las clases de tus pipelines; ReactorDebugAgent.processExistingClasses() puede retransformar las ya cargadas con un costo único.
val pipeline = Flux.just(1, 0).map { 10 / it } // assembled here
Hooks.onOperatorDebug() // enabled here
pipeline.subscribe(
{ println(it) },
{ e -> println("err: " + e.message) }
)🧠 Reto: predice la salida
0/1 · 0/1 answered1. El hook de debug se habilita DESPUÉS de ensamblar el pipeline. ¿El error lleva traza de ensamblado?
checkpoint es quirúrgico — pero la cirugía exige saber dónde cortar, y el problema es justamente que casi nunca lo sabes. Hooks.onOperatorDebug() es la alternativa global: llamado una vez al arrancar, antes de que se ensamble cualquier pipeline, pone a Reactor en modo debug, donde CADA operador registra un stack trace de su sitio de declaración al ser ensamblado. Desde ese momento, cualquier error en cualquier parte llega envuelto con una traza de ensamblado — una lista legible tipo "Flux.map ⇢ at CheckoutService.priceOrders(CheckoutService.kt:42)" que nombra dónde fue declarado cada operador de la cadena que falló, más el camino que recorrió el error hasta el suscriptor.
Ese poder tiene precio: una captura de stack trace en cada ensamblado de cada operador, lo que típicamente hace la construcción de pipelines varias veces más lenta — este hook es una herramienta de desarrollo, punto. Su gemelo apto para producción es ReactorDebugAgent, del artefacto reactor-tools: un agente de Java que instrumenta los sitios de llamada de los operadores al cargar las clases, horneando la misma información de ensamblado en el bytecode en lugar de capturar stack traces en runtime. Su sobrecosto es despreciable, y por eso es seguro dejarlo activo en producción — Spring Boot incluso lo inicializa automáticamente cuando reactor-tools está en el classpath, y el modo debug de Reactor de IntelliJ IDEA usa el mismo mecanismo. Llama a ReactorDebugAgent.init() lo más temprano posible en main(); para clases cargadas antes de eso, processExistingClasses() las retransforma con un costo único.
- checkpoint — dirigido y casi gratis (con descripción), pero tienes que anticipar DÓNDE va a fallar
- Hooks.onOperatorDebug() — atrapa todo sin adivinar nada, pero paga una captura de pila en cada ensamblado de cada operador: solo desarrollo
- ReactorDebugAgent — las mismas trazas globales vía instrumentación de bytecode al cargar clases: sobrecosto despreciable, apto para producción
import reactor.core.publisher.Flux
import reactor.core.publisher.Hooks
fun main() {
Hooks.onOperatorDebug() // enable BEFORE any pipeline is assembled
Flux.just(4, 2, 0)
.map { 100 / it }
.filter { it > 10 }
.subscribe(
{ println("ratio: $it") },
{ e -> println("boom: ${e.javaClass.simpleName}: ${e.message}") }
)
}Primero se habilita el hook, luego un pipeline divide 100 por cada valor y filtra los resultados. 4 y 2 producen 25 y 50; después llega 0, la división dentro de map lanza, y el consumidor de error imprime la excepción. En Reactor real con el modo debug activo, esa misma excepción llevaría además la traza de ensamblado nombrando las líneas exactas donde se declararon map y filter. El Hooks.onOperatorDebug() de este playground es un stub, así que la salida muestra el manejo de error simple — pero la estructura del pipeline y la ubicación del hook son exactamente lo que escribirías en una app real.
ratio: 25 ratio: 50 boom: ArithmeticException: / by zero
El hook solo instrumenta pipelines ensamblados DESPUÉS de la llamada — habilítalo en la primera línea del main(), no cuando ya estás mirando una falla (los pipelines ya construidos conservan sus trazas anónimas). Y nunca despliegues onOperatorDebug() activo: si quieres trazas siempre encendidas en producción, para eso existe exactamente ReactorDebugAgent.
hide
fun <T> Flux<T>.hide(): Flux<T>hide envuelve la secuencia de modo que tanto la clase concreta del Flux como su Subscription quedan anonimizadas. Los operadores aguas abajo que optimizan reconociendo a su upstream — chequeos instanceof de fuentes escalares como Flux.just, negociación Fuseable de colas compartidas — no encuentran nada que reconocer y caen al protocolo Publisher de propósito general. Cada señal (valores, error, completado) se reenvía exactamente como está.
- Probar operadores propios contra la ruta general (no el atajo escalar/fusionado)
- Reproducir o descartar bugs sospechosos de estar relacionados con la fusión
- Fronteras de API: devuelve flux.hide() para que quien llama no haga downcast a tipos internos
- Simular un Publisher 'plano' donde Flux.just tomaría atajos optimizados
hide no es una barrera de corrección: desactiva optimizaciones pero no agrega thread-safety, ni backpressure, ni cumplimiento del protocolo. Cada llamada crea un wrapper (barato, no gratis), y en lógica de negocio casi siempre es una mala señal — su casa son los tests, los benchmarks y las fronteras de librerías.
Flux.just(5, 6)
.hide()
.map { it + 1 }
.subscribe { println(it) }🧠 Reto: predice la salida
0/1 · 0/1 answered1. ¿Qué imprime esto?
hide() es el operador más pequeño de esta categoría: reenvía cada señal sin cambios pero oculta la identidad concreta de su fuente. ¿Por qué alguien querría MENOS información? Porque Reactor mismo inspecciona identidades para optimizar: cuando los operadores reconocen a sus vecinos — un Flux.just aquí, una cola capaz de fusión allá — toman atajos (fusión macro y micro) que se saltan las rutas de código de propósito general. hide() rompe ese reconocimiento a propósito, ocultando tanto la clase del Flux como su Subscription, y fuerza el protocolo Publisher plano, sin optimizar.
Eso lo vuelve una herramienta para tests y diagnóstico, no para lógica de negocio. ¿Probando un operador propio? Flux.just(x) dispararía el atajo escalar y se saltaría justo la ruta de código bajo prueba — Flux.just(x).hide() ejercita la general. ¿Persiguiendo un bug que sospechas de fusión? Ocultar la fuente es la forma más rápida de confirmar o descartar la fusión. ¿Diseñando la API de una librería? Devolver flux.hide() evita que quien llama haga downcast a tus tipos internos y dependa de ellos.
import reactor.core.publisher.Flux
fun main() {
Flux.just("sensor-a: 21.5", "sensor-b: 22.1", "sensor-c: 19.8")
.hide() // downstream now sees an anonymous Flux
.map { it.uppercase() }
.subscribe(
{ println(it) },
{ e -> println("error: $e") },
{ println("complete") }
)
}Tenemos tres lecturas de sensores fluyendo por hide() y luego map. La salida es byte por byte lo que imprimiría el mismo pipeline sin hide — todo su efecto vive en lo que los operadores aguas abajo pueden DESCUBRIR sobre su upstream, nunca en las señales mismas. En concreto: map ya no puede reconocer la fuente respaldada por un arreglo detrás suyo y negociar un atajo fusionado; solo ve un Flux anónimo y le habla Publisher puro.
SENSOR-A: 21.5 SENSOR-B: 22.1 SENSOR-C: 19.8 complete
Si ves hide() en lógica de negocio, tómalo como una mala señal — existe para tests, benchmarks y fronteras de API. Tampoco es una barrera de corrección: desactiva optimizaciones, no agrega thread-safety ni backpressure. Y cada llamada crea un wrapper, así que es barato pero no gratis.