Skip to main content

cala_ledger/posting/
error.rs

1use sqlx::error::DatabaseError;
2use thiserror::Error;
3
4use crate::{
5    account_set::error::AccountSetError,
6    balance::error::BalanceError,
7    primitives::{AccountId, JournalId, TransactionId},
8    tx_template::error::TxTemplateError,
9    velocity::error::VelocityError,
10};
11
12/// The posting module's error — nested under
13/// [`crate::ledger::error::LedgerError`], never the other way around, exactly
14/// like every other domain error.
15///
16/// Domain errors the flow passes through keep their own granularity via
17/// `#[from]`; failures specific to the posting path get their own variants
18/// here. [`Self::Rejected`] additionally attributes a failure to one posting
19/// of the submitted batch.
20#[derive(Error, Debug)]
21pub enum PostingError {
22    #[error("PostingError - Sqlx: {0}")]
23    Sqlx(sqlx::Error),
24    #[error("PostingError - DuplicateKey: {0}")]
25    DuplicateKey(Box<dyn DatabaseError>),
26    #[error("PostingError - TxTemplateError: {0}")]
27    TxTemplateError(#[from] TxTemplateError),
28    #[error("PostingError - VelocityError: {0}")]
29    VelocityError(#[from] VelocityError),
30    #[error("PostingError - AccountSetError: {0}")]
31    AccountSetError(#[from] AccountSetError),
32    #[error("PostingError - BalanceError: {0}")]
33    BalanceError(#[from] BalanceError),
34    /// A failure attributed to a specific posting within a batch.
35    ///
36    /// The batch API is all-or-nothing: the whole operation aborts on the
37    /// first failure. `index` and `tx_id` identify which posting of the
38    /// submitted batch caused it, so a caller can eject the offender and
39    /// retry the remainder without correlating an opaque error against its
40    /// input. Every reason is detected **client-side, before the apply
41    /// statement runs**, which is what keeps the failure attributable:
42    /// nothing has been written when it surfaces. Infrastructure failures
43    /// (constraint races, deadlocks, connection loss) are not attributable
44    /// and surface through the other variants.
45    #[error("PostingError - Rejected: posting {index} ({tx_id}): {reason}")]
46    Rejected {
47        index: usize,
48        tx_id: TransactionId,
49        reason: Box<RejectionReason>,
50    },
51    /// The batch would hold more advisory locks than the shared lock table can
52    /// be relied on to provide. Refused up front, because the alternative is a
53    /// bare `out of shared memory` from Postgres that names neither the cause
54    /// nor the fix — and that can strike unrelated concurrent transactions too.
55    #[error(
56        "PostingError - BatchTooManyAccounts: this batch touches {distinct} distinct \
57         (journal, account, currency) balances; at most {max} may be locked in one batch. \
58         Split it — batch *size* is not the limit, the number of distinct accounts is."
59    )]
60    BatchTooManyAccounts { distinct: usize, max: usize },
61}
62
63impl PostingError {
64    pub(super) fn rejected(
65        index: usize,
66        tx_id: TransactionId,
67        reason: impl Into<RejectionReason>,
68    ) -> Self {
69        // Keep the attribution observable on the flow's span even when the
70        // caller only logs the error.
71        let span = tracing::Span::current();
72        span.record("failed_posting_index", index);
73        span.record("failed_posting_id", tracing::field::display(tx_id));
74        Self::Rejected {
75            index,
76            tx_id,
77            reason: Box::new(reason.into()),
78        }
79    }
80}
81
82impl From<sqlx::Error> for PostingError {
83    fn from(e: sqlx::Error) -> Self {
84        match e {
85            sqlx::Error::Database(err) if err.message().contains("duplicate key") => {
86                Self::DuplicateKey(err)
87            }
88            e => Self::Sqlx(e),
89        }
90    }
91}
92
93/// The business-level reason a posting was rejected.
94#[derive(Error, Debug)]
95pub enum RejectionReason {
96    #[error("{0}")]
97    TxTemplate(#[from] TxTemplateError),
98    #[error("account {0} does not exist")]
99    AccountNotFound(AccountId),
100    #[error(
101        "an entry may not be posted directly to an account-set backing account \
102         ({0}); an account set's balance is derived from its members"
103    )]
104    EntryTargetsAccountSet(AccountId),
105    #[error("account {0} is locked")]
106    AccountLocked(AccountId),
107    #[error("journal {0} is locked")]
108    JournalLocked(JournalId),
109    #[error("journal {0} does not exist")]
110    JournalNotFound(JournalId),
111    #[error("duplicate transaction id {0} within the submitted batch")]
112    DuplicateTransactionIdInBatch(TransactionId),
113    #[error("duplicate external id `{0}` within the submitted batch")]
114    DuplicateExternalIdInBatch(String),
115}
116
117/// The number of distinct `(journal, account, currency)` triples one batch may
118/// lock.
119///
120/// The fence takes two advisory locks per distinct entry account — a shared
121/// class-1 lock and, for non-EC accounts, a per-balance exclusive — and holds
122/// them all until commit. Advisory locks live in the *shared* lock table, sized
123/// `max_locks_per_transaction x (max_connections + max_prepared_transactions)`,
124/// so a batch spanning enough distinct accounts exhausts it and Postgres aborts
125/// with a bare `out of shared memory`, which says nothing about the cause and
126/// can equally be triggered by unrelated concurrent work.
127///
128/// Batch *size* is not the constraint — 500k postings over a small account pool
129/// lock only that pool. Distinct accounts are. This bound is deliberately well
130/// under the stock ceiling (64 x 100 = 6400 slots) because the table is shared
131/// with every other backend; a batch that fits alone can still fail beside
132/// concurrent traffic.
133pub(super) const MAX_DISTINCT_BALANCES_PER_BATCH: usize = 1_000;