cala-ledger 0.22.4

An embeddable double sided accounting ledger built on PG/SQLx
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
//! Behavioural coverage for the epoch-validated set-graph cache
//! (`account_set/graph_cache.rs`).
//!
//! The cache replaces the per-posting recursive ancestor walk with an
//! in-memory expansion over a cached edge snapshot, validated per op by
//! an epoch counter read in the same statement as the direct-membership
//! probe. These tests drive every resolution path through the public
//! posting API and assert the observable outcome (ancestor balances,
//! advisory locks) is identical to the walk's:
//!
//! - cold fallback vs warm memory-path posting parity (EC / non-EC /
//!   multi-journal fixture);
//! - same-op create+attach+post (unknown-seed supplement path — the
//!   dominant posting pattern);
//! - same-op `add_member_set` + post (epoch-mismatch op-local path),
//!   then stale-cache -> refresh -> memory-path sequence;
//! - rollback of an in-op structure change must not leak into any later
//!   resolution (op-local results are never installed — poisoning guard);
//! - cross-instance staleness: a structure change committed by another
//!   `CalaLedger` on the same database is picked up via the epoch check;
//! - ancestor advisory-lock parity between the fallback and memory
//!   paths, observed via `pg_locks` while the posting op is open.
//!
//! Lock observation and the streaming rollup (a global outbox consumer)
//! both need database isolation, so every test uses
//! `helpers::init_isolated_pool`.

mod helpers;

use std::time::Duration;

use rand::distr::{Alphanumeric, SampleString};
use rust_decimal_macros::dec;

use cala_ledger::{
    account::*, account_set::NewAccountSet, primitives::BalanceRollup, tx_template::Params, *,
};

/// How long to give the cache's background snapshot refresh (triggered
/// by a previous posting's fallback resolution) to install. The refresh
/// is a single small query against a local Postgres; one second is
/// orders of magnitude above its latency.
const CACHE_WARM: Duration = Duration::from_secs(1);

async fn init_cala(pool: sqlx::PgPool) -> anyhow::Result<(CalaLedger, job::Jobs)> {
    let mut jobs = helpers::init_jobs(pool.clone()).await?;
    let cala_config = CalaLedgerConfig::builder()
        .pool(pool)
        .exec_migrations(false)
        .build()?;
    let cala = CalaLedger::init(cala_config, &mut jobs).await?;
    Ok((cala, jobs))
}

fn new_account(name: &str, rollup: BalanceRollup) -> NewAccount {
    let code = Alphanumeric.sample_string(&mut rand::rng(), 32);
    NewAccount::builder()
        .id(uuid::Uuid::now_v7())
        .name(format!("{name} {code}"))
        .code(code)
        .balance_rollup(rollup)
        .build()
        .unwrap()
}

fn new_set(journal_id: JournalId, name: &str, rollup: BalanceRollup) -> NewAccountSet {
    let code = Alphanumeric.sample_string(&mut rand::rng(), 32);
    NewAccountSet::builder()
        .id(uuid::Uuid::now_v7())
        .name(format!("{name} {code}"))
        .journal_id(journal_id)
        .balance_rollup(rollup)
        .build()
        .unwrap()
}

async fn create_template(cala: &CalaLedger) -> anyhow::Result<String> {
    let tx_code = Alphanumeric.sample_string(&mut rand::rng(), 32);
    cala.tx_templates()
        .create(helpers::simple_template_with_date_default(&tx_code))
        .await?;
    Ok(tx_code)
}

fn posting_params(
    journal_id: JournalId,
    sender: AccountId,
    recipient: AccountId,
    amount: rust_decimal::Decimal,
) -> Params {
    let mut params = Params::new();
    params.insert("journal_id", journal_id.to_string());
    params.insert("sender", sender);
    params.insert("recipient", recipient);
    params.insert("amount", amount);
    params
}

/// A depth-3 chain plus an other-journal parent:
/// `member -> s1 (sync) -> s2 (EC) -> s3 (sync)`, and `member` also
/// directly in `other_journal_set` (different journal — walked through
/// but excluded from resolution, exactly like the walk SQL).
struct Chain {
    member: AccountId,
    counter: AccountId,
    s1: AccountSetId,
    s2_ec: AccountSetId,
    s3: AccountSetId,
    other_journal_set: AccountSetId,
}

