Skip to main content

cala_ledger/
ec_rollup.rs

1//! Streaming rollup of eventually-consistent (EC) account-set balances.
2//!
3//! A single long-lived outbox event-handler job consumes the obix outbox
4//! in `sequence` order and rolls each committed transaction's leaf-entry
5//! deltas up into its ancestor **EC** account sets — incrementally and
6//! bounded. This replaces the periodic pull/batch
7//! `recalculate_balances_deep` as the steady-state mechanism (which could
8//! OOM a Postgres backend by replaying a whole set's history in one
9//! transaction). Work here is proportional to *new* activity and every
10//! commit is size-bounded.
11//!
12//! ## Shape
13//!
14//! Built on obix's managed [`OutboxEventHandler`] batching runner:
15//! `TransactionCreated` and `EntryCreated` events are collected into the
16//! pending batch (pure memory writes — no transaction per event),
17//! everything else is skipped. When the batch lands the runner calls
18//! [`flush`](OutboxEventHandler::flush) once, **inside the transaction
19//! that commits the checkpoint** — the rollup writes and the stream
20//! position land atomically. Entries are applied straight from the
21//! stream when a transaction's whole event group landed in the batch
22//! (verified against `TransactionValues::entry_ids`), with a DB read
23//! through the flush op as the fallback.
24//!
25//! ## Correctness
26//!
27//! - **Exactly-once DB effect.** The applier *adds* deltas (it is not
28//!   idempotent), so it must never re-run for an already-applied event.
29//!   The runner guarantees this: flushed items and the checkpoint commit
30//!   in one transaction, so a mid-batch crash rolls back both and replay
31//!   re-collects only unapplied events.
32//! - **Single writer.** Registered via `register_event_handler` (a
33//!   *resident* job underneath), so exactly one instance runs
34//!   cluster-wide — no streaming-vs-streaming contention.
35//! - **Sole EC-set writer.** There is no separate pull/batch recalc to
36//!   compose with — this job is the only maintainer of EC-set balances.
37//!   The applier takes the shared EC-set advisory lock on the sets it
38//!   writes (matching the poster lock discipline), but being the only
39//!   EC-set writer it needs no coordination with posters (which never
40//!   write EC-set balances).
41//! - **No membership trigger.** A member can only join/leave an EC set
42//!   while it has no balance history (`MemberHasBalanceHistory`), so
43//!   membership carries no balance to seed/unfold — the live closure alone
44//!   routes future entries.
45
46use chrono::{DateTime, NaiveDate, Utc};
47
48use std::collections::{HashMap, HashSet};
49
50use job::{JobType, Jobs};
51use obix::{
52    out::{
53        EventCtx, EventSubscription, FlushOp, Handled, HandlerStreamStatus, OutboxEventHandler,
54        OutboxEventJobConfig, PersistentOutboxEvent, RegisteredEventHandler,
55    },
56    EventSequence,
57};
58
59use cala_types::entry::EntryValues;
60
61use crate::{
62    balance::{Balances, EcRollupTxn},
63    entry::{Entries, Entry},
64    ledger::error::LedgerError,
65    outbox::{CalaMailboxTables, ObixOutbox, OutboxEventPayload},
66    primitives::{EntryId, JournalId, TransactionId},
67};
68
69const EC_BALANCE_ROLLUP_JOB: JobType = JobType::new("cala.ec_balance_rollup");
70
71/// Maximum number of collected events (transactions + their entries)
72/// folded into a single commit. Bounds per-transaction memory/WAL/lock
73/// hold-time. The per-statement insert is additionally sub-chunked inside
74/// `insert_new_snapshots`.
75const MAX_EVENTS_PER_BATCH: usize = 1_000;
76
77/// Register the streaming EC-balance rollup and spawn its single instance.
78///
79/// Must be called **before** [`Jobs::start_poll`]
80/// (`add_resident_initializer` panics once polling has started).
81/// Idempotent via the resident-job spawn. The returned handle is the
82/// ledger's observation point on the rollup.
83pub(crate) async fn register_ec_balance_rollup(
84    jobs: &mut Jobs,
85    outbox: &ObixOutbox,
86    balances: &Balances,
87    entries: &Entries,
88) -> Result<RegisteredEventHandler<OutboxEventPayload, CalaMailboxTables>, LedgerError> {
89    Ok(outbox
90        .register_event_handler(
91            jobs,
92            OutboxEventJobConfig::new(EC_BALANCE_ROLLUP_JOB)
93                .with_max_batch_size(MAX_EVENTS_PER_BATCH),
94            EcBalanceRollupHandler {
95                balances: balances.clone(),
96                entries: entries.clone(),
97            },
98        )
99        .await?)
100}
101
102/// A transaction pulled from a `TransactionCreated` event, carrying just
103/// what the rollup needs. `entry_ids` is the complete expected entry set,
104/// which is what makes stream-collected entries verifiable (see
105/// [`EcRollupBatch`]).
106struct PendingTx {
107    id: TransactionId,
108    journal_id: JournalId,
109    effective: NaiveDate,
110    created_at: DateTime<Utc>,
111    entry_ids: Vec<EntryId>,
112}
113
114/// One batch landing's accumulator.
115///
116/// Entries are collected best-effort from the `EntryCreated` events that
117/// share the landing with their transaction. A transaction's event group
118/// is *not* guaranteed to land whole: the runner counts events (not
119/// groups) against `max_batch_size`, and concurrent postings interleave
120/// sequences — so a group can straddle two landings. `PendingTx::entry_ids`
121/// makes completeness decidable per transaction at flush time; incomplete
122/// groups fall back to a DB read (the entries committed atomically with
123/// the `TransactionCreated` event, so they are always visible). Straggler
124/// entries whose transaction flushed in an earlier landing are simply
125/// dropped — their data is durable in the ledger and was already applied
126/// via that landing's fallback read.
127#[derive(Default)]
128struct EcRollupBatch {
129    txns: Vec<PendingTx>,
130    entries: HashMap<TransactionId, Vec<EntryValues>>,
131}
132
133impl EcRollupBatch {
134    fn push_tx(&mut self, tx: PendingTx) {
135        self.txns.push(tx);
136    }
137
138    fn push_entry(&mut self, entry: EntryValues) {
139        self.entries
140            .entry(entry.transaction_id)
141            .or_default()
142            .push(entry);
143    }
144
145    /// Entry ids that were *not* collected from the stream in this landing
146    /// (their event group straddled a landing boundary) — the ones the
147    /// flush must load from the DB.
148    fn missing_entry_ids(&self) -> Vec<EntryId> {
149        self.txns
150            .iter()
151            .flat_map(|tx| {
152                let collected: HashSet<EntryId> = self
153                    .entries
154                    .get(&tx.id)
155                    .map(|entries| entries.iter().map(|e| e.id).collect())
156                    .unwrap_or_default();
157                tx.entry_ids
158                    .iter()
159                    .copied()
160                    .filter(move |id| !collected.contains(id))
161            })
162            .collect()
163    }
164
165    /// Assemble the applier's input in landing order: each transaction's
166    /// stream-collected entries, topped up from the DB-`fetched` map where
167    /// the group straddled a landing boundary, sorted by entry sequence.
168    fn into_rollup_txns(self, mut fetched: HashMap<EntryId, Entry>) -> Vec<EcRollupTxn> {
169        let EcRollupBatch { txns, mut entries } = self;
170        txns.into_iter()
171            .map(|tx| {
172                let mut entry_values = entries.remove(&tx.id).unwrap_or_default();
173                if entry_values.len() != tx.entry_ids.len() {
174                    entry_values.extend(
175                        tx.entry_ids
176                            .iter()
177                            .filter_map(|id| fetched.remove(id))
178                            .map(Entry::into_values),
179                    );
180                }
181                entry_values.sort_by_key(|e| e.sequence);
182
183                EcRollupTxn {
184                    journal_id: tx.journal_id,
185                    effective: tx.effective,
186                    created_at: tx.created_at,
187                    entries: entry_values,
188                }
189            })
190            .collect()
191    }
192}
193
194struct EcBalanceRollupHandler {
195    balances: Balances,
196    entries: Entries,
197}
198
199impl OutboxEventHandler<OutboxEventPayload> for EcBalanceRollupHandler {
200    const SUBSCRIPTION: EventSubscription = EventSubscription::PersistentOnly;
201
202    type Batch = EcRollupBatch;
203
204    async fn handle_persistent<'inv>(
205        &self,
206        ctx: EventCtx<'inv, Self::Batch>,
207        event: &PersistentOutboxEvent<OutboxEventPayload>,
208    ) -> Result<Handled<'inv>, Box<dyn std::error::Error + Send + Sync>> {
209        match &event.payload {
210            Some(OutboxEventPayload::TransactionCreated { transaction }) => {
211                let tx = PendingTx {
212                    id: transaction.id,
213                    journal_id: transaction.journal_id,
214                    effective: transaction.effective,
215                    created_at: transaction.created_at,
216                    entry_ids: transaction.entry_ids.clone(),
217                };
218                Ok(ctx.collect_with(|batch| batch.push_tx(tx)))
219            }
220            Some(OutboxEventPayload::EntryCreated { entry }) => {
221                let entry = entry.clone();
222                Ok(ctx.collect_with(|batch| batch.push_entry(entry)))
223            }
224            _ => Ok(ctx.skip()),
225        }
226    }
227
228    #[tracing::instrument(
229        name = "cala_ledger.ec_rollup.flush",
230        skip_all,
231        fields(txns_count = batch.txns.len()),
232        err(level = "warn")
233    )]
234    async fn flush(
235        &self,
236        op: &mut FlushOp<'_>,
237        batch: Self::Batch,
238    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
239        let missing_ids = batch.missing_entry_ids();
240        let fetched = if missing_ids.is_empty() {
241            HashMap::new()
242        } else {
243            self.entries.find_all_in_op(op, &missing_ids).await?
244        };
245
246        let rollup_txns = batch.into_rollup_txns(fetched);
247        self.balances.apply_ec_rollup_in_op(op, rollup_txns).await?;
248        Ok(())
249    }
250}
251
252#[cfg(feature = "fuzz")]
253mod __fuzz {
254    //! Harness for the out-of-tree `ec_rollup_batch` fuzz target. Lives in
255    //! this module so it can reach the private `EcRollupBatch`/`PendingTx`.
256    use super::*;
257    use serde::Deserialize;
258
259    #[derive(Deserialize)]
260    struct FuzzTx {
261        id: TransactionId,
262        journal_id: JournalId,
263        effective: NaiveDate,
264        created_at: DateTime<Utc>,
265        entry_ids: Vec<EntryId>,
266    }
267
268    pub fn fuzz_batch(data: &[u8]) {
269        let parts: Vec<&[u8]> = data.split(|&b| b == 0xFF).collect();
270        if parts.len() < 2 {
271            return;
272        }
273        let Ok(txs) = serde_json::from_slice::<Vec<FuzzTx>>(parts[0]) else {
274            return;
275        };
276        let Ok(entries) = serde_json::from_slice::<Vec<EntryValues>>(parts[1]) else {
277            return;
278        };
279
280        let mut batch = EcRollupBatch::default();
281        for t in &txs {
282            batch.push_tx(PendingTx {
283                id: t.id,
284                journal_id: t.journal_id,
285                effective: t.effective,
286                created_at: t.created_at,
287                entry_ids: t.entry_ids.clone(),
288            });
289        }
290        for e in &entries {
291            batch.push_entry(e.clone());
292        }
293
294        let _missing = batch.missing_entry_ids();
295        let _rollup = batch.into_rollup_txns(HashMap::<EntryId, Entry>::new());
296    }
297}
298
299#[cfg(feature = "fuzz")]
300pub use __fuzz::fuzz_batch;
301
302/// A snapshot of the rollup's position. Every outbox event with sequence ≤
303/// `applied` is folded into EC balances (settled and effective) and
304/// committed.
305///
306/// `frontier` is pinned at construction. [`refresh`](Self::refresh) advances
307/// `applied` against that same fence, so [`lag`](Self::lag) drains toward it
308/// instead of chasing a frontier that new postings keep moving.
309#[derive(Debug, Clone)]
310pub struct EcRollupStatus {
311    /// The rollup job's committed checkpoint.
312    pub applied: EventSequence,
313    /// The outbox frontier pinned when this snapshot was taken.
314    pub frontier: EventSequence,
315    handle: RegisteredEventHandler<OutboxEventPayload, CalaMailboxTables>,
316}
317
318impl EcRollupStatus {
319    pub(crate) fn new(
320        status: HandlerStreamStatus,
321        handle: RegisteredEventHandler<OutboxEventPayload, CalaMailboxTables>,
322    ) -> Self {
323        Self {
324            applied: status.checkpoint,
325            frontier: status.frontier,
326            handle,
327        }
328    }
329
330    /// Re-read the committed checkpoint, keeping the pinned `frontier`, so
331    /// repeated calls watch the lag drain toward the fence this snapshot
332    /// captured.
333    #[tracing::instrument(
334        level = "debug",
335        name = "cala_ledger.ec_rollup_status.refresh",
336        skip_all,
337        fields(frontier = %self.frontier, applied, lag)
338    )]
339    pub async fn refresh(&mut self) -> Result<(), LedgerError> {
340        self.applied = self.handle.load().await?.checkpoint();
341
342        let span = tracing::Span::current();
343        span.record("applied", u64::from(self.applied));
344        span.record("lag", self.lag());
345
346        Ok(())
347    }
348
349    /// Await the rollup applying everything up to this snapshot's pinned
350    /// `frontier`.
351    ///
352    /// On `Ok(())` every posting that had been assigned an outbox sequence
353    /// when the snapshot was taken — committed or still in flight — is folded
354    /// into EC balances (settled and effective) and visible to subsequent
355    /// reads.
356    ///
357    /// This is what makes `close_books(); ec_rollup_status().await?
358    /// .await_completion(..)` free of straggler holes: sequences are assigned
359    /// at entry insert, *before* velocity enforcement, so anything that saw
360    /// the period as open sits at or below the pinned frontier, and gapless
361    /// delivery means the wait covers each one. The checkpoint only *trails*
362    /// the applied state, so the fence never returns early.
363    ///
364    /// The fence does not move: unlike re-reading status, the frontier stays
365    /// where the snapshot pinned it, so a rollup publishing `BalanceUpdated`
366    /// events as it drains cannot extend its own barrier.
367    ///
368    /// `timeout` is mandatory: a wedged rollup surfaces as
369    /// [`LedgerError::EcCaughtUpTimeout`], never a silent hang.
370    pub async fn await_completion(&self, timeout: std::time::Duration) -> Result<(), LedgerError> {
371        self.handle.await_sequence(self.frontier, timeout).await?;
372        Ok(())
373    }
374
375    /// Outbox positions the rollup has yet to consume — the stream-lag SLO
376    /// metric. Counts the `BalanceUpdated` events the rollup publishes
377    /// itself and later crosses as skips, so a healthy stream can report a
378    /// small nonzero lag; alert on lag that is large or not shrinking.
379    pub fn lag(&self) -> u64 {
380        u64::from(self.frontier).saturating_sub(u64::from(self.applied))
381    }
382
383    pub fn is_caught_up(&self) -> bool {
384        self.applied >= self.frontier
385    }
386}