cesar
запушил(а) review/append-only-hot-indexes-followups в cesar/ledger-rs
1d2cd7fc82 Chunk id-batch posting primitives to a safe statement size
The id-batch primitives (get_postings, get_posting_states, reserve,
release, deactivate) matched every id in one statement via an OR of
equality pairs. That OR chain grows with the batch, and SQLite rejects it
once the expression tree passes its depth limit (default 1000), well
before the bind-parameter limits are reached; PostgreSQL has its own
ceilings too. A caller passing a large id set would hit a hard database
error.
Split every id batch into fixed-size chunks, keeping the two write
primitives' chunks inside their existing single transaction so the claim
or release stays atomic, and summing the affected-row counts. Reads
accumulate across chunks. The batch size the primitives accept now has no
practical ceiling.
Also encode hex without a per-byte allocation, and add a test that drives
a batch larger than one chunk through reserve, read, and deactivate.
41ce95cb4d Make posting reads deterministic and migrations atomic
Reviewing the append-only value-table / hot-index split surfaced three
follow-ups worth fixing.
Migrations ran statement-by-statement outside any transaction, recording
a migration as applied only after all of its statements succeeded. The
new migration that drops and rebuilds the postings table could therefore
crash half-applied, leaving the schema in a state the migration could not
be re-run against. Wrap each migration's statements and its bookkeeping
insert in one transaction so a crash rolls back cleanly and the migration
is retried whole. Both SQLite and PostgreSQL support transactional DDL.
Posting reads returned rows in an unspecified order, so LIMIT/OFFSET
pagination could skip or repeat rows across pages, worst for the live set
whose source is a UNION of two tables. Order get_postings_by_account by
the posting id in both backends so the primitive that balance, selection,
close, and pagination all build on returns a stable sequence.
Bring the reference docs back in line with the derived-state model:
postings are immutable and carry no lifecycle column; state is Active,
Reserved, Spent, or Missing derived from index membership.
288392181a Separate append-only value tables from disposable hot indexes
The primary goal is correctness. Value and audit data lives only in
append-only tables that are inserted into and never updated or deleted,
so no code path or credential can corrupt or lose history. `postings` is
the immutable record of every posting; `accounts` is the immutable log
of every account version.
Fast access is served by separate disposable tables that behave like
indexes over that truth and can be dropped and rebuilt from it:
`active_postings` and `reserved_postings` hold the spendable and
in-flight set, and `account_head` points at each account's current
version. A posting's lifecycle state and an account's current version
are derived from membership in these tables, not from a mutable column.
Every write is an INSERT or a DELETE; nothing issues an UPDATE. That
shrinks the margin for error and is enforceable with database grants:
the ledger role needs INSERT on the value tables, INSERT and DELETE on
the hot tables, and UPDATE on nothing.
The hot tables carry full row copies of the live set, so reads hit them
directly without joining back to the value tables, and the reserve,
release, and consume primitives move the whole input set with bounded
set-based statements instead of per-row loops.
See ADR-0016 and ADR-0017.
e56b34bc04 Squashed commit of the following:
commit 92108a8c4ca6f53970c4a47719993a4ee162ace8
Author: Cesar Rodas <cesar@rodasm.com.py>
Date: Fri Jul 10 19:04:35 2026 -0300
Extract prelude and envelope_saga into file modules
The workspace hygiene rules forbid inline module bodies (mod NAME { ... }) in
non-test code so that every namespace is visible in the directory tree. Two
modules still used the inline form: the crate prelude and the legend! saga
wrapper. Move both into their own files (prelude.rs and
ledger/envelope_saga.rs) and switch the declarations to file modules. The
prelude doc comment moves to inner form, dropping now-redundant explicit
intra-doc link targets that resolve on their own once the re-exports are in
scope.
commit e4514148a5b0bb1d476c4a85390b202ed11793e8
Author: Cesar Rodas <cesar@rodasm.com.py>
Date: Thu Jul 9 18:18:00 2026 -0300
Shorten the account code to a fixed 20-character form
The IBAN-style account code was 28 characters: two leading check digits and a
26-character base-36 body that encoded both i64 legs at full width. That is
long to read or speak and does not group evenly, so the code read worse than an
IBAN or a card number.
Pack the base id (63 bits) and the subaccount (30 bits) into one 93-bit value,
run it through a keyed format-preserving permutation (a 94-bit Feistel with
cycle-walking, replacing the 128-bit one), and base-36 encode it in 18
characters, then append the two mod-97 check digits. The result is a fixed 20
characters, five groups of four, with the checksum at the tail.
The change is presentation-only: ToBytes, serde, the SQL schema, and every
content hash keep the two full i64 legs, so there is no migration. The cost is
a 30-bit subaccount range, which caps the hash-derived inflight hold
subaccounts, so they are now masked to that width. See ADR-0015.
3d245083cd Add inflight holds via per-destination holding subaccounts
Callers need to reserve funds for a multi-leg trade without settling it, then
confirm it fully or in parts or void it, and to run several such holds against
one account at the same time. The ledger is append-only with derived balances,
so a hold has to be real committed state, and holds must be attributable to the
account they belong to.
Model an inflight transaction as the ordinary trade with every destination
rewritten to a per-destination holding subaccount (NoOverdraft), committing
that rewritten transfer to park the funds. Confirm and void are ordinary
commits from the holds to their destinations or back to the funders recorded in
the authorize transfer's metadata. Over-confirmation is blocked by the
NoOverdraft hold, and concurrent confirmations serialize on the shared holding
posting. The inflight facts live in a single CBOR-encoded metadata entry, and
confirm accepts a batch of legs built with the existing TransferBuilder.pay
interface.
A hold reuses the existing account subaccount dimension: it is a subaccount of
its destination, keyed by a value derived from a hash of the submitted trade,
so different trades derive different subaccounts and a destination hosts many
concurrent inflights, while the identical trade collides on its existing hold.
Balances are read per subaccount and never summed, so a hold's remaining amount
is just its balance.
Everything rides the existing commit and recover path, so idempotency,
conservation, and crash recovery are inherited, with no new store, sub-trait,
or migration. Recorded in ADR-0013 (inflight holds via holding accounts),
building on the subaccount dimension from ADR-0012.
5 дней назад