async fn build_chain(
    cala: &CalaLedger,
    journal_id: JournalId,
    other_journal_id: JournalId,
) -> anyhow::Result<Chain> {
    let member = cala
        .accounts()
        .create(new_account("member", BalanceRollup::Synchronous))
        .await?;
    let counter = cala
        .accounts()
        .create(new_account("counter", BalanceRollup::Synchronous))
        .await?;
    let s1 = cala
        .account_sets()
        .create(new_set(journal_id, "s1", BalanceRollup::Synchronous))
        .await?;
    let s2_ec = cala
        .account_sets()
        .create(new_set(
            journal_id,
            "s2 ec",
            BalanceRollup::EventuallyConsistent,
        ))
        .await?;
    let s3 = cala
        .account_sets()
        .create(new_set(journal_id, "s3", BalanceRollup::Synchronous))
        .await?;
    let other_journal_set = cala
        .account_sets()
        .create(new_set(
            other_journal_id,
            "other journal",
            BalanceRollup::Synchronous,
        ))
        .await?;

    cala.account_sets().add_member(s1.id(), member.id()).await?;
    cala.account_sets()
        .add_member(other_journal_set.id(), member.id())
        .await?;
    cala.account_sets().add_member(s2_ec.id(), s1.id()).await?;
    cala.account_sets().add_member(s3.id(), s2_ec.id()).await?;

    Ok(Chain {
        member: member.id(),
        counter: counter.id(),
        s1: s1.id(),
        s2_ec: s2_ec.id(),
        s3: s3.id(),
        other_journal_set: other_journal_set.id(),
    })
}

/// Cold (fallback-walk) and warm (in-memory) postings must produce
/// identical ancestor fan-out: synchronous ancestors inline, the EC
/// ancestor via the streaming rollup, the other-journal parent never.
#[tokio::test]
async fn warm_resolution_matches_walk_fallback() -> anyhow::Result<()> {
    let pool = helpers::init_isolated_pool().await?;
    let (cala, mut jobs) = init_cala(pool).await?;

    let journal = cala.journals().create(helpers::test_journal()).await?;
    let other_journal = cala.journals().create(helpers::test_journal()).await?;
    let tx_code = create_template(&cala).await?;
    let usd = "USD".parse::<Currency>()?;

    let chain = build_chain(&cala, journal.id(), other_journal.id()).await?;

    // Posting 1: cold cache -> op-local walk fallback (and triggers the
    // installing background refresh).
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, chain.member, dec!(7)),
    )
    .await?;
    for set_id in [chain.s1, chain.s3] {
        assert_eq!(
            cala.balances()
                .find(journal.id(), set_id, usd)
                .await?
                .settled(),
            dec!(7),
            "cold-path posting must fan into every synchronous ancestor"
        );
    }

    // Posting 2: warm cache -> in-memory expansion.
    tokio::time::sleep(CACHE_WARM).await;
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, chain.member, dec!(7)),
    )
    .await?;
    for set_id in [chain.s1, chain.s3] {
        assert_eq!(
            cala.balances()
                .find(journal.id(), set_id, usd)
                .await?
                .settled(),
            dec!(14),
            "warm-path posting must fan into the same synchronous ancestors"
        );
    }

    // The other-journal parent is excluded by both paths.
    assert!(
        cala.balances()
            .find(journal.id(), chain.other_journal_set, usd)
            .await
            .is_err(),
        "a set in another journal must never receive balance"
    );
    assert!(
        cala.balances()
            .find(other_journal.id(), chain.other_journal_set, usd)
            .await
            .is_err(),
        "no entries were posted in the other journal"
    );

    // The EC ancestor gets both postings via the streaming rollup —
    // which resolves through the same cache.
    jobs.start_poll().await?;
    helpers::wait_for_settled(&cala, journal.id(), chain.s2_ec, usd, dec!(14)).await?;

    Ok(())
}

