From 8dc6476fceb945432104ed03d19ffaad1337fe19 Mon Sep 17 00:00:00 2001 From: Geoffrey Ragot Date: Thu, 2 Jul 2026 12:24:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(EN-1242):=20lazy=20Del=20promote=20+=20gen0?= =?UTF-8?q?=E2=86=92gen1=20read=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the systematic MirrorTouch-at-Preload pass with two in-place primitives on AttributeCache: * Get: gen0 → gen1 fallback. Safe under the coverage_bits gate (invariant #9) — the fallback can only surface keys the proposer explicitly declared. * Del: tombstones in Gen0 when Gen0 hits; otherwise lazy-fabricates a Gen0 tombstone borrowing Gen1's tag. Gen1's live row stays untouched (shadowed by the Gen0 tombstone, purged on the next rotation). writeCacheTombstone still writes a single row to the Gen0 byte — memory equals disk (invariant #1). Preload skips coverage-only AttributeCoverage entries entirely (only seed intents call MirrorPreload). The MirrorTouch method / plumbing and the AttributeCache.Touch / Cache.TouchByType helpers are deleted as dead code. The CacheHit verdict collapses "already in Gen0" and "Gen1-only" into a single admission signal; the FSM's read horizon is still bounded to admission's declared preload set by coverage_bits. CacheUnreachable (rejects proposals with ≥2 predicted rotations) keeps the propose→apply race window bounded to at most one rotation, which is what the fallback + lazy fabrication rely on. Doc: docs/technical/architecture/core/plan-intent-verification.md Regression: internal/infra/cache/keystore_delete_test.go + internal/infra/state/cache_snapshotter_test.go (TestCacheSnapshotter_EN1242_DeleteAfterRotationCrashRestart) --- .../core/plan-intent-verification.md | 118 ++++ .../admission/admission-cache-horizon.md | 11 +- .../subsystems/admission/idempotency.md | 2 +- .../subsystems/fsm/cache-layers.md | 13 +- .../architecture/subsystems/fsm/preload.md | 6 +- internal/application/admission/admission.go | 169 ++--- .../application/admission/admission_test.go | 171 +---- .../admission/wrapper_dispatch_test.go | 60 +- internal/application/check/checker_test.go | 4 +- internal/application/check/replay_store.go | 64 +- .../application/check/replay_store_test.go | 22 +- internal/application/events/emitter.go | 2 +- internal/application/mirror/worker.go | 34 +- internal/bootstrap/backup_proposer.go | 4 +- internal/bootstrap/config.go | 2 +- internal/bootstrap/module.go | 2 +- internal/bootstrap/propose_technical.go | 2 +- internal/domain/errors.go | 2 +- internal/domain/indexes/lookup.go | 14 +- .../domain/indexes/lookup_generated_test.go | 14 +- internal/domain/indexes/lookup_test.go | 6 +- internal/domain/processing/accessor.go | 12 +- .../processing/processor_helpers_test.go | 4 +- internal/domain/processing/processor_index.go | 4 +- .../processing/processor_ledger_metadata.go | 4 +- .../domain/processing/processor_metadata.go | 4 +- .../processing/processor_metadata_schema.go | 4 +- .../domain/processing/processor_mirror.go | 6 +- .../processing/processor_prepared_query.go | 4 +- .../processor_revert_transaction.go | 43 +- .../processing/processor_transaction.go | 1 + internal/domain/processing/store.go | 6 +- internal/domain/replay/replay.go | 4 +- internal/domain/replay/writer.go | 2 +- internal/infra/attributes/key_store.go | 40 +- internal/infra/attributes/key_store_test.go | 8 +- internal/infra/backup/rebuild.go | 3 +- internal/infra/cache/cache.go | 176 +++-- internal/infra/cache/cache_test.go | 203 ++---- internal/infra/cache/keystore_delete_test.go | 120 ++++ internal/infra/node/applier_test.go | 6 +- internal/infra/plan/attribute_resolvers.go | 8 +- internal/infra/plan/builder.go | 76 +-- internal/infra/plan/builder_test.go | 162 +---- internal/infra/plan/coverage.go | 116 ++++ internal/infra/plan/coverage_bits.go | 24 +- internal/infra/plan/coverage_bits_test.go | 99 ++- internal/infra/plan/errors.go | 10 +- internal/infra/plan/needs.go | 130 ---- internal/infra/plan/planerr/errors.go | 18 + internal/infra/plan/resolve.go | 153 ++--- internal/infra/plan/runner.go | 12 +- internal/infra/plan/runner_test.go | 4 +- internal/infra/plan/write_operation.go | 12 +- internal/infra/state/accessor.go | 22 +- internal/infra/state/cache_aware_entry.go | 2 +- internal/infra/state/cache_incremental.go | 21 +- internal/infra/state/cache_snapshotter.go | 91 +-- .../infra/state/cache_snapshotter_test.go | 203 +++--- .../state/cache_touch_persistence_test.go | 190 +----- internal/infra/state/machine.go | 59 +- .../state/machine_cache_divergence_test.go | 24 +- .../infra/state/machine_cache_restart_test.go | 2 +- internal/infra/state/machine_test.go | 50 +- internal/infra/state/scope.go | 32 +- internal/infra/state/scope_test.go | 35 +- internal/infra/state/scope_unit_test.go | 32 +- internal/infra/state/write_set.go | 2 +- .../infra/state/write_set_ephemeral_purge.go | 2 +- internal/infra/state/write_set_test.go | 8 +- internal/pkg/kv/kv.go | 7 +- internal/pkg/kv/sharded_map.go | 6 +- internal/pkg/kv/sharded_map_test.go | 36 +- internal/proto/commonpb/common.pb.go | 172 ++--- internal/proto/commonpb/common_dethash.pb.go | 9 + internal/proto/commonpb/common_reader.pb.go | 5 + internal/proto/commonpb/common_vtproto.pb.go | 76 +++ internal/proto/raftcmdpb/raft_cmd.pb.go | 616 +++++++---------- .../proto/raftcmdpb/raft_cmd_dethash.pb.go | 24 +- .../proto/raftcmdpb/raft_cmd_reader.pb.go | 176 +---- .../proto/raftcmdpb/raft_cmd_vtproto.pb.go | 627 +----------------- misc/proto/common.proto | 7 + misc/proto/raft_cmd.proto | 68 +- .../e2e/business/ephemeral_purge_race_test.go | 2 +- .../e2e/business/prepared_query_race_test.go | 2 +- tests/e2e/cluster/cache_divergence_test.go | 4 +- 86 files changed, 1955 insertions(+), 2857 deletions(-) create mode 100644 docs/technical/architecture/core/plan-intent-verification.md create mode 100644 internal/infra/cache/keystore_delete_test.go create mode 100644 internal/infra/plan/coverage.go delete mode 100644 internal/infra/plan/needs.go create mode 100644 internal/infra/plan/planerr/errors.go diff --git a/docs/technical/architecture/core/plan-intent-verification.md b/docs/technical/architecture/core/plan-intent-verification.md new file mode 100644 index 000000000..268af386e --- /dev/null +++ b/docs/technical/architecture/core/plan-intent-verification.md @@ -0,0 +1,118 @@ +# Plan intent verification + +**Status**: in force since EN-1242. +**Owning code**: `internal/infra/plan/`, `internal/infra/state/machine.go` (Preload path), `internal/infra/state/cache_snapshotter.go`, `internal/infra/cache/cache.go` (`AttributeCache.Get` / `AttributeCache.Del`). +**Related invariants**: [#1 (cache is authority)](../../../CLAUDE.md), [#3 (no Pebble reads on hot path)](../../../CLAUDE.md), [#6 (every FSM read has a preload)](../../../CLAUDE.md), [#7 (never silently skip)](../../../CLAUDE.md), [#9 (never bypass the coverage gate)](../../../CLAUDE.md). + +## TL;DR + +`AttributeCoverage.value` is optional: + +- **`value` set** — seeding action. Admission's Pebble scan found a value; the FSM's `MirrorPreload` writes it into Gen0+Gen1 with gen1-wins semantics. +- **`value` nil** — coverage-only entry. Admission declares "this key is in scope for the emitting order" so the per-order `coverage_bits` (invariant #9) admit reads and deletes against it. **Preload skips coverage-only entries**: no promote pass runs at all. Reads rely on `AttributeCache.Get`'s gen0→gen1 fallback; deletes rely on `AttributeCache.Del`'s lazy Gen0-tombstone fabrication from Gen1's tag. + +Correctness is anchored by two out-of-band guardrails: the **admission-side `CacheUnreachable` verdict** bounds the propose→apply window to at most one cache rotation, and the **coverage gate (invariant #9)** ensures the FSM apply path only reads keys admission actually declared. + +## The problem + +On the pre-fix code (release/v3.0), a `DeleteMetadata` on a key that had migrated to Gen1 via rotation left the cache and disk out of sync — a violation of invariant #1 (cache is the source of authority; every node must see the same cache state for a given applied index): + +``` +T0 Admission builds plan for Delete(X.k) + CheckCache(nextIndex, id) → CacheMiss (Gen0∅, Gen1∅, Pebble∅) + → resolver emits a coverage-only entry (no cache seed) + +T1 Concurrent Save(X.k, v) applies at raftIndex M + → Gen0[id] = v (via KeyStore.Put) + +T2 Rotation fires (raftIndex crosses cache-rotation-threshold) + → old Gen0 becomes new Gen1 + → new Gen0 = ∅ + → net state: Gen1[id] = v (live), Gen0[id] = ∅ + +T3 Delete(X.k) applies at raftIndex N > M + KeyStore.Delete → s.M.Get(id) hits Gen1 via fallback → passes tag check + → s.M.Put(id, entry{Deleted=true}) writes to Gen0 only + Mem net: Gen0[id] = tombstone (fabricated via Put), + Gen1[id] = v (live, untouched) + Disk net: writeCacheTombstone stamps a tombstone at BOTH gen bytes. + → Mem Gen1 = live, Disk Gen1 byte = tombstone. Cache-mirror invariant + broken until the next rotation or restart re-hydrates Gen1. +``` + +Between T3 and the next rotation the follower and leader can diverge on any read of `X.k` if one has been through a restore-from-store (sees the disk tombstone) and the other has not (still holds the live Gen1 row) — a live-vs-tombstone split on a hash-chained order. + +## The fix has two parts + +**1. Admission bounds the race window.** `CheckCache` returns `CacheUnreachable` when 2+ generation rotations are predicted between propose-time and apply-time. Admission rejects the proposal with `plan.ErrCacheHorizonExceeded` (gRPC `Unavailable`, HTTP 503 + `Retry-After: 1`) so the client re-admits against a fresh snapshot. This is what makes the propose→apply window a bounded race — at most one rotation, never two. + +**2. `AttributeCache.Get` and `AttributeCache.Del` handle the bounded race in place.** With the race bounded to a single rotation, everything that could exist for a declared key is still somewhere in Gen0 or Gen1. Two primitives absorb the concern without a Preload-time promote pass: + +- `AttributeCache.Get` falls back Gen0 → Gen1 when Gen0 misses. Reads on a rotated entry surface it directly. +- `AttributeCache.Del` first tries to tombstone in place in Gen0; on Gen0 miss it *fabricates* a Gen0 tombstone borrowing Gen1's tag. `writeCacheTombstone` writes a single row to the Gen0 byte — the in-memory tombstone and the on-disk tombstone stay byte-equivalent for the same applied index (invariant #1). + +Applied to the race above, T3 now becomes: + +``` +T3 Delete(X.k) applies at raftIndex N > M + FSM Preload: coverage-only entry → skipped (no cache mutation). + processDeleteMetadata: + s.AccountMetadata().Get(metaKey) → v (Gen0 miss, Gen1 fallback) + s.AccountMetadata().Delete(metaKey) + → KeyStore.Delete → AttributeCache.Del(id) + → Gen0 miss, Gen1 hit → fabricate Gen0 tombstone (borrow Gen1's tag) + → writeCacheTombstone(gen0Byte, ...) mirrors to Pebble +``` + +Gen1's live row is intentionally left untouched: the Gen0 tombstone shadows it on every read (Get returns the tombstone via the gen0→gen1 fallback — Gen0 hits first, and a tombstone surfaces as `ErrNotFound`), and rotation purges the stale Gen1 row on the next generation flip. + +## The two guardrails and why they matter + +### `CacheUnreachable` (admission-side) + +The fix relies on the concurrent Save's value still being *somewhere* in cache when the delete runs. If two rotations fire between propose and apply, the value is dropped entirely (moved to Gen1 by rotation 1, discarded by rotation 2), and Del would find nothing to fabricate a tombstone from. `CacheUnreachable` prevents this: it rejects the proposal with `plan.ErrCacheHorizonExceeded` when 2+ rotations are predicted, forcing a retry against a fresh admission snapshot. Under a correctly tuned rotation threshold and a healthy apply rate, this should not fire — recurring occurrences indicate either a too-low threshold or FSM apply falling behind admission. + +See `internal/infra/cache/cache.go` `CheckCache` / `internal/infra/plan/planerr/errors.go` `ErrCacheHorizonExceeded`. + +### Coverage gate (invariant #9) + +Every cache-attribute read on the FSM hot path goes through `Scope.GetX(...)` so the per-order `coverage_bits` admit it. The gate enforces that admission's declared preload set is the FSM's only legitimate read horizon. This is what keeps the coverage-only + lazy-Get/Del model honest: an order that reads a key admission didn't declare is rejected at the gate, not silently surfaced by the Gen1 fallback. Combined with the boundedness of the race window, coverage guarantees that "declared but not seeded" is a safe state — either the cache had the value all along and `Get`/`Del` surface it, or the key is genuinely absent and the handler gets a clean `ErrNotFound`. + +## Read semantics — gen0→gen1 fallback + +`AttributeCache.Get` returns Gen0 if present, otherwise falls back to Gen1. The fallback is safe under the coverage gate (invariant #9): the FSM apply path only reaches Get through `Scope.GetX`, and the gate rejects reads on keys the admission-declared coverage_bits didn't authorize. The fallback can only surface keys the proposer explicitly declared. + +Callers that need to distinguish Gen0 from Gen1 explicitly (`MirrorPreload`'s gen1-wins seed decision, snapshot persistence, cache-restore diagnostics) use the `Gen0()` / `Gen1()` accessors. + +## `value` is pure seeding + +`value` is the only case where an AttributeCoverage carries a payload. Admission's Pebble scan resolved a fresh value, so the FSM seeds Gen0+Gen1 via `MirrorPreload`. Gen1-wins semantics apply: if a concurrent Save committed and populated Gen1 with a fresher value between admission's scan and apply, `MirrorPreload` skips seeding Gen0 (gen1 wins), and the FSM apply path reads the fresher value through the gen0→gen1 fallback. + +Verifying `value` at Preload would require either a Pebble re-read (violates invariant #3) or a hash comparison against the seed (adds cost without a known bug to fix). The gen1-wins path is enough. + +## Delete-like handlers + +Delete cascades route through `KeyStore.Delete → AttributeCache.Del`. Admission declares coverage with `p.Add(dal.SubAttrX, key.Bytes())`; `AttributeCache.Del` handles the lazy Gen0-tombstone fabrication from Gen1's tag when needed. The relevant sites are enumerated as a checklist so new deletes don't drift: + +| Order / cascade | Cache | Admission emission site | +|---|---|---| +| `DeleteMetadata` (Account target) | AccountMetadata | `admission.go` `LedgerApplyOrder_DeleteMetadata` | +| `DeleteLedgerMetadata` | LedgerMetadata | `admission.go` `LedgerScopedOrder_DeleteLedgerMetadata` | +| `MirrorIngest.DeletedMetadata` (Account) | AccountMetadata | `admission.go` + `mirror/worker.go` | +| `DeletePreparedQuery` | PreparedQueries | `admission.go` `LedgerScopedOrder_DeletePreparedQuery` | +| `DropIndex` | Indexes | `admission.go` `LedgerApplyOrder_DropIndex` | +| `RemoveMetadataFieldType` (index cascade) | Indexes | `admission.go` `LedgerApplyOrder_RemoveMetadataFieldType` | +| `DeleteLedger` (Boundaries cascade) | Boundaries | `admission.go` `LedgerScopedOrder_DeleteLedger` | +| `RemoveEventsSink` (SinkConfigs cascade) | SinkConfigs | `admission.go` `SystemScopedOrder_RemoveEventsSink` | + +Any new Del site MUST declare coverage for the deleted key. Coverage alone is sufficient — Del's lazy fabrication and Get's fallback handle the race safety. + +## Cross-references + +- Proto definitions: `misc/proto/raft_cmd.proto` — `AttributeCoverage` (optional `value` field) +- Emission: `internal/infra/plan/resolve.go` — `resolveCoverage` maps `CheckCache` verdicts to seed / coverage-only entries +- Verification: `internal/infra/state/machine.go` `Preload` — one dispatch per plan entry (skip when `value` is nil) +- Cache primitives: `internal/infra/cache/cache.go` — `AttributeCache.Get` (gen0→gen1 fallback), `AttributeCache.Del` (in-place tombstone + lazy Gen0 fabrication), `CheckCache` (returns `CacheUnreachable` for 2+ rotation prediction) +- Admission guard: `internal/infra/plan/planerr/errors.go` `ErrCacheHorizonExceeded` — the admission rejection sentinel +- Regression harness: `tests/antithesis/run_model_test.sh` (singleton_driver_model exercises the delete-after-rotation flow through `CheckCache` + `AttributeCache.Del` under fault injection) +- Adapter mappings: `internal/adapter/grpc/server.go` (`codes.Unavailable`), `internal/adapter/http/error_handler.go` (503 + `Retry-After: 1`) diff --git a/docs/technical/architecture/subsystems/admission/admission-cache-horizon.md b/docs/technical/architecture/subsystems/admission/admission-cache-horizon.md index 231a45c61..fd435418c 100644 --- a/docs/technical/architecture/subsystems/admission/admission-cache-horizon.md +++ b/docs/technical/architecture/subsystems/admission/admission-cache-horizon.md @@ -8,14 +8,15 @@ When admission builds a proposal's preload plan, it queries the dual-generation cache (`AttributeCache.CheckCache(at, key)`) to decide, per key, whether to -emit a `Declare`, a `Touch`, or a `Preload`. The decision compares the -predicted apply-time generation `Gen(at, threshold)` against the FSM's current -applied generation: +emit a coverage-only `AttributeCoverage` (value nil) or a seed +`AttributeCoverage` (value set). The decision compares the predicted +apply-time generation `Gen(at, threshold)` against the FSM's current applied +generation: | `Gen(at) − currentGen` | `CheckCache` result | Plan emitted | |---|---|---| -| 0 | `CacheGuaranteed` / `CacheNeedsTouch` / `CacheMiss` | `Declare` / `Touch` / `Preload` | -| 1 | `CacheGuaranteed` / `CacheMiss` | `Declare` / `Preload` | +| 0 | `CacheHit` / `CacheMiss` | coverage-only (`value = nil`) / seed (`value` set) | +| 1 | `CacheHit` (Gen0-hit only) / `CacheMiss` | coverage-only / seed | | **≥ 2** | **`CacheUnreachable`** | **proposal rejected at admission** | The ≥ 2 case is the "cache horizon exceeded" condition this document is about. diff --git a/docs/technical/architecture/subsystems/admission/idempotency.md b/docs/technical/architecture/subsystems/admission/idempotency.md index 67c358263..458927c15 100644 --- a/docs/technical/architecture/subsystems/admission/idempotency.md +++ b/docs/technical/architecture/subsystems/admission/idempotency.md @@ -114,7 +114,7 @@ FSM (apply) ─── in-memory map (bridge) ──────────>│ ### Preloading -During admission, idempotency keys are loaded directly from Pebble (no bloom filter, no dual-generation cache). The preload logic is in `internal/infra/preload/preloader.go` and emits a `ReloadIdempotencyKey` on a dedicated channel of `ExecutionPlan` (separate from the `AttributePlan` channel that drives the coverage gate): +During admission, idempotency keys are loaded directly from Pebble (no bloom filter, no dual-generation cache). The preload logic is in `internal/infra/preload/preloader.go` and emits a `ReloadIdempotencyKey` on a dedicated channel of `ExecutionPlan` (separate from the `AttributeCoverage` channel that drives the coverage gate): ```go value, err := state.LoadIdempotencyKey(reader, ik.Key) diff --git a/docs/technical/architecture/subsystems/fsm/cache-layers.md b/docs/technical/architecture/subsystems/fsm/cache-layers.md index 38cebf4b0..89c1503cc 100644 --- a/docs/technical/architecture/subsystems/fsm/cache-layers.md +++ b/docs/technical/architecture/subsystems/fsm/cache-layers.md @@ -136,10 +136,15 @@ The cache and Pebble are mutated by two paths during apply, both honoring the sa (before processing) (after all handlers — tech + orders) ┌──────────────────────┐ ┌──────────────────────────────────────────┐ │ CacheSnapshotter │ │ WriteSet.Merge │ - │ • MirrorTouch │ │ • derived.Merge() → writer.Put → │ - │ Gen1→Gen0 │ │ KeyStore.Put → Gen0 (immediate) │ - │ • MirrorPreload │ │ • mergeSimpleWithCache → batch 0xF1+0xFF │ - │ raw→Gen0+Gen1 │ │ • SaveLedger / chapter writes / … │ + │ • MirrorPreload │ │ • derived.Merge() → writer.Put → │ + │ (seed entries only)│ │ KeyStore.Put → Gen0 (immediate) │ + │ raw→Gen0+Gen1 │ │ • mergeSimpleWithCache → batch 0xF1+0xFF │ + │ Coverage-only entries│ │ • KeyStore.Delete → AttributeCache.Del │ + │ are skipped: Get │ │ (Gen0-hit tombstones in place; │ + │ falls back Gen0→Gen1 │ │ Gen1-hit lazy-fabricates Gen0 │ + │ and Del lazy- │ │ tombstone with Gen1's tag) → │ + │ fabricates the │ │ writeCacheTombstone → 0xFF gen0Byte │ + │ Gen0 tombstone. │ │ • SaveLedger / chapter writes / … │ │ │ │ │ │ │ │ One Merge drains BOTH tech-update writes │ │ │ │ AND order writes — single atomic batch. │ diff --git a/docs/technical/architecture/subsystems/fsm/preload.md b/docs/technical/architecture/subsystems/fsm/preload.md index d9d9a5e30..b7f392f3c 100644 --- a/docs/technical/architecture/subsystems/fsm/preload.md +++ b/docs/technical/architecture/subsystems/fsm/preload.md @@ -84,13 +84,13 @@ The attribute cache (`internal/infra/cache/cache.go`) holds two generations: - **Gen0** — the active generation, populated on every preload. - **Gen1** — the previous generation, kept for `--cache-rotation-threshold` Raft indices after rotation. -`CheckCache(at, k)` (`cache.go:157-198`) returns one of three states: +`CheckCache(at, k)` returns one of three states: | State | Meaning | |-------|---------| -| `CacheGuaranteed` | Cache is authoritative for `k` at Raft index `at`. | -| `CacheNeedsTouch` | Cache may have rotated; the loader must re-verify against Pebble. | +| `CacheHit` | Cache holds `k` somewhere within the reachable horizon (Gen0 hit, or Gen1 hit — `AttributeCache.Get`'s gen0→gen1 fallback surfaces it at apply time). | | `CacheMiss` | Cache does not hold a value for `k`. The FSM treats this as `ErrNotFound`. | +| `CacheUnreachable` | Target index is 2+ generations ahead — admission rejects the proposal with `plan.ErrCacheHorizonExceeded`. | The FSM apply path **never** reads Pebble on a `CacheMiss` — the call is a silent no-op for that read. This is enforced structurally: the apply path holds a `*dal.WriteSession`, which deliberately has no `Get`/`NewIter` methods. Only Pebble's `dal.Store.OpenWriteSession()` produces a session, and only declared lifecycle paths may call it (see [CLAUDE.md invariant #4](../../../../../AGENTS.md)). diff --git a/internal/application/admission/admission.go b/internal/application/admission/admission.go index d94c9c489..85e3eabb2 100644 --- a/internal/application/admission/admission.go +++ b/internal/application/admission/admission.go @@ -365,13 +365,13 @@ func (a *Admission) Admit(ctx context.Context, req *servicepb.ApplyRequest) ([]* )) // Build the per-order WriteOperation slice. Each operation carries - // its Needs (for preload aggregation) and a SetCoverage closure + // its Coverage (for preload aggregation) and a SetCoverage closure // that the runner invokes at marshal time to write the computed // bitset onto Order.CoverageBits. operations := make([]plan.WriteOperation, len(orders)) for i := range orders { operations[i] = plan.WriteOperation{ - Needs: perOrder[i], + Coverage: perOrder[i], SetCoverage: func(bits []byte) { cmd.GetOrders()[i].CoverageBits = bits }, @@ -392,7 +392,7 @@ func (a *Admission) Admit(ctx context.Context, req *servicepb.ApplyRequest) ([]* var storeReads int64 for _, plan := range build.ExecutionPlan.GetAttributes() { - if _, ok := plan.GetIntent().(*raftcmdpb.AttributePlan_Value); ok { + if plan.GetValue() != nil { storeReads++ } } @@ -413,7 +413,7 @@ func (a *Admission) Admit(ctx context.Context, req *servicepb.ApplyRequest) ([]* // patches PredictedIndex onto the pre-marshaled buffer (or // re-marshals on the rare boundary-shift rebuild). // - // Per-order coverage bits depend on the final AttributePlan slice + // Per-order coverage bits depend on the final AttributeCoverage slice // (positions in cmd.ExecutionPlan.Attributes), and AcquireProposalGuard may // swap cmd.ExecutionPlan for a rebuilt ExecutionPlan on a generation shift. // Compute the bits inside marshalFn so every (re-)marshal sees the @@ -778,7 +778,7 @@ func wrapSystemScoped(order *raftcmdpb.Order, ss *raftcmdpb.SystemScopedOrder) { // balance (see `processing.readVolumeOrZero`). A `*state.ErrCoverageMiss` // (admission contract violation — need never declared) stays distinct // and propagates loud through `ErrStorageOperation{Cause: covErr}`. -func addVolumeNeed(p *plan.Needs, ledgerName string, account, asset string) { +func addVolumeNeed(p *plan.Coverage, ledgerName string, account, asset string) { p.Add(dal.SubAttrVolume, domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: account}, Asset: asset, @@ -787,17 +787,17 @@ func addVolumeNeed(p *plan.Needs, ledgerName string, account, asset string) { // addTransactionTargetNeeds preloads the TransactionState entry for a // TargetTransaction so the FSM can read it from cache. -func addTransactionTargetNeeds(p *plan.Needs, ledgerName string, txID uint64) { +func addTransactionTargetNeeds(p *plan.Coverage, ledgerName string, txID uint64) { p.Add(dal.SubAttrTransaction, domain.TransactionKey{ LedgerName: ledgerName, ID: txID, }.Bytes()) } -// extractLedgerScopedNeeds populates the preload Needs for a ledger-scoped +// extractLedgerScopedNeeds populates the preload Coverage for a ledger-scoped // order. The ledger lives once on the wrapper; every payload variant reads it // from there instead of carrying its own field. -func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { +func extractLedgerScopedNeeds(p *plan.Coverage, ls *raftcmdpb.LedgerScopedOrder) { ledgerName := ls.GetLedger() ledgerKey := domain.LedgerKey{Name: ledgerName} @@ -808,10 +808,16 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { p.Add(dal.SubAttrLedger, ledgerBytes) case *raftcmdpb.LedgerScopedOrder_DeleteLedger: p.Add(dal.SubAttrLedger, ledgerBytes) - // LogPayload_DeleteLedger cascades into - // Derived.Boundaries.Delete → WriteSet.Merge → KeyStore.Delete - // → strict AttributeCache.Del. Preload the boundary attribute - // so Gen0 holds the entry at apply time (invariant #6). + // LogPayload_DeleteLedger cascades via WriteSet.Absorb into + // b.Derived.Boundaries.Delete(...) directly (NOT via + // gatedAccessor.Delete — the Absorb path calls the concrete + // DerivedKeyStore), which flushes at Merge to + // KeyStore.Delete → AttributeCache.Del. The coverage gate is + // therefore NOT enforced on this cascade; the Boundary preload + // declared here is what makes the FSM's cache read horizon match + // admission's intent under invariant #6. AttributeCache.Del still + // lazy-fabricates a Gen0 tombstone from Gen1's tag if a + // concurrent write raced with the rotation. p.Add(dal.SubAttrBoundary, ledgerBytes) case *raftcmdpb.LedgerScopedOrder_PromoteLedger: p.Add(dal.SubAttrLedger, ledgerBytes) @@ -863,8 +869,9 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { case *commonpb.Target_Account: // Mirror-ingested v2 DELETE_METADATA log applies via // processMirrorDeletedMetadata → AccountMetadata.Delete - // → strict AttributeCache.Del. Declare coverage so Gen0 - // holds the entry at apply (invariant #6). + // → AttributeCache.Del. Declare coverage; Del itself + // lazy-fabricates a Gen0 tombstone from Gen1's tag when + // only Gen1 has the entry. p.Add(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: target.Account.GetAddr()}, Key: dm.GetKey(), @@ -888,8 +895,9 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { case *raftcmdpb.LedgerScopedOrder_DeletePreparedQuery: p.Add(dal.SubAttrLedger, ledgerBytes) // processDeletePreparedQuery calls PreparedQueries.Delete → - // strict AttributeCache.Del. Declare coverage so Gen0 holds - // the entry at apply (invariant #6). + // AttributeCache.Del. Declare coverage; Del itself lazy- + // fabricates a Gen0 tombstone from Gen1's tag if a concurrent + // Create + rotation raced with admission. p.Add(dal.SubAttrPreparedQuery, domain.PreparedQueryKey{LedgerName: ledgerName, Name: payload.DeletePreparedQuery.GetName()}.Bytes()) case *raftcmdpb.LedgerScopedOrder_SaveNumscript: p.Add(dal.SubAttrLedger, ledgerBytes) @@ -910,8 +918,10 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { } case *raftcmdpb.LedgerScopedOrder_DeleteLedgerMetadata: p.Add(dal.SubAttrLedger, ledgerBytes) - // Delete's apply calls strict-Del at KeyStore level (invariant #6). - // Declare coverage so Gen0 holds the entry at apply. + // Delete's apply calls KeyStore.Delete → AttributeCache.Del. + // Declare coverage (invariant #6 / #9); Del itself lazy- + // fabricates a Gen0 tombstone from Gen1's tag if a concurrent + // Save + rotation raced with admission. p.Add(dal.SubAttrLedgerMetadata, domain.LedgerMetadataKey{LedgerName: ledgerName, Key: payload.DeleteLedgerMetadata.GetKey()}.Bytes()) case *raftcmdpb.LedgerScopedOrder_Apply: p.Add(dal.SubAttrBoundary, ledgerBytes) @@ -981,8 +991,10 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { case *raftcmdpb.LedgerApplyOrder_DeleteMetadata: if target, ok := applyData.DeleteMetadata.GetTarget().GetTarget().(*commonpb.Target_Account); ok { // Account-metadata Delete's apply routes through - // KeyStore.Delete → strict AttributeCache.Del (requires Gen0 - // to hold the entry — invariant #6). Declare coverage. + // KeyStore.Delete → AttributeCache.Del. Declare + // coverage (invariant #6 / #9); Del itself lazy- + // fabricates a Gen0 tombstone from Gen1's tag if a + // concurrent Save + rotation raced with admission. p.Add(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: ledgerName, Account: target.Account.GetAddr()}, Key: applyData.DeleteMetadata.GetKey(), @@ -1003,8 +1015,10 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { case *raftcmdpb.LedgerApplyOrder_DropIndex: // processDropIndex calls DeleteIndex unconditionally. - // indexes.Remove → w.Delete → strict AttributeCache.Del. - // Declare coverage so Gen0 holds the entry at apply. + // indexes.Remove → w.Delete → AttributeCache.Del. + // Declare coverage; Del itself lazy-fabricates a Gen0 + // tombstone from Gen1's tag across a + // CreateIndex→DropIndex race. p.Add(dal.SubAttrIndex, domain.IndexKey{ LedgerName: ledgerName, Canonical: indexes.Canonical(applyData.DropIndex.GetId()), @@ -1022,8 +1036,10 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { case *raftcmdpb.LedgerApplyOrder_RemoveMetadataFieldType: // Removing a schema field cascades into dropping the index; // processRemoveMetadataFieldType probes the registry first. - // The cascade Find→indexes.Remove reaches strict Del on hit — - // declare coverage. + // The cascade Find→indexes.Remove reaches Del on hit. + // Declare coverage; Del itself lazy-fabricates a Gen0 + // tombstone from Gen1's tag across a + // CreateIndex→RemoveMetadataFieldType race. p.Add(dal.SubAttrIndex, domain.IndexKey{ LedgerName: ledgerName, Canonical: indexes.Canonical(indexes.MetadataID(applyData.RemoveMetadataFieldType.GetTargetType(), applyData.RemoveMetadataFieldType.GetKey())), @@ -1045,20 +1061,26 @@ func extractLedgerScopedNeeds(p *plan.Needs, ls *raftcmdpb.LedgerScopedOrder) { } } -// extractSystemScopedNeeds populates the preload Needs for a system-scoped +// extractSystemScopedNeeds populates the preload Coverage for a system-scoped // order. Only sink-config orders contribute preload keys today; every other // variant is enumerated as an explicit no-op so adding a new payload // without a matching case here trips the loud default — matching the // invariant-7 contract that an unmapped wrapper variant must fail loudly // rather than degrade to a silent cache miss at apply time. -func extractSystemScopedNeeds(p *plan.Needs, ss *raftcmdpb.SystemScopedOrder) { +func extractSystemScopedNeeds(p *plan.Coverage, ss *raftcmdpb.SystemScopedOrder) { switch payload := ss.GetPayload().(type) { case *raftcmdpb.SystemScopedOrder_AddEventsSink: p.Add(dal.SubAttrSinkConfig, domain.SinkConfigKey{Name: payload.AddEventsSink.GetConfig().GetName()}.Bytes()) case *raftcmdpb.SystemScopedOrder_RemoveEventsSink: - // LogPayload_RemovedEventsSink cascades into - // Derived.SinkConfigs.Delete → WriteSet.Merge → KeyStore.Delete - // → strict AttributeCache.Del. Declare coverage. + // LogPayload_RemovedEventsSink cascades via WriteSet.Absorb into + // b.Derived.SinkConfigs.Delete(...) directly (NOT via + // gatedAccessor.Delete — the Absorb path calls the concrete + // DerivedKeyStore), then flushes at Merge to KeyStore.Delete → + // AttributeCache.Del. The coverage gate is therefore NOT enforced + // on this cascade; the SinkConfig preload declared here is what + // makes the FSM's cache read horizon match admission's intent + // under invariant #6. Del itself lazy-fabricates a Gen0 tombstone + // from Gen1's tag across an Add→Remove race. p.Add(dal.SubAttrSinkConfig, domain.SinkConfigKey{Name: payload.RemoveEventsSink.GetName()}.Bytes()) // Explicit no-op cases: every other system-scoped variant intentionally @@ -1092,14 +1114,14 @@ func extractSystemScopedNeeds(p *plan.Needs, ss *raftcmdpb.SystemScopedOrder) { } // extractPreloadNeeds extracts all preload keys from orders in a single pass. -// Returns the proposal-wide aggregate Needs and a parallel slice with one -// Needs per order (used to compute Order.coverage_bits after Build). -func (a *Admission) extractPreloadNeeds(ctx context.Context, orders []*raftcmdpb.Order) (*plan.Needs, []*plan.Needs, error) { - aggregate := plan.NewNeeds() - perOrder := make([]*plan.Needs, len(orders)) +// Returns the proposal-wide aggregate Coverage and a parallel slice with one +// Coverage per order (used to compute Order.coverage_bits after Build). +func (a *Admission) extractPreloadNeeds(ctx context.Context, orders []*raftcmdpb.Order) (*plan.Coverage, []*plan.Coverage, error) { + aggregate := plan.NewCoverage() + perOrder := make([]*plan.Coverage, len(orders)) for orderIdx, order := range orders { - p := plan.NewNeeds() + p := plan.NewCoverage() switch orderType := order.GetType().(type) { case *raftcmdpb.Order_LedgerScoped: @@ -1117,11 +1139,11 @@ func (a *Admission) extractPreloadNeeds(ctx context.Context, orders []*raftcmdpb // resolveScriptsAndEnrichNeeds resolves ScriptReferences and discovers volume/metadata // dependencies from all script-based CreateTransaction orders. It enriches the given -// Needs with the discovered dependencies so that a single Build call covers everything. +// Coverage with the discovered dependencies so that a single Build call covers everything. // // This runs after extractPreloadNeeds (which preloads caller-supplied accountMetadata // keys but skips posting-driven volumes for script-based orders) and before Build. -func (a *Admission) resolveScriptsAndEnrichNeeds(ctx context.Context, orders []*raftcmdpb.Order, overlay *bulkOverlay, p *plan.Needs, perOrder []*plan.Needs) error { +func (a *Admission) resolveScriptsAndEnrichNeeds(ctx context.Context, orders []*raftcmdpb.Order, overlay *bulkOverlay, p *plan.Coverage, perOrder []*plan.Coverage) error { for orderIdx, order := range orders { ls := order.GetLedgerScoped() if ls == nil { @@ -1678,10 +1700,22 @@ func (a *Admission) convertApplyRequest(ctx context.Context, apply *servicepb.Le return nil, err } + // Fetch the original postings so admission can (a) declare volume + // coverage for each posting account (invariant #9 — the FSM's + // applyPosting call reads Volumes().Get through the coverage gate) + // and (b) attach them to the order as a migration bridge for + // pre-EN-1242 TxStates that don't yet carry Postings. + // + // A receipt-signed revert bypasses the store fetch: admission trusts + // the signed claims. On the non-receipt path a fetch miss (missing + // ledger, missing tx, or persistence lag racing a just-applied create) + // yields nil postings and the proposal still enters Raft; the FSM + // apply is the audit authority for the resulting business rejection + // (invariant #8) — processApply.loadBoundaries audits missing ledgers, + // processRevertTransaction's boundary check audits missing txs. var originalPostings []*commonpb.Posting if data.RevertTransaction.GetReceipt() != "" && a.receiptSigner != nil { - // Verify receipt and extract postings claims, err := a.receiptSigner.Verify(data.RevertTransaction.GetReceipt()) if err != nil { return nil, fmt.Errorf("invalid receipt: %w", err) @@ -1697,7 +1731,6 @@ func (a *Admission) convertApplyRequest(ctx context.Context, apply *servicepb.Le originalPostings = receipt.ClaimsToPostings(claims.Postings) } else { - // Fall back to reading from Pebble originalPostings, err = a.getTransactionPostings(apply.GetLedger(), txID) if err != nil { return nil, fmt.Errorf("getting original transaction postings: %w", err) @@ -1858,54 +1891,32 @@ func (a *Admission) resolveRevertTarget(_ context.Context, _ string, payload *se return id, nil } -// getTransactionPostings retrieves the postings of an original transaction from the store. -// It uses FindTransactionCreationLog to locate the creation log and extract postings. +// getTransactionPostings reads the target transaction's postings directly +// from the Transaction attribute (single Pebble point read, no log scan). +// Admission needs them to declare volume coverage for the reversed +// postings' accounts (invariant #9). A missing ledger or missing tx is +// NOT a business rejection here — invariant #8 says every business +// decision must appear in the audit chain, and only the FSM apply path +// writes audit entries. On ErrNotFound the fetch returns (nil, nil) and +// the proposal proceeds; the FSM apply's processApply → loadBoundaries +// audits ErrLedgerNotFound, processRevertTransaction's +// `txID >= boundaries.GetNextTransactionId()` check audits +// ErrTransactionNotFound. func (a *Admission) getTransactionPostings(ledgerName string, transactionID uint64) ([]*commonpb.Posting, error) { - _, ok := a.builder.ResolveLedgerID(ledgerName) - if !ok { - return nil, &domain.BusinessError{Err: &domain.ErrLedgerNotFound{Name: ledgerName}} - } + canonical := domain.TransactionKey{LedgerName: ledgerName, ID: transactionID}.Bytes() - log, err := query.FindTransactionCreationLog(context.Background(), a.store, a.attrs.Transaction, ledgerName, transactionID) + state, err := a.attrs.Transaction.Get(a.store, canonical) if err != nil { if errors.Is(err, domain.ErrNotFound) { - return nil, &domain.BusinessError{Err: &domain.ErrTransactionNotFound{TransactionID: transactionID}} + return nil, nil } - return nil, fmt.Errorf("finding transaction creation log: %w", err) + return nil, fmt.Errorf("reading transaction state: %w", err) } - applyLog, ok := log.GetPayload().GetType().(*commonpb.LogPayload_Apply) - if !ok || applyLog.Apply == nil || applyLog.Apply.GetLog() == nil { - return nil, fmt.Errorf( - "log at sequence %d for ledger %s txID %d does not contain an apply payload (got %T)", - log.GetSequence(), ledgerName, transactionID, log.GetPayload().GetType(), - ) + if state == nil { + return nil, nil } - switch payload := applyLog.Apply.GetLog().GetData().GetPayload().(type) { - case *commonpb.LedgerLogPayload_CreatedTransaction: - if payload.CreatedTransaction == nil || payload.CreatedTransaction.GetTransaction() == nil { - return nil, fmt.Errorf( - "log at sequence %d for ledger %s txID %d has a CreatedTransaction payload but the transaction is nil", - log.GetSequence(), ledgerName, transactionID, - ) - } - - return payload.CreatedTransaction.GetTransaction().GetPostings(), nil - case *commonpb.LedgerLogPayload_RevertedTransaction: - if payload.RevertedTransaction == nil || payload.RevertedTransaction.GetRevertTransaction() == nil { - return nil, fmt.Errorf( - "log at sequence %d for ledger %s txID %d has a RevertedTransaction payload but the revert transaction is nil", - log.GetSequence(), ledgerName, transactionID, - ) - } - - return payload.RevertedTransaction.GetRevertTransaction().GetPostings(), nil - default: - return nil, fmt.Errorf( - "log at sequence %d for ledger %s txID %d has unexpected payload type %T (expected CreatedTransaction or RevertedTransaction)", - log.GetSequence(), ledgerName, transactionID, applyLog.Apply.GetLog().GetData().GetPayload(), - ) - } + return state.GetPostings(), nil } diff --git a/internal/application/admission/admission_test.go b/internal/application/admission/admission_test.go index 92dd84c74..c39add118 100644 --- a/internal/application/admission/admission_test.go +++ b/internal/application/admission/admission_test.go @@ -59,37 +59,6 @@ func createTestStore(t *testing.T) *dal.Store { return s } -// createTransactionLog creates a log with a CreatedTransaction payload. -func createTransactionLog(sequence uint64, ledgerName string, logID uint64, txID uint64, postings []*commonpb.Posting) *commonpb.Log { - return &commonpb.Log{ - Sequence: sequence, - Payload: &commonpb.LogPayload{ - Type: &commonpb.LogPayload_Apply{ - Apply: &commonpb.ApplyLedgerLog{ - LedgerName: ledgerName, - Log: &commonpb.LedgerLog{ - Id: logID, - Date: commonpb.NewTimestamp(time.Now()), - Data: &commonpb.LedgerLogPayload{ - Payload: &commonpb.LedgerLogPayload_CreatedTransaction{ - CreatedTransaction: &commonpb.CreatedTransaction{ - Transaction: &commonpb.Transaction{ - Id: txID, - Postings: postings, - Timestamp: commonpb.NewTimestamp(time.Now()), - InsertedAt: commonpb.NewTimestamp(time.Now()), - UpdatedAt: commonpb.NewTimestamp(time.Now()), - }, - }, - }, - }, - }, - }, - }, - }, - } -} - // createTestAdmission creates an Admission instance for testing. // It returns both the Admission and the Attributes so tests can set up // transaction state directly in Pebble. @@ -121,69 +90,6 @@ func createTestAdmission(t *testing.T, store *dal.Store) (*Admission, *attribute ), attrs } -func TestGetTransactionPostings(t *testing.T) { - t.Parallel() - - t.Run("returns postings for existing transaction", func(t *testing.T) { - t.Parallel() - store := createTestStore(t) - admission, attrs := createTestAdmission(t, store) - - // Create test postings - expectedPostings := []*commonpb.Posting{ - { - Source: "world", - Destination: "user:alice", - Amount: commonpb.NewUint256FromUint64(100), - Asset: "USD", - }, - { - Source: "world", - Destination: "user:bob", - Amount: commonpb.NewUint256FromUint64(50), - Asset: "EUR", - }, - } - - // Create and store a transaction log - txLog := createTransactionLog(1, testLedgerName, 1, 1, expectedPostings) - - batch := store.OpenWriteSession() - err := state.AppendLogs(batch, []*commonpb.Log{txLog}) - require.NoError(t, err) - - // Store TransactionState to link transaction ID to its creating log - _, err = attrs.Transaction.Set(batch, domain.TransactionKey{LedgerName: "test-ledger", ID: 1}.Bytes(), &commonpb.TransactionState{ - CreatedByLog: 1, - }) - require.NoError(t, err) - require.NoError(t, state.SetAppliedIndex(batch, 1)) - require.NoError(t, batch.Commit()) - - // Test getTransactionPostings - postings, err := admission.getTransactionPostings(testLedgerName, 1) - require.NoError(t, err) - require.Len(t, postings, 2) - require.Equal(t, expectedPostings[0].GetSource(), postings[0].GetSource()) - require.Equal(t, expectedPostings[0].GetDestination(), postings[0].GetDestination()) - require.Equal(t, expectedPostings[0].GetAsset(), postings[0].GetAsset()) - require.Equal(t, expectedPostings[1].GetSource(), postings[1].GetSource()) - require.Equal(t, expectedPostings[1].GetDestination(), postings[1].GetDestination()) - require.Equal(t, expectedPostings[1].GetAsset(), postings[1].GetAsset()) - }) - - t.Run("returns error for non-existent transaction", func(t *testing.T) { - t.Parallel() - store := createTestStore(t) - admission, _ := createTestAdmission(t, store) - - // Try to get postings for a transaction that doesn't exist - _, err := admission.getTransactionPostings(testLedgerName, 999) - require.Error(t, err) - require.Contains(t, err.Error(), "transaction 999 does not exist") - }) -} - func TestExtractNeededVolumes(t *testing.T) { t.Parallel() @@ -390,12 +296,13 @@ func TestExtractNeededVolumes(t *testing.T) { func TestConvertApplyRequest_RevertTransaction(t *testing.T) { t.Parallel() - t.Run("fetches original postings for revert transaction", func(t *testing.T) { + t.Run("attaches original postings for volume coverage", func(t *testing.T) { t.Parallel() store := createTestStore(t) admission, attrs := createTestAdmission(t, store) - // First, create a transaction to revert + // Setup: persist a TxState with Postings — the shape a post-EN-1242 + // FSM would produce after applying a CreateTransaction. expectedPostings := []*commonpb.Posting{ { Source: "world", @@ -405,20 +312,14 @@ func TestConvertApplyRequest_RevertTransaction(t *testing.T) { }, } - txLog := createTransactionLog(1, testLedgerName, 1, 1, expectedPostings) - batch := store.OpenWriteSession() - err := state.AppendLogs(batch, []*commonpb.Log{txLog}) - require.NoError(t, err) - // Store TransactionState to link transaction ID to its creating log - _, err = attrs.Transaction.Set(batch, domain.TransactionKey{LedgerName: "test-ledger", ID: 1}.Bytes(), &commonpb.TransactionState{ + _, err := attrs.Transaction.Set(batch, domain.TransactionKey{LedgerName: testLedgerName, ID: 1}.Bytes(), &commonpb.TransactionState{ CreatedByLog: 1, + Postings: expectedPostings, }) require.NoError(t, err) - require.NoError(t, state.SetAppliedIndex(batch, 1)) require.NoError(t, batch.Commit()) - // Now convert a revert request applyRequest := &servicepb.LedgerApplyRequest{ Ledger: testLedgerName, Action: &servicepb.LedgerAction{ @@ -436,19 +337,16 @@ func TestConvertApplyRequest_RevertTransaction(t *testing.T) { require.NoError(t, err) require.NotNil(t, order) - // Verify the order contains the original postings revertOrder := order.GetData().(*raftcmdpb.LedgerApplyOrder_RevertTransaction).RevertTransaction require.NotNil(t, revertOrder) require.Equal(t, uint64(1), revertOrder.GetTransactionId()) - require.False(t, revertOrder.GetForce()) - require.True(t, revertOrder.GetAtEffectiveDate()) - require.Len(t, revertOrder.GetOriginalPostings(), 1) + require.Len(t, revertOrder.GetOriginalPostings(), 1, + "admission reads TxState.Postings directly and attaches them to declare volume coverage (invariant #9)") require.Equal(t, "world", revertOrder.GetOriginalPostings()[0].GetSource()) require.Equal(t, "user:alice", revertOrder.GetOriginalPostings()[0].GetDestination()) - require.Equal(t, "USD", revertOrder.GetOriginalPostings()[0].GetAsset()) }) - t.Run("returns error when transaction to revert does not exist", func(t *testing.T) { + t.Run("passes revert of non-existent transaction through to FSM (audited)", func(t *testing.T) { t.Parallel() store := createTestStore(t) admission, _ := createTestAdmission(t, store) @@ -464,9 +362,21 @@ func TestConvertApplyRequest_RevertTransaction(t *testing.T) { }, } - _, err := admission.convertApplyRequest(t.Context(), applyRequest) - require.Error(t, err) - require.Contains(t, err.Error(), "getting original transaction postings") + // A revert on a non-existent transaction must NOT fail-fast at + // admission: invariant #8 requires business decisions to be + // hash-chained in the audit, and only the FSM apply writes audit + // entries. Admission emits an order with OriginalPostings=nil; + // the FSM's processRevertTransaction returns ErrTransactionNotFound + // (via `txID >= boundaries.GetNextTransactionId()`) BEFORE touching + // volumes — that error lands in the audit chain. + order, err := admission.convertApplyRequest(t.Context(), applyRequest) + require.NoError(t, err) + require.NotNil(t, order) + + revert, ok := order.GetData().(*raftcmdpb.LedgerApplyOrder_RevertTransaction) + require.True(t, ok) + require.Empty(t, revert.RevertTransaction.GetOriginalPostings(), + "admission must pass through with nil postings when the source tx is absent") }) t.Run("returns error when revert payload has no identifier", func(t *testing.T) { @@ -859,35 +769,13 @@ func TestConvertApplyRequest_CreateTransaction_Force(t *testing.T) { func TestRequestToOrder_RevertTransaction(t *testing.T) { t.Parallel() - t.Run("converts revert request with original postings", func(t *testing.T) { + t.Run("converts revert request with empty OriginalPostings", func(t *testing.T) { t.Parallel() store := createTestStore(t) - admission, attrs := createTestAdmission(t, store) - - // Setup transaction to revert - expectedPostings := []*commonpb.Posting{ - { - Source: "bank", - Destination: "user:charlie", - Amount: commonpb.NewUint256FromUint64(500), - Asset: "EUR", - }, - } - - txLog := createTransactionLog(1, testLedgerName, 1, 42, expectedPostings) - - batch := store.OpenWriteSession() - err := state.AppendLogs(batch, []*commonpb.Log{txLog}) - require.NoError(t, err) - // Store TransactionState to link transaction ID to its creating log - _, err = attrs.Transaction.Set(batch, domain.TransactionKey{LedgerName: "test-ledger", ID: 42}.Bytes(), &commonpb.TransactionState{ - CreatedByLog: 1, - }) - require.NoError(t, err) - require.NoError(t, state.SetAppliedIndex(batch, 1)) - require.NoError(t, batch.Commit()) + admission, _ := createTestAdmission(t, store) - // Create revert request + // Non-receipt reverts leave OriginalPostings nil on the wire; the FSM + // reads TxState.Postings authoritatively at apply time. request := &servicepb.Request{ Type: &servicepb.Request_Apply{ Apply: &servicepb.LedgerApplyRequest{ @@ -916,10 +804,7 @@ func TestRequestToOrder_RevertTransaction(t *testing.T) { revertOrder := applyOrder.GetData().(*raftcmdpb.LedgerApplyOrder_RevertTransaction).RevertTransaction require.Equal(t, uint64(42), revertOrder.GetTransactionId()) require.True(t, revertOrder.GetForce()) - require.Len(t, revertOrder.GetOriginalPostings(), 1) - require.Equal(t, "bank", revertOrder.GetOriginalPostings()[0].GetSource()) - require.Equal(t, "user:charlie", revertOrder.GetOriginalPostings()[0].GetDestination()) - require.Equal(t, "EUR", revertOrder.GetOriginalPostings()[0].GetAsset()) + require.Empty(t, revertOrder.GetOriginalPostings()) }) } diff --git a/internal/application/admission/wrapper_dispatch_test.go b/internal/application/admission/wrapper_dispatch_test.go index b4def1116..ffe00a2cb 100644 --- a/internal/application/admission/wrapper_dispatch_test.go +++ b/internal/application/admission/wrapper_dispatch_test.go @@ -16,7 +16,7 @@ const wrapperTestLedger = "books" // TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant pins the contract // that every payload variant under LedgerScopedOrder contributes at minimum -// the wrapper ledger to the preload Needs. The wrapper-level dispatch is +// the wrapper ledger to the preload Coverage. The wrapper-level dispatch is // what guarantees audit attribution + admission scope after #511 — a regression // where a new payload variant is added without a matching case here would // silently leak it past coverage checks. @@ -28,7 +28,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { cases := []struct { name string payload *raftcmdpb.LedgerScopedOrder - assert func(t *testing.T, n *plan.Needs) + assert func(t *testing.T, n *plan.Coverage) }{ { name: "create_ledger", @@ -36,7 +36,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { Ledger: wrapperTestLedger, Payload: &raftcmdpb.LedgerScopedOrder_CreateLedger{CreateLedger: &raftcmdpb.CreateLedgerOrder{}}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedger, ledgerKey.Bytes())) }, }, @@ -46,7 +46,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { Ledger: wrapperTestLedger, Payload: &raftcmdpb.LedgerScopedOrder_DeleteLedger{DeleteLedger: &raftcmdpb.DeleteLedgerOrder{}}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedger, ledgerKey.Bytes())) }, }, @@ -56,7 +56,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { Ledger: wrapperTestLedger, Payload: &raftcmdpb.LedgerScopedOrder_PromoteLedger{PromoteLedger: &raftcmdpb.PromoteLedgerOrder{}}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedger, ledgerKey.Bytes())) }, }, @@ -81,7 +81,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedger, ledgerKey.Bytes())) require.True(t, n.Has(dal.SubAttrBoundary, ledgerKey.Bytes())) require.True(t, n.Has(dal.SubAttrVolume, domain.VolumeKey{ @@ -117,7 +117,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:bob"}, Key: "score", @@ -138,7 +138,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: wrapperTestLedger, ID: 7}.Bytes())) }, }, @@ -159,7 +159,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:carol"}, Key: "score", @@ -180,7 +180,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: wrapperTestLedger, ID: 9}.Bytes())) }, }, @@ -201,7 +201,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }}, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: wrapperTestLedger, ID: 11}.Bytes())) require.True(t, n.Has(dal.SubAttrVolume, domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:alice"}, @@ -219,7 +219,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedger, ledgerKey.Bytes())) require.True(t, n.Has(dal.SubAttrPreparedQuery, domain.PreparedQueryKey{LedgerName: wrapperTestLedger, Name: "q1"}.Bytes())) }, @@ -232,7 +232,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { UpdatePreparedQuery: &raftcmdpb.UpdatePreparedQueryOrder{Name: "q1"}, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrPreparedQuery, domain.PreparedQueryKey{LedgerName: wrapperTestLedger, Name: "q1"}.Bytes())) }, }, @@ -244,7 +244,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { DeletePreparedQuery: &raftcmdpb.DeletePreparedQueryOrder{Name: "q1"}, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrPreparedQuery, domain.PreparedQueryKey{LedgerName: wrapperTestLedger, Name: "q1"}.Bytes())) }, }, @@ -256,7 +256,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { SaveNumscript: &raftcmdpb.SaveNumscriptOrder{Name: "tx", Version: "latest"}, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrNumscriptVersion, domain.NumscriptVersionKey{LedgerName: wrapperTestLedger, Name: "tx"}.Bytes())) // latest does not preload a specific version content require.Zero(t, n.Count(dal.SubAttrNumscriptContent)) @@ -270,7 +270,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { SaveNumscript: &raftcmdpb.SaveNumscriptOrder{Name: "tx", Version: "1.2.3"}, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { // semver saves preload the specific (name, version) for immutability check require.True(t, n.Has(dal.SubAttrNumscriptContent, domain.NumscriptEntryKey{ LedgerName: wrapperTestLedger, @@ -287,7 +287,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { DeleteNumscript: &raftcmdpb.DeleteNumscriptOrder{Name: "tx"}, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrNumscriptVersion, domain.NumscriptVersionKey{LedgerName: wrapperTestLedger, Name: "tx"}.Bytes())) }, }, @@ -303,7 +303,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedgerMetadata, domain.LedgerMetadataKey{LedgerName: wrapperTestLedger, Key: "owner"}.Bytes())) }, }, @@ -315,7 +315,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { DeleteLedgerMetadata: &raftcmdpb.DeleteLedgerMetadataOrder{Key: "owner"}, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrLedgerMetadata, domain.LedgerMetadataKey{LedgerName: wrapperTestLedger, Key: "owner"}.Bytes())) }, }, @@ -336,7 +336,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrReference, domain.TransactionReferenceKey{LedgerName: wrapperTestLedger, Reference: "order-42"}.Bytes())) require.True(t, n.Has(dal.SubAttrVolume, domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:dan"}, @@ -361,7 +361,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: wrapperTestLedger, ID: 13}.Bytes())) require.True(t, n.Has(dal.SubAttrVolume, domain.VolumeKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:eve"}, @@ -388,7 +388,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:fran"}, Key: "badge", @@ -409,7 +409,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: wrapperTestLedger, ID: 17}.Bytes())) }, }, @@ -430,7 +430,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: wrapperTestLedger, Account: "user:gina"}, Key: "badge", @@ -452,7 +452,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { }, }, }, - assert: func(t *testing.T, n *plan.Needs) { + assert: func(t *testing.T, n *plan.Coverage) { require.True(t, n.Has(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: wrapperTestLedger, ID: 19}.Bytes())) }, }, @@ -462,7 +462,7 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - needs := plan.NewNeeds() + needs := plan.NewCoverage() extractLedgerScopedNeeds(needs, tc.payload) tc.assert(t, needs) }) @@ -472,14 +472,14 @@ func TestExtractLedgerScopedNeeds_CoversEveryPayloadVariant(t *testing.T) { // TestExtractSystemScopedNeeds_OnlySinkConfigsContribute pins the contract // that, of all system-scoped payloads, only sink-config writes contribute // preload keys. Every other variant is intentionally a no-op (no cache -// preload required), so the test sweeps each variant and asserts the Needs +// preload required), so the test sweeps each variant and asserts the Coverage // stays empty. func TestExtractSystemScopedNeeds_OnlySinkConfigsContribute(t *testing.T) { t.Parallel() t.Run("add_events_sink", func(t *testing.T) { t.Parallel() - needs := plan.NewNeeds() + needs := plan.NewCoverage() extractSystemScopedNeeds(needs, &raftcmdpb.SystemScopedOrder{ Payload: &raftcmdpb.SystemScopedOrder_AddEventsSink{AddEventsSink: &raftcmdpb.AddEventsSinkOrder{ Config: &commonpb.SinkConfig{Name: "kafka-main"}, @@ -490,7 +490,7 @@ func TestExtractSystemScopedNeeds_OnlySinkConfigsContribute(t *testing.T) { t.Run("remove_events_sink", func(t *testing.T) { t.Parallel() - needs := plan.NewNeeds() + needs := plan.NewCoverage() extractSystemScopedNeeds(needs, &raftcmdpb.SystemScopedOrder{ Payload: &raftcmdpb.SystemScopedOrder_RemoveEventsSink{RemoveEventsSink: &raftcmdpb.RemoveEventsSinkOrder{ Name: "kafka-main", @@ -523,7 +523,7 @@ func TestExtractSystemScopedNeeds_OnlySinkConfigsContribute(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - needs := plan.NewNeeds() + needs := plan.NewCoverage() extractSystemScopedNeeds(needs, tc.payload) require.Zero(t, needs.Count(dal.SubAttrSinkConfig), "%s must not contribute sink configs", tc.name) require.Zero(t, needs.Count(dal.SubAttrLedger), "%s must not contribute a ledger", tc.name) diff --git a/internal/application/check/checker_test.go b/internal/application/check/checker_test.go index c821bba16..109761cd9 100644 --- a/internal/application/check/checker_test.go +++ b/internal/application/check/checker_test.go @@ -377,10 +377,12 @@ func (a *scopeFuncAccessor[K, V, R]) Put(key K, value V) { } } -func (a *scopeFuncAccessor[K, V, R]) Delete(key K) { +func (a *scopeFuncAccessor[K, V, R]) Delete(key K) error { if a.delete != nil { a.delete(key) } + + return nil } func (s *scopeImpl) Ledgers() processing.Accessor[domain.LedgerKey, *commonpb.LedgerInfo, commonpb.LedgerInfoReader] { diff --git a/internal/application/check/replay_store.go b/internal/application/check/replay_store.go index 554d694bd..f73f2caed 100644 --- a/internal/application/check/replay_store.go +++ b/internal/application/check/replay_store.go @@ -209,7 +209,7 @@ func (s *replayStore) DeleteMetadata(canonicalKey []byte) error { // A 1-byte presence flag distinguishes a nil timestamp from a real // Timestamp{Data: 0} (Unix epoch) — the FSM persists the latter unchanged, // so collapsing both to 0 would surface as a CheckStore mismatch. -func (s *replayStore) CreateTransaction(canonicalKey []byte, seq uint64, timestamp *commonpb.Timestamp, metadata map[string]*commonpb.MetadataValue) error { +func (s *replayStore) CreateTransaction(canonicalKey []byte, seq uint64, timestamp *commonpb.Timestamp, metadata map[string]*commonpb.MetadataValue, postings []*commonpb.Posting) error { key := replayKey(replayPrefixTransaction, canonicalKey) var metaBytes []byte @@ -224,15 +224,40 @@ func (s *replayStore) CreateTransaction(canonicalKey []byte, seq uint64, timesta } } - // [txOpCreate][uint64 seq][uint8 hasTimestamp][uint64 timestamp.Data][metaBytes] - buf := make([]byte, 1+8+1+8+len(metaBytes)) + var postingsBytes []byte + if len(postings) > 0 { + // Wrap in a Transaction so vtproto handles the repeated Posting + // framing for us; only Postings is populated. The container fields + // (id, timestamp, metadata) stay at zero — the merger only reads + // GetPostings(). + container := &commonpb.Transaction{Postings: postings} + + var err error + + postingsBytes, err = container.MarshalVT() + if err != nil { + return fmt.Errorf("marshaling tx postings: %w", err) + } + } + + // [txOpCreate][uint64 seq][uint8 hasTimestamp][uint64 timestamp.Data] + // [uint32 metaLen][metaBytes][uint32 postingsLen][postingsBytes] + buf := make([]byte, 1+8+1+8+4+len(metaBytes)+4+len(postingsBytes)) buf[0] = txOpCreate binary.BigEndian.PutUint64(buf[1:], seq) if timestamp != nil { buf[9] = 1 binary.BigEndian.PutUint64(buf[10:], timestamp.GetData()) } - copy(buf[18:], metaBytes) + + off := 18 + binary.BigEndian.PutUint32(buf[off:], uint32(len(metaBytes))) + off += 4 + copy(buf[off:], metaBytes) + off += len(metaBytes) + binary.BigEndian.PutUint32(buf[off:], uint32(len(postingsBytes))) + off += 4 + copy(buf[off:], postingsBytes) return s.db.Merge(key, buf, pebble.NoSync) } @@ -402,7 +427,10 @@ func (m *txMerger) Finish(_ bool) ([]byte, io.Closer, error) { switch op[0] { case txOpCreate: - if len(op) < 18 { + // [txOpCreate][uint64 seq][uint8 hasTimestamp][uint64 timestamp.Data] + // [uint32 metaLen][metaBytes][uint32 postingsLen][postingsBytes] + const headerLen = 1 + 8 + 1 + 8 + 4 + if len(op) < headerLen { return nil, nil, fmt.Errorf("txOpCreate too short: %d bytes", len(op)) } @@ -412,15 +440,37 @@ func (m *txMerger) Finish(_ bool) ([]byte, io.Closer, error) { state.Timestamp = &commonpb.Timestamp{Data: binary.BigEndian.Uint64(op[10:18])} } - if len(op) > 18 { + metaLen := int(binary.BigEndian.Uint32(op[18:22])) + off := 22 + if len(op) < off+metaLen+4 { + return nil, nil, fmt.Errorf("txOpCreate: metadata length %d overruns buffer of %d bytes", metaLen, len(op)) + } + + if metaLen > 0 { mm := &commonpb.MetadataMap{} - if err := mm.UnmarshalVT(op[18:]); err != nil { + if err := mm.UnmarshalVT(op[off : off+metaLen]); err != nil { return nil, nil, fmt.Errorf("unmarshaling create metadata: %w", err) } state.Metadata = mm.GetValues() } + off += metaLen + postingsLen := int(binary.BigEndian.Uint32(op[off : off+4])) + off += 4 + if len(op) < off+postingsLen { + return nil, nil, fmt.Errorf("txOpCreate: postings length %d overruns buffer of %d bytes", postingsLen, len(op)) + } + + if postingsLen > 0 { + container := &commonpb.Transaction{} + if err := container.UnmarshalVT(op[off : off+postingsLen]); err != nil { + return nil, nil, fmt.Errorf("unmarshaling create postings: %w", err) + } + + state.Postings = container.GetPostings() + } + case txOpRevertedBy: if len(op) < 9 { return nil, nil, fmt.Errorf("txOpRevertedBy too short: %d bytes", len(op)) diff --git a/internal/application/check/replay_store_test.go b/internal/application/check/replay_store_test.go index 5835bbce0..2667d2bf7 100644 --- a/internal/application/check/replay_store_test.go +++ b/internal/application/check/replay_store_test.go @@ -166,7 +166,7 @@ func TestReplayStoreTransactionCreate(t *testing.T) { meta := strMetaMap("env", "prod") - require.NoError(t, rs.CreateTransaction(key, 42, nil, meta)) + require.NoError(t, rs.CreateTransaction(key, 42, nil, meta, nil)) state := readTransaction(t, rs, key) require.Equal(t, uint64(42), state.GetCreatedByLog()) @@ -181,7 +181,7 @@ func TestReplayStoreTransactionCreateWithTimestamp(t *testing.T) { key := []byte("ledger\x00tx:1") ts := &commonpb.Timestamp{Data: 1_700_000_000_000_000} - require.NoError(t, rs.CreateTransaction(key, 7, ts, nil)) + require.NoError(t, rs.CreateTransaction(key, 7, ts, nil, nil)) state := readTransaction(t, rs, key) require.Equal(t, uint64(7), state.GetCreatedByLog()) @@ -198,11 +198,11 @@ func TestReplayStoreTransactionCreatePreservesNilVsZeroTimestamp(t *testing.T) { rs := newTestReplayStore(t) keyNil := []byte("ledger\x00tx:1") - require.NoError(t, rs.CreateTransaction(keyNil, 1, nil, nil)) + require.NoError(t, rs.CreateTransaction(keyNil, 1, nil, nil, nil)) require.Nil(t, readTransaction(t, rs, keyNil).GetTimestamp()) keyZero := []byte("ledger\x00tx:2") - require.NoError(t, rs.CreateTransaction(keyZero, 2, &commonpb.Timestamp{Data: 0}, nil)) + require.NoError(t, rs.CreateTransaction(keyZero, 2, &commonpb.Timestamp{Data: 0}, nil, nil)) stateZero := readTransaction(t, rs, keyZero) require.NotNil(t, stateZero.GetTimestamp()) require.Equal(t, uint64(0), stateZero.GetTimestamp().GetData()) @@ -214,7 +214,7 @@ func TestReplayStoreTransactionCreateWithoutMetadata(t *testing.T) { rs := newTestReplayStore(t) key := []byte("ledger\x00tx:1") - require.NoError(t, rs.CreateTransaction(key, 10, nil, nil)) + require.NoError(t, rs.CreateTransaction(key, 10, nil, nil, nil)) state := readTransaction(t, rs, key) require.Equal(t, uint64(10), state.GetCreatedByLog()) @@ -227,7 +227,7 @@ func TestReplayStoreTransactionRevertedBy(t *testing.T) { rs := newTestReplayStore(t) key := []byte("ledger\x00tx:1") - require.NoError(t, rs.CreateTransaction(key, 5, nil, nil)) + require.NoError(t, rs.CreateTransaction(key, 5, nil, nil, nil)) require.NoError(t, rs.SetRevertedBy(key, 99)) state := readTransaction(t, rs, key) @@ -241,7 +241,7 @@ func TestReplayStoreTransactionSaveMeta(t *testing.T) { rs := newTestReplayStore(t) key := []byte("ledger\x00tx:1") - require.NoError(t, rs.CreateTransaction(key, 1, nil, nil)) + require.NoError(t, rs.CreateTransaction(key, 1, nil, nil, nil)) require.NoError(t, rs.SaveTxMetadata(key, strMetaMap("env", "staging"))) state := readTransaction(t, rs, key) @@ -257,7 +257,7 @@ func TestReplayStoreTransactionSaveMetaOverwrite(t *testing.T) { meta := strMetaMap("env", "dev") - require.NoError(t, rs.CreateTransaction(key, 1, nil, meta)) + require.NoError(t, rs.CreateTransaction(key, 1, nil, meta, nil)) require.NoError(t, rs.SaveTxMetadata(key, strMetaMap("env", "prod"))) state := readTransaction(t, rs, key) @@ -273,7 +273,7 @@ func TestReplayStoreTransactionDeleteMeta(t *testing.T) { meta := strMetaMap("env", "prod", "region", "eu") - require.NoError(t, rs.CreateTransaction(key, 1, nil, meta)) + require.NoError(t, rs.CreateTransaction(key, 1, nil, meta, nil)) require.NoError(t, rs.DeleteTxMetadata(key, "env")) state := readTransaction(t, rs, key) @@ -288,7 +288,7 @@ func TestReplayStoreTransactionFullLifecycle(t *testing.T) { key := []byte("ledger\x00tx:1") // Create with initial metadata - require.NoError(t, rs.CreateTransaction(key, 1, nil, strMetaMap("type", "payment"))) + require.NoError(t, rs.CreateTransaction(key, 1, nil, strMetaMap("type", "payment"), nil)) // Add metadata require.NoError(t, rs.SaveTxMetadata(key, strMetaMap("status", "pending"))) @@ -318,7 +318,7 @@ func TestReplayStorePrefixIter(t *testing.T) { require.NoError(t, rs.AddVolumeDelta([]byte("k1"), big.NewInt(10), big.NewInt(0))) require.NoError(t, rs.AddVolumeDelta([]byte("k2"), big.NewInt(20), big.NewInt(0))) require.NoError(t, rs.SetMetadata([]byte("m1"), "v1")) - require.NoError(t, rs.CreateTransaction([]byte("t1"), 1, nil, nil)) + require.NoError(t, rs.CreateTransaction([]byte("t1"), 1, nil, nil, nil)) // Volume prefix should yield exactly 2 entries. iter, err := rs.newPrefixIter(replayPrefixVolume) diff --git a/internal/application/events/emitter.go b/internal/application/events/emitter.go index f291171e3..9b3186bf6 100644 --- a/internal/application/events/emitter.go +++ b/internal/application/events/emitter.go @@ -483,7 +483,7 @@ func (e *Emitter) proposeSinkUpdateOnce(ctx context.Context, update *raftcmdpb.E }} // One WriteOperation per TU. applyEventsSinkUpdate reads no cache - // state, so Needs stays nil and the runner takes the fast path + // state, so Coverage stays nil and the runner takes the fast path // (tracker mutex held just long enough to inject PredictedIndex + // proposer.Propose). operations := []plan.WriteOperation{{ diff --git a/internal/application/mirror/worker.go b/internal/application/mirror/worker.go index b97ec6d10..9fa7d53b5 100644 --- a/internal/application/mirror/worker.go +++ b/internal/application/mirror/worker.go @@ -368,13 +368,13 @@ func (w *Worker) processBatch(ctx context.Context) (bool, error) { // One WriteOperation per Order + one for the cursor TU. The cursor // TU reads Registry.Ledgers[w.ledgerName] in applyMirrorSyncUpdate. - tuNeeds := plan.NewNeeds() + tuNeeds := plan.NewCoverage() tuNeeds.Add(dal.SubAttrLedger, domain.LedgerKey{Name: w.ledgerName}.Bytes()) operations := make([]plan.WriteOperation, 0, len(orders)+1) for i := range orders { operations = append(operations, plan.WriteOperation{ - Needs: perOrder[i], + Coverage: perOrder[i], SetCoverage: func(bits []byte) { cmd.GetOrders()[i].CoverageBits = bits }, @@ -382,7 +382,7 @@ func (w *Worker) processBatch(ctx context.Context) (bool, error) { } operations = append(operations, plan.WriteOperation{ - Needs: tuNeeds, + Coverage: tuNeeds, SetCoverage: func(bits []byte) { cmd.GetTechnicalUpdates()[0].CoverageBits = bits }, @@ -521,11 +521,11 @@ func (w *Worker) reportError(ctx context.Context, message string) { // read and the mirror status update silently skips — the FSM would // emit no audit entry and the error would never reach the store. // One WriteOperation for the error TU with its ledger needs declared. - needs := plan.NewNeeds() + needs := plan.NewCoverage() needs.Add(dal.SubAttrLedger, domain.LedgerKey{Name: w.ledgerName}.Bytes()) operations := []plan.WriteOperation{{ - Needs: needs, + Coverage: needs, SetCoverage: func(bits []byte) { cmd.GetTechnicalUpdates()[0].CoverageBits = bits }, @@ -579,29 +579,29 @@ func marshalMirrorCommand(cmd *raftcmdpb.Proposal) ([]byte, error) { return vtmarshal.MarshalCopy(cmd) } -// extractMirrorNeeds builds plan.Needs from a mirror proposal's orders. -// Returns the proposal-wide aggregate Needs alongside a parallel slice with -// one Needs per order, used to compute Order.coverage_bits after +// extractMirrorNeeds builds plan.Coverage from a mirror proposal's orders. +// Returns the proposal-wide aggregate Coverage alongside a parallel slice with +// one Coverage per order, used to compute Order.coverage_bits after // Build. Mirror only touches ledger info, boundaries, volumes and // account metadata. -func (w *Worker) extractMirrorNeeds(cmd *raftcmdpb.Proposal) (*plan.Needs, []*plan.Needs) { - aggregate := plan.NewNeeds() - perOrder := make([]*plan.Needs, len(cmd.GetOrders())) +func (w *Worker) extractMirrorNeeds(cmd *raftcmdpb.Proposal) (*plan.Coverage, []*plan.Coverage) { + aggregate := plan.NewCoverage() + perOrder := make([]*plan.Coverage, len(cmd.GetOrders())) ledgerBytes := domain.LedgerKey{Name: w.ledgerName}.Bytes() - addAccountMetadata := func(p *plan.Needs, account, key string) { + addAccountMetadata := func(p *plan.Coverage, account, key string) { p.Add(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: w.ledgerName, Account: account}, Key: key, }.Bytes()) } - addTx := func(p *plan.Needs, txID uint64) { + addTx := func(p *plan.Coverage, txID uint64) { p.Add(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: w.ledgerName, ID: txID}.Bytes()) } for orderIdx, order := range cmd.GetOrders() { - p := plan.NewNeeds() + p := plan.NewCoverage() p.Add(dal.SubAttrLedger, ledgerBytes) p.Add(dal.SubAttrBoundary, ledgerBytes) @@ -652,8 +652,10 @@ func (w *Worker) extractMirrorNeeds(cmd *raftcmdpb.Proposal) (*plan.Needs, []*pl if dm := mi.GetEntry().GetDeletedMetadata(); dm != nil { switch target := dm.GetTarget().GetTarget().(type) { case *commonpb.Target_Account: - // Same strict-Del coverage as the admission-side - // MirrorIngest.DeletedMetadata path (see admission.go). + // Same Del coverage as the admission-side + // MirrorIngest.DeletedMetadata path (see admission.go) — + // AttributeCache.Del lazy-fabricates the Gen0 tombstone + // from Gen1's tag if a race occurred. p.Add(dal.SubAttrMetadata, domain.MetadataKey{ AccountKey: domain.AccountKey{LedgerName: w.ledgerName, Account: target.Account.GetAddr()}, Key: dm.GetKey(), diff --git a/internal/bootstrap/backup_proposer.go b/internal/bootstrap/backup_proposer.go index a1bedbf60..191cdf93a 100644 --- a/internal/bootstrap/backup_proposer.go +++ b/internal/bootstrap/backup_proposer.go @@ -33,7 +33,7 @@ func newBackupProposer(builder *plan.Builder, proposer plan.Proposer) *backupPro return &backupProposer{builder: builder, proposer: proposer} } -// Propose pushes the proposal through proposeTechnical with empty Needs +// Propose pushes the proposal through proposeTechnical with empty Coverage // per TU. The backup apply handlers (state.applyBackupOrder / // applyIncrementalBackupOrder) do not read from the FSM cache — they // only mutate BackupJobsState's in-memory map and write to Pebble — so @@ -45,7 +45,7 @@ func (b *backupProposer) Propose(ctx context.Context, cmd *raftcmdpb.Proposal) e for i := range tus { operations = append(operations, plan.WriteOperation{ - Needs: nil, // backup apply does not read FSM cache + Coverage: nil, // backup apply does not read FSM cache SetCoverage: func(bits []byte) { cmd.GetTechnicalUpdates()[i].CoverageBits = bits }, diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 5eaabae53..eea9f6f3d 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -185,7 +185,7 @@ type Config struct { BackupMaxSegmentBytes int64 NumscriptCacheSize int MirrorMaxBatchSize int - // MaxExecutionPlanSize caps the number of AttributePlan entries an + // MaxExecutionPlanSize caps the number of AttributeCoverage entries an // ExecutionPlan may carry. 0 disables the cap. See plan.Builder. MaxExecutionPlanSize int UnsafeSkipConfigValidation bool diff --git a/internal/bootstrap/module.go b/internal/bootstrap/module.go index e69aaaff1..e131b1ce1 100644 --- a/internal/bootstrap/module.go +++ b/internal/bootstrap/module.go @@ -1277,7 +1277,7 @@ func Module() fx.Option { // applyIdempotencyEviction works through // Registry.Idempotency.Evict (no cache-keyed // Registry.Get); no preload needed. One - // WriteOperation with nil Needs so the runner + // WriteOperation with nil Coverage so the runner // takes the fast path. operations := []plan.WriteOperation{{ SetCoverage: func(bits []byte) { diff --git a/internal/bootstrap/propose_technical.go b/internal/bootstrap/propose_technical.go index a7e25fac1..9cc0d4dbb 100644 --- a/internal/bootstrap/propose_technical.go +++ b/internal/bootstrap/propose_technical.go @@ -32,7 +32,7 @@ const maxTechnicalStaleRetries = 5 // // The caller passes one plan.WriteOperation per TechnicalUpdate in the // proposal. Each operation declares the cache keys its handler will -// read (or nil/empty Needs for handlers that read nothing — cluster +// read (or nil/empty Coverage for handlers that read nothing — cluster // config, idempotency eviction) and a SetCoverage closure that the // runner uses to write the bitset onto the corresponding // TechnicalUpdate. Per-TU isolation is therefore structural even when diff --git a/internal/domain/errors.go b/internal/domain/errors.go index db9290654..637a7df16 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -1244,7 +1244,7 @@ func (e *ErrAccountValidation) Metadata() map[string]string { // ErrInvalidExecutionPlan signals that the ExecutionPlan shipped by // admission is structurally inconsistent with itself — a coverage_bits // or production_bits bit flags a position past the slice it indexes, -// or an AttributePlan/Production declares an attr_code the FSM does +// or an AttributeCoverage/Production declares an attr_code the FSM does // not handle. // // Detected at scope construction in the FSM, BEFORE any cache mutation diff --git a/internal/domain/indexes/lookup.go b/internal/domain/indexes/lookup.go index 0cf9090ef..17b613607 100644 --- a/internal/domain/indexes/lookup.go +++ b/internal/domain/indexes/lookup.go @@ -30,10 +30,12 @@ type Lookup interface { // IndexWriter is implemented by the FSM-apply Accessor returned from // Scope.Indexes(). Method names match Accessor so the same instance -// satisfies both Lookup and IndexWriter without an adapter. +// satisfies both Lookup and IndexWriter without an adapter. Delete +// returns an error so a coverage miss (invariant #6) propagates rather +// than silently dropping. type IndexWriter interface { Put(key domain.IndexKey, idx *commonpb.Index) - Delete(key domain.IndexKey) + Delete(key domain.IndexKey) error } // KeyFor builds the registry key for an index. An empty ledgerName addresses @@ -107,10 +109,12 @@ func Put(w IndexWriter, ledgerName string, idx *commonpb.Index) { // Remove deletes the Index entry matching (ledgerName, id). Silently no-ops on // nil ids so callers can pipe order payloads without explicit validation. -func Remove(w IndexWriter, ledgerName string, id *commonpb.IndexID) { +// Returns any error the underlying writer surfaces (e.g. *state.ErrCoverageMiss +// on the FSM hot path when the proposer did not declare the deletion key). +func Remove(w IndexWriter, ledgerName string, id *commonpb.IndexID) error { if id == nil { - return + return nil } - w.Delete(KeyFor(ledgerName, id)) + return w.Delete(KeyFor(ledgerName, id)) } diff --git a/internal/domain/indexes/lookup_generated_test.go b/internal/domain/indexes/lookup_generated_test.go index 406aa22d2..23d2f2907 100644 --- a/internal/domain/indexes/lookup_generated_test.go +++ b/internal/domain/indexes/lookup_generated_test.go @@ -103,9 +103,11 @@ func (m *MockIndexWriter) EXPECT() *MockIndexWriterMockRecorder { } // Delete mocks base method. -func (m *MockIndexWriter) Delete(key domain.IndexKey) { +func (m *MockIndexWriter) Delete(key domain.IndexKey) error { m.ctrl.T.Helper() - m.ctrl.Call(m, "Delete", key) + ret := m.ctrl.Call(m, "Delete", key) + ret0, _ := ret[0].(error) + return ret0 } // Delete indicates an expected call of Delete. @@ -121,19 +123,19 @@ type MockIndexWriterDeleteCall struct { } // Return rewrite *gomock.Call.Return -func (c *MockIndexWriterDeleteCall) Return() *MockIndexWriterDeleteCall { - c.Call = c.Call.Return() +func (c *MockIndexWriterDeleteCall) Return(arg0 error) *MockIndexWriterDeleteCall { + c.Call = c.Call.Return(arg0) return c } // Do rewrite *gomock.Call.Do -func (c *MockIndexWriterDeleteCall) Do(f func(domain.IndexKey)) *MockIndexWriterDeleteCall { +func (c *MockIndexWriterDeleteCall) Do(f func(domain.IndexKey) error) *MockIndexWriterDeleteCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockIndexWriterDeleteCall) DoAndReturn(f func(domain.IndexKey)) *MockIndexWriterDeleteCall { +func (c *MockIndexWriterDeleteCall) DoAndReturn(f func(domain.IndexKey) error) *MockIndexWriterDeleteCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/domain/indexes/lookup_test.go b/internal/domain/indexes/lookup_test.go index b360bb56c..b3bda21bf 100644 --- a/internal/domain/indexes/lookup_test.go +++ b/internal/domain/indexes/lookup_test.go @@ -154,9 +154,9 @@ func TestRemove_DispatchesDeleteIndex(t *testing.T) { ctrl := gomock.NewController(t) writer := NewMockIndexWriter(ctrl) - writer.EXPECT().Delete(indexes.KeyFor("main", ref())) + writer.EXPECT().Delete(indexes.KeyFor("main", ref())).Return(nil) - indexes.Remove(writer, "main", ref()) + require.NoError(t, indexes.Remove(writer, "main", ref())) } func TestRemove_NoOpOnNilID(t *testing.T) { @@ -165,7 +165,7 @@ func TestRemove_NoOpOnNilID(t *testing.T) { ctrl := gomock.NewController(t) writer := NewMockIndexWriter(ctrl) - indexes.Remove(writer, "main", nil) + require.NoError(t, indexes.Remove(writer, "main", nil)) } // TestKeyFor_BucketScopeUsesEmptyLedgerName pins the bucket-scope sentinel: diff --git a/internal/domain/processing/accessor.go b/internal/domain/processing/accessor.go index 1c6738ddb..0ef7fd684 100644 --- a/internal/domain/processing/accessor.go +++ b/internal/domain/processing/accessor.go @@ -24,7 +24,8 @@ type AccessorKey interface { // - raw: pure delegation to the underlying attributes.DerivedKeyStore. // Used by the bare *WriteSet (recovery, technical updates, tests). // - gated: prepends CheckCoverage(sub, key.Bytes()) so the FSM hot -// path never reads a key the proposer did not declare in coverage_bits. +// path never reads OR deletes a key the proposer did not declare in +// coverage_bits. // // Get returns: // - (reader, nil) when the key is present in the overlay or parent. @@ -36,11 +37,18 @@ type AccessorKey interface { // key is outside the scope's coverage map. // - (zero, err) on any other underlying engine error. // +// Delete returns: +// - nil on success (entry marked for deletion in +// the batch overlay; final tombstone happens at Merge/apply time). +// - *ErrCoverageMiss (gated implementation only) when the key +// is outside the scope's coverage map — the FSM hot path never +// deletes an undeclared key, per invariant #6. +// // The per-kind miss convention is preserved verbatim from the pre-refactor // surface — call sites that already discriminated on err vs nil-reader // continue to work unchanged. type Accessor[K AccessorKey, V any, R any] interface { Get(key K) (R, error) Put(key K, value V) - Delete(key K) + Delete(key K) error } diff --git a/internal/domain/processing/processor_helpers_test.go b/internal/domain/processing/processor_helpers_test.go index a6fab3a7d..92639a6a7 100644 --- a/internal/domain/processing/processor_helpers_test.go +++ b/internal/domain/processing/processor_helpers_test.go @@ -111,13 +111,15 @@ func (s *kindStub[K, V, R]) expectPut(t *testing.T, k K, hook func(K, V)) { } } -func (s *kindStub[K, V, R]) Delete(k K) { +func (s *kindStub[K, V, R]) Delete(k K) error { if _, ok := s.expectedDeletes[k]; ok { s.expectedDeletes[k] = true } if s.deleteHook != nil { s.deleteHook(k) } + + return nil } // expectDelete asserts that Delete is called for k by the end of the diff --git a/internal/domain/processing/processor_index.go b/internal/domain/processing/processor_index.go index 7365fb048..3b5616c8b 100644 --- a/internal/domain/processing/processor_index.go +++ b/internal/domain/processing/processor_index.go @@ -54,7 +54,9 @@ func processDropIndex(ledger string, order *raftcmdpb.DropIndexOrder, ctx *Conte } id := order.GetId() - indexes.Remove(ctx.Scope.Indexes(), info.GetName(), id) + if err := indexes.Remove(ctx.Scope.Indexes(), info.GetName(), id); err != nil { + return nil, &domain.ErrStorageOperation{Operation: "dropping index", Cause: err} + } return &commonpb.LedgerLogPayload{ Payload: &commonpb.LedgerLogPayload_DropIndex{ diff --git a/internal/domain/processing/processor_ledger_metadata.go b/internal/domain/processing/processor_ledger_metadata.go index 4cdb12b86..21d613c20 100644 --- a/internal/domain/processing/processor_ledger_metadata.go +++ b/internal/domain/processing/processor_ledger_metadata.go @@ -78,7 +78,9 @@ func processDeleteLedgerMetadata(ledger string, order *raftcmdpb.DeleteLedgerMet return nil, &domain.ErrStorageOperation{Operation: "checking ledger metadata", Cause: err} } - s.LedgerMetadata().Delete(metaKey) + if err := s.LedgerMetadata().Delete(metaKey); err != nil { + return nil, &domain.ErrStorageOperation{Operation: "deleting ledger metadata", Cause: err} + } return &commonpb.LogPayload{ Type: &commonpb.LogPayload_DeletedLedgerMetadata{ diff --git a/internal/domain/processing/processor_metadata.go b/internal/domain/processing/processor_metadata.go index d86fb861e..14e53c549 100644 --- a/internal/domain/processing/processor_metadata.go +++ b/internal/domain/processing/processor_metadata.go @@ -142,7 +142,9 @@ func processDeleteMetadata(ledger string, order *raftcmdpb.DeleteMetadataOrder, return nil, &domain.ErrStorageOperation{Operation: "checking account metadata", Cause: err} } - s.AccountMetadata().Delete(metaKey) + if err := s.AccountMetadata().Delete(metaKey); err != nil { + return nil, &domain.ErrStorageOperation{Operation: "deleting account metadata", Cause: err} + } case *commonpb.Target_TransactionId: txID := target.TransactionId if resolveErr := validateTransactionTarget(txID, boundaries); resolveErr != nil { diff --git a/internal/domain/processing/processor_metadata_schema.go b/internal/domain/processing/processor_metadata_schema.go index 08dae79b9..3eee86045 100644 --- a/internal/domain/processing/processor_metadata_schema.go +++ b/internal/domain/processing/processor_metadata_schema.go @@ -133,7 +133,9 @@ func processRemoveMetadataFieldType(ledger string, order *raftcmdpb.RemoveMetada } if existing != nil { - indexes.Remove(s.Indexes(), info.GetName(), id) + if err := indexes.Remove(s.Indexes(), info.GetName(), id); err != nil { + return nil, &domain.ErrStorageOperation{Operation: "removing metadata field index", Cause: err} + } droppedIndex = id } diff --git a/internal/domain/processing/processor_mirror.go b/internal/domain/processing/processor_mirror.go index 4ec5ba4d1..47a4b88df 100644 --- a/internal/domain/processing/processor_mirror.go +++ b/internal/domain/processing/processor_mirror.go @@ -163,6 +163,7 @@ func processMirrorCreatedTransaction(ledger string, ct *raftcmdpb.MirrorCreatedT CreatedByLog: s.GetNextSequenceID(), Metadata: ct.GetMetadata(), Timestamp: timestamp, + Postings: ct.GetPostings(), }) // Store reference if provided @@ -280,7 +281,9 @@ func processMirrorDeletedMetadata(ledger string, dm *raftcmdpb.MirrorDeletedMeta AccountKey: domain.AccountKey{LedgerName: ledger, Account: target.Account.GetAddr()}, Key: dm.GetKey(), } - s.AccountMetadata().Delete(metaKey) + if err := s.AccountMetadata().Delete(metaKey); err != nil { + return nil, &domain.ErrStorageOperation{Operation: "deleting account metadata", Cause: err} + } case *commonpb.Target_TransactionId: txKey := domain.TransactionKey{LedgerName: ledger, ID: target.TransactionId} @@ -357,6 +360,7 @@ func processMirrorRevertedTransaction(ledger string, rt *raftcmdpb.MirrorReverte CreatedByLog: s.GetNextSequenceID(), Metadata: rt.GetMetadata(), Timestamp: timestamp, + Postings: rt.GetReversePostings(), }) return &commonpb.LedgerLogPayload{ diff --git a/internal/domain/processing/processor_prepared_query.go b/internal/domain/processing/processor_prepared_query.go index 3d7c8ca03..3dba9ba66 100644 --- a/internal/domain/processing/processor_prepared_query.go +++ b/internal/domain/processing/processor_prepared_query.go @@ -131,7 +131,9 @@ func processDeletePreparedQuery(ledger string, order *raftcmdpb.DeletePreparedQu } } - s.PreparedQueries().Delete(domain.PreparedQueryKey{LedgerName: ledger, Name: order.GetName()}) + if err := s.PreparedQueries().Delete(domain.PreparedQueryKey{LedgerName: ledger, Name: order.GetName()}); err != nil { + return nil, &domain.ErrStorageOperation{Operation: "deleting prepared query", Cause: err} + } return &commonpb.LogPayload{ Type: &commonpb.LogPayload_DeletedPreparedQuery{ diff --git a/internal/domain/processing/processor_revert_transaction.go b/internal/domain/processing/processor_revert_transaction.go index 3ae00bea1..c3b3fe659 100644 --- a/internal/domain/processing/processor_revert_transaction.go +++ b/internal/domain/processing/processor_revert_transaction.go @@ -33,10 +33,36 @@ func processRevertTransaction(ledger string, order *raftcmdpb.RevertTransactionO return nil, &domain.ErrTransactionAlreadyReverted{TransactionID: order.GetTransactionId()} } + // Admission attaches OriginalPostings from either the signed receipt or + // its own read of TxState.Postings (via attrs.Transaction.Get). A revert + // order that reaches the FSM with an empty OriginalPostings means the + // tx was not visible to admission at propose time — a business + // rejection that must appear in the audit chain (invariant #8). + originalPostings := order.GetOriginalPostings() + if len(originalPostings) == 0 { + return nil, &domain.ErrTransactionNotFound{TransactionID: order.GetTransactionId()} + } + + origStateReader, err := s.TransactionStates().Get(txKey) + if errors.Is(err, domain.ErrNotFound) { + // Impossible by design under invariants #1/#6: the len(originalPostings) + // check above already established the tx was visible to admission at + // propose time (so admission preloaded TxState). A miss here implies + // a cache/Pebble desync, not a routine business "not found" — surface + // loudly with the invariant-violation error class. + return nil, &domain.ErrTransactionStateInconsistent{TransactionID: order.GetTransactionId(), Operation: "revert"} + } + + if err != nil { + return nil, &domain.ErrStorageOperation{Operation: "getting original transaction state", Cause: err} + } + + origState := origStateReader.Mutate() + // Create reversed postings and update volumes // For a revert: original destination becomes source, original source becomes destination - revertPostings := make([]*commonpb.Posting, len(order.GetOriginalPostings())) - for i, originalPosting := range order.GetOriginalPostings() { + revertPostings := make([]*commonpb.Posting, len(originalPostings)) + for i, originalPosting := range originalPostings { // Create reversed posting revertPostings[i] = &commonpb.Posting{ Source: originalPosting.GetDestination(), // Original destination is now source @@ -70,17 +96,7 @@ func processRevertTransaction(ledger string, order *raftcmdpb.RevertTransactionO boundaries.PostingCount += uint64(len(revertPostings)) boundaries.RevertCount++ - // Update the original transaction's state to record the reversion - origStateReader, err := s.TransactionStates().Get(txKey) - if errors.Is(err, domain.ErrNotFound) { - return nil, &domain.ErrTransactionStateInconsistent{TransactionID: order.GetTransactionId(), Operation: "revert"} - } - - if err != nil { - return nil, &domain.ErrStorageOperation{Operation: "getting original transaction state", Cause: err} - } - - origState := origStateReader.Mutate() + // Record the reversion on the original transaction's state. origState.RevertedByTransaction = revertTxID s.TransactionStates().Put(txKey, origState) @@ -103,6 +119,7 @@ func processRevertTransaction(ledger string, order *raftcmdpb.RevertTransactionO CreatedByLog: s.GetNextSequenceID(), Metadata: order.GetMetadata(), Timestamp: revertTimestamp, + Postings: revertPostings, }) // Compute post-commit volumes if requested diff --git a/internal/domain/processing/processor_transaction.go b/internal/domain/processing/processor_transaction.go index 3fccd9af3..d17ee54d0 100644 --- a/internal/domain/processing/processor_transaction.go +++ b/internal/domain/processing/processor_transaction.go @@ -100,6 +100,7 @@ func processCreateTransaction(ledger string, order *raftcmdpb.CreateTransactionO txState := &commonpb.TransactionState{ CreatedByLog: s.GetNextSequenceID(), Timestamp: timestamp, + Postings: result.Postings, } // Validate account addresses in resolved postings (covers Numscript-resolved addresses). diff --git a/internal/domain/processing/store.go b/internal/domain/processing/store.go index 35b0fc698..46a922c66 100644 --- a/internal/domain/processing/store.go +++ b/internal/domain/processing/store.go @@ -134,18 +134,18 @@ type Scope interface { // // NewScope returns an error when the ExecutionPlan / bits combination // is structurally inconsistent (e.g. a bit indexes past the -// AttributePlan slice, an unknown attr_code is declared). This is +// AttributeCoverage slice, an unknown attr_code is declared). This is // surfaced as a business-level rejection: detection happens BEFORE any // cache mutation so the in-memory state stays in lockstep with Pebble. type ScopeFactory interface { // NewScope returns a per-order or per-TU scope narrowed by the // caller's coverage_bits. nil or empty bits admits no plan — // every CheckCoverage call will miss. Callers that need a - // proposal-wide scope (admit every declared AttributePlan, e.g. + // proposal-wide scope (admit every declared AttributeCoverage, e.g. // for ValidateTransientVolumes) must use NewProposalScope. NewScope(coverageBits []byte) (Scope, error) - // NewProposalScope returns a scope that admits every AttributePlan + // NewProposalScope returns a scope that admits every AttributeCoverage // the proposal declared. Distinct from NewScope(nil) so a per-order // caller passing empty bits (no declared needs) does not silently // inherit coverage from other orders' plans in the same proposal. diff --git a/internal/domain/replay/replay.go b/internal/domain/replay/replay.go index 1c2251b3a..16b13de40 100644 --- a/internal/domain/replay/replay.go +++ b/internal/domain/replay/replay.go @@ -62,7 +62,7 @@ func ReplayLedgerLog( txCanonical := domain.TransactionKey{LedgerName: ledger, ID: tx.GetId()}.Bytes() - if err := w.CreateTransaction(txCanonical, seq, tx.GetTimestamp(), tx.GetMetadata()); err != nil { + if err := w.CreateTransaction(txCanonical, seq, tx.GetTimestamp(), tx.GetMetadata(), tx.GetPostings()); err != nil { return fmt.Errorf("putting tx state for created tx %d: %w", tx.GetId(), err) } @@ -108,7 +108,7 @@ func ReplayLedgerLog( revertTxCanonical := domain.TransactionKey{LedgerName: ledger, ID: revertTx.GetId()}.Bytes() - if err := w.CreateTransaction(revertTxCanonical, seq, revertTx.GetTimestamp(), revertTx.GetMetadata()); err != nil { + if err := w.CreateTransaction(revertTxCanonical, seq, revertTx.GetTimestamp(), revertTx.GetMetadata(), revertTx.GetPostings()); err != nil { return fmt.Errorf("putting tx state for revert tx %d: %w", revertTx.GetId(), err) } diff --git a/internal/domain/replay/writer.go b/internal/domain/replay/writer.go index 5207b7f3d..b3d571273 100644 --- a/internal/domain/replay/writer.go +++ b/internal/domain/replay/writer.go @@ -18,7 +18,7 @@ type Writer interface { SetMetadata(canonicalKey []byte, value string) error DeleteMetadata(canonicalKey []byte) error MoveMetadata(oldKey, newKey []byte) error - CreateTransaction(canonicalKey []byte, seq uint64, timestamp *commonpb.Timestamp, metadata map[string]*commonpb.MetadataValue) error + CreateTransaction(canonicalKey []byte, seq uint64, timestamp *commonpb.Timestamp, metadata map[string]*commonpb.MetadataValue, postings []*commonpb.Posting) error SetRevertedBy(canonicalKey []byte, revertTxID uint64) error SaveTxMetadata(canonicalKey []byte, metadata map[string]*commonpb.MetadataValue) error DeleteTxMetadata(canonicalKey []byte, key string) error diff --git a/internal/infra/attributes/key_store.go b/internal/infra/attributes/key_store.go index 00f8f52d6..ba34c5f27 100644 --- a/internal/infra/attributes/key_store.go +++ b/internal/infra/attributes/key_store.go @@ -158,10 +158,22 @@ func (s *KeyStore[K, T]) GetEntry(canonical []byte) (Entry[T], bool) { return entry, true } -// Delete marks the entry as a tombstone instead of removing it. -// The entry stays in the cache (surviving MirrorTouch during pipelined -// proposals) but reads via Get return nil. Tombstones age out naturally -// via cache generation rotation. +// Delete tombstones the entry in the underlying store. On the dual-generation +// AttributeCache the tombstone lands in Gen0 (in-place when Gen0 has the +// entry, or lazily fabricated from Gen1's tag when only Gen1 has it) — this +// mirrors the single-byte writeCacheTombstone the FSM issues in the same +// batch, keeping the in-memory cache equal to disk for the same applied +// index (invariant #1). Any pre-existing Gen1 entry is left untouched: the +// Gen0 tombstone shadows it on every read, and the stale Gen1 row is +// purged on the next rotation. +// +// Returns domain.ErrNotFound only when the key is absent from both +// generations. The caller (DerivedKeyStore.Merge) treats this as an +// idempotent no-op — legitimate for mirror-ingest paths that apply Delete +// logs without a prior existence check, and safe under invariant #1 (no +// downstream tombstone is written, so cache and disk stay aligned). +// +// Tombstones age out naturally via rotation. func (s *KeyStore[K, T]) Delete(canonical []byte) (id U128, tag uint64, err error) { id, tag = s.hasher.MakeKey(canonical) @@ -174,8 +186,9 @@ func (s *KeyStore[K, T]) Delete(canonical []byte) (id U128, tag uint64, err erro return id, tag, newErrCollisionDetected(canonical, entry.Tag, tag) } - entry.Deleted = true - s.M.Put(id, entry) + if delErr := s.M.Del(id); delErr != nil { + return id, tag, delErr + } return id, tag, nil } @@ -272,7 +285,20 @@ func (s *DerivedKeyStore[K, T]) Merge() ([]Update[K, T], []Deletion[K], error) { canonical := append([]byte(nil), s.scratch...) id, tag, err := s.writer.Delete(canonical) - if err != nil && !errors.Is(err, domain.ErrNotFound) { + if err != nil { + // Genuinely absent: writer.Delete returns ErrNotFound when the + // underlying AttributeCache misses on the key in BOTH generations + // (Get uses a gen0→gen1 fallback, so ErrNotFound here is a "not + // anywhere" signal). Skip the deletion entirely — writing a + // downstream disk tombstone would drift the persisted state past + // the in-memory cache (invariant #1). Legitimate for mirror- + // ingest paths that apply Delete logs without a prior existence + // check; handlers that do a Get-first business check surface + // their own domain error before Delete is queued here. + if errors.Is(err, domain.ErrNotFound) { + continue + } + return nil, nil, err } diff --git a/internal/infra/attributes/key_store_test.go b/internal/infra/attributes/key_store_test.go index 330812397..3dc1e4dc9 100644 --- a/internal/infra/attributes/key_store_test.go +++ b/internal/infra/attributes/key_store_test.go @@ -334,7 +334,7 @@ func TestDerivedKeyStoreMerge(t *testing.T) { require.ErrorIs(t, err, domain.ErrNotFound) }) - t.Run("merge deletion of non-existent key is not an error", func(t *testing.T) { + t.Run("merge deletion of non-existent key is dropped", func(t *testing.T) { t.Parallel() store := newTestKeyStore() @@ -342,9 +342,13 @@ func TestDerivedKeyStoreMerge(t *testing.T) { derived.Delete(testKey{name: "ghost"}) + // Ghost deletions must NOT surface as a Deletion entry — otherwise + // flushAttributeAndCache would write an on-disk tombstone for a + // key the in-memory cache has no matching entry for, breaking the + // cache/disk equality invariant. See DerivedKeyStore.Merge. updates, deletions, err := derived.Merge() require.NoError(t, err) require.Empty(t, updates) - require.Len(t, deletions, 1) + require.Empty(t, deletions) }) } diff --git a/internal/infra/backup/rebuild.go b/internal/infra/backup/rebuild.go index 7d40f069c..b694cdff6 100644 --- a/internal/infra/backup/rebuild.go +++ b/internal/infra/backup/rebuild.go @@ -552,11 +552,12 @@ func (w *attributeReplayWriter) MoveMetadata(oldKey, newKey []byte) error { return w.metadata.Delete(w.batch, oldKey) } -func (w *attributeReplayWriter) CreateTransaction(canonicalKey []byte, seq uint64, timestamp *commonpb.Timestamp, metadata map[string]*commonpb.MetadataValue) error { +func (w *attributeReplayWriter) CreateTransaction(canonicalKey []byte, seq uint64, timestamp *commonpb.Timestamp, metadata map[string]*commonpb.MetadataValue, postings []*commonpb.Posting) error { txState := &commonpb.TransactionState{ CreatedByLog: seq, Metadata: metadata, Timestamp: timestamp, + Postings: postings, } _, err := w.tx.Set(w.batch, canonicalKey, txState) diff --git a/internal/infra/cache/cache.go b/internal/infra/cache/cache.go index 14a4a212c..7926f1c06 100644 --- a/internal/infra/cache/cache.go +++ b/internal/infra/cache/cache.go @@ -12,11 +12,11 @@ import ( "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric/noop" + "github.com/formancehq/ledger/v3/internal/domain" "github.com/formancehq/ledger/v3/internal/infra/attributes" "github.com/formancehq/ledger/v3/internal/pkg/kv" "github.com/formancehq/ledger/v3/internal/proto/commonpb" "github.com/formancehq/ledger/v3/internal/proto/raftcmdpb" - "github.com/formancehq/ledger/v3/internal/storage/dal" ) // u128Hash extracts the low 64 bits of a U128 for shard selection. @@ -32,7 +32,7 @@ func newShardedMap[T any]() *kv.ShardedMap[attributes.U128, attributes.Entry[T]] // AttributeCache is a dual-generation cache for attribute values. // It uses atomic.Pointer for Gen0/Gen1 backed by kv.ShardedMap (64 shards -// with per-shard RWMutex). Readers (Get, IsGuaranteedInCache) take a shared +// with per-shard RWMutex). Readers (Get, Lookup, CheckCache) take a shared // RLock on a single shard — no interface boxing, no heap allocation for U128 keys. // Writers (Put, Del) are called only from the FSM goroutine. // Only Rotate needs synchronization, which is done by the FSM goroutine @@ -52,6 +52,17 @@ func (a *AttributeCache[T]) Gen1() *kv.ShardedMap[attributes.U128, attributes.En return a.gen1.Load() } +// Get returns the entry for k, falling back to Gen1 when Gen0 misses. +// +// The gen0 → gen1 fallback is safe under the FSM's coverage gate +// (invariant #9): every read the apply path performs goes through +// Scope.GetX with an admission-declared preload, so the fallback can +// only surface keys the proposer explicitly authorized. Reads on +// undeclared keys are rejected at the gate before ever reaching Get. +// +// Callers that need to distinguish Gen0 from Gen1 explicitly (e.g. +// MirrorPreload's gen1-wins seed decision, snapshot persistence) use the +// Gen0() / Gen1() accessors. func (a *AttributeCache[T]) Get(k attributes.U128) (attributes.Entry[T], bool) { if v, ok := a.gen0.Load().Get(k); ok { return v, true @@ -74,33 +85,54 @@ func (a *AttributeCache[T]) GetAndPut(k attributes.U128, v attributes.Entry[T]) return a.gen1.Load().Get(k) } -// Del marks the entry as a tombstone in both Gen0 and Gen1 instead of -// removing it. This prevents pipelined MirrorTouch from failing when -// the leader admitted a touch before the FSM processed the deletion — -// admission relies on the tombstone being present in the cache to -// distinguish "deleted" from "cache miss". Tombstones age out naturally -// via rotation. +// Del tombstones the entry in Gen0 in-place, mirroring the on-disk +// writeCacheTombstone which writes a single row to the current gen0 byte. +// +// If Gen0 doesn't hold the entry, Del promotes the Gen1 entry's tag into +// a fresh Gen0 tombstone (Deleted=true, Data=zero). Gen1's live row is +// intentionally left untouched: the Gen0 tombstone shadows it on every +// read (Get returns the tombstone via the gen0→gen1 fallback semantics — +// gen0 hits first), and rotation purges the stale Gen1 row on the next +// generation flip. This lazy promote replaces the historical +// systematic-MirrorTouch-at-Preload pass: coverage_bits (invariant #9) +// already prevents Del from firing on keys admission did not declare, +// so promoting at Del time is both sufficient and cheaper than +// pre-promoting every declared key upfront. +// +// Returns ErrNotFound if the key is genuinely absent from both +// generations. Under proper admission, every Delete is preceded by a +// Get that would surface the same ErrNotFound at the business layer, +// so this error rarely reaches Del — DerivedKeyStore.Merge treats it +// as a soft no-op. // // Data is reset to the zero value: a tombstone's payload is unreadable -// by contract (every consumer checks Deleted first and returns -// ErrNotFound), so retaining the pre-delete value yields zombie state -// that has historically caused snapshot/restore foot-guns (see EN-1377 -// — the persist path used to marshal entry.Data unconditionally and -// resurrect the deleted key on restore). -func (a *AttributeCache[T]) Del(k attributes.U128) { +// by contract (every consumer checks Deleted first), and retaining it +// has historically caused snapshot/restore resurrection (EN-1377). +// +// Called only from the FSM goroutine. +func (a *AttributeCache[T]) Del(k attributes.U128) error { var zero T - if entry, ok := a.gen0.Load().Get(k); ok { + gen0 := a.gen0.Load() + if entry, ok := gen0.Get(k); ok { entry.Deleted = true entry.Data = zero - a.gen0.Load().Put(k, entry) + gen0.Put(k, entry) + + return nil } - if entry, ok := a.gen1.Load().Get(k); ok { - entry.Deleted = true - entry.Data = zero - a.gen1.Load().Put(k, entry) + // Gen0 miss — try Gen1 to reuse the entry's tag on the fabricated + // tombstone. The tag is what KeyStore.Delete matched against; a + // U128 collision on a different canonical key would already have + // been rejected upstream by KeyStore.Delete's tag check. + if gen1Entry, ok := a.gen1.Load().Get(k); ok { + gen0.Put(k, attributes.Entry[T]{Tag: gen1Entry.Tag, Deleted: true, Data: zero}) + + return nil } + + return domain.ErrNotFound } func (a *AttributeCache[T]) Size() uint64 { @@ -142,12 +174,16 @@ func (a *AttributeCache[T]) Reset() { type CacheStatus int const ( - // CacheGuaranteed means the key is in cache and will survive until the target index. - CacheGuaranteed CacheStatus = iota - // CacheNeedsTouch means the key is in Gen1 but not Gen0 — a touch (Gen1→Gen0 promotion) - // will keep it alive without a store read. - CacheNeedsTouch - // CacheMiss means the key is not in cache at all — a full preload from store is needed. + // CacheHit means the key is in cache somewhere within the reachable + // horizon (Gen0 hit now, or Gen1 hit that AttributeCache.Get's gen0→gen1 + // fallback will surface at apply). No Pebble read is required. The + // coverage_bits gate (invariant #9) bounds the FSM's read horizon to + // admission's declared preload set, so the fallback can only reach + // keys the proposer explicitly authorized. Admission therefore does + // not need to distinguish "already in Gen0" from "Gen1-only". + CacheHit CacheStatus = iota + // CacheMiss means the key is not in cache at all — a full preload + // from store is needed. CacheMiss // CacheUnreachable means the target index is 2+ generations ahead of the // current FSM-applied generation: any preload value computed now would be @@ -161,7 +197,7 @@ const ( ) // CheckCache determines whether a key will survive in cache until the future raft -// index `at`, needs a touch (promotion from Gen1 to Gen0), or is a full miss. +// index `at`, or is a full miss. // // The cache uses a dual-generation system where: // - Gen0 contains data from the current generation @@ -206,28 +242,34 @@ func (a *AttributeCache[T]) CheckCache(at uint64, k attributes.U128) CacheStatus switch futureGeneration - actualGeneration { case 0: - // Same generation — no rotation expected. + // Same generation — no rotation expected before apply. Either + // gen holds the key: the FSM apply path reads via Get which + // falls back to Gen1, so the caller only needs to know "in + // cache anywhere". if _, ok := a.gen0.Load().Get(k); ok { - return CacheGuaranteed + return CacheHit } if _, ok := a.gen1.Load().Get(k); ok { - // Key in Gen1 but not Gen0 — will survive this generation, - // but needs a touch to survive the next rotation. - return CacheNeedsTouch + return CacheHit } return CacheMiss case 1: - // Next generation — one rotation expected. - // Only Gen0 data survives (it becomes Gen1 after rotation). + // Next generation — one rotation expected before apply. Rotation + // moves old Gen0 into new Gen1 and empties new Gen0; old Gen1 is + // discarded. + // + // If Gen0 has the key now, post-rotation it sits in new Gen1 only. + // Get's gen0→gen1 fallback still surfaces it (and lazy Del promotes + // on tombstone if the handler deletes). if _, ok := a.gen0.Load().Get(k); ok { - return CacheGuaranteed + return CacheHit } - // A touch is NOT safe here: the FSM applies rotation BEFORE touches, - // so Gen1 would be discarded before the touch can promote the key. - + // If Gen0 lacks the key, the entry (if it exists) lives in old + // Gen1 which rotation will discard — no promote can save it. + // Fall through to CacheMiss so the caller loads from store. return CacheMiss default: // 2+ generations ahead — any preload value computed now would be @@ -239,34 +281,6 @@ func (a *AttributeCache[T]) CheckCache(at uint64, k attributes.U128) CacheStatus } } -// IsGuaranteedInCache checks if a key will still be in the cache when we reach -// the future raft index `at`. Convenience wrapper around CheckCache. -func (a *AttributeCache[T]) IsGuaranteedInCache(at uint64, k attributes.U128) bool { - return a.CheckCache(at, k) == CacheGuaranteed -} - -// Touch promotes a key from Gen1 to Gen0. This ensures the key survives -// the next rotation without needing a full preload from store. -// Called only from the FSM goroutine (same as Put/Del). -// -// IMPORTANT: Touch must NOT overwrite an existing Gen0 entry. After rotation, -// entries processed earlier in the same batch may have already updated Gen0 -// with a newer value via Merge. If a later entry's proposal was admitted -// concurrently (before the FSM applied the earlier entry's Touch+Merge), -// it would carry a redundant CacheNeedsTouch. Blindly overwriting Gen0 with -// the stale Gen1 value would discard the earlier entry's update, causing -// volume corruption (lost deltas). -func (a *AttributeCache[T]) Touch(k attributes.U128) { - gen0 := a.gen0.Load() - if _, ok := gen0.Get(k); ok { - return // Gen0 already has a (possibly newer) value — do not overwrite. - } - - if v, ok := a.gen1.Load().Get(k); ok { - gen0.Put(k, v) - } -} - func newAttributeCache[T any](cache *Cache, cacheType string) *AttributeCache[T] { ac := &AttributeCache[T]{ Cache: cache, @@ -286,7 +300,6 @@ type CacheOps interface { Rotate() Reset() Size() uint64 - Touch(attributes.U128) } type Cache struct { @@ -311,10 +324,8 @@ type Cache struct { caches []CacheOps // cacheNames holds the metric label for each cache, parallel to caches. cacheNames []string - // touchMap maps attribute code bytes to their cache for Touch dispatch. - touchMap map[byte]CacheOps - // currentGeneration is accessed atomically from IsGuaranteedInCache (hot path) + // currentGeneration is accessed atomically from CheckCache (hot path) // and written under mu by rotateLocked/Reset. currentGeneration atomic.Uint64 @@ -507,15 +518,6 @@ func (c *Cache) recordGeneration(gen int64) { c.generationGauge.Record(context.Background(), gen) } -// TouchByType promotes a key from Gen1 to Gen0 for the cache identified by the -// given attribute code byte. This is the unified dispatch point used by the FSM -// Preload path instead of a per-type switch. -func (c *Cache) TouchByType(attrType byte, id attributes.U128) { - if tc, ok := c.touchMap[attrType]; ok { - tc.Touch(id) - } -} - // CurrentGeneration returns the current generation number. func (c *Cache) CurrentGeneration() uint64 { return c.currentGeneration.Load() @@ -620,22 +622,6 @@ func New(generationThreshold uint64, m metric.Meter) (*Cache, error) { "indexes", } - // Register touch dispatch map for attribute code byte -> cache. - ret.touchMap = map[byte]CacheOps{ - dal.SubAttrVolume: ret.Volumes, - dal.SubAttrReference: ret.References, - dal.SubAttrLedger: ret.Ledgers, - dal.SubAttrBoundary: ret.Boundaries, - dal.SubAttrSinkConfig: ret.SinkConfigs, - dal.SubAttrMetadata: ret.AccountMetadata, - dal.SubAttrNumscriptVersion: ret.NumscriptVersions, - dal.SubAttrTransaction: ret.Transactions, - dal.SubAttrNumscriptContent: ret.NumscriptContents, - dal.SubAttrPreparedQuery: ret.PreparedQueries, - dal.SubAttrLedgerMetadata: ret.LedgerMetadata, - dal.SubAttrIndex: ret.Indexes, - } - err := ret.initMetrics(m) if err != nil { return nil, fmt.Errorf("initializing cache metrics: %w", err) diff --git a/internal/infra/cache/cache_test.go b/internal/infra/cache/cache_test.go index 749a0a27c..6a4fd6a15 100644 --- a/internal/infra/cache/cache_test.go +++ b/internal/infra/cache/cache_test.go @@ -142,77 +142,6 @@ func TestAttributeCache_PutGet(t *testing.T) { assert.Equal(t, uint64(123), result.Tag) } -func TestAttributeCache_Del(t *testing.T) { - t.Parallel() - - cache, err := New(10, nil) - require.NoError(t, err) - - ac := cache.Volumes - - key := attributes.NewU128(1, 2) - entry := attributes.Entry[*raftcmdpb.VolumePair]{ - Tag: 123, - Data: &raftcmdpb.VolumePair{}, - } - - ac.Put(key, entry) - result, ok := ac.Get(key) - require.True(t, ok) - assert.False(t, result.Deleted) - - ac.Del(key) - // Del marks as tombstone — the entry is still present but flagged as deleted. - // KeyStore.Get filters tombstones, but AttributeCache.Get returns them - // so that CheckCache/MirrorTouch still see the key as present. - result, ok = ac.Get(key) - assert.True(t, ok) - assert.True(t, result.Deleted) - // Data is reset to the zero value: a tombstone's payload is unreadable by - // contract, and retaining it has historically caused snapshot/restore - // foot-guns (EN-1377). - assert.Nil(t, result.Data, "Del must reset entry.Data to the zero value") -} - -func TestAttributeCache_DelResetsDataAcrossGenerations(t *testing.T) { - t.Parallel() - - cache, err := New(10, nil) - require.NoError(t, err) - - ac := cache.Volumes - - key1 := attributes.NewU128(1, 1) - key2 := attributes.NewU128(2, 2) - - ac.Put(key1, attributes.Entry[*raftcmdpb.VolumePair]{ - Tag: 10, - Data: &raftcmdpb.VolumePair{Input: commonpb.NewUint256FromUint64(100)}, - }) - - ac.Rotate() - - // key1 is now in Gen1 (post-rotation). Add key2 to Gen0 with the same id - // signature so both generations carry a payload — Del must clear both. - ac.Put(key2, attributes.Entry[*raftcmdpb.VolumePair]{ - Tag: 20, - Data: &raftcmdpb.VolumePair{Input: commonpb.NewUint256FromUint64(200)}, - }) - - ac.Del(key1) - ac.Del(key2) - - got, ok := ac.Gen1().Get(key1) - require.True(t, ok, "tombstone must remain visible in Gen1 for the cache predictor") - assert.True(t, got.Deleted) - assert.Nil(t, got.Data, "Gen1 tombstone payload must be reset") - - got, ok = ac.Gen0().Get(key2) - require.True(t, ok, "tombstone must remain visible in Gen0 for the cache predictor") - assert.True(t, got.Deleted) - assert.Nil(t, got.Data, "Gen0 tombstone payload must be reset") -} - func TestAttributeCache_Size(t *testing.T) { t.Parallel() @@ -244,33 +173,31 @@ func TestAttributeCache_Rotate(t *testing.T) { // Add to Gen0 ac.Put(key1, attributes.Entry[*raftcmdpb.VolumePair]{Tag: 1}) - // Rotate: Gen0 -> Gen1, new empty Gen0 + // Rotate: Gen0 -> Gen1, new empty Gen0. ac.Rotate() - // key1 should still be accessible (now in Gen1) + // Get falls back to Gen1 when Gen0 misses. _, ok := ac.Get(key1) - assert.True(t, ok, "key1 should be in Gen1 after rotation") + assert.True(t, ok, "key1 must be reachable via Get's Gen1 fallback after rotation") - // Add key2 to new Gen0 + // Add key2 to new Gen0. ac.Put(key2, attributes.Entry[*raftcmdpb.VolumePair]{Tag: 2}) - // Both keys should be accessible + // Both keys visible via Get. _, ok = ac.Get(key1) assert.True(t, ok) _, ok = ac.Get(key2) assert.True(t, ok) - // Rotate again: Gen1 (with key1) is discarded + // Rotate again: Gen1 (with key1) is discarded. ac.Rotate() - - // key1 should be gone, key2 should be in Gen1 _, ok = ac.Get(key1) - assert.False(t, ok, "key1 should be gone after second rotation") + assert.False(t, ok, "key1 fully evicted after two rotations") _, ok = ac.Get(key2) - assert.True(t, ok, "key2 should be in Gen1 after second rotation") + assert.True(t, ok, "key2 sits in Gen1 after the second rotation") } -func TestAttributeCache_IsGuaranteedInCache_SameGeneration(t *testing.T) { +func TestAttributeCache_CheckCache_SameGeneration(t *testing.T) { t.Parallel() cache, err := New(10, nil) @@ -279,24 +206,25 @@ func TestAttributeCache_IsGuaranteedInCache_SameGeneration(t *testing.T) { ac := cache.Volumes cache.SetCurrentGeneration(0) - key := attributes.NewU128(1, 1) - ac.Put(key, attributes.Entry[*raftcmdpb.VolumePair]{}) - - // Index 5 is in generation 0 (same as current), key is in Gen0 -> true - assert.True(t, ac.IsGuaranteedInCache(5, key)) - - // Non-existent key - nonExistent := attributes.NewU128(99, 99) - assert.False(t, ac.IsGuaranteedInCache(5, nonExistent)) + // Gen0 hit → CacheHit. + gen0Key := attributes.NewU128(1, 1) + ac.Put(gen0Key, attributes.Entry[*raftcmdpb.VolumePair]{}) + assert.Equal(t, CacheHit, ac.CheckCache(5, gen0Key)) - // Key in Gen1 only needs a touch — not guaranteed (would be lost after next rotation) + // Gen1-only entry within the same-generation horizon: the FSM apply + // path reads via AttributeCache.Get which falls back to Gen1, so + // admission does not need to distinguish "already Gen0" from + // "Gen1-only" — both return CacheHit. gen1Key := attributes.NewU128(3, 3) ac.Gen1().Put(gen1Key, attributes.Entry[*raftcmdpb.VolumePair]{}) - assert.False(t, ac.IsGuaranteedInCache(5, gen1Key)) - assert.Equal(t, CacheNeedsTouch, ac.CheckCache(5, gen1Key)) + assert.Equal(t, CacheHit, ac.CheckCache(5, gen1Key)) + + // Absent from both generations → CacheMiss. + nonExistent := attributes.NewU128(99, 99) + assert.Equal(t, CacheMiss, ac.CheckCache(5, nonExistent)) } -func TestAttributeCache_IsGuaranteedInCache_NextGeneration(t *testing.T) { +func TestAttributeCache_CheckCache_NextGeneration(t *testing.T) { t.Parallel() cache, err := New(10, nil) @@ -308,21 +236,23 @@ func TestAttributeCache_IsGuaranteedInCache_NextGeneration(t *testing.T) { keyInGen0 := attributes.NewU128(1, 1) keyInGen1 := attributes.NewU128(2, 2) - // Put keyInGen0 in Gen0 + // Put keyInGen0 in Gen0 (rotation will move it to new Gen1 by apply + // time; Get's gen0→gen1 fallback still surfaces it, and lazy Del + // promotes it if the handler tombstones). ac.Put(keyInGen0, attributes.Entry[*raftcmdpb.VolumePair]{}) - // Simulate keyInGen1 being in Gen1 only + // Simulate keyInGen1 being in Gen1 only (rotation will discard it — + // no promote can save it). ac.Gen1().Put(keyInGen1, attributes.Entry[*raftcmdpb.VolumePair]{}) - // Index 15 is in generation 1 (next generation) - // keyInGen0 is in Gen0, will survive one rotation -> true - assert.True(t, ac.IsGuaranteedInCache(15, keyInGen0)) - - // keyInGen1 is in Gen1, will be discarded after rotation -> false - assert.False(t, ac.IsGuaranteedInCache(15, keyInGen1)) + // Index 15 is in generation 1 (next generation): one rotation expected. + assert.Equal(t, CacheHit, ac.CheckCache(15, keyInGen0), + "Gen0-hit at next-gen horizon still reaches apply via the gen0→gen1 fallback") + assert.Equal(t, CacheMiss, ac.CheckCache(15, keyInGen1), + "Gen1-only at next-gen horizon is discarded by rotation") } -func TestAttributeCache_IsGuaranteedInCache_TwoGenerationsAhead(t *testing.T) { +func TestAttributeCache_CheckCache_TwoGenerationsAhead(t *testing.T) { t.Parallel() cache, err := New(10, nil) @@ -336,11 +266,7 @@ func TestAttributeCache_IsGuaranteedInCache_TwoGenerationsAhead(t *testing.T) { // Index 25 is in generation 2 (two generations ahead): any preload // computed now would be rotated out before apply. CheckCache surfaces - // this as CacheUnreachable so admission can reject the proposal with a - // transient error — Guaranteed must be false and the explicit status - // must be CacheUnreachable (not CacheMiss, which would mistakenly drive - // a stale preload). - assert.False(t, ac.IsGuaranteedInCache(25, key)) + // CacheUnreachable so admission rejects the proposal transiently. assert.Equal(t, CacheUnreachable, ac.CheckCache(25, key), "≥2 generations ahead must report CacheUnreachable") @@ -475,9 +401,9 @@ func TestCache_CheckRotationNeeded_NewGeneration(t *testing.T) { cache.CheckRotationNeeded(11) assert.Equal(t, uint64(1), cache.CurrentGeneration()) - // Data should now be in Gen1 (still accessible) + // Get falls back to Gen1 after a rotation. _, ok := cache.Volumes.Get(key) - assert.True(t, ok) + assert.True(t, ok, "rotated entry still reachable via Get's Gen1 fallback") // BaseIndex should be the canonical boundary genEndIndex(0, 10) = 10 assert.Equal(t, uint64(10), cache.BaseIndex.Gen0) @@ -500,21 +426,21 @@ func TestCache_CheckRotationNeeded_MultipleGenerations(t *testing.T) { keyGen1 := attributes.NewU128(2, 2) cache.Volumes.Put(keyGen1, attributes.Entry[*raftcmdpb.VolumePair]{Tag: 2}) - // Both keys accessible + // Both keys visible via Get (keyGen0 via the Gen1 fallback). _, ok := cache.Volumes.Get(keyGen0) - assert.True(t, ok) + assert.True(t, ok, "keyGen0 in Gen1 reachable via Get's fallback") _, ok = cache.Volumes.Get(keyGen1) - assert.True(t, ok) + assert.True(t, ok, "keyGen1 was just written to Gen0") // Move to generation 2 cache.CheckRotationNeeded(21) assert.Equal(t, uint64(2), cache.CurrentGeneration()) - // keyGen0 should be gone, keyGen1 should still be there + // keyGen0 is fully evicted; keyGen1 is now in Gen1. _, ok = cache.Volumes.Get(keyGen0) - assert.False(t, ok, "keyGen0 should be gone after second rotation") + assert.False(t, ok, "keyGen0 gone after second rotation") _, ok = cache.Volumes.Get(keyGen1) - assert.True(t, ok, "keyGen1 should still be accessible") + assert.True(t, ok, "keyGen1 still reachable via Gen1 fallback after the second rotation") } func TestCache_NewCache_ZeroThresholdReturnsError(t *testing.T) { @@ -630,38 +556,6 @@ func TestCache_SetGenerationThresholdRejectsZero(t *testing.T) { ) } -func TestAttributeCache_Touch_DoesNotOverwriteGen0(t *testing.T) { - t.Parallel() - - cache, err := New(10, nil) - require.NoError(t, err) - - ac := cache.Volumes - - key := attributes.NewU128(1, 1) - - // Put a value in Gen0, then rotate so it moves to Gen1 - gen1Entry := attributes.Entry[*raftcmdpb.VolumePair]{Tag: 100} - ac.Put(key, gen1Entry) - ac.Rotate() - - // Touch promotes Gen1 → Gen0 (Gen0 is empty) - ac.Touch(key) - v, ok := ac.Gen0().Get(key) - require.True(t, ok) - assert.Equal(t, uint64(100), v.Tag) - - // Simulate a Merge updating Gen0 with a newer value - gen0Entry := attributes.Entry[*raftcmdpb.VolumePair]{Tag: 200} - ac.Gen0().Put(key, gen0Entry) - - // A redundant Touch (from a concurrent admission) must NOT overwrite the newer Gen0 value - ac.Touch(key) - v, ok = ac.Gen0().Get(key) - require.True(t, ok) - assert.Equal(t, uint64(200), v.Tag, "Touch must not overwrite existing Gen0 entry") -} - func TestCache_AllAttributeCachesRotate(t *testing.T) { t.Parallel() @@ -676,16 +570,17 @@ func TestCache_AllAttributeCachesRotate(t *testing.T) { // Trigger rotation cache.CheckRotationNeeded(11) - // All data should still be accessible (in Gen1) + // After rotation, Get's gen0→gen1 fallback still surfaces the entry + // (Gen0 is fresh empty, Gen1 holds what used to be Gen0). _, ok := cache.Volumes.Get(key) - assert.True(t, ok) + assert.True(t, ok, "Volumes still reachable via gen1 fallback after first rotation") _, ok = cache.AccountMetadata.Get(key) - assert.True(t, ok) + assert.True(t, ok, "AccountMetadata still reachable via gen1 fallback after first rotation") - // Trigger another rotation + // Trigger another rotation — the old Gen1 (which was Gen0) is now + // discarded and both generations are empty. cache.CheckRotationNeeded(21) - // All data should be gone _, ok = cache.Volumes.Get(key) assert.False(t, ok) _, ok = cache.AccountMetadata.Get(key) diff --git a/internal/infra/cache/keystore_delete_test.go b/internal/infra/cache/keystore_delete_test.go new file mode 100644 index 000000000..f3a61e529 --- /dev/null +++ b/internal/infra/cache/keystore_delete_test.go @@ -0,0 +1,120 @@ +package cache + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/formancehq/ledger/v3/internal/domain" + "github.com/formancehq/ledger/v3/internal/infra/attributes" + "github.com/formancehq/ledger/v3/internal/proto/commonpb" +) + +// TestKeyStoreDelete_Gen0Only exercises the well-formed flow: the entry is in +// Gen0 at Delete time, Del tombstones it in place, cache equals disk for the +// same applied index (invariant #1). +func TestKeyStoreDelete_Gen0Only(t *testing.T) { + t.Parallel() + + c, err := New(10, nil) + require.NoError(t, err) + + ac := c.LedgerMetadata + ks := attributes.NewKeyStore[domain.LedgerMetadataKey, *commonpb.MetadataValue](ac) + + canonical := domain.LedgerMetadataKey{LedgerName: "l", Key: "k"}.Bytes() + + _, idWithTag, err := ks.Put(canonical, commonpb.NewStringValue("v")) + require.NoError(t, err) + + id := idWithTag.ID + tag := idWithTag.Tag + + _, _, err = ks.Delete(canonical) + require.NoError(t, err) + + gen0Entry, ok := ac.Gen0().Get(id) + require.True(t, ok, "Gen0 must hold a tombstone after delete") + assert.True(t, gen0Entry.Deleted) + assert.Equal(t, tag, gen0Entry.Tag) + + // KeyStore.Get filters tombstones — a read of the deleted key surfaces + // as ErrNotFound. + _, _, err = ks.Get(canonical) + require.ErrorIs(t, err, domain.ErrNotFound) +} + +// TestKeyStoreDelete_LazyPromoteFromGen1: after a rotation the live entry +// sits in Gen1 only. AttributeCache.Del fabricates a Gen0 tombstone from +// the Gen1 entry's tag; Gen1's live row is intentionally left untouched +// (shadowed by the Gen0 tombstone on every read, purged on the next +// rotation). The on-disk writeCacheTombstone writes a single row to the +// current gen0 byte, so cache stays equal to disk. +func TestKeyStoreDelete_LazyPromoteFromGen1(t *testing.T) { + t.Parallel() + + c, err := New(10, nil) + require.NoError(t, err) + + ac := c.LedgerMetadata + ks := attributes.NewKeyStore[domain.LedgerMetadataKey, *commonpb.MetadataValue](ac) + + canonical := domain.LedgerMetadataKey{LedgerName: "l", Key: "k"}.Bytes() + + _, idWithTag, err := ks.Put(canonical, commonpb.NewStringValue("v")) + require.NoError(t, err) + + id := idWithTag.ID + tag := idWithTag.Tag + + // Rotate: the live entry moves Gen0 -> Gen1. + ac.Rotate() + + // Del promotes the tombstone into Gen0 lazily — no separate MirrorTouch + // pass is needed. Gen1 keeps its live row. + _, _, err = ks.Delete(canonical) + require.NoError(t, err) + + gen0Entry, ok := ac.Gen0().Get(id) + require.True(t, ok, "Gen0 must hold a fabricated tombstone after lazy promote") + assert.True(t, gen0Entry.Deleted) + assert.Equal(t, tag, gen0Entry.Tag, "tombstone borrows Gen1's tag") + + // Gen1 stays live — shadowed by the Gen0 tombstone on read, purged on + // the next rotation. + gen1Entry, ok := ac.Gen1().Get(id) + require.True(t, ok, "Gen1 must keep its pre-rotation row") + assert.False(t, gen1Entry.Deleted, "Gen1 entry is not tombstoned by Del") + + // KeyStore.Get surfaces the Gen0 tombstone as ErrNotFound. + _, _, err = ks.Get(canonical) + require.ErrorIs(t, err, domain.ErrNotFound) +} + +// TestKeyStoreDelete_AbsentReturnsNotFound: Del on a key present in +// neither generation surfaces domain.ErrNotFound. DerivedKeyStore.Merge +// treats this as a soft skip; under proper admission every Delete is +// preceded by a Get that would surface the same ErrNotFound before Del +// is reached. +func TestKeyStoreDelete_AbsentReturnsNotFound(t *testing.T) { + t.Parallel() + + c, err := New(10, nil) + require.NoError(t, err) + + ac := c.LedgerMetadata + ks := attributes.NewKeyStore[domain.LedgerMetadataKey, *commonpb.MetadataValue](ac) + + canonical := domain.LedgerMetadataKey{LedgerName: "l", Key: "k"}.Bytes() + + _, _, err = ks.Delete(canonical) + require.ErrorIs(t, err, domain.ErrNotFound) + + // No fabricated tombstone. + id, _ := attributes.MakeKey(canonical) + _, ok := ac.Gen0().Get(id) + assert.False(t, ok) + _, ok = ac.Gen1().Get(id) + assert.False(t, ok) +} diff --git a/internal/infra/node/applier_test.go b/internal/infra/node/applier_test.go index 16f29d78a..6ab6c4c27 100644 --- a/internal/infra/node/applier_test.go +++ b/internal/infra/node/applier_test.go @@ -174,9 +174,8 @@ func makeCreateLedgerEntry(t *testing.T, index uint64, name string) (raftpb.Entr // processCreateLedger performs on WriteSet.GetLedger before writing. ledgerID, _ := attributes.MakeKey(domain.LedgerKey{Name: name}.Bytes()) cmd.ExecutionPlan = &raftcmdpb.ExecutionPlan{ - Attributes: []*raftcmdpb.AttributePlan{{ + Attributes: []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: ledgerID[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }}, } @@ -495,9 +494,8 @@ func makeCreateLedgerEntryWithTerm(t *testing.T, term, index uint64, name string ledgerID, _ := attributes.MakeKey(domain.LedgerKey{Name: name}.Bytes()) cmd.ExecutionPlan = &raftcmdpb.ExecutionPlan{ - Attributes: []*raftcmdpb.AttributePlan{{ + Attributes: []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: ledgerID[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }}, } order.CoverageBits = []byte{0b1} diff --git a/internal/infra/plan/attribute_resolvers.go b/internal/infra/plan/attribute_resolvers.go index ea78c8ca8..af5bcd456 100644 --- a/internal/infra/plan/attribute_resolvers.go +++ b/internal/infra/plan/attribute_resolvers.go @@ -18,9 +18,9 @@ import ( // protoAttrResolver[T]; the interface exists so the builder can hold // them uniformly in a map keyed by attrCode. type attrResolver interface { - // Resolve walks the keys set through resolveAttributePreload for + // Resolve walks the keys set through resolveCoverage for // this attribute's cache/loader/store bindings. Returns the batch - // of AttributePlan entries and the tracker keys the caller must + // of AttributeCoverage entries and the tracker keys the caller must // associate with Loader() for the CleanupToken. Resolve( keys map[string]struct{}, @@ -61,7 +61,7 @@ func (r *protoAttrResolver[T]) Resolve( store dal.PebbleGetter, logger logging.Logger, ) (*resolveResult, error) { - return resolveAttributePreload[T]( + return resolveCoverage[T]( keys, nextIndex, boundary, cacheEpoch, r.cache, r.loader, r.getValue, store, r.attrCode, nil, r.bloom(), logger, r.typeName, @@ -72,7 +72,7 @@ func (r *protoAttrResolver[T]) Loader() preload.LoaderOps { return r.loader } // buildAttrResolvers returns the full registration set keyed by // dal.SubAttrX for every attribute cache. Adding a new attribute -// cache means adding one entry here — no changes needed in Needs, +// cache means adding one entry here — no changes needed in Coverage, // admission call sites, coverage_bits, or the builder loop. func buildAttrResolvers( c *cache.Cache, diff --git a/internal/infra/plan/builder.go b/internal/infra/plan/builder.go index e4dfddb10..128a5eaa8 100644 --- a/internal/infra/plan/builder.go +++ b/internal/infra/plan/builder.go @@ -43,7 +43,7 @@ type Builder struct { // single-line change in buildAttrResolvers. resolvers map[byte]attrResolver - // maxPlanSize is the cap on the number of AttributePlan entries an + // maxPlanSize is the cap on the number of AttributeCoverage entries an // ExecutionPlan may carry. Build returns domain.ErrExecutionPlanTooLarge // past this threshold so admission rejects pathological payloads up // front rather than paying for proportionally-large coverage slices @@ -57,7 +57,7 @@ type Builder struct { // // operations is captured here so Run can iterate them right before each // (re-)marshal without the caller re-passing the slice. aggregate is the -// merged Needs across all operations — used for preload boundary +// merged Coverage across all operations — used for preload boundary // validation under the guard. type BuildResult struct { ExecutionPlan *raftcmdpb.ExecutionPlan @@ -65,7 +65,7 @@ type BuildResult struct { nextIndex uint64 nextIndexGen uint64 // gen(nextIndex, threshold) — the future generation used by CheckCache operations []WriteOperation - aggregate *Needs + aggregate *Coverage } // ReleaseLoaders releases the loader cleanup token from the build. @@ -116,7 +116,7 @@ func (g *ProposalGuard) ReleaseAll() { } // NewBuilder creates a Builder using the given IndexTracker for Raft index -// prediction. maxPlanSize caps the number of AttributePlan entries an +// prediction. maxPlanSize caps the number of AttributeCoverage entries an // ExecutionPlan may carry (0 = unlimited); see Builder.maxPlanSize. func NewBuilder(tracker *node.IndexTracker, c *cache.Cache, attrs *attributes.Attributes, store *dal.Store, bloomFilters *bloom.FilterSet, logger logging.Logger, maxPlanSize int) *Builder { b := &Builder{ @@ -140,56 +140,6 @@ func (p *Builder) Loaders() *preload.Loaders { return p.loaders } -// ResolveLedgerID resolves a ledger name to its uint32 ID using the standard -// attribute resolution path: bloom → cache → Pebble. -// Returns (0, false) if the ledger does not exist. -func (p *Builder) ResolveLedgerID(name string) (uint32, bool) { - canonical := domain.LedgerKey{Name: name}.Bytes() - id, _ := attributes.MakeKey(canonical) - - // 1. Bloom filter: if definitely absent, skip. Use the IsReady-guarded - // helper — the raw FilterForAttrType returns the (still-empty) filter - // even while restoreBloomFilters / StartAsyncBloomPopulate is rebuilding - // it on boot or after a rebuild. During that window every MayContain - // returns false, so ResolveLedgerID would falsely answer "not found" - // for every pre-existing ledger and admission would reject the matching - // proposals with ErrBalanceNotPreloaded / reverts with ErrLedgerNotFound - // (#318). - bf := p.bloomFilter(dal.SubAttrLedger) - if bf != nil && !bf.MayContain(id) { - return 0, false - } - - // bf == nil with bloom configured but not ready means the populate window - // (#318) is open and we are falling through to cache/Pebble. Re-reading - // readiness here can race SetReady: the window may close between the two - // reads, which only under-reports the condition — it never over-reports - // (a bloom-disabled deployment keeps bloomFilters == nil and a ready - // snapshot keeps Ready() == true). - bloomPopulating := bf == nil && p.bloomFilters != nil && !p.bloomFilters.Snapshot().Ready() - - var ( - resolvedID uint32 - resolved bool - ) - - // 2. Cache: check gen0/gen1. - if entry, ok := p.cache.Ledgers.Get(id); ok && entry.Data != nil { - resolvedID, resolved = entry.Data.GetId(), true - } else if info, err := p.attrs.Ledger.Get(p.store, canonical); err == nil && info != nil { - // 3. Pebble fallback (single point read, no snapshot needed). - resolvedID, resolved = info.GetId(), true - } - - // Antithesis anchor: a pre-existing ledger was successfully resolved - // through the fallback path while the bloom filters were still - // populating — proves the not-ready window degrades gracefully instead - // of faking absence. - assert.Sometimes(bloomPopulating && resolved, "ledger resolved while bloom filters not ready", nil) - - return resolvedID, resolved -} - // ReadBoundaries reads the current LedgerBoundaries for the given ledger // directly from Pebble (not from the cache overlay). func (p *Builder) ReadBoundaries(ledgerName string) (*raftcmdpb.LedgerBoundaries, error) { @@ -215,17 +165,17 @@ func (p *Builder) LockTracker() { p.tracker.Lock() } func (p *Builder) UnlockTracker() { p.tracker.Unlock() } // Build resolves all preload needs optimistically without holding the -// proposal lock. operations contribute their per-operation Needs to a +// proposal lock. operations contribute their per-operation Coverage to a // single aggregate that drives the preload; the slice is retained on // the BuildResult so Run can assign each operation's coverage_bits at // marshal time. // // On error, the caller must call build.ReleaseLoaders(). func (p *Builder) Build(operations []WriteOperation) (*BuildResult, error) { - aggregate := NewNeeds() + aggregate := NewCoverage() for _, op := range operations { - if op.Needs != nil { - aggregate.Merge(op.Needs) + if op.Coverage != nil { + aggregate.Merge(op.Coverage) } } @@ -345,7 +295,7 @@ type buildResult struct { // buildPreloadsAt resolves all preload needs at the given nextIndex. // Each attribute type uses independent caches and loaders, so they are resolved // in parallel to reduce wall-clock time and shard lock hold duration. -func (p *Builder) buildPreloadsAt(nextIndex uint64, snap cache.ConfigSnapshot, needs *Needs) (*raftcmdpb.ExecutionPlan, *preload.CleanupToken, error) { +func (p *Builder) buildPreloadsAt(nextIndex uint64, snap cache.ConfigSnapshot, needs *Coverage) (*raftcmdpb.ExecutionPlan, *preload.CleanupToken, error) { boundary := cache.BoundaryIndex(nextIndex, snap.GenerationThreshold) if p.logger.Enabled(logging.TraceLevel) { @@ -362,7 +312,7 @@ func (p *Builder) buildPreloadsAt(nextIndex uint64, snap cache.ConfigSnapshot, n // any synchronization on the slice header. activeAttrCodes := make([]byte, 0, len(needs.Attributes)) for attrCode, set := range needs.Attributes { - if len(set.Keys) == 0 { + if len(set) == 0 { continue } @@ -384,7 +334,7 @@ func (p *Builder) buildPreloadsAt(nextIndex uint64, snap cache.ConfigSnapshot, n // on the apply side, violating invariant #6. assert.Unreachable("plan builder: no resolver registered for attrCode — extend buildAttrResolvers", map[string]any{ "attrCode": attrCode, - "keys": len(needs.Attributes[attrCode].Keys), + "keys": len(needs.Attributes[attrCode]), }) return nil, nil, fmt.Errorf("plan builder: no resolver for attrCode 0x%x", attrCode) @@ -415,7 +365,7 @@ func (p *Builder) buildPreloadsAt(nextIndex uint64, snap cache.ConfigSnapshot, n launch(func(i int) { r, err := resolver.Resolve( - set.Keys, + set, nextIndex, boundary, snap.Epoch, p.store, p.logger, ) @@ -466,7 +416,7 @@ func (p *Builder) buildPreloadsAt(nextIndex uint64, snap cache.ConfigSnapshot, n for i := range results { // Always promote any tracker entries into the cleanup token, - // even when the slot returned an error. resolveAttributePreload + // even when the slot returned an error. resolveCoverage // can populate the tracker for keys that loaded successfully // before a concurrent sibling set firstErr; if we returned // before draining them, the caller's ReleaseLoaders would skip diff --git a/internal/infra/plan/builder_test.go b/internal/infra/plan/builder_test.go index 5759ee3ca..8c3bcf18e 100644 --- a/internal/infra/plan/builder_test.go +++ b/internal/infra/plan/builder_test.go @@ -169,109 +169,6 @@ func TestPreloader_Loaders(t *testing.T) { assert.Same(t, p.loaders, loaders) } -// TestResolveLedgerID verifies the Builder.ResolveLedgerID resolution path: -// bloom miss, Pebble fallback, and cache hit. -func TestResolveLedgerID(t *testing.T) { - t.Parallel() - - ctx := logging.TestingContext() - logger := logging.FromContext(ctx) - meter := noop.NewMeterProvider().Meter("test") - - store, err := dal.NewStore(t.TempDir(), logger, meter, dal.DefaultConfig()) - require.NoError(t, err) - t.Cleanup(func() { _ = store.Close() }) - - attrs := attributes.New() - c, err := cache.New(1000, meter) - require.NoError(t, err) - - // Write a LedgerInfo{Name: "test", Id: 42} to Pebble via the Ledger attribute. - ledgerInfo := &commonpb.LedgerInfo{Name: "test", Id: 42} - canonical := domain.LedgerKey{Name: "test"}.Bytes() - - batch := store.OpenWriteSession() - _, err = attrs.Ledger.Set(batch, canonical, ledgerInfo) - require.NoError(t, err) - require.NoError(t, batch.Commit()) - - tracker := node.NewIndexTracker(1) - - p := NewBuilder(tracker, c, attrs, store, nil, logger, 0) - - // 1. Bloom miss: resolve a name that does not exist. - id, ok := p.ResolveLedgerID("nonexistent") - require.False(t, ok) - require.Equal(t, uint32(0), id) - - // 2. Pebble fallback: resolve "test" (cache is empty, falls through to Pebble). - id, ok = p.ResolveLedgerID("test") - require.True(t, ok) - require.Equal(t, uint32(42), id) - - // 3. Populate the cache with the LedgerInfo entry and verify cache hit. - attrID, _ := attributes.MakeKey(canonical) - c.Ledgers.Put(attrID, attributes.Entry[*commonpb.LedgerInfo]{Data: ledgerInfo}) - - id, ok = p.ResolveLedgerID("test") - require.True(t, ok) - require.Equal(t, uint32(42), id) -} - -// TestResolveLedgerID_BloomNotReadyFallsThrough pins the fix for #318. -// Bloom filters start empty at process start and are populated -// asynchronously (restoreBloomFilters / StartAsyncBloomPopulate). During -// that window IsReady() is false and the raw filter's MayContain returns -// false for every input. ResolveLedgerID used to consult the raw filter -// instead of the IsReady-guarded helper, so it returned (0, false) for -// every pre-existing ledger and admission then rejected the matching -// proposals (ErrBalanceNotPreloaded / ErrLedgerNotFound) until the -// bloom rebuild finished. -func TestResolveLedgerID_BloomNotReadyFallsThrough(t *testing.T) { - t.Parallel() - - ctx := logging.TestingContext() - logger := logging.FromContext(ctx) - meter := noop.NewMeterProvider().Meter("test") - - store, err := dal.NewStore(t.TempDir(), logger, meter, dal.DefaultConfig()) - require.NoError(t, err) - t.Cleanup(func() { _ = store.Close() }) - - attrs := attributes.New() - c, err := cache.New(1000, meter) - require.NoError(t, err) - - // Persist a ledger to Pebble only; nothing in the cache, nothing in - // the bloom yet — the exact state during a fresh boot. - ledgerInfo := &commonpb.LedgerInfo{Name: "test", Id: 42} - canonical := domain.LedgerKey{Name: "test"}.Bytes() - - batch := store.OpenWriteSession() - _, err = attrs.Ledger.Set(batch, canonical, ledgerInfo) - require.NoError(t, err) - require.NoError(t, batch.Commit()) - - // Build a bloom FilterSet with the ledgers type enabled. Do NOT mark - // it ready: that's the exact window the bug used to break. - bfs := bloom.NewFilterSet(&commonpb.ClusterConfig{ - BloomLedgers: &commonpb.BloomTypeConfig{ExpectedKeys: 1024, FpRate: 0.01}, - }, meter) - require.NotNil(t, bfs) - require.False(t, bfs.IsReady(), "precondition: bloom must be in the populating window") - - tracker := node.NewIndexTracker(1) - p := NewBuilder(tracker, c, attrs, store, bfs, logger, 0) - - // Pre-fix the call short-circuited on the empty bloom and returned - // (0, false). Post-fix the IsReady guard skips the bloom, the lookup - // falls through to Pebble, and finds the ledger. - id, ok := p.ResolveLedgerID("test") - require.True(t, ok, - "ResolveLedgerID must fall through to Pebble while the bloom is still populating (#318)") - require.Equal(t, uint32(42), id) -} - // TestBuildPreloads_DeclaresAbsentNonZeroKey pins the coverage-gap fix // reported on #451: a proposer that requests a key for a kind without // zero-value semantics (e.g. transaction references, prepared queries) and @@ -279,7 +176,7 @@ func TestResolveLedgerID_BloomNotReadyFallsThrough(t *testing.T) { // the ExecutionPlan. With the strict Plan the FSM-side read would // crash the node on the missing declaration, breaking common create paths // (new transaction reference, new prepared query). Post-fix the resolve -// loop emits a Declare-intent AttributePlan so the View admits the read +// loop emits a Declare-intent AttributeCoverage so the View admits the read // and the underlying KeyStore returns ErrNotFound for legitimate absence. func TestBuildPreloads_DeclaresAbsentNonZeroKey(t *testing.T) { t.Parallel() @@ -300,10 +197,10 @@ func TestBuildPreloads_DeclaresAbsentNonZeroKey(t *testing.T) { p := NewBuilder(tracker, c, attrs, store, nil, logger, 0) refKey := domain.TransactionReferenceKey{LedgerName: "test", Reference: "fresh-ref"} - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrReference, refKey.Bytes()) - build, err := p.Build([]WriteOperation{{Needs: needs}}) + build, err := p.Build([]WriteOperation{{Coverage: needs}}) require.NoError(t, err) defer build.ReleaseLoaders() @@ -311,15 +208,14 @@ func TestBuildPreloads_DeclaresAbsentNonZeroKey(t *testing.T) { require.NotNil(t, ps) // Pebble has nothing for this key and no zero-value preload exists for - // references — the resolver must emit a Declare-intent AttributePlan to + // references — the resolver must emit a Declare-intent AttributeCoverage to // keep the key covered. Without this the FSM Plan would crash // the node on read. require.Len(t, ps.GetAttributes(), 1, "absent non-zero-valued key must still be declared so the FSM-side View admits the read") plan := ps.GetAttributes()[0] - _, isDeclare := plan.GetIntent().(*raftcmdpb.AttributePlan_Declare) - require.True(t, isDeclare, "intent must be Declare for an absent reference") + require.Nil(t, plan.GetValue(), "absent reference must produce a coverage-only entry (no seed value)") require.Equal(t, uint32(dal.SubAttrReference), plan.GetAttrCode()) expectedID, _ := attributes.MakeKey(refKey.Bytes()) @@ -356,10 +252,10 @@ func TestBuildPreloads_RejectsCacheHorizonExceeded(t *testing.T) { p := NewBuilder(tracker, c, attrs, store, nil, logger, 0) refKey := domain.TransactionReferenceKey{LedgerName: "test", Reference: "ref"} - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrReference, refKey.Bytes()) - build, buildErr := p.Build([]WriteOperation{{Needs: needs}}) + build, buildErr := p.Build([]WriteOperation{{Coverage: needs}}) defer build.ReleaseLoaders() require.Error(t, buildErr, "admission must reject when 2+ rotations are predicted") @@ -367,11 +263,12 @@ func TestBuildPreloads_RejectsCacheHorizonExceeded(t *testing.T) { "reject must surface ErrCacheHorizonExceeded so the gRPC adapter maps to codes.Unavailable") } -// TestBuildPreloads_EmitsDeclareOnCacheGuaranteed pins the CacheGuaranteed -// branch: when admission's CheckCache verdict says the key is in Gen0 and -// still will be at apply, the resolver emits Declare (coverage-only, no -// Pebble read). -func TestBuildPreloads_EmitsDeclareOnCacheGuaranteed(t *testing.T) { +// TestBuildPreloads_EmitsDeclareOnCacheHit pins the unified coverage +// model: when admission's CheckCache verdict is CacheHit (key in Gen0 +// at snapshot, or Gen1-only within reach), the resolver emits a +// coverage-only entry — no Pebble read required. AttributeCache.Get's +// gen0→gen1 fallback surfaces the value on read at apply time. +func TestBuildPreloads_EmitsDeclareOnCacheHit(t *testing.T) { t.Parallel() ctx := logging.TestingContext() @@ -398,25 +295,28 @@ func TestBuildPreloads_EmitsDeclareOnCacheGuaranteed(t *testing.T) { tracker := node.NewIndexTracker(1) p := NewBuilder(tracker, c, attrs, store, nil, logger, 0) - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrMetadata, metaKey.Bytes()) - build, err := p.Build([]WriteOperation{{Needs: needs}}) + build, err := p.Build([]WriteOperation{{Coverage: needs}}) require.NoError(t, err) defer build.ReleaseLoaders() require.Len(t, build.ExecutionPlan.GetAttributes(), 1) plan := build.ExecutionPlan.GetAttributes()[0] - _, isDeclare := plan.GetIntent().(*raftcmdpb.AttributePlan_Declare) - require.True(t, isDeclare, "CacheGuaranteed must emit Declare — cache already has the value, no seed needed") + isCoverageOnly := plan.GetValue() == nil + require.True(t, isCoverageOnly, "CacheHit must emit a coverage-only entry (value nil) — cache already has the value, no seed needed") require.Equal(t, uint32(dal.SubAttrMetadata), plan.GetAttrCode()) require.Equal(t, id[:], plan.GetId().GetId()) } -// TestBuildPreloads_EmitsDeclareOnMissingKey confirms the CacheMiss + -// Pebble-absent branch: nothing to seed, so the resolver emits Declare -// (coverage-only). +// TestBuildPreloads_EmitsDeclareOnMissingKey confirms the CacheMiss +// path under the unified coverage model: when admission's CheckCache +// verdict is CacheMiss AND Pebble has nothing, the resolver emits a +// coverage-only entry (no value seed). Preload skips coverage-only +// entries; if a concurrent write populates the cache before apply, +// Get's gen0→gen1 fallback surfaces it. func TestBuildPreloads_EmitsDeclareOnMissingKey(t *testing.T) { t.Parallel() @@ -440,18 +340,18 @@ func TestBuildPreloads_EmitsDeclareOnMissingKey(t *testing.T) { tracker := node.NewIndexTracker(1) p := NewBuilder(tracker, c, attrs, store, nil, logger, 0) - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrMetadata, metaKey.Bytes()) - build, err := p.Build([]WriteOperation{{Needs: needs}}) + build, err := p.Build([]WriteOperation{{Coverage: needs}}) require.NoError(t, err) defer build.ReleaseLoaders() require.Len(t, build.ExecutionPlan.GetAttributes(), 1) plan := build.ExecutionPlan.GetAttributes()[0] - _, isDeclare := plan.GetIntent().(*raftcmdpb.AttributePlan_Declare) - require.True(t, isDeclare, "CacheMiss + Pebble-absent must emit Declare (coverage-only)") + isCoverageOnly := plan.GetValue() == nil + require.True(t, isCoverageOnly, "CacheMiss + Pebble-absent must emit a coverage-only entry — nothing to seed") } // TestBuildPreloads_EmitsDeclareOnBloomShortcut confirms the @@ -487,16 +387,16 @@ func TestBuildPreloads_EmitsDeclareOnBloomShortcut(t *testing.T) { tracker := node.NewIndexTracker(1) p := NewBuilder(tracker, c, attrs, store, bfs, logger, 0) - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrMetadata, metaKey.Bytes()) - build, err := p.Build([]WriteOperation{{Needs: needs}}) + build, err := p.Build([]WriteOperation{{Coverage: needs}}) require.NoError(t, err) defer build.ReleaseLoaders() require.Len(t, build.ExecutionPlan.GetAttributes(), 1) plan := build.ExecutionPlan.GetAttributes()[0] - _, isDeclare := plan.GetIntent().(*raftcmdpb.AttributePlan_Declare) - require.True(t, isDeclare, "bloom-shortcut must emit Declare — the fast path bypasses Pebble entirely") + isCoverageOnly := plan.GetValue() == nil + require.True(t, isCoverageOnly, "bloom-shortcut must emit a coverage-only entry (value nil) — the fast path bypasses Pebble entirely") } diff --git a/internal/infra/plan/coverage.go b/internal/infra/plan/coverage.go new file mode 100644 index 000000000..c8746d460 --- /dev/null +++ b/internal/infra/plan/coverage.go @@ -0,0 +1,116 @@ +package plan + +import ( + "github.com/formancehq/ledger/v3/internal/domain" +) + +// Coverage describes the preload / coverage requirements for a command. +// +// Attributes[attrCode] is the set of canonical key bytes admission wants +// the FSM apply path to be authorized to access (read or delete) under +// dal.SubAttrXxx = attrCode. The resolver may attach a seed value to an +// entry when admission's Pebble scan resolved one (CacheMiss + Pebble-hit); +// otherwise the entry ships as coverage-only. +// +// IdempotencyKeys stay separate: they do not live in the attribute cache +// (they have a dedicated IdempotencyStore), so the resolver treats them +// via its own load path and they carry no bloom-filter / rotation +// semantics. +// +// The map-of-maps shape collapses what used to be 13 typed key fields +// into a single generic dispatch keyed by attribute code — the same code +// the FSM uses to route through AttributeCoverage.attr_code. +type Coverage struct { + Attributes map[byte]map[string]struct{} + IdempotencyKeys map[domain.IdempotencyKey]struct{} +} + +// Add records `canonical` under attrCode's key set. Idempotent. +// Callers pass the result of the domain key's Bytes() method. +func (c *Coverage) Add(attrCode byte, canonical []byte) { + c.set(attrCode)[string(canonical)] = struct{}{} +} + +// Has reports whether `canonical` is in attrCode's key set. +// Primarily a test helper (production reads iterate the map directly). +func (c *Coverage) Has(attrCode byte, canonical []byte) bool { + set, ok := c.Attributes[attrCode] + if !ok { + return false + } + + _, ok = set[string(canonical)] + + return ok +} + +// Count returns the number of keys declared for attrCode (0 if the +// attrCode has no entry). Test helper. +func (c *Coverage) Count(attrCode byte) int { + return len(c.Attributes[attrCode]) +} + +func (c *Coverage) set(attrCode byte) map[string]struct{} { + if c.Attributes == nil { + c.Attributes = make(map[byte]map[string]struct{}) + } + + set, ok := c.Attributes[attrCode] + if !ok { + set = make(map[string]struct{}) + c.Attributes[attrCode] = set + } + + return set +} + +// TotalKeys returns the total number of keys across all attribute +// caches AND idempotency keys — every key the preload pipeline handles. +func (c *Coverage) TotalKeys() int { + return c.AttributeKeysCount() + len(c.IdempotencyKeys) +} + +// AttributeKeysCount returns the total number of cache-attribute keys. +// Idempotency keys are excluded: a proposal with idempotency keys only +// does not need the cache-epoch revalidation that the slow path +// performs. The runner uses this count to gate runWithoutPreload, so +// idempotency-only proposals (maintenance, signing, chapter schedule) +// take the fast path and avoid spurious ErrStaleProposal on +// cluster-config resets. +func (c *Coverage) AttributeKeysCount() int { + total := 0 + for _, set := range c.Attributes { + total += len(set) + } + + return total +} + +// Merge unions every key set from src into dst. Used by admission to +// roll per-order Coverage into a single proposal-wide Coverage while +// keeping the per-order slice available for coverage_bits computation. +func (c *Coverage) Merge(src *Coverage) { + for attrCode, set := range src.Attributes { + dst := c.set(attrCode) + for k := range set { + dst[k] = struct{}{} + } + } + + if len(src.IdempotencyKeys) > 0 && c.IdempotencyKeys == nil { + c.IdempotencyKeys = make(map[domain.IdempotencyKey]struct{}, len(src.IdempotencyKeys)) + } + + for k := range src.IdempotencyKeys { + c.IdempotencyKeys[k] = struct{}{} + } +} + +// NewCoverage creates a Coverage with initialized maps. Per-attribute +// sets are created lazily on first Add so an empty Coverage stays cheap. +func NewCoverage() *Coverage { + return &Coverage{ + Attributes: make(map[byte]map[string]struct{}), + IdempotencyKeys: make(map[domain.IdempotencyKey]struct{}), + } +} diff --git a/internal/infra/plan/coverage_bits.go b/internal/infra/plan/coverage_bits.go index 8ca745b0c..de2126e85 100644 --- a/internal/infra/plan/coverage_bits.go +++ b/internal/infra/plan/coverage_bits.go @@ -5,7 +5,7 @@ import ( "github.com/formancehq/ledger/v3/internal/proto/raftcmdpb" ) -// planLookupKey indexes the proposal's AttributePlan slice. Multiple plans +// planLookupKey indexes the proposal's AttributeCoverage slice. Multiple plans // can share the same U128 (e.g. a LedgerKey's "gaming" canonical underpins // both the Ledgers attribute and the Boundaries attribute — same canonical // bytes, same U128) so the lookup must include the attribute code to @@ -16,7 +16,7 @@ type planLookupKey struct { } // bitsForNeeds returns the packed coverage_bits bitset over plans for -// one set of Needs. Used by Run internally to flow per-WriteOperation +// one set of Coverage. Used by Run internally to flow per-WriteOperation // needs onto each operation's coverage field at marshal time. // // Returns nil when plans is empty (no coverage to flag) or needs is nil. @@ -25,7 +25,7 @@ type planLookupKey struct { // plans slice (the admission batch case), prefer bitsForNeedsWithIndex // after a single buildPlanIndex call — applyBits does exactly that to // amortize the index build across the whole batch. -func bitsForNeeds(needs *Needs, plans []*raftcmdpb.AttributePlan) []byte { +func bitsForNeeds(needs *Coverage, plans []*raftcmdpb.AttributeCoverage) []byte { if needs == nil || len(plans) == 0 { return nil } @@ -35,13 +35,13 @@ func bitsForNeeds(needs *Needs, plans []*raftcmdpb.AttributePlan) []byte { // bitsForNeedsWithIndex is the inner loop of bitsForNeeds: same output, // but the caller is responsible for building the index once and passing -// it in. planCount is the number of AttributePlan entries the index was +// it in. planCount is the number of AttributeCoverage entries the index was // built over — used to size the returned bitset, since the index may // have fewer entries than the slice (idempotency-key plans are skipped). // // Returns nil when needs is nil; callers that have already filtered // empty-plans cases keep that responsibility (applyBits does). -func bitsForNeedsWithIndex(needs *Needs, planCount int, index map[planLookupKey]uint32) []byte { +func bitsForNeedsWithIndex(needs *Coverage, planCount int, index map[planLookupKey]uint32) []byte { if needs == nil { return nil } @@ -52,10 +52,10 @@ func bitsForNeedsWithIndex(needs *Needs, planCount int, index map[planLookupKey] return bits } -// buildPlanIndex maps each AttributePlan (keyed by canonical U128 + its +// buildPlanIndex maps each AttributeCoverage (keyed by canonical U128 + its // attr_code) to its position in the proposal's plans slice. Idempotency- // key plans (AttributeID == nil) are skipped: they're not coverage-checked. -func buildPlanIndex(plans []*raftcmdpb.AttributePlan) map[planLookupKey]uint32 { +func buildPlanIndex(plans []*raftcmdpb.AttributeCoverage) map[planLookupKey]uint32 { index := make(map[planLookupKey]uint32, len(plans)) for i, plan := range plans { @@ -71,17 +71,17 @@ func buildPlanIndex(plans []*raftcmdpb.AttributePlan) map[planLookupKey]uint32 { } // planAttrCode returns the attribute code (dal.SubAttrXxx) of the given -// plan. attr_code lives on the AttributePlan itself, so the kind is +// plan. attr_code lives on the AttributeCoverage itself, so the kind is // read uniformly regardless of intent — no oneof dispatch needed. -func planAttrCode(plan *raftcmdpb.AttributePlan) byte { +func planAttrCode(plan *raftcmdpb.AttributeCoverage) byte { return byte(plan.GetAttrCode()) } // setIDInBitset walks every key in needs, computes its (U128, attrCode) // lookup key, and sets the matching bit in bits if the pair maps to an -// AttributePlan index. Keys outside the map (idempotency tracker, +// AttributeCoverage index. Keys outside the map (idempotency tracker, // references whose preload was skipped) are silently dropped. -func setIDInBitset(bits []byte, indexByPlan map[planLookupKey]uint32, needs *Needs) { +func setIDInBitset(bits []byte, indexByPlan map[planLookupKey]uint32, needs *Coverage) { mark := func(canonical []byte, attrCode byte) { u128, _ := attributes.MakeKey(canonical) idx, ok := indexByPlan[planLookupKey{id: u128, attrCode: attrCode}] @@ -93,7 +93,7 @@ func setIDInBitset(bits []byte, indexByPlan map[planLookupKey]uint32, needs *Nee } for attrCode, set := range needs.Attributes { - for canonical := range set.Keys { + for canonical := range set { mark([]byte(canonical), attrCode) } } diff --git a/internal/infra/plan/coverage_bits_test.go b/internal/infra/plan/coverage_bits_test.go index 2d20a1f0c..e6c6ed9fd 100644 --- a/internal/infra/plan/coverage_bits_test.go +++ b/internal/infra/plan/coverage_bits_test.go @@ -12,7 +12,7 @@ import ( ) // TestBitsForNeeds_SameCanonicalDifferentAttrCode pins the regression -// behind the gaming/lending/marketplace scenario panics: a Needs that +// behind the gaming/lending/marketplace scenario panics: a Coverage that // declares both Ledgers["gaming"] and Boundaries["gaming"] shares a // canonical (the raw name) and therefore the same U128. Indexing plans // by U128 alone collapsed the two plans onto a single bitset position; @@ -27,18 +27,16 @@ func TestBitsForNeeds_SameCanonicalDifferentAttrCode(t *testing.T) { canonical := domain.LedgerKey{Name: ledgerName}.Bytes() u128, _ := attributes.MakeKey(canonical) - plans := []*raftcmdpb.AttributePlan{ + plans := []*raftcmdpb.AttributeCoverage{ { Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }, { Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: uint32(dal.SubAttrBoundary), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }, } - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrLedger, domain.LedgerKey{Name: ledgerName}.Bytes()) needs.Add(dal.SubAttrBoundary, domain.LedgerKey{Name: ledgerName}.Bytes()) @@ -47,7 +45,7 @@ func TestBitsForNeeds_SameCanonicalDifferentAttrCode(t *testing.T) { } // TestBitsForNeeds_CoversEveryNeedsKind pins setIDInBitset's -// exhaustive dispatch over every Needs map. A new field on Needs that +// exhaustive dispatch over every Coverage map. A new field on Coverage that // forgets a mark() arm here would silently never flag its bit in the // coverage map, so an FSM read against that kind would surface as // ErrCoverageMiss even when admission declared it. @@ -57,76 +55,78 @@ func TestBitsForNeeds_CoversEveryNeedsKind(t *testing.T) { type kindCase struct { attrCode byte canonical []byte - add func(*Needs) + add func(*Coverage) } cases := []kindCase{ { attrCode: dal.SubAttrLedger, canonical: domain.LedgerKey{Name: "L"}.Bytes(), - add: func(n *Needs) { n.Add(dal.SubAttrLedger, domain.LedgerKey{Name: "L"}.Bytes()) }, + add: func(n *Coverage) { n.Add(dal.SubAttrLedger, domain.LedgerKey{Name: "L"}.Bytes()) }, }, { attrCode: dal.SubAttrBoundary, canonical: domain.LedgerKey{Name: "L"}.Bytes(), - add: func(n *Needs) { n.Add(dal.SubAttrBoundary, domain.LedgerKey{Name: "L"}.Bytes()) }, + add: func(n *Coverage) { n.Add(dal.SubAttrBoundary, domain.LedgerKey{Name: "L"}.Bytes()) }, }, { attrCode: dal.SubAttrVolume, canonical: domain.VolumeKey{AccountKey: domain.AccountKey{LedgerName: "L", Account: "a"}, Asset: "USD"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrVolume, domain.VolumeKey{AccountKey: domain.AccountKey{LedgerName: "L", Account: "a"}, Asset: "USD"}.Bytes()) }, }, { attrCode: dal.SubAttrReference, canonical: domain.TransactionReferenceKey{LedgerName: "L", Reference: "ref"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrReference, domain.TransactionReferenceKey{LedgerName: "L", Reference: "ref"}.Bytes()) }, }, { attrCode: dal.SubAttrMetadata, canonical: domain.MetadataKey{AccountKey: domain.AccountKey{LedgerName: "L", Account: "a"}, Key: "k"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrMetadata, domain.MetadataKey{AccountKey: domain.AccountKey{LedgerName: "L", Account: "a"}, Key: "k"}.Bytes()) }, }, { attrCode: dal.SubAttrTransaction, canonical: domain.TransactionKey{LedgerName: "L", ID: 1}.Bytes(), - add: func(n *Needs) { n.Add(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: "L", ID: 1}.Bytes()) }, + add: func(n *Coverage) { + n.Add(dal.SubAttrTransaction, domain.TransactionKey{LedgerName: "L", ID: 1}.Bytes()) + }, }, { attrCode: dal.SubAttrSinkConfig, canonical: domain.SinkConfigKey{Name: "s"}.Bytes(), - add: func(n *Needs) { n.Add(dal.SubAttrSinkConfig, domain.SinkConfigKey{Name: "s"}.Bytes()) }, + add: func(n *Coverage) { n.Add(dal.SubAttrSinkConfig, domain.SinkConfigKey{Name: "s"}.Bytes()) }, }, { attrCode: dal.SubAttrNumscriptVersion, canonical: domain.NumscriptVersionKey{LedgerName: "L", Name: "n"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrNumscriptVersion, domain.NumscriptVersionKey{LedgerName: "L", Name: "n"}.Bytes()) }, }, { attrCode: dal.SubAttrNumscriptContent, canonical: domain.NumscriptEntryKey{LedgerName: "L", Name: "n", Version: "v"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrNumscriptContent, domain.NumscriptEntryKey{LedgerName: "L", Name: "n", Version: "v"}.Bytes()) }, }, { attrCode: dal.SubAttrPreparedQuery, canonical: domain.PreparedQueryKey{LedgerName: "L", Name: "q"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrPreparedQuery, domain.PreparedQueryKey{LedgerName: "L", Name: "q"}.Bytes()) }, }, { attrCode: dal.SubAttrLedgerMetadata, canonical: domain.LedgerMetadataKey{LedgerName: "L", Key: "k"}.Bytes(), - add: func(n *Needs) { + add: func(n *Coverage) { n.Add(dal.SubAttrLedgerMetadata, domain.LedgerMetadataKey{LedgerName: "L", Key: "k"}.Bytes()) }, }, @@ -134,17 +134,16 @@ func TestBitsForNeeds_CoversEveryNeedsKind(t *testing.T) { for _, c := range cases { u128, _ := attributes.MakeKey(c.canonical) - plan := &raftcmdpb.AttributePlan{ + plan := &raftcmdpb.AttributeCoverage{ Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: uint32(c.attrCode), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, } - needs := NewNeeds() + needs := NewCoverage() c.add(needs) - require.Equal(t, []byte{0b1}, bitsForNeeds(needs, []*raftcmdpb.AttributePlan{plan}), - "kind %d (%v) must flag bit 0 when its Needs entry is set", c.attrCode, c.canonical) + require.Equal(t, []byte{0b1}, bitsForNeeds(needs, []*raftcmdpb.AttributeCoverage{plan}), + "kind %d (%v) must flag bit 0 when its Coverage entry is set", c.attrCode, c.canonical) } } @@ -152,10 +151,10 @@ func TestBitsForNeeds_CoversEveryNeedsKind(t *testing.T) { func TestBitsForNeeds_EmptyInputs(t *testing.T) { t.Parallel() - require.Nil(t, bitsForNeeds(nil, []*raftcmdpb.AttributePlan{{}}), + require.Nil(t, bitsForNeeds(nil, []*raftcmdpb.AttributeCoverage{{}}), "nil needs must yield nil bitset") - require.Nil(t, bitsForNeeds(NewNeeds(), nil), + require.Nil(t, bitsForNeeds(NewCoverage(), nil), "nil plans must yield nil bitset") } @@ -168,30 +167,28 @@ func TestBitsForNeeds_TracksPlanPosition(t *testing.T) { const ledgerName = "alpha" u128, _ := attributes.MakeKey(domain.LedgerKey{Name: ledgerName}.Bytes()) - ledgerPlan := &raftcmdpb.AttributePlan{ + ledgerPlan := &raftcmdpb.AttributeCoverage{ Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, } - padding := &raftcmdpb.AttributePlan{ + padding := &raftcmdpb.AttributeCoverage{ Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: uint32(dal.SubAttrBoundary), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, } - needs := NewNeeds() + needs := NewCoverage() needs.Add(dal.SubAttrLedger, domain.LedgerKey{Name: ledgerName}.Bytes()) // First plan order: ledger at index 0 → bit 0. - require.Equal(t, []byte{0b01}, bitsForNeeds(needs, []*raftcmdpb.AttributePlan{ledgerPlan, padding})) + require.Equal(t, []byte{0b01}, bitsForNeeds(needs, []*raftcmdpb.AttributeCoverage{ledgerPlan, padding})) // Simulate a rebuild that put the ledger plan at index 1 → bit 1. - require.Equal(t, []byte{0b10}, bitsForNeeds(needs, []*raftcmdpb.AttributePlan{padding, ledgerPlan}), + require.Equal(t, []byte{0b10}, bitsForNeeds(needs, []*raftcmdpb.AttributeCoverage{padding, ledgerPlan}), "rebuild must produce bits tracking the new plan position") } // TestApplyBits_SharesPlanIndexAcrossOperations pins the // buildPlanIndex hoist: applyBits must build the planLookupKey→position // map once per call and feed it to every WriteOperation, yet still -// emit a coverage bitset that mirrors only the per-operation Needs. +// emit a coverage bitset that mirrors only the per-operation Coverage. // // This is the hot-path optimization that drops applyBits' cost from // O(N · P) runtime.mapassign (one map rebuild per operation) to O(P) @@ -209,24 +206,22 @@ func TestApplyBits_SharesPlanIndexAcrossOperations(t *testing.T) { idA, _ := attributes.MakeKey(domain.LedgerKey{Name: ledgerA}.Bytes()) idB, _ := attributes.MakeKey(domain.LedgerKey{Name: ledgerB}.Bytes()) - plans := []*raftcmdpb.AttributePlan{ + plans := []*raftcmdpb.AttributeCoverage{ { Id: &raftcmdpb.AttributeID{Id: idA[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }, { Id: &raftcmdpb.AttributeID{Id: idB[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }, } - needsA := NewNeeds() + needsA := NewCoverage() needsA.Add(dal.SubAttrLedger, domain.LedgerKey{Name: ledgerA}.Bytes()) - needsB := NewNeeds() + needsB := NewCoverage() needsB.Add(dal.SubAttrLedger, domain.LedgerKey{Name: ledgerB}.Bytes()) - needsAB := NewNeeds() + needsAB := NewCoverage() needsAB.Add(dal.SubAttrLedger, domain.LedgerKey{Name: ledgerA}.Bytes()) needsAB.Add(dal.SubAttrLedger, domain.LedgerKey{Name: ledgerB}.Bytes()) @@ -240,12 +235,12 @@ func TestApplyBits_SharesPlanIndexAcrossOperations(t *testing.T) { build := &BuildResult{ operations: []WriteOperation{ - {Needs: needsA, SetCoverage: func(b []byte) { gotA = b }}, - {Needs: needsB, SetCoverage: func(b []byte) { gotB = b }}, - {Needs: needsAB, SetCoverage: func(b []byte) { gotAB = b }}, - {Needs: NewNeeds(), SetCoverage: func(b []byte) { gotZ = b }}, - {Needs: nil, SetCoverage: func(b []byte) { gotN = true }}, - {Needs: needsA, SetCoverage: nil}, // skip — nil callback, must not panic + {Coverage: needsA, SetCoverage: func(b []byte) { gotA = b }}, + {Coverage: needsB, SetCoverage: func(b []byte) { gotB = b }}, + {Coverage: needsAB, SetCoverage: func(b []byte) { gotAB = b }}, + {Coverage: NewCoverage(), SetCoverage: func(b []byte) { gotZ = b }}, + {Coverage: nil, SetCoverage: func(b []byte) { gotN = true }}, + {Coverage: needsA, SetCoverage: nil}, // skip — nil callback, must not panic }, } @@ -254,15 +249,15 @@ func TestApplyBits_SharesPlanIndexAcrossOperations(t *testing.T) { require.Equal(t, []byte{0b01}, gotA, "op A flags only bit 0 (ledgerA at index 0)") require.Equal(t, []byte{0b10}, gotB, "op B flags only bit 1 (ledgerB at index 1)") require.Equal(t, []byte{0b11}, gotAB, "op AB flags both bits") - require.Equal(t, []byte{0b00}, gotZ, "op with empty Needs gets a zero bitset, not nil") - require.True(t, gotN, "nil Needs op must still be invoked with nil per the original contract") + require.Equal(t, []byte{0b00}, gotZ, "op with empty Coverage gets a zero bitset, not nil") + require.True(t, gotN, "nil Coverage op must still be invoked with nil per the original contract") // gotN's nil-input path returns nil from bitsForNeedsWithIndex; the // callback simply records that it ran. } // TestApplyBits_EmptyPlansPreservesNilContract pins the no-plan branch -// of applyBits: when the proposal carries zero AttributePlan entries -// (every WriteOperation has empty Needs, common for technical-only +// of applyBits: when the proposal carries zero AttributeCoverage entries +// (every WriteOperation has empty Coverage, common for technical-only // proposals), every SetCoverage callback must still fire with nil to // keep the original bitsForNeeds(_, nil) → nil contract that handlers // rely on (a zero-length bitset is semantically different from a @@ -277,9 +272,9 @@ func TestApplyBits_EmptyPlansPreservesNilContract(t *testing.T) { build := &BuildResult{ operations: []WriteOperation{ - {Needs: NewNeeds(), SetCoverage: func(b []byte) { calls[0].called = true; calls[0].got = b }}, - {Needs: nil, SetCoverage: func(b []byte) { calls[1].called = true; calls[1].got = b }}, - {Needs: NewNeeds(), SetCoverage: nil}, // must be skipped silently + {Coverage: NewCoverage(), SetCoverage: func(b []byte) { calls[0].called = true; calls[0].got = b }}, + {Coverage: nil, SetCoverage: func(b []byte) { calls[1].called = true; calls[1].got = b }}, + {Coverage: NewCoverage(), SetCoverage: nil}, // must be skipped silently }, } diff --git a/internal/infra/plan/errors.go b/internal/infra/plan/errors.go index ff0b8c3e7..36bdcea26 100644 --- a/internal/infra/plan/errors.go +++ b/internal/infra/plan/errors.go @@ -1,6 +1,8 @@ package plan -import "errors" +import ( + "github.com/formancehq/ledger/v3/internal/infra/plan/planerr" +) // ErrCacheHorizonExceeded is returned by Builder.Build when admission predicts // 2+ cache generation rotations will fire on the FSM side between this @@ -23,4 +25,8 @@ import "errors" // // Mapped to gRPC codes.Unavailable in adapter/grpc/server.go so existing client // retry interceptors handle it transparently. -var ErrCacheHorizonExceeded = errors.New("admission cache horizon exceeded: 2+ cache rotations predicted between propose and apply") +// +// The sentinel itself lives in the leaf planerr package so the state +// package can reference it without an import cycle (state <- plan). Callers +// keep using plan.ErrCacheHorizonExceeded through this re-export. +var ErrCacheHorizonExceeded = planerr.ErrCacheHorizonExceeded diff --git a/internal/infra/plan/needs.go b/internal/infra/plan/needs.go deleted file mode 100644 index 8a0683d74..000000000 --- a/internal/infra/plan/needs.go +++ /dev/null @@ -1,130 +0,0 @@ -package plan - -import ( - "github.com/formancehq/ledger/v3/internal/domain" -) - -// AttributeSet is the per-attribute-code preload requirement: the set of -// canonical key bytes admission wants covered. The resolver emits Value -// (Pebble seed) or Declare (coverage-only) plans for these based on the -// CheckCache verdict. -// -// Keyed by string(canonical) — Go requires comparable map keys and -// []byte is not comparable. The string is a memory-cheap view (no copy -// semantics apply here since keys arrive already-allocated from each -// domain key's Bytes() method). -type AttributeSet struct { - Keys map[string]struct{} -} - -// Needs describes the preload requirements for a command. -// -// Attributes[attrCode] holds the per-cache preload requirement, where -// attrCode is a dal.SubAttr* byte constant. A missing entry means "no -// preload for that cache in this proposal". This shape collapses what -// used to be 13 typed key maps into a single generic dispatch keyed by -// attribute code — the same code the FSM uses to route through -// AttributePlan.attr_code. -// -// IdempotencyKeys stay separate: they do not live in the attribute -// cache (they have a dedicated IdempotencyStore), so the resolver -// pipeline treats them via its own load path and they carry no -// bloom-filter / rotation semantics. -type Needs struct { - Attributes map[byte]*AttributeSet - IdempotencyKeys map[domain.IdempotencyKey]struct{} -} - -// Add records `canonical` under attrCode's Keys set. Idempotent. -// Callers pass the result of the domain key's Bytes() method. -func (n *Needs) Add(attrCode byte, canonical []byte) { - n.set(attrCode).Keys[string(canonical)] = struct{}{} -} - -// Has reports whether `canonical` is in attrCode's Keys set. -// Primarily a test helper (production reads iterate via AttributeSet -// directly). -func (n *Needs) Has(attrCode byte, canonical []byte) bool { - set, ok := n.Attributes[attrCode] - if !ok { - return false - } - - _, ok = set.Keys[string(canonical)] - - return ok -} - -// Count returns the number of Keys declared for attrCode (0 if the -// attrCode has no entry). Test helper. -func (n *Needs) Count(attrCode byte) int { - set, ok := n.Attributes[attrCode] - if !ok { - return 0 - } - - return len(set.Keys) -} - -func (n *Needs) set(attrCode byte) *AttributeSet { - if n.Attributes == nil { - n.Attributes = make(map[byte]*AttributeSet) - } - - set, ok := n.Attributes[attrCode] - if !ok { - set = &AttributeSet{ - Keys: make(map[string]struct{}), - } - n.Attributes[attrCode] = set - } - - return set -} - -// TotalKeys returns the total number of keys across all attribute -// caches AND idempotency keys — every key the preload pipeline handles. -func (n *Needs) TotalKeys() int { - return n.AttributeKeysCount() + len(n.IdempotencyKeys) -} - -// AttributeKeysCount returns the total number of cache-attribute keys. -// Idempotency keys are excluded: a proposal with idempotency keys only -// does not need the cache-epoch revalidation that the slow path -// performs. The runner uses this count to gate runWithoutPreload, so -// idempotency-only proposals (maintenance, signing, chapter schedule) -// take the fast path and avoid spurious ErrStaleProposal on -// cluster-config resets. -func (n *Needs) AttributeKeysCount() int { - total := 0 - for _, set := range n.Attributes { - total += len(set.Keys) - } - - return total -} - -// Merge unions every key set from src into dst. Used by admission to -// roll per-order Needs into a single proposal-wide Needs while keeping -// the per-order slice available for coverage_bits computation. -func (n *Needs) Merge(src *Needs) { - for attrCode, set := range src.Attributes { - dst := n.set(attrCode) - for k := range set.Keys { - dst.Keys[k] = struct{}{} - } - } - - for k := range src.IdempotencyKeys { - n.IdempotencyKeys[k] = struct{}{} - } -} - -// NewNeeds creates a Needs with initialized maps. Per-attribute sets -// are created lazily on first Add so an empty Needs stays cheap. -func NewNeeds() *Needs { - return &Needs{ - Attributes: make(map[byte]*AttributeSet), - IdempotencyKeys: make(map[domain.IdempotencyKey]struct{}), - } -} diff --git a/internal/infra/plan/planerr/errors.go b/internal/infra/plan/planerr/errors.go new file mode 100644 index 000000000..7fa697b27 --- /dev/null +++ b/internal/infra/plan/planerr/errors.go @@ -0,0 +1,18 @@ +// Package planerr holds the infrastructure-level sentinel errors that the +// plan pipeline surfaces. It lives in a leaf package so both the plan +// package (proposer / builder side) and the state package (FSM applier +// side) can reference the sentinels without introducing an import cycle: +// state <-- plan already exists, so state cannot import plan directly. +// +// Consumers are expected to reach these through the plan package +// re-exports (plan.ErrCacheHorizonExceeded) rather than importing +// planerr directly, keeping the public API surface on plan. +package planerr + +import "errors" + +// ErrCacheHorizonExceeded fires when admission's CheckCache verdict is +// CacheUnreachable (2+ cache generation rotations predicted between +// propose-time and apply-time). See plan.ErrCacheHorizonExceeded for the +// full rationale. +var ErrCacheHorizonExceeded = errors.New("admission cache horizon exceeded: 2+ cache rotations predicted between propose and apply") diff --git a/internal/infra/plan/resolve.go b/internal/infra/plan/resolve.go index 0de1eaded..381da27f3 100644 --- a/internal/infra/plan/resolve.go +++ b/internal/infra/plan/resolve.go @@ -22,72 +22,76 @@ import ( const resolveParallelism = 16 // resolveResult holds the output of a resolve call: a flat list of -// per-attribute plans plus the tracker keys. Each AttributePlan carries -// exactly one intent — Value (loaded from Pebble), Touch (Gen1→Gen0 -// promotion at apply), or Declare (already in Gen0 or verified absent — -// coverage only). The FSM-side preload.View consumes the whole list as -// its coverage set so reads on declared keys never trip a false-positive -// "not preloaded" miss. +// AttributeCoverage entries plus the tracker keys. Each entry is either +// coverage-only (value = nil) or a seed (value = Pebble-loaded payload). +// +// The FSM's Preload path routes seed entries through MirrorPreload +// (gen0+gen1 write with gen1-wins semantics). Coverage-only entries are +// pure declarations: they contribute to coverage_bits (invariant #9) so +// the scope admits their key in the FSM apply path, but they do NOT +// mutate the cache — AttributeCache.Get's gen0→gen1 fallback and +// AttributeCache.Del's lazy gen1→gen0 tombstone fabrication cover the +// read and delete cases without a preemptive promote pass. // // Idempotency keys live on the parallel idempotencyKeys slice — they are // NOT cache attributes (the FSM applies them to a dedicated store) so -// they bypass the AttributePlan list and ship on ExecutionPlan.idempotency_keys. +// they bypass the AttributeCoverage list and ship on +// ExecutionPlan.idempotency_keys. type resolveResult struct { - attributes []*raftcmdpb.AttributePlan + attributes []*raftcmdpb.AttributeCoverage idempotencyKeys []*raftcmdpb.ReloadIdempotencyKey tracker []attributes.U128 } -// declarePlan returns an AttributePlan whose intent is Declare: the key -// is either already in Gen0 or verified absent, so no FSM-side cache -// mutation is required. -func declarePlan(id attributes.U128, attrCode byte, tag uint64) *raftcmdpb.AttributePlan { - return &raftcmdpb.AttributePlan{ - Id: &raftcmdpb.AttributeID{Id: id[:], Tag: tag}, - AttrCode: uint32(attrCode), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, - } -} - -// touchPlan returns an AttributePlan whose intent is Touch: the FSM must -// promote this key from Gen1 to Gen0 before order handlers run. -func touchPlan(id attributes.U128, attrCode byte, tag uint64) *raftcmdpb.AttributePlan { - return &raftcmdpb.AttributePlan{ +// coverageEntry returns a coverage-only AttributeCoverage — no seed. +// The entry is only a declaration for coverage_bits (invariant #9); +// the FSM's Preload skips it (no cache mutation), and the handler's +// Get / Del rely on AttributeCache's built-in gen0→gen1 fallback and +// lazy tombstone fabrication. +func coverageEntry(id attributes.U128, attrCode byte, tag uint64) *raftcmdpb.AttributeCoverage { + return &raftcmdpb.AttributeCoverage{ Id: &raftcmdpb.AttributeID{Id: id[:], Tag: tag}, AttrCode: uint32(attrCode), - Intent: &raftcmdpb.AttributePlan_Touch{Touch: &raftcmdpb.Touch{}}, } } -// preloadPlan wraps a typed AttributeValue into an AttributePlan. attr_code -// lives on the plan envelope so the FSM apply path routes the dispatch -// without unwrapping the oneof. -func preloadPlan(attrID *raftcmdpb.AttributeID, attrCode byte, value *raftcmdpb.AttributeValue) *raftcmdpb.AttributePlan { - return &raftcmdpb.AttributePlan{ +// seedEntry returns an AttributeCoverage carrying a Pebble-loaded seed — +// the FSM's Preload path calls MirrorPreload to write the value into both +// generations. attr_code lives on the envelope so the FSM apply path +// routes the typed unmarshal without inspecting the value payload itself. +func seedEntry(attrID *raftcmdpb.AttributeID, attrCode byte, value *raftcmdpb.AttributeValue) *raftcmdpb.AttributeCoverage { + return &raftcmdpb.AttributeCoverage{ Id: attrID, AttrCode: uint32(attrCode), - Intent: &raftcmdpb.AttributePlan_Value{Value: value}, + Value: value, } } -// resolveAttributePreload resolves one attribute cache for the plan -// pipeline. Keys are passed as canonical byte strings (see AttributeSet -// on Needs) — no K generic; the (attrCode, canonical) pair is the whole -// identity. +// resolveCoverage resolves one attribute cache for the plan pipeline. +// Keys are passed as canonical byte strings (see Coverage.Attributes) — +// no K generic; the (attrCode, canonical) pair is the whole identity. // -// For each key, resolveAttributePreload emits ONE AttributePlan based on -// admission's CheckCache verdict: +// For each key, resolveCoverage emits ONE AttributeCoverage entry based +// on admission's CheckCache verdict: // // - CacheUnreachable → ErrCacheHorizonExceeded (admission rejection). -// - CacheGuaranteed → Declare (cache already has the key in Gen0 at -// apply time — no cache mutation needed). -// - CacheNeedsTouch → Touch (key sits in Gen1; the FSM promotes it back -// to Gen0 before order handlers run). -// - CacheMiss + bloom/Pebble-absent → Declare (coverage-only). -// - CacheMiss + Pebble-load-hit → Value(v) (MirrorPreload seeds gen0+gen1). +// - CacheHit → coverage-only (value nil); AttributeCache.Get's gen0→gen1 +// fallback surfaces the entry on read, and AttributeCache.Del's lazy +// promote fabricates the gen0 tombstone if the handler deletes. +// - CacheMiss + bloom/Pebble-absent → coverage-only. +// - CacheMiss + Pebble-load-hit → seeded (value = the loaded payload; +// MirrorPreload writes gen0+gen1). +// +// FSM-side race protection is handled at Preload for seeds only (via +// MirrorPreload's gen1-wins), and by the coverage_bits gate (invariant +// #9) for reads. Keys a concurrent-write race populated are visible to +// the handler via the gen0→gen1 fallback; keys nothing populated see +// ErrNotFound and produce the expected NotFound business outcome. +// Bounded by CacheUnreachable (2+ rotations rejected at admission), no +// data can be lost between admission and apply. // // Keys are resolved with bounded parallelism to amortize I/O latency. -func resolveAttributePreload[T interface { +func resolveCoverage[T interface { MarshalVT() ([]byte, error) }]( keys map[string]struct{}, @@ -106,7 +110,7 @@ func resolveAttributePreload[T interface { mu sync.Mutex wg sync.WaitGroup firstErr error - plans []*raftcmdpb.AttributePlan + plans []*raftcmdpb.AttributeCoverage ) sem := make(chan struct{}, resolveParallelism) @@ -117,11 +121,15 @@ func resolveAttributePreload[T interface { switch attrCache.CheckCache(nextIndex, id) { case cache.CacheUnreachable: - // Admission predicts ≥2 rotations between propose and apply: a - // preload computed now would be rotated out before the FSM reads - // it. Record the rejection but continue processing so wg.Wait() - // below drains any CacheMiss loader goroutine earlier iterations - // already launched. + // Admission predicts ≥2 rotations between propose and apply. + // Any preload computed now would be discarded before the FSM + // reads it, so reject at admission and let the client retry + // against a fresher snapshot. Bounded to at most 1 rotation + // between admission and apply, which the gen0→gen1 read + // fallback and lazy Del promote handle correctly. + // + // Continue processing so wg.Wait() below drains any CacheMiss + // loader goroutine earlier iterations already launched. if logger.Enabled(logging.TraceLevel) { logger.WithFields(map[string]any{ "type": typeName, @@ -139,32 +147,14 @@ func resolveAttributePreload[T interface { continue - case cache.CacheGuaranteed: - // Key is already in Gen0 and will still be there at apply. - // Emit Declare — the FSM's coverage view admits the read; no - // mutation required. - mu.Lock() - plans = append(plans, declarePlan(id, attrCode, tag)) - mu.Unlock() - - continue - - case cache.CacheNeedsTouch: - // Key sits in Gen1 (predicted-apply lands in the next - // generation). Emit Touch so the FSM promotes Gen1 → Gen0 - // before the order runs — otherwise the next rotation would - // drop the entry. - if logger.Enabled(logging.TraceLevel) { - logger.WithFields(map[string]any{ - "type": typeName, - "key": hex.EncodeToString(canonicalKey), - "nextIndex": nextIndex, - "boundary": boundary, - }).Tracef("Cache touch: promoting key from gen1 to gen0") - } - + case cache.CacheHit: + // Cache has the key somewhere (gen0 or gen1). Emit a + // coverage-only entry — no cache mutation is needed at + // Preload: Get's gen0→gen1 fallback surfaces the value on + // read and Del's lazy promote fabricates a gen0 tombstone + // on delete. No Pebble read required. mu.Lock() - plans = append(plans, touchPlan(id, attrCode, tag)) + plans = append(plans, coverageEntry(id, attrCode, tag)) mu.Unlock() continue @@ -175,7 +165,7 @@ func resolveAttributePreload[T interface { // (coverage-only, no value to seed). if bloomFilter != nil && !bloomFilter.MayContain(id) { mu.Lock() - plans = append(plans, declarePlan(id, attrCode, tag)) + plans = append(plans, coverageEntry(id, attrCode, tag)) mu.Unlock() continue @@ -239,13 +229,16 @@ func resolveAttributePreload[T interface { return } - plans = append(plans, preloadPlan(attrID, attrCode, attrValue)) + plans = append(plans, seedEntry(attrID, attrCode, attrValue)) return } - // Pebble had no value either — coverage-only Declare. - plans = append(plans, declarePlan(id, attrCode, tag)) + // Pebble had no value either — coverage-only entry. If a + // concurrent write populated the cache between admission + // and apply, Get's gen0→gen1 fallback will surface it at + // apply time (bounded by CacheUnreachable at ≥2 rotations). + plans = append(plans, coverageEntry(id, attrCode, tag)) }) } } @@ -266,8 +259,8 @@ func resolveAttributePreload[T interface { } // buildPreloadPayload marshals value into an AttributeValue envelope. -// The kind (attrCode) lives on the parent AttributePlan, not here — the -// FSM dispatches the typed unmarshal via AttributePlan.attr_code. +// The kind (attrCode) lives on the parent AttributeCoverage, not here — the +// FSM dispatches the typed unmarshal via AttributeCoverage.attr_code. func buildPreloadPayload[V interface { MarshalVT() ([]byte, error) }](attrCode byte, value V) (*raftcmdpb.AttributeValue, error) { diff --git a/internal/infra/plan/runner.go b/internal/infra/plan/runner.go index 781cc2bc7..952816788 100644 --- a/internal/infra/plan/runner.go +++ b/internal/infra/plan/runner.go @@ -138,7 +138,7 @@ func (p *Builder) Run( // PredictedIndex and push into Raft's queue. // // Gating on AttributeKeysCount (not TotalKeys) so an idempotent - // no-read order — whose Needs carry IdempotencyKeys but no cache + // no-read order — whose Coverage carry IdempotencyKeys but no cache // attribute — also takes the fast path. Otherwise the slow path // would serialize a cache_epoch on cmd, and a cluster-config // cache reset between Build and apply would spuriously reject the @@ -221,7 +221,7 @@ func (p *Builder) Run( } // applyBits is the per-marshal hook that flows the proposal's final -// AttributePlan slice into every WriteOperation's coverage bitset. Run +// AttributeCoverage slice into every WriteOperation's coverage bitset. Run // calls this once on the happy path and again on the rare rebuild // under guard. // @@ -230,7 +230,7 @@ func (p *Builder) Run( // all operations in the batch, so rebuilding the map per operation // costs O(N·P) runtime.mapassign for N orders × P plans where O(P) // suffices. -func (b *BuildResult) applyBits(_ *raftcmdpb.Proposal, plans []*raftcmdpb.AttributePlan) { +func (b *BuildResult) applyBits(_ *raftcmdpb.Proposal, plans []*raftcmdpb.AttributeCoverage) { var ( index map[planLookupKey]uint32 planCount = len(plans) @@ -251,7 +251,7 @@ func (b *BuildResult) applyBits(_ *raftcmdpb.Proposal, plans []*raftcmdpb.Attrib continue } - op.SetCoverage(bitsForNeedsWithIndex(op.Needs, planCount, index)) + op.SetCoverage(bitsForNeedsWithIndex(op.Coverage, planCount, index)) } } @@ -263,7 +263,7 @@ func (b *BuildResult) applyBits(_ *raftcmdpb.Proposal, plans []*raftcmdpb.Attrib // // Returns a RunResult whose Guard wraps build.token so the caller's // usual ReleaseLoaders() still works uniformly. build.token is typically -// the empty token from a zero-Needs Build, so ReleaseLoaders is a no-op +// the empty token from a zero-Coverage Build, so ReleaseLoaders is a no-op // — but symmetry with the slow path keeps the call sites simple. func (p *Builder) runWithoutPreload( ctx context.Context, @@ -273,7 +273,7 @@ func (p *Builder) runWithoutPreload( proposer Proposer, ) (*RunResult, error) { // The empty-needs path reads nothing from the cache, so neither - // the AttributePlan slice nor the CacheEpoch must ride on the + // the AttributeCoverage slice nor the CacheEpoch must ride on the // proposal — both would only trip checkStaleProposal on a // cluster-config cache reset between Build and apply. But we // keep IdempotencyKeys: machine.Preload applies them to the diff --git a/internal/infra/plan/runner_test.go b/internal/infra/plan/runner_test.go index b7dd1183b..8be9a56d2 100644 --- a/internal/infra/plan/runner_test.go +++ b/internal/infra/plan/runner_test.go @@ -45,7 +45,7 @@ func TestRunWithoutPreload_ClearsPreSetExecutionPlan(t *testing.T) { build := &BuildResult{ ExecutionPlan: cmd.GetExecutionPlan(), - aggregate: NewNeeds(), // empty → triggers the fast path + aggregate: NewCoverage(), // empty → triggers the fast path operations: nil, token: &preload.CleanupToken{}, } @@ -91,7 +91,7 @@ func TestRun_IdempotencyOnlyNeedsTakesFastPath(t *testing.T) { // build.ExecutionPlan, not from whatever cmd happens to carry. cmd := &raftcmdpb.Proposal{Id: 42} - needs := NewNeeds() + needs := NewCoverage() needs.IdempotencyKeys[domain.IdempotencyKey{Key: "idem-only"}] = struct{}{} build := &BuildResult{ diff --git a/internal/infra/plan/write_operation.go b/internal/infra/plan/write_operation.go index 2dcca8ef9..953fe58da 100644 --- a/internal/infra/plan/write_operation.go +++ b/internal/infra/plan/write_operation.go @@ -2,7 +2,7 @@ package plan // WriteOperation describes one of the writes a proposal carries (an // Order or a TechnicalUpdate). Run consumes a slice of these to -// (1) aggregate per-operation Needs for Build, and (2) compute each +// (1) aggregate per-operation Coverage for Build, and (2) compute each // operation's coverage_bits and assign them onto the proto right // before each marshal — both on the happy path and on the rare // rebuild under the proposal guard. @@ -15,12 +15,12 @@ package plan // (cluster config, idempotency eviction, events sink — TUs whose // handlers don't read cache state). type WriteOperation struct { - // Needs declares which cache keys this operation will read at FSM - // apply time. nil or empty Needs means "no reads". - Needs *Needs + // Coverage declares which cache keys this operation will read at FSM + // apply time. nil or empty Coverage means "no reads". + Coverage *Coverage - // SetCoverage receives the bitset computed from Needs over the - // proposal's final AttributePlan slice. The callback writes it to + // SetCoverage receives the bitset computed from Coverage over the + // proposal's final AttributeCoverage slice. The callback writes it to // the right field — Order.CoverageBits, TechnicalUpdate.CoverageBits, // etc. nil callback = bitset discarded. SetCoverage func(bits []byte) diff --git a/internal/infra/state/accessor.go b/internal/infra/state/accessor.go index 1e77f6eac..cbef2ece0 100644 --- a/internal/infra/state/accessor.go +++ b/internal/infra/state/accessor.go @@ -76,15 +76,19 @@ func (a *rawAccessor[K, V, R]) Put(key K, value V) { a.store.Put(key, value) } -func (a *rawAccessor[K, V, R]) Delete(key K) { +func (a *rawAccessor[K, V, R]) Delete(key K) error { a.store.Delete(key) + + return nil } // gatedAccessor decorates an inner processing.Accessor with the per-scope -// coverage gate. Reads check CheckCoverage(sub, key.Bytes()) before -// delegating; writes (Put/Delete) inherit unchanged from the embedded -// Accessor because admission's preload covers reads, not writes, and the -// FSM-side gate never blocked mutations either. +// coverage gate. Get and Delete both check CheckCoverage(sub, key.Bytes()) +// before delegating — every FSM hot-path read AND deletion is bound to +// admission's declared preload set (invariants #6, #9). Put is not gated +// because in-batch writes to keys admission did not preload are still +// covered by the batch's own mutation overlay (Derived) and never race +// with the cache-mem/disk equality invariant that strict Del guards. type gatedAccessor[K accessorKey, V any, R any] struct { processing.Accessor[K, V, R] @@ -106,6 +110,14 @@ func (a *gatedAccessor[K, V, R]) Get(key K) (R, error) { return a.Accessor.Get(key) } +func (a *gatedAccessor[K, V, R]) Delete(key K) error { + if err := a.g.CheckCoverage(a.sub, key.Bytes()); err != nil { + return err + } + + return a.Accessor.Delete(key) +} + // recorderAccessor decorates an inner Accessor by recording every // Put-touched key under a caller-controlled slot index. The slots // themselves are an opaque [][]K vector; the caller decides what the diff --git a/internal/infra/state/cache_aware_entry.go b/internal/infra/state/cache_aware_entry.go index dfcf7745e..3ab73f816 100644 --- a/internal/infra/state/cache_aware_entry.go +++ b/internal/infra/state/cache_aware_entry.go @@ -79,7 +79,7 @@ func (c *CacheAwareEntry[K, V]) PutWithCache( // PutCacheOnly writes a value to the in-memory KeyStore and the 0xFF cache zone // WITHOUT writing to the 0xF1 attribute zone. Used for the ephemeral purge // pattern where the 0xF1 entry is deleted separately but the cache must stay -// populated for co-batched CacheGuaranteed proposals. +// populated for co-batched CacheHit proposals. func (c *CacheAwareEntry[K, V]) PutCacheOnly( batch *dal.WriteSession, genByte byte, diff --git a/internal/infra/state/cache_incremental.go b/internal/infra/state/cache_incremental.go index f0c9db40f..633e4e648 100644 --- a/internal/infra/state/cache_incremental.go +++ b/internal/infra/state/cache_incremental.go @@ -77,14 +77,17 @@ func writeCacheRaw(batch *dal.WriteSession, genByte, cacheType byte, id attribut return batch.Set(key[:], batch.CacheBuffer, pebble.NoSync) } -// writeCacheTombstone writes a tombstone row to both gen bytes in 0xFF, -// matching AttributeCache.Del's tombstone semantic. The on-disk form is -// [tag 8][0x01] (no trailing bytes). -func writeCacheTombstone(batch *dal.WriteSession, cacheType byte, id attributes.U128, tag uint64) error { - for _, genByte := range []byte{0, 1} { - if err := writeCacheRaw(batch, genByte, cacheType, id, tag, true, nil); err != nil { - return fmt.Errorf("writing cache tombstone: %w", err) - } +// writeCacheTombstone writes a tombstone row to the current gen0 byte in 0xFF, +// matching AttributeCache.Del's gen0-only tombstone semantic. The on-disk form +// is [tag 8][0x01] (no trailing bytes). +// +// The gen1 byte is intentionally NOT touched: any pre-existing live entry sits +// there as harmless stale data (gen0 wins on every read) and is purged on the +// next rotation alongside the rest of gen1. Writing only gen0 keeps the +// in-memory cache equal to disk for the same applied index (invariant #1). +func writeCacheTombstone(batch *dal.WriteSession, genByte, cacheType byte, id attributes.U128, tag uint64) error { + if err := writeCacheRaw(batch, genByte, cacheType, id, tag, true, nil); err != nil { + return fmt.Errorf("writing cache tombstone: %w", err) } return nil @@ -144,7 +147,7 @@ func flushAttributeAndCache[K attributes.Key, V proto.Message]( return fmt.Errorf("failed deleting %s attribute: %w", label, err) } - if err := writeCacheTombstone(batch, cacheType, deletion.ID, deletion.Tag); err != nil { + if err := writeCacheTombstone(batch, genByte, cacheType, deletion.ID, deletion.Tag); err != nil { return fmt.Errorf("failed writing %s cache tombstone: %w", label, err) } } diff --git a/internal/infra/state/cache_snapshotter.go b/internal/infra/state/cache_snapshotter.go index 9a1f531fb..8bedf2c28 100644 --- a/internal/infra/state/cache_snapshotter.go +++ b/internal/infra/state/cache_snapshotter.go @@ -8,7 +8,6 @@ import ( "time" "github.com/antithesishq/antithesis-sdk-go/assert" - "github.com/antithesishq/antithesis-sdk-go/lifecycle" "github.com/cockroachdb/pebble/v2" logging "github.com/formancehq/go-libs/v5/pkg/observe/log" @@ -87,10 +86,6 @@ type cacheSnapshotSlot interface { Persist(batch *dal.WriteSession, genByte byte, genIndex int) error // RestoreEntry returns a function that restores a single entry into the given generation. RestoreEntry(genIndex int) func(u128 attributes.U128, rawValue []byte) error - // MirrorTouch promotes id from gen1 to gen0 in-memory and mirrors the - // new gen0 entry to 0xFF. No-op if gen0 already has the key or the key - // is in neither generation. - MirrorTouch(batch *dal.WriteSession, gen0Byte byte, id attributes.U128) error // MirrorPreload puts value into both in-memory generations and mirrors // to 0xFF at both byte positions. rawValue is the vtproto-marshaled // blob carried by Preload.raw_value; the concrete implementation @@ -143,57 +138,6 @@ func (s *protoSnapshotSlot[V]) Persist(batch *dal.WriteSession, genByte byte, ge return persistLeanProtoEntries(batch, genByte, s.cacheType, s.selectGen(genIndex)) } -func (s *protoSnapshotSlot[V]) MirrorTouch(batch *dal.WriteSession, gen0Byte byte, id attributes.U128) error { - // Skip when gen0 already holds the key — Touch is a no-op then, and - // the 0xFF gen0Byte row may hold a fresher in-batch Merge value. - if _, ok := s.ac.Gen0().Get(id); ok { - return nil - } - - s.ac.Touch(id) - - entry, ok := s.ac.Gen0().Get(id) - if !ok { - // Touch was a no-op: key was NOT in gen1. - // The leader thought this key was in gen1 (CacheNeedsTouch) but this - // node doesn't have it. - details := map[string]any{ - "id": fmt.Sprintf("%x", id), - "cacheType": s.cacheType, - "gen0Size": s.ac.Gen0().Size(), - "gen1Size": s.ac.Gen1().Size(), - } - lifecycle.SendEvent("touch_noop", details) - assert.Unreachable("touch_noop: key missing from gen1 — cache divergence imminent", details) - - return fmt.Errorf("cache divergence: touch_noop for key %x (cacheType=%d) — key missing from gen1, gen0Size=%d gen1Size=%d", - id, s.cacheType, s.ac.Gen0().Size(), s.ac.Gen1().Size()) - } - - // Tombstones are persisted with the explicit tombstone flag byte - // (writeCacheRaw deleted=true), which RestoreEntry reads back as Deleted. - // AttributeCache.Del also resets entry.Data to zero, so even a stray - // marshalValue would emit an empty proto rather than a live payload. - if entry.Deleted { - if err := writeCacheRaw(batch, gen0Byte, s.cacheType, id, entry.Tag, true, nil); err != nil { - return fmt.Errorf("persisting touched tombstone: %w", err) - } - - return nil - } - - valueBytes, err := s.marshalValue(entry.Data) - if err != nil { - return fmt.Errorf("marshaling touched value: %w", err) - } - - if err := writeCacheRaw(batch, gen0Byte, s.cacheType, id, entry.Tag, false, valueBytes); err != nil { - return fmt.Errorf("persisting touched value: %w", err) - } - - return nil -} - func (s *protoSnapshotSlot[V]) MirrorPreload( batch *dal.WriteSession, gen0Byte, gen1Byte byte, @@ -290,9 +234,10 @@ func (s *protoSnapshotSlot[V]) RestoreEntry(genIndex int) func(u128 attributes.U tag, deleted, valueBytes := parseLeanValue(rawValue) // The flag byte at offset 8 distinguishes tombstones from live entries. - // Tombstones are kept in cache to prevent pipelined MirrorTouch failures. - // A live entry whose proto marshals to zero bytes is valid and must - // round-trip as live (EN-1377). + // Tombstones are kept in cache to shadow any live row in gen1 (see + // AttributeCache.Del's lazy fabrication). A live entry whose proto + // marshals to zero bytes is valid and must round-trip as live + // (EN-1377). if deleted { var zero V store.Put(u128, attributes.Entry[V]{Tag: tag, Data: zero, Deleted: true}) @@ -327,8 +272,8 @@ func newProtoSnapshotSlot[V interface { // Extracted from Machine to isolate pure IO serialization logic. // // The snapshotter does NOT retain a dal.RecoveryReader: Machine holds a -// snapshotter as a hot-path field (for MirrorTouch / MirrorPreload, which are -// pure write operations), so a reader stored here would re-introduce indirect +// snapshotter as a hot-path field (for MirrorPreload, which is a pure +// write operation), so a reader stored here would re-introduce indirect // Pebble-read access from the hot path. Reader-bearing methods // (RestoreFromStore, StartAsyncBloomPopulate, hasPersistedBloomBlocks) accept // the reader as a parameter and are only called from non-hot-path contexts @@ -338,9 +283,9 @@ type CacheSnapshotter struct { registry *StateRegistry bloomFilters *bloom.FilterSet slots []cacheSnapshotSlot - // touchSlots maps attribute code bytes to the corresponding slot, - // for the FSM apply path's MirrorTouch dispatch. - touchSlots map[byte]cacheSnapshotSlot + // slotByAttrCode maps attribute code bytes to the corresponding slot, + // for the FSM apply path's MirrorPreload dispatch. + slotByAttrCode map[byte]cacheSnapshotSlot // bloomExecutor ensures at most one background bloom goroutine runs at a time. // Interrupt cancels the current goroutine and waits for it to finish before @@ -374,7 +319,7 @@ func NewCacheSnapshotter(logger logging.Logger, registry *StateRegistry, bloomFi transactions, sinks, numscriptVersions, numscriptContents, preparedQueries, ledgerMetadata, indexEntries, }, - touchSlots: map[byte]cacheSnapshotSlot{ + slotByAttrCode: map[byte]cacheSnapshotSlot{ dal.SubAttrVolume: volumes, dal.SubAttrMetadata: metadata, dal.SubAttrLedger: ledgers, @@ -392,20 +337,8 @@ func NewCacheSnapshotter(logger logging.Logger, registry *StateRegistry, bloomFi } } -// MirrorTouch performs an in-memory Touch and mirrors the gen0 promotion -// to 0xFF, so a restart restores the entry into the same generation it -// occupies in memory. gen0Byte = currentGeneration % 2. -func (s *CacheSnapshotter) MirrorTouch(batch *dal.WriteSession, attrType byte, gen0Byte byte, id attributes.U128) error { - slot, ok := s.touchSlots[attrType] - if !ok { - return nil - } - - return slot.MirrorTouch(batch, gen0Byte, id) -} - // MirrorPreload populates both in-memory generations and mirrors to 0xFF -// at both byte positions. attrCode (from the parent AttributePlan) picks +// at both byte positions. attrCode (from the parent AttributeCoverage) picks // the slot; value.raw_value carries the typed value bytes (vtproto- // marshaled), and attrID carries the U128 + the xxh3 collision tag. func (s *CacheSnapshotter) MirrorPreload(batch *dal.WriteSession, gen0Byte, gen1Byte byte, attrID *raftcmdpb.AttributeID, attrCode byte, value *raftcmdpb.AttributeValue) error { @@ -413,7 +346,7 @@ func (s *CacheSnapshotter) MirrorPreload(batch *dal.WriteSession, gen0Byte, gen1 return nil } - slot, ok := s.touchSlots[attrCode] + slot, ok := s.slotByAttrCode[attrCode] if !ok { return nil } diff --git a/internal/infra/state/cache_snapshotter_test.go b/internal/infra/state/cache_snapshotter_test.go index 44b07a701..281c1dba0 100644 --- a/internal/infra/state/cache_snapshotter_test.go +++ b/internal/infra/state/cache_snapshotter_test.go @@ -540,65 +540,148 @@ func TestCacheSnapshotter_MachineIntegration(t *testing.T) { require.Equal(t, int64(100), restored.Data.GetInput().ToBigInt().Int64()) } -// Regression for the delete-resurrection-across-recovery bug found via the -// singleton_driver_model antithesis workload: a deleted ledger-metadata key -// must not reappear after a node rebuilds its cache from the 0xFF snapshot zone. +// TestCacheSnapshotter_EN1242_DeleteAfterRotationCrashRestart drives the full +// EN-1242 cycle end-to-end with a real Pebble store under the lazy-Del model: // -// AttributeCache.Del marks a tombstone but keeps the old value in entry.Data. -// MirrorTouch promotes a gen1 entry to gen0 and mirrors it to 0xFF; without the -// Deleted guard it persists marshalValue(entry.Data) — a NON-EMPTY value — so a -// CacheNeedsTouch on a gen1-only deleted key writes the deleted key's old value -// back as a live 0xFF entry. The live cache still reads "deleted" (it keeps the -// flag), so it only surfaces when a node rehydrates from 0xFF (RestoreFromStore): -// RestoreEntry sees non-empty bytes and restores a live value. -// -// This drives that exact sequence against a real Pebble store: a gen1 tombstone -// (the deleted-then-rotated state) + the 0xFF tombstone a delete writes, then -// MirrorTouch, then RestoreFromStore — and asserts the key stays deleted. -func TestCacheSnapshotter_MirrorTouchDoesNotResurrectDeletedMetadata(t *testing.T) { +// 1. Put a metadata key — mem gen0 + disk gen0 byte = live. +// 2. Rotate the cache (mem) and writeCacheRotation (disk) — the live row +// migrates: mem gen1 holds it, gen0 is empty; disk's old gen0 byte now +// plays gen1, and the new gen0 byte was purged. +// 3. FSM-apply batch: KeyStore.Delete (via s.M.Del → AttributeCache.Del) +// lazy-fabricates a gen0 tombstone from Gen1's tag — no separate +// MirrorTouch pass needed. writeCacheTombstone writes the tombstone to +// disk gen0 byte. +// 4. Reset memory and RestoreFromStore — exactly as a crashed node does. +// 5. Verify: mem gen0 = tombstone, mem gen1 = unchanged live row (carried +// over from the rotation step), and Get returns ErrNotFound. Cache and +// disk are byte-equivalent for the same applied index (invariant #1). +// 6. Another rotation purges the stale gen1 row everywhere. +func TestCacheSnapshotter_EN1242_DeleteAfterRotationCrashRestart(t *testing.T) { t.Parallel() snapshotter, dataStore, registry := newTestCacheSnapshotter(t, nil) lmk := domain.LedgerMetadataKey{LedgerName: "test-ledger", Key: "k0"} - u128, tag := attributes.MakeKey(lmk.Bytes()) - deletedValue := commonpb.NewStringValue("should-stay-deleted") + canonical := lmk.Bytes() + liveValue := commonpb.NewStringValue("v0") + liveBytes, err := liveValue.MarshalVT() + require.NoError(t, err) - // In-memory state after a delete followed by a rotation: the entry is a - // tombstone in gen1 (Deleted, but Del retained the old value in Data) and - // absent from gen0 — i.e. gen1-only, which CheckCache reports as CacheNeedsTouch. - registry.Cache.LedgerMetadata.Gen1().Put(u128, attributes.Entry[*commonpb.MetadataValue]{ - Tag: tag, - Data: deletedValue, - Deleted: true, - }) + ks := attributes.NewKeyStore[domain.LedgerMetadataKey, *commonpb.MetadataValue](registry.Cache.LedgerMetadata) - gen0Byte := byte(registry.Cache.CurrentGeneration() % 2) + // Step 1: Put — memory + disk land the live entry in Gen0. + _, idWithTag, err := ks.Put(canonical, liveValue) + require.NoError(t, err) + id := idWithTag.ID + tag := idWithTag.Tag + + gen0ByteAtPut := byte(registry.Cache.CurrentGeneration() % 2) batch := dataStore.OpenWriteSession() - // The delete wrote a tombstone to the 0xFF zone (both generations). - require.NoError(t, writeCacheTombstone(batch, dal.SubAttrLedgerMetadata, u128, tag)) - // The leader's CacheNeedsTouch promotes the gen1 tombstone to gen0. MirrorTouch - // must persist a tombstone for it — not the retained value. - require.NoError(t, snapshotter.MirrorTouch(batch, dal.SubAttrLedgerMetadata, gen0Byte, u128)) + require.NoError(t, writeCacheRaw(batch, gen0ByteAtPut, dal.SubAttrLedgerMetadata, id, tag, false, liveBytes)) + require.NoError(t, batch.Commit()) + + // Step 2: Rotate — memory rotation, then writeCacheRotation on disk. + registry.Cache.LedgerMetadata.Rotate() + newGen := registry.Cache.CurrentGeneration() + 1 + registry.Cache.SetCurrentGeneration(newGen) + + batch = dataStore.OpenWriteSession() + require.NoError(t, writeCacheRotation(batch, newGen, newGen, newGen-1)) + require.NoError(t, batch.Commit()) + + gen0Byte := byte(newGen % 2) + gen1Byte := byte((newGen + 1) % 2) + + _, gen0Has := registry.Cache.LedgerMetadata.Gen0().Get(id) + require.False(t, gen0Has, "precondition: Gen0 empty after rotation") + postRotateLive, gen1Has := registry.Cache.LedgerMetadata.Gen1().Get(id) + require.True(t, gen1Has) + require.False(t, postRotateLive.Deleted, "precondition: live row migrated to Gen1") + + // Step 3: FSM apply — KeyStore.Delete lazy-fabricates the gen0 + // tombstone from Gen1's tag (no separate MirrorTouch step), and + // writeCacheTombstone mirrors the tombstone to the gen0 byte on disk. + batch = dataStore.OpenWriteSession() + _, _, err = ks.Delete(canonical) + require.NoError(t, err, "Delete must succeed via the lazy gen1→gen0 promote") + + require.NoError(t, writeCacheTombstone(batch, gen0Byte, dal.SubAttrLedgerMetadata, id, tag)) require.NoError(t, batch.Commit()) - // Rehydrate the cache from the 0xFF zone, exactly as a node does on restart. + // Pre-restart sanity: Gen0 mem = fabricated tombstone (borrowed tag), + // Gen1 mem = pre-rotation live row (untouched by Del). + memTombstone, ok := registry.Cache.LedgerMetadata.Gen0().Get(id) + require.True(t, ok) + require.True(t, memTombstone.Deleted) + require.Equal(t, tag, memTombstone.Tag) + + memLive, ok := registry.Cache.LedgerMetadata.Gen1().Get(id) + require.True(t, ok) + require.False(t, memLive.Deleted, "Gen1 mem must keep the live row untouched") + + // Step 4: Simulate crash + restart by resetting memory and rehydrating + // from disk. registry.Cache.Reset() require.NoError(t, snapshotter.RestoreFromStore(dataStore)) - // The key must still be deleted in the restored cache. On the buggy code the - // restored gen0 entry is a live value (Deleted=false): the deleted key - // resurrected to its pre-delete value. - if e, ok := registry.Cache.LedgerMetadata.Gen0().Get(u128); ok { - require.True(t, e.Deleted, - "deleted ledger-metadata key resurrected after restore: gen0 holds live value %q", - e.Data.GetStringValue()) - } - if e, ok := registry.Cache.LedgerMetadata.Gen1().Get(u128); ok && !e.Deleted { - t.Fatalf("deleted ledger-metadata key resurrected after restore: gen1 holds live value %q", - e.Data.GetStringValue()) - } + // Step 5: Verify mem == disk for the same applied index. + require.Equal(t, newGen, registry.Cache.CurrentGeneration(), "restored generation must match pre-crash") + + restoredGen0, ok := registry.Cache.LedgerMetadata.Gen0().Get(id) + require.True(t, ok, "Gen0 must hold the tombstone after restart") + require.True(t, restoredGen0.Deleted) + require.Equal(t, tag, restoredGen0.Tag) + + restoredGen1, ok := registry.Cache.LedgerMetadata.Gen1().Get(id) + require.True(t, ok, "Gen1 must hold the carried-over live row after restart") + require.False(t, restoredGen1.Deleted) + require.Equal(t, tag, restoredGen1.Tag) + + // KeyStore.Get filters tombstones — Gen0 wins over Gen1's live row. + _, _, err = ks.Get(canonical) + require.ErrorIs(t, err, domain.ErrNotFound, "Get must surface ErrNotFound after restart") + + // Step 6: Next rotation must purge the stale Gen1 live row both in memory + // and on disk; only the tombstone migrates into the new Gen1. + registry.Cache.LedgerMetadata.Rotate() + postRotateGen := newGen + 1 + registry.Cache.SetCurrentGeneration(postRotateGen) + + batch = dataStore.OpenWriteSession() + require.NoError(t, writeCacheRotation(batch, postRotateGen, postRotateGen, postRotateGen-1)) + require.NoError(t, batch.Commit()) + + postRotateGen0Byte := byte(postRotateGen % 2) + postRotateGen1Byte := byte((postRotateGen + 1) % 2) + + _ = gen1Byte // gen1Byte pre-rotation is the same as postRotateGen0Byte; assert symmetry + require.Equal(t, gen1Byte, postRotateGen0Byte, "new Gen0 byte is the old Gen1 byte") + + _, ok = registry.Cache.LedgerMetadata.Gen0().Get(id) + require.False(t, ok, "post-rotation Gen0 must be empty") + + postRotateMemGen1, ok := registry.Cache.LedgerMetadata.Gen1().Get(id) + require.True(t, ok, "post-rotation Gen1 must keep the tombstone") + require.True(t, postRotateMemGen1.Deleted) + + // And on disk: the byte that was previously Gen1 (carrying the live row) + // is now Gen0 and was purged by writeCacheRotation; the byte that was + // previously Gen0 (carrying the tombstone) is now Gen1. + registry.Cache.Reset() + require.NoError(t, snapshotter.RestoreFromStore(dataStore)) + + _, ok = registry.Cache.LedgerMetadata.Gen0().Get(id) + require.False(t, ok, "after second restart, Gen0 must stay empty") + + finalGen1, ok := registry.Cache.LedgerMetadata.Gen1().Get(id) + require.True(t, ok, "after second restart, Gen1 must hold the tombstone") + require.True(t, finalGen1.Deleted) + + _, _, err = ks.Get(canonical) + require.ErrorIs(t, err, domain.ErrNotFound, "Get must still return ErrNotFound after second cycle") + + _ = postRotateGen1Byte // referenced for symmetry with the rotation derivation } // TestCacheSnapshotter_EN1377_LiveZeroByteProtoRoundTrip is the main @@ -713,38 +796,6 @@ func TestCacheSnapshotter_EN1377_MirrorPreloadEmptyRawValue(t *testing.T) { require.Equal(t, tag, restored.Tag) } -// TestCacheSnapshotter_EN1377_MirrorTouchOfZeroByteValue mirrors the -// previous test on the Touch path. A live gen1 entry whose proto marshals -// to zero bytes is promoted to gen0 by MirrorTouch; the new gen0 row must -// be readable as live after a restart. -func TestCacheSnapshotter_EN1377_MirrorTouchOfZeroByteValue(t *testing.T) { - t.Parallel() - - snapshotter, dataStore, registry := newTestCacheSnapshotter(t, nil) - - u128 := attributes.HashU128([]byte("touch:empty-boundaries")) - const tag uint64 = 11 - - registry.Cache.Boundaries.Gen1().Put(u128, attributes.Entry[*raftcmdpb.LedgerBoundaries]{ - Tag: tag, - Data: &raftcmdpb.LedgerBoundaries{}, - }) - - gen0Byte := byte(registry.Cache.CurrentGeneration() % 2) - - batch := dataStore.OpenWriteSession() - require.NoError(t, snapshotter.MirrorTouch(batch, dal.SubAttrBoundary, gen0Byte, u128)) - require.NoError(t, batch.Commit()) - - registry.Cache.Reset() - require.NoError(t, snapshotter.RestoreFromStore(dataStore)) - - restored, ok := registry.Cache.Boundaries.Gen0().Get(u128) - require.True(t, ok, "touched zero-byte entry must restore in gen0") - require.False(t, restored.Deleted, "touched live entry must NOT restore as tombstone (EN-1377)") - require.Equal(t, tag, restored.Tag) -} - // TestCacheSnapshotter_EN1377_RestoreRejectsShortValue asserts the // snapshotter panics on a 0xFF row shorter than the lean header. Every // row is produced by writeCacheRaw which writes at least cacheValueHeaderLen diff --git a/internal/infra/state/cache_touch_persistence_test.go b/internal/infra/state/cache_touch_persistence_test.go index 2119ad477..29569177f 100644 --- a/internal/infra/state/cache_touch_persistence_test.go +++ b/internal/infra/state/cache_touch_persistence_test.go @@ -12,23 +12,23 @@ import ( "github.com/formancehq/ledger/v3/internal/storage/dal" ) -// TestPreload_RejectsMalformedAttributePlan pins the gate that catches -// forged or partially-decoded plans before any MirrorTouch / MirrorPreload -// can mutate the cache. Without this, a Touch with a nil AttributeID -// would silently land a zero-padded U128 in the cache and a 0xFF Pebble -// write that the failure-audit batch would commit on later business -// rejection. -func TestPreload_RejectsMalformedAttributePlan(t *testing.T) { +// TestPreload_RejectsMalformedAttributeCoverage pins the gate that catches +// forged or partially-decoded plans before any MirrorPreload can mutate the +// cache. Without this, a coverage-only entry with a nil AttributeID would +// silently zero-pad through scope's applyPlans and admit a phantom U128 into +// the coverage slot; a seed entry would land the zero-padded U128 in the +// cache and a 0xFF Pebble write that the failure-audit batch would commit +// on later business rejection. +func TestPreload_RejectsMalformedAttributeCoverage(t *testing.T) { t.Parallel() machine, dataStore, _ := newTestMachine(t) plan := &raftcmdpb.ExecutionPlan{ LastPersistedIndex: machine.Registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{{ + Attributes: []*raftcmdpb.AttributeCoverage{{ Id: nil, // forged / decoded-incomplete envelope AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Touch{Touch: &raftcmdpb.Touch{}}, }}, } @@ -44,11 +44,11 @@ func TestPreload_RejectsMalformedAttributePlan(t *testing.T) { } // TestPreload_RejectsUnknownAttrCode pins that a forged ExecutionPlan -// whose AttributePlan declares an attr_code the FSM does not handle is -// caught at Preload entry. Without the gate, MirrorTouch / MirrorPreload -// would route the write to an orphan 0xFF Pebble slot, and a -// technical-only / no-read proposal would never reach a scope-level -// validation that could catch it. +// whose AttributeCoverage declares an attr_code the FSM does not handle is +// caught at Preload entry. Without the gate, a seed intent's MirrorPreload +// would route the write to an orphan 0xFF Pebble slot, and a technical-only +// / no-read proposal would never reach a scope-level validation that could +// catch it. func TestPreload_RejectsUnknownAttrCode(t *testing.T) { t.Parallel() @@ -57,10 +57,10 @@ func TestPreload_RejectsUnknownAttrCode(t *testing.T) { plan := &raftcmdpb.ExecutionPlan{ LastPersistedIndex: machine.Registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{{ + Attributes: []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: 0xff, // unknown attr_code - Intent: &raftcmdpb.AttributePlan_Touch{Touch: &raftcmdpb.Touch{}}, + }}, } @@ -77,7 +77,7 @@ func TestPreload_RejectsUnknownAttrCode(t *testing.T) { } // TestPreload_IdempotencyOnlyProposalAppliesKeys pins the behaviour for a -// proposal that ships only idempotency keys (no AttributePlan entries) — +// proposal that ships only idempotency keys (no AttributeCoverage entries) — // the typical shape of an idempotent maintenance / signature order with no // attribute needs. The early-exit on `len(GetAttributes()) == 0` must NOT // short-circuit the IdempotencyStore restore, otherwise at-most-once @@ -106,156 +106,10 @@ func TestPreload_IdempotencyOnlyProposalAppliesKeys(t *testing.T) { require.NoError(t, machine.Preload(executionPlan, batch, gen0Byte)) got, ok := machine.Registry.Idempotency.Get("idem-only") - require.True(t, ok, "idempotency key must be present after Preload even with no AttributePlan entries") + require.True(t, ok, "idempotency key must be present after Preload even with no AttributeCoverage entries") require.Equal(t, uint64(42), got.GetFirstLogSequence()) } -// Asserts a CacheTouch promotion lands at 0xFF gen0Byte and survives a -// restart — without the mirror, RestoreFromStore would put the entry back -// into gen1 and the next rotation would evict it. -func TestPreload_TouchIsPersistedToCacheZone(t *testing.T) { - t.Parallel() - - machine, dataStore, _ := newTestMachine(t) - _ = dataStore - registry := machine.Registry - - // gen0Byte / gen1Byte for currentGeneration=0. - const ( - gen0Byte byte = 0 - gen1Byte byte = 1 - ) - - // Pre-touch shape: entry in gen1 in-memory and at 0xFF gen1Byte. - ledgerKey := domain.LedgerKey{Name: "gaming"} - id := attributes.HashU128(ledgerKey.Bytes()) - info := &commonpb.LedgerInfo{Name: "gaming"} - - registry.Cache.Ledgers.Gen1().Put(id, attributes.Entry[*commonpb.LedgerInfo]{Tag: 7, Data: info}) - - seedBatch := dataStore.OpenWriteSession() - infoBytes, err := info.MarshalVT() - require.NoError(t, err) - require.NoError(t, - writeCacheRaw(seedBatch, gen1Byte, dal.SubAttrLedger, id, 7, false, infoBytes)) - require.NoError(t, seedBatch.Commit()) - - gen1Key := []byte{dal.ZoneCache, gen1Byte, dal.SubAttrLedger} - gen1Key = append(gen1Key, id[:]...) - gen0Key := []byte{dal.ZoneCache, gen0Byte, dal.SubAttrLedger} - gen0Key = append(gen0Key, id[:]...) - - if val, closer, getErr := dataStore.Get(gen1Key); getErr == nil { - require.NotEmpty(t, val) - require.NoError(t, closer.Close()) - } else { - t.Fatalf("seed: gen1 row missing: %v", getErr) - } - - _, _, err = dataStore.Get(gen0Key) - require.Error(t, err, "seed: gen0 row should not exist yet") - - applyBatch := dataStore.OpenWriteSession() - defer func() { _ = applyBatch.Cancel() }() - - executionPlan := &raftcmdpb.ExecutionPlan{ - LastPersistedIndex: registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{{ - Id: &raftcmdpb.AttributeID{Id: id[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Touch{Touch: &raftcmdpb.Touch{}}, - }}, - } - - require.NoError(t, machine.Preload(executionPlan, applyBatch, gen0Byte)) - require.NoError(t, applyBatch.Commit()) - - // Touch is a copy, not a move — gen1 keeps the entry. - got, ok := registry.Cache.Ledgers.Gen0().Get(id) - require.True(t, ok, "after touch: gen0 must have the entry") - require.Equal(t, "gaming", got.Data.GetName()) - require.Equal(t, uint64(7), got.Tag) - - if val, closer, getErr := dataStore.Get(gen0Key); getErr != nil { - t.Fatalf("0xFF gen0 row missing after touch: %v", getErr) - } else { - require.NotEmpty(t, val) - require.NoError(t, closer.Close()) - } - - // Restart simulation. - registry.Cache.Reset() - require.NoError(t, machine.cacheSnapshotter.RestoreFromStore(dataStore)) - - restored, ok := registry.Cache.Ledgers.Gen0().Get(id) - require.True(t, ok, "gen0 must hold the entry after restore") - require.Equal(t, "gaming", restored.Data.GetName()) -} - -// Asserts a CacheTouch for a key already in gen0 is a no-op and doesn't -// overwrite 0xFF gen0Byte (which may hold a fresher in-batch Merge value). -func TestPreload_TouchSkipsWhenGen0HasFreshValue(t *testing.T) { - t.Parallel() - - machine, dataStore, _ := newTestMachine(t) - _ = dataStore - registry := machine.Registry - - const ( - gen0Byte byte = 0 - gen1Byte byte = 1 - ) - - ledgerKey := domain.LedgerKey{Name: "gaming"} - id := attributes.HashU128(ledgerKey.Bytes()) - - staleInfo := &commonpb.LedgerInfo{Name: "gaming-stale"} - freshInfo := &commonpb.LedgerInfo{Name: "gaming-fresh"} - - // Post-Merge shape: stale in gen1 + 0xFF gen1Byte; fresh in gen0 + 0xFF gen0Byte. - registry.Cache.Ledgers.Gen1().Put(id, attributes.Entry[*commonpb.LedgerInfo]{Tag: 1, Data: staleInfo}) - registry.Cache.Ledgers.Gen0().Put(id, attributes.Entry[*commonpb.LedgerInfo]{Tag: 1, Data: freshInfo}) - - seedBatch := dataStore.OpenWriteSession() - staleBytes, err := staleInfo.MarshalVT() - require.NoError(t, err) - - freshBytes, err := freshInfo.MarshalVT() - require.NoError(t, err) - require.NoError(t, - writeCacheRaw(seedBatch, gen1Byte, dal.SubAttrLedger, id, 1, false, staleBytes)) - require.NoError(t, - writeCacheRaw(seedBatch, gen0Byte, dal.SubAttrLedger, id, 1, false, freshBytes)) - require.NoError(t, seedBatch.Commit()) - - applyBatch := dataStore.OpenWriteSession() - defer func() { _ = applyBatch.Cancel() }() - - executionPlan := &raftcmdpb.ExecutionPlan{ - LastPersistedIndex: registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{{ - Id: &raftcmdpb.AttributeID{Id: id[:]}, AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Touch{Touch: &raftcmdpb.Touch{}}, - }}, - } - require.NoError(t, machine.Preload(executionPlan, applyBatch, gen0Byte)) - require.NoError(t, applyBatch.Commit()) - - got, ok := registry.Cache.Ledgers.Gen0().Get(id) - require.True(t, ok) - require.Equal(t, "gaming-fresh", got.Data.GetName(), "touch must not overwrite fresh gen0 value") - - gen0Key := []byte{dal.ZoneCache, gen0Byte, dal.SubAttrLedger} - gen0Key = append(gen0Key, id[:]...) - val, closer, err := dataStore.Get(gen0Key) - require.NoError(t, err) - - defer func() { _ = closer.Close() }() - - // Lean format: [8-byte tag LE][1-byte flag][value bytes]. - require.Equal(t, cacheValueFlagLive, val[8], "0xFF gen0 row must be live") - require.Equal(t, freshBytes, val[cacheValueHeaderLen:], "0xFF gen0 row must not be clobbered with stale gen1 value") -} - // Asserts a CacheMiss preload for a key already in gen0 is a no-op for that // generation — must not clobber a fresher in-batch Merge value at 0xFF gen0Byte // with the (potentially stale) preload value computed at admission time. @@ -303,10 +157,10 @@ func TestPreload_FullPreloadSkipsWhenGen0HasFreshValue(t *testing.T) { // (stale wrt the in-batch merge). executionPlan := &raftcmdpb.ExecutionPlan{ LastPersistedIndex: registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{{ + Attributes: []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: hash[:], Tag: tag}, AttrCode: uint32(dal.SubAttrNumscriptContent), - Intent: &raftcmdpb.AttributePlan_Value{Value: rawPreload(t, dal.SubAttrNumscriptContent, staleInfo)}, + Value: rawPreload(t, dal.SubAttrNumscriptContent, staleInfo), }}, // NOTE: helper rawPreload defined at the bottom of this file. } @@ -371,10 +225,10 @@ func TestPreload_FullPreloadIsPersistedToCacheZone(t *testing.T) { executionPlan := &raftcmdpb.ExecutionPlan{ LastPersistedIndex: registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{{ + Attributes: []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: hash[:], Tag: tag}, AttrCode: uint32(dal.SubAttrNumscriptContent), - Intent: &raftcmdpb.AttributePlan_Value{Value: rawPreload(t, dal.SubAttrNumscriptContent, scriptInfo)}, + Value: rawPreload(t, dal.SubAttrNumscriptContent, scriptInfo), }}, } diff --git a/internal/infra/state/machine.go b/internal/infra/state/machine.go index a10ae25aa..1f4932e5f 100644 --- a/internal/infra/state/machine.go +++ b/internal/infra/state/machine.go @@ -938,7 +938,7 @@ func (fsm *Machine) Preload(executionPlan *raftcmdpb.ExecutionPlan, batch *dal.W return nil } - // Idempotency keys live outside the AttributePlan stream — they are not + // Idempotency keys live outside the AttributeCoverage stream — they are not // a cache attribute (the FSM applies them to the dedicated Idempotency- // Store, not the per-kind cache). Apply them first and unconditionally: // a proposal carrying only idempotency keys (idempotent maintenance / @@ -958,14 +958,14 @@ func (fsm *Machine) Preload(executionPlan *raftcmdpb.ExecutionPlan, batch *dal.W return nil } - // Pre-validate every AttributePlan envelope before touching the + // Pre-validate every AttributeCoverage envelope before touching the // cache. Without this, a forged plan with a nil/short AttributeID - // or no intent would silently zero-pad through MirrorTouch / - // MirrorPreload, mutating both the in-memory cache and the 0xFF - // Pebble writes. A later business rejection from the scope path - // commits its failure audit batch — and the cache mutations would - // commit with it. Run the same validation the scope path uses - // here, so a malformed plan is caught before the first MirrorTouch. + // or an unknown attr_code would silently zero-pad through MirrorPreload, + // mutating both the in-memory cache and the 0xFF Pebble writes. A + // later business rejection from the scope path commits its failure + // audit batch — and the cache mutations would commit with it. Run the + // same validation the scope path uses here, so a malformed plan is + // caught before the first MirrorPreload. for i, plan := range executionPlan.GetAttributes() { if err := validatePlan(plan, i); err != nil { return err @@ -1009,22 +1009,25 @@ func (fsm *Machine) Preload(executionPlan *raftcmdpb.ExecutionPlan, batch *dal.W gen1Byte := genByte ^ 1 for _, plan := range executionPlan.GetAttributes() { - switch intent := plan.GetIntent().(type) { - case *raftcmdpb.AttributePlan_Declare: - // Pure coverage declaration: the value is already in Gen0 on - // every node. No FSM-side mutation; the Plan consumes - // the declaration separately. - - case *raftcmdpb.AttributePlan_Touch: - id := attributes.U128FromBytes(plan.GetId().GetId()) - if err := fsm.cacheSnapshotter.MirrorTouch(batch, byte(plan.GetAttrCode()), genByte, id); err != nil { - return err - } + value := plan.GetValue() + if value == nil { + // Coverage-only entry: nothing to seed. The gen0→gen1 fallback + // in AttributeCache.Get and the lazy gen1→gen0 promote in + // AttributeCache.Del cover the handler's reads and deletes + // respectively; coverage_bits (invariant #9) bounds the read + // horizon to admission's declared preload set. Keeps Preload + // O(seeds) instead of O(coverage entries). + continue + } - case *raftcmdpb.AttributePlan_Value: - if err := fsm.cacheSnapshotter.MirrorPreload(batch, genByte, gen1Byte, plan.GetId(), byte(plan.GetAttrCode()), intent.Value); err != nil { - return err - } + attrCode := byte(plan.GetAttrCode()) + + // Seed: MirrorPreload writes gen0+gen1 with the Pebble-loaded + // payload. Gen1-wins semantics preserve any fresher value a + // concurrent write may have already populated between admission's + // Pebble scan and apply. + if err := fsm.cacheSnapshotter.MirrorPreload(batch, genByte, gen1Byte, plan.GetId(), attrCode, value); err != nil { + return err } } @@ -1112,7 +1115,7 @@ func (fsm *Machine) checkStaleProposal(raftIndex uint64, proposal *raftcmdpb.Pro // when err is some other kind of error (Pebble write failure, etc.) so // the caller can fall through to the FSM-killing path. // -// Admission ships bits that don't match the AttributePlan slice → +// Admission ships bits that don't match the AttributeCoverage slice → // *ErrCoverageMiss or *domain.ErrInvalidExecutionPlan. Both implement // Describable with KindInternal. Surfacing them via ApplyResult.Error // rejects the proposal as a business error instead of wedging the FSM @@ -1170,10 +1173,10 @@ func (fsm *Machine) applyProposal(ctx context.Context, raftIndex uint64, batch * genByte := byte(fsm.Registry.Cache.CurrentGeneration() % 2) if err := fsm.Preload(proposal.GetExecutionPlan(), batch, genByte); err != nil { if invariant := planInvariantDescribable(err); invariant != nil { - // Malformed AttributePlan caught before any MirrorTouch / - // MirrorPreload — no cache mutation landed. Surface as a - // business rejection in the same shape as scope-level plan - // invariants so the admission side can diagnose its bug. + // Malformed AttributeCoverage caught before any MirrorPreload + // — no cache mutation landed. Surface as a business rejection + // in the same shape as scope-level plan invariants so the + // admission side can diagnose its bug. return &ApplyResult{ ProposalID: proposal.GetId(), Error: &domain.BusinessError{Err: invariant}, diff --git a/internal/infra/state/machine_cache_divergence_test.go b/internal/infra/state/machine_cache_divergence_test.go index 488d0a6b5..881866045 100644 --- a/internal/infra/state/machine_cache_divergence_test.go +++ b/internal/infra/state/machine_cache_divergence_test.go @@ -22,7 +22,7 @@ import ( // simulating a leader-follower pair where: // 1. Both machines apply the same entries with preloads computed by the leader // 2. The follower crashes and restarts (cache reset + RestoreFromStore) -// 3. Post-restart entries use the leader's cache state (CacheGuaranteed) +// 3. Post-restart entries use the leader's cache state (CacheHit) // which may not match the follower's restored cache // // The test checks that volume values stay consistent between leader and follower. @@ -319,13 +319,13 @@ func TestCacheDivergenceBatchReplay(t *testing.T) { // TestCacheDivergencePipelinedAdmission simulates the REAL bug scenario: // the leader admits MULTIPLE proposals from the SAME cache state before any -// of them are applied. This creates entries with stale CacheGuaranteed that -// don't include touches for keys that will move to gen1 after rotation. +// of them are applied. This creates entries with stale CacheHit verdicts +// that don't include seeds for keys that will move to gen1 after rotation. // // With threshold=3 and a pipeline depth of 6+ entries: -// - Entries are admitted when K is in gen0 → CacheGuaranteed +// - Entries are admitted when K is in gen0 → CacheHit // - First entry triggers rotation → K moves to gen1 -// - Next entries don't have Touch for K (CacheGuaranteed in preloads) +// - Next entries carry the same stale CacheHit-derived Declare plan // - Second rotation → K in gen1 is PURGED // - K is permanently lost from the follower's cache func TestCacheDivergencePipelinedAdmission(t *testing.T) { @@ -558,7 +558,7 @@ func buildProposalWithLeaderPreloads( boundary := cache.BoundaryIndex(nextIndex, threshold) - var plans []*raftcmdpb.AttributePlan + var plans []*raftcmdpb.AttributeCoverage // For CreateLedger, we need to preload the ledger key (it won't be in cache) for _, order := range orders { @@ -592,10 +592,8 @@ func buildProposalWithLeaderPreloads( ledgerU128, ledgerTag := attributes.MakeKey(ledgerCanonical) switch leader.Registry.Cache.Ledgers.CheckCache(nextIndex, ledgerU128) { - case cache.CacheGuaranteed: + case cache.CacheHit: plans = append(plans, declareTestPlan(ledgerU128, dal.SubAttrLedger)) - case cache.CacheNeedsTouch: - plans = append(plans, touchTestPlan(ledgerU128, dal.SubAttrLedger)) case cache.CacheMiss: info, _, err := leader.Registry.Ledgers.Get(ledgerCanonical) if err == nil && info != nil { @@ -608,10 +606,8 @@ func buildProposalWithLeaderPreloads( // Check Boundaries in leader's cache switch leader.Registry.Cache.Boundaries.CheckCache(nextIndex, ledgerU128) { - case cache.CacheGuaranteed: + case cache.CacheHit: plans = append(plans, declareTestPlan(ledgerU128, dal.SubAttrBoundary)) - case cache.CacheNeedsTouch: - plans = append(plans, touchTestPlan(ledgerU128, dal.SubAttrBoundary)) case cache.CacheMiss: boundaries, _, err := leader.Registry.Boundaries.Get(ledgerCanonical) if err == nil && boundaries != nil { @@ -647,10 +643,8 @@ func buildProposalWithLeaderPreloads( volU128, volTag := attributes.MakeKey(volCanonical) switch leader.Registry.Cache.Volumes.CheckCache(nextIndex, volU128) { - case cache.CacheGuaranteed: + case cache.CacheHit: plans = append(plans, declareTestPlan(volU128, dal.SubAttrVolume)) - case cache.CacheNeedsTouch: - plans = append(plans, touchTestPlan(volU128, dal.SubAttrVolume)) case cache.CacheMiss: vol, _, err := leader.Registry.Volumes.Get(volCanonical) if err != nil || vol == nil { diff --git a/internal/infra/state/machine_cache_restart_test.go b/internal/infra/state/machine_cache_restart_test.go index db82f1f8e..f7ef5c6bf 100644 --- a/internal/infra/state/machine_cache_restart_test.go +++ b/internal/infra/state/machine_cache_restart_test.go @@ -21,7 +21,7 @@ import ( // from the in-memory cache (gen0 or gen1) // // If any volume is in 0xF1 but not in the cache after restart + rotations, -// proposals marked CacheGuaranteed for that volume will fail with +// proposals marked CacheHit for that volume will fail with // "not preloaded: not found" — the root cause of the volume divergence bug. func TestCacheCoherenceAfterRestart(t *testing.T) { t.Parallel() diff --git a/internal/infra/state/machine_test.go b/internal/infra/state/machine_test.go index 4560de72b..bf86a7e7d 100644 --- a/internal/infra/state/machine_test.go +++ b/internal/infra/state/machine_test.go @@ -80,10 +80,10 @@ func makeProposal(id uint64, orders ...*raftcmdpb.Order) *raftcmdpb.Proposal { } // sealProposal stamps every order's CoverageBits to flag every -// AttributePlan in the proposal's ExecutionPlan. This is the +// AttributeCoverage in the proposal's ExecutionPlan. This is the // "admit-all per order" shortcut tests rely on: production goes // through plan.Builder.Run which derives per-order bits from each -// order's declared Needs via plan.bitsForNeeds. Rebuilding that path +// order's declared Coverage via plan.bitsForNeeds. Rebuilding that path // here would require pulling admission.extractPreloadNeeds (and its // 200+ lines of order-type switch) into a shared package. Tracked // for a follow-up. None of the existing tests exercise per-order @@ -110,34 +110,24 @@ func sealProposal(p *raftcmdpb.Proposal) *raftcmdpb.Proposal { return p } -// declareTestPlan builds a Declare-intent AttributePlan over an attribute -// (code, U128) pair. Used by tests that simulate what admission's resolve -// layer emits when a key is already CacheGuaranteed (Gen0). -func declareTestPlan(id attributes.U128, attrCode byte) *raftcmdpb.AttributePlan { - return &raftcmdpb.AttributePlan{ +// declareTestPlan builds a coverage-only AttributeCoverage over an +// attribute (code, U128) pair. Used by tests that simulate what +// admission's resolve layer emits when a key is already CacheHit. +func declareTestPlan(id attributes.U128, attrCode byte) *raftcmdpb.AttributeCoverage { + return &raftcmdpb.AttributeCoverage{ Id: &raftcmdpb.AttributeID{Id: id[:]}, AttrCode: uint32(attrCode), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, } } -// touchTestPlan builds a Touch-intent AttributePlan: Gen1→Gen0 promotion. -func touchTestPlan(id attributes.U128, attrCode byte) *raftcmdpb.AttributePlan { - return &raftcmdpb.AttributePlan{ - Id: &raftcmdpb.AttributeID{Id: id[:]}, - AttrCode: uint32(attrCode), - Intent: &raftcmdpb.AttributePlan_Touch{Touch: &raftcmdpb.Touch{}}, - } -} - -// preloadTestPlan wraps an AttributeValue payload into an AttributePlan. -// attrID carries the canonical U128 + xxh3 collision tag; attrCode -// (dal.SubAttrXxx) drives the unmarshal dispatch. -func preloadTestPlan(attrID *raftcmdpb.AttributeID, attrCode byte, value *raftcmdpb.AttributeValue) *raftcmdpb.AttributePlan { - return &raftcmdpb.AttributePlan{ +// preloadTestPlan wraps an AttributeValue payload into a seeded +// AttributeCoverage. attrID carries the canonical U128 + xxh3 collision +// tag; attrCode (dal.SubAttrXxx) drives the unmarshal dispatch. +func preloadTestPlan(attrID *raftcmdpb.AttributeID, attrCode byte, value *raftcmdpb.AttributeValue) *raftcmdpb.AttributeCoverage { + return &raftcmdpb.AttributeCoverage{ Id: attrID, AttrCode: uint32(attrCode), - Intent: &raftcmdpb.AttributePlan_Value{Value: value}, + Value: value, } } @@ -160,21 +150,21 @@ func rawPreloadNoT[V interface { panic(fmt.Errorf("rawPreload: marshal attrCode=0x%x: %w", attrCode, err)) } - _ = attrCode // attr_code lives on the parent AttributePlan now + _ = attrCode // attr_code lives on the parent AttributeCoverage now return &raftcmdpb.AttributeValue{ RawValue: raw, } } -// buildOrderDeclarations emits Declare-intent AttributePlans for the +// buildOrderDeclarations emits Declare-intent AttributeCoverages for the // side-channel keys the FSM reads while applying orders but that // buildVolumePreloads does not cover: the ledger itself, its boundaries, // target transactions for reverts, and metadata keys. // // The ledger name comes from the order itself (apply.GetLedger()), matching // the LedgerName-based canonical key layout introduced by #469. -func buildOrderDeclarations(orders []*raftcmdpb.Order) []*raftcmdpb.AttributePlan { +func buildOrderDeclarations(orders []*raftcmdpb.Order) []*raftcmdpb.AttributeCoverage { type acctMetaKey struct { ledgerName string account string @@ -234,7 +224,7 @@ func buildOrderDeclarations(orders []*raftcmdpb.Order) []*raftcmdpb.AttributePla } } - var declared []*raftcmdpb.AttributePlan + var declared []*raftcmdpb.AttributeCoverage for name := range ledgers { ledgerKeyID, _ := attributes.MakeKey(domain.LedgerKey{Name: name}.Bytes()) @@ -266,14 +256,14 @@ func buildOrderDeclarations(orders []*raftcmdpb.Order) []*raftcmdpb.AttributePla // orders and creates zero-value volume preloads for each unique combination. // The second parameter is the default ledger name for orders that do not carry // one (none in practice today, but kept for callers passing a literal sentinel). -func buildVolumePreloads(orders []*raftcmdpb.Order) []*raftcmdpb.AttributePlan { +func buildVolumePreloads(orders []*raftcmdpb.Order) []*raftcmdpb.AttributeCoverage { type volumeKey struct { ledger string account string asset string } seen := make(map[volumeKey]struct{}) - var plans []*raftcmdpb.AttributePlan + var plans []*raftcmdpb.AttributeCoverage zero := commonpb.NewUint256FromUint64(0) @@ -324,7 +314,7 @@ func buildVolumePreloads(orders []*raftcmdpb.Order) []*raftcmdpb.AttributePlan { // makeEntry marshals a proposal into a raft entry at the given index. // sealProposal is applied first so every order's CoverageBits flags -// every AttributePlan in the proposal, matching what the admission +// every AttributeCoverage in the proposal, matching what the admission // runner does for production proposals — tests built inline would // otherwise hit *ErrCoverageMiss on the first cache read. func makeEntry(t *testing.T, index uint64, proposal *raftcmdpb.Proposal) raftpb.Entry { diff --git a/internal/infra/state/scope.go b/internal/infra/state/scope.go index 13831d49e..d1d2b0e05 100644 --- a/internal/infra/state/scope.go +++ b/internal/infra/state/scope.go @@ -81,7 +81,7 @@ type gatedScope struct { // plans is the proposal's ExecutionPlan. NewScope re-applies a // subset (selected by coverage_bits) into coverage every call. - plans []*raftcmdpb.AttributePlan + plans []*raftcmdpb.AttributeCoverage // coverage is a dense slot table — one entry per supported // sub-attribute kind (see cacheAttrKinds). coverageSlotIndex maps @@ -112,36 +112,28 @@ type gatedScope struct { gatedIndexes *gatedAccessor[domain.IndexKey, *commonpb.Index, commonpb.IndexReader] } -// validatePlan rejects AttributePlans whose envelope is malformed: +// validatePlan rejects AttributeCoverages whose envelope is malformed: // - missing AttributeID or an ID payload that is not the 16-byte U128 // we expect (attributes.U128FromBytes would silently zero-pad); -// - missing intent oneof (the preloader would skip it but the scope -// would still admit the resulting coverage slot); -// - attr_code that the FSM does not handle (MirrorTouch / -// MirrorPreload route it to an orphan 0xFF Pebble slot; scope -// validation only catches selected plans later). +// - attr_code that the FSM does not handle (a seed intent's +// MirrorPreload would route the write to an orphan 0xFF Pebble +// slot; scope validation only catches selected plans later). // // Run at every gate that touches plans (Preload entry, applyPlans, // applyAllPlans) so a forged ExecutionPlan never reaches a side-effecting // call. The shared check keeps the three sites in lock-step. -func validatePlan(plan *raftcmdpb.AttributePlan, idx int) *domain.ErrInvalidExecutionPlan { +func validatePlan(plan *raftcmdpb.AttributeCoverage, idx int) *domain.ErrInvalidExecutionPlan { id := plan.GetId() if id == nil || len(id.GetId()) != 16 { return &domain.ErrInvalidExecutionPlan{ - Reason_: fmt.Sprintf("plans[%d]: AttributePlan must carry a 16-byte AttributeID (got %d bytes)", idx, len(id.GetId())), - } - } - - if plan.GetIntent() == nil { - return &domain.ErrInvalidExecutionPlan{ - Reason_: fmt.Sprintf("plans[%d]: AttributePlan has no intent set", idx), + Reason_: fmt.Sprintf("plans[%d]: AttributeCoverage must carry a 16-byte AttributeID (got %d bytes)", idx, len(id.GetId())), } } kind := byte(plan.GetAttrCode()) if coverageSlotIndex[kind] < 0 { return &domain.ErrInvalidExecutionPlan{ - Reason_: fmt.Sprintf("plans[%d]: AttributePlan declares attr_code 0x%02x which the FSM does not handle", idx, kind), + Reason_: fmt.Sprintf("plans[%d]: AttributeCoverage declares attr_code 0x%02x which the FSM does not handle", idx, kind), } } @@ -173,7 +165,7 @@ func validatePlan(plan *raftcmdpb.AttributePlan, idx int) *domain.ErrInvalidExec // plans slice or when a plan declares an attr_code the FSM does not // handle. On error, the partial reset is harmless: the caller does not // use the scope and the next NewScope call truncates again. -func applyPlans(coverage *coverageSlots, plans []*raftcmdpb.AttributePlan, coverageBits []byte) *domain.ErrInvalidExecutionPlan { +func applyPlans(coverage *coverageSlots, plans []*raftcmdpb.AttributeCoverage, coverageBits []byte) *domain.ErrInvalidExecutionPlan { for i := range coverage { coverage[i] = coverage[i][:0] } @@ -230,7 +222,7 @@ func applyPlans(coverage *coverageSlots, plans []*raftcmdpb.AttributePlan, cover // // Returns *ErrInvalidExecutionPlan when a plan declares an attr_code // the FSM does not handle. -func applyAllPlans(coverage *coverageSlots, plans []*raftcmdpb.AttributePlan) *domain.ErrInvalidExecutionPlan { +func applyAllPlans(coverage *coverageSlots, plans []*raftcmdpb.AttributeCoverage) *domain.ErrInvalidExecutionPlan { for i := range coverage { coverage[i] = coverage[i][:0] } @@ -276,7 +268,7 @@ func NewScopeFactory( missCounter metric.Int64Counter, raftIndex uint64, ) processing.ScopeFactory { - var plans []*raftcmdpb.AttributePlan + var plans []*raftcmdpb.AttributeCoverage if plan != nil { plans = plan.GetAttributes() } @@ -323,7 +315,7 @@ func (g *gatedScope) NewScope(coverageBits []byte) (processing.Scope, error) { return g, nil } -// NewProposalScope reconfigures the scope to admit every AttributePlan +// NewProposalScope reconfigures the scope to admit every AttributeCoverage // the proposal declared. Used by ValidateTransientVolumes and other // cross-order checks that must reach into any ledger the proposal may // have touched. diff --git a/internal/infra/state/scope_test.go b/internal/infra/state/scope_test.go index 264f128d7..93fbf59f6 100644 --- a/internal/infra/state/scope_test.go +++ b/internal/infra/state/scope_test.go @@ -41,7 +41,7 @@ func TestScope_TechnicalUpdate_CoverageMissShortCircuits(t *testing.T) { okID, _ := attributes.MakeKey(okKey.Bytes()) executionPlan := &raftcmdpb.ExecutionPlan{ LastPersistedIndex: fsm.Registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{ + Attributes: []*raftcmdpb.AttributeCoverage{ declareTestPlan(okID, dal.SubAttrLedger), }, } @@ -121,7 +121,7 @@ func TestScope_TechnicalUpdate_PerUpdateCoverageIsolation(t *testing.T) { bID, _ := attributes.MakeKey(bKey.Bytes()) executionPlan := &raftcmdpb.ExecutionPlan{ LastPersistedIndex: fsm.Registry.Cache.BaseIndex.Gen0, - Attributes: []*raftcmdpb.AttributePlan{ + Attributes: []*raftcmdpb.AttributeCoverage{ declareTestPlan(aID, dal.SubAttrLedger), // bit 0 declareTestPlan(bID, dal.SubAttrLedger), // bit 1 }, @@ -188,3 +188,34 @@ func TestScope_OrderRead_RequiresCoverageEvenForOverlayHit(t *testing.T) { var miss *ErrCoverageMiss require.ErrorAs(t, err, &miss, "Scope.GetLedger must surface ErrCoverageMiss instead of the overlay value") } + +// TestScope_OrderDelete_RequiresCoverage pins the delete-gate invariant +// added in EN-1242: gatedAccessor.Delete must call CheckCoverage before +// delegating, so a handler that tries to delete a key its coverage_bits +// did not declare is rejected with ErrCoverageMiss rather than silently +// tombstoning an undeclared entry. Symmetric with Get's gate. +func TestScope_OrderDelete_RequiresCoverage(t *testing.T) { + t.Parallel() + + fsm, _, _ := newTestMachine(t) + + // Empty ExecutionPlan → no plans declared → every gated operation must miss. + plan := &raftcmdpb.ExecutionPlan{} + + fsm.writeSet.Reset(&commonpb.Timestamp{Data: 1}) + buffer := fsm.writeSet + scope, err := NewScopeFactory(buffer, plan, fsm.logger, fsm.preloadMissCounter, 42).NewScope(nil) + require.NoError(t, err) + + // A handler attempts to delete an undeclared metadata key. Put isn't + // gated (the batch overlay isolates it), but Delete IS — the coverage + // gate must reject before the deletion is queued. + err = scope.AccountMetadata().Delete(domain.MetadataKey{ + AccountKey: domain.AccountKey{LedgerName: "L", Account: "alice"}, + Key: "label", + }) + + var miss *ErrCoverageMiss + require.ErrorAs(t, err, &miss, "Scope.AccountMetadata().Delete on an undeclared key must surface ErrCoverageMiss") + require.Equal(t, "account_metadata", miss.Attribute) +} diff --git a/internal/infra/state/scope_unit_test.go b/internal/infra/state/scope_unit_test.go index ecb48f692..56cd1d55e 100644 --- a/internal/infra/state/scope_unit_test.go +++ b/internal/infra/state/scope_unit_test.go @@ -87,7 +87,7 @@ func TestApplyPlans_Errors(t *testing.T) { var coverage coverageSlots - err := applyPlans(&coverage, []*raftcmdpb.AttributePlan{}, []byte{0b1}) + err := applyPlans(&coverage, []*raftcmdpb.AttributeCoverage{}, []byte{0b1}) require.NotNil(t, err) require.Contains(t, err.Reason_, "coverage_bits flags position 0 past plans length 0") }) @@ -98,10 +98,9 @@ func TestApplyPlans_Errors(t *testing.T) { var coverage coverageSlots u128, _ := attributes.MakeKey(domain.LedgerKey{Name: "L"}.Bytes()) - plans := []*raftcmdpb.AttributePlan{{ + plans := []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: 0xff, // not in cacheAttrKinds - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }} // applyAllPlans (the proposal-wide path used by NewProposalScope) @@ -118,10 +117,9 @@ func TestApplyPlans_Errors(t *testing.T) { var coverage coverageSlots u128, _ := attributes.MakeKey(domain.LedgerKey{Name: "L"}.Bytes()) - plans := []*raftcmdpb.AttributePlan{{ + plans := []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: u128[:]}, AttrCode: 0xfe, - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }} err := applyPlans(&coverage, plans, []byte{0b1}) @@ -135,10 +133,9 @@ func TestApplyPlans_Errors(t *testing.T) { var coverage coverageSlots - plans := []*raftcmdpb.AttributePlan{{ + plans := []*raftcmdpb.AttributeCoverage{{ Id: nil, // forged / decoded-incomplete envelope AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }} err := applyPlans(&coverage, plans, []byte{0b1}) @@ -152,10 +149,9 @@ func TestApplyPlans_Errors(t *testing.T) { var coverage coverageSlots - plans := []*raftcmdpb.AttributePlan{{ + plans := []*raftcmdpb.AttributeCoverage{{ Id: &raftcmdpb.AttributeID{Id: []byte{0x01, 0x02}}, // 2 bytes — would silently zero-pad AttrCode: uint32(dal.SubAttrLedger), - Intent: &raftcmdpb.AttributePlan_Declare{Declare: &raftcmdpb.Declare{}}, }} err := applyAllPlans(&coverage, plans) @@ -164,24 +160,6 @@ func TestApplyPlans_Errors(t *testing.T) { require.Contains(t, err.Reason_, "16-byte AttributeID") require.Contains(t, err.Reason_, "got 2 bytes") }) - - t.Run("plan with nil intent", func(t *testing.T) { - t.Parallel() - - var coverage coverageSlots - - u128, _ := attributes.MakeKey(domain.LedgerKey{Name: "L"}.Bytes()) - plans := []*raftcmdpb.AttributePlan{{ - Id: &raftcmdpb.AttributeID{Id: u128[:]}, - AttrCode: uint32(dal.SubAttrLedger), - Intent: nil, // preloader would skip; coverage would still admit - }} - - err := applyPlans(&coverage, plans, []byte{0b1}) - require.NotNil(t, err) - require.Contains(t, err.Reason_, "plans[0]") - require.Contains(t, err.Reason_, "no intent set") - }) } // TestPlanInvariantDescribable pins the predicate that decides whether diff --git a/internal/infra/state/write_set.go b/internal/infra/state/write_set.go index 465446c85..c5a5a8e55 100644 --- a/internal/infra/state/write_set.go +++ b/internal/infra/state/write_set.go @@ -476,7 +476,7 @@ func (b *WriteSet) Merge(batch *dal.WriteSession, logsOrRefs []*raftcmdpb.Create // batches: the next GetVolume would return the prior cumulative value, // causing PCVs on re-touched transient cells to drift. A populated cache // entry (rather than a delete) is still required for any co-batched - // proposal admitted with CacheGuaranteed. + // proposal admitted with CacheHit. if err := b.zeroVolumeCache(batch, genByte, partResult.transient); err != nil { return fmt.Errorf("failed zeroing transient volumes in cache: %w", err) } diff --git a/internal/infra/state/write_set_ephemeral_purge.go b/internal/infra/state/write_set_ephemeral_purge.go index c74ff56fa..7f2391c60 100644 --- a/internal/infra/state/write_set_ephemeral_purge.go +++ b/internal/infra/state/write_set_ephemeral_purge.go @@ -198,7 +198,7 @@ func buildPurgedByLog(perOrderVolumeKeys [][]domain.VolumeKey, purged map[purged // applyEphemeralPurge deletes purged volumes from 0xF1 then zeroes the cache. // Deleting saves storage; the cache is zeroed (rather than deleted) so any -// co-batched proposal admitted with CacheGuaranteed still sees a populated +// co-batched proposal admitted with CacheHit still sees a populated // entry. func (b *WriteSet) applyEphemeralPurge( batch *dal.WriteSession, diff --git a/internal/infra/state/write_set_test.go b/internal/infra/state/write_set_test.go index 597caba3c..dcb226cfa 100644 --- a/internal/infra/state/write_set_test.go +++ b/internal/infra/state/write_set_test.go @@ -125,7 +125,7 @@ func TestWriteSetDeleteAccountMetadata(t *testing.T) { require.NoError(t, err) require.NotNil(t, val) - buf.AccountMetadata().Delete(key) + require.NoError(t, buf.AccountMetadata().Delete(key)) // After delete the key reads as absent (ErrNotFound), like a committed tombstone. _, err = buf.AccountMetadata().Get(key) @@ -688,7 +688,7 @@ func TestWriteSetPreparedQueryDeletePersistsThroughMerge(t *testing.T) { // Proposal 2: delete it. buf2 := NewWriteSet(machine) buf2.Reset(&commonpb.Timestamp{Data: 1700000001}) - buf2.PreparedQueries().Delete(domain.PreparedQueryKey{LedgerName: ledger, Name: "pq-del"}) + require.NoError(t, buf2.PreparedQueries().Delete(domain.PreparedQueryKey{LedgerName: ledger, Name: "pq-del"})) batch2 := dataStore.OpenWriteSession() require.NoError(t, buf2.Merge(batch2, nil)) require.NoError(t, batch2.Commit()) @@ -840,7 +840,7 @@ func TestValidateTransientVolumesListsAllOffendersSorted(t *testing.T) { // Coverage: declare each ledger key (Ledgers().Get) and each volume key // (CheckCoverage before the base-volume read). - attrPlans := make([]*raftcmdpb.AttributePlan, 0, len(ledgers)+len(offenders)) + attrPlans := make([]*raftcmdpb.AttributeCoverage, 0, len(ledgers)+len(offenders)) for _, li := range ledgers { lid, _ := attributes.MakeKey((&domain.LedgerKey{Name: li.GetName()}).Bytes()) attrPlans = append(attrPlans, declareTestPlan(lid, dal.SubAttrLedger)) @@ -919,7 +919,7 @@ func TestValidateTransientVolumesStorageFaultTakesPrecedence(t *testing.T) { // fails inside ValidateTransientVolumes. lid, _ := attributes.MakeKey((&domain.LedgerKey{Name: ledger.GetName()}).Bytes()) vid, _ := attributes.MakeKey(businessOffender.Bytes()) - attrPlans := []*raftcmdpb.AttributePlan{ + attrPlans := []*raftcmdpb.AttributeCoverage{ declareTestPlan(lid, dal.SubAttrLedger), declareTestPlan(vid, dal.SubAttrVolume), } diff --git a/internal/pkg/kv/kv.go b/internal/pkg/kv/kv.go index 2e6ad8d96..c248e2e98 100644 --- a/internal/pkg/kv/kv.go +++ b/internal/pkg/kv/kv.go @@ -10,7 +10,12 @@ type KV[K any, V any] interface { // GetAndPut atomically reads the old value and writes the new one. // Returns the old value and whether it existed. GetAndPut(K, V) (V, bool) - Del(K) + // Del removes K from the store. Returns an error when the implementation + // can detect the key is genuinely absent (e.g. the dual-generation + // AttributeCache returns domain.ErrNotFound when both Gen0 and Gen1 miss, + // so callers can treat delete-on-absent as an idempotent no-op). + // Simple implementations (ShardedMap) always return nil. + Del(K) error Size() uint64 Iter() iter.Seq2[K, V] } diff --git a/internal/pkg/kv/sharded_map.go b/internal/pkg/kv/sharded_map.go index 50f5d78a1..c2d8b28c2 100644 --- a/internal/pkg/kv/sharded_map.go +++ b/internal/pkg/kv/sharded_map.go @@ -78,7 +78,9 @@ func (s *ShardedMap[K, V]) GetAndPut(k K, v V) (old V, existed bool) { return old, existed } -func (s *ShardedMap[K, V]) Del(k K) { +// Del removes k. ShardedMap has no notion of a primary store, so the contract +// violation that kv.KV.Del documents can never fire here — always returns nil. +func (s *ShardedMap[K, V]) Del(k K) error { sh := s.shard(k) sh.mu.Lock() @@ -91,6 +93,8 @@ func (s *ShardedMap[K, V]) Del(k K) { if exists { s.size.Add(-1) } + + return nil } func (s *ShardedMap[K, V]) Size() uint64 { diff --git a/internal/pkg/kv/sharded_map_test.go b/internal/pkg/kv/sharded_map_test.go index 38a50473d..ada62f489 100644 --- a/internal/pkg/kv/sharded_map_test.go +++ b/internal/pkg/kv/sharded_map_test.go @@ -39,38 +39,42 @@ func TestShardedMap_GetMissing(t *testing.T) { require.False(t, ok) } -func TestShardedMap_Del(t *testing.T) { +func TestShardedMap_Size(t *testing.T) { t.Parallel() sm := NewShardedMap[string, int](stringHash) + require.Equal(t, uint64(0), sm.Size()) + sm.Put("a", 1) - sm.Del("a") + sm.Put("b", 2) + sm.Put("c", 3) + require.Equal(t, uint64(3), sm.Size()) - _, ok := sm.Get("a") - require.False(t, ok) + require.NoError(t, sm.Del("b")) + require.Equal(t, uint64(2), sm.Size()) } -func TestShardedMap_DelMissing(t *testing.T) { +func TestShardedMap_Del(t *testing.T) { t.Parallel() sm := NewShardedMap[string, int](stringHash) - sm.Del("missing") - require.Equal(t, uint64(0), sm.Size()) + sm.Put("a", 1) + + require.NoError(t, sm.Del("a"), + "ShardedMap has no primary/secondary store — Del always returns nil") + + _, ok := sm.Get("a") + require.False(t, ok) } -func TestShardedMap_Size(t *testing.T) { +func TestShardedMap_DelMissing(t *testing.T) { t.Parallel() sm := NewShardedMap[string, int](stringHash) - require.Equal(t, uint64(0), sm.Size()) - sm.Put("a", 1) - sm.Put("b", 2) - sm.Put("c", 3) - require.Equal(t, uint64(3), sm.Size()) - - sm.Del("b") - require.Equal(t, uint64(2), sm.Size()) + require.NoError(t, sm.Del("missing"), + "Del on an absent key is an idempotent no-op — returns nil") + require.Equal(t, uint64(0), sm.Size()) } func TestShardedMap_PutOverwrite(t *testing.T) { diff --git a/internal/proto/commonpb/common.pb.go b/internal/proto/commonpb/common.pb.go index 3a0149368..14c5661db 100644 --- a/internal/proto/commonpb/common.pb.go +++ b/internal/proto/commonpb/common.pb.go @@ -7541,7 +7541,14 @@ type TransactionState struct { // Effective timestamp of the transaction (user-provided or command date at // creation). Required for at_effective_date reverts to be deterministic // without re-reading the original log from Pebble. - Timestamp *Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + Timestamp *Timestamp `protobuf:"bytes,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + // Postings captured at creation. Populated by processCreateTransaction (and + // the mirror equivalent) so a later revert reads them from the FSM cache + // without needing to scan the Pebble-backed log — invariant #3 (no Pebble + // reads on the hot path) and invariant #8 (business decisions belong in + // the audit chain, so a revert of a non-existent tx must reach the FSM + // apply loop rather than fail-fast at admission). + Postings []*Posting `protobuf:"bytes,5,rep,name=postings,proto3" json:"postings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -7604,6 +7611,13 @@ func (x *TransactionState) GetTimestamp() *Timestamp { return nil } +func (x *TransactionState) GetPostings() []*Posting { + if x != nil { + return x.Postings + } + return nil +} + // IdempotencyKeyValue stores the outcome associated with a proposal's // idempotency key: the committed logs on success, or a replayed failure when // the first apply was a definitive business rejection (exactly-once: every @@ -11187,12 +11201,13 @@ const file_common_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2\x15.common.MetadataValueR\x05value:\x028\x01\"Q\n" + "\x15DeleteMetadataCommand\x12&\n" + "\x06target\x18\x01 \x01(\v2\x0e.common.TargetR\x06target\x12\x10\n" + - "\x03key\x18\x02 \x01(\tR\x03key\"\xb9\x02\n" + + "\x03key\x18\x02 \x01(\tR\x03key\"\xe6\x02\n" + "\x10TransactionState\x12$\n" + "\x0ecreated_by_log\x18\x01 \x01(\x06R\fcreatedByLog\x126\n" + "\x17reverted_by_transaction\x18\x02 \x01(\x06R\x15revertedByTransaction\x12B\n" + "\bmetadata\x18\x03 \x03(\v2&.common.TransactionState.MetadataEntryR\bmetadata\x12/\n" + - "\ttimestamp\x18\x04 \x01(\v2\x11.common.TimestampR\ttimestamp\x1aR\n" + + "\ttimestamp\x18\x04 \x01(\v2\x11.common.TimestampR\ttimestamp\x12+\n" + + "\bpostings\x18\x05 \x03(\v2\x0f.common.PostingR\bpostings\x1aR\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12+\n" + "\x05value\x18\x02 \x01(\v2\x15.common.MetadataValueR\x05value:\x028\x01\"\xec\x01\n" + @@ -11891,81 +11906,82 @@ var file_common_proto_depIdxs = []int32{ 32, // 156: common.DeleteMetadataCommand.target:type_name -> common.Target 170, // 157: common.TransactionState.metadata:type_name -> common.TransactionState.MetadataEntry 17, // 158: common.TransactionState.timestamp:type_name -> common.Timestamp - 110, // 159: common.IdempotencyKeyValue.failure:type_name -> common.IdempotencyFailure - 11, // 160: common.IdempotencyFailure.reason:type_name -> common.ErrorReason - 171, // 161: common.IdempotencyFailure.metadata:type_name -> common.IdempotencyFailure.MetadataEntry - 114, // 162: common.SegmentType.uuid:type_name -> common.UUIDConstraint - 115, // 163: common.SegmentType.uint64:type_name -> common.Uint64Constraint - 116, // 164: common.SegmentType.bytes:type_name -> common.BytesConstraint - 13, // 165: common.AccountType.persistence:type_name -> common.AccountTypePersistence - 172, // 166: common.AccountType.segment_types:type_name -> common.AccountType.SegmentTypesEntry - 117, // 167: common.AddedAccountTypeLog.account_type:type_name -> common.AccountType - 12, // 168: common.UpdatedDefaultEnforcementModeLog.enforcement_mode:type_name -> common.ChartEnforcementMode - 132, // 169: common.QueryFilter.field:type_name -> common.FieldCondition - 138, // 170: common.QueryFilter.address:type_name -> common.AddressMatch - 128, // 171: common.QueryFilter.and:type_name -> common.AndFilter - 129, // 172: common.QueryFilter.or:type_name -> common.OrFilter - 130, // 173: common.QueryFilter.not:type_name -> common.NotFilter - 122, // 174: common.QueryFilter.reference:type_name -> common.ReferenceCondition - 125, // 175: common.QueryFilter.builtin_uint:type_name -> common.BuiltinUintCondition - 123, // 176: common.QueryFilter.ledger:type_name -> common.LedgerCondition - 124, // 177: common.QueryFilter.log_id:type_name -> common.LogIdCondition - 126, // 178: common.QueryFilter.log_builtin_uint:type_name -> common.LogBuiltinUintCondition - 127, // 179: common.QueryFilter.account_has_asset:type_name -> common.AccountHasAssetCondition - 133, // 180: common.ReferenceCondition.cond:type_name -> common.StringCondition - 133, // 181: common.LedgerCondition.cond:type_name -> common.StringCondition - 135, // 182: common.LogIdCondition.cond:type_name -> common.UintCondition - 3, // 183: common.BuiltinUintCondition.field:type_name -> common.TransactionBuiltinIndex - 135, // 184: common.BuiltinUintCondition.cond:type_name -> common.UintCondition - 5, // 185: common.LogBuiltinUintCondition.field:type_name -> common.LogBuiltinIndex - 135, // 186: common.LogBuiltinUintCondition.cond:type_name -> common.UintCondition - 121, // 187: common.AndFilter.filters:type_name -> common.QueryFilter - 121, // 188: common.OrFilter.filters:type_name -> common.QueryFilter - 121, // 189: common.NotFilter.filter:type_name -> common.QueryFilter - 131, // 190: common.FieldCondition.field:type_name -> common.FieldRef - 133, // 191: common.FieldCondition.string_cond:type_name -> common.StringCondition - 134, // 192: common.FieldCondition.int_cond:type_name -> common.IntCondition - 135, // 193: common.FieldCondition.uint_cond:type_name -> common.UintCondition - 136, // 194: common.FieldCondition.bool_cond:type_name -> common.BoolCondition - 137, // 195: common.FieldCondition.exists_cond:type_name -> common.ExistsCondition - 14, // 196: common.AddressMatch.role:type_name -> common.AddressRole - 121, // 197: common.PreparedQuery.filter:type_name -> common.QueryFilter - 15, // 198: common.PreparedQuery.target:type_name -> common.QueryTarget - 22, // 199: common.AggregatedVolume.input:type_name -> common.Uint256 - 22, // 200: common.AggregatedVolume.output:type_name -> common.Uint256 - 140, // 201: common.AggregateResult.volumes:type_name -> common.AggregatedVolume - 142, // 202: common.AggregateResult.groups:type_name -> common.GroupedAggregateResult - 140, // 203: common.GroupedAggregateResult.volumes:type_name -> common.AggregatedVolume - 30, // 204: common.PreparedQueryCursor.account_data:type_name -> common.Account - 24, // 205: common.PreparedQueryCursor.transaction_data:type_name -> common.Transaction - 146, // 206: common.CallerSnapshot.identity:type_name -> common.CallerIdentity - 148, // 207: common.BackupStorage.s3:type_name -> common.S3StorageConfig - 149, // 208: common.BackupStorage.azure:type_name -> common.AzureStorageConfig - 151, // 209: common.ListOptions.read:type_name -> common.ReadOptions - 121, // 210: common.ListOptions.filter:type_name -> common.QueryFilter - 19, // 211: common.MetadataMap.ValuesEntry.value:type_name -> common.MetadataValue - 19, // 212: common.Transaction.MetadataEntry.value:type_name -> common.MetadataValue - 26, // 213: common.VolumesByAssets.VolumesEntry.value:type_name -> common.Volumes - 28, // 214: common.PostCommitVolumes.VolumesByAccountEntry.value:type_name -> common.VolumesByAssets - 19, // 215: common.Account.MetadataEntry.value:type_name -> common.MetadataValue - 27, // 216: common.Account.VolumesEntry.value:type_name -> common.VolumesWithBalance - 33, // 217: common.MetadataSchema.AccountFieldsEntry.value:type_name -> common.MetadataFieldSchema - 33, // 218: common.MetadataSchema.TransactionFieldsEntry.value:type_name -> common.MetadataFieldSchema - 33, // 219: common.MetadataSchema.LedgerFieldsEntry.value:type_name -> common.MetadataFieldSchema - 19, // 220: common.SavedLedgerMetadataLog.MetadataEntry.value:type_name -> common.MetadataValue - 117, // 221: common.CreatedLedgerLog.AccountTypesEntry.value:type_name -> common.AccountType - 20, // 222: common.CreatedTransaction.AccountMetadataEntry.value:type_name -> common.MetadataMap - 19, // 223: common.SavedMetadata.MetadataEntry.value:type_name -> common.MetadataValue - 117, // 224: common.LedgerInfo.AccountTypesEntry.value:type_name -> common.AccountType - 19, // 225: common.LedgerInfo.MetadataEntry.value:type_name -> common.MetadataValue - 19, // 226: common.SaveMetadataCommand.MetadataEntry.value:type_name -> common.MetadataValue - 19, // 227: common.TransactionState.MetadataEntry.value:type_name -> common.MetadataValue - 113, // 228: common.AccountType.SegmentTypesEntry.value:type_name -> common.SegmentType - 229, // [229:229] is the sub-list for method output_type - 229, // [229:229] is the sub-list for method input_type - 229, // [229:229] is the sub-list for extension type_name - 229, // [229:229] is the sub-list for extension extendee - 0, // [0:229] is the sub-list for field type_name + 23, // 159: common.TransactionState.postings:type_name -> common.Posting + 110, // 160: common.IdempotencyKeyValue.failure:type_name -> common.IdempotencyFailure + 11, // 161: common.IdempotencyFailure.reason:type_name -> common.ErrorReason + 171, // 162: common.IdempotencyFailure.metadata:type_name -> common.IdempotencyFailure.MetadataEntry + 114, // 163: common.SegmentType.uuid:type_name -> common.UUIDConstraint + 115, // 164: common.SegmentType.uint64:type_name -> common.Uint64Constraint + 116, // 165: common.SegmentType.bytes:type_name -> common.BytesConstraint + 13, // 166: common.AccountType.persistence:type_name -> common.AccountTypePersistence + 172, // 167: common.AccountType.segment_types:type_name -> common.AccountType.SegmentTypesEntry + 117, // 168: common.AddedAccountTypeLog.account_type:type_name -> common.AccountType + 12, // 169: common.UpdatedDefaultEnforcementModeLog.enforcement_mode:type_name -> common.ChartEnforcementMode + 132, // 170: common.QueryFilter.field:type_name -> common.FieldCondition + 138, // 171: common.QueryFilter.address:type_name -> common.AddressMatch + 128, // 172: common.QueryFilter.and:type_name -> common.AndFilter + 129, // 173: common.QueryFilter.or:type_name -> common.OrFilter + 130, // 174: common.QueryFilter.not:type_name -> common.NotFilter + 122, // 175: common.QueryFilter.reference:type_name -> common.ReferenceCondition + 125, // 176: common.QueryFilter.builtin_uint:type_name -> common.BuiltinUintCondition + 123, // 177: common.QueryFilter.ledger:type_name -> common.LedgerCondition + 124, // 178: common.QueryFilter.log_id:type_name -> common.LogIdCondition + 126, // 179: common.QueryFilter.log_builtin_uint:type_name -> common.LogBuiltinUintCondition + 127, // 180: common.QueryFilter.account_has_asset:type_name -> common.AccountHasAssetCondition + 133, // 181: common.ReferenceCondition.cond:type_name -> common.StringCondition + 133, // 182: common.LedgerCondition.cond:type_name -> common.StringCondition + 135, // 183: common.LogIdCondition.cond:type_name -> common.UintCondition + 3, // 184: common.BuiltinUintCondition.field:type_name -> common.TransactionBuiltinIndex + 135, // 185: common.BuiltinUintCondition.cond:type_name -> common.UintCondition + 5, // 186: common.LogBuiltinUintCondition.field:type_name -> common.LogBuiltinIndex + 135, // 187: common.LogBuiltinUintCondition.cond:type_name -> common.UintCondition + 121, // 188: common.AndFilter.filters:type_name -> common.QueryFilter + 121, // 189: common.OrFilter.filters:type_name -> common.QueryFilter + 121, // 190: common.NotFilter.filter:type_name -> common.QueryFilter + 131, // 191: common.FieldCondition.field:type_name -> common.FieldRef + 133, // 192: common.FieldCondition.string_cond:type_name -> common.StringCondition + 134, // 193: common.FieldCondition.int_cond:type_name -> common.IntCondition + 135, // 194: common.FieldCondition.uint_cond:type_name -> common.UintCondition + 136, // 195: common.FieldCondition.bool_cond:type_name -> common.BoolCondition + 137, // 196: common.FieldCondition.exists_cond:type_name -> common.ExistsCondition + 14, // 197: common.AddressMatch.role:type_name -> common.AddressRole + 121, // 198: common.PreparedQuery.filter:type_name -> common.QueryFilter + 15, // 199: common.PreparedQuery.target:type_name -> common.QueryTarget + 22, // 200: common.AggregatedVolume.input:type_name -> common.Uint256 + 22, // 201: common.AggregatedVolume.output:type_name -> common.Uint256 + 140, // 202: common.AggregateResult.volumes:type_name -> common.AggregatedVolume + 142, // 203: common.AggregateResult.groups:type_name -> common.GroupedAggregateResult + 140, // 204: common.GroupedAggregateResult.volumes:type_name -> common.AggregatedVolume + 30, // 205: common.PreparedQueryCursor.account_data:type_name -> common.Account + 24, // 206: common.PreparedQueryCursor.transaction_data:type_name -> common.Transaction + 146, // 207: common.CallerSnapshot.identity:type_name -> common.CallerIdentity + 148, // 208: common.BackupStorage.s3:type_name -> common.S3StorageConfig + 149, // 209: common.BackupStorage.azure:type_name -> common.AzureStorageConfig + 151, // 210: common.ListOptions.read:type_name -> common.ReadOptions + 121, // 211: common.ListOptions.filter:type_name -> common.QueryFilter + 19, // 212: common.MetadataMap.ValuesEntry.value:type_name -> common.MetadataValue + 19, // 213: common.Transaction.MetadataEntry.value:type_name -> common.MetadataValue + 26, // 214: common.VolumesByAssets.VolumesEntry.value:type_name -> common.Volumes + 28, // 215: common.PostCommitVolumes.VolumesByAccountEntry.value:type_name -> common.VolumesByAssets + 19, // 216: common.Account.MetadataEntry.value:type_name -> common.MetadataValue + 27, // 217: common.Account.VolumesEntry.value:type_name -> common.VolumesWithBalance + 33, // 218: common.MetadataSchema.AccountFieldsEntry.value:type_name -> common.MetadataFieldSchema + 33, // 219: common.MetadataSchema.TransactionFieldsEntry.value:type_name -> common.MetadataFieldSchema + 33, // 220: common.MetadataSchema.LedgerFieldsEntry.value:type_name -> common.MetadataFieldSchema + 19, // 221: common.SavedLedgerMetadataLog.MetadataEntry.value:type_name -> common.MetadataValue + 117, // 222: common.CreatedLedgerLog.AccountTypesEntry.value:type_name -> common.AccountType + 20, // 223: common.CreatedTransaction.AccountMetadataEntry.value:type_name -> common.MetadataMap + 19, // 224: common.SavedMetadata.MetadataEntry.value:type_name -> common.MetadataValue + 117, // 225: common.LedgerInfo.AccountTypesEntry.value:type_name -> common.AccountType + 19, // 226: common.LedgerInfo.MetadataEntry.value:type_name -> common.MetadataValue + 19, // 227: common.SaveMetadataCommand.MetadataEntry.value:type_name -> common.MetadataValue + 19, // 228: common.TransactionState.MetadataEntry.value:type_name -> common.MetadataValue + 113, // 229: common.AccountType.SegmentTypesEntry.value:type_name -> common.SegmentType + 230, // [230:230] is the sub-list for method output_type + 230, // [230:230] is the sub-list for method input_type + 230, // [230:230] is the sub-list for extension type_name + 230, // [230:230] is the sub-list for extension extendee + 0, // [0:230] is the sub-list for field type_name } func init() { file_common_proto_init() } diff --git a/internal/proto/commonpb/common_dethash.pb.go b/internal/proto/commonpb/common_dethash.pb.go index 13ee21dab..0cca1fe86 100644 --- a/internal/proto/commonpb/common_dethash.pb.go +++ b/internal/proto/commonpb/common_dethash.pb.go @@ -2400,6 +2400,15 @@ func (m *TransactionState) MarshalToSizedBufferDeterministicVT(dAtA []byte) (int i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.Postings) > 0 { + for iNdEx := len(m.Postings) - 1; iNdEx >= 0; iNdEx-- { + size, _ := m.Postings[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x2a + } + } if m.Timestamp != nil { size, _ := m.Timestamp.MarshalToSizedBufferVT(dAtA[:i]) i -= size diff --git a/internal/proto/commonpb/common_reader.pb.go b/internal/proto/commonpb/common_reader.pb.go index e9411c28e..ad40e30d6 100644 --- a/internal/proto/commonpb/common_reader.pb.go +++ b/internal/proto/commonpb/common_reader.pb.go @@ -7657,6 +7657,7 @@ type TransactionStateReader interface { GetRevertedByTransaction() uint64 GetMetadata() TransactionState_MetadataMapReader GetTimestamp() TimestampReader + GetPostings() PostingListReader Mutate() *TransactionState } @@ -7682,6 +7683,10 @@ func (r *transactionStateReadonly) GetTimestamp() TimestampReader { return v.AsReader() } +func (r *transactionStateReadonly) GetPostings() PostingListReader { + return NewPostingListReader(r.v.GetPostings()) +} + func (r *transactionStateReadonly) Mutate() *TransactionState { return r.v.CloneVT() } diff --git a/internal/proto/commonpb/common_vtproto.pb.go b/internal/proto/commonpb/common_vtproto.pb.go index 5548f4662..42060a97f 100644 --- a/internal/proto/commonpb/common_vtproto.pb.go +++ b/internal/proto/commonpb/common_vtproto.pb.go @@ -2503,6 +2503,13 @@ func (m *TransactionState) CloneVT() *TransactionState { } r.Metadata = tmpContainer } + if rhs := m.Postings; rhs != nil { + tmpContainer := make([]*Posting, len(rhs)) + for k, v := range rhs { + tmpContainer[k] = v.CloneVT() + } + r.Postings = tmpContainer + } if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -7847,6 +7854,23 @@ func (this *TransactionState) EqualVT(that *TransactionState) bool { if !this.Timestamp.EqualVT(that.Timestamp) { return false } + if len(this.Postings) != len(that.Postings) { + return false + } + for i, vx := range this.Postings { + vy := that.Postings[i] + if p, q := vx, vy; p != q { + if p == nil { + p = &Posting{} + } + if q == nil { + q = &Posting{} + } + if !p.EqualVT(q) { + return false + } + } + } return string(this.unknownFields) == string(that.unknownFields) } @@ -16148,6 +16172,18 @@ func (m *TransactionState) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } + if len(m.Postings) > 0 { + for iNdEx := len(m.Postings) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Postings[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x2a + } + } if m.Timestamp != nil { size, err := m.Timestamp.MarshalToSizedBufferVT(dAtA[:i]) if err != nil { @@ -21781,6 +21817,12 @@ func (m *TransactionState) SizeVT() (n int) { l = m.Timestamp.SizeVT() n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } + if len(m.Postings) > 0 { + for _, e := range m.Postings { + l = e.SizeVT() + n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) + } + } n += len(m.unknownFields) return n } @@ -38824,6 +38866,40 @@ func (m *TransactionState) UnmarshalVT(dAtA []byte) error { return err } iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Postings", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return protohelpers.ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return protohelpers.ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return protohelpers.ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Postings = append(m.Postings, &Posting{}) + if err := m.Postings[len(m.Postings)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/internal/proto/raftcmdpb/raft_cmd.pb.go b/internal/proto/raftcmdpb/raft_cmd.pb.go index 760848167..e511ebaa5 100644 --- a/internal/proto/raftcmdpb/raft_cmd.pb.go +++ b/internal/proto/raftcmdpb/raft_cmd.pb.go @@ -5061,17 +5061,18 @@ type ExecutionPlan struct { LastPersistedIndex uint64 `protobuf:"fixed64,1,opt,name=lastPersistedIndex,proto3" json:"lastPersistedIndex,omitempty"` // Cache epoch at admission time; FSM rejects on mismatch (cache was reset) CacheEpoch uint64 `protobuf:"fixed64,4,opt,name=cache_epoch,json=cacheEpoch,proto3" json:"cache_epoch,omitempty"` - // Per-attribute plans produced by admission. Each entry is either a - // value preload, a Gen1→Gen0 promotion, or a pure coverage declaration — - // every plan also feeds the FSM-side state.Plan coverage set, so the - // admission layer is the single source of truth for what the FSM may - // read during apply. - Attributes []*AttributePlan `protobuf:"bytes,6,rep,name=attributes,proto3" json:"attributes,omitempty"` + // Coverage entries produced by admission. Each entry declares that the + // FSM apply path may access the key; entries with a `value` field set + // additionally seed the FSM cache with the Pebble-loaded value at + // admission time. Every entry feeds the FSM-side state.Plan coverage + // set, so the admission layer is the single source of truth for what + // the FSM may read (and delete) during apply. + Attributes []*AttributeCoverage `protobuf:"bytes,6,rep,name=attributes,proto3" json:"attributes,omitempty"` // Idempotency keys preloaded for this proposal. They live outside the - // AttributePlan list because they are NOT a cache attribute: the FSM - // applies them to the dedicated IdempotencyStore and the state.Plan + // AttributeCoverage list because they are NOT a cache attribute: the + // FSM applies them to the dedicated IdempotencyStore and the state.Plan // does not track their coverage. Kept as a top-level field instead of - // an AttributePlan variant to make that distinction visible at the + // an AttributeCoverage variant to make that distinction visible at the // wire level. IdempotencyKeys []*ReloadIdempotencyKey `protobuf:"bytes,7,rep,name=idempotency_keys,json=idempotencyKeys,proto3" json:"idempotency_keys,omitempty"` unknownFields protoimpl.UnknownFields @@ -5122,7 +5123,7 @@ func (x *ExecutionPlan) GetCacheEpoch() uint64 { return 0 } -func (x *ExecutionPlan) GetAttributes() []*AttributePlan { +func (x *ExecutionPlan) GetAttributes() []*AttributeCoverage { if x != nil { return x.Attributes } @@ -5136,41 +5137,38 @@ func (x *ExecutionPlan) GetIdempotencyKeys() []*ReloadIdempotencyKey { return nil } -// AttributePlan describes what the FSM must do with a single attribute key -// on apply. attr_code (dal.SubAttrXxx) lives on the plan itself, not on -// the intent, so the kind/code can be read uniformly without inspecting -// the oneof — keeps coverage indexing, dispatch, and unmarshal routing -// all on the same field. Exactly one of declare/touch/value is set; that -// choice tells the cache-mutation path (Touch promotes, AttributeValue -// writes, Declare is a no-op) what to do. -type AttributePlan struct { +// AttributeCoverage declares the FSM apply path may access `id` under +// `attr_code`. When `value` is set, the FSM's MirrorPreload seeds the +// cache with it (CacheMiss + Pebble-load-hit at admission); when unset, +// the entry is coverage-only and Preload skips it — AttributeCache.Get's +// gen0→gen1 fallback and AttributeCache.Del's lazy Gen0-tombstone +// fabrication handle reads and deletes without a preemptive promote pass. +// attr_code (dal.SubAttrXxx) lives on the envelope so coverage indexing, +// dispatch, and unmarshal routing all read the same field. +type AttributeCoverage struct { state protoimpl.MessageState `protogen:"open.v1"` Id *AttributeID `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` AttrCode uint32 `protobuf:"varint,2,opt,name=attr_code,json=attrCode,proto3" json:"attr_code,omitempty"` - // Types that are valid to be assigned to Intent: - // - // *AttributePlan_Declare - // *AttributePlan_Touch - // *AttributePlan_Value - Intent isAttributePlan_Intent `protobuf_oneof:"intent"` + // Optional seed: nil = coverage-only; non-nil = seed into the FSM cache. + Value *AttributeValue `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *AttributePlan) Reset() { - *x = AttributePlan{} +func (x *AttributeCoverage) Reset() { + *x = AttributeCoverage{} mi := &file_raft_cmd_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *AttributePlan) String() string { +func (x *AttributeCoverage) String() string { return protoimpl.X.MessageStringOf(x) } -func (*AttributePlan) ProtoMessage() {} +func (*AttributeCoverage) ProtoMessage() {} -func (x *AttributePlan) ProtoReflect() protoreflect.Message { +func (x *AttributeCoverage) ProtoReflect() protoreflect.Message { mi := &file_raft_cmd_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -5182,169 +5180,37 @@ func (x *AttributePlan) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use AttributePlan.ProtoReflect.Descriptor instead. -func (*AttributePlan) Descriptor() ([]byte, []int) { +// Deprecated: Use AttributeCoverage.ProtoReflect.Descriptor instead. +func (*AttributeCoverage) Descriptor() ([]byte, []int) { return file_raft_cmd_proto_rawDescGZIP(), []int{68} } -func (x *AttributePlan) GetId() *AttributeID { +func (x *AttributeCoverage) GetId() *AttributeID { if x != nil { return x.Id } return nil } -func (x *AttributePlan) GetAttrCode() uint32 { +func (x *AttributeCoverage) GetAttrCode() uint32 { if x != nil { return x.AttrCode } return 0 } -func (x *AttributePlan) GetIntent() isAttributePlan_Intent { - if x != nil { - return x.Intent - } - return nil -} - -func (x *AttributePlan) GetDeclare() *Declare { +func (x *AttributeCoverage) GetValue() *AttributeValue { if x != nil { - if x, ok := x.Intent.(*AttributePlan_Declare); ok { - return x.Declare - } - } - return nil -} - -func (x *AttributePlan) GetTouch() *Touch { - if x != nil { - if x, ok := x.Intent.(*AttributePlan_Touch); ok { - return x.Touch - } - } - return nil -} - -func (x *AttributePlan) GetValue() *AttributeValue { - if x != nil { - if x, ok := x.Intent.(*AttributePlan_Value); ok { - return x.Value - } + return x.Value } return nil } -type isAttributePlan_Intent interface { - isAttributePlan_Intent() -} - -type AttributePlan_Declare struct { - // Declare: the key was already CacheGuaranteed (Gen0) at admission time. - // No FSM-side cache mutation; pure coverage declaration so state.Plan - // admits reads on hot keys without a false-positive miss. - Declare *Declare `protobuf:"bytes,3,opt,name=declare,proto3,oneof"` -} - -type AttributePlan_Touch struct { - // Touch: the key is in Gen1 and would not survive the next rotation. - // The FSM mirrors the promotion via MirrorTouch. - Touch *Touch `protobuf:"bytes,4,opt,name=touch,proto3,oneof"` -} - -type AttributePlan_Value struct { - // AttributeValue: load the value into Gen0 cache. Carries the typed value. - Value *AttributeValue `protobuf:"bytes,5,opt,name=value,proto3,oneof"` -} - -func (*AttributePlan_Declare) isAttributePlan_Intent() {} - -func (*AttributePlan_Touch) isAttributePlan_Intent() {} - -func (*AttributePlan_Value) isAttributePlan_Intent() {} - -// Declare is the sentinel intent for AttributePlan. Empty by design — the -// presence of the field is the entire signal. -type Declare struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Declare) Reset() { - *x = Declare{} - mi := &file_raft_cmd_proto_msgTypes[69] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Declare) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Declare) ProtoMessage() {} - -func (x *Declare) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[69] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Declare.ProtoReflect.Descriptor instead. -func (*Declare) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{69} -} - -// Touch is the sentinel intent for AttributePlan. Empty by design — the -// presence of the field plus AttributePlan.attr_code drive the Gen1→Gen0 -// promotion on the FSM side. -type Touch struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Touch) Reset() { - *x = Touch{} - mi := &file_raft_cmd_proto_msgTypes[70] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Touch) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Touch) ProtoMessage() {} - -func (x *Touch) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[70] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Touch.ProtoReflect.Descriptor instead. -func (*Touch) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{70} -} - -// AttributeValue carries the typed value to write into the FSM-side cache -// for one AttributePlan. The value is type-erased on the wire (a vtproto- -// marshaled blob) and the FSM dispatches the typed unmarshal via the -// parent AttributePlan.attr_code (dal.SubAttrXxx) — kept off this message -// to avoid carrying the kind in two places. +// AttributeValue carries the typed value to seed the FSM-side cache for +// one AttributeCoverage entry. The value is type-erased on the wire (a +// vtproto-marshaled blob) and the FSM dispatches the typed unmarshal via +// the parent AttributeCoverage.attr_code (dal.SubAttrXxx) — kept off +// this message to avoid carrying the kind in two places. type AttributeValue struct { state protoimpl.MessageState `protogen:"open.v1"` RawValue []byte `protobuf:"bytes,1,opt,name=raw_value,json=rawValue,proto3" json:"raw_value,omitempty"` @@ -5354,7 +5220,7 @@ type AttributeValue struct { func (x *AttributeValue) Reset() { *x = AttributeValue{} - mi := &file_raft_cmd_proto_msgTypes[71] + mi := &file_raft_cmd_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5366,7 +5232,7 @@ func (x *AttributeValue) String() string { func (*AttributeValue) ProtoMessage() {} func (x *AttributeValue) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[71] + mi := &file_raft_cmd_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5379,7 +5245,7 @@ func (x *AttributeValue) ProtoReflect() protoreflect.Message { // Deprecated: Use AttributeValue.ProtoReflect.Descriptor instead. func (*AttributeValue) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{71} + return file_raft_cmd_proto_rawDescGZIP(), []int{69} } func (x *AttributeValue) GetRawValue() []byte { @@ -5404,7 +5270,7 @@ type ReloadIdempotencyKey struct { func (x *ReloadIdempotencyKey) Reset() { *x = ReloadIdempotencyKey{} - mi := &file_raft_cmd_proto_msgTypes[72] + mi := &file_raft_cmd_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5416,7 +5282,7 @@ func (x *ReloadIdempotencyKey) String() string { func (*ReloadIdempotencyKey) ProtoMessage() {} func (x *ReloadIdempotencyKey) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[72] + mi := &file_raft_cmd_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5429,7 +5295,7 @@ func (x *ReloadIdempotencyKey) ProtoReflect() protoreflect.Message { // Deprecated: Use ReloadIdempotencyKey.ProtoReflect.Descriptor instead. func (*ReloadIdempotencyKey) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{72} + return file_raft_cmd_proto_rawDescGZIP(), []int{70} } func (x *ReloadIdempotencyKey) GetKey() string { @@ -5456,7 +5322,7 @@ type CacheGenerationMeta struct { func (x *CacheGenerationMeta) Reset() { *x = CacheGenerationMeta{} - mi := &file_raft_cmd_proto_msgTypes[73] + mi := &file_raft_cmd_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5468,7 +5334,7 @@ func (x *CacheGenerationMeta) String() string { func (*CacheGenerationMeta) ProtoMessage() {} func (x *CacheGenerationMeta) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[73] + mi := &file_raft_cmd_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5481,7 +5347,7 @@ func (x *CacheGenerationMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use CacheGenerationMeta.ProtoReflect.Descriptor instead. func (*CacheGenerationMeta) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{73} + return file_raft_cmd_proto_rawDescGZIP(), []int{71} } func (x *CacheGenerationMeta) GetBaseIndex() uint64 { @@ -5501,7 +5367,7 @@ type CacheSnapshotMeta struct { func (x *CacheSnapshotMeta) Reset() { *x = CacheSnapshotMeta{} - mi := &file_raft_cmd_proto_msgTypes[74] + mi := &file_raft_cmd_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5513,7 +5379,7 @@ func (x *CacheSnapshotMeta) String() string { func (*CacheSnapshotMeta) ProtoMessage() {} func (x *CacheSnapshotMeta) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[74] + mi := &file_raft_cmd_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5526,7 +5392,7 @@ func (x *CacheSnapshotMeta) ProtoReflect() protoreflect.Message { // Deprecated: Use CacheSnapshotMeta.ProtoReflect.Descriptor instead. func (*CacheSnapshotMeta) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{74} + return file_raft_cmd_proto_rawDescGZIP(), []int{72} } func (x *CacheSnapshotMeta) GetCurrentGeneration() uint64 { @@ -5548,7 +5414,7 @@ type PeerAddress struct { func (x *PeerAddress) Reset() { *x = PeerAddress{} - mi := &file_raft_cmd_proto_msgTypes[75] + mi := &file_raft_cmd_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5560,7 +5426,7 @@ func (x *PeerAddress) String() string { func (*PeerAddress) ProtoMessage() {} func (x *PeerAddress) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[75] + mi := &file_raft_cmd_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5573,7 +5439,7 @@ func (x *PeerAddress) ProtoReflect() protoreflect.Message { // Deprecated: Use PeerAddress.ProtoReflect.Descriptor instead. func (*PeerAddress) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{75} + return file_raft_cmd_proto_rawDescGZIP(), []int{73} } func (x *PeerAddress) GetNodeId() uint64 { @@ -5613,7 +5479,7 @@ type GenerationSnapshot struct { func (x *GenerationSnapshot) Reset() { *x = GenerationSnapshot{} - mi := &file_raft_cmd_proto_msgTypes[76] + mi := &file_raft_cmd_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5625,7 +5491,7 @@ func (x *GenerationSnapshot) String() string { func (*GenerationSnapshot) ProtoMessage() {} func (x *GenerationSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[76] + mi := &file_raft_cmd_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5638,7 +5504,7 @@ func (x *GenerationSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use GenerationSnapshot.ProtoReflect.Descriptor instead. func (*GenerationSnapshot) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{76} + return file_raft_cmd_proto_rawDescGZIP(), []int{74} } func (x *GenerationSnapshot) GetBaseIndex() uint64 { @@ -5709,7 +5575,7 @@ type VolumeAttributeSnapshotEntry struct { func (x *VolumeAttributeSnapshotEntry) Reset() { *x = VolumeAttributeSnapshotEntry{} - mi := &file_raft_cmd_proto_msgTypes[77] + mi := &file_raft_cmd_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5721,7 +5587,7 @@ func (x *VolumeAttributeSnapshotEntry) String() string { func (*VolumeAttributeSnapshotEntry) ProtoMessage() {} func (x *VolumeAttributeSnapshotEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[77] + mi := &file_raft_cmd_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5734,7 +5600,7 @@ func (x *VolumeAttributeSnapshotEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use VolumeAttributeSnapshotEntry.ProtoReflect.Descriptor instead. func (*VolumeAttributeSnapshotEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{77} + return file_raft_cmd_proto_rawDescGZIP(), []int{75} } func (x *VolumeAttributeSnapshotEntry) GetId() *AttributeID { @@ -5769,7 +5635,7 @@ type MetadataAttributeEntry struct { func (x *MetadataAttributeEntry) Reset() { *x = MetadataAttributeEntry{} - mi := &file_raft_cmd_proto_msgTypes[78] + mi := &file_raft_cmd_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5781,7 +5647,7 @@ func (x *MetadataAttributeEntry) String() string { func (*MetadataAttributeEntry) ProtoMessage() {} func (x *MetadataAttributeEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[78] + mi := &file_raft_cmd_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5794,7 +5660,7 @@ func (x *MetadataAttributeEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use MetadataAttributeEntry.ProtoReflect.Descriptor instead. func (*MetadataAttributeEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{78} + return file_raft_cmd_proto_rawDescGZIP(), []int{76} } func (x *MetadataAttributeEntry) GetId() *AttributeID { @@ -5822,7 +5688,7 @@ type LedgerAttributeEntry struct { func (x *LedgerAttributeEntry) Reset() { *x = LedgerAttributeEntry{} - mi := &file_raft_cmd_proto_msgTypes[79] + mi := &file_raft_cmd_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5834,7 +5700,7 @@ func (x *LedgerAttributeEntry) String() string { func (*LedgerAttributeEntry) ProtoMessage() {} func (x *LedgerAttributeEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[79] + mi := &file_raft_cmd_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5847,7 +5713,7 @@ func (x *LedgerAttributeEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use LedgerAttributeEntry.ProtoReflect.Descriptor instead. func (*LedgerAttributeEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{79} + return file_raft_cmd_proto_rawDescGZIP(), []int{77} } func (x *LedgerAttributeEntry) GetId() *AttributeID { @@ -5875,7 +5741,7 @@ type BoundaryAttributeEntry struct { func (x *BoundaryAttributeEntry) Reset() { *x = BoundaryAttributeEntry{} - mi := &file_raft_cmd_proto_msgTypes[80] + mi := &file_raft_cmd_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5887,7 +5753,7 @@ func (x *BoundaryAttributeEntry) String() string { func (*BoundaryAttributeEntry) ProtoMessage() {} func (x *BoundaryAttributeEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[80] + mi := &file_raft_cmd_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5900,7 +5766,7 @@ func (x *BoundaryAttributeEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use BoundaryAttributeEntry.ProtoReflect.Descriptor instead. func (*BoundaryAttributeEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{80} + return file_raft_cmd_proto_rawDescGZIP(), []int{78} } func (x *BoundaryAttributeEntry) GetId() *AttributeID { @@ -5928,7 +5794,7 @@ type TransactionReferenceAttributeEntry struct { func (x *TransactionReferenceAttributeEntry) Reset() { *x = TransactionReferenceAttributeEntry{} - mi := &file_raft_cmd_proto_msgTypes[81] + mi := &file_raft_cmd_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5940,7 +5806,7 @@ func (x *TransactionReferenceAttributeEntry) String() string { func (*TransactionReferenceAttributeEntry) ProtoMessage() {} func (x *TransactionReferenceAttributeEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[81] + mi := &file_raft_cmd_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5953,7 +5819,7 @@ func (x *TransactionReferenceAttributeEntry) ProtoReflect() protoreflect.Message // Deprecated: Use TransactionReferenceAttributeEntry.ProtoReflect.Descriptor instead. func (*TransactionReferenceAttributeEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{81} + return file_raft_cmd_proto_rawDescGZIP(), []int{79} } func (x *TransactionReferenceAttributeEntry) GetId() *AttributeID { @@ -5981,7 +5847,7 @@ type TransactionStateAttributeEntry struct { func (x *TransactionStateAttributeEntry) Reset() { *x = TransactionStateAttributeEntry{} - mi := &file_raft_cmd_proto_msgTypes[82] + mi := &file_raft_cmd_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5993,7 +5859,7 @@ func (x *TransactionStateAttributeEntry) String() string { func (*TransactionStateAttributeEntry) ProtoMessage() {} func (x *TransactionStateAttributeEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[82] + mi := &file_raft_cmd_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6006,7 +5872,7 @@ func (x *TransactionStateAttributeEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use TransactionStateAttributeEntry.ProtoReflect.Descriptor instead. func (*TransactionStateAttributeEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{82} + return file_raft_cmd_proto_rawDescGZIP(), []int{80} } func (x *TransactionStateAttributeEntry) GetId() *AttributeID { @@ -6035,7 +5901,7 @@ type IdempotencyKeyAttributeEntry struct { func (x *IdempotencyKeyAttributeEntry) Reset() { *x = IdempotencyKeyAttributeEntry{} - mi := &file_raft_cmd_proto_msgTypes[83] + mi := &file_raft_cmd_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6047,7 +5913,7 @@ func (x *IdempotencyKeyAttributeEntry) String() string { func (*IdempotencyKeyAttributeEntry) ProtoMessage() {} func (x *IdempotencyKeyAttributeEntry) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[83] + mi := &file_raft_cmd_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6060,7 +5926,7 @@ func (x *IdempotencyKeyAttributeEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use IdempotencyKeyAttributeEntry.ProtoReflect.Descriptor instead. func (*IdempotencyKeyAttributeEntry) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{83} + return file_raft_cmd_proto_rawDescGZIP(), []int{81} } func (x *IdempotencyKeyAttributeEntry) GetId() *AttributeID { @@ -6087,7 +5953,7 @@ type AttributeID struct { func (x *AttributeID) Reset() { *x = AttributeID{} - mi := &file_raft_cmd_proto_msgTypes[84] + mi := &file_raft_cmd_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6099,7 +5965,7 @@ func (x *AttributeID) String() string { func (*AttributeID) ProtoMessage() {} func (x *AttributeID) ProtoReflect() protoreflect.Message { - mi := &file_raft_cmd_proto_msgTypes[84] + mi := &file_raft_cmd_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6112,7 +5978,7 @@ func (x *AttributeID) ProtoReflect() protoreflect.Message { // Deprecated: Use AttributeID.ProtoReflect.Descriptor instead. func (*AttributeID) Descriptor() ([]byte, []int) { - return file_raft_cmd_proto_rawDescGZIP(), []int{84} + return file_raft_cmd_proto_rawDescGZIP(), []int{82} } func (x *AttributeID) GetId() []byte { @@ -6484,24 +6350,19 @@ const file_raft_cmd_proto_rawDesc = "" + "\n" + "VolumePair\x12%\n" + "\x05input\x18\x01 \x01(\v2\x0f.common.Uint256R\x05input\x12'\n" + - "\x06output\x18\x02 \x01(\v2\x0f.common.Uint256R\x06output\"\x9e\x02\n" + + "\x06output\x18\x02 \x01(\v2\x0f.common.Uint256R\x06output\"\xa2\x02\n" + "\rExecutionPlan\x12.\n" + "\x12lastPersistedIndex\x18\x01 \x01(\x06R\x12lastPersistedIndex\x12\x1f\n" + "\vcache_epoch\x18\x04 \x01(\x06R\n" + - "cacheEpoch\x123\n" + + "cacheEpoch\x127\n" + "\n" + - "attributes\x18\x06 \x03(\v2\x13.raft.AttributePlanR\n" + + "attributes\x18\x06 \x03(\v2\x17.raft.AttributeCoverageR\n" + "attributes\x12E\n" + - "\x10idempotency_keys\x18\a \x03(\v2\x1a.raft.ReloadIdempotencyKeyR\x0fidempotencyKeysJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\x05\x10\x06J\x04\b\b\x10\tR\bpreloadsR\atouchesR\bdeclaredR\vproductions\"\xd7\x01\n" + - "\rAttributePlan\x12!\n" + + "\x10idempotency_keys\x18\a \x03(\v2\x1a.raft.ReloadIdempotencyKeyR\x0fidempotencyKeysJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\x05\x10\x06J\x04\b\b\x10\tR\bpreloadsR\atouchesR\bdeclaredR\vproductions\"\x7f\n" + + "\x11AttributeCoverage\x12!\n" + "\x02id\x18\x01 \x01(\v2\x11.raft.AttributeIDR\x02id\x12\x1b\n" + - "\tattr_code\x18\x02 \x01(\rR\battrCode\x12)\n" + - "\adeclare\x18\x03 \x01(\v2\r.raft.DeclareH\x00R\adeclare\x12#\n" + - "\x05touch\x18\x04 \x01(\v2\v.raft.TouchH\x00R\x05touch\x12,\n" + - "\x05value\x18\x05 \x01(\v2\x14.raft.AttributeValueH\x00R\x05valueB\b\n" + - "\x06intent\"\t\n" + - "\aDeclare\"\a\n" + - "\x05Touch\"-\n" + + "\tattr_code\x18\x02 \x01(\rR\battrCode\x12*\n" + + "\x05value\x18\x03 \x01(\v2\x14.raft.AttributeValueR\x05value\"-\n" + "\x0eAttributeValue\x12\x1b\n" + "\traw_value\x18\x01 \x01(\fR\brawValue\"[\n" + "\x14ReloadIdempotencyKey\x12\x10\n" + @@ -6582,7 +6443,7 @@ func file_raft_cmd_proto_rawDescGZIP() []byte { } var file_raft_cmd_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_raft_cmd_proto_msgTypes = make([]protoimpl.MessageInfo, 96) +var file_raft_cmd_proto_msgTypes = make([]protoimpl.MessageInfo, 94) var file_raft_cmd_proto_goTypes = []any{ (BackupKind)(0), // 0: raft.BackupKind (BackupJobStatus)(0), // 1: raft.BackupJobStatus @@ -6654,63 +6515,61 @@ var file_raft_cmd_proto_goTypes = []any{ (*LedgerBoundaries)(nil), // 67: raft.LedgerBoundaries (*VolumePair)(nil), // 68: raft.VolumePair (*ExecutionPlan)(nil), // 69: raft.ExecutionPlan - (*AttributePlan)(nil), // 70: raft.AttributePlan - (*Declare)(nil), // 71: raft.Declare - (*Touch)(nil), // 72: raft.Touch - (*AttributeValue)(nil), // 73: raft.AttributeValue - (*ReloadIdempotencyKey)(nil), // 74: raft.ReloadIdempotencyKey - (*CacheGenerationMeta)(nil), // 75: raft.CacheGenerationMeta - (*CacheSnapshotMeta)(nil), // 76: raft.CacheSnapshotMeta - (*PeerAddress)(nil), // 77: raft.PeerAddress - (*GenerationSnapshot)(nil), // 78: raft.GenerationSnapshot - (*VolumeAttributeSnapshotEntry)(nil), // 79: raft.VolumeAttributeSnapshotEntry - (*MetadataAttributeEntry)(nil), // 80: raft.MetadataAttributeEntry - (*LedgerAttributeEntry)(nil), // 81: raft.LedgerAttributeEntry - (*BoundaryAttributeEntry)(nil), // 82: raft.BoundaryAttributeEntry - (*TransactionReferenceAttributeEntry)(nil), // 83: raft.TransactionReferenceAttributeEntry - (*TransactionStateAttributeEntry)(nil), // 84: raft.TransactionStateAttributeEntry - (*IdempotencyKeyAttributeEntry)(nil), // 85: raft.IdempotencyKeyAttributeEntry - (*AttributeID)(nil), // 86: raft.AttributeID - nil, // 87: raft.CreateLedgerOrder.AccountTypesEntry - nil, // 88: raft.MirrorCreatedTransaction.MetadataEntry - nil, // 89: raft.MirrorCreatedTransaction.AccountMetadataEntry - nil, // 90: raft.MirrorSavedMetadata.MetadataEntry - nil, // 91: raft.MirrorRevertedTransaction.MetadataEntry - nil, // 92: raft.CreateTransactionOrder.MetadataEntry - nil, // 93: raft.CreateTransactionOrder.AccountMetadataEntry - nil, // 94: raft.NumscriptReference.VarsEntry - nil, // 95: raft.SaveMetadataOrder.MetadataEntry - nil, // 96: raft.RevertTransactionOrder.MetadataEntry - nil, // 97: raft.SaveLedgerMetadataOrder.MetadataEntry - (*commonpb.PreparedQuery)(nil), // 98: common.PreparedQuery - (*commonpb.QueryFilter)(nil), // 99: common.QueryFilter - (*commonpb.SinkConfig)(nil), // 100: common.SinkConfig - (*commonpb.Timestamp)(nil), // 101: common.Timestamp - (*commonpb.SetMetadataFieldTypeCommand)(nil), // 102: common.SetMetadataFieldTypeCommand - (commonpb.LedgerMode)(0), // 103: common.LedgerMode - (*commonpb.MirrorSourceConfig)(nil), // 104: common.MirrorSourceConfig - (commonpb.ChartEnforcementMode)(0), // 105: common.ChartEnforcementMode - (*commonpb.Posting)(nil), // 106: common.Posting - (*commonpb.Target)(nil), // 107: common.Target - (*commonpb.IndexID)(nil), // 108: common.IndexID - (*commonpb.AccountType)(nil), // 109: common.AccountType - (commonpb.TargetType)(0), // 110: common.TargetType - (commonpb.MetadataType)(0), // 111: common.MetadataType - (*commonpb.Script)(nil), // 112: common.Script - (*commonpb.CallerSnapshot)(nil), // 113: common.CallerSnapshot - (*commonpb.Idempotency)(nil), // 114: common.Idempotency - (*signaturepb.SignedApplyBatch)(nil), // 115: signature.SignedApplyBatch - (*commonpb.ClusterConfig)(nil), // 116: common.ClusterConfig - (*commonpb.MirrorSyncError)(nil), // 117: common.MirrorSyncError - (*commonpb.SinkError)(nil), // 118: common.SinkError - (*commonpb.Log)(nil), // 119: common.Log - (*commonpb.Uint256)(nil), // 120: common.Uint256 - (*commonpb.IdempotencyKeyValue)(nil), // 121: common.IdempotencyKeyValue - (*commonpb.MetadataValue)(nil), // 122: common.MetadataValue - (*commonpb.LedgerInfo)(nil), // 123: common.LedgerInfo - (*commonpb.TransactionReferenceValue)(nil), // 124: common.TransactionReferenceValue - (*commonpb.TransactionState)(nil), // 125: common.TransactionState - (*commonpb.MetadataMap)(nil), // 126: common.MetadataMap + (*AttributeCoverage)(nil), // 70: raft.AttributeCoverage + (*AttributeValue)(nil), // 71: raft.AttributeValue + (*ReloadIdempotencyKey)(nil), // 72: raft.ReloadIdempotencyKey + (*CacheGenerationMeta)(nil), // 73: raft.CacheGenerationMeta + (*CacheSnapshotMeta)(nil), // 74: raft.CacheSnapshotMeta + (*PeerAddress)(nil), // 75: raft.PeerAddress + (*GenerationSnapshot)(nil), // 76: raft.GenerationSnapshot + (*VolumeAttributeSnapshotEntry)(nil), // 77: raft.VolumeAttributeSnapshotEntry + (*MetadataAttributeEntry)(nil), // 78: raft.MetadataAttributeEntry + (*LedgerAttributeEntry)(nil), // 79: raft.LedgerAttributeEntry + (*BoundaryAttributeEntry)(nil), // 80: raft.BoundaryAttributeEntry + (*TransactionReferenceAttributeEntry)(nil), // 81: raft.TransactionReferenceAttributeEntry + (*TransactionStateAttributeEntry)(nil), // 82: raft.TransactionStateAttributeEntry + (*IdempotencyKeyAttributeEntry)(nil), // 83: raft.IdempotencyKeyAttributeEntry + (*AttributeID)(nil), // 84: raft.AttributeID + nil, // 85: raft.CreateLedgerOrder.AccountTypesEntry + nil, // 86: raft.MirrorCreatedTransaction.MetadataEntry + nil, // 87: raft.MirrorCreatedTransaction.AccountMetadataEntry + nil, // 88: raft.MirrorSavedMetadata.MetadataEntry + nil, // 89: raft.MirrorRevertedTransaction.MetadataEntry + nil, // 90: raft.CreateTransactionOrder.MetadataEntry + nil, // 91: raft.CreateTransactionOrder.AccountMetadataEntry + nil, // 92: raft.NumscriptReference.VarsEntry + nil, // 93: raft.SaveMetadataOrder.MetadataEntry + nil, // 94: raft.RevertTransactionOrder.MetadataEntry + nil, // 95: raft.SaveLedgerMetadataOrder.MetadataEntry + (*commonpb.PreparedQuery)(nil), // 96: common.PreparedQuery + (*commonpb.QueryFilter)(nil), // 97: common.QueryFilter + (*commonpb.SinkConfig)(nil), // 98: common.SinkConfig + (*commonpb.Timestamp)(nil), // 99: common.Timestamp + (*commonpb.SetMetadataFieldTypeCommand)(nil), // 100: common.SetMetadataFieldTypeCommand + (commonpb.LedgerMode)(0), // 101: common.LedgerMode + (*commonpb.MirrorSourceConfig)(nil), // 102: common.MirrorSourceConfig + (commonpb.ChartEnforcementMode)(0), // 103: common.ChartEnforcementMode + (*commonpb.Posting)(nil), // 104: common.Posting + (*commonpb.Target)(nil), // 105: common.Target + (*commonpb.IndexID)(nil), // 106: common.IndexID + (*commonpb.AccountType)(nil), // 107: common.AccountType + (commonpb.TargetType)(0), // 108: common.TargetType + (commonpb.MetadataType)(0), // 109: common.MetadataType + (*commonpb.Script)(nil), // 110: common.Script + (*commonpb.CallerSnapshot)(nil), // 111: common.CallerSnapshot + (*commonpb.Idempotency)(nil), // 112: common.Idempotency + (*signaturepb.SignedApplyBatch)(nil), // 113: signature.SignedApplyBatch + (*commonpb.ClusterConfig)(nil), // 114: common.ClusterConfig + (*commonpb.MirrorSyncError)(nil), // 115: common.MirrorSyncError + (*commonpb.SinkError)(nil), // 116: common.SinkError + (*commonpb.Log)(nil), // 117: common.Log + (*commonpb.Uint256)(nil), // 118: common.Uint256 + (*commonpb.IdempotencyKeyValue)(nil), // 119: common.IdempotencyKeyValue + (*commonpb.MetadataValue)(nil), // 120: common.MetadataValue + (*commonpb.LedgerInfo)(nil), // 121: common.LedgerInfo + (*commonpb.TransactionReferenceValue)(nil), // 122: common.TransactionReferenceValue + (*commonpb.TransactionState)(nil), // 123: common.TransactionState + (*commonpb.MetadataMap)(nil), // 124: common.MetadataMap } var file_raft_cmd_proto_depIdxs = []int32{ 3, // 0: raft.Order.ledger_scoped:type_name -> raft.LedgerScopedOrder @@ -6743,31 +6602,31 @@ var file_raft_cmd_proto_depIdxs = []int32{ 23, // 27: raft.SystemScopedOrder.delete_query_checkpoint:type_name -> raft.DeleteQueryCheckpointOrder 25, // 28: raft.SystemScopedOrder.set_query_checkpoint_schedule:type_name -> raft.SetQueryCheckpointScheduleOrder 26, // 29: raft.SystemScopedOrder.delete_query_checkpoint_schedule:type_name -> raft.DeleteQueryCheckpointScheduleOrder - 98, // 30: raft.CreatePreparedQueryOrder.query:type_name -> common.PreparedQuery - 99, // 31: raft.UpdatePreparedQueryOrder.filter:type_name -> common.QueryFilter - 100, // 32: raft.AddEventsSinkOrder.config:type_name -> common.SinkConfig - 101, // 33: raft.QueryCheckpointState.created_at:type_name -> common.Timestamp - 102, // 34: raft.CreateLedgerOrder.initial_schema:type_name -> common.SetMetadataFieldTypeCommand - 103, // 35: raft.CreateLedgerOrder.mode:type_name -> common.LedgerMode - 104, // 36: raft.CreateLedgerOrder.mirror_source:type_name -> common.MirrorSourceConfig - 87, // 37: raft.CreateLedgerOrder.account_types:type_name -> raft.CreateLedgerOrder.AccountTypesEntry - 105, // 38: raft.CreateLedgerOrder.default_enforcement_mode:type_name -> common.ChartEnforcementMode + 96, // 30: raft.CreatePreparedQueryOrder.query:type_name -> common.PreparedQuery + 97, // 31: raft.UpdatePreparedQueryOrder.filter:type_name -> common.QueryFilter + 98, // 32: raft.AddEventsSinkOrder.config:type_name -> common.SinkConfig + 99, // 33: raft.QueryCheckpointState.created_at:type_name -> common.Timestamp + 100, // 34: raft.CreateLedgerOrder.initial_schema:type_name -> common.SetMetadataFieldTypeCommand + 101, // 35: raft.CreateLedgerOrder.mode:type_name -> common.LedgerMode + 102, // 36: raft.CreateLedgerOrder.mirror_source:type_name -> common.MirrorSourceConfig + 85, // 37: raft.CreateLedgerOrder.account_types:type_name -> raft.CreateLedgerOrder.AccountTypesEntry + 103, // 38: raft.CreateLedgerOrder.default_enforcement_mode:type_name -> common.ChartEnforcementMode 29, // 39: raft.MirrorIngestOrder.entry:type_name -> raft.MirrorLogEntry 31, // 40: raft.MirrorLogEntry.created_transaction:type_name -> raft.MirrorCreatedTransaction 32, // 41: raft.MirrorLogEntry.saved_metadata:type_name -> raft.MirrorSavedMetadata 33, // 42: raft.MirrorLogEntry.reverted_transaction:type_name -> raft.MirrorRevertedTransaction 34, // 43: raft.MirrorLogEntry.deleted_metadata:type_name -> raft.MirrorDeletedMetadata 30, // 44: raft.MirrorLogEntry.fill_gap:type_name -> raft.MirrorFillGap - 106, // 45: raft.MirrorCreatedTransaction.postings:type_name -> common.Posting - 88, // 46: raft.MirrorCreatedTransaction.metadata:type_name -> raft.MirrorCreatedTransaction.MetadataEntry - 101, // 47: raft.MirrorCreatedTransaction.timestamp:type_name -> common.Timestamp - 89, // 48: raft.MirrorCreatedTransaction.account_metadata:type_name -> raft.MirrorCreatedTransaction.AccountMetadataEntry - 107, // 49: raft.MirrorSavedMetadata.target:type_name -> common.Target - 90, // 50: raft.MirrorSavedMetadata.metadata:type_name -> raft.MirrorSavedMetadata.MetadataEntry - 106, // 51: raft.MirrorRevertedTransaction.reverse_postings:type_name -> common.Posting - 91, // 52: raft.MirrorRevertedTransaction.metadata:type_name -> raft.MirrorRevertedTransaction.MetadataEntry - 101, // 53: raft.MirrorRevertedTransaction.timestamp:type_name -> common.Timestamp - 107, // 54: raft.MirrorDeletedMetadata.target:type_name -> common.Target + 104, // 45: raft.MirrorCreatedTransaction.postings:type_name -> common.Posting + 86, // 46: raft.MirrorCreatedTransaction.metadata:type_name -> raft.MirrorCreatedTransaction.MetadataEntry + 99, // 47: raft.MirrorCreatedTransaction.timestamp:type_name -> common.Timestamp + 87, // 48: raft.MirrorCreatedTransaction.account_metadata:type_name -> raft.MirrorCreatedTransaction.AccountMetadataEntry + 105, // 49: raft.MirrorSavedMetadata.target:type_name -> common.Target + 88, // 50: raft.MirrorSavedMetadata.metadata:type_name -> raft.MirrorSavedMetadata.MetadataEntry + 104, // 51: raft.MirrorRevertedTransaction.reverse_postings:type_name -> common.Posting + 89, // 52: raft.MirrorRevertedTransaction.metadata:type_name -> raft.MirrorRevertedTransaction.MetadataEntry + 99, // 53: raft.MirrorRevertedTransaction.timestamp:type_name -> common.Timestamp + 105, // 54: raft.MirrorDeletedMetadata.target:type_name -> common.Target 45, // 55: raft.LedgerApplyOrder.create_transaction:type_name -> raft.CreateTransactionOrder 47, // 56: raft.LedgerApplyOrder.add_metadata:type_name -> raft.SaveMetadataOrder 48, // 57: raft.LedgerApplyOrder.revert_transaction:type_name -> raft.RevertTransactionOrder @@ -6779,37 +6638,37 @@ var file_raft_cmd_proto_depIdxs = []int32{ 40, // 63: raft.LedgerApplyOrder.add_account_type:type_name -> raft.AddAccountTypeOrder 41, // 64: raft.LedgerApplyOrder.remove_account_type:type_name -> raft.RemoveAccountTypeOrder 42, // 65: raft.LedgerApplyOrder.update_default_enforcement_mode:type_name -> raft.UpdateDefaultEnforcementModeOrder - 108, // 66: raft.CreateIndexOrder.id:type_name -> common.IndexID - 108, // 67: raft.DropIndexOrder.id:type_name -> common.IndexID - 109, // 68: raft.AddAccountTypeOrder.account_type:type_name -> common.AccountType - 105, // 69: raft.UpdateDefaultEnforcementModeOrder.enforcement_mode:type_name -> common.ChartEnforcementMode - 110, // 70: raft.SetMetadataFieldTypeOrder.target_type:type_name -> common.TargetType - 111, // 71: raft.SetMetadataFieldTypeOrder.type:type_name -> common.MetadataType - 110, // 72: raft.RemoveMetadataFieldTypeOrder.target_type:type_name -> common.TargetType - 106, // 73: raft.CreateTransactionOrder.postings:type_name -> common.Posting - 112, // 74: raft.CreateTransactionOrder.script:type_name -> common.Script - 101, // 75: raft.CreateTransactionOrder.timestamp:type_name -> common.Timestamp - 92, // 76: raft.CreateTransactionOrder.metadata:type_name -> raft.CreateTransactionOrder.MetadataEntry - 93, // 77: raft.CreateTransactionOrder.account_metadata:type_name -> raft.CreateTransactionOrder.AccountMetadataEntry + 106, // 66: raft.CreateIndexOrder.id:type_name -> common.IndexID + 106, // 67: raft.DropIndexOrder.id:type_name -> common.IndexID + 107, // 68: raft.AddAccountTypeOrder.account_type:type_name -> common.AccountType + 103, // 69: raft.UpdateDefaultEnforcementModeOrder.enforcement_mode:type_name -> common.ChartEnforcementMode + 108, // 70: raft.SetMetadataFieldTypeOrder.target_type:type_name -> common.TargetType + 109, // 71: raft.SetMetadataFieldTypeOrder.type:type_name -> common.MetadataType + 108, // 72: raft.RemoveMetadataFieldTypeOrder.target_type:type_name -> common.TargetType + 104, // 73: raft.CreateTransactionOrder.postings:type_name -> common.Posting + 110, // 74: raft.CreateTransactionOrder.script:type_name -> common.Script + 99, // 75: raft.CreateTransactionOrder.timestamp:type_name -> common.Timestamp + 90, // 76: raft.CreateTransactionOrder.metadata:type_name -> raft.CreateTransactionOrder.MetadataEntry + 91, // 77: raft.CreateTransactionOrder.account_metadata:type_name -> raft.CreateTransactionOrder.AccountMetadataEntry 46, // 78: raft.CreateTransactionOrder.numscript_reference:type_name -> raft.NumscriptReference - 94, // 79: raft.NumscriptReference.vars:type_name -> raft.NumscriptReference.VarsEntry - 107, // 80: raft.SaveMetadataOrder.target:type_name -> common.Target - 95, // 81: raft.SaveMetadataOrder.metadata:type_name -> raft.SaveMetadataOrder.MetadataEntry - 96, // 82: raft.RevertTransactionOrder.metadata:type_name -> raft.RevertTransactionOrder.MetadataEntry - 106, // 83: raft.RevertTransactionOrder.original_postings:type_name -> common.Posting - 107, // 84: raft.DeleteMetadataOrder.target:type_name -> common.Target - 97, // 85: raft.SaveLedgerMetadataOrder.metadata:type_name -> raft.SaveLedgerMetadataOrder.MetadataEntry + 92, // 79: raft.NumscriptReference.vars:type_name -> raft.NumscriptReference.VarsEntry + 105, // 80: raft.SaveMetadataOrder.target:type_name -> common.Target + 93, // 81: raft.SaveMetadataOrder.metadata:type_name -> raft.SaveMetadataOrder.MetadataEntry + 94, // 82: raft.RevertTransactionOrder.metadata:type_name -> raft.RevertTransactionOrder.MetadataEntry + 104, // 83: raft.RevertTransactionOrder.original_postings:type_name -> common.Posting + 105, // 84: raft.DeleteMetadataOrder.target:type_name -> common.Target + 95, // 85: raft.SaveLedgerMetadataOrder.metadata:type_name -> raft.SaveLedgerMetadataOrder.MetadataEntry 2, // 86: raft.Proposal.orders:type_name -> raft.Order - 101, // 87: raft.Proposal.date:type_name -> common.Timestamp + 99, // 87: raft.Proposal.date:type_name -> common.Timestamp 69, // 88: raft.Proposal.execution_plan:type_name -> raft.ExecutionPlan - 113, // 89: raft.Proposal.caller_snapshot:type_name -> common.CallerSnapshot - 114, // 90: raft.Proposal.idempotency:type_name -> common.Idempotency - 115, // 91: raft.Proposal.signature:type_name -> signature.SignedApplyBatch + 111, // 89: raft.Proposal.caller_snapshot:type_name -> common.CallerSnapshot + 112, // 90: raft.Proposal.idempotency:type_name -> common.Idempotency + 113, // 91: raft.Proposal.signature:type_name -> signature.SignedApplyBatch 53, // 92: raft.Proposal.technical_updates:type_name -> raft.TechnicalUpdate 64, // 93: raft.TechnicalUpdate.mirror_sync:type_name -> raft.MirrorSyncUpdate 65, // 94: raft.TechnicalUpdate.events_sink:type_name -> raft.EventsSinkUpdate 63, // 95: raft.TechnicalUpdate.idempotency_eviction:type_name -> raft.IdempotencyEviction - 116, // 96: raft.TechnicalUpdate.cluster_config:type_name -> common.ClusterConfig + 114, // 96: raft.TechnicalUpdate.cluster_config:type_name -> common.ClusterConfig 58, // 97: raft.TechnicalUpdate.backup_order:type_name -> raft.BackupOrder 59, // 98: raft.TechnicalUpdate.incremental_backup_order:type_name -> raft.IncrementalBackupOrder 55, // 99: raft.BackupDestination.s3:type_name -> raft.S3BackupTarget @@ -6824,55 +6683,53 @@ var file_raft_cmd_proto_depIdxs = []int32{ 61, // 108: raft.IncrementalBackupOrder.complete:type_name -> raft.BackupOrderComplete 62, // 109: raft.IncrementalBackupOrder.fail:type_name -> raft.BackupOrderFail 54, // 110: raft.BackupOrderStart.destination:type_name -> raft.BackupDestination - 117, // 111: raft.MirrorSyncUpdate.error:type_name -> common.MirrorSyncError - 118, // 112: raft.EventsSinkUpdate.error:type_name -> common.SinkError - 119, // 113: raft.CreatedLogOrReference.created_log:type_name -> common.Log - 120, // 114: raft.VolumePair.input:type_name -> common.Uint256 - 120, // 115: raft.VolumePair.output:type_name -> common.Uint256 - 70, // 116: raft.ExecutionPlan.attributes:type_name -> raft.AttributePlan - 74, // 117: raft.ExecutionPlan.idempotency_keys:type_name -> raft.ReloadIdempotencyKey - 86, // 118: raft.AttributePlan.id:type_name -> raft.AttributeID - 71, // 119: raft.AttributePlan.declare:type_name -> raft.Declare - 72, // 120: raft.AttributePlan.touch:type_name -> raft.Touch - 73, // 121: raft.AttributePlan.value:type_name -> raft.AttributeValue - 121, // 122: raft.ReloadIdempotencyKey.value:type_name -> common.IdempotencyKeyValue - 79, // 123: raft.GenerationSnapshot.volumes:type_name -> raft.VolumeAttributeSnapshotEntry - 80, // 124: raft.GenerationSnapshot.metadata:type_name -> raft.MetadataAttributeEntry - 80, // 125: raft.GenerationSnapshot.ledger_metadata:type_name -> raft.MetadataAttributeEntry - 81, // 126: raft.GenerationSnapshot.ledgers:type_name -> raft.LedgerAttributeEntry - 82, // 127: raft.GenerationSnapshot.boundaries:type_name -> raft.BoundaryAttributeEntry - 83, // 128: raft.GenerationSnapshot.references:type_name -> raft.TransactionReferenceAttributeEntry - 84, // 129: raft.GenerationSnapshot.transactions:type_name -> raft.TransactionStateAttributeEntry - 86, // 130: raft.VolumeAttributeSnapshotEntry.id:type_name -> raft.AttributeID - 120, // 131: raft.VolumeAttributeSnapshotEntry.input:type_name -> common.Uint256 - 120, // 132: raft.VolumeAttributeSnapshotEntry.output:type_name -> common.Uint256 - 86, // 133: raft.MetadataAttributeEntry.id:type_name -> raft.AttributeID - 122, // 134: raft.MetadataAttributeEntry.value:type_name -> common.MetadataValue - 86, // 135: raft.LedgerAttributeEntry.id:type_name -> raft.AttributeID - 123, // 136: raft.LedgerAttributeEntry.info:type_name -> common.LedgerInfo - 86, // 137: raft.BoundaryAttributeEntry.id:type_name -> raft.AttributeID - 67, // 138: raft.BoundaryAttributeEntry.boundaries:type_name -> raft.LedgerBoundaries - 86, // 139: raft.TransactionReferenceAttributeEntry.id:type_name -> raft.AttributeID - 124, // 140: raft.TransactionReferenceAttributeEntry.value:type_name -> common.TransactionReferenceValue - 86, // 141: raft.TransactionStateAttributeEntry.id:type_name -> raft.AttributeID - 125, // 142: raft.TransactionStateAttributeEntry.state:type_name -> common.TransactionState - 86, // 143: raft.IdempotencyKeyAttributeEntry.id:type_name -> raft.AttributeID - 121, // 144: raft.IdempotencyKeyAttributeEntry.value:type_name -> common.IdempotencyKeyValue - 109, // 145: raft.CreateLedgerOrder.AccountTypesEntry.value:type_name -> common.AccountType - 122, // 146: raft.MirrorCreatedTransaction.MetadataEntry.value:type_name -> common.MetadataValue - 126, // 147: raft.MirrorCreatedTransaction.AccountMetadataEntry.value:type_name -> common.MetadataMap - 122, // 148: raft.MirrorSavedMetadata.MetadataEntry.value:type_name -> common.MetadataValue - 122, // 149: raft.MirrorRevertedTransaction.MetadataEntry.value:type_name -> common.MetadataValue - 122, // 150: raft.CreateTransactionOrder.MetadataEntry.value:type_name -> common.MetadataValue - 126, // 151: raft.CreateTransactionOrder.AccountMetadataEntry.value:type_name -> common.MetadataMap - 122, // 152: raft.SaveMetadataOrder.MetadataEntry.value:type_name -> common.MetadataValue - 122, // 153: raft.RevertTransactionOrder.MetadataEntry.value:type_name -> common.MetadataValue - 122, // 154: raft.SaveLedgerMetadataOrder.MetadataEntry.value:type_name -> common.MetadataValue - 155, // [155:155] is the sub-list for method output_type - 155, // [155:155] is the sub-list for method input_type - 155, // [155:155] is the sub-list for extension type_name - 155, // [155:155] is the sub-list for extension extendee - 0, // [0:155] is the sub-list for field type_name + 115, // 111: raft.MirrorSyncUpdate.error:type_name -> common.MirrorSyncError + 116, // 112: raft.EventsSinkUpdate.error:type_name -> common.SinkError + 117, // 113: raft.CreatedLogOrReference.created_log:type_name -> common.Log + 118, // 114: raft.VolumePair.input:type_name -> common.Uint256 + 118, // 115: raft.VolumePair.output:type_name -> common.Uint256 + 70, // 116: raft.ExecutionPlan.attributes:type_name -> raft.AttributeCoverage + 72, // 117: raft.ExecutionPlan.idempotency_keys:type_name -> raft.ReloadIdempotencyKey + 84, // 118: raft.AttributeCoverage.id:type_name -> raft.AttributeID + 71, // 119: raft.AttributeCoverage.value:type_name -> raft.AttributeValue + 119, // 120: raft.ReloadIdempotencyKey.value:type_name -> common.IdempotencyKeyValue + 77, // 121: raft.GenerationSnapshot.volumes:type_name -> raft.VolumeAttributeSnapshotEntry + 78, // 122: raft.GenerationSnapshot.metadata:type_name -> raft.MetadataAttributeEntry + 78, // 123: raft.GenerationSnapshot.ledger_metadata:type_name -> raft.MetadataAttributeEntry + 79, // 124: raft.GenerationSnapshot.ledgers:type_name -> raft.LedgerAttributeEntry + 80, // 125: raft.GenerationSnapshot.boundaries:type_name -> raft.BoundaryAttributeEntry + 81, // 126: raft.GenerationSnapshot.references:type_name -> raft.TransactionReferenceAttributeEntry + 82, // 127: raft.GenerationSnapshot.transactions:type_name -> raft.TransactionStateAttributeEntry + 84, // 128: raft.VolumeAttributeSnapshotEntry.id:type_name -> raft.AttributeID + 118, // 129: raft.VolumeAttributeSnapshotEntry.input:type_name -> common.Uint256 + 118, // 130: raft.VolumeAttributeSnapshotEntry.output:type_name -> common.Uint256 + 84, // 131: raft.MetadataAttributeEntry.id:type_name -> raft.AttributeID + 120, // 132: raft.MetadataAttributeEntry.value:type_name -> common.MetadataValue + 84, // 133: raft.LedgerAttributeEntry.id:type_name -> raft.AttributeID + 121, // 134: raft.LedgerAttributeEntry.info:type_name -> common.LedgerInfo + 84, // 135: raft.BoundaryAttributeEntry.id:type_name -> raft.AttributeID + 67, // 136: raft.BoundaryAttributeEntry.boundaries:type_name -> raft.LedgerBoundaries + 84, // 137: raft.TransactionReferenceAttributeEntry.id:type_name -> raft.AttributeID + 122, // 138: raft.TransactionReferenceAttributeEntry.value:type_name -> common.TransactionReferenceValue + 84, // 139: raft.TransactionStateAttributeEntry.id:type_name -> raft.AttributeID + 123, // 140: raft.TransactionStateAttributeEntry.state:type_name -> common.TransactionState + 84, // 141: raft.IdempotencyKeyAttributeEntry.id:type_name -> raft.AttributeID + 119, // 142: raft.IdempotencyKeyAttributeEntry.value:type_name -> common.IdempotencyKeyValue + 107, // 143: raft.CreateLedgerOrder.AccountTypesEntry.value:type_name -> common.AccountType + 120, // 144: raft.MirrorCreatedTransaction.MetadataEntry.value:type_name -> common.MetadataValue + 124, // 145: raft.MirrorCreatedTransaction.AccountMetadataEntry.value:type_name -> common.MetadataMap + 120, // 146: raft.MirrorSavedMetadata.MetadataEntry.value:type_name -> common.MetadataValue + 120, // 147: raft.MirrorRevertedTransaction.MetadataEntry.value:type_name -> common.MetadataValue + 120, // 148: raft.CreateTransactionOrder.MetadataEntry.value:type_name -> common.MetadataValue + 124, // 149: raft.CreateTransactionOrder.AccountMetadataEntry.value:type_name -> common.MetadataMap + 120, // 150: raft.SaveMetadataOrder.MetadataEntry.value:type_name -> common.MetadataValue + 120, // 151: raft.RevertTransactionOrder.MetadataEntry.value:type_name -> common.MetadataValue + 120, // 152: raft.SaveLedgerMetadataOrder.MetadataEntry.value:type_name -> common.MetadataValue + 153, // [153:153] is the sub-list for method output_type + 153, // [153:153] is the sub-list for method input_type + 153, // [153:153] is the sub-list for extension type_name + 153, // [153:153] is the sub-list for extension extendee + 0, // [0:153] is the sub-list for field type_name } func init() { file_raft_cmd_proto_init() } @@ -6962,18 +6819,13 @@ func file_raft_cmd_proto_init() { (*CreatedLogOrReference_CreatedLog)(nil), (*CreatedLogOrReference_ReferenceSequence)(nil), } - file_raft_cmd_proto_msgTypes[68].OneofWrappers = []any{ - (*AttributePlan_Declare)(nil), - (*AttributePlan_Touch)(nil), - (*AttributePlan_Value)(nil), - } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_raft_cmd_proto_rawDesc), len(file_raft_cmd_proto_rawDesc)), NumEnums: 2, - NumMessages: 96, + NumMessages: 94, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/proto/raftcmdpb/raft_cmd_dethash.pb.go b/internal/proto/raftcmdpb/raft_cmd_dethash.pb.go index 94b3c8b45..45ba3eced 100644 --- a/internal/proto/raftcmdpb/raft_cmd_dethash.pb.go +++ b/internal/proto/raftcmdpb/raft_cmd_dethash.pb.go @@ -1885,29 +1885,7 @@ func (m *ExecutionPlan) MarshalToSizedBufferDeterministicVT(dAtA []byte) (int, e return len(dAtA) - i, nil } -func (m *AttributePlan) MarshalDeterministicVT(dAtA []byte) []byte { - if m == nil { - return dAtA - } - b, err := m.MarshalVT() - if err != nil { - panic("MarshalDeterministicVT: " + err.Error()) - } - return append(dAtA, b...) -} - -func (m *Declare) MarshalDeterministicVT(dAtA []byte) []byte { - if m == nil { - return dAtA - } - b, err := m.MarshalVT() - if err != nil { - panic("MarshalDeterministicVT: " + err.Error()) - } - return append(dAtA, b...) -} - -func (m *Touch) MarshalDeterministicVT(dAtA []byte) []byte { +func (m *AttributeCoverage) MarshalDeterministicVT(dAtA []byte) []byte { if m == nil { return dAtA } diff --git a/internal/proto/raftcmdpb/raft_cmd_reader.pb.go b/internal/proto/raftcmdpb/raft_cmd_reader.pb.go index 165bd2155..0c08db035 100644 --- a/internal/proto/raftcmdpb/raft_cmd_reader.pb.go +++ b/internal/proto/raftcmdpb/raft_cmd_reader.pb.go @@ -5445,7 +5445,7 @@ func NewVolumePairListReader(s []*VolumePair) VolumePairListReader { return volu type ExecutionPlanReader interface { GetLastPersistedIndex() uint64 GetCacheEpoch() uint64 - GetAttributes() AttributePlanListReader + GetAttributes() AttributeCoverageListReader GetIdempotencyKeys() ReloadIdempotencyKeyListReader Mutate() *ExecutionPlan } @@ -5460,8 +5460,8 @@ func (r *executionPlanReadonly) GetCacheEpoch() uint64 { return r.v.GetCacheEpoch() } -func (r *executionPlanReadonly) GetAttributes() AttributePlanListReader { - return NewAttributePlanListReader(r.v.GetAttributes()) +func (r *executionPlanReadonly) GetAttributes() AttributeCoverageListReader { + return NewAttributeCoverageListReader(r.v.GetAttributes()) } func (r *executionPlanReadonly) GetIdempotencyKeys() ReloadIdempotencyKeyListReader { @@ -5522,18 +5522,18 @@ func NewExecutionPlanListReader(s []*ExecutionPlan) ExecutionPlanListReader { return executionPlanListReadonly(s) } -// AttributePlanReader provides read-only access to AttributePlan. +// AttributeCoverageReader provides read-only access to AttributeCoverage. // Call Mutate() to obtain a mutable clone. -type AttributePlanReader interface { +type AttributeCoverageReader interface { GetId() AttributeIDReader GetAttrCode() uint32 - GetIntent() isAttributePlan_Intent - Mutate() *AttributePlan + GetValue() AttributeValueReader + Mutate() *AttributeCoverage } -type attributePlanReadonly struct{ v *AttributePlan } +type attributeCoverageReadonly struct{ v *AttributeCoverage } -func (r *attributePlanReadonly) GetId() AttributeIDReader { +func (r *attributeCoverageReadonly) GetId() AttributeIDReader { v := r.v.GetId() if v == nil { return nil @@ -5541,105 +5541,47 @@ func (r *attributePlanReadonly) GetId() AttributeIDReader { return v.AsReader() } -func (r *attributePlanReadonly) GetAttrCode() uint32 { +func (r *attributeCoverageReadonly) GetAttrCode() uint32 { return r.v.GetAttrCode() } -func (r *attributePlanReadonly) GetIntent() isAttributePlan_Intent { - return r.v.GetIntent() -} - -func (r *attributePlanReadonly) Mutate() *AttributePlan { - return r.v.CloneVT() -} - -// AsReader returns a read-only view of this AttributePlan. -func (m *AttributePlan) AsReader() AttributePlanReader { - if m == nil { - return nil - } - return &attributePlanReadonly{v: m} -} - -// Mutate returns a mutable deep clone of this AttributePlan. -func (m *AttributePlan) Mutate() *AttributePlan { - return m.CloneVT() -} - -// AttributePlanListReader provides read-only iteration over []*AttributePlan. -type AttributePlanListReader interface { - Len() int - Get(i int) AttributePlanReader - Range(yield func(int, AttributePlanReader) bool) -} - -type attributePlanListReadonly []*AttributePlan - -func (l attributePlanListReadonly) Len() int { return len(l) } - -func (l attributePlanListReadonly) Get(i int) AttributePlanReader { - v := l[i] +func (r *attributeCoverageReadonly) GetValue() AttributeValueReader { + v := r.v.GetValue() if v == nil { return nil } return v.AsReader() } -func (l attributePlanListReadonly) Range(yield func(int, AttributePlanReader) bool) { - for i, v := range l { - var r AttributePlanReader - if v != nil { - r = v.AsReader() - } - if !yield(i, r) { - return - } - } -} - -// NewAttributePlanListReader wraps s for read-only iteration. The returned -// view aliases the underlying slice; do not mutate s afterwards. -func NewAttributePlanListReader(s []*AttributePlan) AttributePlanListReader { - return attributePlanListReadonly(s) -} - -// DeclareReader provides read-only access to Declare. -// Call Mutate() to obtain a mutable clone. -type DeclareReader interface { - Mutate() *Declare -} - -type declareReadonly struct{ v *Declare } - -func (r *declareReadonly) Mutate() *Declare { +func (r *attributeCoverageReadonly) Mutate() *AttributeCoverage { return r.v.CloneVT() } -// AsReader returns a read-only view of this Declare. -func (m *Declare) AsReader() DeclareReader { +// AsReader returns a read-only view of this AttributeCoverage. +func (m *AttributeCoverage) AsReader() AttributeCoverageReader { if m == nil { return nil } - return &declareReadonly{v: m} + return &attributeCoverageReadonly{v: m} } -// Mutate returns a mutable deep clone of this Declare. -func (m *Declare) Mutate() *Declare { +// Mutate returns a mutable deep clone of this AttributeCoverage. +func (m *AttributeCoverage) Mutate() *AttributeCoverage { return m.CloneVT() } -// DeclareListReader provides read-only iteration over []*Declare. -type DeclareListReader interface { +// AttributeCoverageListReader provides read-only iteration over []*AttributeCoverage. +type AttributeCoverageListReader interface { Len() int - Get(i int) DeclareReader - Range(yield func(int, DeclareReader) bool) + Get(i int) AttributeCoverageReader + Range(yield func(int, AttributeCoverageReader) bool) } -type declareListReadonly []*Declare +type attributeCoverageListReadonly []*AttributeCoverage -func (l declareListReadonly) Len() int { return len(l) } +func (l attributeCoverageListReadonly) Len() int { return len(l) } -func (l declareListReadonly) Get(i int) DeclareReader { +func (l attributeCoverageListReadonly) Get(i int) AttributeCoverageReader { v := l[i] if v == nil { return nil @@ -5647,9 +5589,9 @@ func (l declareListReadonly) Get(i int) DeclareReader { return v.AsReader() } -func (l declareListReadonly) Range(yield func(int, DeclareReader) bool) { +func (l attributeCoverageListReadonly) Range(yield func(int, AttributeCoverageReader) bool) { for i, v := range l { - var r DeclareReader + var r AttributeCoverageReader if v != nil { r = v.AsReader() } @@ -5659,70 +5601,12 @@ func (l declareListReadonly) Range(yield func(int, DeclareReader) bool) { } } -// NewDeclareListReader wraps s for read-only iteration. The returned +// NewAttributeCoverageListReader wraps s for read-only iteration. The returned // view aliases the underlying slice; do not mutate s afterwards. -func NewDeclareListReader(s []*Declare) DeclareListReader { return declareListReadonly(s) } - -// TouchReader provides read-only access to Touch. -// Call Mutate() to obtain a mutable clone. -type TouchReader interface { - Mutate() *Touch -} - -type touchReadonly struct{ v *Touch } - -func (r *touchReadonly) Mutate() *Touch { - return r.v.CloneVT() -} - -// AsReader returns a read-only view of this Touch. -func (m *Touch) AsReader() TouchReader { - if m == nil { - return nil - } - return &touchReadonly{v: m} +func NewAttributeCoverageListReader(s []*AttributeCoverage) AttributeCoverageListReader { + return attributeCoverageListReadonly(s) } -// Mutate returns a mutable deep clone of this Touch. -func (m *Touch) Mutate() *Touch { - return m.CloneVT() -} - -// TouchListReader provides read-only iteration over []*Touch. -type TouchListReader interface { - Len() int - Get(i int) TouchReader - Range(yield func(int, TouchReader) bool) -} - -type touchListReadonly []*Touch - -func (l touchListReadonly) Len() int { return len(l) } - -func (l touchListReadonly) Get(i int) TouchReader { - v := l[i] - if v == nil { - return nil - } - return v.AsReader() -} - -func (l touchListReadonly) Range(yield func(int, TouchReader) bool) { - for i, v := range l { - var r TouchReader - if v != nil { - r = v.AsReader() - } - if !yield(i, r) { - return - } - } -} - -// NewTouchListReader wraps s for read-only iteration. The returned -// view aliases the underlying slice; do not mutate s afterwards. -func NewTouchListReader(s []*Touch) TouchListReader { return touchListReadonly(s) } - // AttributeValueReader provides read-only access to AttributeValue. // Call Mutate() to obtain a mutable clone. type AttributeValueReader interface { diff --git a/internal/proto/raftcmdpb/raft_cmd_vtproto.pb.go b/internal/proto/raftcmdpb/raft_cmd_vtproto.pb.go index 530364ed3..d2e33db35 100644 --- a/internal/proto/raftcmdpb/raft_cmd_vtproto.pb.go +++ b/internal/proto/raftcmdpb/raft_cmd_vtproto.pb.go @@ -2005,7 +2005,7 @@ func (m *ExecutionPlan) CloneVT() *ExecutionPlan { r.LastPersistedIndex = m.LastPersistedIndex r.CacheEpoch = m.CacheEpoch if rhs := m.Attributes; rhs != nil { - tmpContainer := make([]*AttributePlan, len(rhs)) + tmpContainer := make([]*AttributeCoverage, len(rhs)) for k, v := range rhs { tmpContainer[k] = v.CloneVT() } @@ -2029,59 +2029,14 @@ func (m *ExecutionPlan) CloneMessageVT() proto.Message { return m.CloneVT() } -func (m *AttributePlan) CloneVT() *AttributePlan { +func (m *AttributeCoverage) CloneVT() *AttributeCoverage { if m == nil { - return (*AttributePlan)(nil) + return (*AttributeCoverage)(nil) } - r := AttributePlanFromVTPool() + r := new(AttributeCoverage) r.Id = m.Id.CloneVT() r.AttrCode = m.AttrCode - if m.Intent != nil { - r.Intent = m.Intent.(interface{ CloneVT() isAttributePlan_Intent }).CloneVT() - } - if len(m.unknownFields) > 0 { - r.unknownFields = make([]byte, len(m.unknownFields)) - copy(r.unknownFields, m.unknownFields) - } - return r -} - -func (m *AttributePlan) CloneMessageVT() proto.Message { - return m.CloneVT() -} - -func (m *AttributePlan_Declare) CloneVT() isAttributePlan_Intent { - if m == nil { - return (*AttributePlan_Declare)(nil) - } - r := new(AttributePlan_Declare) - r.Declare = m.Declare.CloneVT() - return r -} - -func (m *AttributePlan_Touch) CloneVT() isAttributePlan_Intent { - if m == nil { - return (*AttributePlan_Touch)(nil) - } - r := new(AttributePlan_Touch) - r.Touch = m.Touch.CloneVT() - return r -} - -func (m *AttributePlan_Value) CloneVT() isAttributePlan_Intent { - if m == nil { - return (*AttributePlan_Value)(nil) - } - r := new(AttributePlan_Value) r.Value = m.Value.CloneVT() - return r -} - -func (m *Declare) CloneVT() *Declare { - if m == nil { - return (*Declare)(nil) - } - r := new(Declare) if len(m.unknownFields) > 0 { r.unknownFields = make([]byte, len(m.unknownFields)) copy(r.unknownFields, m.unknownFields) @@ -2089,23 +2044,7 @@ func (m *Declare) CloneVT() *Declare { return r } -func (m *Declare) CloneMessageVT() proto.Message { - return m.CloneVT() -} - -func (m *Touch) CloneVT() *Touch { - if m == nil { - return (*Touch)(nil) - } - r := new(Touch) - if len(m.unknownFields) > 0 { - r.unknownFields = make([]byte, len(m.unknownFields)) - copy(r.unknownFields, m.unknownFields) - } - return r -} - -func (m *Touch) CloneMessageVT() proto.Message { +func (m *AttributeCoverage) CloneMessageVT() proto.Message { return m.CloneVT() } @@ -5916,10 +5855,10 @@ func (this *ExecutionPlan) EqualVT(that *ExecutionPlan) bool { vy := that.Attributes[i] if p, q := vx, vy; p != q { if p == nil { - p = &AttributePlan{} + p = &AttributeCoverage{} } if q == nil { - q = &AttributePlan{} + q = &AttributeCoverage{} } if !p.EqualVT(q) { return false @@ -5953,142 +5892,26 @@ func (this *ExecutionPlan) EqualMessageVT(thatMsg proto.Message) bool { } return this.EqualVT(that) } -func (this *AttributePlan) EqualVT(that *AttributePlan) bool { +func (this *AttributeCoverage) EqualVT(that *AttributeCoverage) bool { if this == that { return true } else if this == nil || that == nil { return false } - if this.Intent == nil && that.Intent != nil { - return false - } else if this.Intent != nil { - if that.Intent == nil { - return false - } - if !this.Intent.(interface { - EqualVT(isAttributePlan_Intent) bool - }).EqualVT(that.Intent) { - return false - } - } if !this.Id.EqualVT(that.Id) { return false } if this.AttrCode != that.AttrCode { return false } - return string(this.unknownFields) == string(that.unknownFields) -} - -func (this *AttributePlan) EqualMessageVT(thatMsg proto.Message) bool { - that, ok := thatMsg.(*AttributePlan) - if !ok { - return false - } - return this.EqualVT(that) -} -func (this *AttributePlan_Declare) EqualVT(thatIface isAttributePlan_Intent) bool { - that, ok := thatIface.(*AttributePlan_Declare) - if !ok { - return false - } - if this == that { - return true - } - if this == nil && that != nil || this != nil && that == nil { - return false - } - if p, q := this.Declare, that.Declare; p != q { - if p == nil { - p = &Declare{} - } - if q == nil { - q = &Declare{} - } - if !p.EqualVT(q) { - return false - } - } - return true -} - -func (this *AttributePlan_Touch) EqualVT(thatIface isAttributePlan_Intent) bool { - that, ok := thatIface.(*AttributePlan_Touch) - if !ok { - return false - } - if this == that { - return true - } - if this == nil && that != nil || this != nil && that == nil { - return false - } - if p, q := this.Touch, that.Touch; p != q { - if p == nil { - p = &Touch{} - } - if q == nil { - q = &Touch{} - } - if !p.EqualVT(q) { - return false - } - } - return true -} - -func (this *AttributePlan_Value) EqualVT(thatIface isAttributePlan_Intent) bool { - that, ok := thatIface.(*AttributePlan_Value) - if !ok { - return false - } - if this == that { - return true - } - if this == nil && that != nil || this != nil && that == nil { - return false - } - if p, q := this.Value, that.Value; p != q { - if p == nil { - p = &AttributeValue{} - } - if q == nil { - q = &AttributeValue{} - } - if !p.EqualVT(q) { - return false - } - } - return true -} - -func (this *Declare) EqualVT(that *Declare) bool { - if this == that { - return true - } else if this == nil || that == nil { - return false - } - return string(this.unknownFields) == string(that.unknownFields) -} - -func (this *Declare) EqualMessageVT(thatMsg proto.Message) bool { - that, ok := thatMsg.(*Declare) - if !ok { - return false - } - return this.EqualVT(that) -} -func (this *Touch) EqualVT(that *Touch) bool { - if this == that { - return true - } else if this == nil || that == nil { + if !this.Value.EqualVT(that.Value) { return false } return string(this.unknownFields) == string(that.unknownFields) } -func (this *Touch) EqualMessageVT(thatMsg proto.Message) bool { - that, ok := thatMsg.(*Touch) +func (this *AttributeCoverage) EqualMessageVT(thatMsg proto.Message) bool { + that, ok := thatMsg.(*AttributeCoverage) if !ok { return false } @@ -11384,7 +11207,7 @@ func (m *ExecutionPlan) MarshalToSizedBufferVT(dAtA []byte) (int, error) { return len(dAtA) - i, nil } -func (m *AttributePlan) MarshalVT() (dAtA []byte, err error) { +func (m *AttributeCoverage) MarshalVT() (dAtA []byte, err error) { if m == nil { return nil, nil } @@ -11397,12 +11220,12 @@ func (m *AttributePlan) MarshalVT() (dAtA []byte, err error) { return dAtA[:n], nil } -func (m *AttributePlan) MarshalToVT(dAtA []byte) (int, error) { +func (m *AttributeCoverage) MarshalToVT(dAtA []byte) (int, error) { size := m.SizeVT() return m.MarshalToSizedBufferVT(dAtA[:size]) } -func (m *AttributePlan) MarshalToSizedBufferVT(dAtA []byte) (int, error) { +func (m *AttributeCoverage) MarshalToSizedBufferVT(dAtA []byte) (int, error) { if m == nil { return 0, nil } @@ -11414,14 +11237,15 @@ func (m *AttributePlan) MarshalToSizedBufferVT(dAtA []byte) (int, error) { i -= len(m.unknownFields) copy(dAtA[i:], m.unknownFields) } - if vtmsg, ok := m.Intent.(interface { - MarshalToSizedBufferVT([]byte) (int, error) - }); ok { - size, err := vtmsg.MarshalToSizedBufferVT(dAtA[:i]) + if m.Value != nil { + size, err := m.Value.MarshalToSizedBufferVT(dAtA[:i]) if err != nil { return 0, err } i -= size + i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a } if m.AttrCode != 0 { i = protohelpers.EncodeVarint(dAtA, i, uint64(m.AttrCode)) @@ -11441,129 +11265,6 @@ func (m *AttributePlan) MarshalToSizedBufferVT(dAtA []byte) (int, error) { return len(dAtA) - i, nil } -func (m *AttributePlan_Declare) MarshalToVT(dAtA []byte) (int, error) { - size := m.SizeVT() - return m.MarshalToSizedBufferVT(dAtA[:size]) -} - -func (m *AttributePlan_Declare) MarshalToSizedBufferVT(dAtA []byte) (int, error) { - i := len(dAtA) - if m.Declare != nil { - size, err := m.Declare.MarshalToSizedBufferVT(dAtA[:i]) - if err != nil { - return 0, err - } - i -= size - i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) - i-- - dAtA[i] = 0x1a - } - return len(dAtA) - i, nil -} -func (m *AttributePlan_Touch) MarshalToVT(dAtA []byte) (int, error) { - size := m.SizeVT() - return m.MarshalToSizedBufferVT(dAtA[:size]) -} - -func (m *AttributePlan_Touch) MarshalToSizedBufferVT(dAtA []byte) (int, error) { - i := len(dAtA) - if m.Touch != nil { - size, err := m.Touch.MarshalToSizedBufferVT(dAtA[:i]) - if err != nil { - return 0, err - } - i -= size - i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) - i-- - dAtA[i] = 0x22 - } - return len(dAtA) - i, nil -} -func (m *AttributePlan_Value) MarshalToVT(dAtA []byte) (int, error) { - size := m.SizeVT() - return m.MarshalToSizedBufferVT(dAtA[:size]) -} - -func (m *AttributePlan_Value) MarshalToSizedBufferVT(dAtA []byte) (int, error) { - i := len(dAtA) - if m.Value != nil { - size, err := m.Value.MarshalToSizedBufferVT(dAtA[:i]) - if err != nil { - return 0, err - } - i -= size - i = protohelpers.EncodeVarint(dAtA, i, uint64(size)) - i-- - dAtA[i] = 0x2a - } - return len(dAtA) - i, nil -} -func (m *Declare) MarshalVT() (dAtA []byte, err error) { - if m == nil { - return nil, nil - } - size := m.SizeVT() - dAtA = make([]byte, size) - n, err := m.MarshalToSizedBufferVT(dAtA[:size]) - if err != nil { - return nil, err - } - return dAtA[:n], nil -} - -func (m *Declare) MarshalToVT(dAtA []byte) (int, error) { - size := m.SizeVT() - return m.MarshalToSizedBufferVT(dAtA[:size]) -} - -func (m *Declare) MarshalToSizedBufferVT(dAtA []byte) (int, error) { - if m == nil { - return 0, nil - } - i := len(dAtA) - _ = i - var l int - _ = l - if m.unknownFields != nil { - i -= len(m.unknownFields) - copy(dAtA[i:], m.unknownFields) - } - return len(dAtA) - i, nil -} - -func (m *Touch) MarshalVT() (dAtA []byte, err error) { - if m == nil { - return nil, nil - } - size := m.SizeVT() - dAtA = make([]byte, size) - n, err := m.MarshalToSizedBufferVT(dAtA[:size]) - if err != nil { - return nil, err - } - return dAtA[:n], nil -} - -func (m *Touch) MarshalToVT(dAtA []byte) (int, error) { - size := m.SizeVT() - return m.MarshalToSizedBufferVT(dAtA[:size]) -} - -func (m *Touch) MarshalToSizedBufferVT(dAtA []byte) (int, error) { - if m == nil { - return 0, nil - } - i := len(dAtA) - _ = i - var l int - _ = l - if m.unknownFields != nil { - i -= len(m.unknownFields) - copy(dAtA[i:], m.unknownFields) - } - return len(dAtA) - i, nil -} - func (m *AttributeValue) MarshalVT() (dAtA []byte, err error) { if m == nil { return nil, nil @@ -12469,7 +12170,7 @@ var vtprotoPool_ExecutionPlan = sync.Pool{ func (m *ExecutionPlan) ResetVT() { if m != nil { for _, mm := range m.Attributes { - mm.ResetVT() + mm.Reset() } f0 := m.Attributes[:0] for _, mm := range m.IdempotencyKeys { @@ -12491,30 +12192,6 @@ func ExecutionPlanFromVTPool() *ExecutionPlan { return vtprotoPool_ExecutionPlan.Get().(*ExecutionPlan) } -var vtprotoPool_AttributePlan = sync.Pool{ - New: func() interface{} { - return &AttributePlan{} - }, -} - -func (m *AttributePlan) ResetVT() { - if m != nil { - if oneof, ok := m.Intent.(*AttributePlan_Value); ok { - oneof.Value.ReturnToVTPool() - } - m.Reset() - } -} -func (m *AttributePlan) ReturnToVTPool() { - if m != nil { - m.ResetVT() - vtprotoPool_AttributePlan.Put(m) - } -} -func AttributePlanFromVTPool() *AttributePlan { - return vtprotoPool_AttributePlan.Get().(*AttributePlan) -} - var vtprotoPool_AttributeValue = sync.Pool{ New: func() interface{} { return &AttributeValue{} @@ -14668,7 +14345,7 @@ func (m *ExecutionPlan) SizeVT() (n int) { return n } -func (m *AttributePlan) SizeVT() (n int) { +func (m *AttributeCoverage) SizeVT() (n int) { if m == nil { return 0 } @@ -14681,65 +14358,10 @@ func (m *AttributePlan) SizeVT() (n int) { if m.AttrCode != 0 { n += 1 + protohelpers.SizeOfVarint(uint64(m.AttrCode)) } - if vtmsg, ok := m.Intent.(interface{ SizeVT() int }); ok { - n += vtmsg.SizeVT() - } - n += len(m.unknownFields) - return n -} - -func (m *AttributePlan_Declare) SizeVT() (n int) { - if m == nil { - return 0 - } - var l int - _ = l - if m.Declare != nil { - l = m.Declare.SizeVT() - n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) - } - return n -} -func (m *AttributePlan_Touch) SizeVT() (n int) { - if m == nil { - return 0 - } - var l int - _ = l - if m.Touch != nil { - l = m.Touch.SizeVT() - n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) - } - return n -} -func (m *AttributePlan_Value) SizeVT() (n int) { - if m == nil { - return 0 - } - var l int - _ = l if m.Value != nil { l = m.Value.SizeVT() n += 1 + l + protohelpers.SizeOfVarint(uint64(l)) } - return n -} -func (m *Declare) SizeVT() (n int) { - if m == nil { - return 0 - } - var l int - _ = l - n += len(m.unknownFields) - return n -} - -func (m *Touch) SizeVT() (n int) { - if m == nil { - return 0 - } - var l int - _ = l n += len(m.unknownFields) return n } @@ -26116,11 +25738,11 @@ func (m *ExecutionPlan) UnmarshalVT(dAtA []byte) error { return io.ErrUnexpectedEOF } if len(m.Attributes) == cap(m.Attributes) { - m.Attributes = append(m.Attributes, &AttributePlan{}) + m.Attributes = append(m.Attributes, &AttributeCoverage{}) } else { m.Attributes = m.Attributes[:len(m.Attributes)+1] if m.Attributes[len(m.Attributes)-1] == nil { - m.Attributes[len(m.Attributes)-1] = &AttributePlan{} + m.Attributes[len(m.Attributes)-1] = &AttributeCoverage{} } } if err := m.Attributes[len(m.Attributes)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { @@ -26190,7 +25812,7 @@ func (m *ExecutionPlan) UnmarshalVT(dAtA []byte) error { } return nil } -func (m *AttributePlan) UnmarshalVT(dAtA []byte) error { +func (m *AttributeCoverage) UnmarshalVT(dAtA []byte) error { l := len(dAtA) iNdEx := 0 for iNdEx < l { @@ -26213,10 +25835,10 @@ func (m *AttributePlan) UnmarshalVT(dAtA []byte) error { fieldNum := int32(wire >> 3) wireType := int(wire & 0x7) if wireType == 4 { - return fmt.Errorf("proto: AttributePlan: wiretype end group for non-group") + return fmt.Errorf("proto: AttributeCoverage: wiretype end group for non-group") } if fieldNum <= 0 { - return fmt.Errorf("proto: AttributePlan: illegal tag %d (wire type %d)", fieldNum, wire) + return fmt.Errorf("proto: AttributeCoverage: illegal tag %d (wire type %d)", fieldNum, wire) } switch fieldNum { case 1: @@ -26275,88 +25897,6 @@ func (m *AttributePlan) UnmarshalVT(dAtA []byte) error { } } case 3: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Declare", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if oneof, ok := m.Intent.(*AttributePlan_Declare); ok { - if err := oneof.Declare.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - } else { - v := &Declare{} - if err := v.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - m.Intent = &AttributePlan_Declare{Declare: v} - } - iNdEx = postIndex - case 4: - if wireType != 2 { - return fmt.Errorf("proto: wrong wireType = %d for field Touch", wireType) - } - var msglen int - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - msglen |= int(b&0x7F) << shift - if b < 0x80 { - break - } - } - if msglen < 0 { - return protohelpers.ErrInvalidLength - } - postIndex := iNdEx + msglen - if postIndex < 0 { - return protohelpers.ErrInvalidLength - } - if postIndex > l { - return io.ErrUnexpectedEOF - } - if oneof, ok := m.Intent.(*AttributePlan_Touch); ok { - if err := oneof.Touch.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - } else { - v := &Touch{} - if err := v.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - m.Intent = &AttributePlan_Touch{Touch: v} - } - iNdEx = postIndex - case 5: if wireType != 2 { return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) } @@ -26385,120 +25925,13 @@ func (m *AttributePlan) UnmarshalVT(dAtA []byte) error { if postIndex > l { return io.ErrUnexpectedEOF } - if oneof, ok := m.Intent.(*AttributePlan_Value); ok { - if err := oneof.Value.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - } else { - v := AttributeValueFromVTPool() - if err := v.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { - return err - } - m.Intent = &AttributePlan_Value{Value: v} - } - iNdEx = postIndex - default: - iNdEx = preIndex - skippy, err := protohelpers.Skip(dAtA[iNdEx:]) - if err != nil { - return err - } - if (skippy < 0) || (iNdEx+skippy) < 0 { - return protohelpers.ErrInvalidLength - } - if (iNdEx + skippy) > l { - return io.ErrUnexpectedEOF - } - m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) - iNdEx += skippy - } - } - - if iNdEx > l { - return io.ErrUnexpectedEOF - } - return nil -} -func (m *Declare) UnmarshalVT(dAtA []byte) error { - l := len(dAtA) - iNdEx := 0 - for iNdEx < l { - preIndex := iNdEx - var wire uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - wire |= uint64(b&0x7F) << shift - if b < 0x80 { - break + if m.Value == nil { + m.Value = &AttributeValue{} } - } - fieldNum := int32(wire >> 3) - wireType := int(wire & 0x7) - if wireType == 4 { - return fmt.Errorf("proto: Declare: wiretype end group for non-group") - } - if fieldNum <= 0 { - return fmt.Errorf("proto: Declare: illegal tag %d (wire type %d)", fieldNum, wire) - } - switch fieldNum { - default: - iNdEx = preIndex - skippy, err := protohelpers.Skip(dAtA[iNdEx:]) - if err != nil { + if err := m.Value.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { return err } - if (skippy < 0) || (iNdEx+skippy) < 0 { - return protohelpers.ErrInvalidLength - } - if (iNdEx + skippy) > l { - return io.ErrUnexpectedEOF - } - m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) - iNdEx += skippy - } - } - - if iNdEx > l { - return io.ErrUnexpectedEOF - } - return nil -} -func (m *Touch) UnmarshalVT(dAtA []byte) error { - l := len(dAtA) - iNdEx := 0 - for iNdEx < l { - preIndex := iNdEx - var wire uint64 - for shift := uint(0); ; shift += 7 { - if shift >= 64 { - return protohelpers.ErrIntOverflow - } - if iNdEx >= l { - return io.ErrUnexpectedEOF - } - b := dAtA[iNdEx] - iNdEx++ - wire |= uint64(b&0x7F) << shift - if b < 0x80 { - break - } - } - fieldNum := int32(wire >> 3) - wireType := int(wire & 0x7) - if wireType == 4 { - return fmt.Errorf("proto: Touch: wiretype end group for non-group") - } - if fieldNum <= 0 { - return fmt.Errorf("proto: Touch: illegal tag %d (wire type %d)", fieldNum, wire) - } - switch fieldNum { + iNdEx = postIndex default: iNdEx = preIndex skippy, err := protohelpers.Skip(dAtA[iNdEx:]) diff --git a/misc/proto/common.proto b/misc/proto/common.proto index 3f311e630..33f734052 100644 --- a/misc/proto/common.proto +++ b/misc/proto/common.proto @@ -895,6 +895,13 @@ message TransactionState { // creation). Required for at_effective_date reverts to be deterministic // without re-reading the original log from Pebble. Timestamp timestamp = 4; + // Postings captured at creation. Populated by processCreateTransaction (and + // the mirror equivalent) so a later revert reads them from the FSM cache + // without needing to scan the Pebble-backed log — invariant #3 (no Pebble + // reads on the hot path) and invariant #8 (business decisions belong in + // the audit chain, so a revert of a non-existent tx must reach the FSM + // apply loop rather than fail-fast at admission). + repeated Posting postings = 5; } // IdempotencyKeyValue stores the outcome associated with a proposal's diff --git a/misc/proto/raft_cmd.proto b/misc/proto/raft_cmd.proto index 164365e41..c293e7c42 100644 --- a/misc/proto/raft_cmd.proto +++ b/misc/proto/raft_cmd.proto @@ -692,18 +692,19 @@ message ExecutionPlan { fixed64 lastPersistedIndex = 1; // Cache epoch at admission time; FSM rejects on mismatch (cache was reset) fixed64 cache_epoch = 4; - // Per-attribute plans produced by admission. Each entry is either a - // value preload, a Gen1→Gen0 promotion, or a pure coverage declaration — - // every plan also feeds the FSM-side state.Plan coverage set, so the - // admission layer is the single source of truth for what the FSM may - // read during apply. - repeated AttributePlan attributes = 6; + // Coverage entries produced by admission. Each entry declares that the + // FSM apply path may access the key; entries with a `value` field set + // additionally seed the FSM cache with the Pebble-loaded value at + // admission time. Every entry feeds the FSM-side state.Plan coverage + // set, so the admission layer is the single source of truth for what + // the FSM may read (and delete) during apply. + repeated AttributeCoverage attributes = 6; // Idempotency keys preloaded for this proposal. They live outside the - // AttributePlan list because they are NOT a cache attribute: the FSM - // applies them to the dedicated IdempotencyStore and the state.Plan + // AttributeCoverage list because they are NOT a cache attribute: the + // FSM applies them to the dedicated IdempotencyStore and the state.Plan // does not track their coverage. Kept as a top-level field instead of - // an AttributePlan variant to make that distinction visible at the + // an AttributeCoverage variant to make that distinction visible at the // wire level. repeated ReloadIdempotencyKey idempotency_keys = 7; @@ -711,43 +712,26 @@ message ExecutionPlan { reserved "preloads", "touches", "declared", "productions"; } -// AttributePlan describes what the FSM must do with a single attribute key -// on apply. attr_code (dal.SubAttrXxx) lives on the plan itself, not on -// the intent, so the kind/code can be read uniformly without inspecting -// the oneof — keeps coverage indexing, dispatch, and unmarshal routing -// all on the same field. Exactly one of declare/touch/value is set; that -// choice tells the cache-mutation path (Touch promotes, AttributeValue -// writes, Declare is a no-op) what to do. -message AttributePlan { +// AttributeCoverage declares the FSM apply path may access `id` under +// `attr_code`. When `value` is set, the FSM's MirrorPreload seeds the +// cache with it (CacheMiss + Pebble-load-hit at admission); when unset, +// the entry is coverage-only and Preload skips it — AttributeCache.Get's +// gen0→gen1 fallback and AttributeCache.Del's lazy Gen0-tombstone +// fabrication handle reads and deletes without a preemptive promote pass. +// attr_code (dal.SubAttrXxx) lives on the envelope so coverage indexing, +// dispatch, and unmarshal routing all read the same field. +message AttributeCoverage { AttributeID id = 1; uint32 attr_code = 2; - oneof intent { - // Declare: the key was already CacheGuaranteed (Gen0) at admission time. - // No FSM-side cache mutation; pure coverage declaration so state.Plan - // admits reads on hot keys without a false-positive miss. - Declare declare = 3; - // Touch: the key is in Gen1 and would not survive the next rotation. - // The FSM mirrors the promotion via MirrorTouch. - Touch touch = 4; - // AttributeValue: load the value into Gen0 cache. Carries the typed value. - AttributeValue value = 5; - } + // Optional seed: nil = coverage-only; non-nil = seed into the FSM cache. + AttributeValue value = 3; } -// Declare is the sentinel intent for AttributePlan. Empty by design — the -// presence of the field is the entire signal. -message Declare {} - -// Touch is the sentinel intent for AttributePlan. Empty by design — the -// presence of the field plus AttributePlan.attr_code drive the Gen1→Gen0 -// promotion on the FSM side. -message Touch {} - -// AttributeValue carries the typed value to write into the FSM-side cache -// for one AttributePlan. The value is type-erased on the wire (a vtproto- -// marshaled blob) and the FSM dispatches the typed unmarshal via the -// parent AttributePlan.attr_code (dal.SubAttrXxx) — kept off this message -// to avoid carrying the kind in two places. +// AttributeValue carries the typed value to seed the FSM-side cache for +// one AttributeCoverage entry. The value is type-erased on the wire (a +// vtproto-marshaled blob) and the FSM dispatches the typed unmarshal via +// the parent AttributeCoverage.attr_code (dal.SubAttrXxx) — kept off +// this message to avoid carrying the kind in two places. message AttributeValue { bytes raw_value = 1; } diff --git a/tests/e2e/business/ephemeral_purge_race_test.go b/tests/e2e/business/ephemeral_purge_race_test.go index 65aaa876d..5ff2719fc 100644 --- a/tests/e2e/business/ephemeral_purge_race_test.go +++ b/tests/e2e/business/ephemeral_purge_race_test.go @@ -24,7 +24,7 @@ import ( // - B touches the same account (must read its volume at apply time) // // Both are admitted while the cache holds the account, so CheckCache returns -// CacheGuaranteed and neither admission emits a preload for it. If they +// CacheHit and neither admission emits a preload for it. If they // co-batch on apply with A first, A's applyEphemeralPurge calls // fsm.Registry.Volumes.Delete which clears the in-memory cache immediately. // B then runs in the same batch with no preload and an empty cache, and diff --git a/tests/e2e/business/prepared_query_race_test.go b/tests/e2e/business/prepared_query_race_test.go index 20b300430..9c5ff195f 100644 --- a/tests/e2e/business/prepared_query_race_test.go +++ b/tests/e2e/business/prepared_query_race_test.go @@ -23,7 +23,7 @@ import ( // - create q-N again // // Both admit while the cache holds the entry, so CheckCache returns -// CacheGuaranteed and neither admission emits a preload for q-N. If the +// CacheHit and neither admission emits a preload for q-N. If the // delete is processed first in the batch, it clears the in-memory cache. // The create then runs in the same batch with no preload and an empty // cache: GetPreparedQuery returns ErrNotFound, which leaks out of diff --git a/tests/e2e/cluster/cache_divergence_test.go b/tests/e2e/cluster/cache_divergence_test.go index dc7f86319..f9ffccb0c 100644 --- a/tests/e2e/cluster/cache_divergence_test.go +++ b/tests/e2e/cluster/cache_divergence_test.go @@ -24,7 +24,7 @@ import ( // The race: during rotateLocked, currentGeneration is stored atomically BEFORE // the gen0/gen1 ShardedMaps are rotated. A concurrent CheckCache (from the // admission goroutine) can see the new generation but pre-rotation data, -// returning CacheNeedsTouch for a key about to be purged — when CacheMiss +// returning CacheHit for a key about to be purged — when CacheMiss // (full preload) was correct. // // Strategy: maximize concurrent CheckCache calls during rotations by: @@ -103,7 +103,7 @@ var _ = Describe("Cache divergence under chaos", func() { }).Within(30 * time.Second).ProbeEvery(200 * time.Millisecond).Should(Succeed()) // Now send MORE transactions through the leader while follower catches up - // These proposals may use CacheGuaranteed for volumes the follower + // These proposals may use CacheHit for volumes the follower // doesn't have — this is where the race manifests By(" Sending transactions after restart (race window)") sendParallelTransactions(ctx, servers[0].Client, ledgerName, parallelism, txPerWorker, cycle*1000+500, "COIN")