Implementation design · 002-trade-ledger

Append-only trade ledger

How we will build it.

Status Draft Spec 002-trade-ledger Owner Brian Corbin · @briancorbinxyz Branch 002-trade-ledger Created Read time

Build the ledger as an in-memory, append-only ArrayList<TradeEvent> hot tail sequenced by a single AtomicLong, with fold-checkpoint snapshots and full/ranged replay — the smallest thing that satisfies FR-001, FR-003 and FR-004 for the spike. The biggest risk is deferred: the durable, cold-tiering storage engine (TBD-storage-engine) that unlimited history (FR-002) and the memory ceiling (NFR-004) ultimately require lives outside this slice.

1 · Principles check

Verify this design respects every principle in v0.1.0. Any violation requires either a principles amendment or a deliberate exception logged below.

A focused check of the principles most load-bearing for this slice follows; platform-wide principle conformance is tracked in the umbrella design (001).

PrincipleComplianceNotes
P-2 · Small, reversible changesOKIn-memory list + one AtomicLong is the smallest thing that satisfies FR-001/FR-003; the storage engine is deferred, not pre-committed.
P-3 · Explicit over implicitOKSequence is dense and monotonic; a gap is a correctness fault under NFR-003, not a silently-tolerated state.
P-5 · Record the whyOKD-001..D-003 in §6 each cite the requirement they satisfy and the §3 fact they rest on.
P-6 · Tests accompany behaviorOKEvery story is TDD: the test task is written and failing before its implementation task (see tasks.html).
P-7 · Done means verifiedOKGap-freedom, replay equality and restore==replay are mechanically checked; append latency is measured against SLO-append.
P-9 · Decisions are groundedOKEach §6 decision carries a grounding status backed by a §3 evidence row.
P-15 · Structured observabilityOKA Micrometer timer wraps the append step; Prometheus scrape at /q/metrics.
P-16 · Service level objectivesOKThe four NFR SLOs (append, ingest, integrity, hot-set) are the reliability envelope this design builds to.
P-8 · Ship dark by defaultn/a hereThe analytics REST surface behind portfolio.analytics.enabled is owned by 001; the ledger is an internal library with no HTTP surface of its own.

2 · Technical context

Language(s)
Java 21 (Temurin 21.0.9)
Frameworks
Quarkus 3.37.3 (quarkus-arc, quarkus-micrometer-registry-prometheus); Maven build via mvn
Architecture
Event-sourced, library-first. The ledger is the append-only log; analytics are stateful in-memory projections that fold TradeEvent + PriceTick. This slice owns only the log (package io.spectastic.portfolio.ledger).
Storage
Spike: in-memory hot tail (ArrayList<TradeEvent>). Durable append + cold-tiering engine deferred to TBD-storage-engine (a §8 risk).
Testing
Fast JUnit5 unit tests on the pure domain, plus one @QuarkusTest REST smoke owned by 001. rest-assured for the smoke.
Coverage
Diff-aware jacoco: patch ≥ 90% on touched ledger domain, and a change never lowers touched-line coverage. Never a single universal %.
Perf targets
Append p99 < 5 µs (SLO-append); sustained ingest ≥ 1,000,000 appends/s (SLO-ingest); 0 loss-or-gap events (SLO-integrity); hot-tier < 2 GB at 1e9 lifetime trades (SLO-hotset).
Constraints
Single logical writer on the append path; no floating point on the money path (exact integer fixed-point, PRICE_SCALE = 10_000); a persisted event is never mutated or deleted — corrections are compensating events.

3 · Grounding & evidence

Every design-bearing fact this design rests on, and how it was confirmed — read this before the approach: it separates what the design knows from what it assumes (per REQ-LIFECYCLE-006). Status is verified (opened the source this turn), spike (decidable only by a time-boxed investigation — run it, record the finding), or assumed (taken as true without verification). A must-tier decision may not rest on an assumed/unresolved-spike fact unless accepted as a <spec-risk> (see §8).

Claim the design rests onSourceStatusFinding / note
The TradeEvent model is defined once, by the umbrella, and persisted verbatim here. specs/002-trade-ledger/spec.html · §4 Data model verified Spec §4 states TradeEvent is "as defined by 001; persisted verbatim, immutable, under an assigned seq". The model is shared, owned by 001.
A fold is a pure function of the event prefix, so restore-then-replay-tail equals a full replay. specs/002-trade-ledger/spec.html · FR-004, SC-002 verified Determinism is a stated platform property; SC-002 requires restore@N + replay N+1..M to be bit-identical to full replay [0,M]. Grounds D-002.
A single AtomicLong gives a monotonic, gap-free sequence under one writer. java.util.concurrent.atomic.AtomicLong#incrementAndGet verified Under the single-writer assumption (§6 spec) incrementAndGet yields dense 1..N with no gaps. Grounds D-001.
An ArrayList append meets append p99 < 5 µs and ≥ 1e6/s on the hot path. Spike: JMH microbenchmark of amortized ArrayList.add vs SLO-append / SLO-ingest spike Pending. Amortized O(1) add is expected to clear both floors; the polish phase runs the benchmark and records the number. Accepted meanwhile as R-2 in §8.
Durable append and cold-tiering are needed for FR-002 / NFR-004 but are out of this slice. assumed Spec defers the concrete engine to TBD-storage-engine; the in-memory tail cannot honour unlimited history at the 2 GB ceiling. Carried as R-1 in §8.

4 · Approach

The ledger is a small, dependency-light library under io.spectastic.portfolio.ledger. TradeLedger holds an ArrayList<TradeEvent> hot tail and one AtomicLong sequence. append assigns the next seq via incrementAndGet and adds to the tail; an optional client event key is checked against a seen-key Set so an at-least-once producer that re-delivers does not double-book (FR-005). Replay is a read over the tail: full, or ranged [seqA, seqB], returned in sequence order (FR-003). For the spike the tier boundary is a seam, not a second store — replay is written to span it so the durable engine can slot in behind the same signature later.