/// A dominant posting pattern: create a set, attach a fresh account, and
/// post to it — all in one op. No epoch bump happens (account-member
/// adds don't touch the edge graph), so the warm cache takes the
/// unknown-seed supplement path and must still resolve the fresh set.
#[tokio::test]
async fn same_op_create_attach_post_resolves_fresh_set() -> anyhow::Result<()> {
    let pool = helpers::init_isolated_pool().await?;
    let (cala, _jobs) = init_cala(pool).await?;

    let journal = cala.journals().create(helpers::test_journal()).await?;
    let other_journal = cala.journals().create(helpers::test_journal()).await?;
    let tx_code = create_template(&cala).await?;
    let usd = "USD".parse::<Currency>()?;

    // Warm the cache with an unrelated posting.
    let chain = build_chain(&cala, journal.id(), other_journal.id()).await?;
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, chain.member, dec!(1)),
    )
    .await?;
    tokio::time::sleep(CACHE_WARM).await;

    // One op: create account + set, attach, post.
    let mut op = cala.begin_operation().await?;
    let fresh_account = cala
        .accounts()
        .create_in_op(&mut op, new_account("fresh", BalanceRollup::Synchronous))
        .await?;
    let fresh_set = cala
        .account_sets()
        .create_in_op(
            &mut op,
            new_set(journal.id(), "fresh set", BalanceRollup::Synchronous),
        )
        .await?;
    cala.account_sets()
        .add_member_in_op(&mut op, fresh_set.id(), fresh_account.id())
        .await?;
    cala.post_transaction_in_op(
        &mut op,
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, fresh_account.id(), dec!(3)),
    )
    .await?;
    op.commit().await?;

    assert_eq!(
        cala.balances()
            .find(journal.id(), fresh_set.id(), usd)
            .await?
            .settled(),
        dec!(3),
        "a set created+attached+posted in one op must receive the posting inline"
    );

    Ok(())
}

/// A same-op `add_member_set` bumps the epoch inside the op, so the
/// posting's probe sees a mismatch and resolves op-locally (observing
/// the uncommitted edge). After commit the shared cache is stale ->
/// epoch fallback -> refresh -> memory path; each posting must fan out
/// identically.
#[tokio::test]
async fn same_op_add_member_set_then_stale_then_warm() -> anyhow::Result<()> {
    let pool = helpers::init_isolated_pool().await?;
    let (cala, _jobs) = init_cala(pool).await?;

    let journal = cala.journals().create(helpers::test_journal()).await?;
    let other_journal = cala.journals().create(helpers::test_journal()).await?;
    let tx_code = create_template(&cala).await?;
    let usd = "USD".parse::<Currency>()?;

    let chain = build_chain(&cala, journal.id(), other_journal.id()).await?;

    // Fresh subtree with no history (the freeze guard only fences
    // members that already have activity): account b -> set t.
    let b = cala
        .accounts()
        .create(new_account("b", BalanceRollup::Synchronous))
        .await?;
    let t = cala
        .account_sets()
        .create(new_set(journal.id(), "t", BalanceRollup::Synchronous))
        .await?;
    cala.account_sets().add_member(t.id(), b.id()).await?;

    // Warm the cache (t and its membership become part of the snapshot).
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, chain.member, dec!(1)),
    )
    .await?;
    tokio::time::sleep(CACHE_WARM).await;

    // One op: graft t under s1 (epoch bump, uncommitted) + post to b.
    // The op-local resolution must already see b's new ancestors.
    let mut op = cala.begin_operation().await?;
    cala.account_sets()
        .add_member_in_op(&mut op, chain.s1, t.id())
        .await?;
    cala.post_transaction_in_op(
        &mut op,
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, b.id(), dec!(5)),
    )
    .await?;
    op.commit().await?;

    assert_eq!(
        cala.balances()
            .find(journal.id(), t.id(), usd)
            .await?
            .settled(),
        dec!(5)
    );
    assert_eq!(
        cala.balances()
            .find(journal.id(), chain.s1, usd)
            .await?
            .settled(),
        dec!(6),
        "the same-op posting must fan through the edge added in the same op"
    );

    // Posting 2: shared cache is stale (committed epoch moved) ->
    // epoch-mismatch fallback, still correct.
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, b.id(), dec!(5)),
    )
    .await?;
    assert_eq!(
        cala.balances()
            .find(journal.id(), chain.s1, usd)
            .await?
            .settled(),
        dec!(11)
    );

    // Posting 3: refresh installed -> memory path, same fan-out.
    tokio::time::sleep(CACHE_WARM).await;
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, b.id(), dec!(5)),
    )
    .await?;
    assert_eq!(
        cala.balances()
            .find(journal.id(), chain.s1, usd)
            .await?
            .settled(),
        dec!(16)
    );
    assert_eq!(
        cala.balances()
            .find(journal.id(), chain.s3, usd)
            .await?
            .settled(),
        dec!(16),
        "the whole chain above the grafted subtree must see every posting"
    );

    Ok(())
}

