Implementation design · 002-trade-ledger
Append-only trade ledger
How we will build it.
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).
| Principle | Compliance | Notes |
|---|---|---|
| P-2 · Small, reversible changes | In-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 implicit | Sequence is dense and monotonic; a gap is a correctness fault under NFR-003, not a silently-tolerated state. | |
| P-5 · Record the why | D-001..D-003 in §6 each cite the requirement they satisfy and the §3 fact they rest on. | |
| P-6 · Tests accompany behavior | Every story is TDD: the test task is written and failing before its implementation task (see tasks.html). | |
| P-7 · Done means verified | Gap-freedom, replay equality and restore==replay are mechanically checked; append latency is measured against SLO-append. | |
| P-9 · Decisions are grounded | Each §6 decision carries a grounding status backed by a §3 evidence row. | |
| P-15 · Structured observability | A Micrometer timer wraps the append step; Prometheus scrape at /q/metrics. | |
| P-16 · Service level objectives | The four NFR SLOs (append, ingest, integrity, hot-set) are the reliability envelope this design builds to. | |
| P-8 · Ship dark by default | n/a here | The 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 (packageio.spectastic.portfolio.ledger). - Storage
- Spike: in-memory hot tail (
ArrayList<TradeEvent>). Durable append + cold-tiering engine deferred toTBD-storage-engine(a §8 risk). - Testing
- Fast JUnit5 unit tests on the pure domain, plus one
@QuarkusTestREST 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 on | Source | Status | Finding / note |
|---|---|---|---|
The TradeEvent model is defined once, by the umbrella, and persisted verbatim here. |
specs/002-trade-ledger/spec.html · §4 Data model |
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 |
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 |
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 |
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. | — | 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).
seq and holds the hot tail; a projection replays or restores-then-replays to fold state, snapshotting to bound cold-start.5 · Alternatives considered
| Option | Spike simplicity | Replay completeness | Unlimited-retention fit | Total |
|---|---|---|---|---|
In-memory ArrayList hot tail — chosen for the spike | 5 | 4 | 2 | 11 |
| Embedded LSM / RocksDB | 2 | 5 | 4 | 11 |
| External log service (Kafka / Pulsar) | 1 | 5 | 5 | 11 |
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#incrementAndGetrow. - Decision
TradeLedger=ArrayList<TradeEvent>hot tail +AtomicLongsequence.appenddoesincrementAndGetthenadd.- 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
Nthat, restored and replayedN+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 atseq+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
Setof 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
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
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. | H | H | Write 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). | M | M | Pre-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. | M | L | Acceptable 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
- Initial design. In-memory
ArrayListhot tail +AtomicLongsequence (D-001), fold-checkpoint snapshots (D-002), idempotent append via a seen-key guard (D-003); durable cold-tiering engine deferred toTBD-storage-engine(R-1).