Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
4635091
feat(EN-1334): add usagebuilder subsystem for template + event counters
Jul 1, 2026
051b935
feat(EN-1420): extend usagebuilder with ephemeral + transient counters
Jul 1, 2026
4b7516a
feat(EN-1420): drop MetadataCount until a sound foundation lands
Jul 2, 2026
fe7e0b1
feat(EN-1422): migrate VolumeCount to usagebuilder via new_volumes on…
Jul 2, 2026
ddc316a
perf(EN-1422): split LedgerLog volume annotations into 3 disjoint lists
Jul 2, 2026
d6d11ba
docs(usage): dedicated subsystem page under docs/technical/architectu…
Jul 2, 2026
c5a11f8
fix(EN-1422): dedup volume tuples per audit entry + adapt transient t…
Jul 2, 2026
5c6884e
fix(EN-1334): address PR review findings on usagebuilder
Jul 2, 2026
115d5ea
refactor(EN-1334): port usagebuilder onto shared tailworker skeleton
Jul 3, 2026
e64a73e
fix(EN-1334): 4 legit findings from PR round 2
Jul 6, 2026
9ad9b14
fix(EN-1334): drop redundant filepath.Join(dataDir) test case
Jul 6, 2026
4cd0378
fix(EN-1334): 3 new PR round-3 findings
Jul 6, 2026
8b51d43
docs(EN-1334): document 404 on GetTemplateUsage endpoint
Jul 6, 2026
645c178
chore(EN-1334): address NumaryBot review findings
Jul 10, 2026
92fe77a
fix(EN-1334): rewind usage cursor on primary-store rollback (blocker)
Jul 12, 2026
bb7ece0
fix(EN-1334): reserve deleted LedgerBoundaries field numbers 3-10
Jul 12, 2026
8c6f1a2
refactor(EN-1334): drop prohibited type aliases
Jul 12, 2026
f63b3aa
fix(EN-1334): propagate spinner Start errors in rebuild-usage
Jul 12, 2026
1ebccf4
fix(EN-1334): emit epoch lastUsed in numscript-usage response
Jul 12, 2026
2a19066
fix(EN-1334): rewind usage cursor on runtime follower-sync restore
Jul 12, 2026
b18fb63
docs(EN-1334): make usagestore counter checker-scope an explicit desi…
Jul 12, 2026
d611d0b
fix(EN-1334): add auditindexer-parity gap heuristic to usage cursor g…
Jul 12, 2026
a147292
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 12, 2026
9923f96
fix(EN-1334): detect primary-store rollback via restore generation (b…
Jul 12, 2026
fa5fe6e
Revert "fix(EN-1334): detect primary-store rollback via restore gener…
Jul 12, 2026
b09f824
refactor(EN-1334): drop impossible primary-store-rollback detection f…
Jul 12, 2026
de75839
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 13, 2026
7f988f3
feat(EN-1334): remove offline usage-rebuild; defer correct rebuild to…
Jul 13, 2026
aefc084
test(EN-1334): wrap transient-purge log assertion in Eventually
Jul 13, 2026
8b46435
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 13, 2026
4a34dd0
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 13, 2026
59de632
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 13, 2026
fe3c883
docs(EN-1334): fix usagestore rollback/checker-scope doc drift
Jul 13, 2026
6f36851
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 14, 2026
47dce64
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 14, 2026
e357eb9
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 16, 2026
6f4519f
fix(EN-1334): peer-store the usage counters after release/v3.0 merge
Jul 16, 2026
0510143
fix(EN-1334): skipped orders must not inflate usage counters
Jul 16, 2026
ea05119
Merge remote-tracking branch 'origin/release/v3.0' into feat/EN-1334-…
Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,11 @@ linters:
# (not OpenWriteSession on the main store).
- path: internal/storage/readstore/
linters: [forbidigo]
# The usagestore is a peer secondary store to readstore, dedicated to the
# usagebuilder projections. It manages its own Pebble DB and uses
# NewWriteSessionFromDB by the same rationale.
- path: internal/storage/usagestore/
linters: [forbidigo]
# Tests use OpenWriteSession to seed data; the structural guarantee is on
# production paths.
- path: _test\.go$
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion cmd/ledgerctl/ledgers/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ func runStats(cmd *cobra.Command, _ []string) error {
pterm.Printf("Postings: %d\n", stats.GetPostingCount())
pterm.Printf("Logs: %d\n", stats.GetLogCount())
pterm.Printf("Volumes: %d\n", stats.GetVolumeCount())
pterm.Printf("Metadata: %d\n", stats.GetMetadataCount())
pterm.Printf("References: %d\n", stats.GetReferenceCount())
pterm.Printf("Reverts: %d\n", stats.GetRevertCount())
pterm.Printf("Numscript execs: %d\n", stats.GetNumscriptExecutionCount())
Expand Down
16 changes: 8 additions & 8 deletions docs/technical/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,17 +154,17 @@ Per-ledger boundaries use the `LedgerBoundaries` protobuf message from `raft_cmd
message LedgerBoundaries {
fixed64 next_transaction_id = 1;
fixed64 next_log_id = 2;
fixed64 volume_count = 3;
fixed64 metadata_count = 4;
fixed64 reference_count = 5;
fixed64 posting_count = 6;
fixed64 ephemeral_evicted_count = 7;
fixed64 transient_used_count = 8;
fixed64 revert_count = 9;
fixed64 numscript_execution_count = 10;
}
```

> Every per-ledger counter — postings, reverts, numscript executions, references, ephemeral evictions, transient volumes, **and volumes** — lives in the usagebuilder side-store (`internal/application/usagebuilder`, `internal/storage/usagestore`). All counters are derived from the audit chain: event counts (posting / revert / numscript-exec / reference) come from replaying orders; transient counts come from `AppliedProposal.TransientVolumes`; ephemeral and volume counts come from three DISJOINT per-log lists that the FSM populates on every `LedgerLog`:
>
> - `purged_volumes` — draining evictions (was non-zero in Pebble, now zero, entry deleted at commit)
> - `new_kept_volumes` — persistent-new (created + survives past commit)
> - `ephemeral_volumes` — pure ephemeral (created + evicted same log)
>
> Volume count delta per log = `len(new_kept_volumes) − len(purged_volumes)`. Pure ephemeral tuples contribute +0 (was zero, is zero) and are tracked only for the index builder's skip logic and for the ephemeral-eviction counter. Splitting ephemeral out of `purged_volumes` avoids the 2× byte cost that a naive union-encoding would pay on ephemeral-heavy workloads. `LedgerBoundaries` carries only the two ID generators that drive apply-time allocation decisions. `metadata_count` is intentionally not exposed — the admission preload no longer injects old metadata values so cardinality cannot be reliably derived at the FSM level; it will come back on a sound foundation later.

> **Note:** The `State` / `LedgerState` proto messages sometimes shown in older documentation are conceptual models, not actual proto definitions. The real FSM state is spread across the `Machine` struct fields and its `StateRegistry`.

### Benefits of Single Raft
Expand Down
19 changes: 19 additions & 0 deletions docs/technical/architecture/subsystems/usage/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Usage

The background worker (`internal/application/usagebuilder`) that turns committed audit entries into per-ledger housekeeping counters — postings, reverts, numscript executions, references, ephemeral evictions, transient uses, live volume cardinality — plus per-Numscript-template invocation records. Runs on every node — leader and followers — independently, mirroring the indexer's decoupling from the FSM hot path: the FSM commits and signals; the usagebuilder reads its own Pebble read handle and writes to a dedicated secondary store (`internal/storage/usagestore`).

The projections it maintains are exposed by the API — `GET /v3/{ledger}/stats` for the aggregate counters, `GET /v3/{ledger}/numscripts/{name}/usage` for per-template invocation counts.

## Documents

| Document | Description |
|----------|-------------|
| [usagebuilder.md](usagebuilder.md) | Pipeline mechanics: builder loop, audit-chain tailing, per-batch commit, log-payload consumption. |
| [counters.md](counters.md) | Counter definitions, storage keys, `LedgerLog.{purged,new_kept,ephemeral}_volumes` split, formula for each counter. |

## Related

- [FSM](../fsm/) — emits the `LedgerLog.new_kept_volumes` / `ephemeral_volumes` / `purged_volumes` annotations the usagebuilder consumes. Depends on the volume preload contract (see AGENTS.md invariant #6).
- [Indexer](../indexer/) — sibling subsystem with the same audit-tailing shape, but writes to a different secondary store (`readstore`).
- [Checker](../checker/) — verifies the projections against the audit chain (`compareExclusionProjections` for ephemeral/transient tuples).
- [Storage](../storage/) — the usage store is a peer Pebble instance to the readstore, WAL disabled, own comparer, own Pebble tuning.
144 changes: 144 additions & 0 deletions docs/technical/architecture/subsystems/usage/counters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Counters and Storage Schema

This page documents the projections the usagebuilder materialises: what each counter counts, how the FSM plumbs the source data through the log payload, and how the counters are keyed in the usagestore.

These counters live in the **usagestore**, a peer secondary store. Per the checker-scope framing (CLAUDE.md invariant #8), a peer secondary store is out of the **main-store checker's** scope *by construction* — `Check()` never opens the usagestore — so the checker does **not** verify these counters. Their integrity is a per-replica rebuild-health concern (rebuildable from the audit on demand), not a main-store invariant-#8 projection.

For the pipeline that populates these keys, see [usagebuilder.md](usagebuilder.md).

## Counter Catalogue

Every counter is keyed by `(ledger, counter_id)` in the usagestore (`internal/storage/usagestore/keys.go`) — one byte per counter, stable on-disk identifiers, never renumbered.

| ID | Name | Source | Delta per event |
|----|------|--------|-----------------|
| `0x01` | `CounterPosting` | `Transaction.Postings` (`CreatedTransaction`) + `RevertTransaction.Postings` (`RevertedTransaction`) | `+len(Postings)` per applicable log |
| `0x02` | `CounterRevert` | `RevertTransactionOrder` unmarshalled from `AuditItem.SerializedOrder` (covers both direct and mirror-ingested reverts) | `+1` per **successful** revert (gated on the produced log being a `RevertedTransaction`; a skipped order — `OrderSkipped` log — counts nothing) |
| `0x03` | `CounterNumscriptExecution` | `CreateTransactionOrder` with a non-empty `Script.Plain` or a non-nil `NumscriptReference` | `+1` per **successful** order (gated on a produced `CreatedTransaction`; skips count nothing) |
| `0x04` | `CounterReference` | `CreateTransactionOrder.Reference != ""` | `+1` per **successful** order (gated on a produced `CreatedTransaction`; skips count nothing) |
| `0x05` | `CounterEphemeralEvicted` | `len(LedgerLog.EphemeralVolumes)` per log — the pure-ephemeral tuples (new + purged same log) | `+len(EphemeralVolumes)` |
| `0x06` | `CounterTransientUsed` | `len(AppliedProposal.TransientVolumes[ledger].Volumes)` — batch-level, keyed by audit sequence | `+len(TransientVolumes[ledger])` per proposal |
| `0x07` | `CounterVolume` | `len(LedgerLog.NewKeptVolumes) − len(LedgerLog.PurgedVolumes)` per log | net change in live volume-key cardinality |

Missing keys read as `0`. Every counter clamps at zero on underflow via `applyDelta` — a subsystem bug that emits a spurious `−1` cannot drive the counter into an out-of-band representation.

## Template Usage

Per-Numscript-template records live under a separate key shape:

```
[usagestore prefix][ledger 64B][template_name] → TemplateUsage { fixed64 count; Timestamp last_used }
```

Populated when a `CreateTransactionOrder` carries a non-nil `NumscriptReference`. `last_used` is the order's `Timestamp` (deterministic — same on every replica). Multiple invocations in the same batch fold via max on `last_used` and sum on `count`.

Read path: `GET /v3/{ledger}/numscripts/{name}/usage` (`internal/adapter/http/handlers_get_numscript_usage.go`) — returns the persisted `TemplateUsage` proto or a zero-valued one when the template has never been invoked (not a 404).

## The Log-Payload Contract

Six of the seven counters plus template usage need FSM-side annotations on the log or the audit chain. Two categories:

- **Straight from the raw order** (posting, revert, numscript-exec, reference, template usage): no FSM enrichment needed — the fields are already on `raftcmdpb.CreateTransactionOrder` / `RevertTransactionOrder`, which the usagebuilder unmarshalls from `AuditItem.SerializedOrder`.
- **Volume annotations on the log** (ephemeral evicted, volume count): the FSM computes three DISJOINT per-log lists during `WriteSet.Merge` and injects them into the `LedgerLog` message.

### `LedgerLog` volume annotations

`misc/proto/common.proto` — three disjoint fields on `LedgerLog`:

```protobuf
message LedgerLog {
LedgerLogPayload data = 1;
Timestamp date = 2;
fixed64 id = 3;
repeated TouchedVolume purged_volumes = 4; // DRAINING only
repeated TouchedVolume new_kept_volumes = 5; // new + survives
repeated TouchedVolume ephemeral_volumes = 6; // new + purged same log
}
```

The three sets partition every volume update the log touched into DISJOINT categories:

| Category | Prior value | Post-commit state | Field |
|----------|-------------|-------------------|-------|
| Draining | non-zero in Pebble | evicted (zero balance) | `purged_volumes` |
| New + kept | undefined or zero placeholder | persisted | `new_kept_volumes` |
| Pure ephemeral | undefined or zero placeholder | evicted (zero balance) | `ephemeral_volumes` |
| Normal update | defined + non-zero | still persisted (updated value) | (none — no annotation needed) |
| Transient | any | never persisted | (none — carried on `AppliedProposal.TransientVolumes` at batch level) |

### Why disjoint (and not overlapping)

An earlier encoding included ephemeral tuples in BOTH `purged_volumes` (as "evicted") and `new_volumes` (as "newly created"). Every ephemeral (account, asset) tuple paid its wire cost twice. On workloads with high ephemeral throughput (payout fan-out, escrow pass-throughs — 100+ ephemeral accounts per transaction is normal), the doubling adds a few MB/s of WAL / audit-chain growth for no functional benefit — the ephemeral net delta on `VolumeCount` is +0.

The disjoint encoding pays exactly `len(ephemeral)` per log instead of `2 × len(ephemeral)`. The three lists are still complete: consumers that need "everything evicted from Pebble" take the union (see [indexer consumer](#indexer-consumer)); the volume-count formula becomes clean subtraction (`new_kept − purged`) with ephemeral contributing zero.

### FSM emission

`internal/infra/state/write_set.go` computes the three sets during `Merge`:

1. `partitionVolumes(volumeUpdates)` (existing) yields `partResult.{kept, purged, transient}`.
2. `splitPurged(partResult.purged)` (new, in `write_set_new_volumes.go`) partitions `purged` further into:
- **ephemeral**: `!Old.IsDefined() || isVolumePreloadZero(Old.Value())` — the key had no prior state, was touched, immediately purged.
- **draining**: `Old.IsDefined() && !isVolumePreloadZero(Old.Value())` — had a prior non-zero balance, drained to zero, evicted.
3. `makeNewKeptKeySet(partResult.kept)` (new) yields the subset of `kept` where `Old` was undefined / zero-placeholder — i.e. new persistent volumes.
4. `buildTouchedByLog(volumes.Slots(), setX)` (new, generalised from the previous `buildPurgedByLog`) intersects the per-order touched-volume tracking with each of the three sets to produce the per-log annotation lists. Deduplication + deterministic sort by (account, asset) keeps the log payload byte-identical across nodes.

The three lists are injected into each `LedgerLog` inside the same `createdLogs` build loop that also injects `purged_volumes`. Note the audit hash chain does **not** cover `LedgerLog` content — it binds the audit header plus each item's order index, log sequence, and serialized order (see [Checker consumer](#checker-consumer) for what this means for tamper detection of the derived counters).

### The preload contract this depends on

The classification "new vs existing" is decided by the preloaded prior value at merge time — specifically `Update.Old.IsDefined()` combined with the zero-placeholder check. This is safe **because volume preload is structurally required by the FSM**: balance checks, Uint256 arithmetic and numscript resolution all read the current volume value, so admission has to preload every touched key. That contract is documented as invariant #6 in AGENTS.md.

The comparable metadata preload was removed opportunistically once the indexer no longer needed it — the corresponding `MetadataCount` counter had to be dropped (see the EN-1420 commit) because `Old.IsDefined()` no longer distinguished "new key" from "overwrite" on the metadata merge. The volume analog holds because the FSM cannot function without those old values.

## Usagestore Layout

```
[template_prefix][ledger 64B][template_name] → TemplateUsage proto
[counter_prefix][ledger 64B][counter_id 1B] → uint64 BE
[0xFE][0x01] → progress cursor (uint64 BE, last consumed audit sequence)
```

Full keyspace conventions in `internal/storage/usagestore/keys.go`. The `[ledger 64B]` block is zero-padded fixed-width (`dal.LedgerNameFixedSize`) so the comparer can extract a per-ledger prefix for bloom-filter scoping — same technique as the readstore.

## Consumers

### API — `GetLedgerStats` reader

`internal/application/ctrl/controller_default.go` — opens a single `usagestore.Snapshot` and routes all seven counter Gets through it. See [usagebuilder.md § Snapshot on the reader side](usagebuilder.md#snapshot-on-the-reader-side).

### API — template usage endpoint

`internal/adapter/http/handlers_get_numscript_usage.go` → `ctrl.Controller.GetTemplateUsage` → `usagestore.GetTemplateUsage`. Single point-read against the live store (no snapshot needed for a single-value query).

### Indexer consumer

`internal/application/indexbuilder/applied_proposal_sync.go`'s `extractPurgedVolumes` returns the UNION of `LedgerLog.PurgedVolumes ∪ LedgerLog.EphemeralVolumes` because both categories share the same downstream treatment (Pebble entry gone → skip acct→tx mapping). The protowire fast path (`protowire_postings.go`) parses field 6 alongside field 4 and exposes `GetEphemeralVolumes` on `parsedLog`.

### Checker consumer

`compareExclusionProjections` (`internal/application/check/checker.go`) accumulates `PurgedVolumes` and `EphemeralVolumes` from every log into the stored projection set, then compares against the exclusion set derived by replaying the audit chain (`AppliedProposal.TransientVolumes` union). Both eviction lists have identical semantics for the checker — the split is a pure log-payload compaction, not an invariant change. This pass verifies the exclusion projection *in the primary store* — the set the indexbuilder consumes.

### Why the usagestore counters are not a checker target (design decision, not a gap)

The checker (invariant #8) verifies projections persisted **in the primary Pebble store** — the store it operates on: `Volume`, `Metadata`, `Transaction`, `Reference`, `Boundary`, idempotency outcomes, the index registry, and the exclusion projection above. The usagestore is a **distinct, peer secondary Pebble instance** (`<data-dir>/usage/`) holding a *derived cache* of counters, rebuilt from the audit chain by the boot/tick fold, which folds forward from the persisted cursor (from an empty cursor, i.e. `usagestore.Reset()` or a wiped `<data-dir>/usage/`, that replays from audit sequence 0). There is no automatic rollback rewind — the audit chain is append-only, so the cursor never sits ahead of the head. Its authoritativeness is explicitly bounded by "eventually consistent with the FSM".

Consequently, the three volume-annotation categories are deliberately **not** given a dedicated usagestore checker pass:

- `NewKeptVolumes` has **no** primary-store consumer — it feeds only `CounterVolume` in the usagestore.
- The primary-store-relevant signal, the exclusion set `PurgedVolumes ∪ EphemeralVolumes` consumed by the indexbuilder, **is** already verified by `compareExclusionProjections` above. The indexbuilder consumes the union and is indifferent to the Purged-vs-Ephemeral split, so the union is the correct verification granularity for the primary store.

Corruption of a usagestore counter is therefore a *rebuild*, not an integrity incident: the recovery contract is "drop and replay from the audit chain", and the audit chain itself **is** cryptographically verified (`verifyAuditHashChain`). Serving a derived, rebuildable value through `GetLedgerStats` / `GetTemplateUsage` does not make it authoritative primary-store state. Extending audit-derived tamper coverage to the usagestore counters (which would require threading a new-volume collector through the shared `internal/domain/replay` package, also used by backup restore) is a separately-scoped effort tracked under EN-1422 — not a prerequisite for this subsystem.

## Metrics

Registered in `misc/devenv/monitoring-dashboards/jsonnet/lib/metrics.libsonnet`:

| Metric | Description |
|--------|-------------|
| `usage.builder.last_indexed_sequence` | Highest audit sequence the builder has committed for this replica. |
| `usage.builder.audit_last_sequence` | Highest audit sequence present in Pebble on this replica. |
| `usage.builder.lag` | Difference between the two (indicator of the eventual-consistency window). |
| `usagestore.level.bytes` / `memtable.bytes` / `cache.hits` / `cache.misses` | Pebble-internal metrics for the usagestore instance (parallel to the readindex namespace). |

The three progress gauges are registered through `tailworker.RegisterTailGauges` — the same helper the audit indexer uses — so the naming pattern (`{ns}.last_indexed_sequence`, `{ns}.{source}_last_sequence`, `{ns}.lag`) stays consistent across every tail-worker subsystem.
Loading
Loading