A subscription delivers events to a handler as they are appended, picking up
where it left off across restarts. Kiroku subscriptions are in-process: they
spawn a worker thread, catch up from a durable checkpoint, then switch to
live delivery driven by PostgreSQL NOTIFY. This guide covers the
MonadIO API, delivery semantics, overflow policy, the effectful API, and
the Streamly bridge. To drive subscriptions from the Shibuya framework, see
Shibuya Adapter.
subscribe ::
(MonadIO m) => KirokuStore -> SubscriptionConfig -> m SubscriptionHandle
withSubscription ::
(MonadUnliftIO m) =>
KirokuStore -> SubscriptionConfig -> (SubscriptionHandle -> m a) -> m aBuild a config with defaultSubscriptionConfig and run it with
withSubscription, which cancels the worker on either normal exit or an
exception:
{-# LANGUAGE OverloadedStrings #-}
import Control.Lens ((^.))
import Kiroku.Store
import Kiroku.Store.Subscription
runProjection :: KirokuStore -> IO ()
runProjection store = do
let cfg =
defaultSubscriptionConfig
(SubscriptionName "inventory-projection")
AllStreams
handler
withSubscription store cfg $ \h -> do
result <- wait h -- block until Stop, cancel, or failure
print result
where
handler :: RecordedEvent -> IO SubscriptionResult
handler event = do
apply (event ^. #payload) -- your projection update
pure ContinuePrefer withSubscription for any non-trivial path. The bare subscribe
returns a SubscriptionHandle whose worker thread runs until you cancel it
or the handler returns Stop; forgetting to cancel leaks the thread.
The handler returns SubscriptionResult:
Continue— process the next event.Stop— stop the subscription gracefully; the checkpoint is saved at this event andwaitresolves withRight ().Retry delay— redeliver this same event afterdelay, before the checkpoint advances past it. Redelivery is bounded by the subscription'sretryPolicy(default five attempts); on exhaustion the worker dead-letters the event and moves on. See Per-Event Retry And Dead-Letter.DeadLetter reason— record this event in thekiroku.dead_letterstable withreasonand atomically advance the checkpoint past it, then continue with the next event.
SubscriptionConfig (built by defaultSubscriptionConfig name target handler):
| Field | Default | Meaning |
|---|---|---|
name :: SubscriptionName |
(required) | Stable name. This is the checkpoint key in the subscriptions table; reuse the same name across restarts to resume. |
target :: SubscriptionTarget |
(required) | AllStreams or Category categoryName. |
handler |
(required) | RecordedEvent -> m SubscriptionResult, invoked once per event in order. |
batchSize :: Int32 |
100 |
Events fetched per database round-trip during catch-up. |
queueCapacity :: Natural |
16 |
Maximum number of batches the publisher may enqueue for this subscriber before applying the overflow policy. Effective event capacity is queueCapacity * publisherBatchSize. |
overflowPolicy :: OverflowPolicy |
PauseAndResume |
What the publisher does when this subscriber's queue is full (see below). |
retryPolicy :: RetryPolicy |
defaultRetryPolicy (5 attempts) |
Bounds how many times an event for which the handler returned Retry is redelivered before it is dead-lettered. Handlers that never return Retry are unaffected. |
eventTypeFilter :: EventTypeFilter |
AllEventTypes |
Restrict delivery to chosen event types. See Event-Type Filtering. |
selector :: Maybe (RecordedEvent -> Bool) |
Nothing |
Optional opaque per-event predicate, the escape hatch for filtering the type set cannot express (e.g. metadata). Composed with eventTypeFilter as a logical AND. See Event-Type Filtering. |
consumerGroup :: Maybe ConsumerGroup |
Nothing |
Run this worker as one member of a hash-partitioned group. See Consumer Groups. |
SubscriptionTarget is AllStreams (every event in global position order)
or Category !CategoryName (events whose source stream's name prefix matches
the category). Category subscriptions still use $all global positions as
their cursor.
The worker thread:
- Reads the saved checkpoint for
name, or starts at global position 0 for a fresh name. - Catches up by querying the database directly in
batchSizepages until it reaches the publisher's last-published cursor. - Switches to live mode. For
AllStreamsit reads pre-broadcast events from the publisher's bounded per-subscriber queue. For ordinaryCategorysubscriptions it bypasses the broadcast and re-queries the database when that category receives aNOTIFYsignal, with a 30-second safety poll for missed notifications. Consumer-group members use a DB-driven live loop gated by the publisher's global cursor because their partition predicate is computed in SQL.
Live wake-ups are driven by PostgreSQL LISTEN/NOTIFY; a 30-second safety
poll covers any missed notification. Transient database errors during catch-up
or DB-driven live fetches are surfaced through KirokuEventSubscriptionDbError
and retried at the same cursor with capped backoff. The checkpoint advances per
batch (see below).
Events are delivered at least once. The checkpoint is saved per
batch, not per event: when the handler returns Continue for every event
in a batch, the checkpoint is saved at the batch tail; when it returns Stop,
the checkpoint is saved at that event.
Because of this, some events replay on a subsequent subscription with the
same SubscriptionName:
- The handler returned
Continuebut the worker was cancelled or the process crashed before the checkpoint was saved. - The worker was interrupted mid-batch; already-processed events re-deliver alongside the not-yet-processed ones.
- A transient publisher pool error caused a re-fetch and re-broadcast.
Handlers must be idempotent — process a duplicate without producing a wrong-on-replay result — or tolerate duplicates by a domain check (e.g. a unique key on the projection table).
The worker is driven by an explicit finite state machine: at any instant it is
in exactly one named SubscriptionState, readable through the handle's
currentState :: m (Maybe SubscriptionState) accessor (a point-in-time
observability read resolved through the store's central subscription-state
registry):
| State | Meaning |
|---|---|
CatchingUp |
Reading history directly from the database until the cursor reaches the publisher's last-published position. |
Live |
Caught up; waiting for new events via NOTIFY / the publisher queue. |
Paused |
Recoverable backpressure: the queue filled under PauseAndResume; the worker drains it and re-catches-up from its checkpoint (see Overflow Policy). |
Reconnecting |
A Category / consumer-group worker lost its database connection on a live fetch; it backs off and re-catches-up from its checkpoint instead of dying. |
Retrying |
Redelivering a single event for which the handler returned Retry, bounded by retryPolicy. |
Stopped |
Terminal (handler Stop, cancellation, overflow, or crash). |
currentState returns Just s while the worker is live and still owns its
registry entry, and Nothing once the subscription is not currently live —
it has stopped, been cancelled, crashed, was never started, or has been
superseded by a newer worker registered under the same (name, member) key.
Note that Stopped is never observed through currentState: a not-live
subscription is represented by Nothing ("stopped = absent"), the same rule the
registry snapshot follows. For the aggregate state of every live
subscription at once — without holding individual handles — read
subscriptionStates store, which returns a snapshot map of SubscriptionStateView
records (name, member, live state, a stable statePhase label, and the FSM
cursor position); see Observability. For the stream of
past transitions (including the terminal stop reason), wire the KirokuEvent
lifecycle events instead.
SubscriptionHandle carries cancel :: m (),
wait :: m (Either SomeException ()), and currentState (above). wait
resolves with one of:
| Result | Meaning |
|---|---|
Right () |
The handler returned Stop; the worker exited cleanly, checkpoint saved at that event. |
Left AsyncCancelled |
The caller invoked cancel. No checkpoint advance is guaranteed; in-flight events replay on the next start. |
Left SubscriptionOverflowed |
The publisher dropped this subscriber under the (non-default) DropSubscription policy. Under the default PauseAndResume the worker recovers rather than failing. |
Left e (any other) |
The handler threw. Exceptions are not caught — the worker thread dies and the exception propagates. A throwing handler signals the subscription cannot proceed safely. |
When a subscriber's bounded queue fills (the handler is slower than the
append rate), the publisher applies the overflowPolicy:
PauseAndResume(default) — recoverable backpressure, lossless. The publisher marks the subscriberPausedand stops pushing (it does not drop). The worker enters thePausedstate, drains the stale queue, and re-catches-up from its checkpoint, re-reading the events it missed directly from the database. No event is lost, the checkpoint stays monotonic, and other subscribers are unaffected — a transient slowdown pauses and recovers instead of killing the subscription.DropSubscription— mark the subscription overflowed; the worker surfacesSubscriptionOverflowedthroughwaitand terminates. Choose this when a slow consumer should be a hard, fail-fast error rather than a silent pause. Investigate the slow handler, raisequeueCapacity, or switch policy.DropOldest— drop the oldest queued batch and enqueue the new one. The subscription continues but loses events. Choose only for telemetry-style consumers where at-least-once is not required.
Beyond Continue / Stop, a handler can dispose of a single problematic event
without blocking the whole subscription:
Retry delayredelivers the same event afterdelay. The worker keeps the checkpoint pinned at that event and counts attempts; onceretryPolicy.retryMaxAttempts(default5) is reached, the worker dead-letters the event and advances. While retrying,currentStatereportsRetrying.DeadLetter reasonrecords the event in thekiroku.dead_letterstable and atomically advances the checkpoint past it in the same statement, so the subscription never stalls on a poison event. The event itself stays immutable inkiroku.events; the dead-letter row references it byevent_idandglobal_position. See Schema for the table.
These dispositions also back the Shibuya adapter's AckRetry / AckDeadLetter
decisions — see Shibuya Adapter.
Set eventTypeFilter to deliver only the event types a subscription cares
about:
import qualified Data.Set as Set
import Kiroku.Store.Subscription (EventTypeFilter (..))
let cfg =
(defaultSubscriptionConfig name AllStreams handler)
{ eventTypeFilter = OnlyEventTypes (Set.fromList [EventType "OrderPlaced"]) }AllEventTypes (the default) delivers everything. OnlyEventTypes s delivers
only events whose type is in s; filtered-out events still advance the
checkpoint (the worker moves its cursor past them) so a highly selective
subscription never stalls on a long run of non-matching events. The filter is
applied worker-side, before the handler, so a filtered-out event never
reaches the handler and is never retried or dead-lettered.
For filtering the type set cannot express — for example on metadata or stream
name during a one-off catch-up reprocess — set the opaque selector :: Maybe (RecordedEvent -> Bool). It composes with eventTypeFilter as a logical
AND (an event must pass both) and obeys the same no-stall / never-retried
guarantees. Prefer eventTypeFilter for the steady-state case: a closed
Set EventType is introspectable, Eq/Show-able, and can be pushed into SQL,
which an opaque closure cannot.
Kiroku.Store.Subscription.Effect exposes a Subscription effect whose
handler runs in the caller's Eff stack, so it can use any effects in
scope (state, reader, logging):
import Kiroku.Store.Subscription.Effect (Subscription, subscribe, withSubscription)The Subscription effect and its interpreters (runSubscription store,
runSubscriptionResource) are re-exported from Kiroku.Store, but the
effectful subscribe/withSubscription wrappers are not — that would
clash with the MonadIO subscribe. Import the Effect module explicitly.
The interpreter runs the handler with a ConcUnlift Persistent (Limited 1)
unlift strategy: handler calls are single-threaded (one in flight at a time)
and the effect environment outlives them, so State/Reader contents stay
consistent across the whole subscription. The same at-least-once,
per-batch-checkpoint semantics apply. As with the MonadIO API, the
Eff-based subscribe captures the caller's effect environment in the worker
thread — prefer the effectful withSubscription so a leaking thread cannot
outlive a torn-down environment.
subscriptionStream turns a subscription into a pull-based Streamly stream,
with a bounded TBQueue providing backpressure:
import Kiroku.Store.Subscription.Stream (subscriptionStream)
(stream, cancelAction) <- subscriptionStream store cfg 256It returns (Stream IO RecordedEvent, IO ()). The handler field in the
config is ignored — the bridge installs its own. The returned cancel action
stops the subscription and unblocks any reader waiting on the queue.
subscriptionStream checkpoints independently of the downstream consumer (the
bridge handler returns Continue as soon as it enqueues). For per-event
acknowledgement — where the checkpoint must not advance until the consumer has
processed the event, and the consumer can ask for Retry / DeadLetter — use
the ack-coupled variant subscriptionAckStream, which emits AckItem
values each carrying a one-shot reply variable. This is the mechanism the
Shibuya Adapter builds on so a Shibuya AckRetry /
AckDeadLetter drives a real Kiroku disposition.
- Shibuya Adapter — supervised multi-subscription processing.
- Observability — subscription lifecycle and error
events (
KirokuEvent). - Reading Events — one-shot reads versus continuous delivery.
- Consumer Groups — horizontal scaling with hash-partitioned members, per-member checkpoints, and the resize procedure.