Reactive Subjects (Push)¶
A subject is a producer-driven, push-based event source: values arrive when
something calls subject-emit, and every subscriber is notified synchronously,
in subscription order, before subject-emit returns. There is no thread pool,
no scheduler, and no laziness — a subject is fully deterministic and
thread-free, which makes it a good fit for event-driven workflows where you
want to react to values as they happen rather than pull them on demand.
This is the push-side counterpart to the pull-based flow-stream covered in
Streams (Pull). A stream is consumer-driven — nothing happens
until something asks for the next element — while a subject is
producer-driven: emission is the only thing that moves values through the
graph. Most subject operators have a stream operator with matching semantics,
just flipped from "pull the next value" to "react to the next emission"; this
page frames each one as the push-side dual of its stream counterpart where one
exists. See Core Concepts for the library's other
primitives, and the Public API Reference for the full
exported symbol list (Reactive subject APIs and Reactive operator APIs).
What a subject is¶
make-subject creates a fresh subject with no subscribers. subject-p tests
whether a value is one:
A subject is opaque — there is no reader for its internal subscriber list.
Everything else in this page (subject-subscribe, subject-emit, and the
derived/stateful operators) is the public surface for building and driving
one. Subjects are also outside the flow-name/flow-metadata/flow-kind
introspection protocol in Observability, which signals a
type-error for them just as it does for streams; subject-subscriber-count
is the only built-in introspection a subject offers.
Subscription lifecycle¶
subject-subscribe registers a function of one argument (the emitted value)
as a subscriber, after any existing subscribers, and returns that function —
which doubles as an unsubscribe token:
(defparameter *seen* '())
(defparameter *handler*
(cl-dataflow:subject-subscribe
*orders*
(lambda (value) (push value *seen*))))
(cl-dataflow:subject-subscriber-count *orders*)
;; => 1
subject-unsubscribe removes a function from a subject's subscribers (every
occurrence of it, if it was registered more than once) and returns the
subject:
(cl-dataflow:subject-unsubscribe *orders* *handler*)
(cl-dataflow:subject-subscriber-count *orders*)
;; => 0
subject-subscriber-count is a plain count of currently registered
subscribers — handy for asserting that setup/teardown wiring behaved as
expected.
Emission¶
subject-emit pushes a value to every subscriber present when the call
starts, synchronously and in the order they subscribed, then returns the
subject:
(cl-dataflow:subject-subscribe *orders* (lambda (value) (push value *seen*)))
(cl-dataflow:subject-emit *orders* 42)
*seen*
;; => (42)
The set of subscribers notified by a given subject-emit call is a
snapshot taken at the start of that call. If a subscriber function itself
subscribes or unsubscribes another handler (or the same one) while it runs,
that change is visible to reentrant and later emissions, but never disturbs
the emission currently in progress — the pass that already started keeps
notifying exactly the subscribers it started with.
There is no consumer-driven equivalent of subject-emit on the stream side:
a stream only produces a value when something pulls it. A subject inverts
that — nothing is pulled, everything is pushed the moment subject-emit
runs.
Derived subjects¶
subject-map and subject-filter build a new subject that subscribes to a
source subject and re-emits a transformed view of it — the push duals of
stream-map and stream-filter:
(defparameter *priced* (cl-dataflow:make-subject))
(defparameter *doubled* (cl-dataflow:subject-map *priced* (lambda (v) (* v 2))))
(defparameter *big* (cl-dataflow:subject-filter *priced* (lambda (v) (> v 50))))
(cl-dataflow:subject-subscribe *doubled* (lambda (v) (format t "~&doubled: ~D~%" v)))
(cl-dataflow:subject-subscribe *big* (lambda (v) (format t "~&big: ~D~%" v)))
(cl-dataflow:subject-emit *priced* 30)
;; prints: doubled: 60
(cl-dataflow:subject-emit *priced* 70)
;; prints: doubled: 140
;; big: 70
subject-merge takes any number of source subjects and returns one derived
subject that emits whenever any of them emits — the push dual of
stream-concat, though unlike concatenation there is no ordering by source:
whichever source subject emits first drives the next value through:
(defparameter *web-orders* (cl-dataflow:make-subject))
(defparameter *phone-orders* (cl-dataflow:make-subject))
(defparameter *all-orders* (cl-dataflow:subject-merge *web-orders* *phone-orders*))
Because derived subjects are themselves ordinary subjects, they compose:
(subject-filter (subject-map source #'f) #'p) is a small reactive pipeline
built out of two subscriptions.
Inspecting emissions¶
subject-collect subscribes a collector to a subject and returns a function
of no arguments that yields every value the subject has emitted since, in
emission order:
(defparameter *alerts* (cl-dataflow:subject-collect *big*))
(cl-dataflow:subject-emit *priced* 10)
(cl-dataflow:subject-emit *priced* 99)
(funcall *alerts*)
;; => (99)
Note the "since": the collector only sees emissions that happen after it
subscribes. The 70 that *big* emitted earlier on this page is already
gone — nothing buffers it — and 10 never reaches *big* at all because
the filter rejects it. Subjects have no replay, so attach collectors and
subscribers before the emissions you care about.
:limit bounds how many values are retained, and :on-limit controls what
happens once the limit is reached — :error (the default) signals from the
subject-emit call that would exceed it, :drop-newest silently ignores
values past the limit instead. This mirrors the limit/on-limit keywords
that bound stream-collect and friends on the pull side.
Stateful and combining operators¶
src/reactive-ops.lisp layers stateful, single-source operators and
multi-source combinators on top of the core subject API, bringing the push
side to parity with the pull-stream vocabulary. Each one returns a fresh
derived subject; the operator's own state (an accumulator, a remaining count,
a queue, ...) lives in a closure private to that derived subject.
| Subject operator | Push semantics | Stream analog |
|---|---|---|
subject-scan |
Emits a running accumulation: from seed, each value produces (funcall function accumulator value). The seed itself is never emitted, so N source values produce N emissions. |
stream-scan (which does yield the seed first, so N elements in produce N+1 out) |
subject-distinct |
Re-emits only the first occurrence of each value (per :test). |
stream-distinct |
subject-tap |
Calls a function on each value for its side effect, then re-emits it unchanged. | stream-tap |
subject-take |
Re-emits only the first n values, then emits nothing further. |
stream-take |
subject-drop |
Ignores the first n values, then re-emits the rest. |
stream-drop |
subject-take-while |
Re-emits the leading run satisfying a predicate; permanently stops at the first failure. | stream-take-while |
subject-drop-while |
Drops the leading run satisfying a predicate, then re-emits every value from the first failure onward. | stream-drop-while |
subject-count |
Emits the running emission count (1, 2, 3, ...) as a live derived subject. | stream-count (an eager, one-shot total — not a running series) |
subject-flat-map |
For each value, calls a function to get an inner subject and forwards all of its later emissions (the flatten operator). | stream-flat-map |
subject-partition |
Returns two derived subjects, splitting emissions live by a predicate. | stream-partition (an eager, one-shot list split) |
subject-zip |
Pairs two sources in lockstep, queuing values until their counterpart arrives. | stream-zip |
subject-combine-latest |
Emits (latest-a . latest-b) whenever either source emits, once both have emitted at least once. |
no direct stream analog |
subject-buffer |
Collects every n values into a list and emits that list; a trailing partial buffer is never emitted. n must be positive. |
stream-chunk/stream-window-style batching |
A few operators are worth calling out in more detail:
-
subject-take/subject-drop,subject-take-while/subject-drop-while. The take variants latch permanently: oncesubject-take's count is exhausted orsubject-take-while's predicate first fails, that derived subject never emits again, even if the source keeps emitting.subject-drop/subject-drop-whileare the complementary halves — they swallow a leading run and then pass everything through indefinitely. -
subject-count. Unlikestream-count, which eagerly drains a whole stream once and returns a single number,subject-countis a live derived subject: it emits a new running total after every single source emission, so subscribers see 1, then 2, then 3, and so on. -
subject-flat-map. The higher-order (flatten) operator: the supplied function is called with each source value and must return an inner subject; every later emission of that inner subject is forwarded to the derived subject. It only forwards values emitted by the inner subject after it is created — anything the inner subject already emitted before being returned is missed, the same "subscribe first" caveat that applies to any subject. -
subject-partition. Returns two values,(values matching non-matching), each a subject in its own right: every source value is emitted onmatchingwhen the predicate holds, and onnon-matchingotherwise. This is a live, ongoing split — contrast withstream-partition, which drains its whole input once and returns two plain lists. -
subject-zip. Values that arrive on one source before their counterpart arrives on the other are queued rather than dropped, so a burst on one side and a trickle on the other still pair up correctly once both have caught up. -
subject-combine-latest. Has no direct stream-side analog, since a pull stream has no notion of "whichever source is pulled next" — it always emits the most recent value from each source, combined, triggered by either source's next emission (once both have emitted at least once).
Examples¶
Filtering and collecting high-value alerts¶
This is the reactive half of the order-processing scenario in
examples/integration.lisp: a subject of priced orders, filtered down to
high-value ones, and collected for later inspection.
(let* ((orders (cl-dataflow:make-subject))
(alerts (cl-dataflow:subject-collect
(cl-dataflow:subject-filter orders (lambda (v) (> v 50))))))
(dolist (value '(30 70 12 120))
(cl-dataflow:subject-emit orders value))
(funcall alerts))
;; => (70 120)
A running total with subject-scan¶
(let* ((deposits (cl-dataflow:make-subject))
(running-total (cl-dataflow:subject-scan deposits #'+ 0))
(balances (cl-dataflow:subject-collect running-total)))
(dolist (amount '(10 5 20))
(cl-dataflow:subject-emit deposits amount))
(funcall balances))
;; => (10 15 35)
Combining two sources with subject-combine-latest¶
(let* ((price (cl-dataflow:make-subject))
(quantity (cl-dataflow:make-subject))
(totals (cl-dataflow:subject-combine-latest price quantity))
(seen (cl-dataflow:subject-collect totals)))
(cl-dataflow:subject-emit price 10) ; quantity has no value yet: no emission
(cl-dataflow:subject-emit quantity 3) ; both have a value now: emits (10 . 3)
(cl-dataflow:subject-emit price 12) ; re-emits with the latest quantity: (12 . 3)
(funcall seen))
;; => ((10 . 3) (12 . 3))
Choosing push versus pull¶
Reach for a subject when values originate from an external event you don't
control the timing of — user input, a queue callback, a sensor reading — and
you want every interested party notified the moment it happens. Reach for a
stream when you are transforming a known or generatable
sequence and want to control exactly when (and how much of) it gets
consumed, including infinite generators that a subject has no equivalent
for. The two are not mutually exclusive: examples/integration.lisp uses a
flow-stream for offline analytics over a finished batch of results in the
same run that uses a subject for live, event-driven alerts.