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}