/// A rolled-back op that mutated the edge graph (and resolved through
/// the mutated graph op-locally) must leave no trace: the shared cache
/// only ever installs committed data, so the next posting must resolve
/// against the pre-rollback graph.
#[tokio::test]
async fn rolled_back_structure_change_does_not_poison_cache() -> anyhow::Result<()> {
    let pool = helpers::init_isolated_pool().await?;
    let (cala, _jobs) = init_cala(pool).await?;

    let journal = cala.journals().create(helpers::test_journal()).await?;
    let other_journal = cala.journals().create(helpers::test_journal()).await?;
    let tx_code = create_template(&cala).await?;
    let usd = "USD".parse::<Currency>()?;

    let chain = build_chain(&cala, journal.id(), other_journal.id()).await?;

    let b = cala
        .accounts()
        .create(new_account("b", BalanceRollup::Synchronous))
        .await?;
    let t = cala
        .account_sets()
        .create(new_set(journal.id(), "t", BalanceRollup::Synchronous))
        .await?;
    cala.account_sets().add_member(t.id(), b.id()).await?;

    // Warm the cache.
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, chain.member, dec!(1)),
    )
    .await?;
    tokio::time::sleep(CACHE_WARM).await;
    let s1_before = cala
        .balances()
        .find(journal.id(), chain.s1, usd)
        .await?
        .settled();

    // Graft t under s1 and post to b — then ROLL BACK the whole op.
    let mut op = cala.begin_operation().await?;
    cala.account_sets()
        .add_member_in_op(&mut op, chain.s1, t.id())
        .await?;
    cala.post_transaction_in_op(
        &mut op,
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, b.id(), dec!(9)),
    )
    .await?;
    drop(op); // rollback

    // Give any background refresh the rolled-back op may have triggered
    // time to run — it must only ever see committed state.
    tokio::time::sleep(CACHE_WARM).await;

    // A committed posting to b must fan into t only — never into s1.
    cala.post_transaction(
        TransactionId::new(),
        &tx_code,
        posting_params(journal.id(), chain.counter, b.id(), dec!(9)),
    )
    .await?;
    assert_eq!(
        cala.balances()
            .find(journal.id(), t.id(), usd)
            .await?
            .settled(),
        dec!(9)
    );
    assert_eq!(
        cala.balances()
            .find(journal.id(), chain.s1, usd)
            .await?
            .settled(),
        s1_before,
        "the rolled-back edge must not influence any later resolution"
    );

    Ok(())
}

/// Two `CalaLedger` instances (separate in-process caches) on one
/// database: a structure change committed through instance 2 must be
/// observed by instance 1's next posting via the epoch check — there is
/// no cross-instance invalidation channel, and none is needed.
#[tokio::test]
async fn cross_instance_structure_change_is_observed() -> anyhow::Result<()> {
    let pool = helpers::init_isolated_pool().await?;
    let (cala1, _jobs1) = init_cala(pool.clone()).await?;
    let (cala2, _jobs2) = init_cala(pool).await?;

    let journal = cala1.journals().create(helpers::test_journal()).await?;
    let other_journal = cala1.journals().create(helpers::test_journal()).await?;
    let tx_code = create_template(&cala1).await?;
    let usd = "USD".parse::<Currency>()?;

    let chain = build_chain(&cala1, journal.id(), other_journal.id()).await?;

    let b = cala1
        .accounts()
        .create(new_account("b", BalanceRollup::Synchronous))
        .await?;
    let t = cala1
        .account_sets()
        .create(new_set(journal.id(), "t", BalanceRollup::Synchronous))
        .await?;
    cala1.account_sets().add_member(t.id(), b.id()).await?;

    // Warm instance 1's cache with a posting that leaves t's subtree
    // untouched (activity under t would freeze it against the graft).
    cala1
        .post_transaction(
            TransactionId::new(),
            &tx_code,
            posting_params(journal.id(), chain.counter, chain.member, dec!(2)),
        )
        .await?;
    tokio::time::sleep(CACHE_WARM).await;

    // Instance 2 grafts t under s1 (epoch bump commits).
    cala2.account_sets().add_member(chain.s1, t.id()).await?;

    // Instance 1's cache is now stale; its next posting must observe
    // the new edge through the epoch check and fan into s1.
    cala1
        .post_transaction(
            TransactionId::new(),
            &tx_code,
            posting_params(journal.id(), chain.counter, b.id(), dec!(2)),
        )
        .await?;
    assert_eq!(
        cala1
            .balances()
            .find(journal.id(), chain.s1, usd)
            .await?
            .settled(),
        dec!(4),
        "a structure change committed by another instance must be visible immediately"
    );
    assert_eq!(
        cala1
            .balances()
            .find(journal.id(), t.id(), usd)
            .await?
            .settled(),
        dec!(2)
    );

    Ok(())
}

