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;