Skip to content
Open
Show file tree
Hide file tree
Changes from 34 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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ This document contains rules and conventions for AI agents working on this codeb
4. **Pebble writes only from the hot path or declared lifecycle paths** — `*dal.Store.OpenWriteSession()` is the only producer of `*dal.WriteSession`. Outside the FSM hot path, only declared lifecycle paths may call it: `internal/bootstrap/config_validation.go`, `internal/infra/backup/`, `internal/infra/attributes/prepare.go`. This is enforced by a `forbidigo` rule in `.golangci.yaml`; new call sites must be added to the exclusions block with a justification.
5. **Never delete cache entries outside of rotations** — Cache entries must only be evicted during generation rotations (Gen0 → Gen1 → discard). Deleting individual entries breaks the cache prediction mechanism (bloom filters, tombstones).
6. **Every FSM `Registry.X.Get(...)` must have a matching preload, declared by the component that proposes the command** — The FSM apply path reads from the in-memory cache; a cache miss turns the read into a silent no-op. Each component that emits a proposal (the metadata converter, the index builder, the cluster-config reconciler, the idempotency-eviction scheduler, the mirror worker, admission) is responsible for declaring its own `preload.Needs` covering every key its apply path will read. There is NO central proposal→needs registry — coupling the preload package to every proposal type creates a single point that easily falls behind. The component knows what it reads; the component declares it. The shared `proposeTechnical` helper takes a `*preload.Needs` parameter the caller fills in (pass nil or an empty `Needs` when the apply path has no cache-keyed reads, e.g. cluster config / idempotency eviction). The preload populates the cache via `MirrorPreload` with a fresh value read at propose time (Pebble fallback on cache miss), and `PredictedIndex` catches mutations between propose and apply.

**Volume preload is load-bearing for the `LedgerLog.new_kept_volumes` / `ephemeral_volumes` split.** Admission MUST preload the current value of every `(account, asset)` volume touched by an order, including postings resolved from numscript execution. The FSM classifies a volume as "newly created" by checking `Old.IsDefined()` (or the zero-placeholder via `isVolumePreloadZero`) at merge time — a silent cache miss on an existing volume produces a false-new classification, mis-routes the tuple between the `new_kept_volumes` / `ephemeral_volumes` / `purged_volumes` per-log lists, and inflates `usagestore.CounterVolume`. Because `usagestore` is a rebuildable peer side-store held **outside** checker scope (invariant #8 verifies only primary-Pebble-store projections), no checker pass would surface the drift; it persists until an explicit usage rebuild. That absence of a backstop makes the preload correctness even more load-bearing, not less. This is not opportunistic (unlike the metadata preload that was removed after the indexbuilder stopped needing it): balance checks, Uint256 arithmetic and numscript resolution all require the current volume value, so the preload is structurally mandatory. If a future refactor considers alleviating volume preload, `VolumeCount` must be re-hosted first (via computed-on-read or a subsystem-side seen-keys table) — see EN-1422 for the design rationale.
7. **Never silently skip a "should not happen" branch** — A branch that is reachable only if an invariant is violated (nil where the contract says non-nil, a state we believe unreachable, a cache miss after a guaranteed preload, etc.) MUST surface a loud signal: `return fmt.Errorf("invariant: ...")` so it bubbles up, or `assert.Unreachable(...)` for SUT-level invariants exercised under antithesis. A silent `return nil` / `continue` on these branches hides real bugs — particularly catastrophic in the FSM apply path, where a no-op desyncs nodes from each other. Branches that represent genuine runtime conditions (cache miss as an expected outcome, stale proposal, deleted entity) keep their soft `return nil`. The distinction is whether the case is *expected* (soft skip OK) or *impossible by design* (must fail loudly). The comment must say *why* the case is impossible so a reader can decide whether to add a hard fail or relax the rule.

8. **The audit log is the only source of truth — every other persisted dataset is a projection and must be verified by the checker** — Only `AuditEntry` (zone `Cold`, sub `Audit`) is cryptographically bound, via the hash chain that `state.BuildHashedHeaderPayload` + `processing.HashGenerator` produce and `checker.verifyAuditHashChain` verifies on every Check() run. Everything else stored in Pebble — `Log`, `AuditItem`, `AppliedProposal`, `LedgerLog.PurgedVolumes`, attribute caches (`Volume`, `Metadata`, `Transaction`, `Reference`, `Boundary`, etc.), reversion bitsets, idempotency keys, mirror cursors, chapters, bloom filters, signing keys, the read-side index — is a *projection* of orders that already live in the audit chain. Projections are rebuildable from the audit on demand, so we deliberately do NOT extend the hash chain to cover them (refactor over hash binding — see `feedback_audit_is_source_of_truth`). In exchange, **`internal/application/check/checker.go` MUST verify every projection it persists**: re-derive the value the projection should hold by replaying the audit (`ReplayLedgerLog`, `SimulateEphemeralPurge`, `partitionVolumes`, etc.) and compare to what is stored, emitting the matching `CHECK_STORE_ERROR_TYPE_*` event on divergence. A projection that the checker does not verify is a tampering vector — adding a new persisted projection without a matching compare* / collect* pass in the checker is the violation. The current passes are `compareVolumes`, `compareMetadata`, `compareTransactions`, `compareExclusionProjections` (AppliedProposal.TransientVolumes + LedgerLog.PurgedVolumes), `checkReversionInvariants`, `verifySealingHash`, `compareIdempotencyOutcomes` (frozen idempotency outcomes in SubIdempKeys vs the hash-chained AuditFailure/AuditSuccess that wrote them — the failure kind is re-derived from the chain-bound reason via `domain.KindForReason`, never stored), and `compareIndexes` (SubAttrIndex registry vs CreateIndex/DropIndex/RemovedMetadataFieldType/DeleteLedger logs — covers presence + identity; BuildStatus is intentionally excluded because it is purely informational on the cluster-wide registry entry — queries gate on the per-replica `IndexVersionState.CurrentVersion`, not on BuildStatus); extend the list as new persisted projections land.
Expand Down
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.
Loading
Loading