/// `true` iff a session currently holds the 1-arg per-balance advisory
/// exclusive for `(journal, target, currency)` — the same key shape the
/// poster takes on non-EC ancestor sets. `pg_locks` splits the 64-bit
/// key into `(classid, objid)` with `objsubid = 1` for the 1-arg form;
/// `hashtext(..)::bigint` sign-extends, so compare the halves.
async fn per_balance_exclusive_held(
    pool: &sqlx::PgPool,
    journal_id: JournalId,
    target: impl Into<AccountId>,
    currency: Currency,
) -> anyhow::Result<bool> {
    let key = format!("{}{}{}", journal_id, target.into(), currency.code());
    let held = sqlx::query_scalar::<_, bool>(
        r#"
        SELECT EXISTS (
            SELECT 1 FROM pg_locks
            WHERE locktype = 'advisory'
            AND objsubid = 1
            AND mode = 'ExclusiveLock'
            AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
            AND classid::bigint = ((hashtext($1)::bigint >> 32) & 4294967295)
            AND objid::bigint = (hashtext($1)::bigint & 4294967295)
        )
        "#,
    )
    .bind(key)
    .fetch_one(pool)
    .await?;
    Ok(held)
}

/// Lock parity between the fallback and memory paths: with a posting op
/// held open, the non-EC ancestors' per-balance exclusives must be held
/// and the EC ancestor's must not — identically on the cold (walk) and
/// warm (in-memory) resolutions.
#[tokio::test]
async fn ancestor_lock_parity_between_fallback_and_memory_paths() -> anyhow::Result<()> {
    let pool = helpers::init_isolated_pool().await?;
    let (cala, _jobs) = init_cala(pool.clone()).await?;

    let journal = cala.journals().create(helpers::test_journal()).await?;
    let other_journal = cala.journals().create(helpers::test_journal()).await?;
    let tx_code = create_template(&cala).await?;
    let usd = "USD".parse::<Currency>()?;

    let chain = build_chain(&cala, journal.id(), other_journal.id()).await?;

    for (pass, warm) in [("cold/fallback", false), ("warm/memory", true)] {
        if warm {
            tokio::time::sleep(CACHE_WARM).await;
        }
        let mut op = cala.begin_operation().await?;
        cala.post_transaction_in_op(
            &mut op,
            TransactionId::new(),
            &tx_code,
            posting_params(journal.id(), chain.counter, chain.member, dec!(1)),
        )
        .await?;

        for set_id in [chain.s1, chain.s3] {
            assert!(
                per_balance_exclusive_held(&pool, journal.id(), set_id, usd).await?,
                "{pass}: the non-EC ancestor's per-balance exclusive must be held"
            );
        }
        assert!(
            !per_balance_exclusive_held(&pool, journal.id(), chain.s2_ec, usd).await?,
            "{pass}: the EC ancestor must not be per-balance locked (rollup owns it)"
        );
        assert!(
            !per_balance_exclusive_held(&pool, journal.id(), chain.other_journal_set, usd).await?,
            "{pass}: an other-journal parent must never be locked"
        );

        op.commit().await?;
    }

    Ok(())
}