Snapshot is a fold-checkpoint: a deep copy of a projection's opaque state at a sequence N. Restoring it and replaying N+1.. equals a full replay from zero because the fold is pure over the event prefix (D-002). A Micrometer timer wraps the append step; the whole surface stays behind the umbrella's portfolio.analytics.enabled flag. Nothing here mutates or deletes a persisted event — corrections are new compensating events (FR-001).

Producer appends fills TradeLedger seq + hot tail Projection fold + snapshot
A producer appends fills to the ledger, which assigns a monotonic seq and holds the hot tail; a projection replays or restores-then-replays to fold state, snapshotting to bound cold-start.

5 · Alternatives considered

OptionSpike simplicityReplay completenessUnlimited-retention fitTotal
In-memory ArrayList hot tail — chosen for the spike54211
Embedded LSM / RocksDB25411
External log service (Kafka / Pulsar)15511

All three tie on total, but the criteria are not equal weight for a spike: replay completeness is preserved behind the tier seam whichever store lands, and unlimited-retention fit is explicitly deferred to TBD-storage-engine. That leaves spike simplicity as the deciding axis — the in-memory list wins, and tiering is postponed rather than pre-bought.

6 · Decisions

D-001 · Hot tail is an ArrayList sequenced by one AtomicLong

Status
Accepted
Context
FR-001 needs an append-only, monotonic, gap-free sequence, and FR-003 needs ordered replay, for a single-writer spike. Rests on the §3 AtomicLong#incrementAndGet row.
Decision
TradeLedger = ArrayList<TradeEvent> hot tail + AtomicLong sequence. append does incrementAndGet then add.
Consequences
+ smallest thing that satisfies FR-001/FR-003; trivial to test for gap-freedom. − does not honour FR-002/NFR-004 at scale on its own; the durable engine (R-1) must land behind the same signature.

D-002 · Snapshot is a fold-checkpoint deep copy; restore-then-replay-tail equals full replay

Status
Accepted
Context
FR-004 / SC-002 require a checkpoint at sequence N that, restored and replayed N+1..M, is bit-identical to a full replay [0,M]. Rests on the §3 fold-purity row.
Decision
Snapshot = { seq, foldId, opaqueState }, a deep copy of a projection's state. Restore installs the state, then replay resumes at seq+1.
Consequences
+ cold-start time is bounded by tail length, not lifetime trade count. − each projection must expose a deep-copyable state; snapshot cost scales with state size, not history.

D-003 · Idempotent append via a seen-client-key Set guard

Status
Accepted
Context
FR-005 (SHOULD) asks that an at-least-once producer re-delivering the same event not double-book. Rests on the single-writer §6 assumption in the spec.
Decision
When an append carries an optional client event key, check it against a Set of seen keys; a duplicate is accepted-and-ignored, appended once, never twice.
Consequences
+ satisfies the duplicate-delivery edge case with a single guard. − the seen-key set is unbounded in the spike; the durable engine (R-1) must bound or tier it alongside the log.

7 · Project structure

Only the new or changed paths, grouped by the boundaries the §6 event-sourced, library-first decision draws.

src/main/java/io/spectastic/portfolio/
  ledger/
    TradeLedger.java     append-only hot tail + AtomicLong seq + idempotent guard
    Snapshot.java        { seq, foldId, opaqueState } fold-checkpoint
src/test/java/io/spectastic/portfolio/
  LedgerTest.java        gap-freedom, full/ranged replay, restore==replay, idempotency

The model package (TradeEvent, PriceTick, Side) and the Maven/Quarkus scaffold are owned by the umbrella (001); this slice references them, it does not create them.

8 · Risks & mitigations

RiskLikelihoodImpactMitigation
R-1 · The in-memory tail cannot honour unlimited history (FR-002) or the 2 GB hot-set ceiling (NFR-004); the durable, cold-tiering engine is deferred to TBD-storage-engine.HHWrite replay to span the tier boundary from day one, so the durable store slots in behind an unchanged signature; track the engine choice as the umbrella's next storage slice.
R-2 · ArrayList.add may miss append p99 < 5 µs or 1e6/s under GC pressure or resize stalls (the §3 spike is still pending).MMPre-size the tail and run the JMH benchmark in the polish phase against SLO-append / SLO-ingest; if it misses, move to a pre-allocated ring or off-heap segment before adding a store.
R-3 · The idempotency seen-key Set grows without bound across lifetime appends.MLAcceptable for the spike; bound or tier the key set alongside the durable log when R-1 is resolved.

9 · Complexity tracking

Anywhere this design introduces complexity not strictly required by the spec, justify it here. A reviewer should be able to point at any non-trivial choice and find its rationale in this section.

The tier-boundary seam in replay is built before there is a cold store to cross it. This is deliberate: it is required by FR-002's "replay MUST remain complete across hot and cold tiers", and building the seam now keeps the durable engine (R-1) a drop-in behind an unchanged signature rather than a later rewrite of every replay call site.

10 · Open questions

None outstanding — the append-only, unlimited-retention, snapshot-and-replay contract is fixed by the spec, and the one genuinely open choice (the storage engine) is explicitly deferred to TBD-storage-engine and carried as R-1, not left unresolved here.

11 · Change log

  1. Initial design. In-memory ArrayList hot tail + AtomicLong sequence (D-001), fold-checkpoint snapshots (D-002), idempotent append via a seen-key guard (D-003); durable cold-tiering engine deferred to TBD-storage-engine (R-1).