diff --git a/.github/workflows/go_build.yml b/.github/workflows/go_build.yml index 4fa7306c9..7b7d01159 100644 --- a/.github/workflows/go_build.yml +++ b/.github/workflows/go_build.yml @@ -35,13 +35,15 @@ jobs: - name: Voting, checkpoint, streaming and scheduler race regressions # Run the complete affected package suites, including subprocess crash - # recovery and cancellation tests. The independent sealevel suite has - # known base-branch failures documented in the validation report. + # recovery and cancellation tests. run: >- go test -race -p 2 -count=1 - ./pkg/alpenglow ./pkg/consensus ./pkg/replay + ./pkg/alpenglow ./pkg/consensus ./pkg/replay ./pkg/rewards ./pkg/turbine ./pkg/sigverify ./pkg/blockprod/... ./cmd/mithril/node ./cmd/mithril/configcmd - - name: Vote-program deque ownership race regression - run: go test -race -count=1 ./pkg/sealevel -run '^TestProcessNewVoteStateOwnsRetainedDeque$' + - name: Interpreter differential and memory race regressions + run: go test -race -count=1 ./pkg/sbpf/... + + - name: Sealevel interpreter, syscall and vote ownership regressions + run: go test -race -count=1 ./pkg/sealevel diff --git a/cmd/mithril/configcmd/configcmd.go b/cmd/mithril/configcmd/configcmd.go index 3236b9fcc..54fa1952c 100644 --- a/cmd/mithril/configcmd/configcmd.go +++ b/cmd/mithril/configcmd/configcmd.go @@ -158,6 +158,9 @@ authorized_voter_keypair = "" # BLS derivation signer (empty defaults to id authorized_withdrawer_keypair = "" # Authorized withdrawer keypair path (diagnostics only) tpu_quic_bind_addr = "0.0.0.0:8004" advertised_ip = "" # Required only in validator mode; public IP advertised for TPU QUIC +wait_to_vote_slot = 0 # Minimum slot for new votes; does not bypass recovery checks +tpu_max_buffered_transactions = 0 # 0 = 131,072 buffered transactions +block_completion_reserve_ms = 0 # 0 = 75ms local completion/broadcast reserve tpu_sigverify_workers = 0 # 0 = GOMAXPROCS [consensus] @@ -175,6 +178,9 @@ authorized_voter_keypair = "" # Empty defaults to authorized_withdrawer_keypair = "" tpu_quic_bind_addr = "0.0.0.0:8004" advertised_ip = "" # REQUIRED: public IP advertised for TPU QUIC +wait_to_vote_slot = 0 # Minimum slot for new votes; does not bypass recovery checks +tpu_max_buffered_transactions = 0 # 0 = 131,072 buffered transactions +block_completion_reserve_ms = 0 # 0 = 75ms local completion/broadcast reserve tpu_sigverify_workers = 0 [consensus] diff --git a/cmd/mithril/configcmd/configcmd_test.go b/cmd/mithril/configcmd/configcmd_test.go index 2d62bb8b5..d6905a819 100644 --- a/cmd/mithril/configcmd/configcmd_test.go +++ b/cmd/mithril/configcmd/configcmd_test.go @@ -13,6 +13,10 @@ func TestStarterConfigSignatureVerification(t *testing.T) { v := viper.New() v.SetConfigType("toml") require.NoError(t, v.ReadConfig(strings.NewReader(generateStarterConfig(validator)))) + for _, key := range []string{"validator.wait_to_vote_slot", "validator.tpu_max_buffered_transactions", "validator.block_completion_reserve_ms"} { + require.True(t, v.IsSet(key), key) + require.Zero(t, v.GetInt(key), key) + } require.Equal(t, "auto", v.GetString("sigverify.backend")) require.True(t, v.IsSet("sigverify.workers")) require.Zero(t, v.GetInt("sigverify.workers")) diff --git a/cmd/mithril/node/node.go b/cmd/mithril/node/node.go index 57c8f76a0..c9c964064 100644 --- a/cmd/mithril/node/node.go +++ b/cmd/mithril/node/node.go @@ -126,6 +126,9 @@ var ( pprofPort int64 blockstorePath string txParallelism int64 + // streamingMaxOpenMs is --streaming-max-open-ms; resolved into + // replay.StreamingExecutionCfg.MaxOpenAge with the other [replay] keys. + streamingMaxOpenMs int debugTxs []string debugAcctWrites []string @@ -538,6 +541,14 @@ func init() { // [replay] section flags Run.Flags().Int64Var(&txParallelism, "txpar", 0, "Transaction execution workers (>0 enables topsort parallelism; explicit 0 is sequential; unset validator mode defaults to 2x CPU cores)") Run.Flags().Int64Var(&numReplaySlots, "num-slots", 0, "Number of slots to replay (0 = run continuously)") + Run.Flags().BoolVar(&replay.StreamingExecutionCfg.Enabled, "streaming-execution", false, + "Execute Turbine blocks while their shreds arrive (Alpenglow validator/verifying modes only; the complete block remains authoritative and any mismatch falls back to whole-block execution)") + Run.Flags().IntVar(&replay.StreamingExecutionCfg.Workers, "streaming-workers", 0, + "Streaming execution workers per transaction group (0 = min(txpar, 4))") + Run.Flags().IntVar(&replay.StreamingExecutionCfg.MinGroupBatches, "streaming-min-group-batches", 0, + "Contiguous decoded batches to accumulate before a streaming group executes (0 or 1 = execute as batches arrive)") + Run.Flags().IntVar(&streamingMaxOpenMs, "streaming-max-open-ms", 0, + "Discard a streaming bank whose block has not completed after this many milliseconds (0 = 2000)") Run.Flags().Int64VarP(&endSlot, "end-slot", "e", -1, "Block at which to stop replaying, inclusive (-1 = run continuously)") // [consensus] section flags @@ -1165,6 +1176,13 @@ func initConfigAndBindFlags(cmd *cobra.Command) error { } resolvedSigverifyBackend = resolved sbpf.UsePool = getBool("use-pool", "tuning.use_pool") + // [tuning] streaming execution (off by default; Alpenglow turbine only). + replay.StreamingExecutionCfg.Enabled = getBool("streaming-execution", "tuning.streaming_execution") + replay.StreamingExecutionCfg.Workers = getInt("streaming-workers", "tuning.streaming_workers") + replay.StreamingExecutionCfg.MinGroupBatches = getInt("streaming-min-group-batches", "tuning.streaming_min_group_batches") + if ms := getInt("streaming-max-open-ms", "tuning.streaming_max_open_ms"); ms > 0 { + replay.StreamingExecutionCfg.MaxOpenAge = time.Duration(ms) * time.Millisecond + } accountsdb.StoreAccountsWorkers = getInt("store-accounts-workers", "tuning.store_accounts_workers") accountsdb.ProgramCacheMaxMB = getInt("program-cache-max-mb", "tuning.program_cache_max_mb") if accountsdb.ProgramCacheMaxMB <= 0 { diff --git a/config.example.toml b/config.example.toml index 7106f737a..4211c3871 100644 --- a/config.example.toml +++ b/config.example.toml @@ -511,6 +511,21 @@ name = "mithril" # Zstd decoder concurrency (defaults to NumCPU) # zstd_decoder_concurrency = 16 + # Streaming execution (Alpenglow, native turbine only): execute a block's + # entry batches while its remaining shreds arrive, on a speculative bank + # over the executed parent. The complete block stays authoritative — the + # executed prefix must be the block's own transactions (pointer identity) + # or the bank is discarded and the block executes whole. Off by default. + # streaming_execution = false + # Execution workers per streaming group (0 = min(txpar, 4)). + # streaming_workers = 0 + # Contiguous decoded batches to accumulate before a group executes + # (0 or 1 = execute as each batch arrives). + # streaming_min_group_batches = 0 + # Discard a speculative bank whose block has not completed after this + # many milliseconds (0 = 2000). + # streaming_max_open_ms = 0 + # Snapshot bootstrap I/O tuning. # These defaults deliberately avoid flooding a single NVMe with hundreds of # concurrent writes. Increase cautiously on very fast multi-disk systems. diff --git a/docs/leader_block_packing.md b/docs/leader_block_packing.md index fd76fe466..b30ad57a1 100644 --- a/docs/leader_block_packing.md +++ b/docs/leader_block_packing.md @@ -81,7 +81,14 @@ The corresponding flags are `--tpu-max-buffered-transactions` and `--leader-completion-reserve-ms`. The measured full-prefill trial used 262,144 queue entries and a 60ms reserve. Those are opt-in tuning values; defaults stay unchanged. A larger queue uses additional memory for owned wire, decoded and -prepared objects. A shorter reserve needs measured local finalization/broadcast +prepared objects. `BenchmarkReadonlyPairPreparationMemory` measured 728 allocated +bytes per preparation (9 allocations) on Go 1.26.4 arm64 for the 198-byte fixture. +That is 91 MiB of allocation volume for 131,072 preparations, or 182 MiB for +262,144, in addition to wire/decoded transactions and queue indexes. Allocation +volume includes temporary preparation storage: it is not retained heap or RSS. +More accounts/instructions increase the footprint; these are not worst-case caps. +Reproduce with `go test ./pkg/blockprod -run '^$' -bench '^BenchmarkReadonlyPairPreparationMemory$' -benchmem`. +A shorter reserve needs measured local finalization/broadcast margin and does not change the protocol deadline. Shifting completion also shifts later bank start times, so it does not add the same packing time to all four blocks. diff --git a/docs/rewards-unwind-retirement.md b/docs/rewards-unwind-retirement.md new file mode 100644 index 000000000..edf207be8 --- /dev/null +++ b/docs/rewards-unwind-retirement.md @@ -0,0 +1,58 @@ +# Retiring durable rewards bookkeeping + +A completed partitioned-rewards distribution used to leave its in-memory +descriptor alive for the rest of the replay attempt. The fork-switch guard +rejects any such descriptor because account-overlay unwind cannot restore the +consumed spool or its distribution counters. This is necessary while completion +is speculative, but unnecessarily forces checkpoint replay after completion +has become durable. + +Replay now observes the inactive EpochRewards sysvar in a successfully executed +bank's immutable snapshot, with zero partitions remaining. It remembers that +bank's slot and the exact distribution descriptor. Only applying a successful +durable fold through that slot retires the descriptor. Later bank observations +do not move the completion slot forward. A new descriptor/epoch invalidates the +old evidence; missing sysvars or unknown completion retain the old fallback. + +## Safety and recovery contract + +- Completion in memory, certificate finality, and submitting a fold do not + authorize retirement. Failed folds leave the durable watermark unchanged. +- Active distribution and completed-but-not-durable distribution retain the + existing rewards guard. No spool reconstruction or rewards rollback is added. +- After retirement, in-memory switches still require the existing epoch, + vote/stake-cache, parent-context, sysvar and transaction-status checks. + Switches at/below the durable watermark still require durable recovery. +- Completion evidence is replay-thread-owned and process-local. It does not + change checkpoint formats, signing reservations, persisted vote history, + clean-shutdown rules or restart authorization. Restart retains the existing + persisted EpochRewards validation. No extra file or disk sync is introduced. + +## Incident motivating the change + +On Zen 5, distribution completed at slot 3,942,001. At a later parent-linked +switch, the durable checkpoint was already 3,944,067; child 3,944,076 selected +parent 3,944,073, abandoning the suffix from 3,944,074. The remaining descriptor +forced the rewards-window fallback even though completion was below the root. +Checkpoint recovery re-fetched previously received blocks, with logged waits +of 2.739 seconds and 0.967 seconds. A buffered 665-transaction block waited +3,613.510 ms for replay admission and then executed in 7.520 ms. + +These are incident observations, not a before/after benchmark or a measurement +of checkpoint encoding/fsync time. Thirteen observed FAST aggregates omitted +our vote during the recovery interval; that does not prove absence from every +FAST aggregate or a single cause for all thirteen omissions. No live latency +improvement is established until a comparable switch exercises the new path. + +## Validation + +`rewards_retirement_test.go` covers active/missing bank state, unknown completion, +the exact durable boundary, later-bank observations, generation changes, failed +and successful folds, and an exact-parent unwind after retirement (including +account values, resume state and immutable rewards sysvars). Existing unwind +tests still require fallback for zero-remaining bookkeeping without retirement, +cross-epoch switches, dirty vote/stake caches and invalid parent snapshots. + +Full replay/rewards race suites passed locally and in the combined native +build; native node recovery/checkpoint race tests, vet and validator build also +passed. These are software tests, not mainnet power-loss qualification. diff --git a/docs/status-checkpoint-capture.md b/docs/status-checkpoint-capture.md new file mode 100644 index 000000000..8ba391cb5 --- /dev/null +++ b/docs/status-checkpoint-capture.md @@ -0,0 +1,98 @@ +# Transaction-status checkpoint capture and encoding + +Replay captures immutable lineage and coverage metadata before submitting a +checkpoint to the promotion worker. Sorting, encoding and writing happen on +that worker. Capture does not retain parent links outside the selected window. +Publication and durable-root ordering are unchanged. + +Each node memoizes its canonical encoded body on first serialization. Capture +and pruning share the same cache object when copying a node header; they never +copy a used synchronization primitive. Encoding depends on the immutable slot, +block-ID presence/value and status delta, not its parent link. Concurrent +encoders synchronize through `sync.Once` without taking the live cache lock. +Each snapshot still constructs its own coverage header and returns an owned +output buffer. The MTS2 format and restore validation are unchanged. + +The cache retains roughly one extra encoded window (30 MB for 1.5 million +keys), plus any nodes pinned by older views. There is no global encoding map: +caches become collectible with their last node/view. A completely new window +still pays for all sorting. Output copying and checkpoint I/O remain necessary. + +## Encoding benchmark + +`BenchmarkTransactionStatusCheckpointEncoding` uses a 300-root window with +5,000 keys per root (1.5 million keys, roughly 30 MB encoded). Each iteration +replaces the specified number of roots. Fixture creation and initial warming +are excluded; new node headers, sorting and output allocations are included. +The baseline is the original uncached wire encoder retained in tests. + +Apple M4 Pro, Go 1.26.4, one caller, GOMAXPROCS=12; medians of three runs: + +| New roots per checkpoint | Original encoding | Cached encoding | +| --- | ---: | ---: | +| 1 | 158.05 ms | 1.35 ms | +| 8 | 157.14 ms | 5.09 ms | +| 32 | 155.62 ms | 17.51 ms | +| 128 (default fold cadence) | 153.74 ms | 67.11 ms | +| 300 (entirely new) | 155.90 ms | 156.29 ms | + +At the default cadence, allocated bytes per encoding fell from 99.12 MB to +57.33 MB; this excludes retained heap. These are encoding measurements, not +end-to-end fold/replay timings or live FAST improvements. Data distribution +matters: newly rooted large blocks can account for most keys in the window. + +Run `go test ./pkg/replay -run '^$' -bench '^BenchmarkTransactionStatusCheckpointEncoding$' -benchmem -benchtime=1s -count=3`. + +Tests compare exact bytes with the original encoder across coverage flags, +block IDs and sorted groups; check concurrent encoding during pruning/unwind; +verify cache sharing before and after warming; and restore checkpoints after +callers mutate their own output buffers. The replay race suite and vet pass. + +Related behavior: [status expiry](transaction-status-expiry.md) and +[status publication](transaction-status-publication.md). + +## Native Zen 5 validation + +AMD Ryzen 7 9700X, Go 1.26.4, GOMAXPROCS=2, Nice 15 and a two-core CPU quota, +while the validator continued its normal workload. Same moving-window fixture; +three samples per case, medians below. This compares the original uncached +encoder with memoization, not the whole status-publication change against dev. + +| New roots per checkpoint | Original encoding | Cached encoding | +| --- | ---: | ---: | +| 1 | 195.34 ms | 3.73 ms | +| 8 | 195.15 ms | 8.56 ms | +| 32 | 194.77 ms | 23.54 ms | +| 128 (default fold cadence) | 194.52 ms | 85.02 ms | +| 300 (entirely new) | 201.88 ms | 198.55 ms | + +The default-cadence result is approximately 2.3x, with the same 99.12 → 57.33 MB +allocation reduction. Cold/all-new windows remain roughly unchanged. Native +combined race suites, vet and the validator build passed. These are historical staging measurements. They do not establish an isolated +live reduction in durable-root lag or missed FAST votes. + +## Fold admission before collecting account writes + +Replay checks for a checkpoint batch on every iteration, including skipped +slots. `WorkingSet.PromotionChunk` first counts eligible held slots under its +read lock. If fewer than the configured batch size are available, ordinary +admission returns nil without allocating account-pointer lists. When ready, +it collects only the oldest batch, not the entire eligible suffix. Forced +partial folds still collect the available prefix. + +This preflight is not a finality shortcut or a new recovery policy. Replay's +existing finality/verification gates supply the upper bound. Selection and +collection hold the same lock; account pointers retain their existing ownership +contract. Preparation does not prune the suffix or advance the durable root. +The worker's write/commit order, required resume context, checkpoint reference +validation, completion bookkeeping, and forced shutdown/epoch-boundary paths +are unchanged. + +`BenchmarkBuildFoldJobWaitingForBatch` holds 127 slots with 512 account writes +each while waiting for the default 128-slot batch. On Ryzen 9700X, +GOMAXPROCS=8, three 300 ms runs, median admission-check time fell from 426 µs +to 31.8 ns; 627,008 bytes and 134 allocations per rejected preparation became +zero. This measures an ineligible batch check, not encoding, disk I/O, or a +ready checkpoint. Boundary tests cover gaps, the finality upper bound, a full +batch, forced partial batches, and selection after promotion; existing replay +checkpoint/recovery tests cover the unchanged durable path. diff --git a/docs/status-checkpoint-expiry-evidence.md b/docs/status-checkpoint-expiry-evidence.md new file mode 100644 index 000000000..978180120 --- /dev/null +++ b/docs/status-checkpoint-expiry-evidence.md @@ -0,0 +1,19 @@ +# Status Checkpoint Expiry: benchmark evidence + +The maintained subsystem documentation and reusable Go benchmarks describe the +implementation and reproduction method. Historical raw results and session +notes are retained at [the tested source snapshot](https://github.com/Overclock-Validator/mithril/tree/a511ad3b0bc77cf8b5ae4ee16359ac6b453fc7bf) +(tag `review-evidence-20260916-status-checkpoint-expiry`). They are omitted from this proposed merge. + +[Historical result files](https://github.com/Overclock-Validator/mithril/tree/a511ad3b0bc77cf8b5ae4ee16359ac6b453fc7bf/docs/results) + +Measurements retain their original baselines. Rebasing onto PR #278 does not +turn an intermediate-version benchmark into a comparison with the new base. +Component timings and short live observations do not establish sustained FAST +inclusion gains. The final review description records validation of the rebased +source separately from historical benchmark results. + +The tagged snapshot also preserves the later 64-partition visible-status-map +experiment. That experiment is deliberately excluded from this review: it added +preparation work and did not demonstrate an overall large-block p99 benefit. +Earlier publication preparation and immutable-node encoding reuse remain. diff --git a/docs/transaction-status-expiry.md b/docs/transaction-status-expiry.md new file mode 100644 index 000000000..538acb0f8 --- /dev/null +++ b/docs/transaction-status-expiry.md @@ -0,0 +1,61 @@ +# Batched transaction-status expiry + +Applying an asynchronous checkpoint still calls `TransactionStatusCache.Root` +on replay. Live Zen 5 instruction probes measured 43–101 ms inside that function. +The previous expiry path visited every key in every retired bank, even when an +entire recent-blockhash group could be discarded. + +Expiry now examines the expired and retained bank deltas by blockhash. It drops +fully expired groups directly. For a group spanning the cutoff, it either +subtracts the expired keys or rebuilds the visible reference counts from the +retained deltas, whichever requires fewer key visits. Retained unrooted banks +are included. Physical map reclamation is still Go GC work; this is not a claim +that memory reclamation costs disappear. + +The 300-root retention rule, immediate logical expiry, duplicate-key reference +counts, selected-parent validation, checkpoint format and immutable producer +views are unchanged. All index changes remain under the existing cache lock. +This does not move unsafe mutable state to another goroutine or delay expiry. +A long-lived blockhash with many transactions on both sides of the cutoff can +still require substantial per-key work. This patch reduces that work to the +smaller side; it does not give a constant-time worst-case bound. + +## Validation + +The replay race suite, replay vet and validator production build pass. New tests +compare exact visible indexes against the original per-key removal for 100 +random lineages with shared hashes, collisions and empty groups, then unwind +surviving banks. A Root integration test checks pinned producer views, +checkpoint bytes, restored duplicate detection and rooted-unwind rejection. + +M4 Pro, Go benchmark, single caller, two iterations per case. Each iteration +expires 128 banks of 33,760 unique keys (4,321,280 entries) and retains another +33,760 entries. Setup is outside the timer. The baseline invokes the original +per-key removal; the new path invokes batched expiry. These are **expiry-path** +measurements, not end-to-end Root/replay or a prediction of live FAST scores. + +| Recent-blockhash grouping | Old expiry | Batched expiry | +|---|---:|---:| +| Groups shared by four expired banks | 185–189 ms | 0.037–0.080 ms | +| One fully expired group | 604–614 ms | 0.025–0.026 ms | +| One group shared by expired and retained banks | 590 ms | 1.63–2.36 ms | + +Run `go test ./pkg/replay -run '^$' -bench '^BenchmarkTransactionStatusBatchExpiry$' -benchtime=1x -count=2`. + +## Native benchmark + +Ryzen 7 9700X, Go 1.26.4, original per-key expiry versus batched expiry. +Benchmarks ran with GOMAXPROCS=2, nice=15, one caller and three iterations per +case, while the validator and loader remained active. Setup and later GC are +excluded from the expiry timer. Each case expires 4,321,280 entries (128 banks +of 33,760) and retains 33,760 entries. These synthetic batches exceed the earlier +live stall samples and are not an end-to-end replay or FAST-score comparison. + +| Shape | Original expiry | New expiry | +|---|---:|---:| +| Four-bank blockhash groups | 306–311 ms | 0.049–0.057 ms | +| One fully expired blockhash group | 700–718 ms | 0.024–0.031 ms | +| Group crossing the retention boundary | 717–735 ms | 1.85–2.05 ms | + +[Historical evidence](status-checkpoint-expiry-evidence.md) preserves the original +source revisions, raw measurements and validation. diff --git a/docs/transaction-status-publication.md b/docs/transaction-status-publication.md new file mode 100644 index 000000000..68ca309fa --- /dev/null +++ b/docs/transaction-status-publication.md @@ -0,0 +1,73 @@ +# Preparing transaction-status publication during execution + +Replay previously built the immutable per-bank transaction-status delta and grew the visible duplicate index only after execution and bank-state publication. In a prior live sample of 25 large blocks, TransactionStatusCommit took 7.704 ms median and 10.206 ms maximum. Those live timings motivate this change; they are not the controlled benchmark baseline below. + +Count identities by recent blockhash and allocate each delta map at its final capacity. Pre-size newly created visible maps too. For banks with more than 32 transactions and GOMAXPROCS greater than one, prepare the immutable delta during account loading and execution. Smaller banks and single-thread configurations keep the work inline. There is at most one preparation task per ProcessBlock call, and every return joins it, including rejected banks. No status becomes visible during preparation. + +The worker reads immutable prepared message identities and briefly snapshots only blockhash slice offsets under the cache read lock. It builds its private maps outside the lock. Commit checks exact block/identity binding, complete coverage and parent lineage under the publication lock. It rechecks ancestor duplicates unless the successful pre-execution validation belongs to the same cache instance, immutable identity set and unchanged cache version (see below). A changed slice offset, disappearance of a previously nonzero-offset group, or mismatched preparation triggers a rebuild from the actual block's identities. Publication still happens only after successful bank-state commit. Failed instructions within an accepted bank remain processed; rejected banks publish nothing. Pinned views, snapshots, reference counts and unwind keep their existing semantics. + +TransactionStatusPreparation measures worker wall time, which overlaps execution; it is not additive with replay wall time. TransactionStatusPreparationWait measures the residual join and is nested inside TransactionStatusCommit. The latter still includes waiting, final checks, visible-index updates and node publication. Preparation time excludes initial goroutine scheduling delay; any residual scheduling delay remains in the join/commit timer. + +## Native benchmark + +AMD Ryzen 7 9700X (Zen 5), Go 1.26.4, GOMAXPROCS=2. Tests ran in a separate process on the validator host with Nice=15 and a 200% CPU quota; the validator and loader continued running. This is a shared-host microbenchmark, with observable timing variation. Five samples per case, ten iterations per sample; values below are medians of sample means, not per-block percentiles. + +Each block has 33,760 unique prepared message identities spread across one or four recent blockhashes. Existing-group cases seed 33,760 different ancestor transactions. Fixture creation, hashing, seeding and unwind are untimed. Existing maps retain capacity after unwind: the first timed commit's growth is amortized across the ten iterations. This does not model an index growing indefinitely across live blocks. + +The frozen baseline functions exactly match alpenglow-dev commit `33dde4050d9250557583395810799aaac2f54017`. Both versions use the same prepared identities, parent/duplicate checks and fixtures. + +| Recent blockhash groups | Parent has keys in these groups | Baseline commit | Sized maps, inline | Preparation + commit, no overlap | Commit after preparation | +|---|---|---:|---:|---:|---:| +| 1 | No | 4.990 ms | 3.332 ms | 3.393 ms | 1.587 ms | +| 1 | Yes | 4.991 ms | 4.657 ms | 4.434 ms | 2.757 ms | +| 4 | No | 4.625 ms | 3.744 ms | 5.916 ms | 2.205 ms | +| 4 | Yes | 5.224 ms | 4.644 ms | 4.949 ms | 2.858 ms | + +The last column deliberately excludes delta preparation: it measures the work remaining if execution hides preparation completely. It is not total replay or CPU work. Total publication allocations with new groups fell from approximately 6.30 MB to 3.15 MB per block. Existing-group allocation figures include the amortized first growth described above. + +The four-new-group total-work sample was slower. Preserve that result rather than claiming improvement in every sample. A subsequent baseline/candidate/candidate/baseline comparison of that same case, with 50 iterations per sample, measured baseline **4.400 and 4.565 ms**, candidate **2.985 and 3.131 ms**. This supports a reduction in work but does not isolate the cause of the earlier timing variation. + +## Execution contention and small blocks + +A separate controlled benchmark performs 4,096 load-and-execute calls using the existing transfer fixture while preparing 33,760 independent status keys. It does not commit transfer accounts, and its status fixture differs from the repeated transfer fixture. It tests scheduling/allocation contention, not whole-block replay or a valid block workload. + +With two Go execution threads, the final implementation measured **20.678 ms baseline**, **18.574 ms with sizing alone**, and **17.091 ms with overlap**. Execution itself measured 15.070, 14.369 and 14.967 ms respectively. Thus preparation competed with execution relative to sizing alone, but the shorter final stage outweighed that cost in this controlled workload. These are separate medians and need not add exactly. + +The initial unrestricted version showed no additional total-time benefit from overlap with GOMAXPROCS=1. Tiny-block measurements also showed roughly a microsecond of avoidable scheduling overhead. The final implementation therefore does no background preparation with one Go execution thread or at most 32 transactions. Empty and one-transaction cases retain the baseline allocation counts. The 32-transaction case benefits from sizing without launching a worker. Threshold and single-thread behavior have regression coverage. + +## Validation and limits + +Full replay and block race suites passed on both Zen 5 and M4 Pro. Metrics has no tests. Native vet for replay/metrics and the validator build passed. Tests cover fork replacement introducing a duplicate after preparation, concurrent sibling publication, stale identity binding, changed snapshot slice offsets, rejected/incomplete banks, mismatched preparation, pinned views, snapshot restore, unwind, empty banks and scheduling boundaries. + +Raw logs, source hashes, summaries and the alternating recheck are in [results/status-publication/2026-09-15](https://github.com/Overclock-Validator/mithril/blob/a511ad3b0bc77cf8b5ae4ee16359ac6b453fc7bf/docs/results/status-publication/2026-09-15). The baseline comparison covers only status publication. No live replay or FAST improvement is claimed. The staging binary was not deployed; the existing validator remained active and voting throughout the tests. + +Reproduce from this branch: + +```sh +GOMAXPROCS=2 go test -race -p 2 ./pkg/replay ./pkg/block ./pkg/metrics -count=1 +GOMAXPROCS=2 go vet -p 2 ./pkg/replay ./pkg/metrics +GOMAXPROCS=2 go build -p 2 ./cmd/mithril +GOMAXPROCS=2 go test ./pkg/replay -run '^$' -bench '^BenchmarkTransactionStatusPublication$' -benchtime=10x -count=5 +go test ./pkg/replay -run '^$' -bench '^BenchmarkTransactionStatus(ExecutionOverlap|SmallPublication)$' -benchtime=100ms -count=5 -cpu=1,2 +``` + +## Reusing pre-execution ancestor validation + +`ProcessBlock` now carries a private validation receipt from its successful ancestor scan to status publication. Under the commit lock, an unchanged receipt avoids scanning all transaction messages again. Publication still checks block binding, complete coverage and parent lineage every time; a missing, foreign or stale receipt performs the full ancestor scan. Direct `CommitBlock` callers retain the full scan. + +The receipt is bound to the cache instance and exact immutable prepared-identity pointer. Visible-index insertion/removal, tip binding, root/prune and restore invalidate the version, including empty commits. Committing and then unwinding back to an identical parent cannot revive a receipt. Version saturation disables reuse permanently rather than wrapping. Snapshot/Agave recovery creates a new cache instance. Receipts are never persisted, and no checkpoint format, durability, voting-resume or crash-recovery guarantee changes. + +The publication benchmark adds `validated_commit` and `invalidated_commit` alongside `prepared_commit`. All three exclude delta preparation and the pre-execution scan. The first reuses that scan; the second calls `Root` between validation and publication, forcing revalidation. Each iteration unwinds and obtains a fresh receipt outside the timer. These are incremental publication comparisons, not the full PR against alpenglow-dev or per-block tail latency. Tests exercise fork replacement introducing duplicates, concurrent sibling commits, cross-cache and cross-identity misuse, snapshot replacement, pruning/root invalidation, binding changes, transaction replacement and version saturation. + +Zen 5 incremental measurement (Ryzen 9700X, Go 1.26.4, GOMAXPROCS=2, five samples × 20 iterations, Nice=19 / 200% CPU quota on the running validator host): + +| Recent blockhash groups | Existing ancestor groups | Full recheck | Reused validation | Invalidated validation | +|---|---|---|---|---| +| 1 | yes | 2.510 ms | 1.364 ms | 2.536 ms | +| 4 | yes | 2.412 ms | 1.283 ms | 2.440 ms | +| 1 | no | 1.461 ms | 1.472 ms | 1.769 ms | +| 4 | no | 1.323 ms | 1.395 ms | 1.303 ms | + +Values are medians of sample means. Existing-group cases remove approximately 1.1 ms of repeated lookup work; new-group cases show no clear gain and shared-host variation. Full native replay/block race suites, targeted node recovery race tests, vet and the combined build passed. Local replay race tests and vet also passed. + +Original run artifacts are retained in the [evidence archive](status-checkpoint-expiry-evidence.md). diff --git a/docs/transaction_sigverify_streaming.md b/docs/transaction_sigverify_streaming.md index 42e4bf311..4c40da2bb 100644 --- a/docs/transaction_sigverify_streaming.md +++ b/docs/transaction_sigverify_streaming.md @@ -157,3 +157,146 @@ For four 4,096-transaction components, medians of the three per-run statistics w Completion-finished p99 ranged 46.43–54.52 ms before and 1.477–1.719 ms after. Admission p99 ranged 44.27–53.76 ms before and 0.003206–0.06401 ms after. Every iteration reached its intended request occupancy. For 256-transaction components, completion-finished p99 medians were 3.215→1.165 ms. Shared-host scheduling introduces variation; reserving admission does not remove queued-job or CPU delays, and these 100-sample tails are not a live p99/FAST claim. Total-work throughput was roughly unchanged; no total-work tail improvement is claimed. Original run artifacts are retained in the [evidence archive](streaming-preparation-evidence.md). + +### Completion-critical shred diagnostics + +The optional `MITHRIL_ENTRY_TRACE_MOD`, `MITHRIL_ENTRY_TRACE_SECONDS` (at most +1,800), and `MITHRIL_ENTRY_TRACE_FILE` settings are read at process startup. +Use a new output file for each capture. Tracing is disabled by default. + +Large-block batch records include `critical_shred_index` and its admission +source: `non_repair`, `repair`, or `fec_recovery`. The critical index maximizes +local availability time across the batch **and its preceding DATA_COMPLETE +boundary**. Equal timestamps select the lowest index and report the tie count; +unknown coverage still sets `availability_known=false`. This identifies the +last locally available dependency, not necessarily the replay cursor's current +blocking range. Correlate with execution groups before calling it a replay stall. + +Recovered shreds identify the triggering packet's index, FEC set, coding/data +type and repair status. `non_repair` can include spool hydration. Admission entry +timestamps precede the assembler lock; they are not socket/NIC timestamps. A zero +admission-entry timestamp means it was not sampled. Admission-to-availability +includes local processing and, for recovered data, reconstruction. + +The same JSONL file also contains `event="repair_send"` records. Consumers must +separate these from block reports. Join by `origin_unix_ns`, slot and shred index, +then order by send timestamps; attempt IDs can reset. Start/end bracket the UDP +write, and `success` means only that the local write succeeded. Highest-index +probes are explicitly marked and must not be treated as exact-index requests. +Request records can exist for slots without a large-block report. Send records include the peer endpoint and nonce for response correlation. + +Both queues are bounded and producers never wait for the writer. The cumulative +`dropped_reports` counter covers queue drops and encoding failures; an absent +repair record is not proof of no request if records were dropped. Tracing does +not change repair scheduling, retry intervals, fanout or verification checks. + +### Repair ordering for streaming + +When a streaming subscriber is installed, the first priority repair slot puts +its earliest missing data span ahead of the usual cheapest-FEC-unlock ordering. +Known FEC sets still request only their recovery deficit; an unknown-layout hole +prioritizes one missing index without guessing its FEC shape. Remaining work, +other priority slots and freshness repair retain their previous ordering. +Request budgets, admission shares, retry intervals and fanout are unchanged. + +This trades completing cheap later sets first for making the contiguous input +prefix available sooner. It helps when request capacity is constrained; it does +not accelerate requests already in flight, guarantee an earlier full block, or +prove improved voting latency. The subscriber and priority head are used as the +scope; the selector does not read the execution cursor. + +`go test ./pkg/turbine/repairsim -run TestStreamingPrefixRepairUnderLimitedBudget -v` +compares both policies using authenticated generated shreds and production FEC +recovery, at fixed request budgets and a 20ms simulated round trip. It checks +identical assembled entries/transactions, request counts and full completion, +and measures availability of the first data span. It does not model production +retry timers, peer loss, execution timing, or reproduce a captured live slot. + + +Response-effectiveness tracing also emits `repair_response` and +`repair_admission` events. Treat every record with an `event` field as an event, +not a block report. Match sends/responses by origin, peer, nonce and requested +slot/index; use timestamps to disambiguate nonce reuse. Responses record the +returned index, request-registration timestamp and whether the request had +expired. Registration precedes signing/write; use `send_start_ns` for the closer +approximation to network elapsed time. A matched response is not proof that +assembly accepted it: later receive-path checks can still reject it. + +Admission events cover sampled matched-repair shreds reaching an active assembly; +join to responses by slot/returned index and chronology (no nonce is carried into +the assembler). They report accepted/duplicate/rejected, the coding-layout +recovery deficit before/after, and the number of reconstructed data shreds. +Deficit `-1` means unknown layout; zero means enough shards, not necessarily +successful recovery. Admission timestamps are local assembler entry/exit, +including lock wait and processing, not NIC timestamps. Already-completed, +evicted, or completing slots return before this instrumentation. An unmatched +or canceled response is not emitted as a matched response. Missing records, +particularly with drops or capture boundaries, cannot establish packet loss. + +### Bounded child repair lookahead + +Replay supplies its exact streaming generation as a repair anchor. The +asynchronous decoder can then recognize a decoded header for the immediate next +slot naming that parent, even while replay executes a parent group. A header +published earlier is recovered from the assembler's ready batches. The hint +permits fetching only; it does not establish fork choice, validate the final +parent block ID, or permit child execution before parent completion. + +After ordinary priority and freshness repair, leftover tokens may request up to +four missing data shreds from the child's earliest incomplete FEC span. Existing +in-flight requests for that child count against the four-request lookahead +allowance. Normal repair can independently exceed that allowance. Global rate, +per-scan and admission limits remain in force; lookahead uses bulk single-attempt +policy, with no new retry/fanout or highest-index probing. If no capacity remains, +the child waits. + +Only one child is tracked. The anchor expires after two seconds and is cleared +on parent finalize/discard, parent reset/update, or stream unsubscribe. Child +completion/reset and changed parent markers invalidate its hint. Generation +checks reject stale headers. Already-sent requests still use normal response and +expiry handling. Notifications and repair wakeups remain nonblocking/coalesced; +no extra workers or polling loop are introduced. + +### Highest-index repair followups + +A matched highest-index response triggers followup selection only after receiver +admission and FEC recovery. The assembler supplies its current deficit-aware +selection (at most 256 data requests), rather than treating the interval below +the response as missing. Completed, completing, evicted and absent assembler +slots produce no immediate followups. During disk-only catchup, selection waits +for hydration instead of blindly fetching data that may already be spooled. + +Followups retain the shared token bucket, admission limits, bulk retry policy, +and a reserved token for continued highest-index discovery when needed. A +snapshot can still race with subsequent arrivals; this removes known redundant +requests, not every possible duplicate. No assembler lock is held while signing +or sending requests. Response matching and peer credit are unchanged. + + +### Bounded speculative verification waits + +Replay joins a streaming group's signature verification with one shared 100 ms +budget, capped by the stream's remaining open lifetime. The watchdog stage is +`streaming_sigverify_wait`. Expiry discards the speculative overlay with reason +`sigverify_timeout`; it is not a signature verdict. Whole-block replay still +requires normal verification before accepting the block. + +The streaming wait only observes immutable verifier results. Timing out does not +cancel the shared request or wait for its workers: turbine continues owning its +transactions and retains reservations until readers finish. The owning completion +and cleanup paths retain their joining waits. This bounds speculative replay's +wait, not the duration of whole-block verification or recovery from a failed worker. + +`StreamingExecution.VerificationWait` records wall time spent joining groups, +including failed joins and discarded streams. It overlaps `GroupJoinAssembly` for +successful groups; do not add them together or interpret it as cryptographic CPU +cost. The 100 ms limit is a conservative fallback budget, not a measured optimum. + + +### Runtime pooling default + +Omitting `tuning.use_pool` now preserves the CLI default (`true`), instead of +silently disabling VM pooling through the config reader's zero value. Explicit +TOML `false` remains supported, and an explicitly supplied CLI flag takes +precedence. This is a runtime behavior change for previously minimal configs; +pooled memory is cleared before reuse. The flag remains the single default source. diff --git a/pkg/accounts/overlay_test.go b/pkg/accounts/overlay_test.go index f40a26dfc..682e47217 100644 --- a/pkg/accounts/overlay_test.go +++ b/pkg/accounts/overlay_test.go @@ -411,3 +411,42 @@ func TestOverlayDeltaAccountsIncludesOverride(t *testing.T) { assert.Equal(t, pk(1), delta[0].Key) assert.Equal(t, uint64(99), delta[0].Lamports) } + +func TestWorkingSetPromotionChunkBoundaries(t *testing.T) { + w := NewWorkingSet() + for _, slot := range []uint64{5, 7, 9, 11} { + w.Add(slot, []*Account{uoAcct(1, slot), uoAcct(2, slot+100)}) + } + for _, tc := range []struct { + through uint64 + limit int + partial bool + slots []uint64 + }{ + {4, 2, true, nil}, {5, 2, false, nil}, {7, 2, false, []uint64{5, 7}}, + {11, 2, false, []uint64{5, 7}}, {9, 4, true, []uint64{5, 7, 9}}, + {9, 4, false, nil}, {11, 0, true, nil}, {11, -1, false, nil}, + } { + got := w.PromotionChunk(tc.through, tc.limit, tc.partial) + var slots []uint64 + for _, sd := range got { + slots = append(slots, sd.Slot) + require.Len(t, sd.Delta, 2) + for _, acct := range sd.Delta { + require.True(t, acct.Lamports == sd.Slot || acct.Lamports == sd.Slot+100) + } + } + require.Equal(t, tc.slots, slots) + } + // Preparing a job leaves the live suffix intact. Once the caller commits + // and promotes a prefix, the next chunk must start at the surviving slot. + require.Equal(t, 4, w.HeldSlots()) + w.PromotePrefix(7) + chunk := w.PromotionChunk(11, 2, false) + require.Equal(t, []uint64{9, 11}, []uint64{chunk[0].Slot, chunk[1].Slot}) + require.Zero(t, testing.AllocsPerRun(100, func() { + if w.PromotionChunk(9, 2, false) != nil { + panic("partial chunk escaped") + } + })) +} diff --git a/pkg/accounts/working_set.go b/pkg/accounts/working_set.go index d88725f24..083a4405d 100644 --- a/pkg/accounts/working_set.go +++ b/pkg/accounts/working_set.go @@ -134,17 +134,37 @@ func (w *WorkingSet) PromotionPrefix(through uint64) []SlotDelta { w.mu.RLock() defer w.mu.RUnlock() - var batch []SlotDelta - for _, slot := range w.order { // ascending - if slot > through { - break - } + return w.promotionChunkLocked(through, len(w.order), true) +} + +// PromotionChunk returns at most maxSlots oldest held slots through the caller's +// verified promotion bound. Unless allowPartial is set, an incomplete chunk +// returns nil before allocating or collecting account writes. Selection and +// collection share one read lock, so pruning cannot change the selected prefix. +// This only prepares borrowed account pointers; it does not commit, prune, or +// advance durability. Callers still own finality checks and durable commit order. +func (w *WorkingSet) PromotionChunk(through uint64, maxSlots int, allowPartial bool) []SlotDelta { + w.mu.RLock() + defer w.mu.RUnlock() + return w.promotionChunkLocked(through, maxSlots, allowPartial) +} + +func (w *WorkingSet) promotionChunkLocked(through uint64, maxSlots int, allowPartial bool) []SlotDelta { + count := 0 + for count < len(w.order) && count < maxSlots && w.order[count] <= through { + count++ + } + if count == 0 || (!allowPartial && count < maxSlots) { + return nil + } + batch := make([]SlotDelta, count) + for i, slot := range w.order[:count] { layer := w.bySlot[slot] delta := make([]*Account, 0, len(layer.writes)) for _, a := range layer.writes { delta = append(delta, a) } - batch = append(batch, SlotDelta{Slot: slot, Delta: delta}) + batch[i] = SlotDelta{Slot: slot, Delta: delta} } return batch } diff --git a/pkg/accountsdb/accountsdb.go b/pkg/accountsdb/accountsdb.go index 49714f731..acc1666e5 100644 --- a/pkg/accountsdb/accountsdb.go +++ b/pkg/accountsdb/accountsdb.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log" + "maps" "os" "path/filepath" "runtime/trace" @@ -18,6 +19,7 @@ import ( "github.com/Overclock-Validator/mithril/pkg/accounts" "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/features" "github.com/Overclock-Validator/mithril/pkg/mlog" "github.com/Overclock-Validator/mithril/pkg/sbpf" "github.com/cockroachdb/pebble" @@ -251,6 +253,24 @@ func (accountsDb *AccountsDb) InitCaches() { type ProgramCacheEntry struct { Program *sbpf.Program DeploymentSlot uint64 + // Executables are reusable across banks only for identical source and loader + // features. Slot alone is not a version: competing forks can deploy at the + // same slot. Fields are immutable after cache publication. + sourceBytes []byte + sourceBound bool + sourceFeatures features.Features +} + +// BindSource must be called before publishing the entry; published bindings +// must never be mutated, including when another bank replaces the cache key. +func (entry *ProgramCacheEntry) BindSource(source []byte, f *features.Features) { + entry.sourceBytes = bytes.Clone(source) + entry.sourceBound = true + entry.sourceFeatures = *f.Clone() +} + +func (entry *ProgramCacheEntry) MatchesSource(source []byte, f *features.Features) bool { + return entry != nil && entry.sourceBound && bytes.Equal(entry.sourceBytes, source) && maps.Equal(entry.sourceFeatures, *f) } func programCacheCapacityUnits() int { @@ -287,7 +307,7 @@ func (entry *ProgramCacheEntry) CostUnits() uint32 { if entry == nil || entry.Program == nil { return 1 } - bytes := entry.Program.MemoryBytes() + bytes := entry.Program.MemoryBytes() + uint64(len(entry.sourceBytes)) units := (bytes + programCacheCostUnitBytes - 1) / programCacheCostUnitBytes if units == 0 { return 1 diff --git a/pkg/accountsdb/program_cache_version_test.go b/pkg/accountsdb/program_cache_version_test.go new file mode 100644 index 000000000..1d9d962a0 --- /dev/null +++ b/pkg/accountsdb/program_cache_version_test.go @@ -0,0 +1,31 @@ +package accountsdb + +import ( + "github.com/Overclock-Validator/mithril/pkg/features" + "testing" +) + +func TestProgramCacheSourceBinding(t *testing.T) { + f := features.NewFeaturesDefault() + source := []byte{1, 2, 3} + entry := &ProgramCacheEntry{DeploymentSlot: 100} + if entry.MatchesSource(source, f) { + t.Fatal("unbound entry matched") + } + entry.BindSource(source, f) + if !entry.MatchesSource(source, f) { + t.Fatal("identical source missed") + } + source[0]++ + if entry.MatchesSource(source, f) { + t.Fatal("same-slot fork source matched") + } + source[0]-- + f.EnableFeature(features.VirtualAddressSpaceAdjustments, 0) + if entry.MatchesSource(source, f) { + t.Fatal("different feature environment matched") + } + if !entry.MatchesSource(source, features.NewFeaturesDefault()) { + t.Fatal("binding retained mutable feature map") + } +} diff --git a/pkg/alpenglow/certpool.go b/pkg/alpenglow/certpool.go index 33483cc10..4383898d3 100644 --- a/pkg/alpenglow/certpool.go +++ b/pkg/alpenglow/certpool.go @@ -501,6 +501,7 @@ func (p *CertPool) finishSlotAndUnlock(slot uint64, ps *poolSlot, emits []Certif if p.slots[slot] != ps { emits = nil } + p.snap.CertsEmitted += uint64(len(emits)) target := p.publicationTargetLocked() ps.processing = false p.workCond.Broadcast() @@ -988,7 +989,6 @@ func (p *CertPool) maybeAssembleLocked(slot uint64, ps *poolSlot) []Certificate continue } p.emitted[key] = struct{}{} - p.snap.CertsEmitted++ emits = append(emits, cert) } return emits diff --git a/pkg/alpenglow/peer_sender_test.go b/pkg/alpenglow/peer_sender_test.go index dda145c4a..7595643fe 100644 --- a/pkg/alpenglow/peer_sender_test.go +++ b/pkg/alpenglow/peer_sender_test.go @@ -133,7 +133,9 @@ func testVotorBlockedPeer(t *testing.T, action string) { select { case received := <-marker: t.Logf("Healthy marker received in %s while other peer's SendDatagram is blocked", received.Sub(start)) - case <-time.After(250 * time.Millisecond): + case <-badConn.Context().Done(): + t.Fatal("healthy marker did not arrive before stalled peer was released") + case <-time.After(3 * time.Second): t.Fatal("stalled peer delayed healthy delivery") } require.NoError(t, badConn.Context().Err(), "marker must arrive before watchdog releases stalled peer") @@ -144,7 +146,7 @@ func testVotorBlockedPeer(t *testing.T, action string) { go func() { _ = b.Close(); close(closed) }() select { case <-closed: - case <-time.After(500 * time.Millisecond): + case <-time.After(3 * time.Second): t.Fatal("Close waited for stalled SendDatagram") } case "depart": @@ -158,7 +160,7 @@ func testVotorBlockedPeer(t *testing.T, action string) { } select { case <-badSender.done: - case <-time.After(500 * time.Millisecond): + case <-time.After(3 * time.Second): t.Fatal("old sender did not stop") } require.Error(t, badConn.Context().Err()) @@ -204,7 +206,9 @@ func testVotorBlockedPeer(t *testing.T, action string) { require.NoError(t, b.Enqueue(NewVoteMessage(NewSkipVote(markerSlot), testSignatureSeq(0x43), 3))) select { case <-marker: - case <-time.After(250 * time.Millisecond): + case <-badConn.Context().Done(): + t.Fatal("healthy marker did not arrive before stalled peer was released") + case <-time.After(3 * time.Second): t.Fatal("full peer queue delayed healthy delivery") } // Queue age is measured from fanout, so PTO progress no longer restarts diff --git a/pkg/block/message_identity.go b/pkg/block/message_identity.go index 067ac4dec..4796b90f8 100644 --- a/pkg/block/message_identity.go +++ b/pkg/block/message_identity.go @@ -1,6 +1,12 @@ package block -import "github.com/Overclock-Validator/mithril/pkg/txstatus" +import ( + "errors" + "fmt" + + "github.com/Overclock-Validator/mithril/pkg/txstatus" + "github.com/gagliardetto/solana-go" +) // transactionState initializes the nonserialized holder under a short global // lock. Message hashing itself is protected only by the per-block state lock, @@ -34,3 +40,64 @@ func (prepared *PreparedTransactionMessageIdentities) Identity(index int) txstat func (prepared *PreparedTransactionMessageIdentities) MatchesBlock(block *Block) bool { return block != nil && prepared.matches(block.Transactions) } + +// ErrTransactionAlreadyResolved reports a v0 transaction that already carries +// address-table resolution where an unresolved, wire-decoded object is +// required. +var ErrTransactionAlreadyResolved = errors.New("transaction address-table lookups are already resolved") + +// ExecutionCopies returns execution copies of the transactions this set is +// bound to, together with the same identities bound to those copies. +// +// Streaming replay executes a block's transactions before the block is +// complete. Execution resolves a v0 transaction's address-table lookups in +// place (solana-go's SetAddressTables refuses a second call and ResolveLookups +// appends the looked-up keys to AccountKeys), so a bank that later turns out +// not to be the block's — or the block's, on a different parent — must never +// have run the block's own objects. The copies are made here, from the +// originals this set was prepared for, so an identity can only ever be +// attached to a copy of the very transaction it was computed from: each copy +// shares the original's signatures and instructions and takes the message by +// value with its own account-key slice, which is all that resolution mutates. +// The identity itself (canonical message hash, recent blockhash) is therefore +// the original's by construction; nothing here re-authenticates the signed +// message, and callers must not treat the copies as independently verified. +// +// A bound transaction that already carries resolution is refused with +// ErrTransactionAlreadyResolved: its account keys were derived elsewhere, +// against a parent this bank cannot vouch for. +func (prepared *PreparedTransactionMessageIdentities) ExecutionCopies() ([]*solana.Transaction, *PreparedTransactionMessageIdentities, error) { + if prepared == nil || len(prepared.transactions) != len(prepared.identities) || len(prepared.transactions) != len(prepared.versions) { + return nil, nil, errors.New("prepared identities are not bound to their transactions") + } + copies := make([]*solana.Transaction, len(prepared.transactions)) + for index, tx := range prepared.transactions { + if tx == nil { + return nil, nil, fmt.Errorf("transaction %d is nil", index) + } + if tx.Message.GetVersion() == solana.MessageVersionV0 && tx.Message.IsResolved() { + return nil, nil, fmt.Errorf("transaction %d: %w", index, ErrTransactionAlreadyResolved) + } + message := tx.Message + message.AccountKeys = append(solana.PublicKeySlice(nil), tx.Message.AccountKeys...) + copies[index] = &solana.Transaction{Signatures: tx.Signatures, Message: message} + } + return copies, &PreparedTransactionMessageIdentities{ + transactions: copies, + versions: append([]solana.MessageVersion(nil), prepared.versions...), + identities: append([]txstatus.TransactionMessageIdentity(nil), prepared.identities...), + }, nil +} + +// Slice returns the prepared identities for transactions [from, to) as an +// independent prepared set bound to that sub-slice. +func (prepared *PreparedTransactionMessageIdentities) Slice(from, to int) *PreparedTransactionMessageIdentities { + if prepared == nil || from < 0 || to > len(prepared.identities) || from > to { + return nil + } + return &PreparedTransactionMessageIdentities{ + transactions: prepared.transactions[from:to:to], + versions: prepared.versions[from:to:to], + identities: prepared.identities[from:to:to], + } +} diff --git a/pkg/block/message_identity_execution_copies_test.go b/pkg/block/message_identity_execution_copies_test.go new file mode 100644 index 000000000..57c520c33 --- /dev/null +++ b/pkg/block/message_identity_execution_copies_test.go @@ -0,0 +1,95 @@ +package block + +import ( + "errors" + "testing" + + "github.com/gagliardetto/solana-go" +) + +func TestPreparedTransactionMessageIdentitiesExecutionCopies(t *testing.T) { + first, second := identityTestTransaction(1), identityTestTransaction(2) + originals := []*solana.Transaction{first, second} + prepared, err := (&Block{Transactions: originals}).PrepareTransactionMessageIdentities() + if err != nil { + t.Fatalf("prepare identities: %v", err) + } + + copies, rebound, err := prepared.ExecutionCopies() + if err != nil { + t.Fatalf("execution copies: %v", err) + } + if len(copies) != 2 || copies[0] == first || copies[1] == second { + t.Fatalf("copies must be distinct objects: %v", copies) + } + for i, tx := range copies { + if &tx.Message.AccountKeys[0] == &originals[i].Message.AccountKeys[0] { + t.Fatalf("copy %d shares the original's account-key storage", i) + } + if len(tx.Signatures) != 1 || tx.Signatures[0] != originals[i].Signatures[0] { + t.Fatalf("copy %d signatures differ", i) + } + if tx.Message.RecentBlockhash != originals[i].Message.RecentBlockhash || len(tx.Message.Instructions) != 1 { + t.Fatalf("copy %d message differs", i) + } + } + if rebound.Len() != 2 || rebound.Identity(0) != prepared.Identity(0) || rebound.Identity(1) != prepared.Identity(1) { + t.Fatalf("rebound identities differ: %+v vs %+v", rebound, prepared) + } + if !rebound.matches(copies) { + t.Fatal("rebound set is not bound to the copies") + } + if rebound.matches(originals) { + t.Fatal("rebound set must not claim the originals") + } + if !prepared.matches(originals) { + t.Fatal("making copies must not alter the original set") + } + + // Resolving a v0 copy leaves the original unresolved and still resolvable. + tableID := solana.PublicKey{0x70} + lookup := identityTestTransaction(3) + lookup.Message.SetAddressTableLookups([]solana.MessageAddressTableLookup{{AccountKey: tableID, WritableIndexes: []byte{0}}}) + if _, err := lookup.Message.SetVersion(solana.MessageVersionV0); err != nil { + t.Fatalf("set v0: %v", err) + } + staticKeys := len(lookup.Message.AccountKeys) + prepared, err = (&Block{Transactions: []*solana.Transaction{lookup}}).PrepareTransactionMessageIdentities() + if err != nil { + t.Fatalf("prepare v0 identity: %v", err) + } + copies, _, err = prepared.ExecutionCopies() + if err != nil { + t.Fatalf("v0 execution copy: %v", err) + } + tables := map[solana.PublicKey]solana.PublicKeySlice{tableID: {{0x71}}} + if err := copies[0].Message.SetAddressTables(tables); err != nil { + t.Fatalf("resolve copy: %v", err) + } + if err := copies[0].Message.ResolveLookups(); err != nil { + t.Fatalf("resolve copy: %v", err) + } + if !copies[0].Message.IsResolved() || len(copies[0].Message.AccountKeys) != staticKeys+1 { + t.Fatal("copy did not resolve") + } + if lookup.Message.IsResolved() || len(lookup.Message.AccountKeys) != staticKeys { + t.Fatal("resolving the copy touched the original") + } + if err := lookup.Message.SetAddressTables(tables); err != nil { + t.Fatalf("the original must still accept its own resolution: %v", err) + } + + // An already-resolved v0 transaction is refused. + prepared, err = (&Block{Transactions: []*solana.Transaction{copies[0]}}).PrepareTransactionMessageIdentities() + if err != nil { + t.Fatalf("prepare resolved identity: %v", err) + } + if _, _, err := prepared.ExecutionCopies(); !errors.Is(err, ErrTransactionAlreadyResolved) { + t.Fatalf("resolved input must be refused, got %v", err) + } + + var none *PreparedTransactionMessageIdentities + if _, _, err := none.ExecutionCopies(); err == nil { + t.Fatal("a nil prepared set must be rejected") + } +} diff --git a/pkg/block/verified_message_identity.go b/pkg/block/verified_message_identity.go index 9eff1d264..001067513 100644 --- a/pkg/block/verified_message_identity.go +++ b/pkg/block/verified_message_identity.go @@ -8,27 +8,45 @@ import ( "github.com/gagliardetto/solana-go" ) -// CacheVerifiedTransactionMessageIdentities publishes identities from joined -// signature-verification requests. Every result must cover the exact ordered -// transaction slice. Failed/partial requests and obsolete prefetch generations -// cannot seed the cache. It does not replace block-wide duplicate/status checks. -func (b *Block) CacheVerifiedTransactionMessageIdentities(identities []txverify.VerifiedMessageIdentity) error { - if b == nil || len(identities) != len(b.Transactions) { - return fmt.Errorf("verified message identities do not cover block transactions") +// PrepareVerifiedTransactionMessageIdentities binds identities from joined +// signature-verification requests to the exact ordered transaction slice they +// were computed for. Every identity must be a verified result for the +// transaction at the same index; failed/partial requests cannot seed a set. +func PrepareVerifiedTransactionMessageIdentities(transactions []*solana.Transaction, identities []txverify.VerifiedMessageIdentity) (*PreparedTransactionMessageIdentities, error) { + if len(identities) != len(transactions) { + return nil, fmt.Errorf("verified message identities do not cover transactions") } prepared := &PreparedTransactionMessageIdentities{ - transactions: append([]*solana.Transaction(nil), b.Transactions...), + transactions: append([]*solana.Transaction(nil), transactions...), versions: make([]solana.MessageVersion, len(identities)), identities: make([]txstatus.TransactionMessageIdentity, len(identities)), } - for i, tx := range b.Transactions { + for i, tx := range transactions { identity, ok := identities[i].ForTransaction(tx) if !ok { - return fmt.Errorf("verified message identity does not match transaction %d", i) + return nil, fmt.Errorf("verified message identity does not match transaction %d", i) } prepared.versions[i] = tx.Message.GetVersion() prepared.identities[i] = identity } + return prepared, nil +} + +// CacheVerifiedTransactionMessageIdentities publishes identities from joined +// signature-verification requests. Every result must cover the exact ordered +// transaction slice. Failed/partial requests and obsolete prefetch generations +// cannot seed the cache. It does not replace block-wide duplicate/status checks. +func (b *Block) CacheVerifiedTransactionMessageIdentities(identities []txverify.VerifiedMessageIdentity) error { + if b == nil { + return fmt.Errorf("verified message identities do not cover block transactions") + } + prepared, err := PrepareVerifiedTransactionMessageIdentities(b.Transactions, identities) + if err != nil { + if len(identities) != len(b.Transactions) { + return fmt.Errorf("verified message identities do not cover block transactions") + } + return err + } state := b.transactionState() state.mu.Lock() defer state.mu.Unlock() diff --git a/pkg/blockprod/bank.go b/pkg/blockprod/bank.go index b93e07d48..8535e86ec 100644 --- a/pkg/blockprod/bank.go +++ b/pkg/blockprod/bank.go @@ -184,7 +184,8 @@ func (b *WorkingBank) Forge(wire []byte) (ForgeResult, costmodel.ExceedReason) { return b.ForgeTransaction(tx, len(wire)) } -// ForgeTransaction executes and commits a parsed transaction. +// ForgeTransaction executes and commits a parsed transaction. The caller must +// keep it immutable: accepted transactions are retained for entry publication. func (b *WorkingBank) ForgeTransaction(tx *solana.Transaction, wireSize int) (ForgeResult, costmodel.ExceedReason) { return b.forgeTransaction(tx, wireSize, nil) } @@ -203,8 +204,15 @@ func (b *WorkingBank) forgeTransaction(tx *solana.Transaction, wireSize int, pre b.RebateSchedule(wireSize) return ForgeDroppedParse, costmodel.ExceedNone } + // Validate the full wire before execution can charge fees or publish + // account changes. Entry append then reuses this measured size and has no + // fallible serialization step after commit, including on the prepared path. + serializedSize, err := serializedTransactionSize(tx) + if err != nil { + b.RebateSchedule(wireSize) + return ForgeDroppedParse, costmodel.ExceedNone + } var messageHash [32]byte - var err error if prepared != nil { messageHash = prepared.MessageHash() } else { @@ -307,7 +315,7 @@ func (b *WorkingBank) forgeTransaction(tx *solana.Transaction, wireSize int, pre b.costs.Record(cost) execCU, loadedCost := actualExecutionUsage(output) b.costs.Rebate(cost, execCU, loadedCost) - if flushed, batchBytes, didFlush := b.entries.Append(*tx, wireSize); didFlush { + if flushed, batchBytes, didFlush := b.entries.appendSerialized(*tx, wireSize, serializedSize); didFlush { b.entryHash = b.entries.CurrentEntryHash() b.sink.OnEntryBatch(flushed, batchBytes) } diff --git a/pkg/blockprod/bank_test.go b/pkg/blockprod/bank_test.go index 98b9f7d13..13e13029f 100644 --- a/pkg/blockprod/bank_test.go +++ b/pkg/blockprod/bank_test.go @@ -819,3 +819,74 @@ func TestControllerWorkingBank(t *testing.T) { controller.SetWorkingBank(env.Bank) assert.Equal(t, env.Bank, controller.WorkingBank()) } + +func TestWorkingBankSerializationFailureDoesNotCommit(t *testing.T) { + for _, mode := range []string{"ordinary", "prepared"} { + t.Run(mode, func(t *testing.T) { + sink := &captureSink{} + env := NewTestEnv(TestEnvConfig{Sink: sink}) + defer env.Close() + env.SlotCtx.Features.EnableFeature(features.EnableTxV1, 0) + env.Bank.preparer = replay.NewTransactionPreparer(env.SlotCtx.Features) + tx := mustSignedTransfer(t, 1) + _, err := tx.Message.SetVersion(solana.MessageVersionV1) + require.NoError(t, err) + wire, err := tx.MarshalBinary() + require.NoError(t, err) + prepared := env.Bank.preparer.Prepare(tx) + if mode == "prepared" { + require.NotNil(t, prepared) + } + // Inject a malformed signature list after static preparation to + // ensure that even the prepared path cannot skip full-wire validation. + // The message is unchanged and serializable, but the full V1 wire + // cannot encode more signatures than the message header declares. + tx.Signatures = append(tx.Signatures, solana.Signature{1}) + _, err = replay.TransactionMessageHash(tx) + require.NoError(t, err) + _, err = tx.MarshalBinary() + require.ErrorContains(t, err, "signatures but header requires") + payer, err := env.SlotCtx.GetAccount(txfixture.PayerPubkey()) + require.NoError(t, err) + dest, err := env.SlotCtx.GetAccount(txfixture.DestPubkey()) + require.NoError(t, err) + payerBalance, destBalance := payer.Lamports, dest.Lamports + hash := env.Bank.EntryHash() + require.Equal(t, costmodel.ExceedNone, env.Bank.PrepareSchedule(len(wire))) + require.Positive(t, env.Bank.EntryBuilder().ReservedBytes()) + var result ForgeResult + var reason costmodel.ExceedReason + if mode == "prepared" { + result, reason = env.Bank.ForgePreparedTransaction(tx, len(wire), prepared) + } else { + result, reason = env.Bank.ForgeTransaction(tx, len(wire)) + } + require.Equal(t, ForgeDroppedParse, result) + require.Equal(t, costmodel.ExceedNone, reason) + payer, err = env.SlotCtx.GetAccount(txfixture.PayerPubkey()) + require.NoError(t, err) + dest, err = env.SlotCtx.GetAccount(txfixture.DestPubkey()) + require.NoError(t, err) + require.Equal(t, payerBalance, payer.Lamports) + require.Equal(t, destBalance, dest.Lamports) + require.Zero(t, env.Bank.TxFeeAccumulator().TotalFees) + require.Zero(t, env.Bank.CostTracker().BlockCost()) + require.Zero(t, env.Bank.NumSignatures()) + require.Empty(t, env.Bank.ForgedTransactions()) + require.Empty(t, env.Bank.seenMessages) + require.Empty(t, env.SlotCtx.ModifiedAccts) + require.Zero(t, env.Bank.EntryBuilder().PendingCount()) + require.Zero(t, env.Bank.EntryBuilder().ReservedBytes()) + require.Zero(t, env.Bank.EntryBytes()) + require.Equal(t, hash, env.Bank.EntryHash()) + require.Empty(t, sink.batches) + // The same message remains eligible after correcting the wire. + tx.Signatures = tx.Signatures[:1] + result, reason = env.Bank.ForgeTransaction(tx, len(wire)) + require.Equal(t, ForgeAccepted, result) + require.Equal(t, costmodel.ExceedNone, reason) + require.Zero(t, env.Bank.EntryBuilder().ReservedBytes()) + require.Equal(t, 1, env.Bank.EntryBuilder().PendingCount()) + }) + } +} diff --git a/pkg/blockprod/entry.go b/pkg/blockprod/entry.go index 95e51d600..75ad95a19 100644 --- a/pkg/blockprod/entry.go +++ b/pkg/blockprod/entry.go @@ -105,29 +105,44 @@ func (b *EntryBuilder) dropReservation() { // transaction would overflow the configured batch target. A short leftover is only emitted by // Flush (slot end / Freeze). Appended transactions must remain immutable. func (b *EntryBuilder) Append(tx solana.Transaction, wireSize int) ([]turbine.Entry, int, bool) { - // Canonical component bytes may differ from a transport-size hint. Measure - // once per transaction and reuse the count at flush, preserving slot budgets. + serializedSize, err := serializedTransactionSize(&tx) + if err != nil { + return nil, 0, false + } + return b.appendSerialized(tx, wireSize, serializedSize) +} + +// serializedTransactionSize validates the complete transaction, not just its +// message. In particular, v1 signature-count errors surface only here. +func serializedTransactionSize(tx *solana.Transaction) (int, error) { wire, err := tx.MarshalBinary() if err != nil { _ = statsd.Count(statsd.BlockProductionEntrySerializationErrors, 1, nil) - mlog.Log.Errorf("entry builder: cannot serialize applied transaction: %v", err) - return nil, 0, false + mlog.Log.Errorf("entry builder: cannot serialize transaction: %v", err) + return 0, err } + return len(wire), nil +} + +// appendSerialized cannot fail after bank state is applied: the caller has +// already serialized this exact transaction and must keep it immutable. Keep +// canonical component size separate from the transport reservation-size hint. +func (b *EntryBuilder) appendSerialized(tx solana.Transaction, wireSize, serializedSize int) ([]turbine.Entry, int, bool) { if wireSize <= 0 { - wireSize = len(wire) + wireSize = serializedSize } b.consumeReserved(wireSize) - if b.wouldOverflowBatch(len(wire)) { + if b.wouldOverflowBatch(serializedSize) { flushed, batchBytes := b.flushLocked() b.pendingTxns = append(b.pendingTxns[:0], tx) - b.pendingSerializedBytes = len(wire) + b.pendingSerializedBytes = serializedSize b.pendingWire = wireSize return flushed, batchBytes, true } b.pendingTxns = append(b.pendingTxns, tx) - b.pendingSerializedBytes += len(wire) + b.pendingSerializedBytes += serializedSize b.pendingWire += wireSize return nil, 0, false } diff --git a/pkg/blockprod/readonly_block_prepared_bench_test.go b/pkg/blockprod/readonly_block_prepared_bench_test.go index 68957fcef..ccf2380de 100644 --- a/pkg/blockprod/readonly_block_prepared_bench_test.go +++ b/pkg/blockprod/readonly_block_prepared_bench_test.go @@ -43,3 +43,19 @@ func BenchmarkReadonlyPairPreparedFullBlock(b *testing.B) { } b.ReportMetric(float64(readonlyBlockAccepted), "tx/block") } + +// Allocations per preparation bound the incremental prepared-object footprint; +// excludes the already decoded transaction and wire bytes owned by the queue. +func BenchmarkReadonlyPairPreparationMemory(b *testing.B) { + f := makeReadonlyBlockFixture(b, 1) + setup := f.bank(b, nil) + defer setup.Close() + preparer := replay.NewTransactionPreparer(setup.SlotCtx.Features) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if preparer.Prepare(f.txs[0]) == nil { + b.Fatal("preparation failed") + } + } +} diff --git a/pkg/blockstream/block_source.go b/pkg/blockstream/block_source.go index 49212382c..6c9b6f4e1 100644 --- a/pkg/blockstream/block_source.go +++ b/pkg/blockstream/block_source.go @@ -47,18 +47,21 @@ type BlockSourceOpts struct { // Enables Alpenglow/Votor block-id hints for the Turbine assembler. Classic // Solana clusters leave this off even when blocks are sourced from Turbine. TurbineAlpenglowBlockIDHints bool - TurbineIdentity ed25519.PrivateKey - LeaderForSlot func(slot uint64) (solana.PublicKey, bool) - TurbineStakesForSlot func(slot uint64) map[solana.PublicKey]uint64 - TurbineEpochForSlot func(slot uint64) uint64 - TurbineRootSlot func() uint64 - TurbineUseChaCha8 bool - TurbineDedupAddrs bool - LocalLeaderForSlot func(slot uint64) bool - GossipClient *gossip.Client - AlpenglowDecisionSource func(anchorSlot uint64) (alpenglow.ChainDecision, bool) - AlpenglowCandidateBlockSink func(alpenglow.ReplayBlockObservation) - AlpenglowInvalidBlockSink func(alpenglow.BlockID, string) error + // TurbineStreamingExecution subscribes replay's streaming executor to the + // assembler's decoded-batch feed (StreamEvents). Off by default. + TurbineStreamingExecution bool + TurbineIdentity ed25519.PrivateKey + LeaderForSlot func(slot uint64) (solana.PublicKey, bool) + TurbineStakesForSlot func(slot uint64) map[solana.PublicKey]uint64 + TurbineEpochForSlot func(slot uint64) uint64 + TurbineRootSlot func() uint64 + TurbineUseChaCha8 bool + TurbineDedupAddrs bool + LocalLeaderForSlot func(slot uint64) bool + GossipClient *gossip.Client + AlpenglowDecisionSource func(anchorSlot uint64) (alpenglow.ChainDecision, bool) + AlpenglowCandidateBlockSink func(alpenglow.ReplayBlockObservation) + AlpenglowInvalidBlockSink func(alpenglow.BlockID, string) error // AlpenglowCandidateValidator prevents objectively invalid assembled blocks // from polluting the early ancestry tracker. Replay independently validates // again at the consensus boundary before observing or executing the block. @@ -447,6 +450,11 @@ type BlockSource struct { knownAlpenglowBlockIDs map[uint64]solana.Hash knownAlpenglowBlockIDOrder []uint64 activeTurbineReceiver *turbine.UDPReceiver + // streamEvents carries the turbine streaming feed to replay; nil unless + // TurbineStreamingExecution was requested. Sized for several blocks of + // batches; a full channel drops wake-ups, which the consumer tolerates by + // polling PendingStreamBatches. + streamEvents chan turbine.StreamEvent // Repair-first catchup: gap slots [repairCatchupFrom, repairCatchupUntil] // fill via turbine repair; RPC never fetches at/above the gate while // pending or active. The pending hold persists from construction until @@ -766,6 +774,7 @@ func NewBlockSource(opts *BlockSourceOpts) *BlockSource { turbineShredVersion: opts.TurbineShredVersion, turbineAlpenglowAddr: opts.TurbineAlpenglowAddr, turbineAlpenglowBlockIDHints: opts.TurbineAlpenglowBlockIDHints, + streamEvents: newStreamEventChannel(opts), turbineIdentity: clonePrivateKey(opts.TurbineIdentity), leaderForSlot: opts.LeaderForSlot, turbineStakesForSlot: opts.TurbineStakesForSlot, @@ -3775,20 +3784,48 @@ func (bs *BlockSource) NextBlock() *b.Block { // may be nil to disable decision wakeups, but must never be closed. The third // result distinguishes a decision wakeup from a closed stream or cancellation. func (bs *BlockSource) NextBlockOrAlpenglowEvent(ctx context.Context, decisionChanges <-chan struct{}) (block *b.Block, parentSwitch *AlpenglowParentSwitch, decisionChanged bool) { + in := bs.NextReplayInput(ctx, decisionChanges, nil, nil) + return in.Block, in.ParentSwitch, in.DecisionChanged +} + +// ReplayInput is one wake-up of replay's wait for the next thing to do. At +// most one field is set; the zero value means the wait context ended or the +// source closed. +type ReplayInput struct { + Block *b.Block + ParentSwitch *AlpenglowParentSwitch + DecisionChanged bool + // StreamEvent is a turbine streaming-feed wake-up (see StreamEvents). + StreamEvent *turbine.StreamEvent + // StreamTick is the streaming executor's poll timer. + StreamTick bool +} + +// NextReplayInput is NextBlockOrAlpenglowEvent extended with the streaming +// feed and the executor's poll timer, both of which may be nil (never fire). +// A queued parent switch keeps its priority; among the remaining inputs the +// choice is the runtime's, which is fine because every stream wake-up is +// idempotent and the complete block is processed the same way whether or not +// the feed was drained first. +func (bs *BlockSource) NextReplayInput(ctx context.Context, decisionChanges <-chan struct{}, streamEvents <-chan turbine.StreamEvent, streamTick <-chan time.Time) ReplayInput { select { case event := <-bs.alpenglowParentSwitchCh: - return nil, &event, false + return ReplayInput{ParentSwitch: &event} default: } select { case event := <-bs.alpenglowParentSwitchCh: - return nil, &event, false + return ReplayInput{ParentSwitch: &event} case block := <-bs.streamChan: - return block, nil, false + return ReplayInput{Block: block} case <-decisionChanges: - return nil, nil, true + return ReplayInput{DecisionChanged: true} + case event := <-streamEvents: + return ReplayInput{StreamEvent: &event} + case <-streamTick: + return ReplayInput{StreamTick: true} case <-ctx.Done(): - return nil, nil, false + return ReplayInput{} } } diff --git a/pkg/blockstream/turbine_stream.go b/pkg/blockstream/turbine_stream.go index 1d1228c45..5e53b32a9 100644 --- a/pkg/blockstream/turbine_stream.go +++ b/pkg/blockstream/turbine_stream.go @@ -221,6 +221,9 @@ func (bs *BlockSource) attachAlpenglowBlockIDHintsToReceiver(receiver *turbine.U // would then make its slot permanently unfetchable. bs.alpenglowMu.Lock() bs.activeTurbineReceiver = receiver + if bs.streamEvents != nil { + receiver.SubscribeStream(bs.streamEvents) + } if !bs.turbineAlpenglowBlockIDHints { bs.alpenglowMu.Unlock() return @@ -568,3 +571,63 @@ func (bs *BlockSource) runTurbineStream() { } } } + +// streamEventBuffer bounds the streaming feed: a heavy block is a few hundred +// DATA_COMPLETE ranges, and the consumer drains between groups. +const streamEventBuffer = 4096 + +func newStreamEventChannel(opts *BlockSourceOpts) chan turbine.StreamEvent { + if opts == nil || opts.SourceType != BlockSourceTurbine || !opts.TurbineStreamingExecution { + return nil + } + return make(chan turbine.StreamEvent, streamEventBuffer) +} + +// StreamEvents is the turbine streaming feed for replay's streaming executor; +// nil when streaming execution is not enabled for this source. +func (bs *BlockSource) StreamEvents() <-chan turbine.StreamEvent { + if bs.streamEvents == nil { + return nil + } + return bs.streamEvents +} + +// StreamStatusOf reports the assembler's view of a streaming generation +// through the active receiver; a generation is gone when no receiver is +// active. +func (bs *BlockSource) StreamStatusOf(g turbine.StreamGeneration) turbine.StreamStatus { + bs.alpenglowMu.Lock() + receiver := bs.activeTurbineReceiver + bs.alpenglowMu.Unlock() + if receiver == nil { + return turbine.StreamGone + } + return receiver.StreamStatusOf(g) +} + +// PendingStreamBatches returns the generation's decoded batches at or after +// fromStart, in shred-index order, from the active receiver. +func (bs *BlockSource) PendingStreamBatches(g turbine.StreamGeneration, fromStart uint32) []*turbine.StreamBatch { + bs.alpenglowMu.Lock() + receiver := bs.activeTurbineReceiver + bs.alpenglowMu.Unlock() + if receiver == nil { + return nil + } + return receiver.PendingStreamBatches(g, fromStart) +} + +// PrioritizeStreamRepair keeps a slot that replay is executing while its +// shreds arrive pinned for repair, since the emitter pins the head only when +// it observes a gap. +func (bs *BlockSource) PrioritizeStreamRepair(g turbine.StreamGeneration) { + if bs.sourceType != BlockSourceTurbine || !bs.turbineAlpenglowBlockIDHints { + return + } + bs.alpenglowMu.Lock() + receiver := bs.activeTurbineReceiver + bs.alpenglowMu.Unlock() + if receiver != nil { + receiver.PrioritizeStreamRepair(g) + } +} diff --git a/pkg/consensus/voter.go b/pkg/consensus/voter.go index 4a9cbbc15..3f950126b 100644 --- a/pkg/consensus/voter.go +++ b/pkg/consensus/voter.go @@ -384,6 +384,7 @@ func (v *alpenglowVoter) loop() { for _, event := range pending { if err := v.handle(event); err != nil { v.engine.latchSafetyError(fmt.Errorf("reservation retry: %w", err)) + mlog.Log.Errorf("ALPENGLOW VOTING SAFETY: reservation retry: %v", err) return } } @@ -827,7 +828,7 @@ func (v *alpenglowVoter) sign(vote alpenglow.Vote, respectVotingGate bool) (alpe if err := v.engine.safetyError(); err != nil { return alpenglow.VoteMessage{}, alpenglow.VoteVerifyResult{}, err } - if v.reservation != nil && !v.reservation.allow(vote.Slot, v.engine.alpenglowVerifiedFinalityFloor(), false) { + if !respectVotingGate && v.reservation != nil && !v.reservation.allow(vote.Slot, v.engine.alpenglowVerifiedFinalityFloor(), false) { return alpenglow.VoteMessage{}, alpenglow.VoteVerifyResult{}, fmt.Errorf("%w: waiting for verified recovery or durable signing reservation", errVoterNotReady) } if respectVotingGate { diff --git a/pkg/costmodel/costmodel_test.go b/pkg/costmodel/costmodel_test.go index f5e41ec45..82160999c 100644 --- a/pkg/costmodel/costmodel_test.go +++ b/pkg/costmodel/costmodel_test.go @@ -81,15 +81,6 @@ func TestEstimateTransactionCostCountsPrecompileSignatures(t *testing.T) { ) } -func TestLimitsForFeaturesRaiseBlockLimitsTo100m(t *testing.T) { - feats := features.NewFeaturesDefault() - assert.Equal(t, uint64(MaxBlockUnitsSIMD0256), LimitsForFeatures(feats).BlockCost) - - feats.EnableFeature(features.RaiseBlockLimitsTo100m, 123) - assert.Equal(t, uint64(MaxBlockUnitsSIMD0286), LimitsForFeatures(feats).BlockCost) - assert.Equal(t, uint64(MaxBlockUnitsSIMD0256), DefaultLimits().BlockCost) -} - func TestWritableAccountsUsesUnsignedWritableRange(t *testing.T) { tx := &solana.Transaction{Message: solana.Message{ Header: solana.MessageHeader{ @@ -284,3 +275,12 @@ func TestCostTrackerAcceptsUnderLimits(t *testing.T) { assert.Equal(t, ExceedNone, tracker.WouldExceed(cost)) _ = wire } + +func TestPackEntryBytesMaxChargesEveryFECSetInBatch(t *testing.T) { + // One initial set, two reserved ending sets, then exactly one full batch. + shreds := uint64((1 + 2 + FECSetsPerBatch) * DataShredsPerFECSet) + want := uint64(DefaultTargetBatchBytes - 8 - MaxMicroblockBytes) + assert.Equal(t, want, PackEntryBytesMax(shreds, MaxMicroblockBytes)) + assert.Equal(t, want, PackEntryBytesMax(shreds+DataShredsPerFECSet-1, MaxMicroblockBytes)) + assert.Zero(t, PackEntryBytesMax(shreds-DataShredsPerFECSet, MaxMicroblockBytes)) +} diff --git a/pkg/costmodel/entry_bytes.go b/pkg/costmodel/entry_bytes.go index 7cfec6481..ebf89fc47 100644 --- a/pkg/costmodel/entry_bytes.go +++ b/pkg/costmodel/entry_bytes.go @@ -1,7 +1,7 @@ package costmodel // PackEntryBytesMax is the shred-safe entry-byte bound: we close a batch when -// the next microblock would not fit in one FEC set, so padding is at most one +// the next microblock would not fit in FECSetsPerBatch FEC sets, so padding is at most one // microblock. The slot cap is still min(this, SIMD-0525), decided at schedule // time like Firedancer pack. // @@ -23,7 +23,7 @@ func PackEntryBytesMax(slotMaxDataShreds, maxMicroblock uint64) uint64 { } middle := fecSets - first - lastFEC minBatch := wmark - maxMicroblock - return middle * minBatch + return (middle / FECSetsPerBatch) * minBatch } // DefaultPackEntryBytes is min(shred-safe, SIMD-0525) minus one ending tick. diff --git a/pkg/costmodel/limits.go b/pkg/costmodel/limits.go index 26091a3d0..0afdf925f 100644 --- a/pkg/costmodel/limits.go +++ b/pkg/costmodel/limits.go @@ -79,7 +79,6 @@ func DefaultLimits() Limits { } } -// LimitsForFeatures returns the legacy 400ms budgets. Live banks must use // LimitsForSlot to apply slot-time reductions at the correct epoch boundary. func LimitsForFeatures(feats *features.Features) Limits { limits := DefaultLimits() diff --git a/pkg/global/global_ctx.go b/pkg/global/global_ctx.go index f8241f771..d99d9c5c6 100644 --- a/pkg/global/global_ctx.go +++ b/pkg/global/global_ctx.go @@ -104,9 +104,19 @@ func PendingStakeEntriesSnapshot() []accountsdb.StakeIndexEntry { return out } +// DropPendingStakePubkeys drops only the discarded bank's slot. A local leader +// can have pending entries at later slots even while replay is behind it. +func DropPendingStakePubkeys(slot uint64) int { + instance.pendingStakeMutex.Lock() + defer instance.pendingStakeMutex.Unlock() + dropped := len(instance.pendingStakeBySlot[slot]) + delete(instance.pendingStakeBySlot, slot) + return dropped +} + // DropPendingStakePubkeysFrom discards pending entries for slots >= fromSlot. -// Called by the fork-switch unwind so wrong-fork stake entries never reach the -// durable index. Returns the number of entries dropped. +// This is a global reset operation. Per-bank discard and replay unwind must +// use DropPendingStakePubkeys so they preserve independent leader banks. func DropPendingStakePubkeysFrom(fromSlot uint64) int { instance.pendingStakeMutex.Lock() defer instance.pendingStakeMutex.Unlock() diff --git a/pkg/metrics/account_loader_test.go b/pkg/metrics/account_loader_test.go new file mode 100644 index 000000000..fed0c86ed --- /dev/null +++ b/pkg/metrics/account_loader_test.go @@ -0,0 +1,40 @@ +package metrics + +import ( + "reflect" + "testing" +) + +// Exercise every field so adding a metric without merging it is caught. +func TestAccountLoaderAccumulateAllFields(t *testing.T) { + var src AccountLoader + var fill func(reflect.Value) + fill = func(v reflect.Value) { + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + if f.Kind() == reflect.Struct { + fill(f) + } else { + f.SetUint(uint64(i + 1)) + } + } + } + fill(reflect.ValueOf(&src).Elem()) + var dst AccountLoader + dst.Accumulate(src) + if dst != src { + t.Fatal("first merge lost fields") + } + dst.Accumulate(src) + var check func(reflect.Value, reflect.Value) + check = func(a, b reflect.Value) { + for i := 0; i < a.NumField(); i++ { + if a.Field(i).Kind() == reflect.Struct { + check(a.Field(i), b.Field(i)) + } else if a.Field(i).Uint() != 2*b.Field(i).Uint() { + t.Errorf("field %s not accumulated", a.Type().Field(i).Name) + } + } + } + check(reflect.ValueOf(dst), reflect.ValueOf(src)) +} diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 3f87fa287..fd807da0b 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -32,6 +32,8 @@ func (t *Timing) AddTimingSince(start time.Time) { // AccountLoader is the per-slot decomposition of LoadBlockAccounts. Counters // describe logical loader work; allocation counters cover objects/data created // directly by the batch loader rather than runtime or Pebble internals. +// Counts sum loader operations across groups, not unique keys/files per block; +// in particular UniqueAppendVecs sums each batch's distinct file count. type AccountLoader struct { AddressTableLookups Timing DedupeBlockAccounts Timing @@ -104,6 +106,103 @@ type AccountLoader struct { SysvarCachePublicationEpochRejects uint64 } +// Accumulate merges completed loader work. Both records must be exclusively +// owned by the replay goroutine; this is not a concurrent snapshot operation. +func (dst *AccountLoader) Accumulate(src AccountLoader) { + dst.AddressTableLookups.Count += src.AddressTableLookups.Count + dst.AddressTableLookups.SumNanoseconds += src.AddressTableLookups.SumNanoseconds + dst.DedupeBlockAccounts.Count += src.DedupeBlockAccounts.Count + dst.DedupeBlockAccounts.SumNanoseconds += src.DedupeBlockAccounts.SumNanoseconds + dst.SourceBatch.Count += src.SourceBatch.Count + dst.SourceBatch.SumNanoseconds += src.SourceBatch.SumNanoseconds + dst.ParentMapBuild.Count += src.ParentMapBuild.Count + dst.ParentMapBuild.SumNanoseconds += src.ParentMapBuild.SumNanoseconds + dst.SysvarUpdates.Count += src.SysvarUpdates.Count + dst.SysvarUpdates.SumNanoseconds += src.SysvarUpdates.SumNanoseconds + dst.SysvarClockRead.Count += src.SysvarClockRead.Count + dst.SysvarClockRead.SumNanoseconds += src.SysvarClockRead.SumNanoseconds + dst.SysvarSlotHashesRead.Count += src.SysvarSlotHashesRead.Count + dst.SysvarSlotHashesRead.SumNanoseconds += src.SysvarSlotHashesRead.SumNanoseconds + dst.SysvarRecentBlockhashesRead.Count += src.SysvarRecentBlockhashesRead.Count + dst.SysvarRecentBlockhashesRead.SumNanoseconds += src.SysvarRecentBlockhashesRead.SumNanoseconds + dst.SysvarSlotHistoryRead.Count += src.SysvarSlotHistoryRead.Count + dst.SysvarSlotHistoryRead.SumNanoseconds += src.SysvarSlotHistoryRead.SumNanoseconds + dst.SysvarStakeHistoryRead.Count += src.SysvarStakeHistoryRead.Count + dst.SysvarStakeHistoryRead.SumNanoseconds += src.SysvarStakeHistoryRead.SumNanoseconds + dst.SysvarLastRestartSlotRead.Count += src.SysvarLastRestartSlotRead.Count + dst.SysvarLastRestartSlotRead.SumNanoseconds += src.SysvarLastRestartSlotRead.SumNanoseconds + dst.WorkingSetLookup.Count += src.WorkingSetLookup.Count + dst.WorkingSetLookup.SumNanoseconds += src.WorkingSetLookup.SumNanoseconds + dst.InProgressLookup.Count += src.InProgressLookup.Count + dst.InProgressLookup.SumNanoseconds += src.InProgressLookup.SumNanoseconds + dst.AppendVecPinWait.Count += src.AppendVecPinWait.Count + dst.AppendVecPinWait.SumNanoseconds += src.AppendVecPinWait.SumNanoseconds + dst.ReadCacheEpochWait.Count += src.ReadCacheEpochWait.Count + dst.ReadCacheEpochWait.SumNanoseconds += src.ReadCacheEpochWait.SumNanoseconds + dst.CacheLookup.Count += src.CacheLookup.Count + dst.CacheLookup.SumNanoseconds += src.CacheLookup.SumNanoseconds + dst.AdmissionFilter.Count += src.AdmissionFilter.Count + dst.AdmissionFilter.SumNanoseconds += src.AdmissionFilter.SumNanoseconds + dst.IndexLookup.Count += src.IndexLookup.Count + dst.IndexLookup.SumNanoseconds += src.IndexLookup.SumNanoseconds + dst.ReadPlanning.Count += src.ReadPlanning.Count + dst.ReadPlanning.SumNanoseconds += src.ReadPlanning.SumNanoseconds + dst.AppendVecRead.Count += src.AppendVecRead.Count + dst.AppendVecRead.SumNanoseconds += src.AppendVecRead.SumNanoseconds + dst.CachePublicationWait.Count += src.CachePublicationWait.Count + dst.CachePublicationWait.SumNanoseconds += src.CachePublicationWait.SumNanoseconds + dst.CachePublication.Count += src.CachePublication.Count + dst.CachePublication.SumNanoseconds += src.CachePublication.SumNanoseconds + dst.SysvarWorkingSetLookup.Count += src.SysvarWorkingSetLookup.Count + dst.SysvarWorkingSetLookup.SumNanoseconds += src.SysvarWorkingSetLookup.SumNanoseconds + dst.SysvarClone.Count += src.SysvarClone.Count + dst.SysvarClone.SumNanoseconds += src.SysvarClone.SumNanoseconds + dst.SysvarAppendVecPinWait.Count += src.SysvarAppendVecPinWait.Count + dst.SysvarAppendVecPinWait.SumNanoseconds += src.SysvarAppendVecPinWait.SumNanoseconds + dst.SysvarInProgressLookup.Count += src.SysvarInProgressLookup.Count + dst.SysvarInProgressLookup.SumNanoseconds += src.SysvarInProgressLookup.SumNanoseconds + dst.SysvarReadCacheEpochWait.Count += src.SysvarReadCacheEpochWait.Count + dst.SysvarReadCacheEpochWait.SumNanoseconds += src.SysvarReadCacheEpochWait.SumNanoseconds + dst.SysvarCacheLookup.Count += src.SysvarCacheLookup.Count + dst.SysvarCacheLookup.SumNanoseconds += src.SysvarCacheLookup.SumNanoseconds + dst.SysvarIndexAndAppendVecRead.Count += src.SysvarIndexAndAppendVecRead.Count + dst.SysvarIndexAndAppendVecRead.SumNanoseconds += src.SysvarIndexAndAppendVecRead.SumNanoseconds + dst.SysvarCachePublicationWait.Count += src.SysvarCachePublicationWait.Count + dst.SysvarCachePublicationWait.SumNanoseconds += src.SysvarCachePublicationWait.SumNanoseconds + dst.SysvarCachePublication.Count += src.SysvarCachePublication.Count + dst.SysvarCachePublication.SumNanoseconds += src.SysvarCachePublication.SumNanoseconds + dst.RequestedKeys += src.RequestedKeys + dst.DurableKeys += src.DurableKeys + dst.ParentAccounts += src.ParentAccounts + dst.WorkingSetHits += src.WorkingSetHits + dst.InProgressHits += src.InProgressHits + dst.PendingFoldHits += src.PendingFoldHits + dst.CacheHits += src.CacheHits + dst.IndexHits += src.IndexHits + dst.IndexMisses += src.IndexMisses + dst.UniqueAppendVecs += src.UniqueAppendVecs + dst.AppendVecChunks += src.AppendVecChunks + dst.AppendVecAccounts += src.AppendVecAccounts + dst.OpenFailures += src.OpenFailures + dst.ReadFailures += src.ReadFailures + dst.RetryAccounts += src.RetryAccounts + dst.CommonCacheAdmissions += src.CommonCacheAdmissions + dst.CommonCacheAdmissionsSkipped += src.CommonCacheAdmissionsSkipped + dst.VoteCacheAdmissions += src.VoteCacheAdmissions + dst.VoteCacheAdmissionsSkipped += src.VoteCacheAdmissionsSkipped + dst.CachePublicationEpochRejects += src.CachePublicationEpochRejects + dst.DecodedAccountObjects += src.DecodedAccountObjects + dst.DecodedAccountBytes += src.DecodedAccountBytes + dst.PlaceholderObjects += src.PlaceholderObjects + dst.SysvarReads += src.SysvarReads + dst.SysvarWorkingSetHits += src.SysvarWorkingSetHits + dst.SysvarInProgressHits += src.SysvarInProgressHits + dst.SysvarPendingFoldHits += src.SysvarPendingFoldHits + dst.SysvarCacheHits += src.SysvarCacheHits + dst.SysvarDurableReads += src.SysvarDurableReads + dst.SysvarCachePublicationEpochRejects += src.SysvarCachePublicationEpochRejects +} + // TurbineIngress records per-slot pre-replay pipeline observations. // It is written to replay_timings.jsonl without high-cardinality metric labels. type TurbineIngress struct { @@ -145,6 +244,119 @@ type VoteRewardDetails struct { VoteAccountsUpdated uint64 } +// StreamingExecution records execution that ran while a block's shreds were +// still arriving. Groups are the contiguous ready-batch sets executed per +// wake-up; Transactions counts what they executed. TxLoopBeforeFull is the +// group execution wall time that finished before the slot was fully +// assembled, i.e. the work hidden behind reception. OpenDelay runs from the +// header batch being decoded to the stream opening; the timeline fields and +// the OpenWait* timings below say what held the child (its parent's arrival, +// its parent's replay, or the loop itself). Discarded is 1 when a stream for +// this slot was thrown away and the block was executed whole; DiscardReason +// names why. +type StreamingExecution struct { + // VerificationWait is wall time joining speculative verification groups, + // including failed joins. It overlaps GroupJoinAssembly for successful + // groups; it is neither crypto CPU time nor additional replay latency. + VerificationWait Timing + Opened uint64 + Groups uint64 + Transactions uint64 + TxLoopBeforeFull Timing + OpenDelay Timing + Discarded uint64 + DiscardReason string + + // Timeline: wall-clock unix nanoseconds of the events that bound the + // stream's open, zero when unknown. They join with the parent's record + // (ParentFullNanos is the parent's FullNanos) and with external captures. + // + // HeaderReadyNanos the child's header batch was decoded + // HeaderSeenNanos the executor first handled that header (from its + // wake-up, or recovered from the assembler after a + // dropped wake-up, in which case ready is the + // lookup instant and seen follows it at once) + // ParentFullNanos the parent's last shred (0: parent was a skip or + // not a turbine block) + // ParentAdmittedNanos the source handed the parent to replay (its own + // ancestors replayed, the emitter released it) + // ParentReplayedNanos the parent's replay result reached consensus and + // the frontier advanced to it (0: unknown, e.g. the + // frontier was re-based by a fork switch) + // OpenedNanos the stream's bank opened + // FirstGroupStartNanos the first executed group started + // WaitEnteredNanos the loop first entered the replay wait after the + // parent was replayed (0: unknown) + // FullNanos this block's last shred + // FinalizeStartNanos the complete block reached the stream + HeaderReadyNanos int64 + HeaderSeenNanos int64 + ParentFullNanos int64 + ParentAdmittedNanos int64 + ParentReplayedNanos int64 + WaitEnteredNanos int64 + OpenedNanos int64 + FirstGroupStartNanos int64 + FullNanos int64 + FinalizeStartNanos int64 + + // OpenDelay decomposed into attributable waits (each zero when the + // timeline cannot support it): + // OpenWaitParentArrival header ready → parent's last shred: the child's + // header was decoded before its parent was even + // fully received (arrival timing; cause not established) + // OpenWaitParentReplay parent's last shred (or header ready, whichever + // is later) → parent replayed: the parent's own + // post-full path held the child; split, when the + // parent's admission is known, into + // OpenWaitParentPreAdmission … → admission, including any streamed execution + // OpenWaitParentPostAdmission admission → replayed, including finalization + // OpenWaitLoop max(parent replayed, header ready) → opened; + // includes time before the header is handled + // OpenWaitPostReplay loop start → first wait entry after the parent + // OpenWaitDispatch max(wait entry, loop start) → opened + // These are elapsed intervals, not CPU times. Subdivisions must not be + // added to their aggregates. Unknown parent milestones limit attribution. + OpenWaitParentArrival Timing + OpenWaitParentReplay Timing + OpenWaitParentPreAdmission Timing + OpenWaitParentPostAdmission Timing + OpenWaitLoop Timing + OpenWaitPostReplay Timing + OpenWaitDispatch Timing + + // Groups, from the executor's per-group bookkeeping (each group is the + // contiguous set of decoded batches that was ready at one wake-up, or + // the finalize suffix): + // GroupJoinAssembly verification joins plus batch-slice assembly; + // not pure cryptography or verifier queue time + // GroupJoinAssemblyAfterFull intersection of that interval with post-full + // GroupPreparation identity binding and execution-copy creation + // GroupPreparationAfterFull intersection of preparation with post-full + // TxLoopAfterFull group/suffix execution after the last shred: + // the execution FullToReplayed actually paid for + // GroupsStraddlingFull groups that started before and finished after + // LargestGroupTransactions / LargestGroupBatches: the biggest group (a + // late open turns the whole backlog into one); + // Batches is 0 when that group was the suffix + // LastGroupEndNanos when the last group (or suffix) finished + GroupJoinAssembly Timing + GroupJoinAssemblyAfterFull Timing + GroupPreparation Timing + GroupPreparationAfterFull Timing + TxLoopAfterFull Timing + GroupsStraddlingFull uint64 + LargestGroupTransactions uint64 + LargestGroupBatches uint64 + LastGroupEndNanos int64 + + // NotOpenedReason is set when the block was executed whole without a + // stream having opened for it: why the executor never opened one + // ("header_not_seen", "declined:", "waiting_for_parent:…"). + // Empty when a stream opened (see Discarded for the ones thrown away). + NotOpenedReason string +} + // Metrics for replaying a single block type BlockReplay struct { Slot uint64 @@ -192,16 +404,20 @@ type BlockReplay struct { // BlockUpdateAccounts is synchronous critical-path work: rooted-tail // buffering (including its callback) or legacy store enqueue. It excludes // legacy asynchronous disk completion. - BlockUpdateAccounts Timing - TransactionStatusCommit Timing - SignatureVerificationJoin Timing - AccountsDeltaHash Timing - LtHashDedupe Timing - LtHashWorkerCompute Timing - LtHashPartialReduce Timing - BankHashFinalize Timing - BankHash Timing - AlpenglowFooterVerification Timing + BlockUpdateAccounts Timing + TransactionStatusCommit Timing + // Preparation overlaps execution and is not additive with replay wall time. + // PreparationWait is the residual join nested within TransactionStatusCommit. + TransactionStatusPreparation Timing + TransactionStatusPreparationWait Timing + SignatureVerificationJoin Timing + AccountsDeltaHash Timing + LtHashDedupe Timing + LtHashWorkerCompute Timing + LtHashPartialReduce Timing + BankHashFinalize Timing + BankHash Timing + AlpenglowFooterVerification Timing // PostProcessBlock is caller-side state publication and replay // bookkeeping after ProcessBlock returns. TransactionStatusView, // ChainTipUpdate, and ResumeContext are nested sub-phases; logging, summary @@ -211,6 +427,15 @@ type BlockReplay struct { ChainTipUpdate Timing ResumeContext Timing + // FullToReplayed is the vote-path latency Mithril controls: wall time from + // the last shred of a turbine block being assembled (the assembler's fullAt) + // to the replay result being handed to consensus. Absent for blocks that + // did not arrive as shreds. It is the number streaming execution reduces. + FullToReplayed Timing + // StreamingExecution summarizes any execution that overlapped shred + // reception for this block; all zero when the block was executed whole. + StreamingExecution StreamingExecution + LtHashInputAccounts uint64 LtHashUniqueAccounts uint64 LtHashUnchangedAccounts uint64 diff --git a/pkg/replay/account_loader_metrics_test.go b/pkg/replay/account_loader_metrics_test.go new file mode 100644 index 000000000..128bd1cef --- /dev/null +++ b/pkg/replay/account_loader_metrics_test.go @@ -0,0 +1,34 @@ +package replay + +import ( + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/stretchr/testify/require" +) + +func TestAccountLoaderRetainsGroupsAcrossReset(t *testing.T) { + previous := metrics.GlobalBlockReplay.AccountLoader + t.Cleanup(func() { metrics.GlobalBlockReplay.AccountLoader = previous }) + exec := &blockExecution{} + metrics.GlobalBlockReplay.AccountLoader = metrics.AccountLoader{RequestedKeys: 99} + func() { + defer exec.captureAccountLoader()() + recordAccountLoaderBatchStats(&metrics.GlobalBlockReplay.AccountLoader, accountsdb.BatchReadStats{RequestedKeys: 3, IndexHits: 2, AppendVecAccounts: 2, AppendVecReadNanoseconds: 1000}) + recordAccountLoaderBatchStats(&metrics.GlobalBlockReplay.AccountLoader, accountsdb.BatchReadStats{RequestedKeys: 4, CacheHits: 4}) + }() + require.EqualValues(t, 99, metrics.GlobalBlockReplay.AccountLoader.RequestedKeys, "another slot's record is unchanged") + metrics.GlobalBlockReplay.AccountLoader = metrics.AccountLoader{} + func() { + defer exec.captureAccountLoader()() + recordAccountLoaderBatchStats(&metrics.GlobalBlockReplay.AccountLoader, accountsdb.BatchReadStats{RequestedKeys: 5, CacheHits: 5}) + }() + require.Zero(t, metrics.GlobalBlockReplay.AccountLoader.RequestedKeys, "speculative work is not published before acceptance") + require.EqualValues(t, 12, exec.accountLoader.RequestedKeys) + require.EqualValues(t, 9, exec.accountLoader.CacheHits) + require.EqualValues(t, 2, exec.accountLoader.AppendVecAccounts) + require.EqualValues(t, time.Microsecond, exec.accountLoader.AppendVecRead.SumNanoseconds) + require.EqualValues(t, 3, exec.accountLoader.AppendVecRead.Count) +} diff --git a/pkg/replay/alpenglow_switch.go b/pkg/replay/alpenglow_switch.go index 76f557971..2d384a096 100644 --- a/pkg/replay/alpenglow_switch.go +++ b/pkg/replay/alpenglow_switch.go @@ -13,6 +13,7 @@ import ( "github.com/Overclock-Validator/mithril/pkg/rewards" "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/Overclock-Validator/mithril/pkg/turbine" "github.com/gagliardetto/solana-go" ) @@ -116,6 +117,16 @@ func newAlpenglowSwitchSweeper(engine consensusengine.Engine) *alpenglowSwitchSw return s } +// peek tests for a switch without consuming the sweep's version/frontier gate. +// Streaming admission must leave a detected switch for the replay loop to apply. +func (s *alpenglowSwitchSweeper) peek(executed map[uint64]solana.Hash, lastRooted, tip uint64) *CertifiedSwitch { + if s == nil { + return nil + } + snapshot := *s + return snapshot.sweep(executed, lastRooted, tip) +} + // sweep walks consumed block/skip outcomes in (lastRooted, tip] and returns // the first contradiction with a decisive chain decision. tip includes trailing // skips even when the executed bank remains at an earlier slot. @@ -175,23 +186,86 @@ func waitForAlpenglowReplayInput( decisionChanges <-chan struct{}, pollInterval time.Duration, ) (*b.Block, *blockstream.AlpenglowParentSwitch, *CertifiedSwitch) { + adapted := func(ctx context.Context, decisionChanges <-chan struct{}, _ <-chan turbine.StreamEvent, _ <-chan time.Time) blockstream.ReplayInput { + block, parentSwitch, decisionChanged := next(ctx, decisionChanges) + return blockstream.ReplayInput{Block: block, ParentSwitch: parentSwitch, DecisionChanged: decisionChanged} + } + return waitForReplayInput(ctx, adapted, sweep, decisionChanges, pollInterval, nil) +} + +// replayStreamer is the streaming executor as the wait loop drives it: the +// feed it listens to, its poll timer (nil while idle), and the two handlers, +// which execute transaction groups on the replay goroutine. +type replayStreamer interface { + events() <-chan turbine.StreamEvent + tick() <-chan time.Time + handleEvent(turbine.StreamEvent) + handleTick() +} + +// replayInputSource is BlockSource.NextReplayInput. +type replayInputSource func(ctx context.Context, decisionChanges <-chan struct{}, streamEvents <-chan turbine.StreamEvent, streamTick <-chan time.Time) blockstream.ReplayInput + +// waitForReplayInput is waitForAlpenglowReplayInput with the streaming +// executor folded into the same wait: feed wake-ups and poll ticks are +// handled here, on the replay goroutine, and never end the wait, so the loop +// body only ever sees a block, a switch, or an ended wait exactly as before. +// streamer may be nil (no streaming); sweep may be nil (no certificate +// correction), in which case decision notifications stay disabled and the +// wait blocks without polling, as it always did. +func waitForReplayInput( + ctx context.Context, + next replayInputSource, + sweep func() *CertifiedSwitch, + decisionChanges <-chan struct{}, + pollInterval time.Duration, + streamer replayStreamer, +) (*b.Block, *blockstream.AlpenglowParentSwitch, *CertifiedSwitch) { + var streamEvents <-chan turbine.StreamEvent + if streamer != nil { + // A slot may have become eligible while the previous block executed + // (its header arrived mid-execution); open it before blocking. + streamer.handleTick() + streamEvents = streamer.events() + } if sweep == nil { - block, parentSwitch, _ := next(ctx, nil) - return block, parentSwitch, nil + decisionChanges = nil + if streamer == nil { + in := next(ctx, nil, nil, nil) + return in.Block, in.ParentSwitch, nil + } } for { if ctx.Err() != nil { return nil, nil, nil } - if sw := sweep(); sw != nil { - return nil, nil, sw + if sweep != nil { + if sw := sweep(); sw != nil { + return nil, nil, sw + } + } + waitCtx, cancel := ctx, func() {} + if sweep != nil { + waitCtx, cancel = context.WithTimeout(ctx, pollInterval) } - waitCtx, cancel := context.WithTimeout(ctx, pollInterval) - block, parentSwitch, decisionChanged := next(waitCtx, decisionChanges) - timedOut := waitCtx.Err() == context.DeadlineExceeded + var tick <-chan time.Time + if streamer != nil { + tick = streamer.tick() + } + in := next(waitCtx, decisionChanges, streamEvents, tick) + timedOut := sweep != nil && waitCtx.Err() == context.DeadlineExceeded cancel() - if block != nil || parentSwitch != nil || (!decisionChanged && !timedOut) { - return block, parentSwitch, nil + switch { + case in.Block != nil || in.ParentSwitch != nil: + return in.Block, in.ParentSwitch, nil + case in.StreamEvent != nil: + streamer.handleEvent(*in.StreamEvent) + case in.StreamTick: + streamer.handleTick() + case in.DecisionChanged || timedOut: + // Re-sweep, then wait again. + default: + return nil, nil, nil } } } diff --git a/pkg/replay/alpenglow_switch_test.go b/pkg/replay/alpenglow_switch_test.go index cd8084682..35cd7ca17 100644 --- a/pkg/replay/alpenglow_switch_test.go +++ b/pkg/replay/alpenglow_switch_test.go @@ -455,3 +455,16 @@ func TestWaitForAlpenglowReplayInputHonorsCancellation(t *testing.T) { require.Nil(t, parentSwitch) require.Nil(t, certifiedSwitch) } + +func TestSwitchPeekDoesNotConsumeDecision(t *testing.T) { + q := &fakeChainQuery{certified: map[uint64]alpenglow.BlockID{101: {Slot: 101, Hash: swHash(9)}}, skipped: map[uint64]bool{}, version: 1} + s := newTestSweeper(q) + executed := map[uint64]solana.Hash{101: swHash(1)} + before := *s + first := s.peek(executed, 100, 101) + require.NotNil(t, first) + require.Equal(t, before, *s) + require.Equal(t, first, s.peek(executed, 100, 101)) + require.Equal(t, first, s.sweep(executed, 100, 101), "replay still receives the switch") + require.Nil(t, s.sweep(executed, 100, 101), "normal sweep still consumes its gate") +} diff --git a/pkg/replay/alpenglow_unwind_test.go b/pkg/replay/alpenglow_unwind_test.go index dd478e0fc..52e949c2b 100644 --- a/pkg/replay/alpenglow_unwind_test.go +++ b/pkg/replay/alpenglow_unwind_test.go @@ -7,10 +7,12 @@ import ( "testing" "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/global" "github.com/Overclock-Validator/mithril/pkg/rewards" "github.com/Overclock-Validator/mithril/pkg/sealevel" "github.com/Overclock-Validator/mithril/pkg/state" bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" "github.com/mr-tron/base58" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -373,3 +375,25 @@ func assertUnwindFallbackReason(t *testing.T, want string, sw *CertifiedSwitch, assert.Nil(t, bankSysvars) assert.Equal(t, want, reason) } + +func TestUnwindPreservesPendingLeaderStakeEntries(t *testing.T) { + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 1, "") + for _, slot := range []uint64{8, 9, 12} { + global.EnqueuePendingStakePubkey(slot, solana.PublicKey{byte(slot), 0xFA}) + t.Cleanup(func() { global.DropPendingStakePubkeys(slot) }) + } + for _, slot := range []uint64{8, 9} { + tail.Add(slot, []*accounts.Account{testAccount(1, slot)}, testHashBytes(byte(slot))) + } + tail.unwind(8) + entries := global.PendingStakeEntriesSnapshot() + for _, slot := range []uint64{8, 9, 12} { + found := false + for _, entry := range entries { + if entry.Pubkey == (solana.PublicKey{byte(slot), 0xFA}) { + found = true + } + } + require.Equal(t, slot == 12, found) + } +} diff --git a/pkg/replay/async_checkpoint_capture_test.go b/pkg/replay/async_checkpoint_capture_test.go new file mode 100644 index 000000000..a556c5a37 --- /dev/null +++ b/pkg/replay/async_checkpoint_capture_test.go @@ -0,0 +1,172 @@ +package replay + +import ( + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/state" + "github.com/stretchr/testify/require" +) + +type testCheckpointEncoder func() ([]byte, error) + +func (f testCheckpointEncoder) MarshalBinary() ([]byte, error) { return f() } + +func testCheckpointBytes(payload []byte) TransactionStatusSnapshot { + owned := append([]byte(nil), payload...) + return testCheckpointEncoder(func() ([]byte, error) { return append([]byte(nil), owned...), nil }) +} + +func TestAsyncCheckpointEncodingDoesNotRunDuringJobBuild(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := asyncTestTail(fc, 5, 6) + started, release := make(chan struct{}), make(chan struct{}) + blockEncoding := false + rootDir := t.TempDir() + require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ + Capture: func(through uint64) (TransactionStatusSnapshot, error) { + require.Equal(t, uint64(6), through) + return testCheckpointEncoder(func() ([]byte, error) { + if !blockEncoding { + return nil, errors.New("encoder ran during job construction") + } + close(started) + <-release + return []byte("encoded-on-worker"), nil + }), nil + }, + Install: func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) { + return PrepareTransactionStatusCheckpoint(rootDir, through, payload) + }, + })) + job, err := tail.buildFoldJob(6, false) + require.NoError(t, err) + select { + case <-started: + t.Fatal("job construction ran the encoder") + default: + } + promoter := newAsyncPromoter(fc) + // Always unblock the worker before draining it, including a failed assertion. + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + defer promoter.stop() + defer unblock() + blockEncoding = true + promoter.enqueue(job) + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("checkpoint worker did not start encoding") + } + // Replay can publish a later bank while the worker's encoder is blocked. + tail.Add(7, []*accounts.Account{testAccount(3, 7)}, testHashBytes(7)) + tail.SetContext(7, &state.ResumeContext{Slot: 7}) + require.Equal(t, 3, tail.overlay.HeldSlots()) + require.Nil(t, promoter.poll()) + + unblock() + result := promoter.drain() + require.NotNil(t, result) + require.NoError(t, result.err) + require.Nil(t, result.job.transactionStatusSnapshot) + tail.applyFoldJob(result.job) + require.Equal(t, 1, tail.overlay.HeldSlots()) +} + +func TestCheckpointCaptureFailureOrdering(t *testing.T) { + cases := []struct { + name string + capture func(uint64) (TransactionStatusSnapshot, error) + want string + buildFails bool + }{ + {"nil capture", func(uint64) (TransactionStatusSnapshot, error) { return nil, nil }, "capture is nil", true}, + {"capture error", func(uint64) (TransactionStatusSnapshot, error) { return nil, errors.New("capture failed") }, "capture failed", true}, + {"encode error", func(uint64) (TransactionStatusSnapshot, error) { + return testCheckpointEncoder(func() ([]byte, error) { return nil, errors.New("encode failed") }), nil + }, "encode failed", false}, + {"empty encoding", func(uint64) (TransactionStatusSnapshot, error) { return testCheckpointBytes(nil), nil }, "snapshot is empty", false}, + } + for _, tc := range cases { + for _, forced := range []bool{false, true} { + name := tc.name + "/async" + if forced { + name = tc.name + "/forced" + } + t.Run(name, func(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := asyncTestTail(fc, 5, 6) + installed := false + require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ + Capture: tc.capture, + Install: func(uint64, []byte) (*state.TransactionStatusCheckpointRef, error) { + installed = true + return nil, errors.New("unexpected install") + }, + })) + if forced { + through, _, err := tail.flush(6) + require.ErrorContains(t, err, tc.want) + require.Zero(t, through) + } else { + job, err := tail.buildFoldJob(6, false) + if tc.buildFails { + require.ErrorContains(t, err, tc.want) + require.Nil(t, job) + } else { + require.NoError(t, err) + require.ErrorContains(t, runFoldJob(fc, job), tc.want) + require.Nil(t, job.transactionStatusSnapshot, "failed result retained its captured deltas") + } + } + require.False(t, installed) + require.Empty(t, fc.throughs) + require.Equal(t, 2, tail.overlay.HeldSlots()) + }) + } + } +} + +func TestFoldCheckpointKeepsCapturedRootAfterLiveCacheAdvances(t *testing.T) { + c := importedStatusCacheForTest(t) + for slot := uint64(301); slot <= 350; slot++ { + require.NoError(t, c.CommitBlock(captureTestBlock(slot, 1))) + } + want, err := legacyStatusSnapshotForTest(c, 320) + require.NoError(t, err) + fc := &fakeCommitter{durable: accounts.NewMemAccounts()} + tail := asyncTestTail(fc, 319, 320, 321) + rootDir := t.TempDir() + require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ + Capture: c.CaptureSnapshotThrough, + Install: func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) { + return PrepareTransactionStatusCheckpoint(rootDir, through, payload) + }, + })) + job, err := tail.buildFoldJob(321, false) + require.NoError(t, err) + for slot := uint64(351); slot <= 660; slot++ { + require.NoError(t, c.CommitBlock(captureTestBlock(slot, 1))) + c.Root(slot - 20) + } + require.NoError(t, runFoldJob(fc, job)) + require.Nil(t, job.transactionStatusSnapshot, "completed result retained captured deltas") + require.Positive(t, job.checkpointCaptureTime) + require.Positive(t, job.checkpointEncodeTime) + require.Equal(t, len(want), job.checkpointBytes) + var manifest state.ResumeContext + require.NoError(t, json.Unmarshal(fc.ctxs[320], &manifest)) + require.Equal(t, uint64(320), manifest.TransactionStatusCheckpoint.Root) + got, err := ReadTransactionStatusCheckpoint(rootDir, manifest.TransactionStatusCheckpoint) + require.NoError(t, err) + require.Equal(t, want, got) + restored, err := NewTransactionStatusCacheFromSnapshot(got) + require.NoError(t, err) + require.Equal(t, uint64(320), restored.RootedThrough()) + require.True(t, restored.CoverageComplete()) +} diff --git a/pkg/replay/async_promotion_test.go b/pkg/replay/async_promotion_test.go index 49d47a49e..2780e425d 100644 --- a/pkg/replay/async_promotion_test.go +++ b/pkg/replay/async_promotion_test.go @@ -22,7 +22,7 @@ type slowCommitter struct { delay time.Duration } -func TestFoldJobSnapshotsStatusOnLoopAndReferenceRidesManifest(t *testing.T) { +func TestFoldJobCapturesStatusOnLoopAndReferenceRidesManifest(t *testing.T) { rootDir := t.TempDir() fc := &fakeCommitter{durable: accounts.NewMemAccounts()} tail := asyncTestTail(fc, 5, 6, 7) @@ -32,10 +32,10 @@ func TestFoldJobSnapshotsStatusOnLoopAndReferenceRidesManifest(t *testing.T) { installCalled := false afterCommitCalled := false hooks := TransactionStatusCheckpointHooks{ - Snapshot: func(through uint64) ([]byte, error) { + Capture: func(through uint64) (TransactionStatusSnapshot, error) { require.Equal(t, uint64(6), through) snapshotCalled = true - return scratch, nil + return testCheckpointBytes(scratch), nil }, Install: func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) { require.True(t, snapshotCalled, "worker install ran before loop snapshot") @@ -83,7 +83,7 @@ func TestFoldJobCheckpointFailuresCannotReachCommitBatch(t *testing.T) { fc := &fakeCommitter{durable: accounts.NewMemAccounts()} tail := asyncTestTail(fc, 5, 6) require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ - Snapshot: func(uint64) ([]byte, error) { return nil, errors.New("snapshot boom") }, + Capture: func(uint64) (TransactionStatusSnapshot, error) { return nil, errors.New("snapshot boom") }, Install: func(uint64, []byte) (*state.TransactionStatusCheckpointRef, error) { t.Fatal("install must not run") return nil, nil @@ -101,7 +101,7 @@ func TestFoldJobCheckpointFailuresCannotReachCommitBatch(t *testing.T) { fc := &fakeCommitter{durable: accounts.NewMemAccounts()} tail := asyncTestTail(fc, 5, 6) require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ - Snapshot: func(uint64) ([]byte, error) { return []byte("captured"), nil }, + Capture: func(uint64) (TransactionStatusSnapshot, error) { return testCheckpointBytes([]byte("captured")), nil }, Install: func(uint64, []byte) (*state.TransactionStatusCheckpointRef, error) { return nil, errors.New("fsync boom") }, @@ -120,7 +120,7 @@ func TestFoldJobCheckpointFailuresCannotReachCommitBatch(t *testing.T) { tail := asyncTestTail(fc, 5, 6) afterCommitCalled := false require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ - Snapshot: func(uint64) ([]byte, error) { return []byte("captured"), nil }, + Capture: func(uint64) (TransactionStatusSnapshot, error) { return testCheckpointBytes([]byte("captured")), nil }, Install: func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) { return PrepareTransactionStatusCheckpoint(rootDir, through, payload) }, @@ -144,9 +144,9 @@ func TestForcedFoldCarriesStatusCheckpointReference(t *testing.T) { tail := asyncTestTail(fc, 5) afterCommitCalled := false require.NoError(t, tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ - Snapshot: func(through uint64) ([]byte, error) { + Capture: func(through uint64) (TransactionStatusSnapshot, error) { require.Equal(t, uint64(5), through) - return []byte("forced-partial-status"), nil + return testCheckpointBytes([]byte("forced-partial-status")), nil }, Install: func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) { return PrepareTransactionStatusCheckpoint(rootDir, through, payload) @@ -175,7 +175,7 @@ func TestCheckpointAfterCommitRequiresDurabilityHooks(t *testing.T) { err := tail.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ AfterCommit: func(*state.TransactionStatusCheckpointRef) error { return nil }, }) - require.ErrorContains(t, err, "requires Snapshot and Install") + require.ErrorContains(t, err, "requires Capture and Install") } func (c *slowCommitter) CommitBatch(deltas []accounts.SlotDelta, throughSlot uint64, bankhashes map[uint64][32]byte, resumeCtx []byte) (accountsdb.BatchCommitResult, error) { @@ -444,3 +444,26 @@ func TestShutdownFlushCannotFoldPastGateTarget(t *testing.T) { target = safePromoteTarget(9, true, 7, 6) assert.Equal(t, uint64(5), target, "persisted-divergence floor holds promotion below the disputed slot") } + +// Model repeated replay/skip iterations while a nearly full checkpoint batch +// waits for one more held bank. Account writes must not be copied on this path. +func BenchmarkBuildFoldJobWaitingForBatch(b *testing.B) { + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 128, "") + writes := make([]*accounts.Account, 512) + for i := range writes { + var key [32]byte + key[0], key[1] = byte(i), byte(i>>8) + writes[i] = &accounts.Account{Key: key, Lamports: 1} + } + for slot := uint64(1); slot <= 127; slot++ { + tail.Add(slot, writes, nil) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + job, err := tail.buildFoldJob(127, false) + if err != nil || job != nil { + b.Fatalf("unexpected fold admission: job=%v err=%v", job, err) + } + } +} diff --git a/pkg/replay/block.go b/pkg/replay/block.go index 062802e2d..e571c98d0 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -25,7 +25,6 @@ import ( "github.com/Overclock-Validator/mithril/pkg/accountsdb" a "github.com/Overclock-Validator/mithril/pkg/addresses" "github.com/Overclock-Validator/mithril/pkg/arena" - "github.com/Overclock-Validator/mithril/pkg/bankhash" "github.com/Overclock-Validator/mithril/pkg/base58" b "github.com/Overclock-Validator/mithril/pkg/block" "github.com/Overclock-Validator/mithril/pkg/blockstream" @@ -37,7 +36,6 @@ import ( "github.com/Overclock-Validator/mithril/pkg/lthash" "github.com/Overclock-Validator/mithril/pkg/metrics" "github.com/Overclock-Validator/mithril/pkg/mlog" - "github.com/Overclock-Validator/mithril/pkg/rent" "github.com/Overclock-Validator/mithril/pkg/rewards" "github.com/Overclock-Validator/mithril/pkg/rpcclient" "github.com/Overclock-Validator/mithril/pkg/sealevel" @@ -1032,28 +1030,28 @@ func loadBlockAccountsAndUpdateSysvars( } func recordAccountLoaderBatchStats(dst *metrics.AccountLoader, src accountsdb.BatchReadStats) { - dst.RequestedKeys = src.RequestedKeys - dst.DurableKeys = src.DurableKeys - dst.WorkingSetHits = src.WorkingSetHits - dst.InProgressHits = src.InProgressHits - dst.PendingFoldHits = src.PendingFoldHits - dst.CacheHits = src.CacheHits - dst.IndexHits = src.IndexHits - dst.IndexMisses = src.IndexMisses - dst.UniqueAppendVecs = src.UniqueAppendVecs - dst.AppendVecChunks = src.AppendVecChunks - dst.AppendVecAccounts = src.AppendVecAccounts - dst.OpenFailures = src.OpenFailures - dst.ReadFailures = src.ReadFailures - dst.RetryAccounts = src.RetryAccounts - dst.CommonCacheAdmissions = src.CommonCacheAdmissions - dst.CommonCacheAdmissionsSkipped = src.CommonCacheAdmissionsSkipped - dst.VoteCacheAdmissions = src.VoteCacheAdmissions - dst.VoteCacheAdmissionsSkipped = src.VoteCacheAdmissionsSkipped - dst.CachePublicationEpochRejects = src.CachePublicationEpochRejects - dst.DecodedAccountObjects = src.DecodedAccountObjects - dst.DecodedAccountBytes = src.DecodedAccountBytes - dst.PlaceholderObjects = src.PlaceholderObjects + dst.RequestedKeys += src.RequestedKeys + dst.DurableKeys += src.DurableKeys + dst.WorkingSetHits += src.WorkingSetHits + dst.InProgressHits += src.InProgressHits + dst.PendingFoldHits += src.PendingFoldHits + dst.CacheHits += src.CacheHits + dst.IndexHits += src.IndexHits + dst.IndexMisses += src.IndexMisses + dst.UniqueAppendVecs += src.UniqueAppendVecs + dst.AppendVecChunks += src.AppendVecChunks + dst.AppendVecAccounts += src.AppendVecAccounts + dst.OpenFailures += src.OpenFailures + dst.ReadFailures += src.ReadFailures + dst.RetryAccounts += src.RetryAccounts + dst.CommonCacheAdmissions += src.CommonCacheAdmissions + dst.CommonCacheAdmissionsSkipped += src.CommonCacheAdmissionsSkipped + dst.VoteCacheAdmissions += src.VoteCacheAdmissions + dst.VoteCacheAdmissionsSkipped += src.VoteCacheAdmissionsSkipped + dst.CachePublicationEpochRejects += src.CachePublicationEpochRejects + dst.DecodedAccountObjects += src.DecodedAccountObjects + dst.DecodedAccountBytes += src.DecodedAccountBytes + dst.PlaceholderObjects += src.PlaceholderObjects dst.WorkingSetLookup.AddTiming(time.Duration(src.WorkingSetLookupNanoseconds)) dst.InProgressLookup.AddTiming(time.Duration(src.InProgressNanoseconds)) dst.AppendVecPinWait.AddTiming(time.Duration(src.AppendVecPinWaitNanoseconds)) @@ -1295,6 +1293,18 @@ func reconstructFeeRateGovernor(s *state.MithrilState) *sealevel.FeeRateGovernor func configureBlock(block *b.Block, lastSlotCtx *sealevel.SlotCtx, epochSchedule *sealevel.SysvarEpochSchedule) error { + return configureBlockFromParent(block, lastSlotCtx, epochSchedule, true) +} + +// configureBlockFromParent derives the block's parent-dependent fields from +// the executed parent context. publishGlobal also publishes the block as the +// process-wide current slot (configureGlobalCtx); a speculative streaming +// shell passes false so the global view keeps describing the executed +// frontier until the complete block is configured. +func configureBlockFromParent(block *b.Block, + lastSlotCtx *sealevel.SlotCtx, + epochSchedule *sealevel.SysvarEpochSchedule, + publishGlobal bool) error { copy(block.ParentBankhash[:], lastSlotCtx.FinalBankhash) block.AcctsLtHash = lastSlotCtx.AcctsLtHash @@ -1310,7 +1320,9 @@ func configureBlock(block *b.Block, block.LastBlockhash = lastSlotCtx.Blockhash } - configureGlobalCtx(block) + if publishGlobal { + configureGlobalCtx(block) + } if global.ManageLeaderSchedule() { // epoch boundary. do not set leader @@ -1747,6 +1759,7 @@ func ReplayBlocks( var unwoundParentBankSysvars *sealevel.BankSysvars var partitionedEpochRewardsEnabled bool var partitionedRewardsInfo *rewards.PartitionedRewardDistributionInfo + var rewardsCompletion partitionedRewardsCompletion var featuresActivatedInFirstSlot []*accounts.Account var parentFeaturesActivatedInFirstSlot []*accounts.Account @@ -1928,8 +1941,8 @@ func ReplayBlocks( var highestExecutedSlot uint64 // highest slot ProcessBlock has executed; bounds the promotion-gate walk // While partitioned rewards distribute, promotion holds below the boundary // block so a crash-resume always re-runs it (the distribution bookkeeping is - // RAM-only and not reconstructible mid-window). Self-clears when the window - // completes (NumRewardPartitionsRemaining reaches 0). + // RAM-only and not reconstructible mid-window). Release requires a verified + // completion bank, committed atomically with the whole rewards window. var rewardsHoldBelowSlot uint64 // Alpenglow finality identities captured at observe/ingest time for the promotion // gate (the tracker's own state may be pruned by promotion time). Pruned as slots @@ -1989,9 +2002,9 @@ func ReplayBlocks( checkpointAfterCommit = consensusOpts.TransactionStatusCheckpointAfterCommit } if hookErr := unrootedTailState.SetTransactionStatusCheckpointHooks(TransactionStatusCheckpointHooks{ - // Snapshot runs here on the replay loop during fold-job construction; - // only its immutable bytes cross to the async worker. - Snapshot: transactionStatuses.SnapshotThrough, + // Pin the exact immutable view on replay. Sorting and encoding run + // on the existing fold worker, after releasing the live cache lock. + Capture: transactionStatuses.CaptureSnapshotThrough, Install: func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) { return PrepareTransactionStatusCheckpoint(acctsDbPath, through, payload) }, @@ -2063,6 +2076,10 @@ func ReplayBlocks( mithrilState.LastRootedSlot = promotedThrough mithrilState.LastRootedBankhash = rootedCtx.Bankhash mithrilState.LastRootedContext = rootedCtx + if rewardsCompletion.retire(&partitionedRewardsInfo, promotedThrough) { + rewardsHoldBelowSlot = 0 + mlog.Log.Infof("epoch rewards bookkeeping retired through durable slot %d; later fork switches may unwind in memory", promotedThrough) + } if transactionStatuses.Root(promotedThrough) { mlog.Log.Infof("transaction status cache reconstructed complete %d-root coverage through durable slot %d", maxTransactionStatusRoots, promotedThrough) @@ -2170,13 +2187,9 @@ func ReplayBlocks( } promoteThrough := safePromoteTarget(lastRootedWatermark, verifierRequired, verifiedWM, replayDivergenceFloor) // Partitioned-rewards window: hold promotion below the boundary block - // until every partition distributes, so a crash-resume re-runs the - // boundary and rebuilds the RAM-only distribution bookkeeping. - if rewardsHoldBelowSlot > 0 && partitionedRewardsInfo != nil && partitionedRewardsInfo.NumRewardPartitionsRemaining > 0 { - if promoteThrough >= rewardsHoldBelowSlot { - promoteThrough = rewardsHoldBelowSlot - 1 - } - } + // until the completion bank verifies and is eligible to fold, so a + // failed distribution re-runs the boundary and rebuilds its bookkeeping. + promoteThrough = rewardsCompletion.limitPromotion(partitionedRewardsInfo, rewardsHoldBelowSlot, promoteThrough) if promoteThrough <= mithrilState.LastRootedSlot { // Operator signal: promotion is fully stalled (verifier lag, // divergence floor, or rewards hold) while finality has run at @@ -2207,6 +2220,8 @@ func ReplayBlocks( mlog.Log.FileOnlyf("alpenglow gate: checked=%d matched=%d no_finality=%d no_local_id=%d", gateStats.checked, gateStats.matched, gateStats.noFinality, gateStats.noLocalID) } + // The finality gate can stop before the verified completion bank. + promoteThrough = rewardsCompletion.limitPromotion(partitionedRewardsInfo, rewardsHoldBelowSlot, promoteThrough) if promoteThrough <= mithrilState.LastRootedSlot { return false } @@ -2220,6 +2235,18 @@ func ReplayBlocks( if res := promoter.drain(); res != nil { applyFoldOutcome(res) } + if rewardsHoldBelowSlot > 0 && partitionedRewardsInfo != nil && promoteThrough >= rewardsHoldBelowSlot { + job, jerr := unrootedTailState.buildRewardsCompletionFoldJob(rewardsCompletion.slot) + if jerr != nil { + mlog.Log.Errorf("rooted-durable: rewards completion fold: %v", jerr) + return false + } + if err := runFoldJob(unrootedTailState.committer, job); err != nil { + mlog.Log.Errorf("rooted-durable: rewards completion fold: %v", err) + return false + } + applyFoldOutcome(&foldResult{job: job}) + } promotedThrough, rootedCtx, perr := unrootedTailState.flush(promoteThrough) if perr != nil { mlog.Log.Errorf("rooted-durable: forced fold stopped at slot %d: %v", promotedThrough, perr) @@ -2233,7 +2260,13 @@ func ReplayBlocks( // when idle; completions are applied at the top of this function on a // later iteration. if !promoter.inFlight { - job, jerr := unrootedTailState.buildFoldJob(promoteThrough, false) + var job *foldJob + var jerr error + if rewardsHoldBelowSlot > 0 && partitionedRewardsInfo != nil && promoteThrough >= rewardsHoldBelowSlot { + job, jerr = unrootedTailState.buildRewardsCompletionFoldJob(rewardsCompletion.slot) + } else { + job, jerr = unrootedTailState.buildFoldJob(promoteThrough, false) + } if jerr != nil { mlog.Log.Errorf("rooted-durable: %v; watermark held back", jerr) return false @@ -2329,6 +2362,9 @@ func ReplayBlocks( opts.InitialAlpenglowBlockID = resumeState.ParentAlpenglowBlockID opts.HasInitialAlpenglowBlockID = true } + // Streaming execution needs the turbine batch feed; it is only ever + // eligible under Alpenglow with the unrooted tail (see streamingExecutor). + opts.TurbineStreamingExecution = StreamingExecutionCfg.Enabled && useTurbine && alpenglowMode && unrootedTailState != nil // Apply block fetching options if provided if blockFetchOpts != nil { @@ -2500,6 +2536,55 @@ func ReplayBlocks( } } + // Streaming execution: while the loop waits for the next complete block, + // the executor runs the entry batches of frontier+1 as turbine decodes + // them, against a speculative bank on lastSlotCtx. The closures read the + // loop's state at call time. streamInput is nil when the feed is off so + // the wait keeps its exact pre-streaming behaviour. + var streamer *streamingExecutor + var streamInput replayStreamer + // frontierMark is the timeline record of the last executed block (skips + // do not touch it: a child's parent is always a real block); the executor + // reads it to attribute a child's open delay to its parent's arrival, its + // parent's replay, or the loop itself. + var frontierMark streamingFrontierMark + if blockStream.StreamEvents() != nil { + streamer = newStreamingExecutor(streamingDeps{ + acctsDb: acctsDb, + feed: blockStream, + epochSchedule: epochSchedule, + txParallelism: txParallelism, + dbgOpts: dbgOpts, + persistedHashes: persistedHashes, + tail: unrootedTailState, + transactionStatuses: transactionStatuses, + alpenglowClock: alpenglowMode, + alpenglowMode: alpenglowMode, + unrootedTailUsed: unrootedTailState != nil, + lastSlotCtx: func() *sealevel.SlotCtx { return lastSlotCtx }, + frontier: func() uint64 { return replayFrontier }, + frontierMark: func() streamingFrontierMark { return frontierMark }, + currentFeatures: func() *features.Features { return replayCtx.CurrentFeatures }, + currentEpoch: func() uint64 { return currentEpoch }, + rewardsInFlight: func() bool { + return partitionedRewardsInfo != nil && partitionedRewardsInfo.NumRewardPartitionsRemaining > 0 + }, + switchPending: func() bool { + return switchSweeper.peek(alpenglowExecutedBlockIDs, mithrilState.LastRootedSlot, replayFrontier) != nil + }, + executedBlockID: func(slot uint64) (solana.Hash, bool) { + if id, ok := alpenglowExecutedBlockIDs[slot]; ok { + return id, true + } + return global.AlpenglowBlockID(slot) + }, + }) + streamInput = streamer + defer streamer.shutdown() + mlog.Log.Infof("streaming execution enabled: workers=%d min_group_batches=%d max_open=%s", + StreamingExecutionCfg.workers(txParallelism), StreamingExecutionCfg.MinGroupBatches, StreamingExecutionCfg.maxOpenAge()) + } + for { // The collector is per replay attempt. Discarded candidates, skipped // slots, and typed-recovery exits must never leak timings into the next @@ -2518,6 +2603,7 @@ func ReplayBlocks( ingressTimings *b.TurbineIngressTimings waitTime time.Duration neededAt time.Time // when replay asked the source for this slot + admittedAt time.Time // when the source handed replay this input ) { @@ -2551,14 +2637,20 @@ func ReplayBlocks( } neededAt = time.Now() - block, parentSwitch, certifiedSwitch = waitForAlpenglowReplayInput(ctx, - blockStream.NextBlockOrAlpenglowEvent, sweepWhileWaiting, decisionChanges, alpenglowSwitchPollInterval) + if frontierMark.waitEnteredAt.IsZero() { + // First wait after the last executed block: what precedes it is + // that block's post-replay tail (promotion, RPC, stats). + frontierMark.waitEnteredAt = neededAt + } + block, parentSwitch, certifiedSwitch = waitForReplayInput(ctx, + blockStream.NextReplayInput, sweepWhileWaiting, decisionChanges, alpenglowSwitchPollInterval, streamInput) if ingress, ok := block.CompleteTurbineReplayAdmission(time.Now()); ok { _ = statsd.Duration(statsd.TurbineReplayAdmission, ingress.ReplayAdmission, nil) ingressTimings = &ingress } - waitTime = time.Since(neededAt) + admittedAt = time.Now() + waitTime = admittedAt.Sub(neededAt) if stallDone != nil { close(stallDone) @@ -2568,6 +2660,11 @@ func ReplayBlocks( result.WasCancelled = true break } + // Any fork switch invalidates a speculative bank above the frontier: + // its parent chain is about to be unwound or re-served. + if certifiedSwitch != nil || parentSwitch != nil { + streamer.discard("fork_switch") + } if certifiedSwitch != nil { if handleAlpenglowSwitch(certifiedSwitch, func() bool { blockStream.RewindForAlpenglowSwitch(certifiedSwitch.Slot, certifiedSwitch.Certified) @@ -2653,10 +2750,16 @@ func ReplayBlocks( continue } + // A speculative stream may span unresolved slots on the last bank. + // An actual intervening block invalidates that assumption before any + // validation or bank work can observe speculative global state. + streamer.beforeBlock(block) + // An in-flight source send can race the first quarantine drain. Exact // emitted suffix IDs are hard-tombstoned before that send, so discard // any leaked descendant before it reaches consensus observation. if blockStream.IsObjectivelyInvalidAlpenglowBlock(block) { + streamer.discardSlot(block.Slot, "quarantined") mlog.Log.Warnf("replay: discarding quarantined Alpenglow block %s at slot %d before consensus observation", solana.Hash(block.AlpenglowBlockID), block.Slot) continue @@ -2666,6 +2769,7 @@ func ReplayBlocks( if validationErr := validatePreConsensusTransactionStatuses( transactionStatuses, block, currentExecutedAnchorSlot(), ); validationErr != nil { + streamer.discardSlot(block.Slot, "status_validation") if !IsAlreadyProcessedTransactionError(validationErr) { result.Error = fmt.Errorf("pre-consensus block validation failed at slot %d: %w", block.Slot, validationErr) mlog.Log.Errorf("%v", result.Error) @@ -2687,6 +2791,7 @@ func ReplayBlocks( // or any bank changes, while the selected parent is still untouched. if alpenglowMode && !block.IsSkipped { if validationErr := validatePreConsensusRewardCertificates(block, epochSchedule, block.AlpenglowShredVersion); validationErr != nil { + streamer.discardSlot(block.Slot, "reward_certificates") if !IsInvalidRewardCertificateError(validationErr) { result.Error = fmt.Errorf("pre-consensus reward validation failed at slot %d: %w", block.Slot, validationErr) mlog.Log.Errorf("%v", result.Error) @@ -2735,6 +2840,7 @@ func ReplayBlocks( // selected block in either case. if unrootedTailState != nil { if sw := switchSweeper.sweep(alpenglowExecutedBlockIDs, mithrilState.LastRootedSlot, replayFrontier); sw != nil { + streamer.discard("fork_switch") if handleAlpenglowSwitch(sw, func() bool { blockStream.RewindForAlpenglowSwitch(sw.Slot, sw.Certified) return true @@ -2790,6 +2896,7 @@ func ReplayBlocks( // Handle skipped slots - log and continue without execution if block.IsSkipped { + streamer.discardSlot(block.Slot, "skipped") // Zero is the explicit locally consumed outcome for a skip. Parent-ID // gap inference is provisional; recording it lets a later certificate // or discovered ancestry require a source rewind and, when necessary, @@ -2919,6 +3026,7 @@ func ReplayBlocks( boundaryParentCtx = epochBoundaryParentCtx(acctsDb, block, currentEpoch, replayCtx.CurrentFeatures) } partitionedRewardsInfo = handleEpochTransition(acctsDb, partitionedEpochRewardsEnabled, boundaryParentCtx, replayCtx, epochSchedule, replayCtx.CurrentFeatures, block, currentEpoch, rpcc, dbgOpts) + rewardsCompletion = partitionedRewardsCompletion{} currentEpoch = block.Epoch justCrossedEpochBoundary = true // While partitioned rewards are distributing, hold durable promotion @@ -3016,9 +3124,27 @@ func ReplayBlocks( parentBankSysvars = lastSlotCtx.BankSysvars() } if block.FromLocalProduction { + streamer.discardSlot(block.Slot, "local_production") lastSlotCtx, err = adoptLocalLeaderBlock(block, unrootedTailState, transactionStatuses, persistedHashes) } else { - lastSlotCtx, err = ProcessBlock(acctsDb, block, epochSchedule, txParallelism, dbgOpts, persistedHashes, unrootedTailState, transactionStatuses, alpenglowClock, parentBankSysvars) + // A stream open on this slot finishes the block on its speculative + // bank when the block proves to be what it executed; otherwise the + // stream is discarded and the block executes whole, exactly as + // without streaming. + streamed := false + if streamer.matches(block.Slot) { + var streamedCtx *sealevel.SlotCtx + streamedCtx, streamed, err = streamer.finalize(block, parentBankSysvars) + if streamed { + lastSlotCtx = streamedCtx + } + } else { + streamer.discard("other_block") + } + if !streamed { + streamer.noteWholeBlock(block) + lastSlotCtx, err = ProcessBlock(acctsDb, block, epochSchedule, txParallelism, dbgOpts, persistedHashes, unrootedTailState, transactionStatuses, alpenglowClock, parentBankSysvars) + } } processBlockEnd := time.Now() metrics.GlobalBlockReplay.ProcessBlock.AddTiming(processBlockEnd.Sub(processBlockStart)) @@ -3031,6 +3157,7 @@ func ReplayBlocks( } // The successful child now owns its derived snapshot. Any later bank uses // lastSlotCtx; the one-shot retained unwind bridge is no longer needed. + rewardsCompletion.observeBank(partitionedRewardsInfo, lastSlotCtx.BankSysvars()) unwoundParentBankSysvars = nil postProcessBlockStart := processBlockEnd statusViewStart := time.Now() @@ -3087,6 +3214,11 @@ func ReplayBlocks( break } } + recordFullToReplayed(block) + // The same instant FullToReplayed ends at: from here to the next wait + // entry is this block's post-replay tail, which a child's open timeline + // reports as OpenWaitPostReplay. + frontierMark = streamingFrontierMark{slot: block.Slot, fullNanos: block.ShredFullNanos, admittedAt: admittedAt, replayedAt: time.Now()} if rpcServer != nil { rpcServer.SetSlotCtx(lastSlotCtx) @@ -4159,71 +4291,31 @@ func ProcessBlock( return nil, fmt.Errorf("validate transaction messages for slot %d: %w", block.Slot, err) } statusValidationStart := time.Now() - statusValidationErr := transactionStatuses.validateBlockWithPlan(block, executionPlan) + statusValidation, statusValidationErr := transactionStatuses.validateBlockForPublication(block, executionPlan) metrics.GlobalBlockReplay.TransactionStatusValidation.AddTimingSince(statusValidationStart) if statusValidationErr != nil { return nil, fmt.Errorf("validate transaction statuses for slot %d: %w", block.Slot, statusValidationErr) } - ctx, task := trace.NewTask(context.Background(), "ProcessBlock") - defer task.End() - trace.Log(ctx, "slot", fmt.Sprintf("%d", block.Slot)) - trace.Log(ctx, "txCount", fmt.Sprintf("%d", len(block.Transactions))) - - var replayStage atomic.Value - var replayStageSince atomic.Int64 - setReplayStage := func(stage string) { - replayStage.Store(stage) - replayStageSince.Store(time.Now().UnixNano()) - } - setReplayStage("prepare_dependency_planner") - - replayWatchdogDone := make(chan struct{}) - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - var lastLoggedStage string - var lastLoggedSince int64 - for { - select { - case <-replayWatchdogDone: - return - case <-ticker.C: - stageVal := replayStage.Load() - stage, ok := stageVal.(string) - if !ok || stage == "" { - continue - } - sinceUnix := replayStageSince.Load() - if sinceUnix == 0 { - continue - } - if stage == lastLoggedStage && sinceUnix == lastLoggedSince { - continue - } - stageDuration := time.Since(time.Unix(0, sinceUnix)) - if stageDuration < 10*time.Second { - continue - } - mlog.Log.Warnf("REPLAY WATCHDOG: slot %d stuck in stage %s for %s | txs=%d | lightbringer=%t", - block.Slot, stage, stageDuration.Round(time.Second), len(block.Transactions), block.FromLiveStream) - lastLoggedStage = stage - lastLoggedSince = sinceUnix - } + statusPreparation := transactionStatuses.startStatusPreparation(executionPlan) + defer func() { + // Join before returning so a rejected bank cannot leave work behind or + // charge its preparation time to the next block's metrics record. + statusPreparation.wait() + if statusPreparation != nil { + metrics.GlobalBlockReplay.TransactionStatusPreparation.AddTiming(statusPreparation.duration) } }() - defer close(replayWatchdogDone) + + // The resumable execution state carries the trace task, stage watchdog and + // the SlotCtx; streaming execution drives the same object group by group. + exec := newBlockExecution(acctsDb, block, epochSchedule, txParallelism, dbgOpts, persistedHashes, tail, transactionStatuses, alpenglowClock, parentBankSysvars) + defer exec.close() + exec.setReplayStage("prepare_dependency_planner") if SerializedParameterArena != nil { SerializedParameterArena.Reset() } - var sigverifyWg sync.WaitGroup - defer func() { - sigverifyJoinStart := time.Now() - sigverifyWg.Wait() - metrics.GlobalBlockReplay.SignatureVerificationJoin.AddTimingSince(sigverifyJoinStart) - }() plannerPreparationStart := time.Now() var planner *preparedDependencyPlanner if txParallelism > 0 { @@ -4236,15 +4328,9 @@ func ProcessBlock( metrics.GlobalBlockReplay.DependencyPlannerPreparation.AddTimingSince(plannerPreparationStart) start := time.Now() - setReplayStage("load_accounts") - loadAcctsRegion := trace.StartRegion(ctx, "LoadBlockAccounts") - // In rooted-durable mode, block accounts/sysvars load through the unrooted - // tail (overlay→durable) so execution sees confirmed-but-unrooted state. - var blockSrc blockAccountSource = acctsDb - if tail != nil { - blockSrc = tail - } - accts, parentAccts, accountMapCapacity, bankSysvars, err := loadBlockAccountsAndUpdateSysvars(blockSrc, block, epochSchedule, alpenglowClock, parentBankSysvars, planner) + exec.setReplayStage("load_accounts") + loadAcctsRegion := trace.StartRegion(exec.ctx, "LoadBlockAccounts") + accts, parentAccts, accountMapCapacity, bankSysvars, err := loadBlockAccountsAndUpdateSysvars(exec.blockSrc, block, epochSchedule, alpenglowClock, parentBankSysvars, planner) loadAcctsRegion.End() if err != nil { panic(fmt.Sprintf("unable to load slot accounts and update sysvars: %s", err)) @@ -4255,181 +4341,53 @@ func ProcessBlock( metrics.GlobalBlockReplay.LoadBlockAccounts.AddTimingSince(start) slotCtxSetupStart := time.Now() - slotCtx := newSlotCtx(block, accts, parentAccts, acctsDb, tail, accountMapCapacity) - if err := slotCtx.PublishBankSysvars(bankSysvars); err != nil { - return nil, fmt.Errorf("publish bank sysvars at slot %d: %w", block.Slot, err) + if err := exec.installSlotCtx(accts, parentAccts, accountMapCapacity, bankSysvars); err != nil { + return nil, err } - bankEpochScheduleValue, ok := bankSysvars.EpochSchedule() - if !ok { - return nil, fmt.Errorf("bank-local EpochSchedule sysvar unavailable at slot %d", block.Slot) - } - bankEpochSchedule := &bankEpochScheduleValue + slotCtx := exec.slotCtx if requireAlpenglowBlockFooter(block, slotCtx, alpenglowClock) { if err := validateAlpenglowFooterNanosecondClock(slotCtx, block); err != nil { return nil, err } } - slotCtx.TraceCtx = ctx slotCtx.NumSignatures = executionPlan.processedSignatures metrics.GlobalBlockReplay.SlotCtxSetup.AddTimingSince(slotCtxSetupStart) var txFeeAccumulator fees.TxFeeInfoAccumulator var totalComputeUnitsConsumed uint64 start = time.Now() - setReplayStage("tx_loop") - txLoopRegion := trace.StartRegion(ctx, "TxLoop") + exec.setReplayStage("tx_loop") + txLoopRegion := trace.StartRegion(exec.ctx, "TxLoop") shouldVerifySignatures := !block.TransactionSignaturesVerified() if txParallelism > 0 { - txFeeAccumulator, totalComputeUnitsConsumed = parallelTxLoop(slotCtx, &sigverifyWg, planner, block, executionPlan, txParallelism, dbgOpts, shouldVerifySignatures) + txFeeAccumulator, totalComputeUnitsConsumed = parallelTxLoop(slotCtx, &exec.sigverifyWg, planner, block, executionPlan, txParallelism, dbgOpts, shouldVerifySignatures) } else { - txFeeAccumulator, totalComputeUnitsConsumed = sequentialTxLoop(slotCtx, &sigverifyWg, block, executionPlan, dbgOpts, shouldVerifySignatures) + txFeeAccumulator, totalComputeUnitsConsumed = sequentialTxLoop(slotCtx, &exec.sigverifyWg, block, executionPlan, dbgOpts, shouldVerifySignatures) } slotCtx.TotalComputeUnitsConsumed = totalComputeUnitsConsumed txLoopRegion.End() metrics.GlobalBlockReplay.TxLoop.AddTimingSince(start) - start = time.Now() - setReplayStage("distribute_fees") - - // distribute tx fees to the slot leader - // skip leader handling if there are zero transactions in this block - if !global.ManageLeaderSchedule() && block.BlockReward != nil && len(block.Transactions) > 0 { - slotCtx.LamportsBurnt = fees.DistributeTxFeesToSlotLeader(acctsDb, slotCtx, block.BlockReward.Leader, &txFeeAccumulator) - slotCtx.RecordModifiedAcct(block.BlockReward.Leader) - } else if global.ManageLeaderSchedule() && len(block.Transactions) > 0 { - slotCtx.LamportsBurnt = fees.DistributeTxFeesToSlotLeader(acctsDb, slotCtx, block.Leader, &txFeeAccumulator) - slotCtx.RecordModifiedAcct(block.Leader) - } - metrics.GlobalBlockReplay.Reward.AddTimingSince(start) - - start = time.Now() - setReplayStage("collect_rent") - bankRent, ok := slotCtx.BankSysvars().Rent() - if !ok { - return nil, fmt.Errorf("bank-local Rent sysvar unavailable at slot %d", block.Slot) - } - rentAccts := rent.CollectRentEagerly(slotCtx, &bankRent, bankEpochSchedule) - metrics.GlobalBlockReplay.Rent.AddTimingSince(start) - - start = time.Now() - setReplayStage("run_incinerator") - runIncinerator(slotCtx) - metrics.GlobalBlockReplay.RunIncinerator.AddTimingSince(start) - - // Alpenglow banks set the Clock timestamp from the block footer after execution. - if alpenglowClock { - footerClockStart := time.Now() - if err := applyAlpenglowFooterClock(slotCtx, block, bankEpochSchedule); err != nil { - metrics.GlobalBlockReplay.AlpenglowFooterClock.AddTimingSince(footerClockStart) - return nil, fmt.Errorf("apply alpenglow footer clock at slot %d: %w", block.Slot, err) - } - if err := updateAlpenglowNanosecondClockAccount(slotCtx, block); err != nil { - metrics.GlobalBlockReplay.AlpenglowFooterClock.AddTimingSince(footerClockStart) - return nil, err - } - metrics.GlobalBlockReplay.AlpenglowFooterClock.AddTimingSince(footerClockStart) - voteRewardsStart := time.Now() - voteRewardsErr := ApplyAlpenglowVoteRewards(slotCtx, block, bankEpochSchedule, block.SkipRewardCert, block.NotarRewardCert, block.BlockFinalCert, block.AlpenglowShredVersion) - metrics.GlobalBlockReplay.AlpenglowVoteRewards.AddTimingSince(voteRewardsStart) - if voteRewardsErr != nil { - return nil, voteRewardsErr - } - } - if err := finalizeBankSysvars(slotCtx); err != nil { - return nil, fmt.Errorf("finalize bank sysvars at slot %d: %w", block.Slot, err) - } - - setReplayStage("compile_accounts") - start = time.Now() - writableAccts, modifiedAccts := compileWritableAndModifiedAccts(slotCtx, block, rentAccts) - metrics.GlobalBlockReplay.CompileWritableAndModifiedAccts.AddTimingSince(start) - start = time.Now() - ensureParentsErr := ensureParentAccountsForModified(slotCtx, modifiedAccts) - metrics.GlobalBlockReplay.EnsureParentAccountsForModified.AddTimingSince(start) - if ensureParentsErr != nil { - return nil, ensureParentsErr - } - - start = time.Now() - setReplayStage("bankhash") - slotCtx.FinalBankhash = bankhash.CalculateBankHash(slotCtx, writableAccts, modifiedAccts, block.ParentBankhash, slotCtx.NumSignatures, block.Blockhash) - metrics.GlobalBlockReplay.BankHash.AddTimingSince(start) - if alpenglowClock { - footerVerificationStart := time.Now() - footerVerificationErr := verifyAlpenglowBlockFooter(slotCtx, block, alpenglowClock) - metrics.GlobalBlockReplay.AlpenglowFooterVerification.AddTimingSince(footerVerificationStart) - if footerVerificationErr != nil { - writeFooterBankhashMismatchArtifact(footerVerificationErr, block, slotCtx, writableAccts, modifiedAccts) - return nil, footerVerificationErr - } - } - - // Bankhash consensus enforcement is handled in the replay loop (not here) - // because forkchoice is fed after ProcessBlock returns — checking here would - // never see votes from recently submitted blocks and could deadlock. - - // Enter critical commit window - panics here may leave AccountsDB inconsistent - commitSlot.Store(slotCtx.Slot) - commitInProgress.Store(true) - blockUpdateStart := time.Now() - setReplayStage("store_accounts") - persistedSlot := slotCtx.Slot - persistedBankhash := append([]byte(nil), slotCtx.FinalBankhash...) - persistedBlockSlot := block.Slot - stakeIndexDir := filepath.Join(acctsDb.AcctsDir, "..") - afterStoreAccounts := func() { - if tail != nil { - // Rooted-durable: accounts + bankhash are buffered in the overlay and - // become durable only on promotion; nothing written here (rooted-only). - } else { - if berr := acctsDb.StoreBankHashForSlot(persistedSlot, persistedBankhash); berr != nil { - mlog.Log.Infof("unable to store bankhash for slot %d", persistedSlot) - } - } - if tail == nil { - // Legacy/verify modes (no fork ambiguity): flush per block as before. - // Rooted-durable replay flushes at FOLD time instead — entries stay - // slot-scoped in RAM so a fork unwind can drop them, and scans merge - // the pending set (StreamStakeAccounts) for completeness meanwhile. - flushed, err := global.FlushPendingStakePubkeys(stakeIndexDir) - if err != nil { - mlog.Log.Errorf("failed to flush stake pubkey index: %v", err) - } else if flushed > 0 { - mlog.Log.Debugf("flushed %d new stake pubkeys to index", flushed) - } - } - - persistedHashes.Set(persistedBlockSlot, persistedBankhash) - - // Exit critical commit window - AccountsDB is now consistent - commitInProgress.Store(false) - commitSlot.Store(0) - } + exec.txFeeAccumulator = txFeeAccumulator + exec.totalCU = totalComputeUnitsConsumed + exec.executionPlan = executionPlan + exec.statusPreparation = statusPreparation + exec.statusValidation = statusValidation + return exec.finalize() +} - if tail != nil { - // Rooted-durable: buffer this slot's writes + bankhash in the RAM overlay - // (always, even when empty, so the bankhash is recorded); no durable write. - tail.Add(slotCtx.Slot, modifiedAccts, persistedBankhash) - afterStoreAccounts() - } else if len(modifiedAccts) > 0 { - err = acctsDb.StoreAccounts(modifiedAccts, slotCtx.Slot, afterStoreAccounts) - } - // In rooted-durable mode the callback above is synchronous, so this includes - // the complete critical-path overlay publication. Legacy StoreAccounts only - // enqueues here; its asynchronous disk work deliberately belongs to no slot's - // replay wall time and must never update a later slot's metrics record. - metrics.GlobalBlockReplay.BlockUpdateAccounts.AddTimingSince(blockUpdateStart) - if err != nil { - return slotCtx, err +// recordFullToReplayed measures the vote-path latency replay controls for a +// turbine block: from the assembler's full-assembly instant (the last shred, +// carried as ShredFullNanos) to the replay result reaching consensus. Blocks +// that did not arrive as shreds carry no full instant and record nothing. +func recordFullToReplayed(block *b.Block) { + if block == nil || block.ShredFullNanos <= 0 { + return } - statusCommitStart := time.Now() - statusErr := transactionStatuses.commitBlockWithPlan(block, executionPlan) - metrics.GlobalBlockReplay.TransactionStatusCommit.AddTimingSince(statusCommitStart) - if statusErr != nil { - return nil, fmt.Errorf("commit transaction statuses for slot %d after bank state commit: %w", block.Slot, statusErr) + fullToReplayed := time.Since(time.Unix(0, block.ShredFullNanos)) + if fullToReplayed <= 0 { + return } - - global.IncrTransactionCount(executionPlan.processedTxCount) - setReplayStage("done") - return slotCtx, err + metrics.GlobalBlockReplay.FullToReplayed.AddTiming(fullToReplayed) + _ = statsd.Duration(statsd.ReplayFullToReplayed, fullToReplayed, nil) } diff --git a/pkg/replay/block_execution.go b/pkg/replay/block_execution.go new file mode 100644 index 000000000..5e5e5518e --- /dev/null +++ b/pkg/replay/block_execution.go @@ -0,0 +1,699 @@ +package replay + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "runtime/trace" + "sync" + "sync/atomic" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/arena" + "github.com/Overclock-Validator/mithril/pkg/bankhash" + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/fees" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/Overclock-Validator/mithril/pkg/rent" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/txstatus" + "github.com/gagliardetto/solana-go" +) + +// blockExecution is the resumable state of one bank's execution. ProcessBlock +// drives it in one pass (plan, load, execute every transaction, finalize). +// Streaming execution opens it before the block is complete, feeds +// transaction groups as their shreds arrive, and finalizes against the +// complete block. Both paths share the opening (bank sysvars, SlotCtx) and the +// tail (fees, rent, footer, bank hash, commit), so a bank produced either way +// runs the same end-of-block code over the same SlotCtx. +type blockExecution struct { + acctsDb *accountsdb.AccountsDb + block *b.Block + epochSchedule *sealevel.SysvarEpochSchedule + txParallelism int + dbgOpts *DebugOptions + persistedHashes *persistedTracker + tail unrootedState + transactionStatuses *TransactionStatusCache + alpenglowClock bool + parentBankSysvars *sealevel.BankSysvars + + blockSrc blockAccountSource + slotCtx *sealevel.SlotCtx + parentAccts accounts.MemAccounts + accts accounts.Accounts + bankSysvars *sealevel.BankSysvars + bankEpochSchedule *sealevel.SysvarEpochSchedule + + ctx context.Context + task *trace.Task + setReplayStage func(string) + watchdogDone chan struct{} + sigverifyWg sync.WaitGroup + closed bool + + // Whole-block inputs to the tail, set by ProcessBlock. + executionPlan blockTransactionExecutionPlan + statusPreparation *transactionStatusPreparation + statusValidation transactionStatusValidation + + // Incremental transaction bookkeeping in block order, maintained by + // executeTransactionGroup. ProcessBlock does not use it. + transactions []*solana.Transaction + identities []txstatus.TransactionMessageIdentity + execute []bool + seenMessages map[[32]byte]int + processedTxCount uint64 + processedSignatures uint64 + groups int + + txFeeAccumulator fees.TxFeeInfoAccumulator + totalCU uint64 + + // Retained across global metric resets while a speculative stream waits. + accountLoader metrics.AccountLoader +} + +// newBlockExecution installs the per-bank trace task, the stage watchdog and +// the account source; it does not touch bank state. +func newBlockExecution( + acctsDb *accountsdb.AccountsDb, + block *b.Block, + epochSchedule *sealevel.SysvarEpochSchedule, + txParallelism int, + dbgOpts *DebugOptions, + persistedHashes *persistedTracker, + tail unrootedState, + transactionStatuses *TransactionStatusCache, + alpenglowClock bool, + parentBankSysvars *sealevel.BankSysvars, +) *blockExecution { + exec := &blockExecution{ + acctsDb: acctsDb, + block: block, + epochSchedule: epochSchedule, + txParallelism: txParallelism, + dbgOpts: dbgOpts, + persistedHashes: persistedHashes, + tail: tail, + transactionStatuses: transactionStatuses, + alpenglowClock: alpenglowClock, + parentBankSysvars: parentBankSysvars, + seenMessages: make(map[[32]byte]int), + } + // In rooted-durable mode, block accounts/sysvars load through the unrooted + // tail (overlay→durable) so execution sees confirmed-but-unrooted state. + exec.blockSrc = acctsDb + if tail != nil { + exec.blockSrc = tail + } + + ctx, task := trace.NewTask(context.Background(), "ProcessBlock") + exec.ctx, exec.task = ctx, task + trace.Log(ctx, "slot", fmt.Sprintf("%d", block.Slot)) + trace.Log(ctx, "txCount", fmt.Sprintf("%d", len(block.Transactions))) + + var replayStage atomic.Value + var replayStageSince atomic.Int64 + exec.setReplayStage = func(stage string) { + replayStage.Store(stage) + replayStageSince.Store(time.Now().UnixNano()) + } + + exec.watchdogDone = make(chan struct{}) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + var lastLoggedStage string + var lastLoggedSince int64 + for { + select { + case <-exec.watchdogDone: + return + case <-ticker.C: + stageVal := replayStage.Load() + stage, ok := stageVal.(string) + if !ok || stage == "" { + continue + } + sinceUnix := replayStageSince.Load() + if sinceUnix == 0 { + continue + } + if stage == lastLoggedStage && sinceUnix == lastLoggedSince { + continue + } + stageDuration := time.Since(time.Unix(0, sinceUnix)) + if stageDuration < 10*time.Second { + continue + } + mlog.Log.Warnf("REPLAY WATCHDOG: slot %d stuck in stage %s for %s | txs=%d | lightbringer=%t", + block.Slot, stage, stageDuration.Round(time.Second), len(block.Transactions), block.FromLiveStream) + lastLoggedStage = stage + lastLoggedSince = sinceUnix + } + } + }() + return exec +} + +// close joins outstanding signature verification, stops the watchdog and ends +// the trace task, in the order ProcessBlock's deferred cleanup always used. It +// is idempotent so a discarded stream and a finalized bank both call it. +func (exec *blockExecution) close() { + if exec == nil || exec.closed { + return + } + exec.closed = true + sigverifyJoinStart := time.Now() + exec.sigverifyWg.Wait() + metrics.GlobalBlockReplay.SignatureVerificationJoin.AddTimingSince(sigverifyJoinStart) + if exec.watchdogDone != nil { + close(exec.watchdogDone) + } + if exec.task != nil { + exec.task.End() + } +} + +// installSlotCtx publishes the loaded parent snapshot and derived bank sysvars +// as this bank's SlotCtx. The overlay/parent pair comes from +// loadBlockAccountsAndUpdateSysvars; streaming grows the parent snapshot +// afterwards, group by group, through loadTransactionAccounts. +func (exec *blockExecution) installSlotCtx(accts accounts.Accounts, parentAccts accounts.Accounts, accountMapCapacity int, bankSysvars *sealevel.BankSysvars) error { + block := exec.block + slotCtx := newSlotCtx(block, accts, parentAccts, exec.acctsDb, exec.tail, accountMapCapacity) + if err := slotCtx.PublishBankSysvars(bankSysvars); err != nil { + return fmt.Errorf("publish bank sysvars at slot %d: %w", block.Slot, err) + } + bankEpochScheduleValue, ok := bankSysvars.EpochSchedule() + if !ok { + return fmt.Errorf("bank-local EpochSchedule sysvar unavailable at slot %d", block.Slot) + } + slotCtx.TraceCtx = exec.ctx + exec.slotCtx = slotCtx + exec.accts = accts + if mem, ok := parentAccts.(accounts.MemAccounts); ok { + exec.parentAccts = mem + } + exec.bankSysvars = bankSysvars + exec.bankEpochSchedule = &bankEpochScheduleValue + return nil +} + +// open performs the bank-start work that needs only the parent state: the +// parent sysvar pin and this bank's Clock/SlotHashes derivation, the parent +// snapshot for whatever transactions the block currently carries (none for a +// streaming shell), the overlay, and the SlotCtx. It is ProcessBlock's opening +// without the whole-block planner, so a streaming caller can start executing +// groups before any transaction of the block is known. +func (exec *blockExecution) open() error { + defer exec.captureAccountLoader()() + block := exec.block + exec.setReplayStage("prepare_dependency_planner") + if SerializedParameterArena != nil { + SerializedParameterArena.Reset() + } + + start := time.Now() + exec.setReplayStage("load_accounts") + loadAcctsRegion := trace.StartRegion(exec.ctx, "LoadBlockAccounts") + accts, parentAccts, accountMapCapacity, bankSysvars, err := loadBlockAccountsAndUpdateSysvars(exec.blockSrc, block, exec.epochSchedule, exec.alpenglowClock, exec.parentBankSysvars, nil) + loadAcctsRegion.End() + if err != nil { + return fmt.Errorf("load slot accounts and update sysvars at slot %d: %w", block.Slot, err) + } + if err := bankSysvars.ValidateForExecution(); err != nil { + return fmt.Errorf("invalid bank sysvar snapshot at slot %d: %w", block.Slot, err) + } + metrics.GlobalBlockReplay.LoadBlockAccounts.AddTimingSince(start) + + slotCtxSetupStart := time.Now() + if err := exec.installSlotCtx(accts, parentAccts, accountMapCapacity, bankSysvars); err != nil { + return err + } + metrics.GlobalBlockReplay.SlotCtxSetup.AddTimingSince(slotCtxSetupStart) + return nil +} + +// errBlockExecutionClosed reports a group offered after close or finalize. +var errBlockExecutionClosed = errors.New("block execution is closed") + +// groupIdentitiesFor returns prepared identities for a transaction group, +// hashing the messages when the caller has none from signature verification. +func groupIdentitiesFor(txs []*solana.Transaction, identities *b.PreparedTransactionMessageIdentities) (*b.PreparedTransactionMessageIdentities, error) { + if identities != nil { + if identities.Len() != len(txs) { + return nil, fmt.Errorf("group identities cover %d transactions, group has %d", identities.Len(), len(txs)) + } + return identities, nil + } + view := &b.Block{Transactions: txs} + return view.PrepareTransactionMessageIdentities() +} + +// executeTransactionGroup executes the next transactions of the block, in +// block order, against the open SlotCtx. Every check ProcessBlock applies to a +// whole block is applied incrementally: message versions against the bank's +// features, duplicate messages across every group so far (a duplicate makes +// the whole block invalid, exactly as planBlockTransactionExecution reports +// it), ancestor status-cache validation, address-table resolution, account +// loading into the same parent snapshot, and a dependency plan over the group +// executed by up to txParallelism workers. Groups run strictly one after +// another, so cross-group ordering is the sequential block order. +// +// identities may carry the verifier's message identities for exactly these +// transactions; nil hashes them here. shouldVerifySignatures is passed to +// ProcessTransaction unchanged. +func (exec *blockExecution) executeTransactionGroup(txs []*solana.Transaction, identities *b.PreparedTransactionMessageIdentities, shouldVerifySignatures bool) error { + if exec == nil || exec.slotCtx == nil { + return errors.New("block execution is not open") + } + if exec.closed { + return errBlockExecutionClosed + } + if len(txs) == 0 { + return nil + } + block := exec.block + slot := block.Slot + base := len(exec.transactions) + + view := &b.Block{Slot: slot, Transactions: txs, Features: block.Features} + if err := validateBlockTransactionVersions(view); err != nil { + return fmt.Errorf("validate transaction versions for slot %d: %w", slot, err) + } + + prepared, err := groupIdentitiesFor(txs, identities) + if err != nil { + return fmt.Errorf("validate transaction messages for slot %d: %w", slot, err) + } + execute := make([]bool, len(txs)) + var duplicates *DuplicateTransactionMessagesError + for idx, tx := range txs { + if tx == nil { + return fmt.Errorf("validate transaction messages for slot %d: transaction %d is nil", slot, base+idx) + } + identity := prepared.Identity(idx) + if firstIndex, duplicate := exec.seenMessages[identity.MessageHash]; duplicate { + if duplicates == nil { + duplicates = &DuplicateTransactionMessagesError{Slot: slot} + } + duplicates.DuplicateCount++ + if len(duplicates.Occurrences) < maxDuplicateTransactionOccurrences { + duplicates.Occurrences = append(duplicates.Occurrences, DuplicateTransactionOccurrence{ + Index: base + idx, FirstIndex: firstIndex, + }) + } + continue + } + exec.seenMessages[identity.MessageHash] = base + idx + execute[idx] = true + } + if duplicates != nil { + return fmt.Errorf("validate transaction messages for slot %d: %w", slot, duplicates) + } + if exec.transactionStatuses != nil { + if err := exec.transactionStatuses.validateTransactionsAgainstAncestors(slot, prepared); err != nil { + return fmt.Errorf("validate transaction statuses for slot %d: %w", slot, err) + } + } + + // Record the group before executing so a failure after this point still + // leaves the block-order view consistent for finalize's prefix proof. + for idx, tx := range txs { + exec.transactions = append(exec.transactions, tx) + exec.identities = append(exec.identities, prepared.Identity(idx)) + exec.execute = append(exec.execute, execute[idx]) + if execute[idx] { + exec.processedTxCount++ + exec.processedSignatures += uint64(tx.Message.Header.NumRequiredSignatures) + } + } + exec.slotCtx.NumSignatures = exec.processedSignatures + + exec.setReplayStage("load_accounts") + if err := exec.loadTransactionAccounts(view); err != nil { + return err + } + + exec.setReplayStage("tx_loop") + start := time.Now() + txLoopRegion := trace.StartRegion(exec.ctx, "TxLoop") + feeInfos, computeUnits, err := exec.runTransactionGroup(txs, execute, shouldVerifySignatures) + txLoopRegion.End() + metrics.GlobalBlockReplay.TxLoop.AddTimingSince(start) + if err != nil { + return err + } + for idx, txFeeInfo := range feeInfos { + if !execute[idx] { + continue + } + exec.totalCU += computeUnits[idx] + exec.txFeeAccumulator.Add(txFeeInfo) + } + exec.slotCtx.TotalComputeUnitsConsumed = exec.totalCU + exec.groups++ + metrics.GlobalBlockReplay.StreamingExecution.Groups++ + metrics.GlobalBlockReplay.StreamingExecution.Transactions += uint64(len(txs)) + return nil +} + +// captureAccountLoader isolates this stream's loader work from the global +// record, which may belong to another replayed slot or be reset while waiting. +// Only the replay goroutine may enter this scope; loader workers are joined +// before it exits. Discarded streams never publish their retained totals. +func (exec *blockExecution) captureAccountLoader() func() { + previous := metrics.GlobalBlockReplay.AccountLoader + metrics.GlobalBlockReplay.AccountLoader = metrics.AccountLoader{} + return func() { + exec.accountLoader.Accumulate(metrics.GlobalBlockReplay.AccountLoader) + metrics.GlobalBlockReplay.AccountLoader = previous + } +} + +// loadTransactionAccounts resolves the group's address-table lookups and adds +// the pristine parent image of every account the group can touch to the +// parent snapshot, exactly as the whole-block loader does for a block, except +// that accounts already present keep their earlier image: the batch read at +// block.Slot through the same source returns parent state regardless of the +// overlay, so the first image is the right one and later groups must not +// replace it. +func (exec *blockExecution) loadTransactionAccounts(view *b.Block) error { + defer exec.captureAccountLoader()() + phaseStart := time.Now() + if err := resolveAddrTableLookups(exec.blockSrc, view); err != nil { + return fmt.Errorf("resolve address table lookups at slot %d: %w", view.Slot, err) + } + metrics.GlobalBlockReplay.AccountLoader.AddressTableLookups.AddTimingSince(phaseStart) + + phaseStart = time.Now() + dedupedAccts, _ := extractAndDedupeBlockAccts(view) + if exec.parentAccts.Map != nil { + filtered := dedupedAccts[:0] + for _, key := range dedupedAccts { + if _, loaded := exec.parentAccts.Map[key]; !loaded { + filtered = append(filtered, key) + } + } + dedupedAccts = filtered + } + metrics.GlobalBlockReplay.AccountLoader.DedupeBlockAccounts.AddTimingSince(phaseStart) + if len(dedupedAccts) == 0 { + return nil + } + + phaseStart = time.Now() + slotAccts, batchStats, err := getAccountsBatchSharedWithStats(context.Background(), exec.blockSrc, view.Slot, dedupedAccts) + metrics.GlobalBlockReplay.AccountLoader.SourceBatch.AddTimingSince(phaseStart) + recordAccountLoaderBatchStats(&metrics.GlobalBlockReplay.AccountLoader, batchStats) + if err != nil { + return fmt.Errorf("load transaction accounts at slot %d: %w", view.Slot, err) + } + if exec.parentAccts.Map == nil { + return fmt.Errorf("load transaction accounts at slot %d: parent snapshot is not a memory account set", view.Slot) + } + phaseStart = time.Now() + for _, acct := range slotAccts { + if acct == nil { + continue + } + if _, loaded := exec.parentAccts.Map[acct.Key]; loaded { + continue + } + key := [32]byte(acct.Key) + if err := exec.parentAccts.SetAccount(&key, acct); err != nil { + return err + } + } + metrics.GlobalBlockReplay.AccountLoader.ParentAccounts += uint64(len(slotAccts)) + metrics.GlobalBlockReplay.AccountLoader.ParentMapBuild.AddTimingSince(phaseStart) + return nil +} + +// runTransactionGroup is parallelTxLoop over a transaction slice with a plan +// built for the group alone (indices are group-local). Without the planner +// (txParallelism <= 1) it runs sequentially, which +// is always correct because groups are consumed in block order. +func (exec *blockExecution) runTransactionGroup(txs []*solana.Transaction, execute []bool, shouldVerifySignatures bool) ([]*fees.TxFeeInfo, []uint64, error) { + slotCtx := exec.slotCtx + feeInfos := make([]*fees.TxFeeInfo, len(txs)) + computeUnits := make([]uint64, len(txs)) + dbgOpts := exec.dbgOpts + + workers := exec.txParallelism + if workers > len(txs) { + workers = len(txs) + } + view := &b.Block{Transactions: txs} + if !canUseDependencyPlanner(view) { + return nil, nil, errors.New("streaming group has unresolved address tables") + } + var plan *dependencyPlan + if workers > 1 { + plannerBuildStart := time.Now() + plannerAccounts, _ := plannerAccountsForBlock(view) + plan = buildDependencyPlan(plannerAccounts) + metrics.GlobalBlockReplay.DependencyPlannerBuild.AddTimingSince(plannerBuildStart) + } + if plan == nil { + for idx, tx := range txs { + if !execute[idx] { + continue + } + var txErr error + feeInfos[idx], computeUnits[idx], txErr = ProcessTransaction(slotCtx, &exec.sigverifyWg, tx, nil, dbgOpts, nil, shouldVerifySignatures) + if feeInfos[idx] == nil { + return nil, nil, streamingTransactionError(idx, txErr) + } + } + return feeInfos, computeUnits, nil + } + + metrics.GlobalBlockReplay.DependencyPlannerPrepared = 1 + txErrors := make([]error, len(txs)) + do := make(chan int, len(txs)) + done := make(chan int, len(txs)) + plannerDone := make(chan struct{}) + go func() { + defer close(plannerDone) + plannerDispatchStart := time.Now() + dispatchDependencyPlan(plan, do, done) + metrics.GlobalBlockReplay.DependencyPlannerDispatch.AddTimingSince(plannerDispatchStart) + }() + + wg := &sync.WaitGroup{} + wg.Add(workers) + for i := 0; i < workers; i++ { + go func(workerIdx int) { + defer wg.Done() + var workerArena *arena.Arena[sealevel.BorrowedAccount] + if workerIdx < len(sealevel.BorrowedAccountArenas) { + workerArena = sealevel.BorrowedAccountArenas[workerIdx] + } + for idx := range do { + if !execute[idx] { + done <- idx + continue + } + feeInfos[idx], computeUnits[idx], txErrors[idx] = ProcessTransaction(slotCtx, &exec.sigverifyWg, txs[idx], nil, dbgOpts, workerArena, shouldVerifySignatures) + done <- idx + } + }(i) + } + wg.Wait() + close(done) + <-plannerDone + for idx := range txs { + if execute[idx] && feeInfos[idx] == nil { + return nil, nil, streamingTransactionError(idx, txErrors[idx]) + } + } + return feeInfos, computeUnits, nil +} + +// Instruction failures still carry charged fees and remain valid block entries. +// A missing fee result means transaction admission failed: discard the bank. +func streamingTransactionError(index int, err error) error { + if err == nil { + err = errors.New("missing fee result") + } + return fmt.Errorf("unprocessable streaming transaction %d: %w", index, err) +} + +// finalize runs the end-of-block phases over the open SlotCtx: fees to the +// leader, rent, incinerator, the Alpenglow footer clock and vote rewards, bank +// sysvar finalization, bank hash, footer verification, state publication and +// transaction status commit. It is the unchanged tail of ProcessBlock and is +// shared by streaming execution, which calls it once the complete block has +// been matched against the executed prefix. +func (exec *blockExecution) finalize() (*sealevel.SlotCtx, error) { + block := exec.block + slotCtx := exec.slotCtx + acctsDb := exec.acctsDb + tail := exec.tail + setReplayStage := exec.setReplayStage + alpenglowClock := exec.alpenglowClock + bankEpochSchedule := exec.bankEpochSchedule + txFeeAccumulator := exec.txFeeAccumulator + executionPlan := exec.executionPlan + transactionStatuses := exec.transactionStatuses + persistedHashes := exec.persistedHashes + var err error + + start := time.Now() + setReplayStage("distribute_fees") + + // distribute tx fees to the slot leader + // skip leader handling if there are zero transactions in this block + if !global.ManageLeaderSchedule() && block.BlockReward != nil && len(block.Transactions) > 0 { + slotCtx.LamportsBurnt = fees.DistributeTxFeesToSlotLeader(acctsDb, slotCtx, block.BlockReward.Leader, &txFeeAccumulator) + slotCtx.RecordModifiedAcct(block.BlockReward.Leader) + } else if global.ManageLeaderSchedule() && len(block.Transactions) > 0 { + slotCtx.LamportsBurnt = fees.DistributeTxFeesToSlotLeader(acctsDb, slotCtx, block.Leader, &txFeeAccumulator) + slotCtx.RecordModifiedAcct(block.Leader) + } + metrics.GlobalBlockReplay.Reward.AddTimingSince(start) + + start = time.Now() + setReplayStage("collect_rent") + bankRent, ok := slotCtx.BankSysvars().Rent() + if !ok { + return nil, fmt.Errorf("bank-local Rent sysvar unavailable at slot %d", block.Slot) + } + rentAccts := rent.CollectRentEagerly(slotCtx, &bankRent, bankEpochSchedule) + metrics.GlobalBlockReplay.Rent.AddTimingSince(start) + + start = time.Now() + setReplayStage("run_incinerator") + runIncinerator(slotCtx) + metrics.GlobalBlockReplay.RunIncinerator.AddTimingSince(start) + + // Alpenglow banks set the Clock timestamp from the block footer after execution. + if alpenglowClock { + footerClockStart := time.Now() + if err := applyAlpenglowFooterClock(slotCtx, block, bankEpochSchedule); err != nil { + metrics.GlobalBlockReplay.AlpenglowFooterClock.AddTimingSince(footerClockStart) + return nil, fmt.Errorf("apply alpenglow footer clock at slot %d: %w", block.Slot, err) + } + if err := updateAlpenglowNanosecondClockAccount(slotCtx, block); err != nil { + metrics.GlobalBlockReplay.AlpenglowFooterClock.AddTimingSince(footerClockStart) + return nil, err + } + metrics.GlobalBlockReplay.AlpenglowFooterClock.AddTimingSince(footerClockStart) + voteRewardsStart := time.Now() + voteRewardsErr := ApplyAlpenglowVoteRewards(slotCtx, block, bankEpochSchedule, block.SkipRewardCert, block.NotarRewardCert, block.BlockFinalCert, block.AlpenglowShredVersion) + metrics.GlobalBlockReplay.AlpenglowVoteRewards.AddTimingSince(voteRewardsStart) + if voteRewardsErr != nil { + return nil, voteRewardsErr + } + } + if err := finalizeBankSysvars(slotCtx); err != nil { + return nil, fmt.Errorf("finalize bank sysvars at slot %d: %w", block.Slot, err) + } + + setReplayStage("compile_accounts") + start = time.Now() + writableAccts, modifiedAccts := compileWritableAndModifiedAccts(slotCtx, block, rentAccts) + metrics.GlobalBlockReplay.CompileWritableAndModifiedAccts.AddTimingSince(start) + start = time.Now() + ensureParentsErr := ensureParentAccountsForModified(slotCtx, modifiedAccts) + metrics.GlobalBlockReplay.EnsureParentAccountsForModified.AddTimingSince(start) + if ensureParentsErr != nil { + return nil, ensureParentsErr + } + + start = time.Now() + setReplayStage("bankhash") + slotCtx.FinalBankhash = bankhash.CalculateBankHash(slotCtx, writableAccts, modifiedAccts, block.ParentBankhash, slotCtx.NumSignatures, block.Blockhash) + metrics.GlobalBlockReplay.BankHash.AddTimingSince(start) + if alpenglowClock { + footerVerificationStart := time.Now() + footerVerificationErr := verifyAlpenglowBlockFooter(slotCtx, block, alpenglowClock) + metrics.GlobalBlockReplay.AlpenglowFooterVerification.AddTimingSince(footerVerificationStart) + if footerVerificationErr != nil { + writeFooterBankhashMismatchArtifact(footerVerificationErr, block, slotCtx, writableAccts, modifiedAccts) + return nil, footerVerificationErr + } + } + + // Bankhash consensus enforcement is handled in the replay loop (not here) + // because forkchoice is fed after ProcessBlock returns — checking here would + // never see votes from recently submitted blocks and could deadlock. + + // Enter critical commit window - panics here may leave AccountsDB inconsistent + commitSlot.Store(slotCtx.Slot) + commitInProgress.Store(true) + blockUpdateStart := time.Now() + setReplayStage("store_accounts") + persistedSlot := slotCtx.Slot + persistedBankhash := append([]byte(nil), slotCtx.FinalBankhash...) + persistedBlockSlot := block.Slot + stakeIndexDir := filepath.Join(acctsDb.AcctsDir, "..") + afterStoreAccounts := func() { + if tail != nil { + // Rooted-durable: accounts + bankhash are buffered in the overlay and + // become durable only on promotion; nothing written here (rooted-only). + } else { + if berr := acctsDb.StoreBankHashForSlot(persistedSlot, persistedBankhash); berr != nil { + mlog.Log.Infof("unable to store bankhash for slot %d", persistedSlot) + } + } + if tail == nil { + // Legacy/verify modes (no fork ambiguity): flush per block as before. + // Rooted-durable replay flushes at FOLD time instead — entries stay + // slot-scoped in RAM so a fork unwind can drop them, and scans merge + // the pending set (StreamStakeAccounts) for completeness meanwhile. + flushed, err := global.FlushPendingStakePubkeys(stakeIndexDir) + if err != nil { + mlog.Log.Errorf("failed to flush stake pubkey index: %v", err) + } else if flushed > 0 { + mlog.Log.Debugf("flushed %d new stake pubkeys to index", flushed) + } + } + + persistedHashes.Set(persistedBlockSlot, persistedBankhash) + + // Exit critical commit window - AccountsDB is now consistent + commitInProgress.Store(false) + commitSlot.Store(0) + } + + if tail != nil { + // Rooted-durable: buffer this slot's writes + bankhash in the RAM overlay + // (always, even when empty, so the bankhash is recorded); no durable write. + tail.Add(slotCtx.Slot, modifiedAccts, persistedBankhash) + afterStoreAccounts() + } else if len(modifiedAccts) > 0 { + err = acctsDb.StoreAccounts(modifiedAccts, slotCtx.Slot, afterStoreAccounts) + } + // In rooted-durable mode the callback above is synchronous, so this includes + // the complete critical-path overlay publication. Legacy StoreAccounts only + // enqueues here; its asynchronous disk work deliberately belongs to no slot's + // replay wall time and must never update a later slot's metrics record. + metrics.GlobalBlockReplay.BlockUpdateAccounts.AddTimingSince(blockUpdateStart) + if err != nil { + return slotCtx, err + } + statusCommitStart := time.Now() + statusWaitStart := time.Now() + preparedStatuses := exec.statusPreparation.wait() + metrics.GlobalBlockReplay.TransactionStatusPreparationWait.AddTimingSince(statusWaitStart) + statusErr := transactionStatuses.commitBlockWithValidation(block, executionPlan, preparedStatuses, exec.statusValidation) + metrics.GlobalBlockReplay.TransactionStatusCommit.AddTimingSince(statusCommitStart) + if statusErr != nil { + return nil, fmt.Errorf("commit transaction statuses for slot %d after bank state commit: %w", block.Slot, statusErr) + } + + global.IncrTransactionCount(executionPlan.processedTxCount) + setReplayStage("done") + return slotCtx, err +} diff --git a/pkg/replay/block_execution_test.go b/pkg/replay/block_execution_test.go new file mode 100644 index 000000000..31556fdf2 --- /dev/null +++ b/pkg/replay/block_execution_test.go @@ -0,0 +1,333 @@ +package replay + +import ( + "context" + "errors" + "math" + "math/rand" + "sort" + "sync" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +// memBlockSource serves a fixed parent state to the group loader the way the +// batch loader sees AccountsDB/the unrooted tail: a missing key yields a +// zero-lamport placeholder, never an error. +type memBlockSource struct { + mem accounts.MemAccounts +} + +func (s *memBlockSource) GetAccount(_ uint64, pubkey solana.PublicKey) (*accounts.Account, error) { + if acct, err := s.mem.GetAccountWithoutLock(pubkey); err == nil { + return acct.Clone(), nil + } + return &accounts.Account{Key: pubkey}, nil +} + +func (s *memBlockSource) GetAccountsBatch(_ context.Context, slot uint64, pks []solana.PublicKey) ([]*accounts.Account, error) { + out := make([]*accounts.Account, len(pks)) + for i, pk := range pks { + out[i], _ = s.GetAccount(slot, pk) + } + return out, nil +} + +// groupExecutionEnv is a block execution over a MemAccounts parent snapshot +// and overlay, mirroring what ProcessBlock builds through the account loader, +// with the process-wide sysvar cache set the way newCommitTestSlotCtx sets it. +type groupExecutionEnv struct { + exec *blockExecution + parent accounts.MemAccounts + source *memBlockSource + cleanup func() +} + +func newGroupExecutionEnv(t *testing.T, txParallelism int, payerLamports uint64) *groupExecutionEnv { + t.Helper() + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + + durable := accounts.NewMemAccounts() + _ = durable.SetAccountWithoutLock(addresses.SystemProgramAddr, &accounts.Account{ + Key: addresses.SystemProgramAddr, Lamports: 1, Owner: addresses.NativeLoaderAddr, Executable: true, RentEpoch: math.MaxUint64, + }) + _ = durable.SetAccountWithoutLock(txfixture.PayerPubkey(), &accounts.Account{ + Key: txfixture.PayerPubkey(), Lamports: payerLamports, Owner: addresses.SystemProgramAddr, RentEpoch: math.MaxUint64, + }) + _ = durable.SetAccountWithoutLock(txfixture.DestPubkey(), &accounts.Account{ + Key: txfixture.DestPubkey(), Lamports: 10_000_000, Owner: addresses.SystemProgramAddr, RentEpoch: math.MaxUint64, + }) + + prevRBH := sealevel.SysvarCache.RecentBlockHashes.Sysvar + rbh := sealevel.SysvarRecentBlockhashes{{Blockhash: txfixture.TestBlockhash(), FeeCalculator: sealevel.FeeCalculator{LamportsPerSignature: 5000}}} + sealevel.SysvarCache.RecentBlockHashes.Sysvar = &rbh + prevRent := sealevel.SysvarCache.Rent.Sysvar + rentSysvar := sealevel.NewDefaultRentSysvar() + sealevel.SysvarCache.Rent.Sysvar = &rentSysvar + + parent := accounts.NewMemAccounts() + overlay := accounts.NewOverlayAccounts(parent) + block := &b.Block{Slot: 42, Features: feats} + slotCtx := &sealevel.SlotCtx{ + Accounts: overlay, + ParentAccts: parent, + Slot: block.Slot, + Features: feats, + FeeRateGovernor: &sealevel.FeeRateGovernor{PrevLamportsPerSignature: 5000}, + LastBlockhash: txfixture.TestBlockhash(), + AcctMapsMu: &sync.Mutex{}, + ModifiedAccts: make(map[solana.PublicKey]bool), + WritableAccts: make(map[solana.PublicKey]bool), + VoteTimestampMu: &sync.Mutex{}, + VoteTimestamps: make(map[solana.PublicKey]sealevel.BlockTimestamp), + Replay: true, + } + source := &memBlockSource{mem: durable} + exec := &blockExecution{ + block: block, + txParallelism: txParallelism, + blockSrc: source, + slotCtx: slotCtx, + parentAccts: parent, + accts: overlay, + ctx: context.Background(), + setReplayStage: func(string) {}, + seenMessages: make(map[[32]byte]int), + } + return &groupExecutionEnv{ + exec: exec, + parent: parent, + source: source, + cleanup: func() { + sealevel.SysvarCache.RecentBlockHashes.Sysvar = prevRBH + sealevel.SysvarCache.Rent.Sysvar = prevRent + }, + } +} + +func (env *groupExecutionEnv) lamports(t *testing.T, key solana.PublicKey) uint64 { + t.Helper() + acct, err := env.exec.slotCtx.GetAccountShared(key) + require.NoError(t, err) + return acct.Lamports +} + +func (env *groupExecutionEnv) modifiedKeys() []string { + keys := make([]string, 0, len(env.exec.slotCtx.ModifiedAccts)) + for key := range env.exec.slotCtx.ModifiedAccts { + keys = append(keys, key.String()) + } + sort.Strings(keys) + return keys +} + +func transferTransactions(t *testing.T, n int, firstSeq uint64) []*solana.Transaction { + t.Helper() + txs := make([]*solana.Transaction, n) + for i := range txs { + tx, err := solana.TransactionFromBytes(txfixture.MustSignedTransferWire(firstSeq + uint64(i))) + require.NoError(t, err) + txs[i] = tx + } + return txs +} + +type groupExecutionOutcome struct { + payer, dest uint64 + fees uint64 + cu uint64 + processed, sigs uint64 + executeMask []bool + modified []string + parentPayerLamports uint64 +} + +func runGroups(t *testing.T, txParallelism int, payerLamports uint64, txs []*solana.Transaction, splits []int) groupExecutionOutcome { + t.Helper() + env := newGroupExecutionEnv(t, txParallelism, payerLamports) + defer env.cleanup() + start := 0 + for _, end := range append(append([]int(nil), splits...), len(txs)) { + if end < start { + end = start + } + require.NoError(t, env.exec.executeTransactionGroup(txs[start:end], nil, false)) + start = end + } + parentPayer, err := env.parent.GetAccountWithoutLock(txfixture.PayerPubkey()) + require.NoError(t, err) + return groupExecutionOutcome{ + payer: env.lamports(t, txfixture.PayerPubkey()), + dest: env.lamports(t, txfixture.DestPubkey()), + fees: env.exec.txFeeAccumulator.TotalFees, + cu: env.exec.totalCU, + processed: env.exec.processedTxCount, + sigs: env.exec.processedSignatures, + executeMask: append([]bool(nil), env.exec.execute...), + modified: env.modifiedKeys(), + parentPayerLamports: parentPayer.Lamports, + } +} + +// The reference is the pre-existing sequential path: ProcessTransaction over +// the block order on a slot context whose accounts were preloaded whole. +func runSequentialReference(t *testing.T, payerLamports uint64, txs []*solana.Transaction) groupExecutionOutcome { + t.Helper() + env := newGroupExecutionEnv(t, 0, payerLamports) + defer env.cleanup() + keys := []solana.PublicKey{addresses.SystemProgramAddr, txfixture.PayerPubkey(), txfixture.DestPubkey()} + for _, key := range keys { + acct, _ := env.source.GetAccount(42, key) + pk := [32]byte(key) + require.NoError(t, env.parent.SetAccount(&pk, acct)) + } + var sigverify sync.WaitGroup + var out groupExecutionOutcome + for i, tx := range txs { + feeInfo, cu, err := ProcessTransaction(env.exec.slotCtx, &sigverify, tx, nil, nil, nil, false) + // A failed transfer still returns its fee; a nil fee means the + // transaction was unprocessable (fee payer below rent exemption after + // the fee, bad blockhash...), which a valid block never contains. + require.NotNil(t, feeInfo, "transaction %d unprocessable: %v", i, err) + out.fees += feeInfo.TotalFee + out.cu += cu + out.processed++ + out.sigs += uint64(tx.Message.Header.NumRequiredSignatures) + out.executeMask = append(out.executeMask, true) + } + sigverify.Wait() + out.payer = env.lamports(t, txfixture.PayerPubkey()) + out.dest = env.lamports(t, txfixture.DestPubkey()) + out.modified = env.modifiedKeys() + out.parentPayerLamports = payerLamports + return out +} + +// TestExecuteTransactionGroupMatchesWholeBlock runs the same block-ordered +// transfers as one group, as random groups, and through the sequential +// reference. Every transfer shares the payer and destination, so every group +// boundary is a cross-group write dependency, and the payer balance is small +// enough that later transfers fail for insufficient funds, which exercises +// fee charging on failed transactions and makes outcomes order-dependent. +func TestExecuteTransactionGroupMatchesWholeBlock(t *testing.T) { + // Amounts are 999,001+ lamports each (seq%1e6+1) at a 5,000-lamport fee. + // With 3,200,000 lamports the first two transfers succeed (leaving + // 1,191,997) and the remaining 46 fail — the third would drop the payer + // below its 890,880-lamport rent-exempt minimum — while still paying + // their fee, ending at 961,997: every transaction stays processable (the + // fee payer never falls below rent exemption after the fee), which is + // what a valid block guarantees and what the loaders assert. + const payerLamports = 3_200_000 + txs := transferTransactions(t, 48, 999_000) + reference := runSequentialReference(t, payerLamports, txs) + require.Less(t, reference.payer, uint64(payerLamports)) + + for _, txParallelism := range []int{0, 1, 4} { + single := runGroups(t, txParallelism, payerLamports, txs, nil) + require.Equal(t, reference.payer, single.payer, "txpar %d single group payer", txParallelism) + require.Equal(t, reference.dest, single.dest, "txpar %d single group dest", txParallelism) + require.Equal(t, reference.fees, single.fees) + require.Equal(t, reference.cu, single.cu) + require.Equal(t, reference.processed, single.processed) + require.Equal(t, reference.sigs, single.sigs) + require.Equal(t, reference.executeMask, single.executeMask) + require.Equal(t, reference.modified, single.modified) + require.Equal(t, uint64(payerLamports), single.parentPayerLamports, "parent image must stay pristine") + + rng := rand.New(rand.NewSource(int64(7 + txParallelism))) + for iter := 0; iter < 8; iter++ { + splitCount := 1 + rng.Intn(6) + splits := make([]int, splitCount) + for i := range splits { + splits[i] = rng.Intn(len(txs) + 1) + } + sort.Ints(splits) + grouped := runGroups(t, txParallelism, payerLamports, txs, splits) + require.Equal(t, single, grouped, "txpar %d splits %v", txParallelism, splits) + } + } +} + +func TestExecuteTransactionGroupRejectsDuplicatesAcrossGroups(t *testing.T) { + env := newGroupExecutionEnv(t, 2, 10_000_000_000) + defer env.cleanup() + txs := transferTransactions(t, 3, 100) + require.NoError(t, env.exec.executeTransactionGroup(txs[:2], nil, false)) + err := env.exec.executeTransactionGroup([]*solana.Transaction{txs[2], txs[0]}, nil, false) + var duplicateErr *DuplicateTransactionMessagesError + require.Error(t, err) + require.True(t, errors.As(err, &duplicateErr)) + require.Equal(t, uint64(42), duplicateErr.Slot) + require.Equal(t, uint64(1), duplicateErr.DuplicateCount) + require.Equal(t, []DuplicateTransactionOccurrence{{Index: 3, FirstIndex: 0}}, duplicateErr.Occurrences) + // The rejected group must not have been recorded or executed. + require.Len(t, env.exec.transactions, 2) + require.Equal(t, uint64(2), env.exec.processedTxCount) +} + +func TestExecuteTransactionGroupRejectsV1BeforeActivation(t *testing.T) { + env := newGroupExecutionEnv(t, 2, 10_000_000_000) + defer env.cleanup() + tx, err := solana.TransactionFromBytes(txfixture.MustSignedV1Wire(9, 8)) + require.NoError(t, err) + err = env.exec.executeTransactionGroup([]*solana.Transaction{tx}, nil, false) + require.ErrorIs(t, err, TxErrUnsupportedVersion) + require.Empty(t, env.exec.transactions) +} + +func TestExecuteTransactionGroupKeepsFirstParentImage(t *testing.T) { + env := newGroupExecutionEnv(t, 2, 10_000_000_000) + defer env.cleanup() + txs := transferTransactions(t, 4, 200) + require.NoError(t, env.exec.executeTransactionGroup(txs[:2], nil, false)) + afterFirst := env.lamports(t, txfixture.PayerPubkey()) + require.Less(t, afterFirst, uint64(10_000_000_000)) + // A later group touching the same accounts must not reload the payer's + // parent image over the pristine one, nor see stale overlay state. + require.NoError(t, env.exec.executeTransactionGroup(txs[2:], nil, false)) + parentPayer, err := env.parent.GetAccountWithoutLock(txfixture.PayerPubkey()) + require.NoError(t, err) + require.Equal(t, uint64(10_000_000_000), parentPayer.Lamports) + require.Less(t, env.lamports(t, txfixture.PayerPubkey()), afterFirst) + require.Equal(t, 2, env.exec.groups) + require.Len(t, env.exec.transactions, 4) +} + +func TestExecuteTransactionGroupRefusesClosedExecution(t *testing.T) { + env := newGroupExecutionEnv(t, 2, 10_000_000_000) + defer env.cleanup() + env.exec.closed = true + err := env.exec.executeTransactionGroup(transferTransactions(t, 1, 300), nil, false) + require.ErrorIs(t, err, errBlockExecutionClosed) +} + +func TestStreamingGroupRejectsUnprocessableTransactions(t *testing.T) { + for _, workers := range []int{0, 4} { + env := newGroupExecutionEnv(t, workers, 10_000_000) + defer env.cleanup() + txs := transferTransactions(t, 2, 1) + txs[0].Message.RecentBlockhash = solana.Hash{0xFA} + err := env.exec.executeTransactionGroup(txs, nil, false) + require.ErrorIs(t, err, TxErrInvalidBlockhash) + } +} + +func TestStreamingGroupRejectsUnresolvedLookupsBeforeExecution(t *testing.T) { + for _, workers := range []int{0, 4} { + env := newGroupExecutionEnv(t, workers, 10_000_000) + defer env.cleanup() + txs := decodeTransactions(t, [][]byte{signedV0TransferViaTableWire(t, 1)}) + err := env.exec.executeTransactionGroup(txs, nil, false) + require.ErrorContains(t, err, "unresolved address tables") + require.Zero(t, env.exec.slotCtx.TotalComputeUnitsConsumed) + } +} diff --git a/pkg/replay/promotion.go b/pkg/replay/promotion.go index c750009ea..5debf2ffa 100644 --- a/pkg/replay/promotion.go +++ b/pkg/replay/promotion.go @@ -27,14 +27,13 @@ type batchCommitter interface { CommitBatch(deltas []accounts.SlotDelta, throughSlot uint64, bankhashes map[uint64][32]byte, resumeCtx []byte) (accountsdb.BatchCommitResult, error) } -// TransactionStatusCheckpointHooks deliberately split status-cache capture -// from sidecar I/O. Snapshot runs on the replay loop while its mutable cache is -// coherent; Install runs on the fold worker using only those immutable bytes. -// This makes it impossible for the async worker to traverse concurrently -// changing replay lineage. The later AccountsDB manifest remains the selector. +// TransactionStatusCheckpointHooks split immutable status capture from encoding +// and sidecar I/O. Capture runs on replay; the fold worker serializes the captured +// view and then calls Install. Neither worker operation revisits live lineage. +// The later AccountsDB manifest remains the durable checkpoint selector. type TransactionStatusCheckpointHooks struct { - Snapshot func(through uint64) ([]byte, error) - Install func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) + Capture func(through uint64) (TransactionStatusSnapshot, error) + Install func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) // AfterCommit is an advisory retention hook. It runs only after CommitBatch // has durably selected the manifest carrying selected. Its error is logged // and ignored: once CommitBatch succeeds, the fold must remain successful. @@ -378,7 +377,10 @@ type foldJob struct { ctx *state.ResumeContext ctxJSON []byte stakeIdxDir string - transactionStatusCheckpointPayload []byte + transactionStatusSnapshot TransactionStatusSnapshot + checkpointCaptureTime time.Duration + checkpointEncodeTime time.Duration + checkpointBytes int installTransactionStatusCheckpoint func(through uint64, payload []byte) (*state.TransactionStatusCheckpointRef, error) afterTransactionStatusCheckpointCommit func(selected *state.TransactionStatusCheckpointRef) error } @@ -388,6 +390,23 @@ type foldResult struct { err error } +// buildRewardsCompletionFoldJob puts the entire rewards window in one commit. +// A normal batch cutoff inside that window would leave an active EpochRewards +// checkpoint without the RAM-only spool bookkeeping needed to resume it. The +// retained tail already bounds the size of this once-per-epoch fold. +func (t *unrootedTail) buildRewardsCompletionFoldJob(through uint64) (*foldJob, error) { + whole := *t + whole.batchSlots = t.overlay.HeldSlots() + job, err := whole.buildFoldJob(through, true) + if err != nil { + return nil, err + } + if job == nil || job.through != through { + return nil, fmt.Errorf("rewards completion bank %d is absent from retained fold prefix", through) + } + return job, nil +} + // buildFoldJob snapshots the FIRST fold chunk of the rooted prefix <= through // (loop thread). force also takes a trailing partial chunk. Returns nil when // no chunk is ready. A missing chunk-top context is an error — a context-less @@ -398,16 +417,13 @@ func (t *unrootedTail) buildFoldJob(through uint64, force bool, hookOverrides .. if err != nil { return nil, err } - prefix := t.overlay.PromotionPrefix(through) - if len(prefix) == 0 { + // Check chunk eligibility before materializing account-write lists. Replay + // calls this on every iteration, including skipped slots; a partial batch + // remains in RAM without rescanning all of its accounts each time. + chunk := t.overlay.PromotionChunk(through, t.batchSlots, force) + if len(chunk) == 0 { return nil, nil } - chunk := prefix - if len(chunk) > t.batchSlots { - chunk = chunk[:t.batchSlots] - } else if len(chunk) < t.batchSlots && !force { - return nil, nil // trailing partial chunk stays in RAM - } through = chunk[len(chunk)-1].Slot ctx := t.contexts[through] @@ -415,18 +431,18 @@ func (t *unrootedTail) buildFoldJob(through uint64, force bool, hookOverrides .. return nil, fmt.Errorf("fold chunk through slot %d: no resume context recorded for chunk-top slot", through) } ctx = cloneResumeContextForFold(ctx) - var checkpointPayload []byte - if hooks.Snapshot != nil { - checkpointPayload, err = hooks.Snapshot(through) + var snapshot TransactionStatusSnapshot + var captureTime time.Duration + if hooks.Capture != nil { + start := time.Now() + snapshot, err = hooks.Capture(through) + captureTime = time.Since(start) if err != nil { - return nil, fmt.Errorf("fold chunk through slot %d: snapshot transaction status checkpoint: %w", through, err) + return nil, fmt.Errorf("fold chunk through slot %d: capture transaction status checkpoint: %w", through, err) } - if len(checkpointPayload) == 0 { - return nil, fmt.Errorf("fold chunk through slot %d: transaction status checkpoint snapshot is empty", through) + if snapshot == nil { + return nil, fmt.Errorf("fold chunk through slot %d: transaction status checkpoint capture is nil", through) } - // The worker owns this immutable copy. Even a future Snapshot - // implementation that reuses a scratch buffer cannot race it. - checkpointPayload = append([]byte(nil), checkpointPayload...) } bankhashes := make(map[uint64][32]byte, len(chunk)) for _, sd := range chunk { @@ -440,7 +456,8 @@ func (t *unrootedTail) buildFoldJob(through uint64, force bool, hookOverrides .. bankhashes: bankhashes, ctx: ctx, stakeIdxDir: t.stakeIdxDir, - transactionStatusCheckpointPayload: checkpointPayload, + transactionStatusSnapshot: snapshot, + checkpointCaptureTime: captureTime, installTransactionStatusCheckpoint: hooks.Install, afterTransactionStatusCheckpointCommit: hooks.AfterCommit, }, nil @@ -450,12 +467,26 @@ func (t *unrootedTail) buildFoldJob(through uint64, force bool, hookOverrides .. // state). Stake-index entries flush (fsync'd) BEFORE the batch commit — see // promoteRootedBatched for why that order is a correctness requirement. func runFoldJob(committer batchCommitter, job *foldJob) error { - if job == nil || job.ctx == nil { + if job == nil { + return errors.New("fold job has no resume context") + } + // Failed folds are rebuilt from the retained tail. Neither a failed result + // nor a completed-but-unapplied job should keep checkpoint deltas alive. + defer func() { job.transactionStatusSnapshot = nil }() + if job.ctx == nil { return errors.New("fold job has no resume context") } var selectedCheckpoint *state.TransactionStatusCheckpointRef if job.installTransactionStatusCheckpoint != nil { - ref, err := job.installTransactionStatusCheckpoint(job.through, job.transactionStatusCheckpointPayload) + start := time.Now() + payload, err := encodeTransactionStatusCheckpoint(job.transactionStatusSnapshot) + job.checkpointEncodeTime = time.Since(start) + job.transactionStatusSnapshot = nil + if err != nil { + return fmt.Errorf("fold chunk through slot %d: encode transaction status checkpoint: %w", job.through, err) + } + job.checkpointBytes = len(payload) + ref, err := job.installTransactionStatusCheckpoint(job.through, payload) if err != nil { return fmt.Errorf("fold chunk through slot %d: prepare transaction status checkpoint: %w", job.through, err) } @@ -539,7 +570,9 @@ func (p *asyncPromoter) run() { start := time.Now() err := runFoldJob(p.committer, job) if err == nil { - mlog.Log.FileOnlyf("async fold: committed %d slots through %d in %s", len(job.chunk), job.through, time.Since(start).Round(time.Millisecond)) + mlog.Log.FileOnlyf("async fold: committed %d slots through %d in %s checkpoint_capture=%s checkpoint_encode=%s checkpoint_bytes=%d", + len(job.chunk), job.through, time.Since(start).Round(time.Millisecond), + job.checkpointCaptureTime, job.checkpointEncodeTime, job.checkpointBytes) } p.results <- foldResult{job: job, err: err} } @@ -594,13 +627,11 @@ func (p *asyncPromoter) stop() { // the caller validates the pair and falls back to rooted-checkpoint re-replay. func (t *unrootedTail) unwind(fromSlot uint64) (*state.ResumeContext, *sealevel.BankSysvars) { t.overlay.EvictFrom(fromSlot) - // Branch-scoped side effect: stake pubkeys enqueued by the evicted slots - // must never reach the durable index — drop them with the state. - if dropped := global.DropPendingStakePubkeysFrom(fromSlot); dropped > 0 { - mlog.Log.Infof("fork unwind: dropped %d pending stake-index entries from slots >= %d", dropped, fromSlot) - } + // Only replay-owned held slots are unwound. A future local leader bank + // is not part of this tail and must retain its pending stake entries. for s := range t.bankhashes { if s >= fromSlot { + global.DropPendingStakePubkeys(s) delete(t.bankhashes, s) } } @@ -713,14 +744,15 @@ func promoteRootedBatched( } ctx = cloneResumeContextForFold(ctx) var selectedCheckpoint *state.TransactionStatusCheckpointRef - if hooks.Snapshot != nil { - payload, serr := hooks.Snapshot(chunkThrough) + if hooks.Capture != nil { + snapshot, serr := hooks.Capture(chunkThrough) if serr != nil { - err = fmt.Errorf("promote chunk through slot %d: snapshot transaction status checkpoint: %w", chunkThrough, serr) + err = fmt.Errorf("promote chunk through slot %d: capture transaction status checkpoint: %w", chunkThrough, serr) break } - if len(payload) == 0 { - err = fmt.Errorf("promote chunk through slot %d: transaction status checkpoint snapshot is empty", chunkThrough) + payload, serr := encodeTransactionStatusCheckpoint(snapshot) + if serr != nil { + err = fmt.Errorf("promote chunk through slot %d: encode transaction status checkpoint: %w", chunkThrough, serr) break } ref, perr := hooks.Install(chunkThrough, payload) @@ -792,15 +824,29 @@ func resolveTransactionStatusCheckpointHooks(configured TransactionStatusCheckpo } func validateTransactionStatusCheckpointHooks(hooks TransactionStatusCheckpointHooks) error { - if (hooks.Snapshot == nil) != (hooks.Install == nil) { - return errors.New("transaction status checkpoint Snapshot and Install hooks must either both be set or both be nil") + if (hooks.Capture == nil) != (hooks.Install == nil) { + return errors.New("transaction status checkpoint Capture and Install hooks must either both be set or both be nil") } if hooks.AfterCommit != nil && hooks.Install == nil { - return errors.New("transaction status checkpoint AfterCommit hook requires Snapshot and Install hooks") + return errors.New("transaction status checkpoint AfterCommit hook requires Capture and Install hooks") } return nil } +func encodeTransactionStatusCheckpoint(snapshot TransactionStatusSnapshot) ([]byte, error) { + if snapshot == nil { + return nil, errors.New("transaction status checkpoint capture is nil") + } + payload, err := snapshot.MarshalBinary() + if err != nil { + return nil, err + } + if len(payload) == 0 { + return nil, errors.New("transaction status checkpoint snapshot is empty") + } + return payload, nil +} + func cloneResumeContextForFold(ctx *state.ResumeContext) *state.ResumeContext { if ctx == nil { return nil diff --git a/pkg/replay/remaining_compute_units_test.go b/pkg/replay/remaining_compute_units_test.go index ddec0e686..2cf0ace1c 100644 --- a/pkg/replay/remaining_compute_units_test.go +++ b/pkg/replay/remaining_compute_units_test.go @@ -77,7 +77,9 @@ func TestRemainingComputeUnitsPreservesSuccessfulNonceAdvance(t *testing.T) { } program := &sbpf.Program{Text: text, TextBytes: textBytes, TextVA: sbpf.VaddrProgram} require.NoError(t, program.Verify()) - slotCtx.AccountsDb.AddProgramToCache(programKey, &accountsdb.ProgramCacheEntry{Program: program}) + entry := &accountsdb.ProgramCacheEntry{Program: program} + entry.BindSource(nil, slotCtx.Features) + slotCtx.AccountsDb.AddProgramToCache(programKey, entry) tx, err := solana.NewTransaction([]solana.Instruction{ system.NewAdvanceNonceAccountInstruction(nonceKey, solana.SysVarRecentBlockHashesPubkey, payer).Build(), diff --git a/pkg/replay/rewards_retirement.go b/pkg/replay/rewards_retirement.go new file mode 100644 index 000000000..18783e382 --- /dev/null +++ b/pkg/replay/rewards_retirement.go @@ -0,0 +1,62 @@ +package replay + +import ( + "github.com/Overclock-Validator/mithril/pkg/rewards" + "github.com/Overclock-Validator/mithril/pkg/sealevel" +) + +// partitionedRewardsCompletion is replay-thread-owned, process-local evidence +// that a successfully executed bank contains all effects of this distribution. +// It is not a checkpoint or signing authority. Until that bank is durable, +// tryInLoopUnwind must still reject even a zero-remaining distribution: its +// spool has been consumed and cannot be rolled back with the account overlay. +type partitionedRewardsCompletion struct { + info *rewards.PartitionedRewardDistributionInfo + slot uint64 +} + +// limitPromotion keeps the boundary replayable until a successfully verified +// completion bank is eligible for promotion. Consuming the last spool changes +// the RAM counter before footer verification and is not completion evidence. +func (c *partitionedRewardsCompletion) limitPromotion(info *rewards.PartitionedRewardDistributionInfo, boundary, through uint64) uint64 { + if boundary == 0 || info == nil { + return through + } + if info.NumRewardPartitionsRemaining != 0 || c.info != info || c.slot == 0 || through < c.slot { + return min(through, boundary-1) + } + return through +} + +// observeBank must run only after successful block execution/publication, using +// that bank's immutable sysvars (never the speculative global sysvar cache). +// If the first completed bank lacks evidence, recording a later descendant is +// conservative: retirement then waits for that later bank to become durable. +func (c *partitionedRewardsCompletion) observeBank(info *rewards.PartitionedRewardDistributionInfo, bank *sealevel.BankSysvars) { + if c.info != info { + *c = partitionedRewardsCompletion{info: info} + } + if info == nil || c.slot != 0 || info.NumRewardPartitionsRemaining != 0 || bank == nil || bank.Slot() == 0 { + return + } + epochRewards, ok := bank.EpochRewards() + if ok && !epochRewards.Active { + c.slot = bank.Slot() + } +} + +// retire is called only when replay applies a successfully committed fold and +// advances LastRootedSlot. Finality, an enqueued/in-flight fold, and a failed +// commit do not acknowledge durability. At this boundary every rewards effect +// is in AccountsDB; in-memory switches above it cannot undo distribution. +// Switches at/below it still take durable recovery, whose persisted +// EpochRewards validation remains unchanged. Restart loses this optional +// evidence and reconstructs state through the existing recovery path. +func (c *partitionedRewardsCompletion) retire(info **rewards.PartitionedRewardDistributionInfo, durableSlot uint64) bool { + if *info == nil || *info != c.info || c.slot == 0 || durableSlot < c.slot || (*info).NumRewardPartitionsRemaining != 0 { + return false + } + *info = nil + *c = partitionedRewardsCompletion{} + return true +} diff --git a/pkg/replay/rewards_retirement_test.go b/pkg/replay/rewards_retirement_test.go new file mode 100644 index 000000000..aaa169ae1 --- /dev/null +++ b/pkg/replay/rewards_retirement_test.go @@ -0,0 +1,182 @@ +package replay + +import ( + "bytes" + "encoding/base64" + "fmt" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/rewards" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/state" + bin "github.com/gagliardetto/binary" + "github.com/mr-tron/base58" + "github.com/stretchr/testify/require" +) + +func TestRewardsRetirementRequiresCompletedBankAndDurability(t *testing.T) { + active := &sealevel.SysvarEpochRewards{Active: true} + var raw bytes.Buffer + require.NoError(t, active.MarshalWithEncoder(bin.NewBinEncoder(&raw))) + activeBank, err := sealevel.NewBankSysvars(5, &accounts.Account{Key: sealevel.SysvarEpochRewardsAddr, Data: raw.Bytes()}) + require.NoError(t, err) + missingBank, err := sealevel.NewBankSysvars(5) + require.NoError(t, err) + for _, tc := range []struct { + name string + remaining uint64 + bank *sealevel.BankSysvars + }{ + {"active distribution", 1, testUnwindBankSysvars(t, 5, 50)}, + {"active bank", 0, activeBank}, + {"missing bank", 0, nil}, + {"missing rewards", 0, missingBank}, + {"unknown slot", 0, testUnwindBankSysvars(t, 0, 50)}, + } { + t.Run(tc.name, func(t *testing.T) { + info := &rewards.PartitionedRewardDistributionInfo{NumRewardPartitionsRemaining: tc.remaining} + var completed partitionedRewardsCompletion + completed.observeBank(info, tc.bank) + require.False(t, completed.retire(&info, 100)) + require.NotNil(t, info) + }) + } + + info := &rewards.PartitionedRewardDistributionInfo{} + var completed partitionedRewardsCompletion + require.False(t, completed.retire(&info, 100), "zero remaining without observed completion is insufficient") + completed.observeBank(info, testUnwindBankSysvars(t, 5, 50)) + completed.observeBank(info, testUnwindBankSysvars(t, 7, 50)) + require.False(t, completed.retire(&info, 4), "uncommitted completion must retain the guard") + require.True(t, completed.retire(&info, 5), "later observations must not postpone recorded completion") + require.Nil(t, info) + require.False(t, completed.retire(&info, 100), "retirement is one-shot") +} + +func TestRewardsPromotionRetainsBoundaryAfterFailedDistribution(t *testing.T) { + const boundary = uint64(6264000) + info := &rewards.PartitionedRewardDistributionInfo{NumRewardPartitionsRemaining: 1} + var completed partitionedRewardsCompletion + require.Equal(t, boundary-1, completed.limitPromotion(info, boundary, boundary+1)) + + // Distribution consumes the final spool before ProcessBlock checks the + // footer. The epoch-116 failure took this path; no successful bank was + // observed, so forced shutdown must not persist the boundary bank. + info.NumRewardPartitionsRemaining = 0 + require.Equal(t, boundary-1, completed.limitPromotion(info, boundary, boundary+1)) + completed.observeBank(info, nil) + require.Equal(t, boundary-1, completed.limitPromotion(info, boundary, boundary+1)) + + completed.observeBank(info, testUnwindBankSysvars(t, boundary+1, 50)) + require.Equal(t, boundary-2, completed.limitPromotion(info, boundary, boundary-2)) + require.Equal(t, boundary-1, completed.limitPromotion(info, boundary, boundary), "finality stopped inside rewards window") + require.Equal(t, boundary+1, completed.limitPromotion(info, boundary, boundary+1)) + + next := &rewards.PartitionedRewardDistributionInfo{} + require.Equal(t, boundary-1, completed.limitPromotion(next, boundary, boundary+2), "completion belongs to another distribution") + require.Equal(t, boundary+2, completed.limitPromotion(nil, boundary, boundary+2)) + require.Equal(t, boundary+2, completed.limitPromotion(info, 0, boundary+2)) +} + +func TestRewardsCompletionFoldCannotCheckpointInsideWindow(t *testing.T) { + for _, batchSize := range []int{1, 2, 128} { + t.Run(fmt.Sprintf("batch_%d", batchSize), func(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts(), failOn: 7} + tail := asyncTestTail(fc, 5, 6, 7, 8) + tail.batchSlots = batchSize + info := &rewards.PartitionedRewardDistributionInfo{} + var completed partitionedRewardsCompletion + completed.observeBank(info, testUnwindBankSysvars(t, 7, 50)) + through := completed.limitPromotion(info, 5, 8) + require.Equal(t, uint64(8), through) + + job, err := tail.buildRewardsCompletionFoldJob(completed.slot) + require.NoError(t, err) + require.NotNil(t, job) + require.Equal(t, uint64(7), job.through) + require.Len(t, job.chunk, 3, "the entire distribution must share one commit") + require.Equal(t, batchSize, tail.batchSlots, "normal batching is unchanged") + require.Error(t, runFoldJob(fc, job)) + require.Empty(t, fc.throughs) + require.False(t, completed.retire(&info, 4)) + + fc.failOn = 0 + job, err = tail.buildRewardsCompletionFoldJob(completed.slot) + require.NoError(t, err) + require.NoError(t, runFoldJob(fc, job)) + require.Equal(t, []uint64{7}, fc.throughs) + tail.applyFoldJob(job) + require.True(t, completed.retire(&info, 7)) + require.Equal(t, 1, tail.overlay.HeldSlots(), "later banks remain buffered") + }) + } + tail := asyncTestTail(&fakeCommitter{durable: accounts.NewMemAccounts()}, 5, 6) + _, err := tail.buildRewardsCompletionFoldJob(7) + require.ErrorContains(t, err, "absent from retained fold prefix") +} + +func TestRewardsRetirementDoesNotCrossGenerations(t *testing.T) { + old := &rewards.PartitionedRewardDistributionInfo{SpoolSlot: 1} + next := &rewards.PartitionedRewardDistributionInfo{SpoolSlot: 10} + var completed partitionedRewardsCompletion + completed.observeBank(old, testUnwindBankSysvars(t, 5, 50)) + require.False(t, completed.retire(&next, 100), "old completion cannot retire new bookkeeping") + completed.observeBank(next, testUnwindBankSysvars(t, 11, 60)) + require.False(t, completed.retire(&next, 10)) + require.True(t, completed.retire(&next, 11)) +} + +func TestRewardsRetirementWaitsForSuccessfulFold(t *testing.T) { + fc := &fakeCommitter{durable: accounts.NewMemAccounts(), failOn: 5} + tail := asyncTestTail(fc, 5, 6) + info := &rewards.PartitionedRewardDistributionInfo{} + var completed partitionedRewardsCompletion + completed.observeBank(info, testUnwindBankSysvars(t, 5, 50)) + job, err := tail.buildFoldJob(6, true) + require.NoError(t, err) + require.NotNil(t, job) + root := uint64(4) + require.False(t, completed.retire(&info, root), "capturing a job does not make its bank durable") + require.Error(t, runFoldJob(fc, job)) + require.False(t, completed.retire(&info, root), "a failed fold leaves the old durable root") + fc.failOn = 0 + require.NoError(t, runFoldJob(fc, job)) + ctx := tail.applyFoldJob(job) + require.NotNil(t, ctx) + root = job.through + require.True(t, completed.retire(&info, root)) +} + +func TestRewardsRetirementAllowsExactParentUnwind(t *testing.T) { + resetVoteStakeDirty() + t.Cleanup(resetVoteStakeDirty) + info := &rewards.PartitionedRewardDistributionInfo{} + var completed partitionedRewardsCompletion + completed.observeBank(info, testUnwindBankSysvars(t, 5, 50)) + tail := newUnrootedTail(&fakeDurable{}, &fakeCommitter{durable: accounts.NewMemAccounts()}, 512, 1, "") + parent := &state.ResumeContext{Slot: 7, Bankhash: base58.Encode(make([]byte, 32)), AcctsLtHash: base64.StdEncoding.EncodeToString(make([]byte, 2048)), Capitalization: 700} + bank := testUnwindBankSysvars(t, 7, 50) + tail.Add(7, []*accounts.Account{testAccount(1, 71)}, testHashBytes(7)) + tail.SetContext(7, parent, bank) + tail.Add(8, []*accounts.Account{testAccount(1, 81)}, testHashBytes(8)) + tail.SetContext(8, &state.ResumeContext{Slot: 8}, testUnwindBankSysvars(t, 8, 999)) + sw := &CertifiedSwitch{Slot: 8} + ms := &state.MithrilState{LastRootedSlot: 4} + sched := &sealevel.SysvarEpochSchedule{SlotsPerEpoch: 432000} + rs, _, reason := tryInLoopUnwind(sw, tail, ms, sched, 0, info) + require.Nil(t, rs) + require.Equal(t, unwindFallbackRewardsWindow, reason) + ms.LastRootedSlot = 5 + markVoteStakeDirty(5) // completed reward writes are also below the durable root + require.True(t, completed.retire(&info, ms.LastRootedSlot)) + rs, restored, reason := tryInLoopUnwind(sw, tail, ms, sched, 0, info) + require.Empty(t, reason) + require.Same(t, bank, restored, "use the surviving bank, never abandoned reward sysvars") + want, err := ResumeStateFromRootedContext(parent, nil) + require.NoError(t, err) + require.Equal(t, want, rs, "resume state must match rebuilding the exact retained parent") + acct, err := tail.GetAccount(8, testAccount(1, 0).Key) + require.NoError(t, err) + require.Equal(t, uint64(71), acct.Lamports, "abandoned account writes must be removed") +} diff --git a/pkg/replay/streaming.go b/pkg/replay/streaming.go new file mode 100644 index 000000000..2a02820bb --- /dev/null +++ b/pkg/replay/streaming.go @@ -0,0 +1,1230 @@ +package replay + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/blockstream" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/Overclock-Validator/mithril/pkg/mlog" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/turbine" + "github.com/Overclock-Validator/mithril/pkg/txverify" + "github.com/gagliardetto/solana-go" +) + +// Streaming execution executes a turbine block's entry batches while the rest +// of its shreds are still arriving, so that only the last batch and the +// end-of-block tail remain after the final shred. The complete block from the +// ordinary emission path stays the authority: the executor only pre-computes +// the bank overlay for a prefix of it, proves at finalize that the prefix is +// the block (pointer identity of every executed transaction, same parent slot +// and ID, same generation, same features), and otherwise throws the prefix +// away and lets the whole-block path execute the block from scratch. +// +// Nothing a stream does reaches process-global state until finalize: the +// overlay lives in the SlotCtx, vote-cache publication is deferred on the +// SlotCtx, program-cache insertions are recorded for undo, pending stake index +// entries are slot-keyed and dropped, the parent's VoteTimestamps map is +// cloned at open, the process-wide current slot is not published for the +// shell, and the legacy sysvar cache written by bank open is snapshotted and +// restored. Discard therefore restores the world to the state before the +// stream opened. + +// StreamingExecutionConfig is set from the node flags before replay starts. +type StreamingExecutionConfig struct { + // Enabled turns streaming execution on for turbine-sourced blocks. + Enabled bool + // Workers bounds the executor goroutines per group (0 = min(txpar, 4)). + Workers int + // MinGroupBatches delays a group until this many contiguous batches are + // ready, unless the slot is already complete (0 or 1 = execute as soon as + // one batch is ready). + MinGroupBatches int + // MaxOpenAge discards a stream that has been open this long without its + // block completing (0 = 2 s). + MaxOpenAge time.Duration +} + +// StreamingExecutionCfg is the process-wide streaming configuration. +var StreamingExecutionCfg StreamingExecutionConfig + +const ( + defaultStreamingWorkers = 4 + defaultStreamingMaxAge = 2 * time.Second + streamingPollInterval = 5 * time.Millisecond + // Bound the entire group's verification join, not each batch separately. + // This is a speculative-work budget, not a signature validity deadline. + streamingVerificationWait = 100 * time.Millisecond + // Bound speculation across missing leaders; this never advances replay + // or establishes that the intervening slots are actually skipped. + streamingMaxSlotDistance = uint64(32) + // streamingHardOpenAgeFactor bounds a completed-but-not-yet-emitted stream + // to this multiple of MaxOpenAge. + streamingHardOpenAgeFactor = 10 +) + +func (cfg StreamingExecutionConfig) workers(txParallelism int) int { + workers := cfg.Workers + if workers <= 0 { + workers = defaultStreamingWorkers + } + if txParallelism > 0 && workers > txParallelism { + workers = txParallelism + } + return workers +} + +func (cfg StreamingExecutionConfig) maxOpenAge() time.Duration { + if cfg.MaxOpenAge <= 0 { + return defaultStreamingMaxAge + } + return cfg.MaxOpenAge +} + +// streamingFeed is the block source's view of the turbine feed +// (*blockstream.BlockSource implements it; tests substitute a fake). +type streamingFeed interface { + StreamEvents() <-chan turbine.StreamEvent + StreamStatusOf(turbine.StreamGeneration) turbine.StreamStatus + PendingStreamBatches(turbine.StreamGeneration, uint32) []*turbine.StreamBatch + PrioritizeStreamRepair(turbine.StreamGeneration) +} + +var _ streamingFeed = (*blockstream.BlockSource)(nil) + +// streamingDeps is what the executor needs from the replay loop. The closures +// read loop-local state (last slot context, frontier, features, switch +// status) at call time so the executor never caches a stale view. +type streamingDeps struct { + acctsDb *accountsdb.AccountsDb + feed streamingFeed + epochSchedule *sealevel.SysvarEpochSchedule + txParallelism int + dbgOpts *DebugOptions + persistedHashes *persistedTracker + tail unrootedState + transactionStatuses *TransactionStatusCache + alpenglowClock bool + + lastSlotCtx func() *sealevel.SlotCtx + frontier func() uint64 + // frontierMark reports the last executed block's replay and full instants + // (timeline only; nil when the loop does not track it). + frontierMark func() streamingFrontierMark + currentFeatures func() *features.Features + currentEpoch func() uint64 + rewardsInFlight func() bool + switchPending func() bool + executedBlockID func(slot uint64) (solana.Hash, bool) + alpenglowMode bool + unrootedTailUsed bool +} + +type streamingGroup struct { + // readyAt is when the group was formed from contiguous decoded batches; + // joinedAt when the consumer finished joining verification and copying + // batch slices (not the verifier completion instant); startedAt and + // finishedAt bound the execution itself. + readyAt, joinedAt, startedAt, finishedAt time.Time + batches, transactions int + suffix bool // the finalize suffix, run on the complete block +} + +// streamingFrontierMark is the replay loop's record of the last executed +// block: the slot, the instant its replay result reached consensus, and its +// last-shred instant (0 for a block that did not arrive as shreds). Skips do +// not update it. It only feeds the timeline; the executor never decides +// anything on it. +type streamingFrontierMark struct { + slot uint64 + fullNanos int64 + // admittedAt is when the source handed the block to replay (after its + // ancestors were replayed and the emitter released it); replayedAt when + // its replay result reached consensus. + admittedAt time.Time + replayedAt time.Time + // waitEnteredAt is the loop's first entry into the replay wait after + // replayedAt; what lies between is the executed block's post-replay tail. + waitEnteredAt time.Time +} + +// streamingObservation is what the executor knows about a slot's header. It +// outlives the header itself (pruned when the frontier passes the slot) so +// that a block executed whole can report why no stream opened for it, and a +// stream can report how long its header waited and on what. +type streamingObservation struct { + verificationWait metrics.Timing + generation turbine.StreamGeneration + parentSlot uint64 + readyAt time.Time // header batch decoded (its wake-up's ReadyAt) + seenAt time.Time // executor first handled the header + frontierAtSeen uint64 + declined string // eligibility reason, when the header was declined + discarded string // discard reason, when a stream opened and was thrown away + openedAt time.Time +} + +// streamingSlot is one in-progress stream. +type streamingSlot struct { + slot uint64 + generation turbine.StreamGeneration + parentSlot uint64 + parentID solana.Hash + exec *blockExecution + // origin holds the block's own transaction objects in executed order; + // the bank executes stream-owned copies (block.ExecutionCopies), and the + // handshake proves the block by these originals. + origin []*solana.Transaction + nextStart uint32 + pending map[uint32]*turbine.StreamBatch + footerSeen bool + completed bool + openedAt time.Time + headerAt time.Time + groups []streamingGroup + verificationWait metrics.Timing + // timeline: what bounded the open (see metrics.StreamingExecution). Kept + // here rather than in the collector because the loop resets the collector + // before every wait and the block may arrive several waits after the open. + headerSeenAt time.Time + parentFullNanos int64 + parentAdmittedAt time.Time + parentReplayedAt time.Time + waitEnteredAt time.Time + // restoreSysvarCache puts the legacy process-global sysvar cache back to + // its state before the bank opened; nil when nothing was published. + restoreSysvarCache func() +} + +// streamingExecutor is owned by the replay loop and driven from its select. +type streamingExecutor struct { + deps streamingDeps + current *streamingSlot + // headers remembers header batches for slots ahead of the frontier so the + // next slot can open as soon as its parent finishes, even when its header + // wake-up arrived earlier. + headers map[uint64]*turbine.StreamBatch + // retired is the last generation per slot that this executor discarded or + // declined; header recovery (recoverHeader) never reopens it. Pruned with + // headers. + retired map[uint64]turbine.StreamGeneration + // observed is the per-slot header timeline (see streamingObservation), + // pruned with headers. + observed map[uint64]*streamingObservation + ticker *time.Ticker + // Verification observation and group execution hooks; tests substitute them. + waitVerificationFn func(context.Context, *turbine.StreamBatch) ([]txverify.VerifiedMessageIdentity, bool, error) + executeFn func(exec *blockExecution, txs []*solana.Transaction, identities *b.PreparedTransactionMessageIdentities, shouldVerifySignatures bool) error +} + +func newStreamingExecutor(deps streamingDeps) *streamingExecutor { + return &streamingExecutor{ + deps: deps, + headers: make(map[uint64]*turbine.StreamBatch), + retired: make(map[uint64]turbine.StreamGeneration), + observed: make(map[uint64]*streamingObservation), + executeFn: (*blockExecution).executeTransactionGroup, + waitVerificationFn: func(ctx context.Context, batch *turbine.StreamBatch) ([]txverify.VerifiedMessageIdentity, bool, error) { + return batch.WaitVerification(ctx) + }, + } +} + +// tick returns the polling channel, which is nil (never fires) while no +// stream is open, so the replay loop's select stays quiet when idle. +func (s *streamingExecutor) tick() <-chan time.Time { + if s == nil || s.current == nil { + return nil + } + if s.ticker == nil { + s.ticker = time.NewTicker(streamingPollInterval) + } + return s.ticker.C +} + +func (s *streamingExecutor) stopTicker() { + if s.ticker != nil { + s.ticker.Stop() + s.ticker = nil + } +} + +// events is the feed channel the wait loop selects on; nil when the feed is +// off, which never fires. +func (s *streamingExecutor) events() <-chan turbine.StreamEvent { + if s == nil || s.deps.feed == nil { + return nil + } + return s.deps.feed.StreamEvents() +} + +// matches reports whether a stream is open for slot. +func (s *streamingExecutor) matches(slot uint64) bool { + return s != nil && s.current != nil && s.current.slot == slot +} + +// beforeBlock restores speculative state before an intervening real bank is +// observed. A skip has no bank changes and must not throw away a later child. +func (s *streamingExecutor) beforeBlock(block *b.Block) { + if s != nil && s.current != nil && !block.IsSkipped && !s.matches(block.Slot) { + s.discard("other_block") + } +} + +// shutdown discards any open stream; the replay loop defers it so an exiting +// attempt never leaves a speculative bank (and its watchdog) behind. +func (s *streamingExecutor) shutdown() { + if s == nil { + return + } + s.discard("shutdown") + s.stopTicker() + s.headers = make(map[uint64]*turbine.StreamBatch) + s.retired = make(map[uint64]turbine.StreamGeneration) + s.observed = make(map[uint64]*streamingObservation) +} + +// handleEvent consumes one feed wake-up. +func (s *streamingExecutor) handleEvent(event turbine.StreamEvent) { + if s == nil { + return + } + var live bool + event, live = event.Resolve() + if !live { + return + } + switch event.Kind { + case turbine.StreamBatchReady: + if event.Batch == nil { + return + } + if s.current != nil && event.Generation == s.current.generation { + // A previous group can leave many notifications queued. Refresh + // the authoritative ready set before choosing the next group. + if event.Batch.Start < s.current.nextStart { + return // already consumed by a prior refresh + } + s.offer(event.Batch) + s.pull() + s.consume() + return + } + if event.Batch.Marker == turbine.StreamMarkerHeader && event.Batch.Start == 0 { + s.rememberHeader(event.Batch) + } else { + s.recoverHeader(event.Slot, event.Generation) + } + s.tryOpen() + case turbine.StreamCancelled: + if s.current != nil && event.Generation == s.current.generation { + s.discard("cancelled:" + event.Reason) + } + if header, ok := s.headers[event.Slot]; ok && header.Generation == event.Generation { + delete(s.headers, event.Slot) + } + s.tryOpen() + case turbine.StreamCompleted: + if s.current != nil && event.Generation == s.current.generation { + s.current.completed = true + // Released prefetch results remain immutable and owned by this + // generation. Recover ready batches whose wake-ups were dropped. + s.pull() + s.consume() + return + } + if header, ok := s.headers[event.Slot]; ok && header.Generation == event.Generation { + delete(s.headers, event.Slot) + } + } +} + +// handleTick polls the assembler for batches (recovery after dropped +// wake-ups), enforces the open-age bound, and opens the next slot if idle. +func (s *streamingExecutor) handleTick() { + if s == nil { + return + } + if s.current == nil { + s.tryOpen() + return + } + cur := s.current + switch s.deps.feed.StreamStatusOf(cur.generation) { + case turbine.StreamGone: + s.discard("gone") + s.tryOpen() + return + case turbine.StreamDone: + cur.completed = true + } + // An incomplete slot is bounded by MaxOpenAge (its shreds stopped + // arriving); a completed one may legitimately wait longer in the emitter + // (ancestry decisions) and is only bounded to cap the overlay's lifetime. + age := time.Since(cur.openedAt) + if (!cur.completed && age > StreamingExecutionCfg.maxOpenAge()) || age > streamingHardOpenAgeFactor*StreamingExecutionCfg.maxOpenAge() { + s.discard("timeout") + s.tryOpen() + return + } + s.pull() + s.consume() +} + +func (s *streamingExecutor) rememberHeader(header *turbine.StreamBatch) { + frontier := s.deps.frontier() + if header.Slot <= frontier || header.Slot-frontier > streamingMaxSlotDistance { + return + } + s.headers[header.Slot] = header + if obs := s.observed[header.Slot]; obs == nil || obs.generation != header.Generation { + s.observed[header.Slot] = &streamingObservation{ + generation: header.Generation, + parentSlot: header.ParentSlot, + readyAt: header.ReadyAt, + seenAt: time.Now(), + frontierAtSeen: frontier, + } + } + s.pruneHeaders(frontier) +} + +// recoverHeader handles a wake-up for a batch of a generation whose header +// this executor has not seen: the header's own wake-up may have been dropped +// (full channel), in which case the assembler is the authoritative source. +// Only slots within the bounded lookahead are worth the lookup, and a generation this +// executor already retired (discarded, or declined as ineligible) is never +// brought back: whole-block execution owns it from then on. A recovered +// header retains its original readiness time, including time before this lookup. +func (s *streamingExecutor) recoverHeader(slot uint64, g turbine.StreamGeneration) { + if g.IsZero() { + return + } + anchor := s.deps.frontier() + if s.current != nil { + anchor = s.current.slot + } + if slot <= anchor || slot-anchor > streamingMaxSlotDistance { + return + } + if known, ok := s.headers[slot]; ok && known.Generation == g { + return + } + if retired, ok := s.retired[slot]; ok && retired == g { + return + } + // Sorted by start: the header is the first batch, at 0, or not decoded. + pending := s.deps.feed.PendingStreamBatches(g, 0) + if len(pending) > 0 && pending[0].Start == 0 && pending[0].Marker == turbine.StreamMarkerHeader { + s.rememberHeader(pending[0]) + } +} + +// retire records that generation g of slot must not open again through +// header recovery; discard and the ineligible path call it. +func (s *streamingExecutor) retire(slot uint64, g turbine.StreamGeneration) { + if g.IsZero() { + return + } + s.retired[slot] = g +} + +// pruneHeaders bounds the header and retired maps: anything at or below the +// frontier can never open. +func (s *streamingExecutor) pruneHeaders(frontier uint64) { + for slot := range s.headers { + if slot <= frontier || slot-frontier > streamingMaxSlotDistance { + delete(s.headers, slot) + } + } + for slot := range s.retired { + if slot <= frontier || slot-frontier > streamingMaxSlotDistance { + delete(s.retired, slot) + } + } + for slot := range s.observed { + if slot <= frontier || slot-frontier > streamingMaxSlotDistance { + delete(s.observed, slot) + } + } +} + +// nextHeader prefers the next slot, otherwise the earliest nearby child of +// the executed bank. A header is only a speculation hint: it does not prove +// skips, advance the frontier, or authorize publication or voting. +func (s *streamingExecutor) nextHeader(frontier uint64) *turbine.StreamBatch { + last := s.deps.lastSlotCtx() + var selected *turbine.StreamBatch + for slot, header := range s.headers { + if slot <= frontier || slot-frontier > streamingMaxSlotDistance { + continue + } + if slot-frontier != 1 && (last == nil || header.ParentSlot != last.Slot) { + continue + } + if selected == nil || slot < selected.Slot { + selected = header + } + } + return selected +} + +// tryOpen may speculate across unresolved slots only on the exact executed +// parent. Real intervening blocks and fork switches discard the overlay; +// skip records leave it alone. The complete-block handshake stays mandatory. +func (s *streamingExecutor) tryOpen() { + if s == nil || s.current != nil || !StreamingExecutionCfg.Enabled { + return + } + frontier := s.deps.frontier() + s.pruneHeaders(frontier) + for { + header := s.nextHeader(frontier) + if header == nil { + return + } + delete(s.headers, header.Slot) + if reason := s.eligibility(header); reason != "" { + mlog.Log.FileOnlyf("streaming: slot %d not opened (%s)", header.Slot, reason) + s.retire(header.Slot, header.Generation) + if obs := s.observed[header.Slot]; obs != nil && obs.generation == header.Generation { + obs.declined = reason + } + continue + } + s.openStream(header) + return + } +} + +// eligibility returns an empty string when a stream may open on header, or +// the reason it may not. +func (s *streamingExecutor) eligibility(header *turbine.StreamBatch) string { + d := s.deps + if !d.alpenglowMode || !d.unrootedTailUsed || d.tail == nil { + return "requires alpenglow rooted-durable replay" + } + if d.feed.StreamStatusOf(header.Generation) != turbine.StreamActive { + return "generation no longer active" + } + last := d.lastSlotCtx() + if last == nil { + return "no executed parent context" + } + if frontier := d.frontier(); header.Slot <= frontier || header.Slot-frontier > streamingMaxSlotDistance || header.ParentSlot != last.Slot { + return fmt.Sprintf("slot %d on parent %d does not extend the executed frontier %d (parent context %d)", header.Slot, header.ParentSlot, frontier, last.Slot) + } + executedID, ok := d.executedBlockID(last.Slot) + if !ok || executedID == (solana.Hash{}) || executedID != header.ParentBlockID { + return "parent block id does not match the executed parent" + } + if d.switchPending() { + return "fork switch pending" + } + if d.epochSchedule == nil || d.epochSchedule.GetEpoch(header.Slot) != d.currentEpoch() { + return "epoch boundary" + } + if d.rewardsInFlight() { + return "partitioned rewards in flight" + } + if d.currentFeatures() == nil { + return "no feature set" + } + return "" +} + +func (s *streamingExecutor) openStream(header *turbine.StreamBatch) { + d := s.deps + last := d.lastSlotCtx() + shell := &b.Block{ + Slot: header.Slot, + SourceParentSlot: header.ParentSlot, + FromLiveStream: true, + AlpenglowParentBlockID: header.ParentBlockID, + HasAlpenglowParentBlockID: true, + } + shell.Epoch = d.epochSchedule.GetEpoch(shell.Slot) + // Same derivation the loop applies to the complete block, minus the + // process-global "current slot" publication, which stays at the frontier + // until the complete block is configured. + if err := configureBlockFromParent(shell, last, d.epochSchedule, false); err != nil { + mlog.Log.Warnf("streaming: slot %d not opened: %v", shell.Slot, err) + return + } + // The parent's VoteTimestamps map is shared by reference through + // configureBlock; a speculative bank must mutate its own copy. + shell.VoteTimestamps = maps.Clone(last.VoteTimestamps) + shell.Features = d.currentFeatures() + + // Bank open publishes the child's derived Clock/SlotHashes to the legacy + // process-global sysvar cache (Alpenglow banks never read it back — they + // pin from parentBankSysvars — but RPC simulation may). Snapshot it so a + // discard restores the parent's view; the accepted bank leaves it as a + // whole-block open would have. + sysvarCacheAtOpen := sealevel.SysvarCache + exec := newBlockExecution(d.acctsDb, shell, d.epochSchedule, StreamingExecutionCfg.workers(d.txParallelism), d.dbgOpts, d.persistedHashes, d.tail, d.transactionStatuses, d.alpenglowClock, last.BankSysvars()) + if err := exec.open(); err != nil { + exec.close() + sealevel.SysvarCache = sysvarCacheAtOpen + mlog.Log.Warnf("streaming: slot %d not opened: %v", shell.Slot, err) + return + } + exec.slotCtx.DeferVoteCachePublication = true + exec.slotCtx.TrackProgramCacheAdds = true + exec.setReplayStage("streaming_wait") + + cur := &streamingSlot{ + slot: shell.Slot, + generation: header.Generation, + parentSlot: header.ParentSlot, + parentID: header.ParentBlockID, + exec: exec, + pending: make(map[uint32]*turbine.StreamBatch), + openedAt: time.Now(), + headerAt: header.ReadyAt, + headerSeenAt: header.ReadyAt, + restoreSysvarCache: func() { sealevel.SysvarCache = sysvarCacheAtOpen }, + } + if obs := s.observed[shell.Slot]; obs != nil && obs.generation == header.Generation { + obs.openedAt = cur.openedAt + if !obs.seenAt.IsZero() { + cur.headerSeenAt = obs.seenAt + } + } + if d.frontierMark != nil { + // The mark is only the child's parent when the last executed block is + // that very slot; after a fork-switch re-base it is not, and the + // timeline says "unknown" rather than blaming the wrong slot. + if mark := d.frontierMark(); mark.slot == header.ParentSlot && !mark.replayedAt.IsZero() { + cur.parentFullNanos = mark.fullNanos + cur.parentAdmittedAt = mark.admittedAt + cur.parentReplayedAt = mark.replayedAt + cur.waitEnteredAt = mark.waitEnteredAt + } + } + s.current = cur + metrics.GlobalBlockReplay.StreamingExecution.Opened = 1 + d.feed.PrioritizeStreamRepair(header.Generation) + mlog.Log.FileOnlyf("streaming: opened slot %d on parent %d | %s", shell.Slot, header.ParentSlot, cur.openTimeline()) + s.offer(header) + s.pull() + s.consume() +} + +func (s *streamingExecutor) offer(batch *turbine.StreamBatch) { + cur := s.current + if cur == nil || batch == nil || batch.Start < cur.nextStart { + return + } + if _, seen := cur.pending[batch.Start]; !seen { + cur.pending[batch.Start] = batch + } +} + +// pull asks the assembler for everything decoded since nextStart; it is the +// authoritative path after a dropped wake-up. +func (s *streamingExecutor) pull() { + cur := s.current + if cur == nil { + return + } + for _, batch := range s.deps.feed.PendingStreamBatches(cur.generation, cur.nextStart) { + s.offer(batch) + } +} + +// consume executes every contiguous ready batch from nextStart as one group. +// Nothing is removed from pending until the group is committed, so holding +// for the group minimum leaves markers and batches exactly where they were. +func (s *streamingExecutor) consume() { + cur := s.current + if cur == nil { + return + } + var group []*turbine.StreamBatch + next := cur.nextStart + footer := false + for { + batch, ok := cur.pending[next] + if !ok { + break + } + if batch.Err != nil { + s.discard("decode_error") + return + } + switch batch.Marker { + case turbine.StreamMarkerHeader: + // The header opened the stream; nothing to execute. + case turbine.StreamMarkerUpdateParent: + // The leader abandoned the optimistic prefix we executed. + s.discard("update_parent") + return + case turbine.StreamMarkerFooter: + footer = true + default: + group = append(group, batch) + } + next = batch.End + 1 + } + if minBatches := StreamingExecutionCfg.MinGroupBatches; minBatches > 1 && len(group) > 0 && len(group) < minBatches && !cur.completed { + return // not enough ready work yet; everything stays pending + } + for start := cur.nextStart; start < next; { + batch := cur.pending[start] + delete(cur.pending, start) + start = batch.End + 1 + } + cur.nextStart = next + if footer { + cur.footerSeen = true + } + if len(group) == 0 { + return + } + if err := s.executeGroup(group); err != nil { + s.discard(err.Error()) + } +} + +// executeGroup joins verification for every batch in the group and executes +// the group's transactions as one unit. +func (s *streamingExecutor) executeGroup(group []*turbine.StreamBatch) error { + cur := s.current + var txs []*solana.Transaction + var verified []txverify.VerifiedMessageIdentity + allVerified := true + readyAt := time.Now() + deadline := readyAt.Add(streamingVerificationWait) + maxAge := StreamingExecutionCfg.maxOpenAge() + if cur.completed { + maxAge *= streamingHardOpenAgeFactor + } + if ageDeadline := cur.openedAt.Add(maxAge); ageDeadline.Before(deadline) { + deadline = ageDeadline + } + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + cur.exec.setReplayStage("streaming_sigverify_wait") + // Keep failure timings across replay-loop metric resets, just like headers. + joinStarted := time.Now() + recordJoin := func() { + cur.verificationWait.AddTiming(time.Since(joinStarted)) + if obs := s.observed[cur.slot]; obs != nil && obs.generation == cur.generation { + obs.verificationWait = cur.verificationWait + } + cur.exec.setReplayStage("streaming_wait") + } + for _, batch := range group { + identities, ok, err := s.waitVerificationFn(ctx, batch) + if errors.Is(err, turbine.ErrStreamBatchUnverified) { + ok, err = false, nil + } + if err != nil { + recordJoin() + if errors.Is(err, context.DeadlineExceeded) { + return errors.New("sigverify_timeout") + } + return fmt.Errorf("sigverify: %w", err) + } + if !ok { + allVerified = false + } + txs = append(txs, batch.Transactions...) + verified = append(verified, identities...) + } + recordJoin() + joinedAt := time.Now() + if len(txs) == 0 { + return nil + } + if !allVerified || len(verified) != len(txs) { + // The assembler's verifier refused the batch (admission), so the + // block-level verification at completion will cover it. Verifying here + // through ProcessTransaction is not an option: that path halts the + // process on an invalid signature, which a speculative bank on an + // unauthenticated prefix must never do. + return errors.New("unverified_batch") + } + // The identities are bound to the block's objects by the verifier; that + // binding is checked here, on the originals. The bank then executes + // copies made from those very originals (see block.ExecutionCopies): the + // block's objects are never resolved or otherwise mutated by a stream, + // so a discard leaves them exactly as turbine decoded them. + preparedForOriginals, err := b.PrepareVerifiedTransactionMessageIdentities(txs, verified) + if err != nil { + return fmt.Errorf("identities: %w", err) + } + copies, prepared, err := preparedForOriginals.ExecutionCopies() + if err != nil { + if errors.Is(err, b.ErrTransactionAlreadyResolved) { + return errors.New("resolved_input") + } + return fmt.Errorf("copies: %w", err) + } + started := time.Now() + err = s.executeFn(cur.exec, copies, prepared, false) + cur.exec.setReplayStage("streaming_wait") + if err != nil { + var duplicates *DuplicateTransactionMessagesError + if errors.As(err, &duplicates) { + return errors.New("duplicate_message") + } + if IsAlreadyProcessedTransactionError(err) { + return errors.New("already_processed") + } + return fmt.Errorf("group: %w", err) + } + cur.origin = append(cur.origin, txs...) + cur.groups = append(cur.groups, streamingGroup{readyAt: readyAt, joinedAt: joinedAt, startedAt: started, finishedAt: time.Now(), batches: len(group), transactions: len(txs)}) + return nil +} + +// discard throws the in-progress stream away and undoes every side effect it +// may have had outside its own SlotCtx. +func (s *streamingExecutor) discard(reason string) { + if s == nil || s.current == nil { + return + } + cur := s.current + s.current = nil + s.deps.feed.PrioritizeStreamRepair(turbine.StreamGeneration{}) + s.stopTicker() + s.retire(cur.slot, cur.generation) + if obs := s.observed[cur.slot]; obs != nil && obs.generation == cur.generation { + obs.discarded = reason + } + exec := cur.exec + if exec != nil { + exec.close() + if exec.slotCtx != nil { + exec.slotCtx.TrackProgramCacheAdds = false + for _, key := range exec.slotCtx.TakeProgramCacheAdds() { + if s.deps.acctsDb != nil { + s.deps.acctsDb.RemoveProgramFromCache(key) + } + } + // Deferred vote-cache changes die with the SlotCtx; the stake index + // entries belong to this slot; a concurrent leader bank may own + // entries at later slots. + exec.slotCtx.PendingVoteCache = nil + exec.slotCtx.PendingVoteCacheDeletes = nil + exec.slotCtx.VoteStakeDirty = false + } + } + global.DropPendingStakePubkeys(cur.slot) + if cur.restoreSysvarCache != nil { + cur.restoreSysvarCache() + } + metrics.GlobalBlockReplay.StreamingExecution.VerificationWait = cur.verificationWait + metrics.GlobalBlockReplay.StreamingExecution.Discarded = 1 + metrics.GlobalBlockReplay.StreamingExecution.DiscardReason = reason + mlog.Log.FileOnlyf("streaming: discarded slot %d after %d groups (%s)", cur.slot, len(cur.groups), reason) +} + +// discardSlot discards the stream if it is open for slot. +func (s *streamingExecutor) discardSlot(slot uint64, reason string) { + if s.matches(slot) { + s.discard(reason) + } +} + +// streamingFinalizeError marks a failure after the handshake passed; the +// block is as invalid as it would have been for the whole-block path. +type streamingFinalizeError struct{ err error } + +func (e *streamingFinalizeError) Error() string { return e.err.Error() } +func (e *streamingFinalizeError) Unwrap() error { return e.err } + +// finalize completes execution of block on the open stream. ok reports +// whether the stream matched the block; when it did not, the stream has been +// discarded and the caller must execute the block whole. A non-nil error with +// ok == true is a failure after the handshake and is final for the block, +// exactly as a ProcessBlock error is. +// +// Ownership: the stream keeps owning its bank (s.current) until the tail has +// committed, so every failure path after the handshake goes through the same +// discard as a pre-handshake mismatch — program-cache insertions evicted, +// unpublished vote-cache entries dropped, slot-keyed stake entries dropped, +// the legacy sysvar cache restored, the execution closed. The one publication +// that precedes the tail is the deferred vote cache, applied at the point +// where whole-block execution would already have written it (before fees, +// rent, footer and bank hash); a failure inside the tail therefore leaves the +// same footprint a whole-block tail failure leaves, and the dirty marker it +// sets is what forces the rooted-checkpoint re-replay on recovery. +func (s *streamingExecutor) finalize(block *b.Block, parentBankSysvars *sealevel.BankSysvars) (slotCtx *sealevel.SlotCtx, ok bool, err error) { + if s == nil || s.current == nil || block == nil { + return nil, false, nil + } + cur := s.current + finalizeStart := time.Now() + if reason := s.handshake(block, parentBankSysvars); reason != "" { + s.discard("prefix_mismatch:" + reason) + return nil, false, nil + } + exec := cur.exec + fullAt := time.Time{} + if block.ShredFullNanos > 0 { + fullAt = time.Unix(0, block.ShredFullNanos) + } + + // Whole-block plan and status validation, exactly as ProcessBlock does + // them, now that the authoritative block exists. A failure here is not yet + // a verdict on the block: the whole-block path re-derives it. + if err := validateBlockTransactionVersions(block); err != nil { + s.discard("versions") + return nil, false, nil + } + executionPlanStart := time.Now() + executionPlan, err := planBlockTransactionExecution(block) + metrics.GlobalBlockReplay.TransactionExecutionPlan.AddTimingSince(executionPlanStart) + if err != nil { + s.discard("plan") + return nil, false, nil + } + executed := len(cur.origin) + if len(exec.transactions) != executed { + s.discard("prefix_bookkeeping") + return nil, false, nil + } + for i := 0; i < executed; i++ { + if executionPlan.execute[i] != exec.execute[i] { + s.discard("execution_mask") + return nil, false, nil + } + } + statusValidationStart := time.Now() + statusValidation, statusValidationErr := s.deps.transactionStatuses.validateBlockForPublication(block, executionPlan) + metrics.GlobalBlockReplay.TransactionStatusValidation.AddTimingSince(statusValidationStart) + if statusValidationErr != nil { + s.discard("status_validation") + return nil, false, nil + } + statusPreparation := s.deps.transactionStatuses.startStatusPreparation(executionPlan) + defer func() { + statusPreparation.wait() + if statusPreparation != nil { + metrics.GlobalBlockReplay.TransactionStatusPreparation.AddTiming(statusPreparation.duration) + } + }() + + // From here on the stream is committed to this block: any failure is the + // block's failure. fail undoes the stream's side effects and reports it. + s.stopTicker() + fail := func(reason string, err error) (*sealevel.SlotCtx, bool, error) { + s.discard("finalize:" + reason) + return nil, true, &streamingFinalizeError{err: err} + } + block.FeeRateGovernor = exec.block.FeeRateGovernor + block.VoteTimestamps = exec.slotCtx.VoteTimestamps + exec.block = block + exec.slotCtx.Blockhash = block.Blockhash + exec.slotCtx.Epoch = block.Epoch + if requireAlpenglowBlockFooter(block, exec.slotCtx, s.deps.alpenglowClock) { + if err := validateAlpenglowFooterNanosecondClock(exec.slotCtx, block); err != nil { + return fail("footer_clock", err) + } + } + if suffix := block.Transactions[executed:]; len(suffix) > 0 { + started := time.Now() + err := s.executeFn(exec, suffix, executionPlan.messageIdentities.Slice(executed, len(block.Transactions)), !block.TransactionSignaturesVerified()) + if err != nil { + return fail("suffix", fmt.Errorf("execute block suffix at slot %d: %w", block.Slot, err)) + } + cur.groups = append(cur.groups, streamingGroup{readyAt: started, joinedAt: started, startedAt: started, finishedAt: time.Now(), transactions: len(suffix), suffix: true}) + } + if exec.processedSignatures != executionPlan.processedSignatures || exec.processedTxCount != executionPlan.processedTxCount { + return fail("counts", fmt.Errorf("streaming execution at slot %d processed %d transactions/%d signatures, block plan has %d/%d", + block.Slot, exec.processedTxCount, exec.processedSignatures, executionPlan.processedTxCount, executionPlan.processedSignatures)) + } + exec.slotCtx.NumSignatures = executionPlan.processedSignatures + + // Acceptance of the executed transactions: publish what execution would + // have published as it ran, then run the unchanged tail. Program-cache + // insertions stay tracked until the tail commits so a tail failure can + // still evict them. + publishDeferredVoteCache(exec.slotCtx) + exec.executionPlan = executionPlan + exec.statusPreparation = statusPreparation + exec.statusValidation = statusValidation + slotCtx, err = exec.finalize() + if err != nil { + return fail("tail", err) + } + exec.slotCtx.TrackProgramCacheAdds = false + exec.slotCtx.TakeProgramCacheAdds() + s.current = nil + s.deps.feed.PrioritizeStreamRepair(turbine.StreamGeneration{}) + exec.close() + + // The per-block record is rebuilt from the stream's own bookkeeping: the + // loop resets the collector between waits, so counters accumulated while + // executing groups may or may not have survived to this point. + record := &metrics.GlobalBlockReplay.StreamingExecution + discarded, discardReason := record.Discarded, record.DiscardReason + *record = metrics.StreamingExecution{Opened: 1, Discarded: discarded, DiscardReason: discardReason} + record.VerificationWait = cur.verificationWait + record.Groups = uint64(len(cur.groups)) + for _, group := range cur.groups { + record.Transactions += uint64(group.transactions) + // Work that finished before the last shred arrived is the latency the + // stream took off the vote path. + if !fullAt.IsZero() && group.finishedAt.Before(fullAt) { + record.TxLoopBeforeFull.AddTiming(group.finishedAt.Sub(group.startedAt)) + } + } + record.OpenDelay.AddTiming(cur.openedAt.Sub(cur.headerAt)) + cur.recordTimeline(record, block, finalizeStart) + cur.recordGroups(record, fullAt) + // Publish only the accepted stream, including its open, early groups and + // suffix. The finalization record already contains any tail loader work. + metrics.GlobalBlockReplay.AccountLoader.Accumulate(exec.accountLoader) + return slotCtx, true, nil +} + +// handshake proves the executed prefix is the block. It returns the mismatch +// reason, or "" when every binding holds. +func (s *streamingExecutor) handshake(block *b.Block, parentBankSysvars *sealevel.BankSysvars) string { + cur := s.current + exec := cur.exec + switch { + case block.Slot != cur.slot: + return "slot" + case block.IsSkipped || !block.FromLiveStream: + return "not a live block" + case !block.HasAlpenglowParentBlockID || block.AlpenglowParentBlockID != cur.parentID || block.SourceParentSlot != cur.parentSlot: + return "parent" + case block.ParentSlot != exec.block.ParentSlot || block.ParentBankhash != exec.block.ParentBankhash: + return "configured parent" + case block.Features != exec.block.Features: + return "features" + case parentBankSysvars == nil || parentBankSysvars != exec.parentBankSysvars: + return "parent sysvars" + case block.Epoch != exec.block.Epoch: + return "epoch" + case len(block.EpochUpdatedAccts) != 0: + return "epoch account updates" + case len(block.Transactions) < len(cur.origin): + return "shorter than executed prefix" + } + status := s.deps.feed.StreamStatusOf(cur.generation) + if status == turbine.StreamGone { + return "generation gone" + } + for i, tx := range cur.origin { + if block.Transactions[i] != tx { + return fmt.Sprintf("transaction %d identity", i) + } + } + return "" +} + +// nanosOf is a time as unix nanoseconds, 0 for the zero time. +func nanosOf(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.UnixNano() +} + +// recordTimeline writes the stream's open timeline and its decomposition +// into the block's record. Every wait is attributed to exactly one thing: +// +// header ready ─(parent arrival)─▶ parent full ─(parent replay)─▶ parent +// replayed ─(loop: post-replay tail │ dispatch)─▶ opened +// +// with the header's own wake-up latency (ready → seen) reported alongside. +// Each component is zero when the timeline cannot support it (unknown +// instants, or an ordering that makes it empty). The arithmetic is done on +// the wall-clock nanos the record carries, so the components and the +// instants are exactly consistent for whoever joins them later. +func (cur *streamingSlot) recordTimeline(record *metrics.StreamingExecution, block *b.Block, finalizeStart time.Time) { + ready, seen, opened := nanosOf(cur.headerAt), nanosOf(cur.headerSeenAt), nanosOf(cur.openedAt) + if seen == 0 { + seen = ready + } + parentFull, parentAdmitted, parentReplayed, waitEntered := cur.parentFullNanos, nanosOf(cur.parentAdmittedAt), nanosOf(cur.parentReplayedAt), nanosOf(cur.waitEnteredAt) + record.HeaderReadyNanos = ready + record.HeaderSeenNanos = seen + record.OpenedNanos = opened + record.ParentFullNanos = parentFull + record.ParentAdmittedNanos = parentAdmitted + record.ParentReplayedNanos = parentReplayed + record.WaitEnteredNanos = waitEntered + if len(cur.groups) > 0 { + record.FirstGroupStartNanos = nanosOf(cur.groups[0].startedAt) + } + if block != nil && block.ShredFullNanos > 0 { + record.FullNanos = block.ShredFullNanos + } + record.FinalizeStartNanos = nanosOf(finalizeStart) + + add := func(timing *metrics.Timing, from, to int64) { + if from > 0 && to > from { + timing.AddTiming(time.Duration(to - from)) + } + } + add(&record.OpenWaitParentArrival, ready, parentFull) + if parentReplayed > 0 { + replayStart := max(ready, parentFull) + add(&record.OpenWaitParentReplay, replayStart, parentReplayed) + // Admission is a milestone, not an execution boundary: streaming + // can execute inside waitForReplayInput before admission. + if parentAdmitted > 0 { + add(&record.OpenWaitParentPreAdmission, replayStart, min(parentAdmitted, parentReplayed)) + add(&record.OpenWaitParentPostAdmission, max(parentAdmitted, replayStart), parentReplayed) + } + } + loopStart := max(ready, parentReplayed) + add(&record.OpenWaitLoop, loopStart, opened) + // The loop's wait splits at its first entry into the replay wait after + // the parent: before it is the parent's post-replay tail, after it the + // dispatch of the child's header (queued events ahead of it, the poll). + if waitEntered > 0 && parentReplayed > 0 && waitEntered > parentReplayed { + add(&record.OpenWaitPostReplay, loopStart, min(waitEntered, opened)) + add(&record.OpenWaitDispatch, max(waitEntered, loopStart), opened) + } +} + +// recordGroups writes the per-group view: joining/assembly, preparation, +// and execution elapsed intervals, including how much of each interval +// fell after the last shred (the part FullToReplayed pays for), and the +// largest group (a late open turns the whole backlog into one group). The +// suffix counts as a group that starts after full. +func (cur *streamingSlot) recordGroups(record *metrics.StreamingExecution, fullAt time.Time) { + // Without a full instant nothing is "after full" (as TxLoopBeforeFull + // and FullToReplayed record nothing either); waits and sizes still count. + after := func(from, to time.Time) time.Duration { + if fullAt.IsZero() { + return 0 + } + if fullAt.After(from) { + from = fullAt + } + if to.After(from) { + return to.Sub(from) + } + return 0 + } + for _, group := range cur.groups { + if wait := group.joinedAt.Sub(group.readyAt); wait > 0 && !group.suffix { + record.GroupJoinAssembly.AddTiming(wait) + if late := after(group.readyAt, group.joinedAt); late > 0 { + record.GroupJoinAssemblyAfterFull.AddTiming(late) + } + } + if !group.suffix && group.startedAt.After(group.joinedAt) { + record.GroupPreparation.AddTiming(group.startedAt.Sub(group.joinedAt)) + if late := after(group.joinedAt, group.startedAt); late > 0 { + record.GroupPreparationAfterFull.AddTiming(late) + } + } + if late := after(group.startedAt, group.finishedAt); late > 0 { + record.TxLoopAfterFull.AddTiming(late) + if !group.suffix && !fullAt.IsZero() && group.startedAt.Before(fullAt) { + record.GroupsStraddlingFull++ + } + } + if uint64(group.transactions) > record.LargestGroupTransactions { + record.LargestGroupTransactions = uint64(group.transactions) + record.LargestGroupBatches = uint64(group.batches) + } + } + if n := len(cur.groups); n > 0 { + record.LastGroupEndNanos = nanosOf(cur.groups[n-1].finishedAt) + } + // The tail cases are worth a per-group line; bounded so a heavy block + // with hundreds of groups does not flood the log. + if record.TxLoopAfterFull.SumNanoseconds > uint64(30*time.Millisecond) || record.GroupJoinAssemblyAfterFull.SumNanoseconds > uint64(5*time.Millisecond) { + mlog.Log.FileOnlyf("streaming: slot %d groups (vs last shred): %s", cur.slot, cur.groupTimeline(fullAt, 12)) + } +} + +// groupTimeline renders up to limit groups (the first ones and the last) +// relative to fullAt: "[#0 b=3 tx=1200 ready-150.2 joined-149.8 exec-149.8..-140.1]". +func (cur *streamingSlot) groupTimeline(fullAt time.Time, limit int) string { + rel := func(t time.Time) string { + if t.IsZero() || fullAt.IsZero() { + return "?" + } + return fmt.Sprintf("%+.1f", float64(t.Sub(fullAt).Microseconds())/1e3) + } + var out []byte + render := func(i int) { + g := cur.groups[i] + kind := "" + if g.suffix { + kind = " suffix" + } + out = fmt.Appendf(out, "[#%d%s b=%d tx=%d ready%s joined%s exec%s..%s]", i, kind, g.batches, g.transactions, rel(g.readyAt), rel(g.joinedAt), rel(g.startedAt), rel(g.finishedAt)) + } + n := len(cur.groups) + if n <= limit { + for i := range cur.groups { + render(i) + } + return string(out) + } + for i := 0; i < limit-1; i++ { + render(i) + } + out = fmt.Appendf(out, "…(%d more)", n-limit) + render(n - 1) + return string(out) +} + +// openTimeline renders the open's timeline for the log, relative to the +// header's decode instant. +func (cur *streamingSlot) openTimeline() string { + base := nanosOf(cur.headerAt) + rel := func(nanos int64) string { + if nanos == 0 { + return "?" + } + return fmt.Sprintf("%+.1fms", float64(nanos-base)/1e6) + } + return fmt.Sprintf("header seen %s, parent full %s, parent admitted %s, parent replayed %s, wait entered %s, opened %s (vs header ready)", + rel(nanosOf(cur.headerSeenAt)), rel(cur.parentFullNanos), rel(nanosOf(cur.parentAdmittedAt)), rel(nanosOf(cur.parentReplayedAt)), rel(nanosOf(cur.waitEnteredAt)), rel(nanosOf(cur.openedAt))) +} + +// noteWholeBlock records, for a block about to execute whole, why no stream +// opened for it (the block's record otherwise only says Opened == 0). The +// header timeline is filled in when the header was seen, so the analysis can +// tell "never decoded a header" from "decoded one and could not use it". +func (s *streamingExecutor) noteWholeBlock(block *b.Block) { + if s == nil || block == nil || block.IsSkipped { + return + } + record := &metrics.GlobalBlockReplay.StreamingExecution + if block.ShredFullNanos > 0 { + record.FullNanos = block.ShredFullNanos + } + obs := s.observed[block.Slot] + switch { + case obs == nil: + record.NotOpenedReason = "header_not_seen" + return + case obs.discarded != "": + record.NotOpenedReason = "discarded:" + obs.discarded + case obs.declined != "": + record.NotOpenedReason = "declined:" + obs.declined + case !obs.openedAt.IsZero(): + // Unreachable in practice (an opened stream ends in finalize or in a + // discard, which records itself); kept so the record never lies. + record.NotOpenedReason = "opened_not_discarded" + default: + record.NotOpenedReason = fmt.Sprintf("waiting_for_parent:header_on_parent_%d_seen_at_frontier_%d", obs.parentSlot, obs.frontierAtSeen) + } + record.VerificationWait = obs.verificationWait + record.HeaderReadyNanos = nanosOf(obs.readyAt) + record.HeaderSeenNanos = nanosOf(obs.seenAt) + record.OpenedNanos = nanosOf(obs.openedAt) +} diff --git a/pkg/replay/streaming_lifecycle_test.go b/pkg/replay/streaming_lifecycle_test.go new file mode 100644 index 000000000..6ee885ef8 --- /dev/null +++ b/pkg/replay/streaming_lifecycle_test.go @@ -0,0 +1,593 @@ +package replay + +import ( + "bytes" + "context" + "encoding/binary" + "sort" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/arena" + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/Overclock-Validator/mithril/pkg/turbine" + "github.com/Overclock-Validator/mithril/pkg/txverify" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +// The lifecycle equivalence gate: the same block executed whole by +// ProcessBlock and executed as a stream (bank opened on a transaction-less +// shell, groups fed through the executor, finalize against the complete +// block) must produce the same bank hash, the same committed account delta, +// the same signature/fee/compute totals and the same status publication. +// The bank is self-contained: an in-memory tail stands in for the unrooted +// working set, the parent bank sysvars are an explicit snapshot, rent +// rewrites are skipped (the rent scan needs a real AccountsDB), and the +// AccountsDB is only referenced for its directory. + +// lifecycleTail is an unrooted working set over a fixed durable memory: reads +// resolve to clones (or a zero-lamport placeholder), commits are recorded. +type lifecycleTail struct { + unrootedState + durable accounts.MemAccounts + added []lifecycleCommit +} + +type lifecycleCommit struct { + slot uint64 + delta []*accounts.Account + bankhash []byte +} + +func (t *lifecycleTail) GetAccount(_ uint64, pubkey solana.PublicKey) (*accounts.Account, error) { + if acct, err := t.durable.GetAccountWithoutLock(pubkey); err == nil { + return acct.Clone(), nil + } + return &accounts.Account{Key: pubkey}, nil +} + +func (t *lifecycleTail) GetAccountsBatch(_ context.Context, slot uint64, pks []solana.PublicKey) ([]*accounts.Account, error) { + out := make([]*accounts.Account, len(pks)) + for i, pk := range pks { + out[i], _ = t.GetAccount(slot, pk) + } + return out, nil +} + +func (t *lifecycleTail) Add(slot uint64, delta []*accounts.Account, bankhash []byte) { + t.added = append(t.added, lifecycleCommit{slot: slot, delta: delta, bankhash: append([]byte(nil), bankhash...)}) +} + +func (t *lifecycleTail) OverCap() bool { return false } + +type lifecycleEnv struct { + feats *features.Features + durable accounts.MemAccounts + acctsDb *accountsdb.AccountsDb + epochSchedule *sealevel.SysvarEpochSchedule + parent *sealevel.BankSysvars +} + +const ( + lifecycleParentSlot = uint64(7) + lifecycleSlot = uint64(8) +) + +var lifecycleParentBlockID = solana.Hash{0xAA, 0xBB} + +// ensureBorrowedAccountArenas gives parallelTxLoop the per-worker arena slots +// the node installs at startup (nil arenas are accepted by ProcessTransaction). +func ensureBorrowedAccountArenas(t *testing.T, n int) { + t.Helper() + if len(sealevel.BorrowedAccountArenas) >= n { + return + } + prev := sealevel.BorrowedAccountArenas + sealevel.BorrowedAccountArenas = make([]*arena.Arena[sealevel.BorrowedAccount], n) + t.Cleanup(func() { sealevel.BorrowedAccountArenas = prev }) +} + +func newLifecycleEnv(t *testing.T) *lifecycleEnv { + t.Helper() + ensureBorrowedAccountArenas(t, 4) + // Bank open publishes derived sysvars to the legacy process-global cache; + // leave it as we found it for the rest of the package. + sysvarCacheBefore := sealevel.SysvarCache + t.Cleanup(func() { sealevel.SysvarCache = sysvarCacheBefore }) + feats := features.NewFeaturesDefault() + feats.EnableFeature(features.FormalizeLoadedTransactionDataSize, 0) + feats.EnableFeature(features.SkipRentRewrites, 0) + + durable := accounts.NewMemAccounts() + _ = durable.SetAccountWithoutLock(addresses.SystemProgramAddr, &accounts.Account{ + Key: addresses.SystemProgramAddr, Lamports: 1, Owner: addresses.NativeLoaderAddr, Executable: true, RentEpoch: ^uint64(0), + }) + _ = durable.SetAccountWithoutLock(txfixture.PayerPubkey(), &accounts.Account{ + Key: txfixture.PayerPubkey(), Lamports: 3_200_000, Owner: addresses.SystemProgramAddr, RentEpoch: ^uint64(0), + }) + _ = durable.SetAccountWithoutLock(txfixture.DestPubkey(), &accounts.Account{ + Key: txfixture.DestPubkey(), Lamports: 10_000_000, Owner: addresses.SystemProgramAddr, RentEpoch: ^uint64(0), + }) + + // The parent bank's sysvar snapshot, as the retained parent context would + // hold it. RecentBlockhashes carries the fixture blockhash so the transfers + // are age-valid. + clock := sealevel.SysvarClock{Slot: lifecycleParentSlot, EpochStartTimestamp: 111, UnixTimestamp: 222} + slotHashes := sealevel.SysvarSlotHashes{{Slot: lifecycleParentSlot - 1, Hash: [32]byte{0x61}}} + recent := sealevel.SysvarRecentBlockhashes{{ + Blockhash: txfixture.TestBlockhash(), + FeeCalculator: sealevel.FeeCalculator{LamportsPerSignature: 5_000}, + }} + slotHistory := sealevel.SysvarSlotHistory{ + Bits: sealevel.SlotHistoryBitvec{ + Bits: sealevel.SlotHistoryInner{BlocksLen: 1, Blocks: []uint64{0x81}}, + Len: 64, + }, + NextSlot: lifecycleSlot, + } + stakeHistory := sealevel.SysvarStakeHistory{{Epoch: 0, Entry: sealevel.StakeHistoryEntry{Effective: 91}}} + lastRestart := sealevel.SysvarLastRestartSlot{LastRestartSlot: 3} + epochSchedule := sealevel.SysvarEpochSchedule{SlotsPerEpoch: 100, LeaderScheduleSlotOffset: 100} + rent := sealevel.NewDefaultRentSysvar() + parent, err := sealevel.NewBankSysvars(lifecycleParentSlot, + &accounts.Account{Key: sealevel.SysvarClockAddr, Lamports: 1, Data: clock.MustMarshal()}, + &accounts.Account{Key: sealevel.SysvarSlotHashesAddr, Lamports: 1, Data: slotHashes.MustMarshal()}, + &accounts.Account{Key: sealevel.SysvarRecentBlockHashesAddr, Lamports: 1, Data: recent.MustMarshal()}, + &accounts.Account{Key: sealevel.SysvarSlotHistoryAddr, Lamports: 1, Data: slotHistory.MustMarshal()}, + &accounts.Account{Key: sealevel.SysvarStakeHistoryAddr, Lamports: 1, Data: marshalStakeHistoryForParentLoader(t, &stakeHistory)}, + &accounts.Account{Key: sealevel.SysvarLastRestartSlotAddr, Lamports: 1, Data: marshalLastRestartSlotForParentLoader(t, lastRestart)}, + &accounts.Account{Key: sealevel.SysvarEpochScheduleAddr, Lamports: 1, Data: marshalEpochScheduleForParentLoader(t, epochSchedule)}, + &accounts.Account{Key: sealevel.SysvarRentAddr, Lamports: 1, Data: rent.MustMarshal()}, + ) + require.NoError(t, err) + require.NoError(t, parent.ValidateForExecution()) + + return &lifecycleEnv{ + feats: feats, + durable: durable, + acctsDb: &accountsdb.AccountsDb{AcctsDir: t.TempDir()}, + epochSchedule: &epochSchedule, + parent: parent, + } +} + +// block builds the slot's block as the loop would have configured it on the +// executed parent; txs nil is the streaming shell. +func (env *lifecycleEnv) block(txs []*solana.Transaction) *b.Block { + return &b.Block{ + Slot: lifecycleSlot, + VoteTimestamps: make(map[solana.PublicKey]sealevel.BlockTimestamp), + Epoch: 0, + ParentSlot: lifecycleParentSlot, + ParentBankhash: [32]byte{0x88}, + Blockhash: [32]byte{0x99}, + LastBlockhash: [32]byte{0x77}, + Features: env.feats, + Transactions: txs, + PrevFeeRateGovernor: &sealevel.FeeRateGovernor{TargetLamportsPerSignature: 5_000, LamportsPerSignature: 5_000}, + FromLiveStream: true, + SourceParentSlot: lifecycleParentSlot, + AlpenglowParentBlockID: lifecycleParentBlockID, + HasAlpenglowParentBlockID: true, + } +} + +type lifecycleOutcome struct { + bankhash []byte + numSignatures uint64 + computeUnits uint64 + lamportsBurnt uint64 + delta map[solana.PublicKey]*accounts.Account +} + +func lifecycleOutcomeOf(t *testing.T, slotCtx *sealevel.SlotCtx, tail *lifecycleTail) lifecycleOutcome { + t.Helper() + require.NotNil(t, slotCtx) + require.Len(t, tail.added, 1, "the bank commits exactly once") + require.Equal(t, slotCtx.Slot, tail.added[0].slot) + require.Equal(t, slotCtx.FinalBankhash, tail.added[0].bankhash) + delta := make(map[solana.PublicKey]*accounts.Account, len(tail.added[0].delta)) + for _, acct := range tail.added[0].delta { + delta[acct.Key] = acct + } + require.Contains(t, delta, txfixture.PayerPubkey()) + return lifecycleOutcome{ + bankhash: append([]byte(nil), slotCtx.FinalBankhash...), + numSignatures: slotCtx.NumSignatures, + computeUnits: slotCtx.TotalComputeUnitsConsumed, + lamportsBurnt: slotCtx.LamportsBurnt, + delta: delta, + } +} + +func requireSameLifecycleOutcome(t *testing.T, want, got lifecycleOutcome) { + t.Helper() + require.NotEmpty(t, want.bankhash) + require.Equal(t, want.bankhash, got.bankhash, "bank hash") + require.Equal(t, want.numSignatures, got.numSignatures) + require.Equal(t, want.computeUnits, got.computeUnits) + require.Equal(t, want.lamportsBurnt, got.lamportsBurnt) + wantKeys := make([]string, 0, len(want.delta)) + for key := range want.delta { + wantKeys = append(wantKeys, key.String()) + } + gotKeys := make([]string, 0, len(got.delta)) + for key := range got.delta { + gotKeys = append(gotKeys, key.String()) + } + sort.Strings(wantKeys) + sort.Strings(gotKeys) + require.Equal(t, wantKeys, gotKeys, "committed account set") + for key, acct := range want.delta { + other := got.delta[key] + require.Equal(t, acct.Lamports, other.Lamports, "%s lamports", key) + require.Equal(t, acct.Owner, other.Owner, "%s owner", key) + require.Equal(t, acct.Data, other.Data, "%s data", key) + require.Equal(t, acct.Executable, other.Executable, "%s executable", key) + } +} + +func lifecycleWholeBlock(t *testing.T, env *lifecycleEnv, txs []*solana.Transaction, txParallelism int) lifecycleOutcome { + t.Helper() + tail := &lifecycleTail{durable: env.durable} + statuses := NewTransactionStatusCache() + block := env.block(txs) + block.MarkTransactionSignaturesVerified() + slotCtx, err := ProcessBlock(env.acctsDb, block, env.epochSchedule, txParallelism, nil, &persistedTracker{}, tail, statuses, false, env.parent) + require.NoError(t, err) + return lifecycleOutcomeOf(t, slotCtx, tail) +} + +// lifecycleStream opens the bank on a transaction-less shell, feeds txs in +// the given batch splits through the executor, and finalizes against the +// complete block; suffixTxs of the transactions are never streamed and +// execute at finalize. +func lifecycleStream(t *testing.T, env *lifecycleEnv, txs []*solana.Transaction, splits []int, suffixTxs int, workers int) (lifecycleOutcome, *streamingExecutor) { + t.Helper() + tail := &lifecycleTail{durable: env.durable} + statuses := NewTransactionStatusCache() + shell := env.block(nil) + exec := newBlockExecution(env.acctsDb, shell, env.epochSchedule, workers, nil, &persistedTracker{}, tail, statuses, false, env.parent) + require.NoError(t, exec.open()) + exec.slotCtx.DeferVoteCachePublication = true + exec.slotCtx.TrackProgramCacheAdds = true + + feed := newFakeStreamFeed() + gen := turbine.NewDetachedStreamGeneration(lifecycleSlot) + feed.status[gen] = turbine.StreamActive + s := newStreamingExecutor(streamingDeps{ + feed: feed, + epochSchedule: env.epochSchedule, + tail: tail, + transactionStatuses: statuses, + alpenglowMode: true, + unrootedTailUsed: true, + frontier: func() uint64 { return lifecycleParentSlot }, + lastSlotCtx: func() *sealevel.SlotCtx { return nil }, + currentFeatures: func() *features.Features { return env.feats }, + currentEpoch: func() uint64 { return 0 }, + rewardsInFlight: func() bool { return false }, + switchPending: func() bool { return false }, + executedBlockID: func(uint64) (solana.Hash, bool) { return lifecycleParentBlockID, true }, + }) + s.current = &streamingSlot{ + slot: lifecycleSlot, generation: gen, parentSlot: lifecycleParentSlot, parentID: lifecycleParentBlockID, + exec: exec, pending: make(map[uint32]*turbine.StreamBatch), openedAt: time.Now(), headerAt: time.Now(), + } + header := turbine.NewDetachedStreamMarker(gen, 0, 0, turbine.StreamMarkerHeader, lifecycleParentSlot, lifecycleParentBlockID) + s.handleEvent(turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: lifecycleSlot, Generation: gen, Batch: header}) + + streamed := txs[:len(txs)-suffixTxs] + start, next := 0, uint32(1) + for _, end := range append(append([]int(nil), splits...), len(streamed)) { + if end <= start { + continue + } + group := streamed[start:end] + batch := turbine.NewDetachedStreamBatch(gen, next, next+uint32(len(group))-1, group, verifiedIdentities(t, group)) + s.handleEvent(turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: lifecycleSlot, Generation: gen, Batch: batch}) + next += uint32(len(group)) + start = end + } + require.NotNil(t, s.current, "no group may have discarded the stream (%s)", metrics.GlobalBlockReplay.StreamingExecution.DiscardReason) + sameTransactions(t, streamed, s.current.origin) + sameCopies(t, streamed, exec.transactions) + + block := env.block(txs) + block.MarkTransactionSignaturesVerified() + slotCtx, ok, err := s.finalize(block, env.parent) + require.NoError(t, err) + require.True(t, ok, "the stream must accept its own block") + require.Nil(t, s.current) + require.True(t, exec.closed) + require.Equal(t, uint64(1), metrics.GlobalBlockReplay.StreamingExecution.Opened) + require.Equal(t, uint64(len(txs)), metrics.GlobalBlockReplay.StreamingExecution.Transactions) + return lifecycleOutcomeOf(t, slotCtx, tail), s +} + +func TestStreamingLifecycleMatchesWholeBlock(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + // Transfers of 999,001+ lamports at a 5,000-lamport fee from a + // 3,200,000-lamport payer: the first two succeed, the rest fail for rent + // and still pay, so every group boundary is a write dependency on the + // payer and the outcome is order-dependent. + txs := transferTransactions(t, 12, 999_000) + txCountBefore := global.TransactionCount() + + whole := lifecycleWholeBlock(t, newLifecycleEnv(t), txs, 0) + require.Equal(t, txCountBefore+uint64(len(txs)), global.TransactionCount()) + wholeParallel := lifecycleWholeBlock(t, newLifecycleEnv(t), txs, 4) + requireSameLifecycleOutcome(t, whole, wholeParallel) + + cases := []struct { + name string + splits []int + suffixTxs int + workers int + }{ + {"one group, no suffix", nil, 0, 1}, + {"three groups, no suffix", []int{3, 7}, 0, 4}, + {"per-transaction groups", []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, 0, 2}, + {"two groups and a suffix", []int{4}, 3, 4}, + {"everything in the suffix", nil, 12, 4}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + before := global.TransactionCount() + streamed, _ := lifecycleStream(t, newLifecycleEnv(t), txs, tc.splits, tc.suffixTxs, tc.workers) + requireSameLifecycleOutcome(t, whole, streamed) + require.Equal(t, before+uint64(len(txs)), global.TransactionCount()) + }) + } +} + +// A stream discarded after executing groups leaves the durable view and the +// tail untouched, and the same block then executes whole to the same result. +func TestStreamingLifecycleDiscardThenWholeBlock(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + for _, reason := range []string{"update_parent", "sigverify_timeout"} { + t.Run(reason, func(t *testing.T) { + txs := transferTransactions(t, 8, 999_000) + env := newLifecycleEnv(t) + whole := lifecycleWholeBlock(t, env, txs, 2) + + tail := &lifecycleTail{durable: env.durable} + statuses := NewTransactionStatusCache() + shell := env.block(nil) + exec := newBlockExecution(env.acctsDb, shell, env.epochSchedule, 2, nil, &persistedTracker{}, tail, statuses, false, env.parent) + require.NoError(t, exec.open()) + feed := newFakeStreamFeed() + gen := turbine.NewDetachedStreamGeneration(lifecycleSlot) + feed.status[gen] = turbine.StreamActive + s := newStreamingExecutor(streamingDeps{feed: feed, epochSchedule: env.epochSchedule, tail: tail, transactionStatuses: statuses}) + s.current = &streamingSlot{slot: lifecycleSlot, generation: gen, parentSlot: lifecycleParentSlot, parentID: lifecycleParentBlockID, + exec: exec, pending: make(map[uint32]*turbine.StreamBatch), openedAt: time.Now(), headerAt: time.Now(), nextStart: 1} + s.handleEvent(turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: lifecycleSlot, Generation: gen, + Batch: turbine.NewDetachedStreamBatch(gen, 1, 5, txs[:5], verifiedIdentities(t, txs[:5]))}) + sameTransactions(t, txs[:5], s.current.origin) + sameCopies(t, txs[:5], exec.transactions) + payerNow, err := exec.slotCtx.GetAccountShared(txfixture.PayerPubkey()) + require.NoError(t, err) + require.Less(t, payerNow.Lamports, uint64(3_200_000), "the overlay saw the executed prefix") + + if reason == "sigverify_timeout" { + s.waitVerificationFn = func(context.Context, *turbine.StreamBatch) ([]txverify.VerifiedMessageIdentity, bool, error) { + return nil, false, context.DeadlineExceeded + } + s.handleEvent(turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: lifecycleSlot, Generation: gen, Batch: turbine.NewDetachedStreamBatch(gen, 6, 8, txs[5:], verifiedIdentities(t, txs[5:]))}) + require.Nil(t, s.current) + } else { + s.discard(reason) + } + require.Empty(t, tail.added, "a discarded stream commits nothing") + durablePayer, err := env.durable.GetAccountWithoutLock(txfixture.PayerPubkey()) + require.NoError(t, err) + require.Equal(t, uint64(3_200_000), durablePayer.Lamports, "the durable view is untouched") + + again := lifecycleWholeBlock(t, env, txs, 2) + requireSameLifecycleOutcome(t, whole, again) + + }) + } +} + +// V0 / address-lookup-table coverage. Resolving lookups mutates the message +// object (SetAddressTables refuses a second call; ResolveLookups appends to +// AccountKeys), so a stream must execute its own copies and leave the block's +// objects for the whole-block path — which may run them against a different +// parent, with a different table. + +var lifecycleTableKey = solana.PublicKey{0x7A, 0xB1, 0xE0} + +// lookupTableAccount is an active address lookup table whose only entry is +// dest, encoded the way the ALT program stores it. +func lookupTableAccount(t *testing.T, dest solana.PublicKey) *accounts.Account { + t.Helper() + var buf bytes.Buffer + enc := bin.NewBinEncoder(&buf) + require.NoError(t, enc.WriteUint32(sealevel.AddressLookupTableProgramStateLookupTable, bin.LE)) + authority := txfixture.PayerPubkey() + meta := sealevel.LookupTableMeta{DeactivationSlot: ^uint64(0), LastExtendedSlot: 1, Authority: &authority} + require.NoError(t, meta.MarshalWithEncoder(enc)) + require.NoError(t, enc.WriteBytes(dest[:], false)) + require.Equal(t, sealevel.AddressLookupTableMetaSize+32, buf.Len()) + return &accounts.Account{Key: lifecycleTableKey, Lamports: 1_000_000, Owner: addresses.AddressLookupTableAddr, Data: buf.Bytes(), RentEpoch: ^uint64(0)} +} + +func systemTransferData(lamports uint64) []byte { + data := make([]byte, 12) + binary.LittleEndian.PutUint32(data, 2) // SystemInstruction::Transfer + binary.LittleEndian.PutUint64(data[4:], lamports) + return data +} + +// signedV0TransferViaTableWire is a signed v0 transfer from the fixture payer +// to entry 0 of lifecycleTableKey (a writable lookup): static keys are the +// payer and the System program, so the destination is account index 2. +func signedV0TransferViaTableWire(t *testing.T, seq uint64) []byte { + t.Helper() + msg := solana.Message{ + Header: solana.MessageHeader{NumRequiredSignatures: 1, NumReadonlyUnsignedAccounts: 1}, + AccountKeys: solana.PublicKeySlice{txfixture.PayerPubkey(), solana.SystemProgramID}, + RecentBlockhash: txfixture.TestBlockhash(), + Instructions: []solana.CompiledInstruction{{ + ProgramIDIndex: 1, + Accounts: []uint16{0, 2}, + Data: systemTransferData(1_000 + seq), + }}, + AddressTableLookups: solana.MessageAddressTableLookupSlice{{AccountKey: lifecycleTableKey, WritableIndexes: []uint8{0}}}, + } + _, err := msg.SetVersion(solana.MessageVersionV0) + require.NoError(t, err) + tx := &solana.Transaction{Message: msg} + payerKey := txfixture.PayerPrivateKey() + _, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(txfixture.PayerPubkey()) { + return &payerKey + } + return nil + }) + require.NoError(t, err) + wire, err := tx.MarshalBinary() + require.NoError(t, err) + return wire +} + +// decodeTransactions decodes wires the way turbine does, so each call yields +// fresh, unresolved objects. +func decodeTransactions(t *testing.T, wires [][]byte) []*solana.Transaction { + t.Helper() + txs := make([]*solana.Transaction, len(wires)) + for i, wire := range wires { + tx, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + require.Equal(t, solana.MessageVersionV0, tx.Message.GetVersion()) + require.False(t, tx.Message.IsResolved()) + txs[i] = tx + } + return txs +} + +// newLifecycleEnvWithTable is newLifecycleEnv with a well-funded payer, the +// lookup table pointing at dest, and dest as an existing rent-exempt account, +// so every v0 transfer succeeds and the credited destination shows which +// table resolved it. +func newLifecycleEnvWithTable(t *testing.T, dest solana.PublicKey) *lifecycleEnv { + t.Helper() + env := newLifecycleEnv(t) + _ = env.durable.SetAccountWithoutLock(txfixture.PayerPubkey(), &accounts.Account{ + Key: txfixture.PayerPubkey(), Lamports: 10_000_000_000, Owner: addresses.SystemProgramAddr, RentEpoch: ^uint64(0), + }) + _ = env.durable.SetAccountWithoutLock(dest, &accounts.Account{ + Key: dest, Lamports: 10_000_000, Owner: addresses.SystemProgramAddr, RentEpoch: ^uint64(0), + }) + _ = env.durable.SetAccountWithoutLock(lifecycleTableKey, lookupTableAccount(t, dest)) + return env +} + +// A stream refuses a batch whose transactions already carry resolution: +// their account keys were derived against a parent the stream cannot vouch +// for. The assembler never produces one; this pins the refusal. +func TestStreamingRefusesResolvedInput(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + txs := decodeTransactions(t, [][]byte{signedV0TransferViaTableWire(t, 1), signedV0TransferViaTableWire(t, 2)}) + identities := verifiedIdentities(t, txs) + require.NoError(t, txs[1].Message.SetAddressTables(map[solana.PublicKey]solana.PublicKeySlice{lifecycleTableKey: {{0xD1}}})) + require.NoError(t, txs[1].Message.ResolveLookups()) + + h := newStreamingTestHarness(t) + h.exec.handleEvent(h.event(turbine.NewDetachedStreamBatch(h.gen, 1, 2, txs, identities))) + require.Nil(t, h.exec.current) + require.Equal(t, "resolved_input", h.discardReason()) + require.False(t, txs[0].Message.IsResolved(), "the unresolved sibling is untouched") +} + +func TestStreamingLifecycleV0LookupsMatchWholeBlock(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + dest := solana.PublicKey{0xDA} + wires := make([][]byte, 6) + for i := range wires { + wires[i] = signedV0TransferViaTableWire(t, uint64(i)) + } + whole := lifecycleWholeBlock(t, newLifecycleEnvWithTable(t, dest), decodeTransactions(t, wires), 2) + require.Contains(t, whole.delta, dest) + require.Equal(t, uint64(10_000_000+6*1_000+0+1+2+3+4+5), whole.delta[dest].Lamports, "every transfer reached the table's entry") + + orig := decodeTransactions(t, wires) + streamed, _ := lifecycleStream(t, newLifecycleEnvWithTable(t, dest), orig, []int{2}, 2, 2) + requireSameLifecycleOutcome(t, whole, streamed) + for i := 0; i < 4; i++ { + require.False(t, orig[i].Message.IsResolved(), "streamed transaction %d ran as a copy", i) + } + for i := 4; i < 6; i++ { + require.True(t, orig[i].Message.IsResolved(), "suffix transaction %d ran as the block's own object, like whole-block execution", i) + } +} + +// A v0 prefix executed on a stream against parent A is discarded; the same +// authoritative objects then execute whole against parent B, whose table +// names a different destination. The block's objects must still resolve +// (they were never touched), B's destination must be the one credited, and +// the result must equal a fresh-decoded whole-block reference on B. +func TestStreamingLifecycleDiscardedV0PrefixResolvesAgainstTheNewParent(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + destA, destB := solana.PublicKey{0xA1}, solana.PublicKey{0xB2} + wires := make([][]byte, 6) + for i := range wires { + wires[i] = signedV0TransferViaTableWire(t, uint64(10+i)) + } + orig := decodeTransactions(t, wires) + + envA := newLifecycleEnvWithTable(t, destA) + tail := &lifecycleTail{durable: envA.durable} + statuses := NewTransactionStatusCache() + exec := newBlockExecution(envA.acctsDb, envA.block(nil), envA.epochSchedule, 2, nil, &persistedTracker{}, tail, statuses, false, envA.parent) + require.NoError(t, exec.open()) + feed := newFakeStreamFeed() + gen := turbine.NewDetachedStreamGeneration(lifecycleSlot) + feed.status[gen] = turbine.StreamActive + s := newStreamingExecutor(streamingDeps{feed: feed, epochSchedule: envA.epochSchedule, tail: tail, transactionStatuses: statuses}) + s.current = &streamingSlot{slot: lifecycleSlot, generation: gen, parentSlot: lifecycleParentSlot, parentID: lifecycleParentBlockID, + exec: exec, pending: make(map[uint32]*turbine.StreamBatch), openedAt: time.Now(), headerAt: time.Now(), nextStart: 1} + s.handleEvent(turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: lifecycleSlot, Generation: gen, + Batch: turbine.NewDetachedStreamBatch(gen, 1, 4, orig[:4], verifiedIdentities(t, orig[:4]))}) + require.NotNil(t, s.current, "stream discarded: %s", metrics.GlobalBlockReplay.StreamingExecution.DiscardReason) + sameTransactions(t, orig[:4], s.current.origin) + sameCopies(t, orig[:4], exec.transactions) + creditedA, err := exec.slotCtx.GetAccountShared(destA) + require.NoError(t, err) + require.Equal(t, uint64(10_000_000+4*1_000+10+11+12+13), creditedA.Lamports, "the stream resolved through A's table") + for i, tx := range orig { + require.False(t, tx.Message.IsResolved(), "block object %d must be untouched by the stream", i) + } + + s.discard("fork_switch") + require.Empty(t, tail.added) + + envB := newLifecycleEnvWithTable(t, destB) + whole := lifecycleWholeBlock(t, envB, orig, 2) + require.Contains(t, whole.delta, destB, "the block's objects resolved through B's table") + require.NotContains(t, whole.delta, destA, "nothing of A's resolution survived") + require.Equal(t, uint64(10_000_000+6*1_000+10+11+12+13+14+15), whole.delta[destB].Lamports) + for i, tx := range orig { + require.True(t, tx.Message.IsResolved(), "block object %d was resolved by the whole-block path", i) + } + + reference := lifecycleWholeBlock(t, newLifecycleEnvWithTable(t, destB), decodeTransactions(t, wires), 2) + requireSameLifecycleOutcome(t, reference, whole) +} diff --git a/pkg/replay/streaming_program_mix_test.go b/pkg/replay/streaming_program_mix_test.go new file mode 100644 index 000000000..3391eb15f --- /dev/null +++ b/pkg/replay/streaming_program_mix_test.go @@ -0,0 +1,193 @@ +package replay + +import ( + "bytes" + "encoding/binary" + "fmt" + "testing" + + "github.com/Overclock-Validator/mithril/fixtures" + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/stretchr/testify/require" +) + +func signLifecycleInstructions(t *testing.T, instructions ...solana.Instruction) *solana.Transaction { + t.Helper() + tx, err := solana.NewTransaction(instructions, txfixture.TestBlockhash(), solana.TransactionPayer(txfixture.PayerPubkey())) + require.NoError(t, err) + key := txfixture.PayerPrivateKey() + _, err = tx.Sign(func(pk solana.PublicKey) *solana.PrivateKey { + if pk == key.PublicKey() { + return &key + } + return nil + }) + require.NoError(t, err) + wire, err := tx.MarshalBinary() + require.NoError(t, err) + decoded, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + return decoded +} + +// Each case has a real write consumed or replaced in a subsequent group. Fresh +// wire decoding avoids accidentally sharing resolved transactions across paths. +func TestStreamingLifecycleProgramMutations(t *testing.T) { + previous := StreamingExecutionCfg + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + t.Cleanup(func() { StreamingExecutionCfg = previous }) + payer := txfixture.PayerPubkey() + key := solana.PublicKey{0xC1} + previousVote := global.VoteCacheItem(key) + t.Cleanup(func() { + if previousVote == nil { + global.DeleteVoteCacheItem(key) + } else { + global.PutVoteCacheItem(key, previousVote) + } + }) + for _, kind := range []string{"nonce", "lookup extension", "vote commission", "program upgrade", "program deployment"} { + t.Run(kind, func(t *testing.T) { + setup := func() (*lifecycleEnv, []*solana.Transaction) { + env := newLifecycleEnv(t) + env.acctsDb.InitCaches() + t.Cleanup(env.acctsDb.ProgramCache.Close) + t.Cleanup(env.acctsDb.VoteAcctCache.Close) + t.Cleanup(env.acctsDb.CommonAcctsCache.Close) + put := func(pk solana.PublicKey, owner solana.PublicKey, data []byte, executable bool) { + require.NoError(t, env.durable.SetAccountWithoutLock(pk, &accounts.Account{Key: pk, Owner: owner, Lamports: 100_000_000, Data: data, Executable: executable, RentEpoch: ^uint64(0)})) + } + put(payer, addresses.SystemProgramAddr, nil, false) + native := func(pk solana.PublicKey) { put(pk, addresses.NativeLoaderAddr, nil, true) } + var txs []*solana.Transaction + switch kind { + case "nonce": + state := sealevel.NonceStateVersions{Type: sealevel.NonceVersionCurrent, Current: sealevel.NonceData{IsInitialized: true, Authority: payer, DurableNonce: [32]byte{0xAA}, FeeCalculator: sealevel.FeeCalculator{LamportsPerSignature: 5000}}} + data, err := state.Marshal() + require.NoError(t, err) + put(key, addresses.SystemProgramAddr, data, false) + // Repeated advances have different messages but the same nonce account; + // only the first may advance in this bank. Later instructions must see it. + for i := uint64(1); i <= 3; i++ { + txs = append(txs, signLifecycleInstructions(t, system.NewAdvanceNonceAccountInstruction(key, solana.SysVarRecentBlockHashesPubkey, payer).Build(), system.NewTransferInstruction(i, payer, txfixture.DestPubkey()).Build())) + } + case "lookup extension": + native(addresses.AddressLookupTableAddr) + table := lookupTableAccount(t, txfixture.DestPubkey()) + table.Lamports = 100_000_000 + require.NoError(t, env.durable.SetAccountWithoutLock(lifecycleTableKey, table)) + for i := byte(1); i <= 3; i++ { + instruction := sealevel.AddrLookupTableInstrExtendLookupTable{NewAddresses: []solana.PublicKey{{0xD2, i}}} + var b bytes.Buffer + require.NoError(t, instruction.MarshalWithEncoder(bin.NewBinEncoder(&b))) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(addresses.AddressLookupTableAddr, solana.AccountMetaSlice{solana.Meta(lifecycleTableKey).WRITE(), solana.Meta(payer).SIGNER()}, b.Bytes()))) + } + case "vote commission": + global.DeleteVoteCacheItem(key) + native(addresses.VoteProgramAddr) + state := sealevel.VoteStateVersions{Type: sealevel.VoteStateVersionCurrent, Current: sealevel.VoteState{AuthorizedWithdrawer: payer, Commission: 30}} + var b bytes.Buffer + require.NoError(t, state.MarshalWithEncoder(bin.NewBinEncoder(&b))) + data := make([]byte, sealevel.VoteStateV3Size) + copy(data, b.Bytes()) + put(key, addresses.VoteProgramAddr, data, false) + for _, commission := range []byte{20, 10, 5} { + data := binary.LittleEndian.AppendUint32(nil, sealevel.VoteProgramInstrTypeUpdateCommission) + data = append(data, commission) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(addresses.VoteProgramAddr, solana.AccountMetaSlice{solana.Meta(key).WRITE(), solana.Meta(payer).SIGNER()}, data))) + } + case "program upgrade", "program deployment": + native(addresses.BpfLoaderUpgradeableAddr) + programDataKey := solana.PublicKey{0xC2} + bufferKey := solana.PublicKey{0xC3} + elf := fixtures.Load(t, "sbpf", "noop_aligned.so") + encode := func(state sealevel.UpgradeableLoaderState, size int) []byte { + var b bytes.Buffer + require.NoError(t, state.MarshalWithEncoder(bin.NewBinEncoder(&b))) + out := make([]byte, size) + copy(out, b.Bytes()) + return out + } + put(key, addresses.BpfLoaderUpgradeableAddr, encode(sealevel.UpgradeableLoaderState{Type: sealevel.UpgradeableLoaderStateTypeProgram, Program: sealevel.UpgradeableLoaderStateProgram{ProgramDataAddress: programDataKey}}, 36), true) + pd := encode(sealevel.UpgradeableLoaderState{Type: sealevel.UpgradeableLoaderStateTypeProgramData, ProgramData: sealevel.UpgradeableLoaderStateProgramData{Slot: 1, UpgradeAuthorityAddress: &payer}}, 45+len(elf)) + copy(pd[45:], elf) + put(programDataKey, addresses.BpfLoaderUpgradeableAddr, pd, false) + buf := encode(sealevel.UpgradeableLoaderState{Type: sealevel.UpgradeableLoaderStateTypeBuffer, Buffer: sealevel.UpgradeableLoaderStateBuffer{AuthorityAddress: &payer}}, 37+len(elf)) + copy(buf[37:], elf) + put(bufferKey, addresses.BpfLoaderUpgradeableAddr, buf, false) + if kind == "program deployment" { + programDataKey, _, err := solana.FindProgramAddress([][]byte{key[:]}, addresses.BpfLoaderUpgradeableAddr) + require.NoError(t, err) + put(key, addresses.BpfLoaderUpgradeableAddr, make([]byte, 36), false) + // No pre-funded PDA: Deploy creates it with a signed CPI. + require.NoError(t, env.durable.SetAccountWithoutLock(programDataKey, &accounts.Account{Key: programDataKey, Owner: addresses.SystemProgramAddr})) + write := sealevel.UpgradeableLoaderInstrWrite{Offset: 0, Bytes: elf[:8]} + var b bytes.Buffer + require.NoError(t, write.MarshalWithEncoder(bin.NewBinEncoder(&b))) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(addresses.BpfLoaderUpgradeableAddr, solana.AccountMetaSlice{solana.Meta(bufferKey).WRITE(), solana.Meta(payer).SIGNER()}, b.Bytes()))) + deploy := sealevel.UpgradeableLoaderInstrDeployWithMaxDataLen{MaxDataLen: uint64(len(elf))} + b.Reset() + require.NoError(t, deploy.MarshalWithEncoder(bin.NewBinEncoder(&b))) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(addresses.BpfLoaderUpgradeableAddr, solana.AccountMetaSlice{solana.Meta(payer).WRITE().SIGNER(), solana.Meta(programDataKey).WRITE(), solana.Meta(key).WRITE(), solana.Meta(bufferKey).WRITE(), solana.Meta(sealevel.SysvarRentAddr), solana.Meta(sealevel.SysvarClockAddr), solana.Meta(addresses.SystemProgramAddr), solana.Meta(payer).SIGNER()}, b.Bytes()))) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(key, nil, []byte{2}))) + break + } + + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(key, nil, []byte{1}))) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(addresses.BpfLoaderUpgradeableAddr, solana.AccountMetaSlice{solana.Meta(programDataKey).WRITE(), solana.Meta(key).WRITE(), solana.Meta(bufferKey).WRITE(), solana.Meta(payer).WRITE(), solana.Meta(sealevel.SysvarRentAddr), solana.Meta(sealevel.SysvarClockAddr), solana.Meta(payer).SIGNER()}, binary.LittleEndian.AppendUint32(nil, sealevel.UpgradeableLoaderInstrTypeUpgrade)))) + txs = append(txs, signLifecycleInstructions(t, solana.NewInstruction(key, nil, []byte{2}))) + } + return env, txs + } + env, txs := setup() + whole := lifecycleWholeBlock(t, env, txs, 2) + switch kind { + case "nonce": + require.Contains(t, whole.delta, key) + state, err := sealevel.UnmarshalNonceStateVersions(whole.delta[key].Data) + require.NoError(t, err) + require.NotEqual(t, [32]byte{0xAA}, state.State().DurableNonce) + case "lookup extension": + require.Contains(t, whole.delta, lifecycleTableKey) + require.Len(t, whole.delta[lifecycleTableKey].Data, sealevel.AddressLookupTableMetaSize+4*32) + case "vote commission": + require.Contains(t, whole.delta, key) + state, err := sealevel.UnmarshalVersionedVoteState(whole.delta[key].Data) + require.NoError(t, err) + require.Equal(t, byte(5), state.ConvertToCurrent().Commission) + case "program upgrade", "program deployment": + pk := solana.PublicKey{0xC2} + if kind == "program deployment" { + var err error + pk, _, err = solana.FindProgramAddress([][]byte{key[:]}, addresses.BpfLoaderUpgradeableAddr) + require.NoError(t, err) + require.True(t, whole.delta[key].Executable) + } + require.Contains(t, whole.delta, pk) + state, err := sealevel.UnmarshalUpgradeableLoaderState(whole.delta[pk].Data) + require.NoError(t, err) + require.Equal(t, lifecycleSlot, state.ProgramData.Slot) + } + for _, workers := range []int{1, 4} { + for suffix := 0; suffix <= 3; suffix++ { + t.Run(fmt.Sprintf("workers%d/suffix%d", workers, suffix), func(t *testing.T) { + env, txs := setup() + var splits []int + for i := 1; i < 3-suffix; i++ { + splits = append(splits, i) + } + got, _ := lifecycleStream(t, env, txs, splits, suffix, workers) + requireSameLifecycleOutcome(t, whole, got) + }) + } + } + }) + } +} diff --git a/pkg/replay/streaming_realfeed_test.go b/pkg/replay/streaming_realfeed_test.go new file mode 100644 index 000000000..2c86f7b60 --- /dev/null +++ b/pkg/replay/streaming_realfeed_test.go @@ -0,0 +1,494 @@ +package replay + +import ( + "context" + "net" + "testing" + "time" + + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/sigverify" + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/Overclock-Validator/mithril/pkg/turbine" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +// The real-feed gate. A leader-side BroadcastSession shreds a header, entry +// batches (legacy and v0 transfers), a footer and the ending tick; the +// packets cross a loopback UDP socket into a real UDPReceiver (assembler, +// entry prefetch, signature verifier); the receiver's streaming feed drives +// the real streamingExecutor, which opens a bank on the lifecycle +// environment and executes the prefix while the slot is still incomplete; +// the receiver then emits the complete block, finalize matches it, and the +// result must equal whole-block replay of the same block — enforced twice: +// by the footer's expected bank hash inside finalize (the footer carries the +// whole-block reference hash) and by the explicit outcome comparison. + +const ( + realFeedSlot = lifecycleSlot + realFeedParentSlot = lifecycleParentSlot + realFeedShredVersion = uint16(7) + realFeedTickHashByte = 0xEE + realFeedDriveDeadline = 20 * time.Second +) + +// receiverFeed is the block source's view of the feed, backed by a receiver. +type receiverFeed struct { + r *turbine.UDPReceiver + events chan turbine.StreamEvent +} + +func (f *receiverFeed) StreamEvents() <-chan turbine.StreamEvent { return f.events } +func (f *receiverFeed) StreamStatusOf(g turbine.StreamGeneration) turbine.StreamStatus { + return f.r.StreamStatusOf(g) +} +func (f *receiverFeed) PendingStreamBatches(g turbine.StreamGeneration, from uint32) []*turbine.StreamBatch { + return f.r.PendingStreamBatches(g, from) +} +func (f *receiverFeed) PrioritizeStreamRepair(turbine.StreamGeneration) {} + +type realFeedRig struct { + slot uint64 + t *testing.T + env *lifecycleEnv + receiver *turbine.UDPReceiver + feed *receiverFeed + broadcaster *turbine.UDPBroadcaster + leader solana.PrivateKey + lastCtx *sealevel.SlotCtx + tail *lifecycleTail + statuses *TransactionStatusCache + exec *streamingExecutor + block *b.Block + // mark is the loop's record of the executed parent (replayed before any + // shred of the child is broadcast), as the loop would hold it. + mark streamingFrontierMark + // the slot's content, as wire bytes, decoded fresh for every use + legacyWires, v0Wires [][]byte +} + +func newRealFeedRig(t *testing.T, dest solana.PublicKey, eventBuffer int) *realFeedRig { + t.Helper() + previousOverlap := sigverify.Cfg.DisableShredOverlap + sigverify.Cfg.DisableShredOverlap = false // the entry prefetch is what streams + t.Cleanup(func() { sigverify.Cfg.DisableShredOverlap = previousOverlap }) + // The open-age bound is the loop's protection against a stalled slot; a + // loaded CI host must not trip it between two drive calls. + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true, Workers: 2, MaxOpenAge: realFeedDriveDeadline} + t.Cleanup(func() { StreamingExecutionCfg = StreamingExecutionConfig{} }) + // The per-block collector, as the loop resets it before every wait. + previousMetrics := metrics.GlobalBlockReplay + metrics.GlobalBlockReplay = metrics.BlockReplay{} + t.Cleanup(func() { metrics.GlobalBlockReplay = previousMetrics }) + + env := newLifecycleEnvWithTable(t, dest) + rig := &realFeedRig{slot: realFeedSlot, t: t, env: env, leader: solana.NewWallet().PrivateKey} + for i := 0; i < 4; i++ { + rig.legacyWires = append(rig.legacyWires, txfixture.MustSignedTransferWire(uint64(2000+i))) + } + for i := 0; i < 3; i++ { + rig.v0Wires = append(rig.v0Wires, signedV0TransferViaTableWire(t, uint64(30+i))) + } + + // The executed parent, as the loop would hold it: its bank hash is the + // child's ParentBankhash and its sysvar snapshot the child's parent. + rig.lastCtx = &sealevel.SlotCtx{ + Slot: realFeedParentSlot, + Epoch: 0, + FinalBankhash: append([]byte{0x88}, make([]byte, 31)...), + FeeRateGovernor: &sealevel.FeeRateGovernor{TargetLamportsPerSignature: 5_000, LamportsPerSignature: 5_000}, + VoteTimestamps: map[solana.PublicKey]sealevel.BlockTimestamp{}, + } + require.NoError(t, rig.lastCtx.PublishBankSysvars(env.parent)) + + // A real receiver on a loopback port we pick ourselves (the receiver does + // not report an ephemeral bind), fed by a real broadcaster. + probe, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + bindAddr := probe.LocalAddr().String() + require.NoError(t, probe.Close()) + receiver := turbine.NewUDPReceiver(bindAddr) + receiver.SetShredVersion(realFeedShredVersion) + leaderKey := rig.leader.PublicKey() + receiver.SetLeaderForSlot(func(uint64) (solana.PublicKey, bool) { return leaderKey, true }) + rig.feed = &receiverFeed{r: receiver, events: make(chan turbine.StreamEvent, eventBuffer)} + receiver.SubscribeStream(rig.feed.events) + ctx, cancel := context.WithCancel(context.Background()) + runDone := make(chan struct{}) + go func() { _ = receiver.Run(ctx); close(runDone) }() + t.Cleanup(func() { + cancel() + select { + case <-runDone: + case <-time.After(5 * time.Second): + t.Error("receiver did not stop") + } + }) + select { + case err := <-receiver.Ready(): + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("receiver did not become ready") + } + rig.receiver = receiver + udpAddr, err := net.ResolveUDPAddr("udp", bindAddr) + require.NoError(t, err) + rig.broadcaster, err = turbine.NewUDPBroadcaster("127.0.0.1:0") + require.NoError(t, err) + rig.broadcaster.AddPeer(udpAddr) + t.Cleanup(func() { _ = rig.broadcaster.Close() }) + + rig.tail = &lifecycleTail{durable: env.durable} + rig.statuses = NewTransactionStatusCache() + now := time.Now() + rig.mark = streamingFrontierMark{slot: realFeedParentSlot, fullNanos: now.Add(-10 * time.Millisecond).UnixNano(), admittedAt: now.Add(-5 * time.Millisecond), replayedAt: now, waitEnteredAt: now.Add(time.Microsecond)} + rig.exec = newStreamingExecutor(streamingDeps{ + acctsDb: env.acctsDb, + feed: rig.feed, + epochSchedule: env.epochSchedule, + txParallelism: 2, + persistedHashes: &persistedTracker{}, + tail: rig.tail, + transactionStatuses: rig.statuses, + alpenglowMode: true, + unrootedTailUsed: true, + lastSlotCtx: func() *sealevel.SlotCtx { return rig.lastCtx }, + frontier: func() uint64 { return realFeedParentSlot }, + frontierMark: func() streamingFrontierMark { return rig.mark }, + currentFeatures: func() *features.Features { return env.feats }, + currentEpoch: func() uint64 { return 0 }, + rewardsInFlight: func() bool { return false }, + switchPending: func() bool { return false }, + executedBlockID: func(uint64) (solana.Hash, bool) { return lifecycleParentBlockID, true }, + }) + t.Cleanup(rig.exec.shutdown) + return rig +} + +// entries decodes the slot's content into the two entry batches the leader +// broadcasts: legacy transfers, then v0 transfers through the lookup table. +func (rig *realFeedRig) entries() (legacy, v0 []turbine.Entry) { + values := func(wires [][]byte) []solana.Transaction { + out := make([]solana.Transaction, len(wires)) + for i, tx := range decodeWires(rig.t, wires) { + out[i] = *tx + } + return out + } + return []turbine.Entry{{NumHashes: 1, Hash: solana.Hash{0x11}, Txns: values(rig.legacyWires)}}, + []turbine.Entry{{NumHashes: 1, Hash: solana.Hash{0x22}, Txns: values(rig.v0Wires)}} +} + +func (rig *realFeedRig) allWires() [][]byte { + return append(append([][]byte(nil), rig.legacyWires...), rig.v0Wires...) +} + +func decodeWires(t *testing.T, wires [][]byte) []*solana.Transaction { + t.Helper() + txs := make([]*solana.Transaction, len(wires)) + for i, wire := range wires { + tx, err := solana.TransactionFromBytes(wire) + require.NoError(t, err) + txs[i] = tx + } + return txs +} + +// configure applies what the replay loop applies to every block on the +// executed parent before execution. +func (rig *realFeedRig) configure(block *b.Block) { + require.NoError(rig.t, configureBlockFromParent(block, rig.lastCtx, rig.env.epochSchedule, false)) + block.Epoch = rig.env.epochSchedule.GetEpoch(block.Slot) + block.Features = rig.env.feats +} + +// reference executes the slot whole, from freshly decoded objects, exactly as +// the loop would: same parent, same content, same last-entry hash. +func (rig *realFeedRig) reference() lifecycleOutcome { + block := &b.Block{ + Slot: rig.slot, + SourceParentSlot: realFeedParentSlot, + FromLiveStream: true, + AlpenglowParentBlockID: lifecycleParentBlockID, + HasAlpenglowParentBlockID: true, + Transactions: decodeWires(rig.t, rig.allWires()), + Blockhash: solana.Hash{realFeedTickHashByte}, + } + rig.configure(block) + block.MarkTransactionSignaturesVerified() + tail := &lifecycleTail{durable: rig.env.durable} + slotCtx, err := ProcessBlock(rig.env.acctsDb, block, rig.env.epochSchedule, 2, nil, &persistedTracker{}, tail, NewTransactionStatusCache(), false, rig.env.parent) + require.NoError(rig.t, err) + return lifecycleOutcomeOf(rig.t, slotCtx, tail) +} + +func (rig *realFeedRig) session() *turbine.BroadcastSession { + return turbine.NewBroadcastSession(turbine.BroadcastSessionConfig{ + Leader: rig.leader, + Slot: rig.slot, + ParentSlot: realFeedParentSlot, + ParentBlockID: lifecycleParentBlockID, + ParentChainedMerkleRoot: solana.Hash{0xBB}, + Broadcaster: rig.broadcaster, + Version: realFeedShredVersion, + }) +} + +// broadcastPrefix sends the header and both entry batches; the slot stays +// incomplete (no footer, no ending tick). +func (rig *realFeedRig) broadcastPrefix(session *turbine.BroadcastSession) { + legacy, v0 := rig.entries() + require.NoError(rig.t, session.BroadcastHeader(lifecycleParentBlockID)) + require.NoError(rig.t, session.BroadcastEntryBatch(legacy)) + require.NoError(rig.t, session.BroadcastEntryBatch(v0)) +} + +// broadcastCompletion sends the footer carrying the expected bank hash and +// the ending tick, which completes the slot. +func (rig *realFeedRig) broadcastCompletion(session *turbine.BroadcastSession, expectedBankhash []byte) { + require.NoError(rig.t, session.BroadcastFooter(solana.HashFromBytes(expectedBankhash), 1_700_000_000_000_000_000, nil, nil)) + require.NoError(rig.t, session.BroadcastEndingTickLast(solana.Hash{realFeedTickHashByte})) +} + +// drive runs the replay loop's wait as the loop would: feed wake-ups and +// poll ticks go to the executor, a complete block ends the wait. +func (rig *realFeedRig) drive(until func() bool, what string) { + rig.t.Helper() + deadline := time.After(realFeedDriveDeadline) + for !until() { + select { + case event := <-rig.feed.events: + rig.exec.handleEvent(event) + case <-rig.exec.tick(): + rig.exec.handleTick() + case blk, ok := <-rig.receiver.Blocks(): + require.True(rig.t, ok, "receiver closed its block channel") + rig.receiver.AcknowledgeBlockDelivery(blk.Slot) + rig.block = blk + case <-deadline: + reason := metrics.GlobalBlockReplay.StreamingExecution.DiscardReason + rig.t.Fatalf("timed out waiting for %s (stream open: %v, discard reason %q, block: %v)", what, rig.exec.current != nil, reason, rig.block != nil) + } + } +} + +func (rig *realFeedRig) executedPrefix() int { + if rig.exec.current == nil { + return -1 + } + return len(rig.exec.current.origin) +} + +// finalizeAndCompare completes the streamed bank against the emitted block +// and checks it against the whole-block reference. +func (rig *realFeedRig) finalizeAndCompare(reference lifecycleOutcome) { + rig.t.Helper() + block := rig.block + require.NotNil(rig.t, block) + require.Equal(rig.t, rig.slot, block.Slot) + require.True(rig.t, block.HasAlpenglowParentBlockID) + require.Equal(rig.t, lifecycleParentBlockID, solana.Hash(block.AlpenglowParentBlockID)) + require.True(rig.t, block.HasExpectedBankhash, "the footer carried the reference bank hash") + require.Len(rig.t, block.Transactions, len(rig.allWires())) + require.Positive(rig.t, block.ShredFullNanos) + rig.configure(block) + + slotCtx, ok, err := rig.exec.finalize(block, rig.env.parent) + require.NoError(rig.t, err) + require.True(rig.t, ok, "the stream must accept its own block (discard reason %q)", metrics.GlobalBlockReplay.StreamingExecution.DiscardReason) + require.Nil(rig.t, rig.exec.current) + streamed := lifecycleOutcomeOf(rig.t, slotCtx, rig.tail) + requireSameLifecycleOutcome(rig.t, reference, streamed) + require.Equal(rig.t, reference.bankhash, block.ExpectedBankhash[:], "finalize verified the footer hash against the same value") + + record := metrics.GlobalBlockReplay.StreamingExecution + require.Equal(rig.t, uint64(1), record.Opened) + require.Equal(rig.t, uint64(len(rig.allWires())), record.Transactions) + require.Zero(rig.t, record.Discarded, "discard reason %q", record.DiscardReason) + require.Empty(rig.t, record.NotOpenedReason) + + // The open timeline, in order: the parent was replayed before the child's + // header was decoded, the header was seen no earlier than decoded, the + // stream opened after that, executed, and finalized after the last shred. + require.Equal(rig.t, rig.mark.replayedAt.UnixNano(), record.ParentReplayedNanos) + require.Equal(rig.t, rig.mark.admittedAt.UnixNano(), record.ParentAdmittedNanos) + require.Equal(rig.t, rig.mark.fullNanos, record.ParentFullNanos) + require.Less(rig.t, record.ParentReplayedNanos, record.HeaderReadyNanos) + require.LessOrEqual(rig.t, record.HeaderReadyNanos, record.HeaderSeenNanos) + require.LessOrEqual(rig.t, record.HeaderSeenNanos, record.OpenedNanos) + require.LessOrEqual(rig.t, record.OpenedNanos, record.FirstGroupStartNanos) + require.Equal(rig.t, block.ShredFullNanos, record.FullNanos) + require.LessOrEqual(rig.t, record.FullNanos, record.FinalizeStartNanos) + require.Zero(rig.t, record.OpenWaitParentArrival.Count, "the parent was fully received before the header") + require.Zero(rig.t, record.OpenWaitParentReplay.Count, "the parent was replayed before the header") + require.Zero(rig.t, record.OpenWaitParentPreAdmission.Count) + require.Zero(rig.t, record.OpenWaitParentPostAdmission.Count) + require.Equal(rig.t, uint64(1), record.OpenWaitLoop.Count) + require.Equal(rig.t, uint64(record.OpenedNanos-record.HeaderReadyNanos), record.OpenWaitLoop.SumNanoseconds, "with nothing to wait for, the whole open delay is the loop's") + require.Equal(rig.t, rig.mark.waitEnteredAt.UnixNano(), record.WaitEnteredNanos) + // Every group ran before the completion was even broadcast. + require.Positive(rig.t, record.LastGroupEndNanos) + require.LessOrEqual(rig.t, record.LastGroupEndNanos, record.FullNanos) + require.Zero(rig.t, record.TxLoopAfterFull.Count) + require.Zero(rig.t, record.GroupsStraddlingFull) + require.Contains(rig.t, []uint64{uint64(len(rig.legacyWires)), uint64(len(rig.allWires()))}, record.LargestGroupTransactions, "one or two groups, depending on how the batches were pulled") + require.Zero(rig.t, record.OpenWaitPostReplay.Count, "the header arrived after the loop was already waiting") + require.Equal(rig.t, record.OpenWaitLoop, record.OpenWaitDispatch, "…so the loop's delay is all dispatch") +} + +func TestStreamingRealFeedExecutesPrefixBeforeCompletionAndMatchesWholeBlock(t *testing.T) { + dest := solana.PublicKey{0xF1} + rig := newRealFeedRig(t, dest, 64) + reference := rig.reference() + require.Contains(t, reference.delta, dest) + total := len(rig.allWires()) + + session := rig.session() + rig.broadcastPrefix(session) + rig.drive(func() bool { return rig.executedPrefix() == total }, "the prefix to execute") + require.False(t, rig.receiver.SlotCompleted(realFeedSlot), "the slot is still incomplete while the prefix executes") + require.Nil(t, rig.block) + prefixDone := time.Now() + for i, tx := range rig.exec.current.origin[len(rig.legacyWires):] { + require.Equal(t, solana.MessageVersionV0, tx.Message.GetVersion()) + require.False(t, tx.Message.IsResolved(), "block object %d ran as a stream-owned copy", i) + } + sameCopies(t, rig.exec.current.origin, rig.exec.current.exec.transactions) + + rig.broadcastCompletion(session, reference.bankhash) + rig.drive(func() bool { return rig.block != nil }, "the complete block") + require.NotNil(t, rig.exec.current, "the stream survives completion") + require.Equal(t, turbine.StreamDone, rig.receiver.StreamStatusOf(rig.exec.current.generation)) + require.True(t, time.Unix(0, rig.block.ShredFullNanos).After(prefixDone), "the prefix executed before the last shred arrived") + for i, tx := range rig.exec.current.origin { + require.Same(t, rig.block.Transactions[i], tx, "the executed prefix is the block, by identity") + } + + retainedLoader := rig.exec.current.exec.accountLoader + require.Positive(t, retainedLoader.SourceBatch.Count) + // Replay resets the global collector while waiting for the full block. + // Early loader work must survive and be published exactly once. + metrics.GlobalBlockReplay.AccountLoader = metrics.AccountLoader{} + rig.finalizeAndCompare(reference) + require.Equal(t, retainedLoader.SourceBatch, metrics.GlobalBlockReplay.AccountLoader.SourceBatch) + require.Equal(t, retainedLoader.RequestedKeys, metrics.GlobalBlockReplay.AccountLoader.RequestedKeys) + require.Equal(t, retainedLoader.ParentAccounts, metrics.GlobalBlockReplay.AccountLoader.ParentAccounts) + require.Positive(t, metrics.GlobalBlockReplay.StreamingExecution.TxLoopBeforeFull.Count, "transaction work finished before the slot was full") + require.Zero(t, rig.receiver.StreamDroppedEvents()) +} + +// Dropped wake-ups: with room for one event, whichever of the three prefix +// wake-ups (header, two batches) the prefetch publishes first is queued and +// the other two are dropped — the prefetch decodes ranges concurrently, so +// the survivor is not fixed. The executor must reach the same state from any +// of them: a surviving header opens the stream and pulls the batches; a +// surviving batch recovers the header from the assembler, then opens and +// pulls the same way. Nothing is read from the feed until every wake-up has +// been published, so exactly two are dropped. +func TestStreamingRealFeedRecoversDroppedWakeups(t *testing.T) { + dest := solana.PublicKey{0xF2} + rig := newRealFeedRig(t, dest, 1) + reference := rig.reference() + total := len(rig.allWires()) + + session := rig.session() + rig.broadcastPrefix(session) + require.Eventually(t, func() bool { + return rig.receiver.StreamDroppedEvents() == 2 + }, realFeedDriveDeadline, 5*time.Millisecond, "one wake-up queued, two dropped") + var survivor turbine.StreamEvent + select { + case survivor = <-rig.feed.events: + default: + t.Fatal("the surviving wake-up is not queued") + } + var live bool + survivor, live = survivor.Resolve() + require.True(t, live) + require.Zero(t, len(rig.feed.events), "nothing else was published") + require.Equal(t, turbine.StreamBatchReady, survivor.Kind) + require.Equal(t, uint64(realFeedSlot), survivor.Slot) + require.Len(t, rig.receiver.PendingStreamBatches(survivor.Generation, 0), 3, "header and both batches are decoded and discoverable") + require.Nil(t, rig.exec.current) + + rig.exec.handleEvent(survivor) + require.NotNil(t, rig.exec.current, "the surviving wake-up (marker %v at shred %d) opened the stream", survivor.Batch.Marker, survivor.Batch.Start) + require.Equal(t, total, rig.executedPrefix(), "opening pulled every decoded batch from the assembler") + require.Equal(t, uint64(2), rig.receiver.StreamDroppedEvents(), "recovery reads the assembler, it does not replay wake-ups") + require.False(t, rig.receiver.SlotCompleted(realFeedSlot)) + + rig.broadcastCompletion(session, reference.bankhash) + rig.drive(func() bool { return rig.block != nil }, "the complete block") + rig.finalizeAndCompare(reference) +} + +// Reset while streaming: the generation is cancelled, the stream discards +// and leaves nothing behind; the slot re-broadcast is a new generation, which +// opens a new stream and finalizes to the same result. +func TestStreamingRealFeedResetDiscardsAndRenews(t *testing.T) { + dest := solana.PublicKey{0xF3} + rig := newRealFeedRig(t, dest, 64) + reference := rig.reference() + total := len(rig.allWires()) + + stakeBefore := len(global.PendingStakeEntriesSnapshot()) + rig.broadcastPrefix(rig.session()) + rig.drive(func() bool { return rig.executedPrefix() == total }, "the first prefix to execute") + firstGeneration := rig.exec.current.generation + + rig.receiver.ResetSlot(realFeedSlot) + rig.drive(func() bool { return rig.exec.current == nil }, "the cancellation") + // The reset reaches the executor either as the feed's cancellation wake-up + // or, when the poll tick is selected first, as the generation reading gone. + require.Contains(t, []string{"cancelled:reset", "gone"}, metrics.GlobalBlockReplay.StreamingExecution.DiscardReason) + require.Equal(t, turbine.StreamGone, rig.receiver.StreamStatusOf(firstGeneration)) + require.Empty(t, rig.tail.added, "a discarded stream commits nothing") + require.Len(t, global.PendingStakeEntriesSnapshot(), stakeBefore) + durablePayer, err := rig.env.durable.GetAccountWithoutLock(txfixture.PayerPubkey()) + require.NoError(t, err) + require.Equal(t, uint64(10_000_000_000), durablePayer.Lamports, "the durable view is untouched") + // The loop starts the next replay attempt with a fresh collector. + metrics.GlobalBlockReplay = metrics.BlockReplay{} + + session := rig.session() + rig.broadcastPrefix(session) + rig.drive(func() bool { return rig.executedPrefix() == total }, "the renewed prefix to execute") + // Compared as values (a deep comparison would walk the assembler's live + // slot state without its lock). + require.False(t, firstGeneration == rig.exec.current.generation, "a re-assembled slot is a new generation") + rig.broadcastCompletion(session, reference.bankhash) + rig.drive(func() bool { return rig.block != nil }, "the complete block") + rig.finalizeAndCompare(reference) +} + +// The child executes on parent 7 while slots 8..11 are unresolved. Consuming +// those skips advances only the frontier; the completed child must still +// produce exactly the whole-block bank hash, accounts, fees and CU. +func TestStreamingRealFeedAcrossSkippedSlots(t *testing.T) { + rig := newRealFeedRig(t, solana.PublicKey{0xF1}, 64) + rig.slot += 4 + frontier := uint64(realFeedParentSlot) + rig.exec.deps.frontier = func() uint64 { return frontier } + reference := rig.reference() + session := rig.session() + rig.broadcastPrefix(session) + rig.drive(func() bool { return rig.executedPrefix() == len(rig.allWires()) }, "prefix across unresolved skips") + require.Equal(t, uint64(realFeedParentSlot), frontier, "speculation does not certify skips or advance replay") + require.False(t, rig.receiver.SlotCompleted(rig.slot)) + cur := rig.exec.current + for slot := frontier + 1; slot < rig.slot; slot++ { + rig.exec.beforeBlock(&b.Block{Slot: slot, IsSkipped: true}) + rig.exec.discardSlot(slot, "skipped") + frontier = slot + rig.exec.handleTick() + require.Same(t, cur, rig.exec.current) + } + rig.broadcastCompletion(session, reference.bankhash) + rig.drive(func() bool { return rig.block != nil }, "completed child after skips") + rig.finalizeAndCompare(reference) +} diff --git a/pkg/replay/streaming_test.go b/pkg/replay/streaming_test.go new file mode 100644 index 000000000..d716e84a3 --- /dev/null +++ b/pkg/replay/streaming_test.go @@ -0,0 +1,1197 @@ +package replay + +import ( + "context" + "errors" + "sort" + "testing" + "time" + + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/blockstream" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/metrics" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/Overclock-Validator/mithril/pkg/turbine" + "github.com/Overclock-Validator/mithril/pkg/txverify" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +// fakeStreamFeed stands in for the block source's view of the turbine feed. +type fakeStreamFeed struct { + events chan turbine.StreamEvent + status map[turbine.StreamGeneration]turbine.StreamStatus + pending map[turbine.StreamGeneration][]*turbine.StreamBatch + prioritized []uint64 +} + +func newFakeStreamFeed() *fakeStreamFeed { + return &fakeStreamFeed{ + events: make(chan turbine.StreamEvent, 64), + status: make(map[turbine.StreamGeneration]turbine.StreamStatus), + pending: make(map[turbine.StreamGeneration][]*turbine.StreamBatch), + } +} + +func (f *fakeStreamFeed) StreamEvents() <-chan turbine.StreamEvent { return f.events } + +func (f *fakeStreamFeed) StreamStatusOf(g turbine.StreamGeneration) turbine.StreamStatus { + if status, ok := f.status[g]; ok { + return status + } + return turbine.StreamGone +} + +func (f *fakeStreamFeed) PendingStreamBatches(g turbine.StreamGeneration, fromStart uint32) []*turbine.StreamBatch { + var out []*turbine.StreamBatch + for _, batch := range f.pending[g] { + if batch.Start >= fromStart { + out = append(out, batch) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start }) + return out +} + +func (f *fakeStreamFeed) PrioritizeStreamRepair(g turbine.StreamGeneration) { + f.prioritized = append(f.prioritized, g.Slot()) +} + +// fakeUnrootedState satisfies the tail interface for eligibility checks; no +// method is ever called on it. +type fakeUnrootedState struct{ unrootedState } + +// verifiedIdentities runs the real batch verifier so the batches carry the +// identities the assembler's verifier would attach. +func verifiedIdentities(t *testing.T, txs []*solana.Transaction) []txverify.VerifiedMessageIdentity { + t.Helper() + var verifier txverify.BatchVerifier + errs := make([]error, len(txs)) + identities := make([]txverify.VerifiedMessageIdentity, len(txs)) + verifier.VerifyWithMessageIdentities(txs, errs, identities) + for i, err := range errs { + require.NoError(t, err, "fixture transaction %d must verify", i) + } + return identities +} + +// streamingTestHarness is an executor with a stream already open on the group +// execution environment's bank (slot 42 on parent 41), which is what +// openStream would have produced without the bank machinery. +type streamingTestHarness struct { + env *groupExecutionEnv + feed *fakeStreamFeed + exec *streamingExecutor + gen turbine.StreamGeneration + parentID solana.Hash + frontier uint64 + lastCtx *sealevel.SlotCtx +} + +func newStreamingTestHarness(t *testing.T) *streamingTestHarness { + t.Helper() + env := newGroupExecutionEnv(t, 2, 10_000_000_000) + t.Cleanup(env.cleanup) + feed := newFakeStreamFeed() + h := &streamingTestHarness{env: env, feed: feed, parentID: solana.Hash{7, 7, 7}, frontier: 41} + h.gen = turbine.NewDetachedStreamGeneration(env.exec.block.Slot) + feed.status[h.gen] = turbine.StreamActive + h.lastCtx = &sealevel.SlotCtx{Slot: 41, Epoch: env.exec.block.Epoch} + epochSchedule := sealevel.SysvarEpochSchedule{SlotsPerEpoch: 432000, LeaderScheduleSlotOffset: 432000, FirstNormalEpoch: 0, FirstNormalSlot: 0} + h.exec = newStreamingExecutor(streamingDeps{ + feed: feed, + epochSchedule: &epochSchedule, + tail: fakeUnrootedState{}, + transactionStatuses: NewTransactionStatusCache(), + alpenglowMode: true, + unrootedTailUsed: true, + lastSlotCtx: func() *sealevel.SlotCtx { return h.lastCtx }, + frontier: func() uint64 { return h.frontier }, + currentFeatures: func() *features.Features { return env.exec.block.Features }, + currentEpoch: func() uint64 { return env.exec.block.Epoch }, + rewardsInFlight: func() bool { return false }, + switchPending: func() bool { return false }, + executedBlockID: func(slot uint64) (solana.Hash, bool) { + if slot == 41 { + return h.parentID, true + } + return solana.Hash{}, false + }, + }) + env.exec.parentBankSysvars = &sealevel.BankSysvars{} + h.open() + return h +} + +// open installs the stream the way openStream does after its bank opened. +func (h *streamingTestHarness) open() { + h.exec.current = &streamingSlot{ + slot: h.env.exec.block.Slot, + generation: h.gen, + parentSlot: 41, + parentID: h.parentID, + exec: h.env.exec, + pending: make(map[uint32]*turbine.StreamBatch), + headerAt: time.Now(), + openedAt: time.Now(), + } + h.exec.handleEvent(h.event(h.header())) +} + +func (h *streamingTestHarness) header() *turbine.StreamBatch { + return turbine.NewDetachedStreamMarker(h.gen, 0, 0, turbine.StreamMarkerHeader, 41, h.parentID) +} + +func (h *streamingTestHarness) batch(t *testing.T, start, end uint32, txs []*solana.Transaction) *turbine.StreamBatch { + t.Helper() + return turbine.NewDetachedStreamBatch(h.gen, start, end, txs, verifiedIdentities(t, txs)) +} + +func (h *streamingTestHarness) event(batch *turbine.StreamBatch) turbine.StreamEvent { + return turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: batch.Slot, Generation: batch.Generation, Batch: batch} +} + +// executed returns the block objects the stream has executed (in order); the +// bank itself ran stream-owned copies, which are checked to be copies of +// exactly those objects. +func (h *streamingTestHarness) executed() []*solana.Transaction { + if h.exec.current == nil { + return nil + } + return h.exec.current.origin +} + +// sameCopies asserts that executed holds fresh copies of want, in order: the +// same signatures and static keys, never the same objects, and never sharing +// the originals' account-key storage. +func sameCopies(t *testing.T, want, executed []*solana.Transaction) { + t.Helper() + require.Len(t, executed, len(want)) + for i := range want { + require.NotSame(t, want[i], executed[i], "transaction %d must be a copy", i) + require.Equal(t, want[i].Signatures, executed[i].Signatures, "transaction %d signatures", i) + require.Equal(t, want[i].Message.GetVersion(), executed[i].Message.GetVersion()) + require.Equal(t, want[i].Message.RecentBlockhash, executed[i].Message.RecentBlockhash) + if want[i].Message.GetVersion() == solana.MessageVersionV0 { + require.False(t, want[i].Message.IsResolved(), "transaction %d: the block's object must stay untouched", i) + } + if len(want[i].Message.AccountKeys) > 0 { + require.NotSame(t, &want[i].Message.AccountKeys[0], &executed[i].Message.AccountKeys[0], "transaction %d shares account-key storage", i) + } + } +} + +func (h *streamingTestHarness) discardReason() string { + return metrics.GlobalBlockReplay.StreamingExecution.DiscardReason +} + +func sameTransactions(t *testing.T, want, got []*solana.Transaction) { + t.Helper() + require.Len(t, got, len(want)) + for i := range want { + require.Same(t, want[i], got[i], "transaction %d", i) + } +} + +func TestStreamingConsumeExecutesContiguousGroupsInOrder(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 9, 500) + a, bb, c := h.batch(t, 1, 3, txs[0:3]), h.batch(t, 4, 6, txs[3:6]), h.batch(t, 7, 9, txs[6:9]) + + require.Equal(t, uint32(1), h.exec.current.nextStart, "header consumed") + h.exec.handleEvent(h.event(bb)) + require.Empty(t, h.executed(), "a gap before the batch holds it") + h.exec.handleEvent(h.event(a)) + sameTransactions(t, txs[0:6], h.executed()) + sameCopies(t, txs[0:6], h.env.exec.transactions) + require.Len(t, h.exec.current.groups, 1, "contiguous batches execute as one group") + require.Equal(t, uint32(7), h.exec.current.nextStart) + + // Duplicate wake-ups for consumed or pending ranges are ignored. + h.exec.handleEvent(h.event(a)) + h.exec.handleEvent(h.event(bb)) + sameTransactions(t, txs[0:6], h.executed()) + + h.exec.handleEvent(h.event(c)) + sameTransactions(t, txs, h.executed()) + sameCopies(t, txs, h.env.exec.transactions) + require.Len(t, h.exec.current.groups, 2) + require.Equal(t, uint32(10), h.exec.current.nextStart) + require.Equal(t, uint64(9), h.env.exec.processedTxCount) +} + +func TestStreamingTickPullsBatchesMissedByDroppedWakeups(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true, MaxOpenAge: time.Minute} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 6, 600) + h.feed.pending[h.gen] = []*turbine.StreamBatch{h.batch(t, 4, 6, txs[3:6]), h.batch(t, 1, 3, txs[0:3])} + h.exec.handleTick() + sameTransactions(t, txs, h.executed()) + require.Len(t, h.exec.current.groups, 1) +} + +func TestStreamingConsumeHoldsUntilMinGroupBatches(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true, MinGroupBatches: 2} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 9, 700) + a, bb, c := h.batch(t, 1, 3, txs[0:3]), h.batch(t, 4, 6, txs[3:6]), h.batch(t, 7, 9, txs[6:9]) + + h.exec.handleEvent(h.event(a)) + require.Empty(t, h.executed(), "one batch is below the group minimum") + require.Equal(t, uint32(1), h.exec.current.nextStart, "held batch is put back") + h.exec.handleEvent(h.event(bb)) + sameTransactions(t, txs[0:6], h.executed()) + + h.exec.handleEvent(h.event(c)) + require.Len(t, h.executed(), 6, "a lone trailing batch waits") + h.exec.handleEvent(turbine.StreamEvent{Kind: turbine.StreamCompleted, Slot: c.Slot, Generation: h.gen}) + require.True(t, h.exec.current.completed) + sameTransactions(t, txs, h.executed()) +} + +func TestStreamingDiscardsOnUpdateParentDecodeErrorAndUnverified(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 3, 800) + h.exec.handleEvent(h.event(h.batch(t, 1, 3, txs))) + sameTransactions(t, txs, h.executed()) + update := turbine.NewDetachedStreamMarker(h.gen, 4, 4, turbine.StreamMarkerUpdateParent, 40, solana.Hash{1}) + h.exec.handleEvent(h.event(update)) + require.Nil(t, h.exec.current) + require.True(t, h.env.exec.closed, "discard closes the execution") + require.Equal(t, "update_parent", h.discardReason()) + require.Equal(t, uint64(1), metrics.GlobalBlockReplay.StreamingExecution.Discarded) + + h = newStreamingTestHarness(t) + broken := h.batch(t, 1, 3, txs) + broken.Err = errors.New("bad entry") + h.exec.handleEvent(h.event(broken)) + require.Nil(t, h.exec.current) + require.Equal(t, "decode_error", h.discardReason()) + + h = newStreamingTestHarness(t) + unverified := turbine.NewDetachedStreamBatch(h.gen, 1, 3, txs, nil) + _, verified, err := unverified.WaitVerification(context.Background()) + require.NoError(t, err) + require.False(t, verified) + h.exec.handleEvent(h.event(unverified)) + require.Nil(t, h.exec.current, "a batch the verifier did not admit is never self-verified") + require.Equal(t, "unverified_batch", h.discardReason()) + require.Empty(t, h.executed()) +} + +func TestStreamingGroupFailureDiscards(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 4, 900) + h.exec.executeFn = func(exec *blockExecution, group []*solana.Transaction, identities *b.PreparedTransactionMessageIdentities, shouldVerify bool) error { + require.False(t, shouldVerify, "verified batches never re-verify") + return &DuplicateTransactionMessagesError{Slot: exec.block.Slot, DuplicateCount: 1} + } + h.exec.handleEvent(h.event(h.batch(t, 1, 2, txs[:2]))) + require.Nil(t, h.exec.current) + require.Equal(t, "duplicate_message", h.discardReason()) +} + +func TestStreamingIgnoresOtherGenerationsAndHonoursCancellation(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 3, 1000) + other := turbine.NewDetachedStreamGeneration(h.env.exec.block.Slot) + foreign := turbine.NewDetachedStreamBatch(other, 1, 3, txs, verifiedIdentities(t, txs)) + h.exec.handleEvent(turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: foreign.Slot, Generation: other, Batch: foreign}) + require.Empty(t, h.executed(), "another generation's batches are not this stream's") + require.NotNil(t, h.exec.current) + + h.exec.handleEvent(turbine.StreamEvent{Kind: turbine.StreamCancelled, Slot: foreign.Slot, Generation: other, Reason: "reset"}) + require.NotNil(t, h.exec.current, "another generation's cancellation is ignored") + + h.exec.handleEvent(turbine.StreamEvent{Kind: turbine.StreamCancelled, Slot: h.env.exec.block.Slot, Generation: h.gen, Reason: "reset"}) + require.Nil(t, h.exec.current) + require.Equal(t, "cancelled:reset", h.discardReason()) +} + +func TestStreamingTickEnforcesStatusAndAge(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true, MaxOpenAge: 50 * time.Millisecond} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + + h := newStreamingTestHarness(t) + h.feed.status[h.gen] = turbine.StreamGone + h.exec.handleTick() + require.Nil(t, h.exec.current) + require.Equal(t, "gone", h.discardReason()) + + h = newStreamingTestHarness(t) + h.exec.current.openedAt = time.Now().Add(-time.Second) + h.exec.handleTick() + require.Nil(t, h.exec.current) + require.Equal(t, "timeout", h.discardReason()) + + h = newStreamingTestHarness(t) + h.feed.status[h.gen] = turbine.StreamDone + h.exec.current.openedAt = time.Now().Add(-100 * time.Millisecond) + h.exec.handleTick() + require.NotNil(t, h.exec.current, "a completed stream outlives MaxOpenAge while its block is emitted") + require.True(t, h.exec.current.completed) + h.exec.current.openedAt = time.Now().Add(-time.Minute) + h.exec.handleTick() + require.Nil(t, h.exec.current, "but not the hard cap") + require.Equal(t, "timeout", h.discardReason()) +} + +func TestStreamingDiscardUndoesGlobalSideEffects(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + slot := h.env.exec.block.Slot + slotCtx := h.env.exec.slotCtx + slotCtx.DeferVoteCachePublication = true + voteKey := solana.PublicKey{9} + putVoteCacheItem(slotCtx, voteKey, &sealevel.VoteStateVersions{}) + markSlotVoteStakeDirty(slotCtx) + require.Nil(t, global.VoteCacheItem(voteKey), "deferred put stays off the global cache") + before := len(global.PendingStakeEntriesSnapshot()) + global.EnqueuePendingStakePubkey(slot, solana.PublicKey{8}) + require.Len(t, global.PendingStakeEntriesSnapshot(), before+1) + + h.exec.discard("test") + require.Nil(t, slotCtx.PendingVoteCache) + require.False(t, slotCtx.VoteStakeDirty) + require.Nil(t, global.VoteCacheItem(voteKey)) + require.Len(t, global.PendingStakeEntriesSnapshot(), before, "the stream's stake index entries are dropped") + require.False(t, h.exec.matches(slot)) + require.Nil(t, h.exec.tick(), "no poll timer while idle") +} + +func TestStreamingEligibility(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + h.exec.discard("reset for eligibility") + header := h.header() + require.Equal(t, "", h.exec.eligibility(header)) + + cases := []struct { + name string + mutate func() + want string + }{ + {"generation gone", func() { h.feed.status[h.gen] = turbine.StreamGone }, "generation no longer active"}, + {"generation done", func() { h.feed.status[h.gen] = turbine.StreamDone }, "generation no longer active"}, + {"no parent context", func() { h.lastCtx = nil }, "no executed parent context"}, + {"frontier moved", func() { h.frontier = 42 }, "slot 42 on parent 41 does not extend the executed frontier 42 (parent context 41)"}, + {"parent is not the executed slot", func() { h.lastCtx = &sealevel.SlotCtx{Slot: 40} }, "slot 42 on parent 41 does not extend the executed frontier 41 (parent context 40)"}, + {"parent id mismatch", func() { h.parentID = solana.Hash{1} }, "parent block id does not match the executed parent"}, + {"switch pending", func() { h.exec.deps.switchPending = func() bool { return true } }, "fork switch pending"}, + {"epoch boundary", func() { h.exec.deps.currentEpoch = func() uint64 { return 99 } }, "epoch boundary"}, + {"rewards", func() { h.exec.deps.rewardsInFlight = func() bool { return true } }, "partitioned rewards in flight"}, + {"no features", func() { h.exec.deps.currentFeatures = func() *features.Features { return nil } }, "no feature set"}, + {"no tail", func() { h.exec.deps.tail = nil }, "requires alpenglow rooted-durable replay"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saved := *h + savedDeps := h.exec.deps + savedStatus := h.feed.status[h.gen] + tc.mutate() + require.Equal(t, tc.want, h.exec.eligibility(header)) + *h = saved + h.exec.deps = savedDeps + h.feed.status[h.gen] = savedStatus + }) + } +} + +func TestStreamingRememberHeaderAndTryOpenBounds(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + h.exec.discard("idle") + + stale := turbine.NewDetachedStreamMarker(turbine.NewDetachedStreamGeneration(41), 0, 0, turbine.StreamMarkerHeader, 40, solana.Hash{}) + h.exec.handleEvent(h.event(stale)) + require.Empty(t, h.exec.headers, "headers at or below the frontier are not kept") + + ahead := turbine.NewDetachedStreamMarker(turbine.NewDetachedStreamGeneration(44), 0, 0, turbine.StreamMarkerHeader, 43, solana.Hash{}) + h.exec.handleEvent(h.event(ahead)) + require.Contains(t, h.exec.headers, uint64(44), "a header ahead of the frontier waits for its parent") + require.Nil(t, h.exec.current) + + // The next slot's header is ineligible (its generation is unknown to the + // feed), so it is dropped rather than opened; nothing else changes. + next := turbine.NewDetachedStreamMarker(turbine.NewDetachedStreamGeneration(42), 0, 0, turbine.StreamMarkerHeader, 41, h.parentID) + h.exec.handleEvent(h.event(next)) + require.Nil(t, h.exec.current) + require.NotContains(t, h.exec.headers, uint64(42)) + require.Contains(t, h.exec.headers, uint64(44)) + + h.frontier = 44 + h.exec.handleTick() + require.Empty(t, h.exec.headers, "advancing the frontier past a remembered header drops it") + + StreamingExecutionCfg.Enabled = false + h.frontier = 41 + h.exec.headers[42] = next + h.exec.tryOpen() + require.Contains(t, h.exec.headers, uint64(42), "disabled: nothing opens") +} + +// A dropped header wake-up is recovered from the assembler by the next +// wake-up for the generation (the pending list is authoritative after a +// drop); the usual open path follows. The lookup is only made for the slot +// within the bounded lookahead, never for a generation this executor retired, and only finds +// a header that is decoded. +func TestStreamingRecoversHeaderFromPendingAfterDroppedWakeup(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 3, 700) + g := turbine.NewDetachedStreamGeneration(42) + header := turbine.NewDetachedStreamMarker(g, 0, 0, turbine.StreamMarkerHeader, 41, h.parentID) + batch := turbine.NewDetachedStreamBatch(g, 1, 3, txs, verifiedIdentities(t, txs)) + h.feed.pending[g] = []*turbine.StreamBatch{batch, header} + + h.exec.recoverHeader(42, g) + require.Empty(t, h.exec.headers, "the open stream's own slot is not looked up (its successor is; see below)") + + h.exec.discard("idle") + require.Equal(t, h.gen, h.exec.retired[42], "the discarded generation is retired") + h.exec.recoverHeader(42, g) + require.Same(t, header, h.exec.headers[42], "the batch wake-up recovered the decoded header") + + // Declined once (the feed does not know the generation, so it is + // ineligible), the generation is retired and no later wake-up brings it + // back; whole-block execution owns the slot. + h.exec.tryOpen() + require.Nil(t, h.exec.current) + require.Empty(t, h.exec.headers) + require.Equal(t, g, h.exec.retired[42]) + h.exec.recoverHeader(42, g) + require.Empty(t, h.exec.headers, "a retired generation is never recovered") + + // A new generation of the slot (after a reset) is recoverable again, but + // only once its header is decoded. + renewed := turbine.NewDetachedStreamGeneration(42) + renewedHeader := turbine.NewDetachedStreamMarker(renewed, 0, 0, turbine.StreamMarkerHeader, 41, h.parentID) + h.feed.pending[renewed] = []*turbine.StreamBatch{turbine.NewDetachedStreamBatch(renewed, 1, 3, txs, verifiedIdentities(t, txs))} + h.exec.recoverHeader(42, renewed) + require.Empty(t, h.exec.headers, "a header that is not decoded yet cannot be recovered") + h.feed.pending[renewed] = append(h.feed.pending[renewed], renewedHeader) + h.exec.recoverHeader(42, renewed) + require.Same(t, renewedHeader, h.exec.headers[42]) + delete(h.exec.headers, 42) + + ahead := turbine.NewDetachedStreamGeneration(44) + h.feed.pending[ahead] = []*turbine.StreamBatch{turbine.NewDetachedStreamMarker(ahead, 0, 0, turbine.StreamMarkerHeader, 43, solana.Hash{})} + h.exec.recoverHeader(44, ahead) + require.Contains(t, h.exec.headers, uint64(44), "nearby header can be recovered before its parent is ready") + delete(h.exec.headers, 44) + + h.exec.recoverHeader(42, turbine.StreamGeneration{}) + require.Empty(t, h.exec.headers, "a zero generation has nothing pending") + + // While a stream is open, its successor is the slot that could open next. + h.frontier = 42 + h.exec.pruneHeaders(h.frontier) + require.Empty(t, h.exec.retired, "retirements at or below the frontier are pruned") + h.frontier = 41 + h.open() + successor := turbine.NewDetachedStreamGeneration(43) + successorHeader := turbine.NewDetachedStreamMarker(successor, 0, 0, turbine.StreamMarkerHeader, 42, solana.Hash{1}) + h.feed.pending[successor] = []*turbine.StreamBatch{successorHeader} + h.exec.recoverHeader(43, successor) + require.Same(t, successorHeader, h.exec.headers[43], "the open stream's successor is recoverable") + require.NotNil(t, h.exec.current, "the open stream is untouched") +} + +func (h *streamingTestHarness) matchingBlock(t *testing.T, txs []*solana.Transaction) *b.Block { + t.Helper() + shell := h.env.exec.block + block := &b.Block{ + Slot: shell.Slot, + Epoch: shell.Epoch, + ParentSlot: shell.ParentSlot, + ParentBankhash: shell.ParentBankhash, + Features: shell.Features, + FromLiveStream: true, + SourceParentSlot: 41, + AlpenglowParentBlockID: h.parentID, + HasAlpenglowParentBlockID: true, + Transactions: txs, + } + return block +} + +func TestStreamingHandshake(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 4, 1100) + h.exec.handleEvent(h.event(h.batch(t, 1, 3, txs[:3]))) + sameTransactions(t, txs[:3], h.executed()) + sysvars := h.env.exec.parentBankSysvars + + require.Equal(t, "", h.exec.handshake(h.matchingBlock(t, txs), sysvars), "the block extends the executed prefix") + require.Equal(t, "", h.exec.handshake(h.matchingBlock(t, txs[:3]), sysvars), "the block may end exactly at the prefix") + + cases := []struct { + name string + mutate func(block *b.Block) + want string + }{ + {"slot", func(block *b.Block) { block.Slot++ }, "slot"}, + {"skipped", func(block *b.Block) { block.IsSkipped = true }, "not a live block"}, + {"rpc block", func(block *b.Block) { block.FromLiveStream = false }, "not a live block"}, + {"parent id", func(block *b.Block) { block.AlpenglowParentBlockID = [32]byte{1} }, "parent"}, + {"parent slot", func(block *b.Block) { block.SourceParentSlot = 40 }, "parent"}, + {"configured parent", func(block *b.Block) { block.ParentBankhash = [32]byte{2} }, "configured parent"}, + {"features", func(block *b.Block) { block.Features = features.NewFeaturesDefault() }, "features"}, + {"epoch", func(block *b.Block) { block.Epoch++ }, "epoch"}, + {"epoch accounts", func(block *b.Block) { block.EpochUpdatedAccts = append(block.EpochUpdatedAccts, nil) }, "epoch account updates"}, + {"shorter", func(block *b.Block) { block.Transactions = block.Transactions[:2] }, "shorter than executed prefix"}, + {"different transaction", func(block *b.Block) { + replacement := transferTransactions(t, 1, 1101)[0] // same bytes as txs[1], different object + block.Transactions[1] = replacement + }, "transaction 1 identity"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + block := h.matchingBlock(t, append([]*solana.Transaction(nil), txs...)) + tc.mutate(block) + require.Equal(t, tc.want, h.exec.handshake(block, sysvars)) + }) + } + require.Equal(t, "parent sysvars", h.exec.handshake(h.matchingBlock(t, txs), &sealevel.BankSysvars{})) + require.Equal(t, "parent sysvars", h.exec.handshake(h.matchingBlock(t, txs), nil)) + h.feed.status[h.gen] = turbine.StreamGone + require.Equal(t, "generation gone", h.exec.handshake(h.matchingBlock(t, txs), sysvars)) +} + +func TestStreamingFinalizeFallsBackOnMismatch(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 3, 1200) + h.exec.handleEvent(h.event(h.batch(t, 1, 3, txs))) + block := h.matchingBlock(t, txs) + block.Transactions[0] = transferTransactions(t, 1, 1200)[0] + + slotCtx, ok, err := h.exec.finalize(block, h.env.exec.parentBankSysvars) + require.NoError(t, err) + require.False(t, ok, "the caller must execute the block whole") + require.Nil(t, slotCtx) + require.Nil(t, h.exec.current) + require.Equal(t, "prefix_mismatch:transaction 0 identity", h.discardReason()) + require.True(t, h.env.exec.closed) + + _, ok, err = h.exec.finalize(block, nil) + require.NoError(t, err) + require.False(t, ok, "no stream, nothing to finalize") + require.False(t, h.exec.matches(block.Slot)) +} + +// finalizeFailureHarness prepares a stream whose executed prefix matches the +// block (three of four transfers executed) and instruments every undo hook, +// so a post-handshake failure can be checked for the discard contract. +type finalizeFailureHarness struct { + *streamingTestHarness + txs []*solana.Transaction + restores int + voteKey solana.PublicKey + stakeBefore int + openedPending int +} + +func newFinalizeFailureHarness(t *testing.T) *finalizeFailureHarness { + t.Helper() + h := &finalizeFailureHarness{streamingTestHarness: newStreamingTestHarness(t), voteKey: solana.PublicKey{3, 3, 3}} + h.txs = transferTransactions(t, 4, 1300) + h.exec.handleEvent(h.event(h.batch(t, 1, 3, h.txs[:3]))) + sameTransactions(t, h.txs[:3], h.executed()) + h.exec.current.restoreSysvarCache = func() { h.restores++ } + slotCtx := h.env.exec.slotCtx + slotCtx.DeferVoteCachePublication = true + slotCtx.TrackProgramCacheAdds = true + putVoteCacheItem(slotCtx, h.voteKey, &sealevel.VoteStateVersions{}) + slotCtx.RecordProgramCacheAdd(solana.PublicKey{4}) + h.stakeBefore = len(global.PendingStakeEntriesSnapshot()) + global.EnqueuePendingStakePubkey(h.env.exec.block.Slot, solana.PublicKey{5}) + return h +} + +func (h *finalizeFailureHarness) assertUndone(t *testing.T, reason string) { + t.Helper() + require.Nil(t, h.exec.current, "the stream no longer owns a bank") + require.True(t, h.env.exec.closed, "the execution is closed") + require.Equal(t, reason, h.discardReason()) + require.Equal(t, 1, h.restores, "the legacy sysvar cache is restored once") + require.Nil(t, global.VoteCacheItem(h.voteKey), "unpublished vote-cache entries never reach the global cache") + require.Nil(t, h.env.exec.slotCtx.PendingVoteCache) + require.Empty(t, h.env.exec.slotCtx.TakeProgramCacheAdds(), "tracked program-cache adds were consumed by the undo") + require.Len(t, global.PendingStakeEntriesSnapshot(), h.stakeBefore, "the slot's stake index entries are dropped") + require.Nil(t, h.exec.tick()) +} + +func TestStreamingFinalizeSuffixFailureUndoesTheStream(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newFinalizeFailureHarness(t) + suffixErr := errors.New("suffix exploded") + calls := 0 + h.exec.executeFn = func(exec *blockExecution, group []*solana.Transaction, identities *b.PreparedTransactionMessageIdentities, shouldVerify bool) error { + calls++ + sameTransactions(t, h.txs[3:], group) + require.Equal(t, 1, identities.Len()) + require.True(t, shouldVerify, "an unmarked block's suffix is verified like any whole block") + return suffixErr + } + block := h.matchingBlock(t, h.txs) + + slotCtx, ok, err := h.exec.finalize(block, h.env.exec.parentBankSysvars) + require.True(t, ok, "the handshake passed: the failure is the block's") + require.Nil(t, slotCtx) + require.ErrorIs(t, err, suffixErr) + var final *streamingFinalizeError + require.ErrorAs(t, err, &final) + require.Equal(t, 1, calls) + h.assertUndone(t, "finalize:suffix") +} + +func TestStreamingFinalizeFooterRejectionUndoesTheStream(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newFinalizeFailureHarness(t) + // An Alpenglow bank requires the footer before it executes the suffix; a + // live block without one is rejected exactly as ProcessBlock rejects it. + h.exec.deps.alpenglowClock = true + h.env.exec.block.Features.EnableFeature(features.AlpenglowDevContext, 0) + h.exec.executeFn = func(*blockExecution, []*solana.Transaction, *b.PreparedTransactionMessageIdentities, bool) error { + t.Fatal("the suffix must not execute after a footer rejection") + return nil + } + block := h.matchingBlock(t, h.txs) + require.False(t, block.HasAlpenglowFooter) + + slotCtx, ok, err := h.exec.finalize(block, h.env.exec.parentBankSysvars) + require.True(t, ok) + require.Nil(t, slotCtx) + require.ErrorContains(t, err, "missing block footer") + h.assertUndone(t, "finalize:footer_clock") +} + +func TestStreamingFinalizeProcessedCountMismatchUndoesTheStream(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newFinalizeFailureHarness(t) + // A suffix that "succeeds" without recording its transactions leaves the + // executed counts short of the whole-block plan. + h.exec.executeFn = func(*blockExecution, []*solana.Transaction, *b.PreparedTransactionMessageIdentities, bool) error { + return nil + } + block := h.matchingBlock(t, h.txs) + + _, ok, err := h.exec.finalize(block, h.env.exec.parentBankSysvars) + require.True(t, ok) + require.ErrorContains(t, err, "processed 3 transactions") + h.assertUndone(t, "finalize:counts") +} + +// recordingStreamer is the wait loop's view of an executor. +type recordingStreamer struct { + ch chan turbine.StreamEvent + tickCh chan time.Time + handled []turbine.StreamEvent + ticks int +} + +func (r *recordingStreamer) events() <-chan turbine.StreamEvent { return r.ch } +func (r *recordingStreamer) tick() <-chan time.Time { return r.tickCh } +func (r *recordingStreamer) handleEvent(e turbine.StreamEvent) { r.handled = append(r.handled, e) } +func (r *recordingStreamer) handleTick() { r.ticks++ } + +func TestWaitForReplayInputDispatchesStreamWakeups(t *testing.T) { + streamer := &recordingStreamer{ch: make(chan turbine.StreamEvent, 4), tickCh: make(chan time.Time, 4)} + block := &b.Block{Slot: 5} + script := []blockstream.ReplayInput{ + {StreamEvent: &turbine.StreamEvent{Kind: turbine.StreamBatchReady, Slot: 6}}, + {StreamTick: true}, + {DecisionChanged: true}, + {StreamEvent: &turbine.StreamEvent{Kind: turbine.StreamCompleted, Slot: 6}}, + {Block: block}, + } + var seenEvents []<-chan turbine.StreamEvent + var seenTicks []<-chan time.Time + next := func(ctx context.Context, decisionChanges <-chan struct{}, events <-chan turbine.StreamEvent, tick <-chan time.Time) blockstream.ReplayInput { + seenEvents = append(seenEvents, events) + seenTicks = append(seenTicks, tick) + in := script[0] + script = script[1:] + return in + } + sweeps := 0 + sweep := func() *CertifiedSwitch { sweeps++; return nil } + got, parentSwitch, sw := waitForReplayInput(context.Background(), next, sweep, make(chan struct{}), time.Second, streamer) + require.Same(t, block, got) + require.Nil(t, parentSwitch) + require.Nil(t, sw) + require.Len(t, streamer.handled, 2, "feed wake-ups are handled and never end the wait") + require.Equal(t, turbine.StreamCompleted, streamer.handled[1].Kind) + require.Equal(t, 2, streamer.ticks, "one tick on entry, one from the timer") + require.Equal(t, 5, sweeps, "every wait is preceded by a sweep") + for _, ch := range seenEvents { + require.Equal(t, (<-chan turbine.StreamEvent)(streamer.ch), ch) + } + for _, ch := range seenTicks { + require.Equal(t, (<-chan time.Time)(streamer.tickCh), ch) + } +} + +func TestWaitForReplayInputStreamerWithoutSweepBlocksWithoutPolling(t *testing.T) { + streamer := &recordingStreamer{ch: make(chan turbine.StreamEvent, 1)} + calls := 0 + next := func(ctx context.Context, decisionChanges <-chan struct{}, events <-chan turbine.StreamEvent, tick <-chan time.Time) blockstream.ReplayInput { + calls++ + require.Nil(t, decisionChanges, "decision wake-ups stay disabled without a sweep") + _, hasDeadline := ctx.Deadline() + require.False(t, hasDeadline, "no poll timeout without a sweep") + if calls == 1 { + return blockstream.ReplayInput{StreamTick: true} + } + return blockstream.ReplayInput{} + } + block, parentSwitch, sw := waitForReplayInput(context.Background(), next, nil, make(chan struct{}), time.Second, streamer) + require.Nil(t, block) + require.Nil(t, parentSwitch) + require.Nil(t, sw) + require.Equal(t, 2, calls) + require.Equal(t, 2, streamer.ticks) +} + +func TestWaitForReplayInputWithoutStreamerIsUnchanged(t *testing.T) { + block := &b.Block{Slot: 9} + next := func(ctx context.Context, decisionChanges <-chan struct{}, events <-chan turbine.StreamEvent, tick <-chan time.Time) blockstream.ReplayInput { + require.Nil(t, events) + require.Nil(t, tick) + require.Nil(t, decisionChanges) + return blockstream.ReplayInput{Block: block} + } + got, _, sw := waitForReplayInput(context.Background(), next, nil, make(chan struct{}), time.Second, nil) + require.Same(t, block, got) + require.Nil(t, sw) + + var nilStreamer *streamingExecutor + require.Nil(t, nilStreamer.tick()) + require.Nil(t, nilStreamer.events()) + require.False(t, nilStreamer.matches(1)) + nilStreamer.handleTick() + nilStreamer.discard("noop") + nilStreamer.discardSlot(1, "noop") + nilStreamer.shutdown() + _, ok, err := nilStreamer.finalize(block, nil) + require.False(t, ok) + require.NoError(t, err) +} + +func TestStreamingConfigDefaults(t *testing.T) { + var cfg StreamingExecutionConfig + require.Equal(t, defaultStreamingWorkers, cfg.workers(0)) + require.Equal(t, 2, cfg.workers(2)) + require.Equal(t, defaultStreamingWorkers, cfg.workers(64)) + cfg.Workers = 8 + require.Equal(t, 8, cfg.workers(0)) + require.Equal(t, 3, cfg.workers(3)) + require.Equal(t, defaultStreamingMaxAge, cfg.maxOpenAge()) + cfg.MaxOpenAge = time.Second + require.Equal(t, time.Second, cfg.maxOpenAge()) +} + +func TestStreamingGapSelectionAndSafety(t *testing.T) { + h := newStreamingTestHarness(t) + defer h.exec.shutdown() + makeHeader := func(slot, parent uint64, id solana.Hash) *turbine.StreamBatch { + g := turbine.NewDetachedStreamGeneration(slot) + h.feed.status[g] = turbine.StreamActive + return turbine.NewDetachedStreamMarker(g, 0, 0, turbine.StreamMarkerHeader, parent, id) + } + gap := makeHeader(46, 41, h.parentID) + h.exec.headers[46] = gap + h.exec.headers[48] = makeHeader(48, 41, h.parentID) + h.exec.headers[44] = makeHeader(44, 43, h.parentID) + require.Same(t, gap, h.exec.nextHeader(h.frontier), "earliest direct child, not a grandchild") + require.Empty(t, h.exec.eligibility(gap)) + require.NotEmpty(t, h.exec.eligibility(makeHeader(46, 41, solana.Hash{99}))) + require.NotEmpty(t, h.exec.eligibility(makeHeader(46, 40, h.parentID))) + require.NotEmpty(t, h.exec.eligibility(makeHeader(74, 41, h.parentID)), "lookahead is bounded") + h.exec.deps.switchPending = func() bool { return true } + require.Equal(t, "fork switch pending", h.exec.eligibility(gap)) + h.exec.deps.switchPending = func() bool { return false } + h.exec.headers[42] = makeHeader(42, 41, h.parentID) + require.Equal(t, uint64(42), h.exec.nextHeader(h.frontier).Slot) + + // A late real bank in the unresolved gap must restore all speculative + // state before that bank is validated, configured or executed. + h.exec.current.slot = 46 + cur := h.exec.current + h.exec.beforeBlock(&b.Block{Slot: 42, IsSkipped: true}) + require.Same(t, cur, h.exec.current) + h.exec.beforeBlock(&b.Block{Slot: 42}) + require.Nil(t, h.exec.current) + require.True(t, cur.exec.closed) + require.Equal(t, h.gen, h.exec.retired[46]) +} + +// The open timeline attributes every millisecond between the header's decode +// and the open to exactly one of: the parent's arrival (header decoded before +// the parent's last shred), the parent's replay (last shred → replay result), +// or the loop (nothing left to wait for, still not opened). +func TestStreamingTimelineAttributesOpenDelay(t *testing.T) { + t0 := time.Unix(1_700_000_000, 0) + at := func(ms int) time.Time { return t0.Add(time.Duration(ms) * time.Millisecond) } + block := &b.Block{ShredFullNanos: at(300).UnixNano()} + record := func(cur *streamingSlot) metrics.StreamingExecution { + var out metrics.StreamingExecution + cur.recordTimeline(&out, block, at(320)) + return out + } + ms := func(timing metrics.Timing) float64 { return float64(timing.SumNanoseconds) / 1e6 } + + // Header decoded 50 ms before the parent's last shred, parent admitted + // 20 ms after that and replayed 10 ms later, opened 5 ms after; the + // header wake-up was handled 10 ms after decode. + cur := &streamingSlot{ + headerAt: at(0), headerSeenAt: at(10), openedAt: at(85), + parentFullNanos: at(50).UnixNano(), parentAdmittedAt: at(70), parentReplayedAt: at(80), + groups: []streamingGroup{{startedAt: at(90), finishedAt: at(120), transactions: 3}}, + } + r := record(cur) + require.Equal(t, at(0).UnixNano(), r.HeaderReadyNanos) + require.Equal(t, at(10).UnixNano(), r.HeaderSeenNanos) + require.Equal(t, at(50).UnixNano(), r.ParentFullNanos) + require.Equal(t, at(70).UnixNano(), r.ParentAdmittedNanos) + require.Equal(t, at(80).UnixNano(), r.ParentReplayedNanos) + require.Equal(t, at(85).UnixNano(), r.OpenedNanos) + require.Equal(t, at(90).UnixNano(), r.FirstGroupStartNanos) + require.Equal(t, at(300).UnixNano(), r.FullNanos) + require.Equal(t, at(320).UnixNano(), r.FinalizeStartNanos) + require.Equal(t, 50.0, ms(r.OpenWaitParentArrival)) + require.Equal(t, 30.0, ms(r.OpenWaitParentReplay)) + require.Equal(t, 20.0, ms(r.OpenWaitParentPreAdmission), "the parent sat in the source 20 ms after its last shred") + require.Equal(t, 10.0, ms(r.OpenWaitParentPostAdmission)) + require.Equal(t, 5.0, ms(r.OpenWaitLoop)) + require.Equal(t, uint64(1), r.OpenWaitLoop.Count) + + // The parent was admitted before the child's header was decoded (its + // queueing cannot have held the child): the replay wait is all execution. + cur = &streamingSlot{headerAt: at(0), headerSeenAt: at(1), openedAt: at(40), parentFullNanos: at(-30).UnixNano(), parentAdmittedAt: at(-5), parentReplayedAt: at(30)} + r = record(cur) + require.Zero(t, r.OpenWaitParentPreAdmission.Count) + require.Equal(t, 30.0, ms(r.OpenWaitParentPostAdmission)) + require.Equal(t, 30.0, ms(r.OpenWaitParentReplay)) + + // Parent fully received before the header was even decoded: no arrival + // wait; the parent's replay wait starts at the header. + cur = &streamingSlot{headerAt: at(0), headerSeenAt: at(1), openedAt: at(40), parentFullNanos: at(-20).UnixNano(), parentReplayedAt: at(30)} + r = record(cur) + require.Zero(t, r.OpenWaitParentArrival.Count) + require.Equal(t, 30.0, ms(r.OpenWaitParentReplay)) + require.Equal(t, 10.0, ms(r.OpenWaitLoop)) + + // Header handled only after the parent was replayed (the wake-up sat in + // the channel): include the queued wake-up before the header was seen. + cur = &streamingSlot{headerAt: at(0), headerSeenAt: at(90), openedAt: at(95), parentFullNanos: at(20).UnixNano(), parentReplayedAt: at(60)} + r = record(cur) + require.Equal(t, 20.0, ms(r.OpenWaitParentArrival)) + require.Equal(t, 40.0, ms(r.OpenWaitParentReplay)) + require.Equal(t, 35.0, ms(r.OpenWaitLoop)) + require.Equal(t, 95.0, ms(r.OpenWaitParentArrival)+ms(r.OpenWaitParentReplay)+ms(r.OpenWaitLoop)) + + // The loop wait splits at the first wait entry after the parent: a header + // remembered while the parent executed waited through the parent's + // post-replay tail (replayed → wait entry) and then its dispatch (wait + // entry → opened). + cur = &streamingSlot{headerAt: at(0), headerSeenAt: at(5), openedAt: at(130), parentFullNanos: at(-100).UnixNano(), parentReplayedAt: at(60), waitEnteredAt: at(125)} + r = record(cur) + require.Equal(t, at(125).UnixNano(), r.WaitEnteredNanos) + require.Equal(t, 70.0, ms(r.OpenWaitLoop)) + require.Equal(t, 65.0, ms(r.OpenWaitPostReplay)) + require.Equal(t, 5.0, ms(r.OpenWaitDispatch)) + + // A header seen only after the wait entry (it arrived while the loop was + // already waiting) includes both the tail and queued header dispatch. + cur = &streamingSlot{headerAt: at(0), headerSeenAt: at(140), openedAt: at(141), parentFullNanos: at(-100).UnixNano(), parentReplayedAt: at(60), waitEnteredAt: at(125)} + r = record(cur) + require.Equal(t, 65.0, ms(r.OpenWaitPostReplay)) + require.Equal(t, 16.0, ms(r.OpenWaitDispatch)) + require.Equal(t, 81.0, ms(r.OpenWaitLoop)) + require.Contains(t, cur.openTimeline(), "wait entered +125.0ms") + + // Unknown parent instants (no mark, or a re-based frontier): only the + // loop wait, from the header being seen. + cur = &streamingSlot{headerAt: at(0), headerSeenAt: at(0), openedAt: at(70)} + r = record(cur) + require.Zero(t, r.ParentFullNanos) + require.Zero(t, r.ParentReplayedNanos) + require.Zero(t, r.OpenWaitParentArrival.Count) + require.Zero(t, r.OpenWaitParentReplay.Count) + require.Zero(t, r.OpenWaitParentPreAdmission.Count) + require.Zero(t, r.OpenWaitParentPostAdmission.Count) + require.Equal(t, 70.0, ms(r.OpenWaitLoop)) + require.Zero(t, r.OpenWaitPostReplay.Count) + require.Zero(t, r.OpenWaitDispatch.Count) + require.Zero(t, r.WaitEnteredNanos) + require.Zero(t, r.FirstGroupStartNanos, "no group ran") + require.Contains(t, cur.openTimeline(), "parent full ?") + require.Contains(t, cur.openTimeline(), "opened +70.0ms") +} + +// A block executed whole says why no stream opened for it, from the +// executor's per-slot observation of its header. +func TestStreamingNoteWholeBlockReasons(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + reset := func() { metrics.GlobalBlockReplay.StreamingExecution = metrics.StreamingExecution{} } + reason := func() string { return metrics.GlobalBlockReplay.StreamingExecution.NotOpenedReason } + + // Discarded stream, recorded on the observation (the record's own + // Discarded/DiscardReason may or may not have survived the loop's + // per-attempt reset; the observation always has it). + reset() + h.exec.observed[42] = &streamingObservation{generation: h.gen, parentSlot: 41, readyAt: time.Now(), seenAt: time.Now(), frontierAtSeen: 41} + h.exec.discard("timeout") + h.exec.noteWholeBlock(&b.Block{Slot: 42, ShredFullNanos: 123}) + require.Equal(t, "discarded:timeout", reason()) + require.Equal(t, int64(123), metrics.GlobalBlockReplay.StreamingExecution.FullNanos) + require.Positive(t, metrics.GlobalBlockReplay.StreamingExecution.HeaderReadyNanos) + require.Positive(t, metrics.GlobalBlockReplay.StreamingExecution.HeaderSeenNanos) + + // Never saw a header. + reset() + delete(h.exec.observed, 42) + h.exec.noteWholeBlock(&b.Block{Slot: 42}) + require.Equal(t, "header_not_seen", reason()) + + // Declined: the header's generation is unknown to the feed (gone). + reset() + declined := turbine.NewDetachedStreamMarker(turbine.NewDetachedStreamGeneration(42), 0, 0, turbine.StreamMarkerHeader, 41, h.parentID) + h.exec.handleEvent(h.event(declined)) + require.Nil(t, h.exec.current) + h.exec.noteWholeBlock(&b.Block{Slot: 42}) + require.Contains(t, reason(), "declined:generation no longer active") + + // Waiting: a header whose parent is not the executed frontier stays + // remembered, and a whole-block execution of it reports what it waited on. + reset() + waiting := turbine.NewDetachedStreamMarker(turbine.NewDetachedStreamGeneration(44), 0, 0, turbine.StreamMarkerHeader, 43, solana.Hash{}) + h.exec.handleEvent(h.event(waiting)) + require.Contains(t, h.exec.headers, uint64(44)) + h.exec.noteWholeBlock(&b.Block{Slot: 44}) + require.Equal(t, "waiting_for_parent:header_on_parent_43_seen_at_frontier_41", reason()) + + // Skips never report; a nil executor is a no-op. + reset() + h.exec.noteWholeBlock(&b.Block{Slot: 44, IsSkipped: true}) + require.Empty(t, reason()) + var none *streamingExecutor + none.noteWholeBlock(&b.Block{Slot: 44}) + require.Empty(t, reason()) + + // Observations are pruned with the frontier. + h.frontier = 44 + h.exec.pruneHeaders(h.frontier) + require.Empty(t, h.exec.observed) +} + +// The per-group record splits verification waits and execution at the last +// shred, so the execution FullToReplayed paid for is visible separately +// from the work hidden behind reception. +func TestStreamingGroupRecordSplitsAtFull(t *testing.T) { + t0 := time.Unix(1_700_000_000, 0) + at := func(ms int) time.Time { return t0.Add(time.Duration(ms) * time.Millisecond) } + ms := func(timing metrics.Timing) float64 { return float64(timing.SumNanoseconds) / 1e6 } + cur := &streamingSlot{slot: 42, groups: []streamingGroup{ + {readyAt: at(90), joinedAt: at(92), startedAt: at(92), finishedAt: at(120), batches: 3, transactions: 900}, // entirely before full + {readyAt: at(250), joinedAt: at(310), startedAt: at(310), finishedAt: at(340), batches: 9, transactions: 5000}, // waited 60 ms for the verifier, 10 of them after full; ran after full + {readyAt: at(280), joinedAt: at(281), startedAt: at(281), finishedAt: at(320), batches: 1, transactions: 300}, // straddles full + {readyAt: at(340), joinedAt: at(340), startedAt: at(340), finishedAt: at(350), transactions: 40, suffix: true}, + }} + var r metrics.StreamingExecution + cur.recordGroups(&r, at(300)) + require.Equal(t, 63.0, ms(r.GroupJoinAssembly)) + require.Equal(t, uint64(3), r.GroupJoinAssembly.Count, "the suffix has no verifier wait") + require.Equal(t, 10.0, ms(r.GroupJoinAssemblyAfterFull)) + require.Equal(t, uint64(1), r.GroupJoinAssemblyAfterFull.Count) + require.Equal(t, 60.0, ms(r.TxLoopAfterFull), "30 + 20 + 10 ms of execution after the last shred") + require.Equal(t, uint64(3), r.TxLoopAfterFull.Count) + require.Equal(t, uint64(1), r.GroupsStraddlingFull) + require.Equal(t, uint64(5000), r.LargestGroupTransactions) + require.Equal(t, uint64(9), r.LargestGroupBatches) + require.Equal(t, at(350).UnixNano(), r.LastGroupEndNanos) + line := cur.groupTimeline(at(300), 3) + require.Contains(t, line, "[#0 b=3 tx=900 ready-210.0 joined-208.0 exec-208.0..-180.0]") + require.Contains(t, line, "…(1 more)") + require.Contains(t, line, "[#3 suffix b=0 tx=40 ready+40.0 joined+40.0 exec+40.0..+50.0]") + require.NotContains(t, line, "#2 ") + + // No full instant (a block that did not arrive as shreds): nothing is + // "after full", waits and sizes still count. + var whole metrics.StreamingExecution + cur.recordGroups(&whole, time.Time{}) + require.Equal(t, 63.0, ms(whole.GroupJoinAssembly)) + require.Zero(t, whole.GroupJoinAssemblyAfterFull.Count) + require.Zero(t, whole.TxLoopAfterFull.Count) + require.Zero(t, whole.GroupsStraddlingFull) + require.Equal(t, uint64(5000), whole.LargestGroupTransactions) + require.Equal(t, at(350).UnixNano(), whole.LastGroupEndNanos) +} + +// The group stages account for preparation even when it crosses completion. +func TestStreamingGroupPreparationAccounting(t *testing.T) { + base := time.Unix(1700000000, 0) + at := func(ms int) time.Time { return base.Add(time.Duration(ms) * time.Millisecond) } + cur := &streamingSlot{groups: []streamingGroup{{readyAt: at(0), joinedAt: at(10), startedAt: at(30), finishedAt: at(50)}}} + var r metrics.StreamingExecution + cur.recordGroups(&r, at(20)) + require.Equal(t, uint64(10*time.Millisecond), r.GroupJoinAssembly.SumNanoseconds) + require.Equal(t, uint64(20*time.Millisecond), r.GroupPreparation.SumNanoseconds) + require.Zero(t, r.GroupJoinAssemblyAfterFull.SumNanoseconds) + require.Equal(t, uint64(10*time.Millisecond), r.GroupPreparationAfterFull.SumNanoseconds) + require.Equal(t, uint64(20*time.Millisecond), r.TxLoopAfterFull.SumNanoseconds) + require.Equal(t, uint64(30*time.Millisecond), r.GroupJoinAssemblyAfterFull.SumNanoseconds+r.GroupPreparationAfterFull.SumNanoseconds+r.TxLoopAfterFull.SumNanoseconds) +} + +// One notification must pick up every ready contiguous batch, even when the +// remaining notifications are queued or the generation has just completed. +func TestStreamingEventRefreshesReadyBatches(t *testing.T) { + for _, completed := range []bool{false, true} { + t.Run(map[bool]string{false: "active", true: "completed"}[completed], func(t *testing.T) { + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = StreamingExecutionConfig{} }() + h := newStreamingTestHarness(t) + txs := transferTransactions(t, 9, 1700) + a, b, c := h.batch(t, 1, 3, txs[:3]), h.batch(t, 4, 6, txs[3:6]), h.batch(t, 7, 9, txs[6:]) + h.feed.pending[h.gen] = []*turbine.StreamBatch{c, a, b} + if completed { + h.feed.status[h.gen] = turbine.StreamDone + h.exec.handleEvent(turbine.StreamEvent{Kind: turbine.StreamCompleted, Slot: a.Slot, Generation: h.gen}) + } else { + h.exec.handleEvent(h.event(a)) + } + sameTransactions(t, txs, h.executed()) + require.Len(t, h.exec.current.groups, 1) + h.exec.handleEvent(h.event(b)) + h.exec.handleEvent(h.event(c)) + require.Len(t, h.exec.current.groups, 1, "queued stale notifications do not execute twice") + }) + } +} + +func TestStreamingVerificationDeadlineAndDiscard(t *testing.T) { + old := StreamingExecutionCfg + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true} + defer func() { StreamingExecutionCfg = old }() + h := newFinalizeFailureHarness(t) + cur := h.exec.current + h.exec.observed[cur.slot] = &streamingObservation{generation: h.gen} + StreamingExecutionCfg.MinGroupBatches = 2 + var stage string + cur.exec.setReplayStage = func(value string) { stage = value } + var firstContext context.Context + calls := 0 + h.exec.waitVerificationFn = func(ctx context.Context, batch *turbine.StreamBatch) ([]txverify.VerifiedMessageIdentity, bool, error) { + require.Equal(t, "streaming_sigverify_wait", stage) + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.LessOrEqual(t, time.Until(deadline), streamingVerificationWait) + calls++ + if calls == 1 { + firstContext = ctx + return batch.WaitVerification(ctx) + } + require.Equal(t, firstContext, ctx, "one deadline covers all batches") + return nil, false, context.DeadlineExceeded + } + txs := transferTransactions(t, 2, 98123) + h.exec.handleEvent(h.event(h.batch(t, 4, 4, txs[:1]))) + require.Zero(t, calls) + h.exec.handleEvent(h.event(h.batch(t, 5, 5, txs[1:]))) + require.Equal(t, 2, calls) + h.assertUndone(t, "sigverify_timeout") + require.Equal(t, "streaming_wait", stage) + sameTransactions(t, h.txs[:3], cur.origin) + sameCopies(t, h.txs[:3], cur.exec.transactions) + _, ok, err := h.exec.finalize(h.env.exec.block, h.env.exec.parentBankSysvars) + require.NoError(t, err) + require.False(t, ok, "whole-block replay must take over") + metrics.GlobalBlockReplay.StreamingExecution = metrics.StreamingExecution{} + h.exec.noteWholeBlock(h.env.exec.block) + record := metrics.GlobalBlockReplay.StreamingExecution + require.Equal(t, "discarded:sigverify_timeout", record.NotOpenedReason) + require.Equal(t, uint64(2), record.VerificationWait.Count) + require.Positive(t, record.VerificationWait.SumNanoseconds) +} + +func TestStreamingVerificationWaitRespectsRemainingOpenAge(t *testing.T) { + old := StreamingExecutionCfg + StreamingExecutionCfg = StreamingExecutionConfig{Enabled: true, MaxOpenAge: time.Second} + defer func() { StreamingExecutionCfg = old }() + h := newStreamingTestHarness(t) + cur := h.exec.current + cur.openedAt = time.Now().Add(-time.Second) + h.exec.waitVerificationFn = func(ctx context.Context, _ *turbine.StreamBatch) ([]txverify.VerifiedMessageIdentity, bool, error) { + deadline, ok := ctx.Deadline() + require.True(t, ok) + require.Equal(t, cur.openedAt.Add(time.Second), deadline) + require.ErrorIs(t, ctx.Err(), context.DeadlineExceeded) + return nil, false, ctx.Err() + } + h.exec.handleEvent(h.event(h.batch(t, 1, 1, transferTransactions(t, 1, 98124)))) + require.Nil(t, h.exec.current) + require.Empty(t, h.executed()) + require.Equal(t, "sigverify_timeout", h.discardReason()) +} + +func TestStreamingHeaderAdmissionBounds(t *testing.T) { + frontier := uint64(100) + s := newStreamingExecutor(streamingDeps{frontier: func() uint64 { return frontier }}) + for _, slot := range []uint64{100, 101, 132, 133, ^uint64(0)} { + g := turbine.NewDetachedStreamGeneration(slot) + s.rememberHeader(turbine.NewDetachedStreamMarker(g, 0, 0, turbine.StreamMarkerHeader, 100, solana.Hash{})) + } + require.Len(t, s.headers, 2) + require.Len(t, s.observed, 2) + require.Contains(t, s.headers, uint64(101)) + require.Contains(t, s.headers, uint64(132)) + frontier = ^uint64(0) - 1 + s.pruneHeaders(frontier) + g := turbine.NewDetachedStreamGeneration(^uint64(0)) + s.rememberHeader(turbine.NewDetachedStreamMarker(g, 0, 0, turbine.StreamMarkerHeader, frontier, solana.Hash{})) + require.Len(t, s.headers, 1, "distance comparison must not overflow") +} + +func TestStreamingDiscardPreservesLaterLeaderStakeEntries(t *testing.T) { + h := newFinalizeFailureHarness(t) + leaderSlot := h.exec.current.slot + 4 + leaderStake := solana.PublicKey{0xF7, 0xD1} + global.EnqueuePendingStakePubkey(leaderSlot, leaderStake) + t.Cleanup(func() { global.DropPendingStakePubkeys(leaderSlot) }) + h.exec.discard("test") + entries := global.PendingStakeEntriesSnapshot() + found := false + for _, entry := range entries { + if entry.Pubkey == leaderStake { + found = true + } + } + require.True(t, found, "discard must not remove the independent leader bank's stake entries") +} diff --git a/pkg/replay/transaction.go b/pkg/replay/transaction.go index ab5a61534..39af9c6e7 100644 --- a/pkg/replay/transaction.go +++ b/pkg/replay/transaction.go @@ -297,21 +297,106 @@ func recordVoteTimestampAndSlot(slotCtx *sealevel.SlotCtx, acct *accounts.Accoun func recordStakeAndVoteAccount(slotCtx *sealevel.SlotCtx, execCtx *sealevel.ExecutionCtx, acct *accounts.Account, modifiedVoteAccts bool) { if acct.Lamports == 0 || acct.Owner != a.VoteProgramAddr { - if global.VoteCacheItem(acct.Key) != nil { - global.DeleteVoteCacheItem(acct.Key) - markVoteStakeDirty(slotCtx.Slot) // global cache mutated — gates in-loop unwind + if voteCacheHas(slotCtx, acct.Key) { + deleteVoteCacheItem(slotCtx, acct.Key) + markSlotVoteStakeDirty(slotCtx) // global cache mutated — gates in-loop unwind } } else if modifiedVoteAccts { recordVoteTimestampAndSlot(slotCtx, acct) newVersionedVoteState, wasModified := execCtx.ModifiedVoteStates[acct.Key] if wasModified { - global.PutVoteCacheItem(acct.Key, newVersionedVoteState) + putVoteCacheItem(slotCtx, acct.Key, newVersionedVoteState) } - markVoteStakeDirty(slotCtx.Slot) + markSlotVoteStakeDirty(slotCtx) } if acct.Owner == a.StakeProgramAddr { recordStakeDelegation(slotCtx.Slot, acct) + markSlotVoteStakeDirty(slotCtx) + } +} + +// The vote cache is process-global. A bank executed speculatively (streaming +// replay) defers its puts/deletes into the SlotCtx so a discard leaves the +// cache untouched; publishDeferredVoteCache applies them once the bank is +// accepted. Non-deferring banks publish immediately, exactly as before. + +func voteCacheHas(slotCtx *sealevel.SlotCtx, key solana.PublicKey) bool { + if slotCtx.DeferVoteCachePublication { + slotCtx.PendingVoteCacheMu.Lock() + defer slotCtx.PendingVoteCacheMu.Unlock() + if _, deleted := slotCtx.PendingVoteCacheDeletes[key]; deleted { + return false + } + if _, pending := slotCtx.PendingVoteCache[key]; pending { + return true + } + } + return global.VoteCacheItem(key) != nil +} + +func putVoteCacheItem(slotCtx *sealevel.SlotCtx, key solana.PublicKey, state *sealevel.VoteStateVersions) { + if !slotCtx.DeferVoteCachePublication { + global.PutVoteCacheItem(key, state) + return + } + slotCtx.PendingVoteCacheMu.Lock() + defer slotCtx.PendingVoteCacheMu.Unlock() + if slotCtx.PendingVoteCache == nil { + slotCtx.PendingVoteCache = make(map[solana.PublicKey]*sealevel.VoteStateVersions) + } + slotCtx.PendingVoteCache[key] = state + delete(slotCtx.PendingVoteCacheDeletes, key) +} + +func deleteVoteCacheItem(slotCtx *sealevel.SlotCtx, key solana.PublicKey) { + if !slotCtx.DeferVoteCachePublication { + global.DeleteVoteCacheItem(key) + return + } + slotCtx.PendingVoteCacheMu.Lock() + defer slotCtx.PendingVoteCacheMu.Unlock() + if slotCtx.PendingVoteCacheDeletes == nil { + slotCtx.PendingVoteCacheDeletes = make(map[solana.PublicKey]struct{}) + } + slotCtx.PendingVoteCacheDeletes[key] = struct{}{} + delete(slotCtx.PendingVoteCache, key) +} + +func markSlotVoteStakeDirty(slotCtx *sealevel.SlotCtx) { + if slotCtx.DeferVoteCachePublication { + slotCtx.PendingVoteCacheMu.Lock() + slotCtx.VoteStakeDirty = true + slotCtx.PendingVoteCacheMu.Unlock() + return + } + markVoteStakeDirty(slotCtx.Slot) +} + +// publishDeferredVoteCache applies a speculative bank's buffered vote-cache +// changes and dirty marker. It is called by the streaming finalize step after +// the complete block has been matched to the executed prefix and is a no-op +// for banks that published immediately. +func publishDeferredVoteCache(slotCtx *sealevel.SlotCtx) { + if slotCtx == nil || !slotCtx.DeferVoteCachePublication { + return + } + slotCtx.PendingVoteCacheMu.Lock() + puts := slotCtx.PendingVoteCache + deletes := slotCtx.PendingVoteCacheDeletes + dirty := slotCtx.VoteStakeDirty + slotCtx.PendingVoteCache = nil + slotCtx.PendingVoteCacheDeletes = nil + slotCtx.VoteStakeDirty = false + slotCtx.DeferVoteCachePublication = false + slotCtx.PendingVoteCacheMu.Unlock() + for key := range deletes { + global.DeleteVoteCacheItem(key) + } + for key, state := range puts { + global.PutVoteCacheItem(key, state) + } + if dirty { markVoteStakeDirty(slotCtx.Slot) } } diff --git a/pkg/replay/transaction_status_cache.go b/pkg/replay/transaction_status_cache.go index 8320df056..85068b084 100644 --- a/pkg/replay/transaction_status_cache.go +++ b/pkg/replay/transaction_status_cache.go @@ -10,6 +10,7 @@ import ( "path/filepath" "sort" "sync" + "sync/atomic" b "github.com/Overclock-Validator/mithril/pkg/block" "github.com/Overclock-Validator/mithril/pkg/state" @@ -48,6 +49,38 @@ type transactionStatusNode struct { hasBlockID bool parent *transactionStatusNode delta transactionStatusDelta + + // Shared by parentless checkpoint copies and relinked retained nodes. + // Only the memoized encoding changes after publication; lineage and delta + // remain immutable. Never copy the atomic field after its first use. + encoding atomic.Pointer[transactionStatusNodeEncoding] +} + +type transactionStatusNodeEncoding struct { + once sync.Once + data []byte +} + +func (n *transactionStatusNode) encodingCache() *transactionStatusNodeEncoding { + if cache := n.encoding.Load(); cache != nil { + return cache + } + cache := new(transactionStatusNodeEncoding) + if n.encoding.CompareAndSwap(nil, cache) { + return cache + } + return n.encoding.Load() +} + +// copyInto initializes a fresh node, sharing its encoding without retaining +// excluded ancestry or copying a used synchronization primitive. The encoding excludes +// parent links and depends only on the immutable slot, block ID and delta. +func (n *transactionStatusNode) copyInto(copy *transactionStatusNode, parent *transactionStatusNode) { + *copy = transactionStatusNode{ + slot: n.slot, blockID: n.blockID, hasBlockID: n.hasBlockID, + parent: parent, delta: n.delta, + } + copy.encoding.Store(n.encodingCache()) } type visibleTransactionStatusGroup struct { @@ -72,6 +105,9 @@ type TransactionStatusCache struct { // from a known-empty genesis cache. Without this bit, completeness requires // the full 300 retained roots; a serialized boolean alone is not evidence. coverageFromGenesis bool + + // Protected by mu; see transactionStatusValidation. Never serialized. + validationVersion uint64 } // TransactionStatusView is an immutable view of one bank lineage. It lazily @@ -412,6 +448,7 @@ func (c *TransactionStatusCache) BindTipBlockID(slot uint64, blockID solana.Hash if c.tip.hasBlockID && c.tip.blockID != blockID { return fmt.Errorf("transaction status tip at slot %d has block id %s, cannot bind %s", slot, c.tip.blockID, blockID) } + c.invalidateValidationLocked() c.tip = &transactionStatusNode{ slot: slot, blockID: blockID, hasBlockID: true, parent: c.tip.parent, delta: c.tip.delta, @@ -515,25 +552,52 @@ func (c *TransactionStatusCache) ValidateBlock(block *b.Block) error { // validateBlockWithPlan preserves the status-cache checks while letting // replay reuse the exact immutable identities used for execution planning. func (c *TransactionStatusCache) validateBlockWithPlan(block *b.Block, plan blockTransactionExecutionPlan) error { + _, err := c.validateBlockForPublication(block, plan) + return err +} + +func (c *TransactionStatusCache) validateBlockForPublication(block *b.Block, plan blockTransactionExecutionPlan) (transactionStatusValidation, error) { if block == nil { - return errors.New("nil block") + return transactionStatusValidation{}, errors.New("nil block") } if plan.messageIdentities == nil || !plan.messageIdentities.MatchesBlock(block) { - return errors.New("prepared transaction message identities do not match block") + return transactionStatusValidation{}, errors.New("prepared transaction message identities do not match block") } if c == nil { - return &IncompleteTransactionStatusCoverageError{} + return transactionStatusValidation{}, &IncompleteTransactionStatusCoverageError{} } c.mu.RLock() defer c.mu.RUnlock() if !c.coverageComplete { - return &IncompleteTransactionStatusCoverageError{CachedRoot: c.rootedThrough} + return transactionStatusValidation{}, &IncompleteTransactionStatusCoverageError{CachedRoot: c.rootedThrough} } if err := c.validateParentLocked(block); err != nil { - return err + return transactionStatusValidation{}, err } - return c.validateAncestorTransactionsLocked(block.Slot, plan.messageIdentities) + if err := c.validateAncestorTransactionsLocked(block.Slot, plan.messageIdentities); err != nil { + return transactionStatusValidation{}, err + } + return transactionStatusValidation{cache: c, identities: plan.messageIdentities, version: c.validationVersion}, nil +} + +// validateTransactionsAgainstAncestors is the per-group form of the ancestor +// already-processed check, for execution that starts before the complete +// block exists. It does not validate the parent link; the complete block is +// validated again in full, with validateBlockForPublication, before commit. +func (c *TransactionStatusCache) validateTransactionsAgainstAncestors(slot uint64, identities *b.PreparedTransactionMessageIdentities) error { + if identities == nil { + return errors.New("nil transaction message identities") + } + if c == nil { + return &IncompleteTransactionStatusCoverageError{} + } + c.mu.RLock() + defer c.mu.RUnlock() + if !c.coverageComplete { + return &IncompleteTransactionStatusCoverageError{CachedRoot: c.rootedThrough} + } + return c.validateAncestorTransactionsLocked(slot, identities) } func (c *TransactionStatusCache) validateAncestorTransactionsLocked(slot uint64, identities *b.PreparedTransactionMessageIdentities) error { @@ -586,6 +650,14 @@ func (c *TransactionStatusCache) CommitBlock(block *b.Block) error { // commitBlockWithPlan atomically rechecks the mutable lineage/status state and // publishes the already-prepared immutable transaction identities. func (c *TransactionStatusCache) commitBlockWithPlan(block *b.Block, plan blockTransactionExecutionPlan) error { + return c.commitBlockWithPreparedDelta(block, plan, nil) +} + +func (c *TransactionStatusCache) commitBlockWithPreparedDelta(block *b.Block, plan blockTransactionExecutionPlan, prepared *preparedTransactionStatusDelta) error { + return c.commitBlockWithValidation(block, plan, prepared, transactionStatusValidation{}) +} + +func (c *TransactionStatusCache) commitBlockWithValidation(block *b.Block, plan blockTransactionExecutionPlan, prepared *preparedTransactionStatusDelta, validation transactionStatusValidation) error { if block == nil || plan.messageIdentities == nil || !plan.messageIdentities.MatchesBlock(block) { return errors.New("prepared transaction message identities do not match block") } @@ -594,33 +666,45 @@ func (c *TransactionStatusCache) commitBlockWithPlan(block *b.Block, plan blockT if !c.coverageComplete { return &IncompleteTransactionStatusCoverageError{CachedRoot: c.rootedThrough} } - // Parent lineage and ancestor status are mutable, so both remain under the - // publication lock even when hashing and same-bank deduplication happened - // earlier. This keeps commit safe across a concurrent branch transition. + // Always check coverage, block binding and parent lineage. Reuse the earlier + // ancestor scan only under this lock and only for the same unchanged cache + // and immutable identities. A branch transition (including away and back) + // or root/prune invalidates it, requiring a fresh scan before publication. if err := c.validateParentLocked(block); err != nil { return err } - if err := c.validateAncestorTransactionsLocked(block.Slot, plan.messageIdentities); err != nil { - return err + if !validation.reusableForLocked(c, plan.messageIdentities) { + if err := c.validateAncestorTransactionsLocked(block.Slot, plan.messageIdentities); err != nil { + return err + } } - delta := make(transactionStatusDelta) - for index := 0; index < plan.messageIdentities.Len(); index++ { - identity := plan.messageIdentities.Identity(index) - blockhash := identity.RecentBlockhash - group := delta[blockhash] - if group == nil { - keyIndex := uint8(0) + delta := transactionStatusDelta(nil) + if prepared != nil && prepared.identities == plan.messageIdentities { + delta = prepared.delta + // A restore or branch transition can change a blockhash's slice offset. + // Rebuild from full identities if a group changed offset or disappeared; + // a missing group uses the same zero offset as fresh preparation. + for blockhash, group := range delta { + index := uint8(0) if visible := c.visible[blockhash]; visible != nil { - keyIndex = visible.keyIndex + index = visible.keyIndex } - group = &transactionStatusGroup{ - keyIndex: keyIndex, - keys: make(map[transactionStatusKey]struct{}), + if index != group.keyIndex { + delta = nil + break } - delta[blockhash] = group } - group.keys[sliceTransactionStatusKey(identity.MessageHash, group.keyIndex)] = struct{}{} + } + if delta == nil { + counts := countTransactionStatusGroups(plan.messageIdentities) + indexes := make(map[solana.Hash]uint8, len(counts)) + for blockhash := range counts { + if visible := c.visible[blockhash]; visible != nil { + indexes[blockhash] = visible.keyIndex + } + } + delta = buildTransactionStatusDelta(plan.messageIdentities, counts, indexes) } if err := c.addDeltaVisibleLocked(delta); err != nil { @@ -663,6 +747,7 @@ func (c *TransactionStatusCache) Root(through uint64) bool { } c.mu.Lock() defer c.mu.Unlock() + c.invalidateValidationLocked() wasComplete := c.coverageComplete newlyRooted := c.countNodesBetweenLocked(c.rootedThrough, through) if through > c.rootedThrough { @@ -681,10 +766,32 @@ func (c *TransactionStatusCache) Root(through uint64) bool { return !wasComplete && c.coverageComplete } -// SnapshotThrough serializes only the rooted lineage needed at through. It is -// called while constructing a fold job, so the blob rides in that exact durable -// manifest without being copied into every speculative ResumeContext. -func (c *TransactionStatusCache) SnapshotThrough(through uint64) ([]byte, error) { +// TransactionStatusSnapshot pins an immutable checkpoint view. MarshalBinary +// must use only captured data, without locking or revisiting the live cache, +// and return an owned payload. It can run on the checkpoint worker while replay +// commits, roots, or unwinds its current lineage. +type TransactionStatusSnapshot interface { + MarshalBinary() ([]byte, error) +} + +type transactionStatusSnapshot struct { + nodes []*transactionStatusNode + rootedSinceSeed uint16 + complete bool + coverageFromGenesis bool +} + +func (s *transactionStatusSnapshot) MarshalBinary() ([]byte, error) { + if s == nil { + return nil, nil + } + return marshalTransactionStatusNodes(s.nodes, s.rootedSinceSeed, s.complete, s.coverageFromGenesis) +} + +// CaptureSnapshotThrough selects the exact checkpoint lineage and coverage on +// replay, but leaves transaction-key sorting and serialization to the worker. +// Published deltas are immutable; only small node headers are copied here. +func (c *TransactionStatusCache) CaptureSnapshotThrough(through uint64) (TransactionStatusSnapshot, error) { if c == nil { return nil, nil } @@ -700,7 +807,27 @@ func (c *TransactionStatusCache) SnapshotThrough(through uint64) ([]byte, error) if rootedSinceSeed > maxTransactionStatusRoots { rootedSinceSeed = maxTransactionStatusRoots } - return marshalTransactionStatusNodes(nodes, uint16(rootedSinceSeed), complete, c.coverageFromGenesis) + owned := make([]transactionStatusNode, len(nodes)) + pinned := make([]*transactionStatusNode, len(nodes)) + for i, node := range nodes { + // Do not keep the old parent chain or copy its atomic field. + node.copyInto(&owned[i], nil) + pinned[i] = &owned[i] + } + return &transactionStatusSnapshot{ + nodes: pinned, rootedSinceSeed: uint16(rootedSinceSeed), complete: complete, + coverageFromGenesis: c.coverageFromGenesis, + }, nil +} + +// SnapshotThrough is the synchronous convenience API. Serialization still +// happens after releasing the cache lock; normal folds use CaptureSnapshotThrough. +func (c *TransactionStatusCache) SnapshotThrough(through uint64) ([]byte, error) { + snapshot, err := c.CaptureSnapshotThrough(through) + if err != nil || snapshot == nil { + return nil, err + } + return snapshot.MarshalBinary() } func (c *TransactionStatusCache) processedSlotLocked(blockhash solana.Hash, key transactionStatusKey) uint64 { @@ -749,6 +876,7 @@ func (c *TransactionStatusCache) validateParentLocked(block *b.Block) error { } func (c *TransactionStatusCache) addDeltaVisibleLocked(delta transactionStatusDelta) error { + c.invalidateValidationLocked() for blockhash, deltaGroup := range delta { if group := c.visible[blockhash]; group != nil && group.keyIndex != deltaGroup.keyIndex { return fmt.Errorf("transaction status blockhash %s uses inconsistent key indexes %d and %d", @@ -760,7 +888,7 @@ func (c *TransactionStatusCache) addDeltaVisibleLocked(delta transactionStatusDe if group == nil { group = &visibleTransactionStatusGroup{ keyIndex: deltaGroup.keyIndex, - keys: make(map[transactionStatusKey]uint16), + keys: make(map[transactionStatusKey]uint16, len(deltaGroup.keys)), } c.visible[blockhash] = group } @@ -772,6 +900,7 @@ func (c *TransactionStatusCache) addDeltaVisibleLocked(delta transactionStatusDe } func (c *TransactionStatusCache) removeDeltaVisibleLocked(delta transactionStatusDelta) { + c.invalidateValidationLocked() for blockhash, deltaGroup := range delta { group := c.visible[blockhash] if group == nil { @@ -839,20 +968,76 @@ func (c *TransactionStatusCache) pruneLocked(through uint64) { if drop <= 0 { return } - for _, node := range nodes[:drop] { - c.removeDeltaVisibleLocked(node.delta) - } retained := nodes[drop:] + c.expireVisibleLocked(nodes[:drop], retained) var parent *transactionStatusNode for _, old := range retained { - parent = &transactionStatusNode{ - slot: old.slot, blockID: old.blockID, hasBlockID: old.hasBlockID, - parent: parent, delta: old.delta, - } + next := new(transactionStatusNode) + old.copyInto(next, parent) + parent = next } c.tip = parent } +// expireVisibleLocked expires a whole rooted batch. Most old blockhash groups +// have no surviving bank and can be removed without visiting their transaction +// keys. For a group crossing the boundary, update whichever side is smaller. +// Immutable node deltas (including those pinned by producer views/checkpoints) +// are never mutated. Unrooted retained banks count as survivors too. +func (c *TransactionStatusCache) expireVisibleLocked(expired, retained []*transactionStatusNode) { + type groupExpiry struct { + expiredKeys int + retainedKeys int + survivors []*transactionStatusGroup + } + groups := make(map[solana.Hash]*groupExpiry) + for _, node := range expired { + for hash, delta := range node.delta { + g := groups[hash] + if g == nil { + g = &groupExpiry{} + groups[hash] = g + } + g.expiredKeys += len(delta.keys) + } + } + for _, node := range retained { + for hash, delta := range node.delta { + if g := groups[hash]; g != nil { + g.retainedKeys += len(delta.keys) + g.survivors = append(g.survivors, delta) + } + } + } + for hash, g := range groups { + if len(g.survivors) == 0 { + delete(c.visible, hash) + } else if g.retainedKeys < g.expiredKeys { + rebuilt := &visibleTransactionStatusGroup{keyIndex: g.survivors[0].keyIndex, keys: make(map[transactionStatusKey]uint16)} + for _, delta := range g.survivors { + for key := range delta.keys { + rebuilt.keys[key]++ + } + } + c.visible[hash] = rebuilt + if len(rebuilt.keys) == 0 { + delete(c.visible, hash) + } + } + } + for _, node := range expired { + for hash, delta := range node.delta { + g := groups[hash] + if len(g.survivors) == 0 || g.retainedKeys < g.expiredKeys { + continue + } + // The existing removal path preserves reference counts for keys + // occurring in more than one retained/expired bank. + c.removeDeltaVisibleLocked(transactionStatusDelta{hash: delta}) + } + } +} + func sliceTransactionStatusKey(messageHash [32]byte, keyIndex uint8) transactionStatusKey { // Match Agave's saturating_sub(CACHED_KEY_SIZE + 1), including its // deliberate exclusion of the final possible starting offset. @@ -867,7 +1052,16 @@ func sliceTransactionStatusKey(messageHash [32]byte, keyIndex uint8) transaction } func marshalTransactionStatusNodes(nodes []*transactionStatusNode, rootedSinceSeed uint16, complete bool, coverageFromGenesis bool) ([]byte, error) { - var buf bytes.Buffer + encoded := make([][]byte, len(nodes)) + size := 9 // magic, flags, rooted count and node count + for i, node := range nodes { + cache := node.encodingCache() + cache.once.Do(func() { cache.data = marshalTransactionStatusNode(node) }) + encoded[i] = cache.data + size += len(cache.data) + } + // Every caller owns its result. Never return or append into a cached slice. + buf := bytes.NewBuffer(make([]byte, 0, size)) buf.Write(transactionStatusSnapshotMagic[:]) flags := byte(0) if complete { @@ -877,47 +1071,61 @@ func marshalTransactionStatusNodes(nodes []*transactionStatusNode, rootedSinceSe flags |= 2 } buf.WriteByte(flags) - _ = binary.Write(&buf, binary.LittleEndian, rootedSinceSeed) - _ = binary.Write(&buf, binary.LittleEndian, uint16(len(nodes))) - for _, node := range nodes { - _ = binary.Write(&buf, binary.LittleEndian, node.slot) - nodeFlags := byte(0) - if node.hasBlockID { - nodeFlags = 1 - } - buf.WriteByte(nodeFlags) - if node.hasBlockID { - buf.Write(node.blockID[:]) - } - blockhashes := make([]solana.Hash, 0, len(node.delta)) - for blockhash := range node.delta { - blockhashes = append(blockhashes, blockhash) + _ = binary.Write(buf, binary.LittleEndian, rootedSinceSeed) + _ = binary.Write(buf, binary.LittleEndian, uint16(len(nodes))) + for _, data := range encoded { + buf.Write(data) + } + return buf.Bytes(), nil +} + +func marshalTransactionStatusNode(node *transactionStatusNode) []byte { + size := 8 + 1 + 4 // slot, flags and group count + if node.hasBlockID { + size += len(node.blockID) + } + for _, group := range node.delta { + size += 32 + 1 + 4 + transactionStatusKeySize*len(group.keys) + } + buf := bytes.NewBuffer(make([]byte, 0, size)) + _ = binary.Write(buf, binary.LittleEndian, node.slot) + nodeFlags := byte(0) + if node.hasBlockID { + nodeFlags = 1 + } + buf.WriteByte(nodeFlags) + if node.hasBlockID { + buf.Write(node.blockID[:]) + } + blockhashes := make([]solana.Hash, 0, len(node.delta)) + for blockhash := range node.delta { + blockhashes = append(blockhashes, blockhash) + } + sort.Slice(blockhashes, func(i, j int) bool { + return bytes.Compare(blockhashes[i][:], blockhashes[j][:]) < 0 + }) + _ = binary.Write(buf, binary.LittleEndian, uint32(len(blockhashes))) + for _, blockhash := range blockhashes { + group := node.delta[blockhash] + buf.Write(blockhash[:]) + buf.WriteByte(group.keyIndex) + keys := make([]transactionStatusKey, 0, len(group.keys)) + for key := range group.keys { + keys = append(keys, key) } - sort.Slice(blockhashes, func(i, j int) bool { - return bytes.Compare(blockhashes[i][:], blockhashes[j][:]) < 0 + sort.Slice(keys, func(i, j int) bool { + return bytes.Compare(keys[i][:], keys[j][:]) < 0 }) - _ = binary.Write(&buf, binary.LittleEndian, uint32(len(blockhashes))) - for _, blockhash := range blockhashes { - group := node.delta[blockhash] - buf.Write(blockhash[:]) - buf.WriteByte(group.keyIndex) - keys := make([]transactionStatusKey, 0, len(group.keys)) - for key := range group.keys { - keys = append(keys, key) - } - sort.Slice(keys, func(i, j int) bool { - return bytes.Compare(keys[i][:], keys[j][:]) < 0 - }) - _ = binary.Write(&buf, binary.LittleEndian, uint32(len(keys))) - for _, key := range keys { - buf.Write(key[:]) - } + _ = binary.Write(buf, binary.LittleEndian, uint32(len(keys))) + for _, key := range keys { + buf.Write(key[:]) } } - return buf.Bytes(), nil + return buf.Bytes() } func (c *TransactionStatusCache) restore(data []byte) error { + c.invalidateValidationLocked() reader := bytes.NewReader(data) var magic [4]byte if _, err := io.ReadFull(reader, magic[:]); err != nil { diff --git a/pkg/replay/transaction_status_capture_bench_test.go b/pkg/replay/transaction_status_capture_bench_test.go new file mode 100644 index 000000000..234743222 --- /dev/null +++ b/pkg/replay/transaction_status_capture_bench_test.go @@ -0,0 +1,113 @@ +package replay + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "testing" + + "github.com/gagliardetto/solana-go" +) + +var checkpointBenchmarkPayload []byte +var checkpointBenchmarkCapture TransactionStatusSnapshot + +func checkpointEncodingFixture() *TransactionStatusCache { + // A private, not-yet-published fixture with the same complete 300-root + // metadata as an imported cache. 1.5 million keys encode to roughly 30 MB. + c := newTransactionStatusCache(true) + c.coverageFromGenesis = false + c.rootedSinceSeed = maxTransactionStatusRoots + c.rootedThrough = maxTransactionStatusRoots + for slot := uint64(1); slot <= maxTransactionStatusRoots; slot++ { + keys := make(map[transactionStatusKey]struct{}, 5000) + var seed [16]byte + binary.LittleEndian.PutUint64(seed[:8], slot) + for i := uint64(0); i < 5000; i++ { + binary.LittleEndian.PutUint64(seed[8:], i) + hash := sha256.Sum256(seed[:]) + var key transactionStatusKey + copy(key[:], hash[:]) + keys[key] = struct{}{} + } + c.tip = &transactionStatusNode{slot: slot, parent: c.tip, + delta: transactionStatusDelta{solana.Hash{1}: {keyIndex: 7, keys: keys}}} + } + return c +} + +func BenchmarkTransactionStatusCheckpointCapture(b *testing.B) { + c := checkpointEncodingFixture() + view, err := c.CaptureSnapshotThrough(maxTransactionStatusRoots) + if err != nil { + b.Fatal(err) + } + b.Run("SynchronousBaseline", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + checkpointBenchmarkPayload, err = legacyStatusSnapshotForTest(c, maxTransactionStatusRoots) + if err != nil { + b.Fatal(err) + } + } + }) + b.Run("CaptureOnReplay", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + checkpointBenchmarkCapture, err = c.CaptureSnapshotThrough(maxTransactionStatusRoots) + if err != nil { + b.Fatal(err) + } + } + }) + b.Run("EncodeOnWorker", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + checkpointBenchmarkPayload, err = view.MarshalBinary() + if err != nil { + b.Fatal(err) + } + } + }) +} + +// Moving 300-root windows at several checkpoint cadences. Fixtures and initial +// cache warming are excluded; allocating new node headers and encoding/output +// allocation are included. This measures encoding only, not fsync or account +// checkpoint work. Cold encodes represent first startup/all-new windows. +func BenchmarkTransactionStatusCheckpointEncoding(b *testing.B) { + c := checkpointEncodingFixture() + view, err := c.CaptureSnapshotThrough(300) + if err != nil { + b.Fatal(err) + } + seed := view.(*transactionStatusSnapshot).nodes + for _, advance := range []int{0, 1, 8, 32, defaultFoldBatchSlots, 300} { + for _, cached := range []bool{false, true} { + b.Run(fmt.Sprintf("new=%d/cached=%t", advance, cached), func(b *testing.B) { + nodes := append([]*transactionStatusNode(nil), seed...) + if cached { + _, _ = marshalTransactionStatusNodes(nodes, 300, true, false) + } + b.ReportAllocs() + b.ResetTimer() + for n := 0; n < b.N; n++ { + copy(nodes, nodes[advance:]) + for i := 300 - advance; i < 300; i++ { + nodes[i] = &transactionStatusNode{slot: uint64(301 + n*advance + i), delta: seed[i].delta} + } + var err error + if cached { + checkpointBenchmarkPayload, err = marshalTransactionStatusNodes(nodes, 300, true, false) + } else { + checkpointBenchmarkPayload, err = marshalTransactionStatusNodesUncached(nodes, 300, true, false) + } + if err != nil { + b.Fatal(err) + } + } + b.SetBytes(int64(len(checkpointBenchmarkPayload))) + }) + } + } +} diff --git a/pkg/replay/transaction_status_capture_test.go b/pkg/replay/transaction_status_capture_test.go new file mode 100644 index 000000000..34b8273af --- /dev/null +++ b/pkg/replay/transaction_status_capture_test.go @@ -0,0 +1,334 @@ +package replay + +import ( + "bytes" + "encoding/binary" + "fmt" + "math/rand" + "sort" + "sync" + "testing" + "time" + + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/txstatus" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Keep the pre-split selection/metadata calculation as a differential oracle. +// Use the original uncached wire encoder to check byte-for-byte compatibility. +func legacyStatusSnapshotForTest(c *TransactionStatusCache, through uint64) ([]byte, error) { + c.mu.RLock() + defer c.mu.RUnlock() + nodes := c.nodesThroughLocked(through) + if len(nodes) > maxTransactionStatusRoots { + nodes = nodes[len(nodes)-maxTransactionStatusRoots:] + } + rooted := uint32(c.rootedSinceSeed) + uint32(c.countNodesBetweenLocked(c.rootedThrough, through)) + complete := c.coverageComplete || rooted >= maxTransactionStatusRoots + if rooted > maxTransactionStatusRoots { + rooted = maxTransactionStatusRoots + } + return marshalTransactionStatusNodesUncached(nodes, uint16(rooted), complete, c.coverageFromGenesis) +} + +func importedStatusCacheForTest(t *testing.T) *TransactionStatusCache { + t.Helper() + roots := make([]txstatus.SnapshotSlotDelta, maxTransactionStatusRoots) + for i := range roots { + roots[i] = txstatus.SnapshotSlotDelta{Slot: uint64(i + 1), IsRoot: true} + } + c, err := NewTransactionStatusCacheFromAgaveSnapshot(roots, maxTransactionStatusRoots) + require.NoError(t, err) + return c +} + +func captureTestBlock(slot uint64, branch byte) *b.Block { + tx := statusCacheTestTransaction(1, 2, branch) + data := make([]byte, 9) + binary.LittleEndian.PutUint64(data, slot) + data[8] = branch + tx.Message.Instructions[0].Data = data + return statusCacheTestBlock(slot, tx) +} + +func TestTransactionStatusCaptureSurvivesConcurrentPruneAndUnwind(t *testing.T) { + c := importedStatusCacheForTest(t) + for slot := uint64(301); slot <= 350; slot++ { + require.NoError(t, c.CommitBlock(captureTestBlock(slot, 1))) + } + want, err := legacyStatusSnapshotForTest(c, 320) + require.NoError(t, err) + captured, err := c.CaptureSnapshotThrough(320) + require.NoError(t, err) + view := captured.(*transactionStatusSnapshot) + require.Len(t, view.nodes, maxTransactionStatusRoots) + require.Equal(t, uint64(21), view.nodes[0].slot) + require.Equal(t, uint64(320), view.nodes[len(view.nodes)-1].slot) + for _, node := range view.nodes { + require.Nil(t, node.parent, "capture retained excluded ancestry") + } + + var wg sync.WaitGroup + wg.Add(1) + errs := make(chan error, 1) + go func() { + defer wg.Done() + for slot := uint64(351); slot <= 750; slot++ { + if err := c.CommitBlock(captureTestBlock(slot, 1)); err != nil { + errs <- err + return + } + c.Root(slot - 20) + } + if err := c.Unwind(741); err != nil { + errs <- err + return + } + for slot := uint64(741); slot <= 755; slot++ { + if err := c.CommitBlock(captureTestBlock(slot, 2)); err != nil { + errs <- err + return + } + } + }() + for i := 0; i < 50; i++ { + got, err := captured.MarshalBinary() + if err != nil || string(want) != string(got) { + t.Errorf("captured bytes changed during replay: %v", err) + break + } + } + wg.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + got, err := captured.MarshalBinary() + require.NoError(t, err) + require.Equal(t, want, got) + restored, err := NewTransactionStatusCacheFromSnapshot(got) + require.NoError(t, err) + require.Equal(t, uint64(320), restored.RootedThrough()) + require.True(t, restored.CoverageComplete()) + retry := statusCacheTestBlock(321, captureTestBlock(301, 1).Transactions[0]) + require.Error(t, restored.ValidateBlock(retry), "captured ancestor was forgotten") + // A bank after the capture's through-slot must not leak into recovery. + future := statusCacheTestBlock(321, captureTestBlock(350, 1).Transactions[0]) + require.NoError(t, restored.ValidateBlock(future)) +} + +func TestTransactionStatusCapturePreservesCoverageAndOwnedBytes(t *testing.T) { + for _, complete := range []bool{false, true} { + c := newTransactionStatusCache(complete) + // Exercise metadata selection without changing its pre-existing rules. + for slot := uint64(1); slot <= 310; slot++ { + c.tip = &transactionStatusNode{slot: slot, parent: c.tip, + delta: transactionStatusDelta{solana.Hash{1}: {keyIndex: 7, keys: map[transactionStatusKey]struct{}{{byte(slot), byte(slot >> 8)}: {}}}}} + } + for _, through := range []uint64{0, 1, 299, 300, 310, 400} { + want, err := legacyStatusSnapshotForTest(c, through) + require.NoError(t, err) + view, err := c.CaptureSnapshotThrough(through) + require.NoError(t, err) + got, err := view.MarshalBinary() + require.NoError(t, err) + require.Equal(t, want, got) + got[0] ^= 0xff + again, err := view.MarshalBinary() + require.NoError(t, err) + require.Equal(t, want, again, "caller mutated the captured data through encoded bytes") + } + } + var absent *TransactionStatusCache + view, err := absent.CaptureSnapshotThrough(1) + require.NoError(t, err) + require.Nil(t, view) +} + +func TestTransactionStatusCaptureEncodingDoesNotLockLiveCache(t *testing.T) { + c := importedStatusCacheForTest(t) + require.NoError(t, c.CommitBlock(captureTestBlock(301, 1))) + view, err := c.CaptureSnapshotThrough(301) + require.NoError(t, err) + c.mu.Lock() + done := make(chan error, 1) + go func() { _, err := view.MarshalBinary(); done <- err }() + select { + case err := <-done: + c.mu.Unlock() + require.NoError(t, err) + case <-time.After(2 * time.Second): + c.mu.Unlock() + t.Fatal("checkpoint encoding waited for the live cache lock") + } +} + +func marshalTransactionStatusNodesUncached(nodes []*transactionStatusNode, rootedSinceSeed uint16, complete bool, coverageFromGenesis bool) ([]byte, error) { + var buf bytes.Buffer + buf.Write(transactionStatusSnapshotMagic[:]) + flags := byte(0) + if complete { + flags = 1 + } + if coverageFromGenesis { + flags |= 2 + } + buf.WriteByte(flags) + _ = binary.Write(&buf, binary.LittleEndian, rootedSinceSeed) + _ = binary.Write(&buf, binary.LittleEndian, uint16(len(nodes))) + for _, node := range nodes { + _ = binary.Write(&buf, binary.LittleEndian, node.slot) + nodeFlags := byte(0) + if node.hasBlockID { + nodeFlags = 1 + } + buf.WriteByte(nodeFlags) + if node.hasBlockID { + buf.Write(node.blockID[:]) + } + blockhashes := make([]solana.Hash, 0, len(node.delta)) + for blockhash := range node.delta { + blockhashes = append(blockhashes, blockhash) + } + sort.Slice(blockhashes, func(i, j int) bool { + return bytes.Compare(blockhashes[i][:], blockhashes[j][:]) < 0 + }) + _ = binary.Write(&buf, binary.LittleEndian, uint32(len(blockhashes))) + for _, blockhash := range blockhashes { + group := node.delta[blockhash] + buf.Write(blockhash[:]) + buf.WriteByte(group.keyIndex) + keys := make([]transactionStatusKey, 0, len(group.keys)) + for key := range group.keys { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return bytes.Compare(keys[i][:], keys[j][:]) < 0 + }) + _ = binary.Write(&buf, binary.LittleEndian, uint32(len(keys))) + for _, key := range keys { + buf.Write(key[:]) + } + } + } + return buf.Bytes(), nil +} + +func TestTransactionStatusEncodingSharedAcrossCaptureAndPrune(t *testing.T) { + for _, warmBeforePrune := range []bool{false, true} { + t.Run(fmt.Sprintf("warm=%t", warmBeforePrune), func(t *testing.T) { + c := importedStatusCacheForTest(t) + for slot := uint64(301); slot <= 305; slot++ { + require.NoError(t, c.CommitBlock(captureTestBlock(slot, 1))) + } + first, err := c.CaptureSnapshotThrough(304) + require.NoError(t, err) + pinned := first.(*transactionStatusSnapshot) + want, err := legacyStatusSnapshotForTest(c, 304) + require.NoError(t, err) + if warmBeforePrune { + got, err := first.MarshalBinary() + require.NoError(t, err) + require.Equal(t, want, got) + } + // Force relinking of retained nodes after the snapshot has copied + // their headers, including the still-unencoded case. + c.Root(305) + second, err := c.CaptureSnapshotThrough(305) + require.NoError(t, err) + current := second.(*transactionStatusSnapshot) + caches := make(map[uint64]*transactionStatusNodeEncoding) + for _, node := range pinned.nodes { + caches[node.slot] = node.encodingCache() + } + for _, node := range current.nodes { + if prior := caches[node.slot]; prior != nil { + require.Same(t, prior, node.encodingCache()) + } + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + got, err := first.MarshalBinary() + assert.NoError(t, err) + assert.Equal(t, want, got) + // Mutate the node body as well as the header; neither may + // alias the memoized node data or another caller's result. + clear(got) + }() + } + wg.Wait() + currentWant, err := legacyStatusSnapshotForTest(c, 305) + require.NoError(t, err) + got, err := second.MarshalBinary() + require.NoError(t, err) + require.Equal(t, currentWant, got) + restored, err := NewTransactionStatusCacheFromSnapshot(got) + require.NoError(t, err) + roundTrip, err := restored.SnapshotThrough(305) + require.NoError(t, err) + require.Equal(t, got, roundTrip) + }) + } +} + +func TestTransactionStatusEncodingMatchesOriginalWireFormat(t *testing.T) { + // Deliberately unsorted groups/keys, nonzero offsets, empty deltas and + // mixed block-ID presence exercise every independently cached field. + nodes := []*transactionStatusNode{ + {slot: 3, hasBlockID: true, blockID: solana.Hash{9}, delta: transactionStatusDelta{ + solana.Hash{7}: {keyIndex: 11, keys: map[transactionStatusKey]struct{}{{8}: {}, {1}: {}, {4}: {}}}, + solana.Hash{1}: {keyIndex: 2, keys: map[transactionStatusKey]struct{}{{9}: {}, {2}: {}}}, + }}, + {slot: 5}, + {slot: 8, delta: transactionStatusDelta{solana.Hash{3}: {keyIndex: 0, keys: map[transactionStatusKey]struct{}{}}}}, + } + for _, complete := range []bool{false, true} { + for _, genesis := range []bool{false, true} { + want, err := marshalTransactionStatusNodesUncached(nodes, 3, complete, genesis) + require.NoError(t, err) + got, err := marshalTransactionStatusNodes(nodes, 3, complete, genesis) + require.NoError(t, err) + require.Equal(t, want, got) + } + } +} + +func TestTransactionStatusEncodingRandomizedByteIdentity(t *testing.T) { + rng := rand.New(rand.NewSource(20260916)) + for trial := 0; trial < 200; trial++ { + nodes := make([]*transactionStatusNode, rng.Intn(13)) + var slot uint64 + for i := range nodes { + slot += uint64(1 + rng.Intn(5)) + node := &transactionStatusNode{slot: slot, hasBlockID: rng.Intn(2) == 1, delta: make(transactionStatusDelta)} + _, _ = rng.Read(node.blockID[:]) + for g := rng.Intn(8); g > 0; g-- { + var hash solana.Hash + _, _ = rng.Read(hash[:]) + group := &transactionStatusGroup{keyIndex: uint8(rng.Intn(int(txstatus.MaxCachedKeyIndex) + 1)), keys: make(map[transactionStatusKey]struct{})} + for k := rng.Intn(13); k > 0; k-- { + var key transactionStatusKey + _, _ = rng.Read(key[:]) + group.keys[key] = struct{}{} + } + node.delta[hash] = group + } + nodes[i] = node + } + rooted := uint16(rng.Intn(301)) + complete, genesis := rng.Intn(2) == 1, rng.Intn(2) == 1 + want, err := marshalTransactionStatusNodesUncached(nodes, rooted, complete, genesis) + require.NoError(t, err) + for pass := 0; pass < 2; pass++ { + got, err := marshalTransactionStatusNodes(nodes, rooted, complete, genesis) + require.NoError(t, err) + require.Equal(t, want, got, "trial=%d pass=%d", trial, pass) + } + } +} diff --git a/pkg/replay/transaction_status_expiry_test.go b/pkg/replay/transaction_status_expiry_test.go new file mode 100644 index 000000000..84a11cd28 --- /dev/null +++ b/pkg/replay/transaction_status_expiry_test.go @@ -0,0 +1,129 @@ +package replay + +import ( + "encoding/binary" + "fmt" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + "math/rand" + "testing" +) + +func TestTransactionStatusBatchExpiryMatchesPerKeyRemoval(t *testing.T) { + for seed := int64(0); seed < 100; seed++ { + rng := rand.New(rand.NewSource(seed)) + fast, ref := NewTransactionStatusCache(), NewTransactionStatusCache() + var nodes []*transactionStatusNode + for slot := 0; slot < 40; slot++ { + d := make(transactionStatusDelta) + for j := 0; j < 6; j++ { + h := solana.Hash{byte(rng.Intn(12))} + g := &transactionStatusGroup{keyIndex: h[0], keys: make(map[transactionStatusKey]struct{})} + for k := 0; k < rng.Intn(30); k++ { + g.keys[transactionStatusKey{byte(rng.Intn(40))}] = struct{}{} + } + d[h] = g + } + nodes = append(nodes, &transactionStatusNode{slot: uint64(slot), delta: d}) + require.NoError(t, fast.addDeltaVisibleLocked(d)) + require.NoError(t, ref.addDeltaVisibleLocked(d)) + } + cut := 1 + rng.Intn(len(nodes)-1) + fast.expireVisibleLocked(nodes[:cut], nodes[cut:]) + for _, n := range nodes[:cut] { + ref.removeDeltaVisibleLocked(n.delta) + } + require.Equal(t, ref.visible, fast.visible, "seed %d", seed) + for i := len(nodes) - 1; i >= cut; i-- { + fast.removeDeltaVisibleLocked(nodes[i].delta) + ref.removeDeltaVisibleLocked(nodes[i].delta) + } + require.Equal(t, ref.visible, fast.visible, "unwind seed %d", seed) + } +} + +func TestTransactionStatusBatchExpiryPinnedViewsAndSnapshot(t *testing.T) { + c := NewTransactionStatusCache() + old := statusCacheTestTransaction(1, 1, 1) + keep := statusCacheTestTransaction(2, 2, 2) + require.NoError(t, c.CommitBlock(statusCacheTestBlock(1, old))) + for slot := uint64(2); slot <= maxTransactionStatusRoots+1; slot++ { + blk := statusCacheTestBlock(slot) + if slot == maxTransactionStatusRoots+1 { + blk.Transactions = append(blk.Transactions, keep) + } + require.NoError(t, c.CommitBlock(blk)) + } + pinned := c.View() + snapshot, err := c.CaptureSnapshotThrough(maxTransactionStatusRoots + 1) + require.NoError(t, err) + before, err := snapshot.MarshalBinary() + require.NoError(t, err) + c.coverageComplete = false // Exercise completion once 300 banks become rooted. + c.Root(maxTransactionStatusRoots + 1) + after, err := snapshot.MarshalBinary() + require.NoError(t, err) + require.Equal(t, before, after) + found, err := pinned.ContainsTransaction(old) + require.NoError(t, err) + require.True(t, found) + found, err = c.View().ContainsTransaction(old) + require.NoError(t, err) + require.False(t, found) + require.NoError(t, c.ValidateBlock(statusCacheTestBlock(maxTransactionStatusRoots+2, old))) + require.Error(t, c.ValidateBlock(statusCacheTestBlock(maxTransactionStatusRoots+2, keep))) + blob, err := c.SnapshotThrough(maxTransactionStatusRoots + 1) + require.NoError(t, err) + restored, err := NewTransactionStatusCacheFromSnapshot(blob) + require.NoError(t, err) + require.NoError(t, restored.ValidateBlock(statusCacheTestBlock(maxTransactionStatusRoots+2, old))) + require.Error(t, restored.ValidateBlock(statusCacheTestBlock(maxTransactionStatusRoots+2, keep))) + require.Error(t, c.Unwind(maxTransactionStatusRoots+1)) +} + +func BenchmarkTransactionStatusBatchExpiry(b *testing.B) { + for _, shape := range []string{"four-bank-groups", "one-expired-group", "crossing-group"} { + for _, legacy := range []bool{true, false} { + b.Run(fmt.Sprintf("%s/legacy=%t", shape, legacy), func(b *testing.B) { + for i := 0; i < b.N; i++ { + b.StopTimer() + c := NewTransactionStatusCache() + var expired, retained []*transactionStatusNode + for slot := 0; slot < 129; slot++ { + var h solana.Hash + if shape == "four-bank-groups" || (shape == "one-expired-group" && slot == 128) { + binary.LittleEndian.PutUint64(h[:], uint64(slot/4+1)) + } + g := &transactionStatusGroup{keys: make(map[transactionStatusKey]struct{})} + for k := 0; k < 33760; k++ { + var key transactionStatusKey + binary.LittleEndian.PutUint64(key[:], uint64(slot*33760+k)) + g.keys[key] = struct{}{} + } + n := &transactionStatusNode{delta: transactionStatusDelta{h: g}} + if err := c.addDeltaVisibleLocked(n.delta); err != nil { + b.Fatal(err) + } + if slot < 128 { + expired = append(expired, n) + } else { + retained = append(retained, n) + } + } + b.StartTimer() + if legacy { + for _, n := range expired { + c.removeDeltaVisibleLocked(n.delta) + } + } else { + c.expireVisibleLocked(expired, retained) + } + b.StopTimer() + if len(c.visible) != 1 { + b.Fatal("retained group missing") + } + } + }) + } + } +} diff --git a/pkg/replay/transaction_status_overlap_benchmark_test.go b/pkg/replay/transaction_status_overlap_benchmark_test.go new file mode 100644 index 000000000..0da5f739f --- /dev/null +++ b/pkg/replay/transaction_status_overlap_benchmark_test.go @@ -0,0 +1,110 @@ +package replay + +import ( + "fmt" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/tpu/txfixture" + "github.com/gagliardetto/solana-go" +) + +// This controlled workload runs 4,096 executions of the transfer fixture while +// preparing 33,760 independent status keys. It measures scheduling/GC contention, +// not full replay: no accounts are committed, and the status fixture differs +// from the repeated transfer fixture. Run with -cpu=1,2 to compare contention +// without and with a spare execution thread. Check live replay separately. +func BenchmarkTransactionStatusExecutionOverlap(tb *testing.B) { + for _, mode := range []string{"legacy", "sized", "overlap"} { + tb.Run(mode, func(tb *testing.B) { + slotCtx, cleanup := newCommitTestSlotCtx() + defer cleanup() + tx, err := solana.TransactionFromBytes(txfixture.MustSignedTransferWire(0)) + if err != nil { + tb.Fatal(err) + } + cache := NewTransactionStatusCache() + if err := cache.CommitBlock(statusCacheTestBlock(10)); err != nil { + tb.Fatal(err) + } + blk := statusCacheTestBlock(11, benchmarkUniqueTransactions(33760)...) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + tb.Fatal(err) + } + var execution, commit time.Duration + tb.ReportAllocs() + tb.ResetTimer() + for range tb.N { + var p *transactionStatusPreparation + if mode == "overlap" { + p = cache.startStatusPreparation(plan) + } + start := time.Now() + for range 4096 { + output := LoadAndExecuteTransaction(LoadAndExecuteTransactionInput{SlotCtx: slotCtx, Transaction: tx, LeanResult: true}) + if output.ProcessingResult.TransactionError != nil { + tb.Fatal(output.ProcessingResult.TransactionError) + } + } + execution += time.Since(start) + start = time.Now() + switch mode { + case "legacy": + err = cache.legacyCommitStatusForBenchmark(blk, plan) + case "sized": + err = cache.commitBlockWithPlan(blk, plan) + case "overlap": + err = cache.commitBlockWithPreparedDelta(blk, plan, p.wait()) + } + commit += time.Since(start) + if err != nil { + tb.Fatal(err) + } + tb.StopTimer() + if err := cache.Unwind(11); err != nil { + tb.Fatal(err) + } + tb.StartTimer() + } + tb.StopTimer() + tb.ReportMetric(float64(execution.Nanoseconds())/float64(tb.N), "execution-ns/op") + tb.ReportMetric(float64(commit.Nanoseconds())/float64(tb.N), "commit-with-wait-ns/op") + }) + } +} + +func BenchmarkTransactionStatusSmallPublication(tb *testing.B) { + for _, count := range []int{0, 1, 32} { + for _, mode := range []string{"legacy", "prepared_total"} { + tb.Run(fmt.Sprintf("txs_%d/%s", count, mode), func(tb *testing.B) { + cache := NewTransactionStatusCache() + if err := cache.CommitBlock(statusCacheTestBlock(10)); err != nil { + tb.Fatal(err) + } + blk := statusCacheTestBlock(11, benchmarkUniqueTransactions(count)...) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + tb.Fatal(err) + } + tb.ReportAllocs() + tb.ResetTimer() + for range tb.N { + if mode == "legacy" { + err = cache.legacyCommitStatusForBenchmark(blk, plan) + } else { + err = cache.commitBlockWithPreparedDelta(blk, plan, cache.startStatusPreparation(plan).wait()) + } + if err != nil { + tb.Fatal(err) + } + // Include unwind equally in this small-work benchmark, avoiding + // timer start/stop overhead around microsecond operations. + if err := cache.Unwind(11); err != nil { + tb.Fatal(err) + } + } + }) + } + } +} diff --git a/pkg/replay/transaction_status_plan_binding_test.go b/pkg/replay/transaction_status_plan_binding_test.go index 30b456d3a..faf0b2540 100644 --- a/pkg/replay/transaction_status_plan_binding_test.go +++ b/pkg/replay/transaction_status_plan_binding_test.go @@ -15,9 +15,10 @@ func TestPreparedCommitRejectsTransactionReplacement(t *testing.T) { if err != nil { t.Fatal(err) } + prepared := cache.prepareTransactionStatusDelta(plan.messageIdentities) candidate.Transactions[0] = statusCacheTestTransaction(4, 5, 6) - err = cache.commitBlockWithPlan(candidate, plan) + err = cache.commitBlockWithPreparedDelta(candidate, plan, prepared) if err == nil || err.Error() != "prepared transaction message identities do not match block" { t.Fatalf("commit error = %v, want prepared-plan binding failure", err) } diff --git a/pkg/replay/transaction_status_prepared_test.go b/pkg/replay/transaction_status_prepared_test.go index 44a42ca83..0172fffa6 100644 --- a/pkg/replay/transaction_status_prepared_test.go +++ b/pkg/replay/transaction_status_prepared_test.go @@ -25,7 +25,9 @@ func TestPreparedCommitRechecksAncestorAfterForkSwitch(t *testing.T) { candidate := statusCacheTestBlock(12, retried, unique) plan, err := planBlockTransactionExecution(candidate) requireNoError(err) - requireNoError(cache.validateBlockWithPlan(candidate, plan)) + validation, err := cache.validateBlockForPublication(candidate, plan) + requireNoError(err) + prepared := cache.prepareTransactionStatusDelta(plan.messageIdentities) requireNoError(cache.Unwind(11)) replacement := statusCacheTestBlock( @@ -34,7 +36,7 @@ func TestPreparedCommitRechecksAncestorAfterForkSwitch(t *testing.T) { ) requireNoError(cache.CommitBlock(replacement)) - err = cache.commitBlockWithPlan(candidate, plan) + err = cache.commitBlockWithValidation(candidate, plan, prepared, validation) var ancestorErr *AncestorAlreadyProcessedTransactionMessagesError if !errors.As(err, &ancestorErr) { t.Fatalf("prepared commit error = %v, want ancestor AlreadyProcessed", err) @@ -76,22 +78,26 @@ func TestConcurrentPreparedSiblingCommitsPublishExactlyOne(t *testing.T) { if err != nil { t.Fatal(err) } - if err := cache.validateBlockWithPlan(left, leftPlan); err != nil { + leftValidation, err := cache.validateBlockForPublication(left, leftPlan) + if err != nil { t.Fatalf("prevalidate left sibling: %v", err) } - if err := cache.validateBlockWithPlan(right, rightPlan); err != nil { + rightValidation, err := cache.validateBlockForPublication(right, rightPlan) + if err != nil { t.Fatalf("prevalidate right sibling: %v", err) } + leftPrepared := cache.prepareTransactionStatusDelta(leftPlan.messageIdentities) + rightPrepared := cache.prepareTransactionStatusDelta(rightPlan.messageIdentities) start := make(chan struct{}) results := make(chan error, 2) go func() { <-start - results <- cache.commitBlockWithPlan(left, leftPlan) + results <- cache.commitBlockWithValidation(left, leftPlan, leftPrepared, leftValidation) }() go func() { <-start - results <- cache.commitBlockWithPlan(right, rightPlan) + results <- cache.commitBlockWithValidation(right, rightPlan, rightPrepared, rightValidation) }() close(start) diff --git a/pkg/replay/transaction_status_publication.go b/pkg/replay/transaction_status_publication.go new file mode 100644 index 000000000..e821071ad --- /dev/null +++ b/pkg/replay/transaction_status_publication.go @@ -0,0 +1,83 @@ +package replay + +import ( + "runtime" + "time" + + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/gagliardetto/solana-go" +) + +// Preparation owns private, immutable maps. It never publishes a status or +// authorizes a bank: commit still checks coverage, lineage, and duplicates. +type preparedTransactionStatusDelta struct { + identities *b.PreparedTransactionMessageIdentities + delta transactionStatusDelta +} + +type transactionStatusPreparation struct { + done chan struct{} + prepared *preparedTransactionStatusDelta + duration time.Duration +} + +// Replay joins this task on every exit, including rejected banks. It only reads +// the immutable identities, so account loading and ALT resolution can proceed. +func (c *TransactionStatusCache) startStatusPreparation(plan blockTransactionExecutionPlan) *transactionStatusPreparation { + // Small-block measurements show dispatch/join costs as much as the work. + // With one Go execution thread preparation cannot overlap execution at all. + if plan.messageIdentities.Len() <= 32 || runtime.GOMAXPROCS(0) == 1 { + return nil + } + p := &transactionStatusPreparation{done: make(chan struct{})} + go func() { + defer close(p.done) + start := time.Now() + p.prepared = c.prepareTransactionStatusDelta(plan.messageIdentities) + p.duration = time.Since(start) + }() + return p +} + +func (p *transactionStatusPreparation) wait() *preparedTransactionStatusDelta { + if p == nil { + return nil + } + <-p.done + return p.prepared +} + +func countTransactionStatusGroups(identities *b.PreparedTransactionMessageIdentities) map[solana.Hash]int { + counts := make(map[solana.Hash]int) + for i := 0; i < identities.Len(); i++ { + counts[identities.Identity(i).RecentBlockhash]++ + } + return counts +} + +func buildTransactionStatusDelta(identities *b.PreparedTransactionMessageIdentities, counts map[solana.Hash]int, indexes map[solana.Hash]uint8) transactionStatusDelta { + delta := make(transactionStatusDelta, len(counts)) + for blockhash, count := range counts { + delta[blockhash] = &transactionStatusGroup{keyIndex: indexes[blockhash], keys: make(map[transactionStatusKey]struct{}, count)} + } + for i := 0; i < identities.Len(); i++ { + identity := identities.Identity(i) + group := delta[identity.RecentBlockhash] + group.keys[sliceTransactionStatusKey(identity.MessageHash, group.keyIndex)] = struct{}{} + } + return delta +} + +func (c *TransactionStatusCache) prepareTransactionStatusDelta(identities *b.PreparedTransactionMessageIdentities) *preparedTransactionStatusDelta { + counts := countTransactionStatusGroups(identities) + indexes := make(map[solana.Hash]uint8, len(counts)) + // Copy offsets only, never share mutable visible maps with the worker. + c.mu.RLock() + for blockhash := range counts { + if visible := c.visible[blockhash]; visible != nil { + indexes[blockhash] = visible.keyIndex + } + } + c.mu.RUnlock() + return &preparedTransactionStatusDelta{identities: identities, delta: buildTransactionStatusDelta(identities, counts, indexes)} +} diff --git a/pkg/replay/transaction_status_publication_benchmark_test.go b/pkg/replay/transaction_status_publication_benchmark_test.go new file mode 100644 index 000000000..2151ef330 --- /dev/null +++ b/pkg/replay/transaction_status_publication_benchmark_test.go @@ -0,0 +1,169 @@ +package replay + +import ( + "encoding/binary" + "errors" + "fmt" + "testing" + + b "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/gagliardetto/solana-go" +) + +func (c *TransactionStatusCache) legacyCommitStatusForBenchmark(block *b.Block, plan blockTransactionExecutionPlan) error { + if block == nil || plan.messageIdentities == nil || !plan.messageIdentities.MatchesBlock(block) { + return errors.New("prepared transaction message identities do not match block") + } + c.mu.Lock() + defer c.mu.Unlock() + if !c.coverageComplete { + return &IncompleteTransactionStatusCoverageError{CachedRoot: c.rootedThrough} + } + // Parent lineage and ancestor status are mutable, so both remain under the + // publication lock even when hashing and same-bank deduplication happened + // earlier. This keeps commit safe across a concurrent branch transition. + if err := c.validateParentLocked(block); err != nil { + return err + } + if err := c.validateAncestorTransactionsLocked(block.Slot, plan.messageIdentities); err != nil { + return err + } + + delta := make(transactionStatusDelta) + for index := 0; index < plan.messageIdentities.Len(); index++ { + identity := plan.messageIdentities.Identity(index) + blockhash := identity.RecentBlockhash + group := delta[blockhash] + if group == nil { + keyIndex := uint8(0) + if visible := c.visible[blockhash]; visible != nil { + keyIndex = visible.keyIndex + } + group = &transactionStatusGroup{ + keyIndex: keyIndex, + keys: make(map[transactionStatusKey]struct{}), + } + delta[blockhash] = group + } + group.keys[sliceTransactionStatusKey(identity.MessageHash, group.keyIndex)] = struct{}{} + } + + if err := c.legacyAddStatusForBenchmark(delta); err != nil { + return err + } + c.tip = &transactionStatusNode{ + slot: block.Slot, + blockID: solana.Hash(block.AlpenglowBlockID), + hasBlockID: block.HasAlpenglowBlockID, + parent: c.tip, + delta: delta, + } + return nil +} + +// Frozen production commit algorithm before publication optimization. This is +// an independent baseline, including its original visible-index allocation. +// Benchmark fixtures never reuse validation receipts on this path. This frozen +// helper deliberately omits validation-version bumps and must not be used by +// production callers or copied as a model for mutating the live cache. +func (c *TransactionStatusCache) legacyAddStatusForBenchmark(delta transactionStatusDelta) error { + for blockhash, deltaGroup := range delta { + if group := c.visible[blockhash]; group != nil && group.keyIndex != deltaGroup.keyIndex { + return fmt.Errorf("transaction status blockhash %s uses inconsistent key indexes %d and %d", + blockhash, group.keyIndex, deltaGroup.keyIndex) + } + } + for blockhash, deltaGroup := range delta { + group := c.visible[blockhash] + if group == nil { + group = &visibleTransactionStatusGroup{ + keyIndex: deltaGroup.keyIndex, + keys: make(map[transactionStatusKey]uint16), + } + c.visible[blockhash] = group + } + for key := range deltaGroup.keys { + group.keys[key]++ + } + } + return nil +} + +// BenchmarkTransactionStatusPublication times only status publication, with +// prepared message identities. No execution, disk I/O, signing or networking. +// prepared_commit excludes delta preparation; prepared_total includes it and +// goroutine dispatch/join, with no execution overlap. Neither measures replay. +// validated_commit also excludes the successful pre-execution ancestor scan. +// invalidated_commit roots between validation and commit, forcing a full recheck. +// Each iteration restores the same ancestor contents; existing maps retain +// steady-state capacity. Fixture creation, seeding and unwind are not timed. +func BenchmarkTransactionStatusPublication(tb *testing.B) { + const count = 33760 + for _, groups := range []int{1, 4} { + for _, existing := range []bool{false, true} { + tb.Run(fmt.Sprintf("groups_%d/existing_%t", groups, existing), func(tb *testing.B) { + txs := benchmarkUniqueTransactions(count * 2) + for i, tx := range txs { + binary.LittleEndian.PutUint32(tx.Message.RecentBlockhash[:], uint32(i%groups+1)) + } + parent := statusCacheTestBlock(10, txs[:count]...) + if !existing { + parent.Transactions = nil + } + blk := statusCacheTestBlock(11, txs[count:]...) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + tb.Fatal(err) + } + for _, name := range []string{"legacy", "sized", "prepared_total", "prepared_commit", "validated_commit", "invalidated_commit"} { + tb.Run(name, func(tb *testing.B) { + cache := NewTransactionStatusCache() + if err := cache.CommitBlock(parent); err != nil { + tb.Fatal(err) + } + tb.ReportAllocs() + tb.ResetTimer() + for range tb.N { + var err error + if name == "prepared_commit" || name == "validated_commit" || name == "invalidated_commit" { + tb.StopTimer() + prepared := cache.prepareTransactionStatusDelta(plan.messageIdentities) + validation, validationErr := cache.validateBlockForPublication(blk, plan) + if validationErr != nil { + tb.Fatal(validationErr) + } + if name == "invalidated_commit" { + cache.Root(10) + } + tb.StartTimer() + if name == "prepared_commit" { + err = cache.commitBlockWithPreparedDelta(blk, plan, prepared) + } else { + err = cache.commitBlockWithValidation(blk, plan, prepared, validation) + } + } else if name == "prepared_total" { + prepared := cache.startStatusPreparation(plan).wait() + err = cache.commitBlockWithPreparedDelta(blk, plan, prepared) + } else if name == "legacy" { + err = cache.legacyCommitStatusForBenchmark(blk, plan) + } else { + err = cache.commitBlockWithPlan(blk, plan) + } + if err != nil { + tb.Fatal(err) + } + tb.StopTimer() + if got := cache.tip.slot; got != 11 { + tb.Fatalf("tip=%d", got) + } + if err := cache.Unwind(11); err != nil { + tb.Fatal(err) + } + tb.StartTimer() + } + }) + } + }) + } + } +} diff --git a/pkg/replay/transaction_status_publication_test.go b/pkg/replay/transaction_status_publication_test.go new file mode 100644 index 000000000..3b9b92885 --- /dev/null +++ b/pkg/replay/transaction_status_publication_test.go @@ -0,0 +1,154 @@ +package replay + +import ( + "fmt" + "runtime" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/txstatus" + "github.com/stretchr/testify/require" +) + +func TestPreparedStatusDeltaRebindsSnapshotOffsets(t *testing.T) { + for _, from := range []uint64{0, 7, txstatus.MaxCachedKeyIndex} { + for _, to := range []uint64{0, 7, txstatus.MaxCachedKeyIndex} { + t.Run(fmt.Sprintf("%d_to_%d", from, to), func(t *testing.T) { + ancestor := statusCacheTestTransaction(1, 2, 3) + seed := func(offset uint64) *TransactionStatusCache { + cache, err := NewTransactionStatusCacheFromAgaveSnapshot([]txstatus.SnapshotSlotDelta{ + {Slot: 0, IsRoot: true, Statuses: []txstatus.SnapshotStatus{snapshotStatusCacheStatusForTx(t, ancestor, offset)}}, + }, 0) + require.NoError(t, err) + return cache + } + candidate := statusCacheTestBlock(1, statusCacheTestTransaction(1, 4, 5)) + plan, err := planBlockTransactionExecution(candidate) + require.NoError(t, err) + prepared := seed(from).prepareTransactionStatusDelta(plan.messageIdentities) + cache := seed(to) + pinned := cache.View() + require.NoError(t, cache.commitBlockWithPreparedDelta(candidate, plan, prepared)) + require.Equal(t, uint8(to), cache.tip.delta[candidate.Transactions[0].Message.RecentBlockhash].keyIndex) + found, err := cache.View().ContainsTransaction(candidate.Transactions[0]) + require.NoError(t, err) + require.True(t, found) + found, err = pinned.ContainsTransaction(candidate.Transactions[0]) + require.NoError(t, err) + require.False(t, found) + blob, err := cache.SnapshotThrough(1) + require.NoError(t, err) + restored, err := NewTransactionStatusCacheFromSnapshot(blob) + require.NoError(t, err) + found, err = restored.View().ContainsTransaction(candidate.Transactions[0]) + require.NoError(t, err) + require.True(t, found) + require.NoError(t, cache.Unwind(1)) + found, err = cache.View().ContainsTransaction(candidate.Transactions[0]) + require.NoError(t, err) + require.False(t, found) + found, err = cache.View().ContainsTransaction(ancestor) + require.NoError(t, err) + require.True(t, found) + }) + } + } +} + +func TestPreparedStatusDeltaDoesNotPublishUntilCommit(t *testing.T) { + prior := runtime.GOMAXPROCS(2) + defer runtime.GOMAXPROCS(prior) + cache := NewTransactionStatusCache() + require.NoError(t, cache.CommitBlock(statusCacheTestBlock(10))) + candidate := statusCacheTestBlock(11, benchmarkUniqueTransactions(33760)...) + plan, err := planBlockTransactionExecution(candidate) + require.NoError(t, err) + p := cache.startStatusPreparation(plan) + // A rejected bank joins and discards the prepared maps. Waiting is also + // idempotent for the normal commit followed by ProcessBlock's deferred join. + require.Same(t, p.wait(), p.wait()) + found, err := cache.View().ContainsTransaction(candidate.Transactions[0]) + require.NoError(t, err) + require.False(t, found) + require.Equal(t, uint64(10), cache.tip.slot) + cache.mu.Lock() + cache.coverageComplete = false + cache.mu.Unlock() + var incomplete *IncompleteTransactionStatusCoverageError + require.ErrorAs(t, cache.commitBlockWithPreparedDelta(candidate, plan, p.wait()), &incomplete) + require.Equal(t, uint64(10), cache.tip.slot) +} + +func TestPreparedStatusDeltaRejectsWrongPlanWithoutPublishingIt(t *testing.T) { + cache := NewTransactionStatusCache() + require.NoError(t, cache.CommitBlock(statusCacheTestBlock(10))) + left := statusCacheTestBlock(11, statusCacheTestTransaction(1, 2, 3)) + right := statusCacheTestBlock(11, statusCacheTestTransaction(1, 4, 5)) + leftPlan, err := planBlockTransactionExecution(left) + require.NoError(t, err) + rightPlan, err := planBlockTransactionExecution(right) + require.NoError(t, err) + prepared := cache.prepareTransactionStatusDelta(leftPlan.messageIdentities) + // A mismatched prepared delta falls back to the actual block's identities. + require.NoError(t, cache.commitBlockWithPreparedDelta(right, rightPlan, prepared)) + found, err := cache.View().ContainsTransaction(left.Transactions[0]) + require.NoError(t, err) + require.False(t, found) + found, err = cache.View().ContainsTransaction(right.Transactions[0]) + require.NoError(t, err) + require.True(t, found) +} + +func TestPreparedStatusDeltaEmptyBlock(t *testing.T) { + cache := NewTransactionStatusCache() + block := statusCacheTestBlock(10) + plan, err := planBlockTransactionExecution(block) + require.NoError(t, err) + p := cache.startStatusPreparation(plan) + require.Nil(t, p, "empty bank must not queue background work") + require.NoError(t, cache.commitBlockWithPreparedDelta(block, plan, p.wait())) +} + +func TestPreparedStatusDeltaScheduling(t *testing.T) { + for _, threads := range []int{1, 2} { + for _, count := range []int{1, 32, 33} { + t.Run(fmt.Sprintf("threads_%d/txs_%d", threads, count), func(t *testing.T) { + previous := runtime.GOMAXPROCS(threads) + defer runtime.GOMAXPROCS(previous) + cache := NewTransactionStatusCache() + block := statusCacheTestBlock(10, benchmarkUniqueTransactions(count)...) + plan, err := planBlockTransactionExecution(block) + require.NoError(t, err) + p := cache.startStatusPreparation(plan) + defer p.wait() + require.Equal(t, threads > 1 && count > 32, p != nil) + require.NoError(t, cache.commitBlockWithPreparedDelta(block, plan, p.wait())) + for _, tx := range block.Transactions { + found, err := cache.View().ContainsTransaction(tx) + require.NoError(t, err) + require.True(t, found) + } + }) + } + } +} + +func TestPreparedStatusDeltaRebindsMissingSnapshotGroup(t *testing.T) { + ancestor := statusCacheTestTransaction(1, 2, 3) + seed, err := NewTransactionStatusCacheFromAgaveSnapshot([]txstatus.SnapshotSlotDelta{ + {Slot: 0, IsRoot: true, Statuses: []txstatus.SnapshotStatus{snapshotStatusCacheStatusForTx(t, ancestor, 7)}}, + }, 0) + require.NoError(t, err) + candidate := statusCacheTestBlock(1, statusCacheTestTransaction(1, 4, 5)) + plan, err := planBlockTransactionExecution(candidate) + require.NoError(t, err) + prepared := seed.prepareTransactionStatusDelta(plan.messageIdentities) + // Model restore/branch replacement removing the group after preparation. + cache := NewTransactionStatusCache() + require.NoError(t, cache.commitBlockWithPreparedDelta(candidate, plan, prepared)) + inline := NewTransactionStatusCache() + require.NoError(t, inline.commitBlockWithPlan(candidate, plan)) + require.Equal(t, inline.tip.delta, cache.tip.delta) + found, err := cache.View().ContainsTransaction(candidate.Transactions[0]) + require.NoError(t, err) + require.True(t, found) +} diff --git a/pkg/replay/transaction_status_validation.go b/pkg/replay/transaction_status_validation.go new file mode 100644 index 000000000..4413b8b8f --- /dev/null +++ b/pkg/replay/transaction_status_validation.go @@ -0,0 +1,37 @@ +package replay + +import ( + "math" + + b "github.com/Overclock-Validator/mithril/pkg/block" +) + +// transactionStatusValidation records a successful ancestor scan under cache.mu. +// It authorizes skipping only that scan, never the coverage, parent or exact +// block-identity checks. The receipt is private, bound to one cache instance and +// one immutable identity set, and checked under the publication lock. +// +// Mutating the visible index, binding the tip, rooting/pruning or restoring +// invalidates earlier receipts. In particular, committing and then unwinding to +// the same tip cannot resurrect one. Snapshot/Agave constructors create a new +// cache instance; this receipt is neither persisted nor usable after recovery. +// This optimization changes no crash-recovery or durable-checkpoint guarantee. +type transactionStatusValidation struct { + cache *TransactionStatusCache + identities *b.PreparedTransactionMessageIdentities + version uint64 +} + +func (v transactionStatusValidation) reusableForLocked(c *TransactionStatusCache, identities *b.PreparedTransactionMessageIdentities) bool { + return v.cache == c && v.identities == identities && + v.version == c.validationVersion && c.validationVersion != math.MaxUint64 +} + +// invalidateValidationLocked requires exclusive access (mu, or an unpublished +// constructor). Saturation permanently disables reuse instead of wrapping into +// an old generation. Even empty commits invalidate, because they change lineage. +func (c *TransactionStatusCache) invalidateValidationLocked() { + if c.validationVersion != math.MaxUint64 { + c.validationVersion++ + } +} diff --git a/pkg/replay/transaction_status_validation_test.go b/pkg/replay/transaction_status_validation_test.go new file mode 100644 index 000000000..9a1346125 --- /dev/null +++ b/pkg/replay/transaction_status_validation_test.go @@ -0,0 +1,151 @@ +package replay + +import ( + "errors" + "math" + "testing" + + "github.com/gagliardetto/solana-go" +) + +func TestStatusValidationInvalidation(t *testing.T) { + for _, action := range []string{"commit", "unwind", "round_trip", "root", "bind", "restore"} { + t.Run(action, func(t *testing.T) { + cache := NewTransactionStatusCache() + if err := cache.CommitBlock(statusCacheTestBlock(10)); err != nil { + t.Fatal(err) + } + blk := statusCacheTestBlock(11, statusCacheTestTransaction(1, 2, 3)) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + t.Fatal(err) + } + receipt, err := cache.validateBlockForPublication(blk, plan) + if err != nil { + t.Fatal(err) + } + if !receipt.reusableForLocked(cache, plan.messageIdentities) { + t.Fatal("unchanged receipt not reusable") + } + switch action { + case "commit", "round_trip": + err = cache.CommitBlock(statusCacheTestBlock(11)) + if err == nil && action == "round_trip" { + err = cache.Unwind(11) + } + case "unwind": + err = cache.Unwind(10) + case "root": + cache.Root(10) + case "bind": + err = cache.BindTipBlockID(10, solana.Hash{1}) + case "restore": + var data []byte + data, err = cache.SnapshotThrough(10) + if err == nil { + cache, err = NewTransactionStatusCacheFromSnapshot(data) + } + } + if err != nil { + t.Fatal(err) + } + if receipt.reusableForLocked(cache, plan.messageIdentities) { + t.Fatal("receipt survived " + action) + } + }) + } +} + +func TestStatusValidationCannotCrossCacheOrIdentity(t *testing.T) { + good := NewTransactionStatusCache() + bad := NewTransactionStatusCache() + tx := statusCacheTestTransaction(1, 2, 3) + if err := good.CommitBlock(statusCacheTestBlock(10)); err != nil { + t.Fatal(err) + } + if err := bad.CommitBlock(statusCacheTestBlock(10, tx)); err != nil { + t.Fatal(err) + } + blk := statusCacheTestBlock(11, tx) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + t.Fatal(err) + } + receipt, err := good.validateBlockForPublication(blk, plan) + if err != nil { + t.Fatal(err) + } + // Both caches have the same version and parent slot, but different contents. + if good.validationVersion != bad.validationVersion { + t.Fatal("fixture must have equal versions") + } + prepared := bad.prepareTransactionStatusDelta(plan.messageIdentities) + var already *AncestorAlreadyProcessedTransactionMessagesError + if err := bad.commitBlockWithValidation(blk, plan, prepared, receipt); !errors.As(err, &already) { + t.Fatalf("foreign cache: %v", err) + } + + unique := statusCacheTestBlock(11, statusCacheTestTransaction(4, 5, 6)) + uniquePlan, err := planBlockTransactionExecution(unique) + if err != nil { + t.Fatal(err) + } + receipt, err = bad.validateBlockForPublication(unique, uniquePlan) + if err != nil { + t.Fatal(err) + } + if err := bad.commitBlockWithValidation(blk, plan, prepared, receipt); !errors.As(err, &already) { + t.Fatalf("foreign identities: %v", err) + } + failed, err := bad.validateBlockForPublication(blk, plan) + if err == nil || failed.cache != nil { + t.Fatalf("failed validation returned a receipt: %+v, %v", failed, err) + } +} + +func TestStatusValidationSaturation(t *testing.T) { + cache := NewTransactionStatusCache() + cache.validationVersion = math.MaxUint64 - 1 + blk := statusCacheTestBlock(1) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + t.Fatal(err) + } + receipt, err := cache.validateBlockForPublication(blk, plan) + if err != nil { + t.Fatal(err) + } + cache.Root(0) + cache.Root(0) + if cache.validationVersion != math.MaxUint64 || receipt.reusableForLocked(cache, plan.messageIdentities) { + t.Fatal("generation wrapped or old receipt reusable") + } + receipt, err = cache.validateBlockForPublication(blk, plan) + if err != nil { + t.Fatal(err) + } + if receipt.reusableForLocked(cache, plan.messageIdentities) { + t.Fatal("saturated cache allowed reuse") + } + if err := cache.commitBlockWithValidation(blk, plan, nil, receipt); err != nil { + t.Fatal(err) + } +} + +func TestStatusValidationStillChecksBlockBinding(t *testing.T) { + cache := NewTransactionStatusCache() + blk := statusCacheTestBlock(1, statusCacheTestTransaction(1, 2, 3)) + plan, err := planBlockTransactionExecution(blk) + if err != nil { + t.Fatal(err) + } + receipt, err := cache.validateBlockForPublication(blk, plan) + if err != nil { + t.Fatal(err) + } + prepared := cache.prepareTransactionStatusDelta(plan.messageIdentities) + blk.Transactions[0] = statusCacheTestTransaction(4, 5, 6) + if err := cache.commitBlockWithValidation(blk, plan, prepared, receipt); err == nil { + t.Fatal("replaced transaction accepted") + } +} diff --git a/pkg/rewards/alpenglow_rewards_test.go b/pkg/rewards/alpenglow_rewards_test.go index 76136886d..ae9d451bb 100644 --- a/pkg/rewards/alpenglow_rewards_test.go +++ b/pkg/rewards/alpenglow_rewards_test.go @@ -116,6 +116,59 @@ func TestAlpenglowEarnedPointsAreNotCreditsOnly(t *testing.T) { )) } +func TestAlpenglowSkippedRewardCreditsRespectStakeActivation(t *testing.T) { + votePubkey := solana.PublicKey{1} + voteState := &sealevel.VoteStateVersions{ + Type: sealevel.VoteStateVersionV4, + V4: sealevel.VoteState4{EpochCredits: []sealevel.EpochCredits{{ + Epoch: 115, Credits: 2_000, PrevCredits: 1_000, + }}}, + } + mode := RewardCalculationMode{ + FullAlpenglow: true, + RewardEpochDelegatedStakes: map[solana.PublicKey]uint64{votePubkey: 1_000_000}, + } + for _, tc := range []struct { + name string + activation, deactivation uint64 + advance bool + }{ + {"fully cooled in rewarded epoch", 103, 114, false}, + {"not yet activating", 116, math.MaxUint64, false}, + {"activating in rewarded epoch", 115, math.MaxUint64, true}, + {"effective fractional reward", 103, math.MaxUint64, true}, + {"still cooling in rewarded epoch", 103, 115, true}, + } { + t.Run(tc.name, func(t *testing.T) { + delegation := &sealevel.Delegation{ + VoterPubkey: votePubkey, StakeLamports: 1, + ActivationEpoch: tc.activation, DeactivationEpoch: tc.deactivation, + CreditsObserved: 1_000, + } + pcs := calculateStakePointsAndCredits(solana.PublicKey{}, &sealevel.SysvarStakeHistory{}, + delegation, voteState, nil, 115, mode) + require.True(t, pcs.Points.Eq(wide.Uint128{})) + require.Equal(t, uint64(2_000), pcs.NewCreditsObserved) + require.Equal(t, tc.advance, shouldForceCreditsOnly(pcs, 1, tc.activation, 115, 1_000, mode)) + }) + } +} + +func TestInactiveStakePreservesExplicitCreditUpdates(t *testing.T) { + pcs := CalculatedStakePoints{NewCreditsObserved: 2_000, Inactive: true} + mode := RewardCalculationMode{FullAlpenglow: true} + require.False(t, shouldForceCreditsOnly(pcs, 1, 103, 115, 1_000, mode)) + require.True(t, shouldForceCreditsOnly(pcs, 0, 103, 115, 1_000, mode), "disabled inflation") + require.True(t, shouldForceCreditsOnly(pcs, 1, 115, 115, 1_000, mode), "activation epoch") + pcs.ForceCreditsUpdateWithSkippedReward = true + require.True(t, shouldForceCreditsOnly(pcs, 1, 103, 115, 3_000, mode), "vote credit rewind") + + // Tower does not inherit Alpenglow's automatic skipped-reward advance. + pcs.ForceCreditsUpdateWithSkippedReward = false + pcs.Inactive = false + require.False(t, shouldForceCreditsOnly(pcs, 1, 103, 115, 1_000, RewardCalculationMode{})) +} + func TestInflationRewardsUseHistoricalSlotTimeTransitions(t *testing.T) { schedule := &sealevel.SysvarEpochSchedule{ SlotsPerEpoch: 54_000, diff --git a/pkg/rewards/inactive_stakes_test.go b/pkg/rewards/inactive_stakes_test.go new file mode 100644 index 000000000..c1edc7c95 --- /dev/null +++ b/pkg/rewards/inactive_stakes_test.go @@ -0,0 +1,118 @@ +package rewards + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/Overclock-Validator/mithril/pkg/bankhash" + "github.com/Overclock-Validator/mithril/pkg/features" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/lthash" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + bin "github.com/gagliardetto/binary" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +// This reduced incident fixture holds all unrelated slot effects constant. +// The production reward calculator, spool distributor and bank hasher must +// leave the 33 fully cooled stakes unchanged. See testdata/epoch116/README.md. +func TestEpoch116InactiveStakeBankHash(t *testing.T) { + var fixture struct { + ParentBankhash string `json:"parent_bankhash"` + Blockhash string `json:"blockhash"` + ExpectedBankhash string `json:"expected_bankhash"` + OriginalBankhash string `json:"original_bankhash"` + BaseLtHash []byte `json:"base_accounts_lt_hash"` + StakeHistory []byte `json:"stake_history"` + Stakes []struct { + Account *accounts.Account `json:"account"` + VoteEpochCredits sealevel.EpochCredits `json:"vote_epoch_credits"` + OriginalUpdatedDataSHA256 string `json:"original_updated_data_sha256"` + } `json:"stakes"` + } + raw, err := os.ReadFile("testdata/epoch116/inactive-stakes.json") + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &fixture)) + require.Len(t, fixture.Stakes, 33) + + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "accounts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "largest_file_id"), make([]byte, 8), 0o644)) + db, err := accountsdb.OpenDb(dir) + require.NoError(t, err) + db.InitCaches() + t.Cleanup(db.CloseDb) + global.ClearPendingStakePubkeys() + t.Cleanup(global.ClearPendingStakePubkeys) + + parents := accounts.NewMemAccounts() + var storedAccounts, erroneousUpdates []*accounts.Account + votes := make(map[solana.PublicKey]*sealevel.VoteStateVersions) + for _, row := range fixture.Stakes { + acct := row.Account + stake, err := sealevel.UnmarshalStakeState(acct.Data) + require.NoError(t, err) + require.Equal(t, uint64(114), stake.Stake.Stake.Delegation.DeactivationEpoch) + require.Equal(t, uint64(115), row.VoteEpochCredits.Epoch) + voteKey := stake.Stake.Stake.Delegation.VoterPubkey + votes[voteKey] = &sealevel.VoteStateVersions{ + Type: sealevel.VoteStateVersionV4, + V4: sealevel.VoteState4{EpochCredits: []sealevel.EpochCredits{row.VoteEpochCredits}}, + } + require.NoError(t, parents.SetAccountWithoutLock(acct.Key, acct)) + storedAccounts = append(storedAccounts, acct) + global.EnqueuePendingStakePubkey(6264000, acct.Key) + + // Independently reconstruct the logged erroneous write, and verify its + // byte hash before using it to establish the original bad bank hash. + bad := acct.Clone() + stake.Stake.Stake.CreditsObserved = row.VoteEpochCredits.Credits + require.NoError(t, sealevel.MarshalStakeStakeInto(stake, bad.Data)) + require.Equal(t, row.OriginalUpdatedDataSHA256, fmt.Sprintf("%x", sha256.Sum256(bad.Data))) + erroneousUpdates = append(erroneousUpdates, bad) + } + stored := make(chan struct{}) + require.NoError(t, db.StoreAccounts(storedAccounts, 6264000, func() { close(stored) })) + <-stored + count, err := global.FlushPendingStakePubkeysThrough(dir, 6264000) + require.NoError(t, err) + require.Equal(t, len(fixture.Stakes), count) + db.RootedDurable = true + + f := &features.Features{} + f.EnableFeature(features.AccountsLtHash, 0) + f.EnableFeature(features.RemoveAccountsDeltaHash, 0) + calculateHash := func(updates []*accounts.Account) string { + ctx := &sealevel.SlotCtx{ + Features: f, ParentAccts: parents, + AcctsLtHash: new(lthash.LtHash).InitWithHash(fixture.BaseLtHash), + } + return solana.HashFromBytes(bankhash.CalculateBankHash(ctx, nil, updates, + solana.MustHashFromBase58(fixture.ParentBankhash), 0, + solana.MustHashFromBase58(fixture.Blockhash))).String() + } + require.Equal(t, fixture.OriginalBankhash, calculateHash(erroneousUpdates)) + + var history sealevel.SysvarStakeHistory + require.NoError(t, history.UnmarshalWithDecoder(bin.NewBinDecoder(fixture.StakeHistory))) + newRateEpoch := uint64(0) + result, err := CalculateRewardsStreaming(db, 6264000, &history, &newRateEpoch, + votes, PointValue{Rewards: 12922370184029}, 115, [32]byte{}, + &sealevel.SlotCtx{Features: f}, f, RewardCalculationMode{FullAlpenglow: true}) + require.NoError(t, err) + updated, _, distributed, burned := DistributeStakingRewardsFromSpool( + db, result.SpoolDir, result.SpoolSlot, 0, 6264001, nil) + require.Zero(t, distributed) + require.Zero(t, burned) + require.Equal(t, fixture.ExpectedBankhash, calculateHash(updated)) + require.Zero(t, result.NumStakeRewards) + require.Equal(t, uint64(1), result.NumPartitions) + require.Empty(t, updated) +} diff --git a/pkg/rewards/rewards.go b/pkg/rewards/rewards.go index ce0e824a0..bfff0ebfc 100644 --- a/pkg/rewards/rewards.go +++ b/pkg/rewards/rewards.go @@ -43,6 +43,9 @@ type CalculatedStakePoints struct { Points wide.Uint128 NewCreditsObserved uint64 ForceCreditsUpdateWithSkippedReward bool + // Inactive is set by Alpenglow points calculation when the delegation + // has neither effective nor activating stake in the rewarded epoch. + Inactive bool } const legacyInflationSlotsPerYear = 78_892_314.984 @@ -697,11 +700,14 @@ func calculateStakePointsAndCredits( } newObserved = max(newObserved, latest.Credits) - effectiveStake := delegation.StakeActivatingAndDeactivating( + status := delegation.StakeActivatingAndDeactivating( rewardedEpoch, stakeHistory, newRateActivationEpoch, - ).Effective - if earnedCredits == 0 || effectiveStake == 0 { - return CalculatedStakePoints{NewCreditsObserved: newObserved} + ) + if earnedCredits == 0 || status.Effective == 0 { + return CalculatedStakePoints{ + NewCreditsObserved: newObserved, + Inactive: status.Effective == 0 && status.Activating == 0, + } } totalStake := mode.RewardEpochDelegatedStakes[delegation.VoterPubkey] if totalStake == 0 { @@ -711,7 +717,7 @@ func calculateStakePointsAndCredits( } } points := wide.Uint128FromUint64(earnedCredits). - Mul(wide.Uint128FromUint64(effectiveStake)). + Mul(wide.Uint128FromUint64(status.Effective)). Div(wide.Uint128FromUint64(totalStake)) return CalculatedStakePoints{Points: points, NewCreditsObserved: newObserved} } @@ -789,10 +795,14 @@ func shouldForceCreditsOnly( pointValueRewards, activationEpoch, rewardedEpoch, creditsObserved uint64, mode RewardCalculationMode, ) bool { + // Agave's skipped-reward credit advance applies only to effective or + // activating Alpenglow stakes. Fully cooled stakes must retain their + // account bytes, even though their vote account has earned new credits. + // The explicit forced-update cases still take precedence. return pcs.ForceCreditsUpdateWithSkippedReward || pointValueRewards == 0 || activationEpoch == rewardedEpoch || - (mode.FullAlpenglow && pcs.Points.Eq(wide.Uint128{}) && pcs.NewCreditsObserved != creditsObserved) + (mode.FullAlpenglow && !pcs.Inactive && pcs.Points.Eq(wide.Uint128{}) && pcs.NewCreditsObserved != creditsObserved) } // CalculateRewardsStreaming performs a streaming calculation of stake rewards. diff --git a/pkg/rewards/testdata/epoch116/README.md b/pkg/rewards/testdata/epoch116/README.md new file mode 100644 index 000000000..41cd2cb6a --- /dev/null +++ b/pkg/rewards/testdata/epoch116/README.md @@ -0,0 +1,49 @@ +# Epoch 115 → 116 inactive-stake regression + +`inactive-stakes.json` is a reduced fixture from the Alpenglow failure at slot +6,264,001 on 2026-09-21, running Mithril `320ce8da`. It contains the 33 fully +cooled stake accounts that Mithril incorrectly rewrote, their vote accounts' +epoch-115 credits, and the saved StakeHistory sysvar. All 33 delegated stakes +had deactivation epoch 114 and zero effective/activating stake in epoch 115. + +The source was the supplied `/mnt/mithril-accounts` checkpoint at slot 6,264,000 +and `footer-bankhash-mismatch-slot-6264001.json` in the supplied logs. AccountsDB +was opened read-only. Account bytes are public chain data; no keys or validator +identity files are included. + +To isolate the defect, `base_accounts_lt_hash` includes all correct slot effects +and the unchanged 33 accounts. The other 792 modified accounts were reconstructed +from the parent checkpoint and deterministic slot updates; all 825 reconstructed +accounts matched the diagnostic's individual SHA-256 data hashes. Combining +their deltas with the saved parent LtHash reproduced both the diagnostic LtHash +checksum and the original bad bank hash. Undoing only the 33 credit-only writes +then reproduced the exact expected footer hash: + +| State | Bank hash | +| --- | --- | +| Original 33 erroneous writes | `CCY2QcFoGGAodDaB9RCAW2RUbbZm2xMo9dJw8tKHdDcG` | +| Preserve the 33 inactive accounts | `BBXWdsTHc3EdN8zXbcZZ8vwqtYjzggD3gp8CqkZuBvBm` | + +`TestEpoch116InactiveStakeBankHash` checks each reconstructed erroneous account +against its recorded data hash, checks the bad bank hash, and then runs the +production streaming calculator, spool distributor and bank hasher. Before the +fix, that path emitted 33 zero-lamport writes and produced the bad hash. After +the fix it emits no writes for these stakes and produces the expected hash. +Other slot effects are held constant; this is not a full signed-shred replay or +a replay of later slots. + +Reference behavior: + +- [Agave ab655329, inflation_rewards/mod.rs:254–270](https://github.com/anza-xyz/agave/blob/ab6553293094e59dee7d3e7c928c7fa1023d0684/runtime/src/inflation_rewards/mod.rs#L254-L270) + restricts skipped-reward credit advancement to effective or activating stake, + preserving the explicit forced-update cases. +- [Firedancer 57d39904, fd_rewards.c:649–678](https://github.com/firedancer-io/firedancer/blob/57d39904e3886731b96b9174ff8763ee7c36e3ad/src/flamenco/rewards/fd_rewards.c#L649-L678) + rejects an inactive credit-only update. Its + [points calculation at lines 908–917](https://github.com/firedancer-io/firedancer/blob/57d39904e3886731b96b9174ff8763ee7c36e3ad/src/flamenco/rewards/fd_rewards.c#L908-L917) + retains the inactive flag from the existing activation-status calculation. + +Run with: + +```sh +go test ./pkg/rewards -run TestEpoch116InactiveStakeBankHash -count=1 -v +``` diff --git a/pkg/rewards/testdata/epoch116/inactive-stakes.json b/pkg/rewards/testdata/epoch116/inactive-stakes.json new file mode 100644 index 000000000..e6dec5b48 --- /dev/null +++ b/pkg/rewards/testdata/epoch116/inactive-stakes.json @@ -0,0 +1,1693 @@ +{ + "parent_bankhash": "B2Yi5ecGCiZRpvjdrLeQj2SdSncKgi6zEmVJxpMfzfyB", + "blockhash": "GBQcgk9JL3YFaK1GvpKLGpCbpmJ3My4oYWztnAJ3wirY", + "expected_bankhash": "BBXWdsTHc3EdN8zXbcZZ8vwqtYjzggD3gp8CqkZuBvBm", + "original_bankhash": "CCY2QcFoGGAodDaB9RCAW2RUbbZm2xMo9dJw8tKHdDcG", + "base_accounts_lt_hash": "Mnn6NyRVWRvlXK1fJ68LNsMwgwvv5fH/brKehzoh9D+CaKo7hT/Wa1o3UkLLI7xs14mAj7EgyHCauhEVsMQGISCDkpWwejJy5tH/1Ov8yb+FM+1ZkSIO1vL8vsxDGipgAGfNXO+nGnh/ldg6Jbv66RgGdXJtR4b2IMUD970fj9z7229YpWP7PIwy70TEJ5VQuS16bXRwJlA8cRSSn7RMx9/yW2G4WhS+XAGi13N1uPsIHUkm6DePr4WPRsUi4UjMP3F8fpYUCAyVi8NMeFBPPGHZa/ZkR2C3NTk1Fs/jpI3UTw7MI61RnDFZs2vd4YYx6gr2g2jRKBX2/3NgSEK0112FCnB6T9V+LM6b+flCUlL5OddAd5MtN+4gDN2DlHAX20jb8VTWqwvb602v877ha/L+YK09+D+Gr9+iNxx8CSCAycb9moun0LGVSg6TgenXfhwKQbYlLtbIB7bD6YCrLBeP4A9rifOBIobmidjeYBjF7kicq8vMrH3j+flLSsn4fHXsssP9edqitP69ycRm7nLrYEFTR5+tl985wzrWi9DtOnImREgGo1oHBqGxXe6BksLdnCSei1i9SOX3oEaxKWf8bcf28ocL3pf5JKw3cBNALWTdjda/w/CMkg4N6MB6pUurImpm1M+Sg9O2Qyvv6v24RgG/TBdqMTmt3b46einSa+KZO72BQ+c4KkihabsRBpJNYpLQAiSH1Xm6FWoSuu63pRnGRyYokF3JC7K7M5wYpqE2EYJUUVM/SH7uS/JhOmWZXtGSpBcq0kQjHfIIjsJhrD2om9NJgVxEf4DhwK3ZTHUdTE5SY5aOwB/HVNlaTQd2Ab1ZsJncKAGIz1LcJpLB1uI3CtjtTwKUYrYTukHpWl2OQySqy0gmEzNPqmDUbhMUuEHcvJBUw30wLusR/BmlR9RErlxtigecyID+K9O9sTssRnAfuCY6Hm9SQP0rd6VRbIDqcfvSp4nOdYIZhx84IcN3SkN2Q9ipCc/OBCf0b2vIUu/7YS7VPv0gUQCKgg+kHJzZzUD5vHsOTw2623uxug2Y3tkiaVgrh861s2lJTjsSz351rMZQc0nzjis+GAoOuzo34zpGX3duLHYzxxdiRcX0jSvM4C9CVX47dQsYnltzVgOfXbeLYxA19SBjrhErooboDLe5OfeFeHGMIUO01sQ7dXXTzLZt03Opb8UjTbAtV3zCN4l08vObalYPFm4zEuTGYVnWIrJp4d2sEg/p7ejmWQNif/36zSLANp6ILgJqbVp4uATjuPPQD/nEcqlTspMy3f9yDK2Du3DjOcRNR6w17AtQvKsyH5dNo3w9JWG0a9HeBciz5CwdqYZ8cTi9jouD9Yj8roxAH9l63gNNJ+RgO62/17EBw9fS3pbNDfyJbZfvbnFq1KpIHe0LqK0K7hL7zTpcu/KgDKJLLZZe1YeDUm8jlVlhpbuDCBr3oj5N2p37581r5thU0F4f8//vqHm8G7i2UWt7sXtxJJ5JhUDV7RLYLfmFRc7ZXBJ7Z9qAxTo4WIWWfb3QsxSgZK2VfBFerxHzE8QlDCukgAHnjRxx/sVAHU5kqTI+jHpXsDogLmCTvaTuIW7x2WAmdEazWRi//5J9COsBt3wOwQwwBq7nRfj8w7Zo/M5xwbdpTv/JsvLQKmc51uaYVBSHZy8q54g7jo+j75jiA6TCh4KzMejWbH+c9Ew44ATW5mLiPFCJxmkZLI9Dl5XXxTtspSkHlTinziKS8+FCFUD8iCdnXKkym5gD1oO0imsGvBRs1qqnwbIaOv7esnnh4n8V3ML5La3du54etTnaGlAI35lGv2AEc2otPbHDvffI3PsMuEi9YkktqO7rJmFieUIU/uNiMevudlQqqrWM499kCOI7C6drxi8KfdcCd9b88nGSL6MlKu7F0cwttJWCb2uDTWEA2vYbxpUtDxZ8xYNeCdKDHJbNGk9viaLneQ0BzE3GQLnZPEYAuVkAbh+mWN0o/kmL8DQF4wpa1xBFUBBD6zhhM5zL6yAF5zT5k+bGkrIGS6+D0OteyVZnB+2y/ttjRknqhc+5CQVr90dEBsk+ivEsz7Ta/vZ4ibYJOgHq6z4OfagQWaWCiYBaQoTfKhLpekY3HrIMhQSsXuH8UjiRULhj0mqLnX7UarLLCTAvqEsvCgQxH1vzNbeomzI3HlJtWTrQz/vv8DOPXzJE/aYuxbyTAhq6uZH6TkH4qmv9lOh5dLbtQVZq1uf0upbJgiamwRyf+PGKqBe+rcYz3Mp/2aJk+jAYx4WGVdd66COzTvV///vH+M+/E7Yj4ueEnqsQVuLMtjAW+xTu9UiA/eb/mMVBpzeRaOUELDeYHwiKR8HiwLH73UO2AGuFsCAhqQPsYP/SSOaKvJinVKe7uclMgEgSAknr2UTxVjoPay18vynGYJOYDBrxFaxaDQbCglcjxfJW016E9SGRbxxlZZOqpr9qZtO20MgGMsgotBKniJ87ssxlJDJ+Kx7w2MWcIN5dv7kFj9KVIiBJ206fg6fQUHB+/4g0x7rBedhfmDtWizaNyqNcpWHfRMhoIPI+nOij3u8ASv/Z5HgqvFDeqn3MBix5Z/4+vRpS24pN83kvdbaGnyLSZ6jNuq2wfdN2PYYlnUczLvrwD7QjUkf+mf3PVhZxM+56+lnYG52tToqXIqAtrq4fkJq73lCOLwnd+8cCkdbQtx3qQF+NiR1eMiewNwz6cVTKoMdNqeYsGmXRNMY=", + "stake_history": "dAAAAAAAAABzAAAAAAAAAJ5AeDrqWjwAaanDLwG0AQBXHmbgZhcAAHIAAAAAAAAAJXm6zkOLNwDYUEVSW28GAPX9Y+xHMAAAcQAAAAAAAACbOD5ij1s7AH8vYWThAAAANhpRt13RAwBwAAAAAAAAAEKuN0SOQEAAGg55mi/jAABjcyFX7dUGAG8AAAAAAAAA243TwqZJPgCgvqaXc7gCAGxJJAa+wQAAbgAAAAAAAADlcqREpWI+AF6z7ht6CQAA2yghg6ciAABtAAAAAAAAAOSauglaSj4A7jLIrkcZAABAJ1t/KwEAAGwAAAAAAAAA1bfp3PxJPgBswjk12BAAAMxhZcmqEAAAawAAAAAAAACgpYOCjzs+ADdbCt+GKwAAyzEg90YdAABqAAAAAAAAAHdjrA3dQj4A26mxDlISAAB8hECCzhkAAGkAAAAAAAAAAEdfVIHpPQBW2hsNHo8AAC+XfvrrNQAAaAAAAAAAAABo9vZqfuU9AA58Eg7PBAAABIpq+vsAAABnAAAAAAAAAJFweNQULT4AqV72RLRIAAD2LXPQeJAAAGYAAAAAAAAA9qEC3Yo/PgCF7iNpyQ4AAAZZwTZvIQAAZQAAAAAAAAAuQbhfCTk+ANbBFd9uDAIAstBbUhgGAgBkAAAAAAAAADB84TH1QT4Agz3YD9wMAAAUQT/28xUAAGMAAAAAAAAAgc99eYkCPgDGnMraNEECAIGF5S/2AQIAYgAAAAAAAABNb2WWlro9AGWphlSdhQAAyYxLbNc9AABhAAAAAAAAAFmW5xtaxj0AF1Ks46MDAACV5g+0lg8AAGAAAAAAAAAAQgPK8m0kPgD7oWlICjIAALtPvWhHkAAAXwAAAAAAAADvAGYj8wo+AHtuvjg5PwIA4Gqgee8lAgBeAAAAAAAAAPRPsb1kNj0A6J0nTzorAwC0i2yT2VYCAF0AAAAAAAAAIiDIxHosPgBZmiIiYSYAACkVK22lHAEAXAAAAAAAAAAJvmQRggs+AAIGnLbqIAAAWMutxRwAAABbAAAAAAAAAL/QM8VAMD4Aw7etwKcAAADauqqckSUAAFoAAAAAAAAA65RRlJUVPgDMSCTC02cAAIfN5q1YTQAAWQAAAAAAAACP/BZcKsU9ACAmYvigdAAAiDfz8mMkAABYAAAAAAAAAEK6x8WZyT0ArEiebikKAACSNlCKxQ4AAFcAAAAAAAAACTBqaG7SPQCoR3XOSAsAAF71j7lJFAAAVgAAAAAAAAAjWmA61589AMtdU9w4VwAABJfvbs4kAABVAAAAAAAAAG/JhOUTaT0AUMvSkd44AAB1Iv0rRAIAAFQAAAAAAAAAfK7kidvtPQBNKEsZw0gAAC4+dp63zQAAUwAAAAAAAAB8tlqi8ts9APvZJnnSHwAA/WCY9BIOAABSAAAAAAAAAMAYELB0AD4A+dGs4HEaAADOy8rLIT8AAFEAAAAAAAAA0TQODTb8PQD+ZIrMvQ0AAKLsbq2qCQAAUAAAAAAAAACb6i9EQwM+AEicn0T6PAAAyAM4YTVEAABPAAAAAAAAAJeDquB9Qj4A29/hUTE6AADzZwr3lnkAAE4AAAAAAAAA3YhSTtglPgA1M80MFSYAAGDVIn6dCQAATQAAAAAAAADavbp48hw+ADUoxH3YCAAAmmSrqRsAAABMAAAAAAAAAM9KZ53SHT4AFPzaJG8EAABBh/RQegUAAEsAAAAAAAAA+HspIqN4PQAJ3QG6ZLgAAIXaa4phEwAASgAAAAAAAAC0Hzu/esY6AHt5mUByuQIAmiDhzXUHAABJAAAAAAAAAEMqtGP2cD0A84gQpRYFAABAZYJ8w68CAEgAAAAAAAAADk3OwuxvPQDAS3n7GBkAAE+VB8A4GAAARwAAAAAAAADRsZ0Vx1U9AHLLb4SiHgAAZfdTUKYEAABGAAAAAAAAAKNb7H87Qj0AZZCx2/oiAACT9t0tmQ8AAEUAAAAAAAAAQvmdRxdOPQDVa544vgcAAJYPouDEEwAARAAAAAAAAADx6iXLmDU8APdU+ynaJwEA6/70KIYPAABDAAAAAAAAANZyds2GODwAFLJEEZYGAACa0y6VrgkAAEIAAAAAAAAAe6+cLig2PABhXBlazAsAAC0teOaYCQAAQQAAAAAAAACxff6Ze1E8ACrBuEaqiwAAJkWtgCmnAABAAAAAAAAAAPLJlmbtbzwATFPM6gYKAABz5YOHoygAAD8AAAAAAAAAlXqLG0GDPADGPdE0LRAAAJvWmUOwIwAAPgAAAAAAAAAGKrZkAoU8AFZorVhRMQAALFQFTUEzAAA9AAAAAAAAAAoUqYH8sTsA4IDLEML4AAC9/5aE6iUAADwAAAAAAAAAuzL+y6ZrPAAB8TW7cSgAACoQ4FtL4gAAOwAAAAAAAAAn1bWd5RM8ANwhM7NEiQAA9kqDwa4xAAA6AAAAAAAAAFxafIPkIDwAK2eWKPQ2AADVJYEPHkQAADkAAAAAAAAAViHBYFraOwD4j7g0FpAAADQ7TdK4SQAAOAAAAAAAAAC0B1p+6RA8ALCVqnGzOQAAclnWd2pwAAA3AAAAAAAAABJqVZSu8TsAxZ4RSV55AADnTpfITVoAADYAAAAAAAAATOvQCGvlOwAInOaCLw4AAGlNI3AVAgAANQAAAAAAAADiOkL7a3I7AP3nWviafwIAPurnf8cMAgA0AAAAAAAAAISnBw5hVDsASNjGSPMhAABXGeKPGwQAADMAAAAAAAAA75Bzrs90OwAB3HKPxhQDADDloUJeNQMAMgAAAAAAAADdNGOwmVQ6AKrWSFPxbgEAhqz8EelOAAAxAAAAAAAAAKVlV2M5bTsAt7J6MWEZAABWgGutNTIBADAAAAAAAAAATdF/eGFmOwCMYa1ndx8CAFnG6qTIGAIALwAAAAAAAADaHqDgbWs6APgZHPi53gEAfGTrf/rjAAAuAAAAAAAAAHVDwpv5ozoAnUFqwhSoAAAwz2LP1eAAAC0AAAAAAAAAaE1WTy5QOgC7Lb6VWeQAANrlRLzBkAAALAAAAAAAAABMwuR/ciM6AEHpLe8wUwAAcI0k8acmAAArAAAAAAAAAGUV3bRCGDoAkDAWSOEkAACeekS34RkAACoAAAAAAAAArx9e+fYeOgB8ySqUlQgAAAs3i/h6DwAAKQAAAAAAAACNHcp9cl06AJaDFRAWbgAATpEP8MasAAAoAAAAAAAAAJSotzIIpzoAFwSV826JAAAy92cXMtMAACcAAAAAAAAAPFUSdQ65OgDoJJ6NhCcAADCwfvHBOQAAJgAAAAAAAAAA6p5t5546AMm2R+E9JAAAo0xN31AKAAAlAAAAAAAAAO6h+UfxQToAnx24iv5qAAAddIZ3PQ4AACQAAAAAAAAAKa2tRNmFOgACcRzbgS0AANYPtIOhcQAAIwAAAAAAAAAGOyVz3zI6ALqiF0x0+QAAi9QI3qumAAAiAAAAAAAAAOcmAEoM+jgA4sBkWs5BAQCzLciCLAkAACEAAAAAAAAAdn8sBEvPNABKZYiSUOEFAKq488AelgAAIAAAAAAAAAD0lsvPNDw1AL9v6SJ28wAAlbOWU4hgAQAfAAAAAAAAAGk2uzPpvTUADkS44IFUBAD3aJUNxGQFAB4AAAAAAAAAG4YELojOMwBT2ECjQoQEAAUoip3hlAIAHQAAAAAAAADYscqSXXQzAFufDzqhIgEAGMvVnnbIAAAcAAAAAAAAALtrLOOo4TIAHUaer7SSAAAAAAAAAAAAABsAAAAAAAAAPlfpgiWuLgD+x4A4R4oEAAAAAAAAAAAAGgAAAAAAAABhNJyrrrQrAPe6b3aatQcA4T05ZoT1AAAZAAAAAAAAAOgqRqBFESoAOA1GOSyqCgD7mGj70iUCABgAAAAAAAAAQUvKQ90zKQBbee1BlPgEAIO7vG7m1wIAFwAAAAAAAAB0/UZWrDMpAFfZoR9TcQgAbNhDyqKIBgAWAAAAAAAAAGTa4bqZwSoArSc7BflKAgATQNWp51cJABUAAAAAAAAAuJa1PExVKgAIutFU5XQAADZwSSvTCAAAFAAAAAAAAABdiCSRT04qAEvh4+XMBwAA6Ma1tAYBAAATAAAAAAAAALBNfKM56ykAxKmk5NtrAAAgpzgl+ggAABIAAAAAAAAAcrdEoMDgKQCuPiLWZmYAANwwSk8mXAAAEQAAAAAAAAAUXTuSI9EnAILkep/EswIA4YaSbF2kAAAQAAAAAAAAAEhOd7PGYiUAJd7/3yVuAgAAAAAAAAAAAA8AAAAAAAAAVP+zxFlMIgDlgfGO3iIFAAAAAAAAAAAADgAAAAAAAABT2ixTNXcfALK/SZfhyAcAAAAAAAAAAAANAAAAAAAAAESpbbv03RwARWz5UURhCgAAAAAAAAAAAAwAAAAAAAAAQFrTd6Z7GgDMXyiGppQMAAAAAAAAAAAACwAAAAAAAABcnB0IxEsYAH3pX8hj/w0AAAAAAAAAAAAKAAAAAAAAAJCaKkMgShYA38jJYAvPDwAAAAAAAAAAAAkAAAAAAAAAF58Ll+hyFACFLkwOVlARAAAAAAAAAAAACAAAAAAAAAAxtAZmmsISAEKE8AX0/hIAAAAAAAAAAAAHAAAAAAAAAHXDQI4BNhEAACv2ahmEFAAAAAAAAAAAAAYAAAAAAAAACt0HxSrKDwDjq1Ie9r8VAAAAAAAAAAAABQAAAAAAAACxrhYtYXwOAN0of1y9rhYAAAAAAAAAAAAEAAAAAAAAAC2YPXwpSg0Ap97CpRE4FwAAAAAAAAAAAAMAAAAAAAAANf2HdjwxDAD7OmBvB/AXAAAAAAAAAAAAAgAAAAAAAADgL1gZgy8LACVZ0BHMARgAAAAAAAAAAAABAAAAAAAAADurkkgTQwoASjP+BeaxFQAAAAAAAAAAAAAAAAAAAAAAgGxxLSlqCQDgMpi3KeAVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "stakes": [ + { + "account": { + "Slot": 6210151, + "Key": "3mZwezEBuKcCs42NfAEZfdCbCMyPaV6wnus263mgrhjP", + "Lamports": 59002277492, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI9JSTu3nHzfydAzLjeA87Aht6Ost2Mol90Y42gdbSqB9HisvA0AAABnAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACZzNzwgAgAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "7e91c9547ff107e827f22f226df525ee83be7e9eb22bc8afd812c92c235c69ba", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9430889728015, + "PrevCredits": 9349889838233 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "453ETE3U7Usc4ss87sszs3EvTR5ZYQQWcZHgs9XiAZdY", + "Lamports": 3306766663971, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADSIHvAB+hqCeyUMqrtA7RF7gf0pF5+WOdx24l28NcLZoyuE6gEDAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAALJPnDAREAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "88983f1b8c3153b155fc54b8ffcdbe468b3356ce007cdab77d95cab1d3c50dc3", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 18772882129308, + "PrevCredits": 18699280524299 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "462wmoxLUzHfxcURxwkMj5f7cmeVXeSeaFU5uXd9jrQE", + "Lamports": 30092278839, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB7glTPNCA5Xp0adNqBsSaCIXSwoB3DhCXGyxm9mwIQut+aAAQcAAAA0AAAAAAAAAHIAAAAAAAAAAAAAAAAAAABJX296lgQAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "a0c0c400478bf441c374e56141b0122846da672b9b1a5d01157374dadfa83649", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 5082302209818, + "PrevCredits": 5044345724745 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "4aNZYt3giNatv4J58sYHwRNs8L6ZQpLqG9GZiyqrXUqS", + "Lamports": 2329209663118, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK8Fa5ghODdNV981iyfO/aADxuCTmU56Dtg15Wf9Xk+LDhmUTx4CAAABAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAeHbzX0wkAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "78674eaaabfbe62cf2e558bd33e01dd53a33a7a4024e52eba673bb1acf73ff83", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 10909280173169, + "PrevCredits": 10805462179102 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "4u4XDhXRtXA9QqP8uC9vpoyw4KLaeWHm7g2gP4aLDGK1", + "Lamports": 59002277492, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABtRAg7KdM61d5wq/bIMit41UurMHM2/gwzsJrDkSJWr9HisvA0AAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAC8dSEI5AgAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "f49eb225b3f858e7609ffecf5e9d2734cb28fe754ca697800e0a44a144ab0f3d", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9855957906862, + "PrevCredits": 9775481976252 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "4zrGtmZj2s7akr4FyiJ7E3fQY4n59cZoVEmL192GttRq", + "Lamports": 3956852941834, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApBQ/86W982eATp1soYW/TvCCjcBXX1dK637et+U7Qaio6tRpkDAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABz+BFZ1gkAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "6a0c148dbf978bfec9e6e5e3227ad57e6089c056b2d23730b85be7bdcc2a3384", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 10900522634265, + "PrevCredits": 10816222001267 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "5ZEUi72iZM7ozwisZV2jezjgR3BLBQToz8ZxpdnqYaue", + "Lamports": 10521999279304, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEvFm+VinHP4nTd2zkbv4cVplRt3TlVeTEidtXhPv7vISK/k15EJAABiAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABDEW5ATQcAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "a0c42ad17b70bd37283facbe1655a9401660d0ba96e7c57fad1ba452c46c77c8", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 8111781721836, + "PrevCredits": 8028374831427 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "6dNDcZRYeX7zoGirAbYxjJVCoHg14KLcy1ndb5a6hLtp", + "Lamports": 92402274844, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALJZyCh5aoPz7tWdy6VqoilN4lxeoCa10yfJpJBoYSa8nPx3gxUAAAAAAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACEXlHvNAgAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "4914fbc352aae3350f8dd39a94733fc3bd927e1ccd2ed471ac81318d759594f9", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9114167602897, + "PrevCredits": 9023446408836 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "7Xwjq1Pu1ibBufp4mdu51yrRzUoadak98sdj6BQhGmBH", + "Lamports": 59002277493, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMb+8swy4nz1EbP63bJ/XUX49DQYo/CiTC3dFaYhgfYs9XisvA0AAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACE/LmcrgcAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "01df0af720551818974311706b7a5ebb9c5b333eb90e877e0f227f08fd4cf9e3", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 8523194289275, + "PrevCredits": 8446535138436 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "7kGbq8H94roSeiCvasCE8kWq2Kx1dCTZQTUnkiPqibTg", + "Lamports": 59002277493, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaFSKZ/FpeRshn8N9+v+AkQycTuIPwqrs5Yf2mgYnGv9XisvA0AAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAF9xJssgcAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "f04a2844299ed1e7c980b160a89a508f61a135ee5d333ddbb948aabb00b80a6d", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 8544891256299, + "PrevCredits": 8462898755333 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "7uyNRdfhmk6SwJPfM172LF4ajEUKUkP9RC2okeB3RZsC", + "Lamports": 48931548636, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABLFnpY8I58W61QVYzmx6wfM3seg+Q0PxwXVhMb9YvpaXFhpZAsAAAAzAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABZwrzIpQYAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "d2a561a19b6f7cd09f21985876978ae5ab1e11c7cc9262c5a17caf9af6e0c92b", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 7371534095782, + "PrevCredits": 7309107184217 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "8QYDcU8pvmMKvovzUiqWq4H7ZFiWpbet4HwUnDy5XTYK", + "Lamports": 1002282880, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFgYhGqxkhQFABeF03ECpK9RUUF1hdavhflLAu/MvP/rAMqaOwAAAABtAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAs5ZJ1sQUAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "ff84dc2a8426d4deb78786afc87899cbb4c052b6c20346fac76f7fcd865026c5", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 6318982736717, + "PrevCredits": 6259739911468 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "8n3LHx61MeuZTF28hkHvsiEtKSEXLUQDYCMHS8sCQ264", + "Lamports": 3058382969924, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9GQ73y9miIEoMD1/GtvVm6Z6RC6u1sNUKM6BM6jWJ0xMaxFcgCAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABQGTGKHhEAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "20b17fb68d98d290dfa4742237ac719219e23147a8f7cdfb27ac1be790f9e8d8", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 18900384617754, + "PrevCredits": 18822865164624 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "98joChCUmuTBYZx8xHDJzJzbF37HPuP4EHqm3BwD8KSo", + "Lamports": 132002282880, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAite2/mh1w1kW4RMXwAjDtR4kt3TcBCr/oy5ngX7hOQACjQux4AAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACf+Oklb0QAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "7c11ae1d9490b3f0876c515a564337a0170c21fae8c3cb3f43c66e5dee2cd34f", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 76179624781196, + "PrevCredits": 75244168149151 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "9PAPBcFDq536ZzMWhNywZAYPZt5mF92VadrJc5ctoQqY", + "Lamports": 1894659468576, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALo09fj+TmHMhldcHlC9iwfK4NZlNl+rkERD8XdoYYH1oFdeIrkBAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAC9udrpXwwAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "ba19d0de0f1d7ccded2e93dd4b9d57b8077397c396005d1918b4d6dd719196b7", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 13714985011803, + "PrevCredits": 13606084852157 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "ALrrFs8DAkNHjyce6LhwkotcBQp8U9dGKb4prUHZp5GF", + "Lamports": 1002282880, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACZSY+h/WqtJAAWy9FwQ5zvCPyGbQUYwp5yepcxIn0qbAMqaOwAAAABwAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACXOQj8AgEAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "a96af37ed2c6d36cfc77971b4432cb7c62f4e045b3883a5b7e2e6434fa21dec9", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 1147511243813, + "PrevCredits": 1112329959831 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "ARPzersuLPyg9ZJaxYuKc2eDKKg8t4qWXpTy9TzKjLJU", + "Lamports": 44842277493, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF6PbC1s5mob1cKneVa1heDI7d/UjwFUwoZKG6DN1okV9QSscAoAAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAZRdBpGAcAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "7c3b359b3e2c72b291d1703977f464395b1772f9197466913c9f87661d3ebac0", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 7864995243760, + "PrevCredits": 7801435866393 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "AXC6s8QGstr1nnTJ5ranGEeSZ7rcHKVeQav4r4J8icJL", + "Lamports": 2951443346384, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2DXLDpqE+RtusMr3795aLUMJhXsVQnXUHgtyhqt1j0UJ6YL68CAAAAAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACQUqzjgQ8AAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "0e44daa0385ca084667f7e44f0fb96571b6b963f6bd046bf66c2c7fb31e232ce", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 17293215270489, + "PrevCredits": 17050544919184 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "BWiQJLo1TGhbNp3ionXVFPnSgfGimGU9FVHm1hdwLKbJ", + "Lamports": 30092278839, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADUkqa5BeE51KvSv12H//GyC8rI6ScrXABI9UawzUXiot+aAAQcAAAA0AAAAAAAAAHIAAAAAAAAAAAAAAAAAAADlkymHrAQAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "8cf243491cd54f9d1efc732287caf92bd392dfc1ad410c74c94e2d8f4e4deb18", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 5181125525357, + "PrevCredits": 5139048535013 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "CbFTDpV9HjcnVQReCUwuZmLMp6PjMoWZBA7VTvLzszQ", + "Lamports": 5114239502629, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANp1F9fR0omLpsfk9YL0X7w0EuA6D8E39bIn27KGqRgppfNKwKYEAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAADfLChErwgAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "6bf6057cfa3c4d2a7d786bb5f325032604cca8eb30391468f2caf1b71406ce57", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9639820890790, + "PrevCredits": 9548855782623 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "D5Zx5bsdVDg2kBZkPvaF59ULdKd8LACtScaWE7NEAbtw", + "Lamports": 1002282880, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANbDQeBxoTnMt62Ygze0/L0wq3Tv5ynlZXdjhuu6lY/4AMqaOwAAAABuAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABO6xz/EAYAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "2480dc54ffb62581eec0ca3bcb7201b2ba4260a97bdc186e2f2345241abade3c", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 6731352374437, + "PrevCredits": 6670069328718 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "DNCCPW9Bsv8SdcDMFDA7b2HT5jKxgUnBrA2gkKXwe5Bg", + "Lamports": 44252278839, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEZlguB/hNNN/i6p/xPZEHXCTr6qIxawtvTnx9A4WE77t1qBTQoAAAAWAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAJpOYcEAUAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "a02301fdf180b2f542124caf7da178cae4397acc833030b9dca28116d6e2b747", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 5630002073869, + "PrevCredits": 5566762492937 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "E2TDLwJNmzvg6K7MhrkQ3HV4n4mp2zL49FqGwV4tFUfC", + "Lamports": 1002282880, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIZbuXtVwiaoNZBvLxeR/DEy46f1oQ/1DEqV1R+wxiWyAMqaOwAAAABvAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAA+JHwoWwgAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "2ecdc0e446f6e0c112c947c79b6976cbdeea776ecb4fe62ece89b1f725e3ae48", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9263388828449, + "PrevCredits": 9187614270526 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "EA3MVKFbieGtDZDG4bmCiwnE2HWndMuWcjMjRNSrbMBC", + "Lamports": 3787600669028, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACGc29KCGn33qCWHYLj1Li6Q1LaT9ffcV8Q3PJ6AssCM5NN03nEDAAAGAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAChfXpwQQkAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "c0bf9bd502e0b675a186b4b855812ae1915d04110a951499d911e6174ed2b2b2", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 10252916692527, + "PrevCredits": 10176664599969 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "Eijmmf2xFvjStkcu6WQRj93x5iYP1uv5zuZP8PARdqd", + "Lamports": 3449661803946, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC3bR7fFvVz26LQA9LQq1JGFKWXl7hg6BaC5sp0SpTflKvi6LyMDAAAAAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABF19K7FxEAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "a4810c657cb2161f3c47f0452d78dc74d9d4597763d82c3dfd48b3d1dc43dfe9", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 18871239559140, + "PrevCredits": 18793633077061 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "FxhNhXDkoqMTcXqyHxYXW8BJAdpWH5qdyz3qgpiySrKn", + "Lamports": 71250450995, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHeMPT3PZmkZZcARjam+dHkyZP326BpHWqy/ktZchIums8S4lhAAAAAgAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAXAHi5oAYAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "ab580bd8bb513651006208d529d58dbe8e40d0306141e4b9bbe32c0559040af8", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 7382306449058, + "PrevCredits": 7287376183319 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "GAyfc6a5E5uzXQgQ4KXHuJyunyGNtLGZgUUdWKEL4Df9", + "Lamports": 59002277493, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0QDCxOaKoUheu2WKN7BFVixKbK4ogx2BYksnFTbTzL9XisvA0AAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACYANgjswcAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "d635c01a4fc8bc710b4ca6d9b0b0ba960b9ab3ad8ac0359ee8adbdd62ad30f3a", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 8554183692312, + "PrevCredits": 8465981898904 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "H2dM1xYSeuZaujtByMa4jvPwUVYjfxzUL3K9iVLaCW9G", + "Lamports": 64469922880, + "Data": "AgAAAIDVIgAAAAAAzi6d3+ZZh+CVMQop8DVJNLt57mKRukRhiZHz0dnIWEDOLp3f5lmH4JUxCinwNUk0u3nuYpG6RGGJkfPR2chYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAiQo+w+o08xym3k1JBF+HYHIJZGTDaZaufK/simzs1bwB6SAg8AAAAgAAAAAAAAAHIAAAAAAAAAAAAAAAAAAACe3jqUQyIAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "b39033f338419cea17108c3989a6331d8272749095fcf3ad856031cbab264110", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 38085420659301, + "PrevCredits": 37673645039262 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "Hh8u21hJnAvE9PMNBmNTsDUTseY6bYfwvbZQgj8R2vcU", + "Lamports": 44842277493, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJOHGqQMr9AgJ5hIj044ilcd/38t2ijdTubnJyZ6iT+d9QSscAoAAABGAAAAAAAAAHIAAAAAAAAAAAAAAAAAAABymDuk3wUAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "0569f4f3dbc16ab5c284f6efaefd1e2b6b5b8db18ffa1b1fbd0da98468c54d78", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 6525335322632, + "PrevCredits": 6458091214962 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "HicPaN5ogXhE5jQ99WotjGUQLPJ11bdCqXjJhMKUq5N", + "Lamports": 1690908095830, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ1KF6URsOxn3mdoAgUdjof1ae904ReOis3chjrZx0h+1h/XsYkBAAAAAAAAAAAAAHIAAAAAAAAAAAAAAAAAAADt4ogGQgUAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "47ad9ddb61ff1edee564852ac0e48ff45b47a377b0ca1a5f1e0aef74a27627b4", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 5826444097536, + "PrevCredits": 5781135614701 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "c6Ev8GfADHdrGtvH1VqwH7JmN28PisZgGfUy64RE7np", + "Lamports": 3782196946839, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDb0jorArBVkC7lcchWRZHVxqD+PrkqtMdnxFvaxkbfF5JenHADAAACAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAA1TQUrswgAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "b361cd2d770b1b3432d58f8098c654f300754fab58787d6033dae1e724618467", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9647673972566, + "PrevCredits": 9565613935925 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "eHboMWq5bmx1DxdKw79zpse75XLk2GEgeRnJG9nysJn", + "Lamports": 5125666350406, + "Data": "AgAAAIDVIgAAAAAA/LTRFge4YpO0iugL7CcyV2OPIzVpqNRFyNcRlWoBALL8tNEWB7hik7SK6AvsJzJXY48jNWmo1EXI1xGVagEAsgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPznsTwPeDl/u5+jig9MvOBeS/RKUvjM0z9ITapNTvPoxs9iaakEAAAAAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAEpkAsaggAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "8e052b16e36ecb6e80919ada5456816e25e3b804fe9119319cff9861e7242e25", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 9334067720448, + "PrevCredits": 9252101989892 + } + }, + { + "account": { + "Slot": 6210151, + "Key": "v5pSSEZAuvG9ewGhdMNVcTVHeJPzFnGse3mX4FBEyLT", + "Lamports": 1082110856099, + "Data": "AgAAAIDVIgAAAAAAzi6d3+ZZh+CVMQop8DVJNLt57mKRukRhiZHz0dnIWEDOLp3f5lmH4JUxCinwNUk0u3nuYpG6RGGJkfPR2chYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHeMPT3PZmkZZcARjam+dHkyZP326BpHWqy/ktZchIumI3ay8vsAAAAgAAAAAAAAAHIAAAAAAAAAAAAAAAAAAAAXAHi5oAYAAAAAAAA=", + "Owner": [ + 6, + 161, + 216, + 23, + 145, + 55, + 84, + 42, + 152, + 52, + 55, + 189, + 254, + 42, + 122, + 178, + 85, + 127, + 83, + 92, + 138, + 120, + 114, + 43, + 104, + 164, + 157, + 192, + 0, + 0, + 0, + 0 + ], + "Executable": false, + "RentEpoch": 18446744073709551615, + "IsDummy": false + }, + "original_updated_data_sha256": "053f427dd888bbe45c6e0f772faf6eac1f9ede5822cc74477c757eaac75cb8b1", + "vote_epoch_credits": { + "Epoch": 115, + "Credits": 7382306449058, + "PrevCredits": 7287376183319 + } + } + ] +} \ No newline at end of file diff --git a/pkg/sbpf/interpreter.go b/pkg/sbpf/interpreter.go index 96d3cb25b..8cd4c681c 100644 --- a/pkg/sbpf/interpreter.go +++ b/pkg/sbpf/interpreter.go @@ -1111,10 +1111,16 @@ func (ip *Interpreter) translateInputRegion(offset, size uint64, write bool) (un // same idea as Agave's MappingCache). Only the currently mapped // RegionSize bytes are exposed; anything beyond takes the slow path // again so that OnWrite / growth semantics are preserved. - if region.RegionSize != 0 && (region.Data == nil || uint64(len(region.Data)) >= region.RegionSize) { - cached := memRegion{base: base, start: region.Offset, rlen: region.RegionSize, gapShift: 63} + cacheLen := region.RegionSize + if region.Data == nil { + cacheLen = min(cacheLen, uint64(len(ip.input))-region.HostOffset) + } else { + cacheLen = min(cacheLen, uint64(len(region.Data))) + } + if cacheLen != 0 { + cached := memRegion{base: base, start: region.Offset, rlen: cacheLen, gapShift: 63} if region.Writable { - cached.wlen = region.RegionSize + cached.wlen = cacheLen } ip.regions[VaddrInput>>32] = cached } diff --git a/pkg/sbpf/perf_differential_test.go b/pkg/sbpf/perf_differential_test.go index 0d072f564..3ad824959 100644 --- a/pkg/sbpf/perf_differential_test.go +++ b/pkg/sbpf/perf_differential_test.go @@ -2,8 +2,10 @@ package sbpf import ( "bufio" + "crypto/sha256" "fmt" "hash/fnv" + "io" "math/rand" "os" "testing" @@ -316,11 +318,14 @@ func TestDifferentialDump(t *testing.T) { w := bufio.NewWriter(f) defer w.Flush() + writeDifferentialDump(t, w, 100000) +} + +func writeDifferentialDump(t *testing.T, w io.Writer, n int) { rng := rand.New(rand.NewSource(12345)) - const N = 100000 generated, verified := 0, 0 var verifiedByVersion [4]int - for i := 0; i < N; i++ { + for i := 0; i < n; i++ { // Equal representation of every version, independent of RNG consumption. ver := uint32(i % 4) p := genProgram(rng, ver) @@ -406,3 +411,15 @@ func TestDifferentialDump(t *testing.T) { } t.Logf("generated=%d verified=%d verified_by_version=%v", generated, verified, verifiedByVersion) } + +// Golden derived from the pre-optimization e1204b32 interpreter with this +// generator (seed 12345). Do not regenerate from candidate output alone. +// Covers 1024 programs per version; dumps compare results, errors, CU and memory. +func TestDifferentialGolden(t *testing.T) { + h := sha256.New() + writeDifferentialDump(t, h, 4096) + const want = "ccc16b4021e784342a7200a3d8911d362d2ab11a5ddefccf1c93880a704426b0" + if got := fmt.Sprintf("%x", h.Sum(nil)); got != want { + t.Fatalf("differential drift: got %s, want %s", got, want) + } +} diff --git a/pkg/sbpf/vasa_test.go b/pkg/sbpf/vasa_test.go index 70296ea43..77912c07a 100644 --- a/pkg/sbpf/vasa_test.go +++ b/pkg/sbpf/vasa_test.go @@ -100,3 +100,16 @@ func TestStackFrameGapsAreLegacyOnly(t *testing.T) { require.Equal(t, VaddrStack+StackFrameSize*2, regs[10]) require.NotNil(t, stack.GetFrame(StackFrameSize)) } + +func TestInputRegionFastCacheClampsToBackingBytes(t *testing.T) { + ip := &Interpreter{input: make([]byte, 8), inputRegions: []InputRegion{{Offset: 0, HostOffset: 4, RegionSize: 100, AddressSpaceReserved: 100, Writable: true, AccountIndex: -1}}} + ip.initRegions() + require.NoError(t, ip.Write8(VaddrInput, 7)) + require.NotNil(t, ip.fastRead(VaddrInput+3, 1)) + require.Nil(t, ip.fastRead(VaddrInput+4, 1)) + require.Nil(t, ip.fastWrite(VaddrInput+4, 1)) + _, err := ip.Read8(VaddrInput + 4) + require.Error(t, err) + require.Error(t, ip.Write8(VaddrInput+4, 8)) + require.Equal(t, byte(7), ip.input[4]) +} diff --git a/pkg/sealevel/bpf_loader.go b/pkg/sealevel/bpf_loader.go index b80ab8853..16ba486cc 100644 --- a/pkg/sealevel/bpf_loader.go +++ b/pkg/sealevel/bpf_loader.go @@ -123,7 +123,11 @@ func (write *UpgradeableLoaderInstrWrite) MarshalWithEncoder(encoder *bin.Encode return err } - err = encoder.WriteBytes(write.Bytes, true) + // UpgradeableLoaderInstruction uses bincode's fixed-width u64 vector length. + if err = encoder.WriteUint64(uint64(len(write.Bytes)), bin.LE); err != nil { + return err + } + err = encoder.WriteBytes(write.Bytes, false) return err } @@ -1338,6 +1342,11 @@ func executeLoadedProgram(execCtx *ExecutionCtx, program *sbpf.Program, syscallR func executeProgramFromBytes(execCtx *ExecutionCtx, programAddr solana.PublicKey, programData []byte, syscallRegistry sbpf.SyscallRegistry) error { start := time.Now() + // The caller has already validated the bank-visible loader metadata. Never + // let a global cache hit bypass that validation or select a different fork. + if entry, ok := execCtx.SlotCtx.AccountsDb.MaybeGetProgramFromCache(programAddr); ok && entry.MatchesSource(programData, &execCtx.Features) { + return executeLoadedProgram(execCtx, entry.Program, syscallRegistry) + } loader, err := loader.NewLoaderWithSyscalls(programData, syscallRegistry, false, &execCtx.Features) if err != nil { return InstrErrUnsupportedProgramId @@ -1352,6 +1361,7 @@ func executeProgramFromBytes(execCtx *ExecutionCtx, programAddr solana.PublicKey } entry := &accountsdb.ProgramCacheEntry{Program: program} + entry.BindSource(programData, &execCtx.Features) if !execCtx.IsSimulation { addProgramToCache(execCtx, programAddr, entry) } @@ -1361,11 +1371,16 @@ func executeProgramFromBytes(execCtx *ExecutionCtx, programAddr solana.PublicKey return executeLoadedProgram(execCtx, program, syscallRegistry) } +// All loader cache insertions must pass through this helper. A speculative +// stream records replacements for eviction on discard; the previous program is +// then reloaded from authoritative account data, never retained from that stream. +// Replay must discard its stream before executing a different bank. func addProgramToCache(execCtx *ExecutionCtx, programAddr solana.PublicKey, entry *accountsdb.ProgramCacheEntry) { if execCtx.SlotCtx == nil || execCtx.SlotCtx.AccountsDb == nil { return } execCtx.SlotCtx.AccountsDb.AddProgramToCache(programAddr, entry) + execCtx.SlotCtx.RecordProgramCacheAdd(programAddr) } func mapVirtualAddressSpaceRunErr(execCtx *ExecutionCtx, err error, inputRegions []sbpf.InputRegion) error { @@ -1486,36 +1501,27 @@ func BpfLoaderProgramExecute(execCtx *ExecutionCtx) error { } var programBytes []byte - var loadedProgram *sbpf.Program - var hasLoadedProgram bool var programAcctKey solana.PublicKey programOwner := programAcct.Owner() if programOwner == a.BpfLoader2Addr || programOwner == a.BpfLoaderDeprecatedAddr { - var programCacheEntry *accountsdb.ProgramCacheEntry - programCacheEntry, hasLoadedProgram = execCtx.SlotCtx.AccountsDb.MaybeGetProgramFromCache(programAcct.Key()) - if hasLoadedProgram { - programAcctKey = programAcct.Key() - loadedProgram = programCacheEntry.Program - } else { // program is not cached - if len(programAcct.Data()) == 0 { - var paTmp *accounts.Account - paTmp, err = execCtx.SlotCtx.GetAccount(programAcct.Key()) + if len(programAcct.Data()) == 0 { + var paTmp *accounts.Account + paTmp, err = execCtx.SlotCtx.GetAccount(programAcct.Key()) + if err != nil { + paTmp, err = execCtx.SlotCtx.GetAccountFromAccountsDb(programAcct.Key()) if err != nil { - paTmp, err = execCtx.SlotCtx.GetAccountFromAccountsDb(programAcct.Key()) - if err != nil { - //mlog.Log.Debugf("unable to get account %s from accountsdb", programAcct.Key()) - return InstrErrUnsupportedProgramId - } + //mlog.Log.Debugf("unable to get account %s from accountsdb", programAcct.Key()) + return InstrErrUnsupportedProgramId } - programBytes = paTmp.Data - } else { - programBytes = programAcct.Data() } - programAcctKey = programAcct.Key() + programBytes = paTmp.Data + } else { + programBytes = programAcct.Data() } + programAcctKey = programAcct.Key() } else if programOwner == a.BpfLoaderUpgradeableAddr { var programAcctState *UpgradeableLoaderState @@ -1543,49 +1549,38 @@ func BpfLoaderProgramExecute(execCtx *ExecutionCtx) error { } start := time.Now() - var programCacheEntry *accountsdb.ProgramCacheEntry - programCacheEntry, hasLoadedProgram = execCtx.SlotCtx.AccountsDb.MaybeGetProgramFromCache(programAcctState.Program.ProgramDataAddress) - if hasLoadedProgram { - if programCacheEntry.DeploymentSlot >= execCtx.SlotCtx.Slot { - return InstrErrInvalidAccountData - } - programAcctKey = programAcctState.Program.ProgramDataAddress - loadedProgram = programCacheEntry.Program - metrics.GlobalBlockReplay.GetProgramDataCached.AddTimingSince(start) - } else { // program is not cached - programDataAcct, err := execCtx.SlotCtx.GetAccount(programAcctState.Program.ProgramDataAddress) + programDataAcct, err := execCtx.SlotCtx.GetAccount(programAcctState.Program.ProgramDataAddress) + if err != nil { + programDataAcct, err = execCtx.SlotCtx.GetAccountFromAccountsDb(programAcctState.Program.ProgramDataAddress) if err != nil { - programDataAcct, err = execCtx.SlotCtx.GetAccountFromAccountsDb(programAcctState.Program.ProgramDataAddress) - if err != nil { - return InstrErrUnsupportedProgramId - } - metrics.GlobalBlockReplay.GetProgramDataUncachedAccountsDb.AddTimingSince(start) - } else { - metrics.GlobalBlockReplay.GetProgramDataUncachedAccounts.AddTimingSince(start) + return InstrErrUnsupportedProgramId } + metrics.GlobalBlockReplay.GetProgramDataUncachedAccountsDb.AddTimingSince(start) + } else { + metrics.GlobalBlockReplay.GetProgramDataUncachedAccounts.AddTimingSince(start) + } - start = time.Now() - programDataAcctState, err := UnmarshalUpgradeableLoaderState(programDataAcct.Data) - if err != nil { - return err - } + start = time.Now() + programDataAcctState, err := UnmarshalUpgradeableLoaderState(programDataAcct.Data) + if err != nil { + return err + } - if programDataAcctState.Type != UpgradeableLoaderStateTypeProgramData { - return InstrErrUnsupportedProgramId - } + if programDataAcctState.Type != UpgradeableLoaderStateTypeProgramData { + return InstrErrUnsupportedProgramId + } - programDataSlot := programDataAcctState.ProgramData.Slot - if programDataSlot >= execCtx.SlotCtx.Slot { - return InstrErrInvalidAccountData - } + programDataSlot := programDataAcctState.ProgramData.Slot + if programDataSlot >= execCtx.SlotCtx.Slot { + return InstrErrInvalidAccountData + } - if len(programDataAcct.Data) < upgradeableLoaderSizeOfProgramDataMetaData { - return InstrErrUnsupportedProgramId - } - programAcctKey = programAcctState.Program.ProgramDataAddress - programBytes = programDataAcct.Data[upgradeableLoaderSizeOfProgramDataMetaData:] - metrics.GlobalBlockReplay.GetProgramDataUncachedMarshal.AddTimingSince(start) + if len(programDataAcct.Data) < upgradeableLoaderSizeOfProgramDataMetaData { + return InstrErrUnsupportedProgramId } + programAcctKey = programAcctState.Program.ProgramDataAddress + programBytes = programDataAcct.Data[upgradeableLoaderSizeOfProgramDataMetaData:] + metrics.GlobalBlockReplay.GetProgramDataUncachedMarshal.AddTimingSince(start) } else { return InstrErrUnsupportedProgramId } @@ -1596,13 +1591,7 @@ func BpfLoaderProgramExecute(execCtx *ExecutionCtx) error { return Syscalls(&execCtx.Features, false, u) }) - // two cases here: we're either executing from the program cache, so from a pre-parsed/loaded program, or from bytes if - // the the program was not found in the cache. - if hasLoadedProgram { - err = executeLoadedProgram(execCtx, loadedProgram, syscallRegistry) - } else { - err = executeProgramFromBytes(execCtx, programAcctKey, programBytes, syscallRegistry) - } + err = executeProgramFromBytes(execCtx, programAcctKey, programBytes, syscallRegistry) return err } diff --git a/pkg/sealevel/execution_ctx.go b/pkg/sealevel/execution_ctx.go index ba54cc267..326da6762 100644 --- a/pkg/sealevel/execution_ctx.go +++ b/pkg/sealevel/execution_ctx.go @@ -107,6 +107,50 @@ type SlotCtx struct { bankSysvars atomic.Pointer[BankSysvars] TraceCtx context.Context + + // Speculative execution support (streaming replay). A bank executed before + // its block is complete must not publish to process-global state until + // the block is accepted, so a discard leaves nothing behind. + // + // DeferVoteCachePublication buffers global vote-cache puts and deletes in + // the pending maps (protected by PendingVoteCacheMu) and turns the + // vote/stake dirty marker into VoteStakeDirty; the replay finalize step + // publishes them once the bank is accepted. + DeferVoteCachePublication bool + PendingVoteCacheMu sync.Mutex + PendingVoteCache map[solana.PublicKey]*VoteStateVersions + PendingVoteCacheDeletes map[solana.PublicKey]struct{} + VoteStakeDirty bool + // TrackProgramCacheAdds records every program-cache insertion made while + // executing this bank so a discarded bank can evict them again (eviction + // only forces a reload from account data, so it is always safe). + TrackProgramCacheAdds bool + ProgramCacheAddsMu sync.Mutex + ProgramCacheAdds []solana.PublicKey +} + +// RecordProgramCacheAdd notes a program-cache insertion for later undo when +// TrackProgramCacheAdds is set. +func (slotCtx *SlotCtx) RecordProgramCacheAdd(key solana.PublicKey) { + if slotCtx == nil || !slotCtx.TrackProgramCacheAdds { + return + } + slotCtx.ProgramCacheAddsMu.Lock() + slotCtx.ProgramCacheAdds = append(slotCtx.ProgramCacheAdds, key) + slotCtx.ProgramCacheAddsMu.Unlock() +} + +// TakeProgramCacheAdds returns and clears the recorded program-cache +// insertions. +func (slotCtx *SlotCtx) TakeProgramCacheAdds() []solana.PublicKey { + if slotCtx == nil { + return nil + } + slotCtx.ProgramCacheAddsMu.Lock() + defer slotCtx.ProgramCacheAddsMu.Unlock() + adds := slotCtx.ProgramCacheAdds + slotCtx.ProgramCacheAdds = nil + return adds } // BankSysvars returns the immutable sysvar snapshot owned by this bank. diff --git a/pkg/sealevel/legacy_bank_fixture_test.go b/pkg/sealevel/legacy_bank_fixture_test.go new file mode 100644 index 000000000..cbf7cf81a --- /dev/null +++ b/pkg/sealevel/legacy_bank_fixture_test.go @@ -0,0 +1,37 @@ +package sealevel + +import ( + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + "github.com/gagliardetto/solana-go" + "github.com/maypok86/otter" + "github.com/stretchr/testify/require" + "testing" +) + +// Legacy instruction fixtures predate the bank and program-cache dependencies +// used by loaded programs. Give each fixture isolated state, as replay does. +func initializeLegacyBankFixture(t *testing.T, ctx *ExecutionCtx) { + t.Helper() + ctx.RecordInnerInstructions = true + if ctx.Log == nil { + ctx.Log = &LogRecorder{} + } + t.Cleanup(func() { + if t.Failed() { + t.Logf("program logs: %#v", ctx.Log) + } + }) + cache, err := otter.MustBuilder[solana.PublicKey, *accountsdb.ProgramCacheEntry](1024).Cost(func(solana.PublicKey, *accountsdb.ProgramCacheEntry) uint32 { return 1 }).Build() + require.NoError(t, err) + t.Cleanup(cache.Close) + if ctx.Accounts == nil { + ctx.Accounts = accounts.NewMemAccounts() + } + if ctx.SlotCtx == nil { + ctx.SlotCtx = &SlotCtx{} + } + ctx.SlotCtx.Accounts = ctx.Accounts + ctx.SlotCtx.AccountsDb = &accountsdb.AccountsDb{ProgramCache: cache} + ctx.TransactionContext.ComputeBudgetLimits = &ComputeBudgetLimits{UpdatedHeapBytes: 32768} +} diff --git a/pkg/sealevel/loader_v4.go b/pkg/sealevel/loader_v4.go index 6bb264b21..8ab33effb 100644 --- a/pkg/sealevel/loader_v4.go +++ b/pkg/sealevel/loader_v4.go @@ -274,38 +274,28 @@ func LoaderV4Execute(execCtx *ExecutionCtx) error { return err } - var loadedProgram *sbpf.Program var programBytes []byte - - programCacheEntry, hasLoadedProgram := execCtx.SlotCtx.AccountsDb.MaybeGetProgramFromCache(program.Key()) - if hasLoadedProgram { - if programCacheEntry.DeploymentSlot >= execCtx.SlotCtx.Slot { - return InstrErrInvalidAccountData - } - loadedProgram = programCacheEntry.Program - } else { - programDataAcct, err := execCtx.SlotCtx.GetAccount(program.Key()) - if err != nil { - programDataAcct, err = execCtx.SlotCtx.GetAccountFromAccountsDb(program.Key()) - if err != nil { - return InstrErrUnsupportedProgramId - } - } - - state, err := decodeLoaderV4State(programDataAcct.Data) + programDataAcct, err := execCtx.SlotCtx.GetAccount(program.Key()) + if err != nil { + programDataAcct, err = execCtx.SlotCtx.GetAccountFromAccountsDb(program.Key()) if err != nil { return InstrErrUnsupportedProgramId } + } - if state.Status == LoaderV4StatusRetracted { - return InstrErrUnsupportedProgramId - } - if state.Slot >= execCtx.SlotCtx.Slot { - return InstrErrUnsupportedProgramId - } + state, err := decodeLoaderV4State(programDataAcct.Data) + if err != nil { + return InstrErrUnsupportedProgramId + } - programBytes = programDataAcct.Data[loaderV4ProgramDataOffset:] + if state.Status == LoaderV4StatusRetracted { + return InstrErrUnsupportedProgramId } + if state.Slot >= execCtx.SlotCtx.Slot { + return InstrErrUnsupportedProgramId + } + + programBytes = programDataAcct.Data[loaderV4ProgramDataOffset:] syscallRegistry := sbpf.SyscallRegistry(func(u uint32) (sbpf.Syscall, bool) { return Syscalls(&execCtx.Features, false, u) @@ -313,13 +303,8 @@ func LoaderV4Execute(execCtx *ExecutionCtx) error { program.Drop() - // two cases here: we're either executing from the program cache, so from a pre-parsed/loaded program, or from bytes if - // the the program was not found in the cache. - if hasLoadedProgram { - err = executeLoadedProgram(execCtx, loadedProgram, syscallRegistry) - } else { - err = executeProgramFromBytes(execCtx, program.Key(), programBytes, syscallRegistry) - } + err = executeProgramFromBytes(execCtx, program.Key(), programBytes, syscallRegistry) + } return err @@ -627,6 +612,7 @@ func LoaderV4ProcessDeploy(execCtx *ExecutionCtx) error { entry := &accountsdb.ProgramCacheEntry{Program: programObj, DeploymentSlot: currentSlot} if !execCtx.IsSimulation { execCtx.SlotCtx.AccountsDb.AddProgramToCache(program.Key(), entry) + execCtx.SlotCtx.RecordProgramCacheAdd(program.Key()) } return nil diff --git a/pkg/sealevel/loader_wire_test.go b/pkg/sealevel/loader_wire_test.go new file mode 100644 index 000000000..e45d375e8 --- /dev/null +++ b/pkg/sealevel/loader_wire_test.go @@ -0,0 +1,30 @@ +package sealevel + +import ( + "bytes" + "encoding/binary" + bin "github.com/gagliardetto/binary" + "github.com/stretchr/testify/require" + "testing" +) + +func TestUpgradeableLoaderWriteWireLayout(t *testing.T) { + instruction := UpgradeableLoaderInstrWrite{Offset: 0x12345678, Bytes: []byte{0xAB, 0xCD}} + var buf bytes.Buffer + require.NoError(t, instruction.MarshalWithEncoder(bin.NewBinEncoder(&buf))) + require.Equal(t, []byte{1, 0, 0, 0, 0x78, 0x56, 0x34, 0x12, 2, 0, 0, 0, 0, 0, 0, 0, 0xAB, 0xCD}, buf.Bytes()) + var decoded UpgradeableLoaderInstrWrite + require.NoError(t, decoded.UnmarshalWithDecoder(bin.NewBinDecoder(buf.Bytes()[4:]))) + require.Equal(t, instruction, decoded) +} + +func TestSolAccountMetaCWireLayout(t *testing.T) { + for _, bits := range [][2]byte{{0, 0}, {1, 0}, {0, 1}, {1, 1}} { + meta := SolAccountMetaC{PubkeyAddr: 0x12345678, IsWritable: bits[0], IsSigner: bits[1]} + wire, err := meta.Marshal() + require.NoError(t, err) + want := binary.LittleEndian.AppendUint64(nil, meta.PubkeyAddr) + want = append(want, bits[:]...) + require.Equal(t, want, wire) + } +} diff --git a/pkg/sealevel/program_cache_version_test.go b/pkg/sealevel/program_cache_version_test.go new file mode 100644 index 000000000..83089af25 --- /dev/null +++ b/pkg/sealevel/program_cache_version_test.go @@ -0,0 +1,72 @@ +package sealevel + +import ( + "bytes" + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/accountsdb" + a "github.com/Overclock-Validator/mithril/pkg/addresses" + "github.com/Overclock-Validator/mithril/pkg/features" + bin "github.com/gagliardetto/binary" + "github.com/stretchr/testify/require" + "testing" +) + +func TestExecutionRejectsOtherBankProgramCache(t *testing.T) { + for _, kind := range []string{"unbound", "same-slot-fork", "different-features"} { + t.Run(kind, func(t *testing.T) { + w := expandedProgramWorkloads(t)[0] + run := workloadRunner(t, w, false) + ctx, err := run() + require.NoError(t, err) + poison := &accountsdb.ProgramCacheEntry{DeploymentSlot: 1336} // nil executable: using it would panic + f := features.NewFeaturesDefault() + switch kind { + case "same-slot-fork": + poison.BindSource([]byte("another fork's program"), f) + case "different-features": + f.EnableFeature(features.VirtualAddressSpaceAdjustments, 0) + poison.BindSource(w.elf, f) + } + ctx.SlotCtx.AccountsDb.AddProgramToCache(w.program, poison) + next, err := run() + require.NoError(t, err) + w.check(t, next) + require.Equal(t, ctx.ComputeMeter.Used(), next.ComputeMeter.Used()) + }) + } +} + +func TestWarmCacheCannotBypassDeploymentSlot(t *testing.T) { + key, dataKey := benchPubkey(80), benchPubkey(81) + var encoded bytes.Buffer + state := UpgradeableLoaderState{Type: UpgradeableLoaderStateTypeProgram, Program: UpgradeableLoaderStateProgram{ProgramDataAddress: dataKey}} + require.NoError(t, state.MarshalWithEncoder(bin.NewBinEncoder(&encoded))) + program := accounts.Account{Key: key, Owner: a.BpfLoaderUpgradeableAddr, Executable: true, Lamports: 10000000, Data: encoded.Bytes()} + tx := NewTransactionAccounts([]accounts.Account{program}) + ctx := newBenchExecCtx(tx, 100) + initializeLegacyBankFixture(t, ctx) + ctx.SlotCtx.Slot = 100 + var data bytes.Buffer + state = UpgradeableLoaderState{Type: UpgradeableLoaderStateTypeProgramData, ProgramData: UpgradeableLoaderStateProgramData{Slot: 100}} + require.NoError(t, state.MarshalWithEncoder(bin.NewBinEncoder(&data))) + require.NoError(t, ctx.Accounts.SetAccount((*[32]byte)(&dataKey), &accounts.Account{Key: dataKey, Owner: a.BpfLoaderUpgradeableAddr, Lamports: 10000000, Data: data.Bytes()})) + // The cache describes a previous bank. Its older deployment slot must not + // hide this bank's deployment, which cannot be invoked in the same slot. + ctx.SlotCtx.AccountsDb.AddProgramToCache(dataKey, &accountsdb.ProgramCacheEntry{DeploymentSlot: 99}) + err := ctx.ProcessInstruction(nil, nil, []uint64{0}) + require.ErrorIs(t, err, InstrErrInvalidAccountData) +} + +func TestWarmCacheCannotBypassLoaderV4Retraction(t *testing.T) { + key := benchPubkey(82) + state := LoaderV4State{Slot: 99, Status: LoaderV4StatusRetracted} + program := accounts.Account{Key: key, Owner: a.LoaderV4Addr, Executable: true, Lamports: 10000000, Data: state.Marshal()} + tx := NewTransactionAccounts([]accounts.Account{program}) + ctx := newBenchExecCtx(tx, 100) + initializeLegacyBankFixture(t, ctx) + ctx.SlotCtx.Slot = 100 + require.NoError(t, ctx.Accounts.SetAccount((*[32]byte)(&key), &program)) + ctx.SlotCtx.AccountsDb.AddProgramToCache(key, &accountsdb.ProgramCacheEntry{DeploymentSlot: 99}) + err := ctx.ProcessInstruction(nil, nil, []uint64{0}) + require.ErrorIs(t, err, InstrErrUnsupportedProgramId) +} diff --git a/pkg/sealevel/program_workloads_bench_test.go b/pkg/sealevel/program_workloads_bench_test.go index 649d35ddd..fd58e60b3 100644 --- a/pkg/sealevel/program_workloads_bench_test.go +++ b/pkg/sealevel/program_workloads_bench_test.go @@ -126,7 +126,9 @@ func workloadRunner(t testing.TB, w programWorkload, vasa bool) func() (*Executi require.NoError(t, err) t.Cleanup(cache.Close) db := &accountsdb.AccountsDb{ProgramCache: cache} - db.AddProgramToCache(w.program, &accountsdb.ProgramCacheEntry{Program: prog}) + entry := &accountsdb.ProgramCacheEntry{Program: prog} + entry.BindSource(w.elf, f) + db.AddProgramToCache(w.program, entry) _, cached := db.MaybeGetProgramFromCache(w.program) require.True(t, cached, "warm program must be cached") return func() (*ExecutionCtx, error) { diff --git a/pkg/sealevel/sealevel_bpf_loader_test.go b/pkg/sealevel/sealevel_bpf_loader_test.go index f20f13890..ab1b89d47 100644 --- a/pkg/sealevel/sealevel_bpf_loader_test.go +++ b/pkg/sealevel/sealevel_bpf_loader_test.go @@ -48,6 +48,7 @@ func TestExecute_Tx_BpfLoader_InitializeBuffer_Success(t *testing.T) { instrData := make([]byte, 4) binary.LittleEndian.AppendUint32(instrData, UpgradeableLoaderInstrTypeInitializeBuffer) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -91,6 +92,7 @@ func TestExecute_Tx_BpfLoader_InitializeBuffer_Buffer_Acct_Already_Initialize_Fa instrData := make([]byte, 4) binary.LittleEndian.AppendUint32(instrData, UpgradeableLoaderInstrTypeInitializeBuffer) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrAccountAlreadyInitialized, err) @@ -143,6 +145,7 @@ func TestExecute_Tx_BpfLoader_Write_Success(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -203,6 +206,7 @@ func TestExecute_Tx_BpfLoader_Write_Offset_Too_Large_Failure(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrAccountDataTooSmall, err) } @@ -254,6 +258,7 @@ func TestExecute_Tx_BpfLoader_Write_Buffer_Authority_Didnt_Sign_Failure(t *testi txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -310,6 +315,7 @@ func TestExecute_Tx_BpfLoader_Write_Incorrect_Authority_Failure(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -346,8 +352,9 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Not_Enough_Instr_Accts_Failure(t *tes txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) - assert.Equal(t, InstrErrNotEnoughAccountKeys, err) + assert.Equal(t, InstrErrMissingAccount, err) } func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_Success(t *testing.T) { @@ -391,6 +398,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_Success(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -445,6 +453,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_ProgramData_Success(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -499,6 +508,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_Immutable_Failure(t *testing.T txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrImmutable, err) } @@ -549,6 +559,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_Wrong_Upgrade_Authority_Failur txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -594,6 +605,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_Authority_Didnt_Sign_Failure(t txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -633,6 +645,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_No_New_Authority_Failure(t *te txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -678,6 +691,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_Buffer_Uninitialized_Account_Failure( txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -723,6 +737,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_ProgramData_Immutable_Failure(t *test txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrImmutable, err) } @@ -768,6 +783,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_ProgramData_Authority_Didnt_Sign_Fail txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -818,6 +834,7 @@ func TestExecute_Tx_BpfLoader_SetAuthority_ProgramData_Wrong_Authority_Failure(t txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -855,9 +872,10 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Not_Enough_Instr_Accts_Failure txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) - assert.Equal(t, InstrErrNotEnoughAccountKeys, err) + assert.Equal(t, InstrErrMissingAccount, err) } func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_Success(t *testing.T) { @@ -902,7 +920,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_Success(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -958,7 +977,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_ProgramData_Success(t *testing txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -1014,7 +1034,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_Immutable_Failure(t *te txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrImmutable, err) } @@ -1066,7 +1087,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_Wrong_Upgrade_Authority txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -1113,7 +1135,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_Authority_Didnt_Sign_Fa txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -1160,7 +1183,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_New_Authority_Didnt_Sig txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -1207,7 +1231,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_Buffer_Uninitialized_Account_F txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -1254,7 +1279,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_ProgramData_Immutable_Failure( txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrImmutable, err) } @@ -1301,7 +1327,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_ProgramData_Authority_Didnt_Si txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -1348,7 +1375,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_ProgramData_New_Authority_Didn txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -1400,7 +1428,8 @@ func TestExecute_Tx_BpfLoader_SetAuthorityChecked_ProgramData_Wrong_Authority_Fa txCtx := NewTransactionCtx(*transactionAccts, 5, 64) f := features.NewFeaturesDefault() f.EnableFeature(features.EnableBpfLoaderSetAuthorityCheckedIx, 0) - execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + execCtx := ExecutionCtx{Features: *f, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -1446,6 +1475,7 @@ func TestExecute_Tx_BpfLoader_Close_Buffer_Success(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -1502,6 +1532,7 @@ func TestExecute_Tx_BpfLoader_Close_Buffer_Immutable_Failure(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrImmutable, err) } @@ -1547,6 +1578,7 @@ func TestExecute_Tx_BpfLoader_Close_Buffer_Authority_Didnt_Sign_Failure(t *testi txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrMissingRequiredSignature, err) } @@ -1598,6 +1630,7 @@ func TestExecute_Tx_BpfLoader_Close_Buffer_Wrong_Authority_Failure(t *testing.T) txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -1636,6 +1669,7 @@ func TestExecute_Tx_BpfLoader_Close_Uninitialized_Success(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -1680,6 +1714,7 @@ func TestExecute_Tx_BpfLoader_Close_Recipient_Same_As_Account_Being_Closed_Failu txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -1723,8 +1758,9 @@ func TestExecute_Tx_BpfLoader_Close_Buffer_Not_Enough_Accounts(t *testing.T) { txCtx := NewTransactionCtx(*transactionAccts, 5, 64) execCtx := ExecutionCtx{TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeterDefault()} + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) - assert.Equal(t, InstrErrNotEnoughAccountKeys, err) + assert.Equal(t, InstrErrMissingAccount, err) } func TestExecute_Tx_BpfLoader_Close_ProgramData_Success(t *testing.T) { @@ -1787,6 +1823,7 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_Success(t *testing.T) { clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -1861,8 +1898,9 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_Not_Enough_Accounts_Failure(t *t clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) - assert.Equal(t, InstrErrNotEnoughAccountKeys, err) + assert.Equal(t, InstrErrMissingAccount, err) } func TestExecute_Tx_BpfLoader_Close_ProgramData_Program_Acct_Not_Writable_Failure(t *testing.T) { @@ -1925,6 +1963,7 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_Program_Acct_Not_Writable_Failur clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -1990,6 +2029,7 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_Program_Acct_Wrong_Owner_Failure clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectProgramId, err) } @@ -2055,6 +2095,7 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_Already_Deployed_In_This_Block_F clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -2120,6 +2161,7 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_ProgramData_Not_A_Program_Acct_F clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -2185,6 +2227,7 @@ func TestExecute_Tx_BpfLoader_Close_ProgramData_Nonclosable_Account_Failure(t *t clockAcct.Lamports = 1 execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) WriteClockSysvar(&execCtx.Accounts, clock) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidArgument, err) } @@ -2265,6 +2308,7 @@ func TestExecute_Tx_BpfLoader_ExtendProgram_Success(t *testing.T) { execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -2367,6 +2411,7 @@ func TestExecute_Tx_BpfLoader_ExtendProgram_Extend_By_Zero_Bytes_Failure(t *test execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrInvalidInstructionData, err) } @@ -2447,6 +2492,7 @@ func TestExecute_Tx_BpfLoader_ExtendProgram_With_Rent_Exemption_Payment_Not_Enou execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrNotEnoughAccountKeys, err) } @@ -2487,8 +2533,8 @@ func TestExecute_Tx_BpfLoader_ExtendProgram_With_Rent_Exemption_Payment_Success( payerPrivateKey, err := solana.NewRandomPrivateKey() assert.NoError(t, err) payerPubkey := payerPrivateKey.PublicKey() - payerAcct := accounts.Account{Key: payerPubkey, Lamports: 10, Data: make([]byte, 0), Owner: a.SystemProgramAddr, Executable: false, RentEpoch: 100} - origPayerBalance := uint64(10) + payerAcct := accounts.Account{Key: payerPubkey, Lamports: 1_000_000, Data: make([]byte, 0), Owner: a.SystemProgramAddr, Executable: false, RentEpoch: 100} + origPayerBalance := uint64(1_000_000) // program account programPrivKey, err := solana.NewRandomPrivateKey() @@ -2540,6 +2586,7 @@ func TestExecute_Tx_BpfLoader_ExtendProgram_With_Rent_Exemption_Payment_Success( execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -2666,10 +2713,11 @@ func TestExecute_Tx_BpfLoader_Upgrade_Success(t *testing.T) { rent.ExemptionThreshold = 1 rent.BurnPercent = 0 - rentAcct := accounts.Account{} + rentAcct := accounts.Account{Lamports: 1} execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -2777,10 +2825,11 @@ func TestExecute_Tx_BpfLoader_Upgrade_Buffer_Wrong_Authority_Failure(t *testing. rent.LamportsPerUint8Year = 1 rent.ExemptionThreshold = 1 rent.BurnPercent = 0 - rentAcct := accounts.Account{} + rentAcct := accounts.Account{Lamports: 1} execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, InstrErrIncorrectAuthority, err) } @@ -2885,6 +2934,7 @@ func TestExecute_Tx_BpfLoader_DeployWithMaxDataLen_Success(t *testing.T) { execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) WriteRentSysvar(&execCtx.Accounts, rent) + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) @@ -2970,6 +3020,7 @@ func TestExecute_Tx_BpfLoader_Invoke_Bpf_Program_Success(t *testing.T) { execCtx.SlotCtx = new(SlotCtx) execCtx.SlotCtx.Slot = 1337 + initializeLegacyBankFixture(t, &execCtx) err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) } diff --git a/pkg/sealevel/sealevel_config_program_test.go b/pkg/sealevel/sealevel_config_program_test.go index 0941cd82c..3f33a5216 100644 --- a/pkg/sealevel/sealevel_config_program_test.go +++ b/pkg/sealevel/sealevel_config_program_test.go @@ -53,7 +53,7 @@ func TestExecute_Tx_Config_Program_Success(t *testing.T) { acct, err := txCtx.Accounts.GetAccount(1) require.NoError(t, err) - hasNewData := bytes.HasSuffix(acct.Data, []byte("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")) + hasNewData := bytes.Equal(acct.Data[:len(instrData)], instrData) assert.Equal(t, true, hasNewData) } diff --git a/pkg/sealevel/sealevel_system_program_test.go b/pkg/sealevel/sealevel_system_program_test.go index 952461fa7..73ce90e13 100644 --- a/pkg/sealevel/sealevel_system_program_test.go +++ b/pkg/sealevel/sealevel_system_program_test.go @@ -195,7 +195,7 @@ func TestExecute_Tx_System_Program_CreateAccount_Not_Enough_Accts_Failure(t *tes WriteRentSysvar(&execCtx.Accounts, rent) err = execCtx.ProcessInstruction(instrBytes, instructionAccts, []uint64{0}) - assert.Equal(t, InstrErrNotEnoughAccountKeys, err) + assert.Equal(t, InstrErrMissingAccount, err) } func TestExecute_Tx_System_Program_CreateAccount_New_Acct_Has_Lamports_Failure(t *testing.T) { diff --git a/pkg/sealevel/sealevel_test.go b/pkg/sealevel/sealevel_test.go index 18309927b..41ac410be 100644 --- a/pkg/sealevel/sealevel_test.go +++ b/pkg/sealevel/sealevel_test.go @@ -3,6 +3,7 @@ package sealevel import ( "bytes" _ "embed" + "encoding/binary" "encoding/json" "fmt" "io/fs" @@ -75,6 +76,7 @@ func TestInterpreter_Noop(t *testing.T) { ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -107,13 +109,16 @@ func TestInterpreter_Memcpy_Strings_Match(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() assert.Equal(t, log.Logs, []string{ @@ -145,14 +150,17 @@ func TestInterpreter_Memcpy_Do_Not_Match(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() assert.Equal(t, log.Logs, []string{ @@ -183,14 +191,17 @@ func TestInterpreter_Memmove_Strings_Match(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() assert.Equal(t, log.Logs, []string{ @@ -222,14 +233,17 @@ func TestInterpreter_Memmove_Do_Not_Match(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() assert.Equal(t, log.Logs, []string{ @@ -259,14 +273,17 @@ func TestInterpreter_Memcpy_Overlapping(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() @@ -297,14 +314,17 @@ func TestInterpreter_Memcmp_Matches(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -338,14 +358,17 @@ func TestInterpreter_Memcmp_Does_Not_Match(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -379,14 +402,17 @@ func TestInterpreter_Memset_Check_Correct(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -429,6 +455,7 @@ func TestInterpreter_Sha256(t *testing.T) { ComputeMeter: &ctx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -462,14 +489,17 @@ func TestInterpreter_Blake3(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -503,14 +533,17 @@ func TestInterpreter_Keccak256(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -545,14 +578,17 @@ func TestInterpreter_CreateProgramAddress(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -590,14 +626,17 @@ func TestInterpreter_TryFindProgramAddress(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -628,18 +667,24 @@ func TestInterpreter_TestPanic(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: ToFunc(syscalls), - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: ToFunc(syscalls), + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.Error(t, err) - assert.Equal(t, err.Error(), "exception at 16: SBF program Panicked in some_file_1234.c at 1337:10") + require.Contains(t, err.Error(), "SBF program Panicked in some_file_1234.c at 1337:10") + var exception *sbpf.Exception + require.ErrorAs(t, err, &exception) + require.Equal(t, int64(17), exception.PC) } func TestInterpreter_Secp256k1_Syscall(t *testing.T) { @@ -659,14 +704,17 @@ func TestInterpreter_Secp256k1_Syscall(t *testing.T) { var log LogRecorder + execCtx := &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()} interpreter := sbpf.NewInterpreter(program, &sbpf.VMOpts{ - HeapMax: 32 * 1024, - Input: nil, - MaxCU: 10000, - Syscalls: syscalls, - Context: &ExecutionCtx{Log: &log, ComputeMeter: cu.NewComputeMeterDefault()}, + HeapMax: 32 * 1024, + Input: nil, + MaxCU: 10000, + Syscalls: syscalls, + Context: execCtx, + ComputeMeter: &execCtx.ComputeMeter, }) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() require.NoError(t, err) @@ -737,7 +785,7 @@ func TestInterpreter_Get_Stack_Height_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -809,7 +857,7 @@ func TestInterpreter_ReturnData_Syscalls(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -894,130 +942,11 @@ func TestInterpreter_Poseidon_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) - execCtx.SlotCtx.Slot = 1337 - - err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) - assert.Equal(t, nil, err) -} - -func TestInterpreter_Get_Sysvar_Syscalls(t *testing.T) { - // program data account - programDataPrivKey, err := solana.NewRandomPrivateKey() - assert.NoError(t, err) - programDataPubkey := programDataPrivKey.PublicKey() - programDataAcctState := UpgradeableLoaderState{Type: UpgradeableLoaderStateTypeProgramData, ProgramData: UpgradeableLoaderStateProgramData{Slot: 0, UpgradeAuthorityAddress: nil}} - validProgramBytes := fixtures.Load(t, "sbpf", "sysvars.so") - programDataStateWriter := new(bytes.Buffer) - programDataStateEncoder := bin.NewBinEncoder(programDataStateWriter) - err = programDataAcctState.MarshalWithEncoder(programDataStateEncoder) - assert.NoError(t, err) - programDataStateWriter.Write(validProgramBytes) - programDataStateBytes := make([]byte, len(validProgramBytes)+upgradeableLoaderSizeOfProgramDataMetaData) - copy(programDataStateBytes, programDataStateWriter.Bytes()) - copy(programDataStateBytes[upgradeableLoaderSizeOfProgramDataMetaData:], validProgramBytes) - - programDataAcct := accounts.Account{Key: programDataPubkey, Lamports: 0, Data: programDataStateBytes, Owner: a.BpfLoaderUpgradeableAddr, Executable: false, RentEpoch: 100} - - // program account - programAcctState := UpgradeableLoaderState{Type: UpgradeableLoaderStateTypeProgram, Program: UpgradeableLoaderStateProgram{ProgramDataAddress: programDataAcct.Key}} - programWriter := new(bytes.Buffer) - programEncoder := bin.NewBinEncoder(programWriter) - err = programAcctState.MarshalWithEncoder(programEncoder) - assert.NoError(t, err) - programBytes := programWriter.Bytes() - programPrivKey, err := solana.NewRandomPrivateKey() - assert.NoError(t, err) - programPubkey := programPrivKey.PublicKey() - programData := make([]byte, 5000) - copy(programData, programBytes) - programAcct := accounts.Account{Key: programPubkey, Lamports: 10000, Data: programData, Owner: a.BpfLoaderUpgradeableAddr, Executable: true, RentEpoch: 100} - - instrData := make([]byte, 0) - - transactionAccts := NewTransactionAccounts([]accounts.Account{programAcct}) - - acctMetas := []AccountMeta{{Pubkey: programAcct.Key, IsSigner: false, IsWritable: false}} - - instructionAccts := InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) - - txCtx := NewTransactionCtx(*transactionAccts, 5, 64) - var log LogRecorder - execCtx := ExecutionCtx{Log: &log, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeter(10000000000)} - - execCtx.Accounts = accounts.NewMemAccounts() - var clock SysvarClock - clock.Slot = 1234 - clock.Epoch = 1111 - clock.EpochStartTimestamp = 2222 - clock.UnixTimestamp = 3 - clock.LeaderScheduleEpoch = 100000 - clockAcct := accounts.Account{} - clockAcct.Lamports = 1 - execCtx.Accounts.SetAccount(&SysvarClockAddr, &clockAcct) - WriteClockSysvar(&execCtx.Accounts, clock) - - var rent SysvarRent - rent.LamportsPerUint8Year = 12 - rent.ExemptionThreshold = 34 - rent.BurnPercent = 56 - - rentAcct := accounts.Account{} - rentAcct.Lamports = 1 - execCtx.Accounts.SetAccount(&SysvarRentAddr, &rentAcct) - WriteRentSysvar(&execCtx.Accounts, rent) - - var epochSchedule SysvarEpochSchedule - epochSchedule.SlotsPerEpoch = 1111 - epochSchedule.LeaderScheduleSlotOffset = 2222 - epochSchedule.Warmup = true - epochSchedule.FirstNormalEpoch = 4444 - epochSchedule.FirstNormalSlot = 5555 - - epochScheduleAcct := accounts.Account{} - epochScheduleAcct.Lamports = 1 - execCtx.Accounts.SetAccount(&SysvarEpochScheduleAddr, &epochScheduleAcct) - WriteEpochScheduleSysvar(&execCtx.Accounts, epochSchedule) - - var lastRestartSlot SysvarLastRestartSlot - lastRestartSlot.LastRestartSlot = 989898 - lastRestartSlotAcct := accounts.Account{} - lastRestartSlotAcct.Lamports = 1 - execCtx.Accounts.SetAccount(&SysvarLastRestartSlotAddr, &lastRestartSlotAcct) - WriteLastRestartSlotSysvar(&execCtx.Accounts, lastRestartSlot) - - var epochRewards SysvarEpochRewards - epochRewards.DistributionStartingBlockHeight = 1234 - epochRewards.NumPartitions = 4321 - copy(epochRewards.ParentBlockhash[:], "abaaaaaaaaaaaaaaaaaaaaaaaaaaaada") - epochRewards.TotalPoints.Lo = 0xffffffffffffffff - epochRewards.TotalPoints.Hi = 0xeeeeeeeeeeeeeeee - epochRewards.TotalRewards = 5656 - epochRewards.DistributedRewards = 6767 - epochRewards.Active = false - epochRewardsAcct := accounts.Account{} - epochRewardsAcct.Lamports = 1 - execCtx.Accounts.SetAccount(&SysvarEpochRewardsAddr, &epochRewardsAcct) - WriteEpochRewardsSysvar(&execCtx.Accounts, epochRewards) - - f := features.NewFeaturesDefault() - f.EnableFeature(features.LastRestartSlotSysvar, 0) - f.EnableFeature(features.EnablePartitionedEpochReward, 0) - execCtx.Features = *f - - pk := [32]byte(programDataAcct.Key) - err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) - assert.NoError(t, err) - - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) assert.Equal(t, nil, err) - - for _, l := range log.Logs { - fmt.Printf("log: %s\n", l) - } } func TestInterpreter_AltBn128_Ops_Syscall(t *testing.T) { @@ -1064,7 +993,7 @@ func TestInterpreter_AltBn128_Ops_Syscall(t *testing.T) { var log LogRecorder execCtx := ExecutionCtx{Log: &log, TransactionContext: txCtx, ComputeMeter: cu.NewComputeMeter(10000000000)} - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 execCtx.SlotCtx.Accounts = accounts.NewMemAccounts() @@ -1190,7 +1119,7 @@ func TestInterpreter_Alloc_Free_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1254,7 +1183,7 @@ func TestInterpreter_Alt_Bn128_Compression_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1318,7 +1247,7 @@ func TestInterpreter_Validate_Point_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1382,7 +1311,7 @@ func TestInterpreter_Curve_Group_Ops_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1446,7 +1375,7 @@ func TestInterpreter_Curve_Multiscalar_Mul_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1510,7 +1439,7 @@ func TestInterpreter_Log_Data_Syscall(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1585,7 +1514,7 @@ func TestInterpreter_Cpi_C_System_Program_Allocate(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1665,7 +1594,7 @@ func TestInterpreter_Cpi_Rust_System_Program_Allocate(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1747,7 +1676,7 @@ func TestInterpreter_Cpi_C_Bpf_Program_Call(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1839,7 +1768,7 @@ func executeFirstBpfProgramAndReturnExecCtx(t *testing.T, log *LogRecorder, acct err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1915,14 +1844,21 @@ func TestInterpreter_Get_Processed_Sibling_Instruction_Test(t *testing.T) { fmt.Printf("******** second program call is %s\n", programAcct.Key) + // The introspection ELF only reads siblings; it does not invoke Allocate. + // Execute that preceding sibling explicitly before asking for indices 0/1. + allocateData := make([]byte, 12) + binary.LittleEndian.PutUint32(allocateData, SystemProgramInstrTypeAllocate) + binary.LittleEndian.PutUint64(allocateData[4:], 16) + allocateAccounts := InstructionAcctsFromAccountMetas([]AccountMeta{{Pubkey: acctToAlloc.Key, IsSigner: true, IsWritable: true}}, execCtx.TransactionContext.Accounts) + require.NoError(t, execCtx.ProcessInstruction(allocateData, allocateAccounts, []uint64{2})) + err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{1}) - assert.NoError(t, err) + require.NoError(t, err) - // test that the program logs from the CPI'd program (which calls get_processed_sibling_instruction) - // are as expected - expected := fmt.Sprintf("Program log: ******** sibling instruction 0 program id: %s", programAcct.Key) + // Check the introspection program reports both completed top-level siblings. + expected := fmt.Sprintf("Program log: ******** sibling instruction 0 program id: %s", solana.PublicKey(a.SystemProgramAddr)) assert.Equal(t, expected, log.Logs[1]) - expected = fmt.Sprintf("Program log: ******** sibling instruction 0 instruction data: %s", reformatHexBytes(instrData)) + expected = fmt.Sprintf("Program log: ******** sibling instruction 0 instruction data: %s", reformatHexBytes(allocateData)) assert.Equal(t, expected, log.Logs[2]) expected = fmt.Sprintf("Program log: ******** sibling instruction 1 program id: %s", firstProgramAcct.Key) @@ -1968,7 +1904,7 @@ func TestInterpreter_Test_Memo_Program_With_LoaderV2(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -1985,7 +1921,7 @@ func TestInterpreter_Test_Memo_Program_With_LoaderV2(t *testing.T) { instrData[1] = 0xff err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) - assert.Equal(t, nil, err) + assert.Equal(t, InstrErrInvalidInstructionData, err) expected = fmt.Sprintf("Program log: Signed by %s", signerPubkey) containsExpected = strings.HasPrefix(log.Logs[2], expected) @@ -2043,7 +1979,7 @@ func TestInterpreter_Test_Deprecated_Loader(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) @@ -2088,9 +2024,13 @@ func (e *executeCase) run(t *testing.T) { tx.PushInstructionCtx(InstructionCtx{}) opts := tx.newVMOpts(&e.Params) opts.Tracer = testLogger{t} + ctx := opts.Context.(*ExecutionCtx) + ctx.ComputeMeter = cu.NewComputeMeter(uint64(opts.MaxCU)) + opts.ComputeMeter = &ctx.ComputeMeter interpreter := sbpf.NewInterpreter(program, opts) require.NotNil(t, interpreter) + defer interpreter.Finish() _, _, err = interpreter.Run() assert.NoError(t, err) diff --git a/pkg/sealevel/spl_token_demo_test.go b/pkg/sealevel/spl_token_demo_test.go index c0ede8c86..0a6cc6441 100644 --- a/pkg/sealevel/spl_token_demo_test.go +++ b/pkg/sealevel/spl_token_demo_test.go @@ -24,7 +24,7 @@ var splTokenProgramAddr = base58.MustDecodeFromString("TokenkegQfeZyiNwAJbNbGKPF // spl token program later. func setupSplTokenProgramAccount(t *testing.T, accts *accounts.Accounts) accounts.Account { programBytes := fixtures.Load(t, "sbpf", "spl-token.so") - splTokenAcct := accounts.Account{Key: splTokenProgramAddr, Lamports: 0, Data: programBytes, Owner: a.BpfLoader2Addr, Executable: true, RentEpoch: 100} + splTokenAcct := accounts.Account{Key: splTokenProgramAddr, Lamports: 1, Data: programBytes, Owner: a.BpfLoader2Addr, Executable: true, RentEpoch: 100} pk := [32]byte(splTokenProgramAddr) err := (*accts).SetAccount(&pk, &splTokenAcct) @@ -201,6 +201,7 @@ func Test_Spl_Token_Program_Demo(t *testing.T) { {Pubkey: SysvarRentAddr, IsSigner: false, IsWritable: false}} instructionAccts := InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) execCtx.TransactionContext = NewTransactionCtx(*transactionAccts, 5, 64) + initializeLegacyBankFixture(t, execCtx) // InitializeMint: execute SPL token InitializeMint instruction err := execCtx.ProcessInstruction(initMintInstrData, instructionAccts, []uint64{0}) @@ -231,6 +232,7 @@ func Test_Spl_Token_Program_Demo(t *testing.T) { instructionAccts = InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) execCtx.TransactionContext = NewTransactionCtx(*transactionAccts, 5, 64) + initializeLegacyBankFixture(t, execCtx) // InitializeAccount: execute SPL token InitializeMint instruction err = execCtx.ProcessInstruction(initAccountInstrData, instructionAccts, []uint64{0}) @@ -260,6 +262,7 @@ func Test_Spl_Token_Program_Demo(t *testing.T) { instructionAccts = InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) execCtx.TransactionContext = NewTransactionCtx(*transactionAccts, 5, 64) + initializeLegacyBankFixture(t, execCtx) // InitializeAccount: execute SPL token InitializeMint instruction err = execCtx.ProcessInstruction(initAccountInstrData, instructionAccts, []uint64{0}) @@ -281,6 +284,7 @@ func Test_Spl_Token_Program_Demo(t *testing.T) { instructionAccts = InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) execCtx.TransactionContext = NewTransactionCtx(*transactionAccts, 5, 64) + initializeLegacyBankFixture(t, execCtx) // MintTo: serialize up a MintTo instruction numTokensToMint := uint64(61616161) @@ -306,6 +310,7 @@ func Test_Spl_Token_Program_Demo(t *testing.T) { instructionAccts = InstructionAcctsFromAccountMetas(acctMetas, *transactionAccts) execCtx.TransactionContext = NewTransactionCtx(*transactionAccts, 5, 64) + initializeLegacyBankFixture(t, execCtx) // Transfer: serialize up a Transfer instruction numTokensToTransfer := uint64(1337) diff --git a/pkg/sealevel/syscalls_call.go b/pkg/sealevel/syscalls_call.go index 761069485..193f44baa 100644 --- a/pkg/sealevel/syscalls_call.go +++ b/pkg/sealevel/syscalls_call.go @@ -53,11 +53,11 @@ func SyscallGetReturnDataImpl(vm sbpf.VM, returnDataAddr, length, programIdAddr return syscallErr(err) } - if len(returnData) != len(returnDataResult) { + if int(length) != len(returnDataResult) { return syscallErr(SyscallErrInvalidLength) } - copy(returnDataResult, returnData) + copy(returnDataResult, returnData[:length]) var programIdResult []byte programIdResult, err = vm.Translate(programIdAddr, solana.PublicKeyLength, true) diff --git a/pkg/sealevel/syscalls_return_data_test.go b/pkg/sealevel/syscalls_return_data_test.go new file mode 100644 index 000000000..e3252a154 --- /dev/null +++ b/pkg/sealevel/syscalls_return_data_test.go @@ -0,0 +1,43 @@ +package sealevel + +import ( + "bytes" + "fmt" + "github.com/Overclock-Validator/mithril/pkg/cu" + "github.com/Overclock-Validator/mithril/pkg/sbpf" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" + "testing" +) + +func TestSyscallGetReturnDataPrefix(t *testing.T) { + for _, n := range []uint64{0, 1, 7, 32, 64} { + t.Run(fmt.Sprint(n), func(t *testing.T) { + input := bytes.Repeat([]byte{0xCC}, 128) + vm, ctx := newMemSyscallVM(t, input, nil) + ctx.TransactionContext = &TransactionCtx{} + data := bytes.Repeat([]byte{0x5A}, 32) + pk := solana.PublicKey{0x72} + ctx.TransactionContext.SetReturnData(pk, data) + before := ctx.ComputeMeter.Remaining() + got, err := SyscallGetReturnDataImpl(vm, sbpf.VaddrInput, n, sbpf.VaddrInput+64) + require.NoError(t, err) + require.Equal(t, uint64(len(data)), got) + copied := min(n, uint64(len(data))) + actual, err := vm.Translate(sbpf.VaddrInput, 128, false) + require.NoError(t, err) + require.Equal(t, data[:copied], actual[:copied]) + require.Equal(t, bytes.Repeat([]byte{0xCC}, int(64-copied)), actual[copied:64]) + charge := uint64(cu.CUSyscallBaseCost) + if copied != 0 { + require.Equal(t, pk[:], actual[64:96]) + charge += (copied + 32) / cu.CUCpiBytesPerUnit + } else { + require.Equal(t, bytes.Repeat([]byte{0xCC}, 32), actual[64:96]) + } + require.Equal(t, charge, before-ctx.ComputeMeter.Remaining()) + _, retained := ctx.TransactionContext.ReturnData() + require.Equal(t, data, retained) + }) + } +} diff --git a/pkg/sealevel/syscalls_sysvar.go b/pkg/sealevel/syscalls_sysvar.go index acd8929b4..400744653 100644 --- a/pkg/sealevel/syscalls_sysvar.go +++ b/pkg/sealevel/syscalls_sysvar.go @@ -12,7 +12,6 @@ import ( //"github.com/Overclock-Validator/mithril/pkg/mlog" "github.com/Overclock-Validator/mithril/pkg/safemath" "github.com/Overclock-Validator/mithril/pkg/sbpf" - "github.com/Overclock-Validator/mithril/pkg/util" "github.com/gagliardetto/solana-go" ) @@ -172,9 +171,9 @@ func SyscallGetEpochRewardsSysvarImpl(vm sbpf.VM, addr uint64) (uint64, error) { binary.LittleEndian.PutUint64(epochRewardsDst[8:16], epochRewards.NumPartitions) copy(epochRewardsDst[16:48], epochRewards.ParentBlockhash[:]) + // repr(C) u128 uses little-endian low/high limbs on the SBF target. binary.LittleEndian.PutUint64(epochRewardsDst[48:56], epochRewards.TotalPoints.Lo) binary.LittleEndian.PutUint64(epochRewardsDst[56:64], epochRewards.TotalPoints.Hi) - util.ReverseBytesInPlace(epochRewardsDst[48:64]) binary.LittleEndian.PutUint64(epochRewardsDst[64:72], epochRewards.TotalRewards) binary.LittleEndian.PutUint64(epochRewardsDst[72:80], epochRewards.DistributedRewards) diff --git a/pkg/sealevel/syscalls_sysvar_layout_test.go b/pkg/sealevel/syscalls_sysvar_layout_test.go new file mode 100644 index 000000000..1cea7ecb9 --- /dev/null +++ b/pkg/sealevel/syscalls_sysvar_layout_test.go @@ -0,0 +1,68 @@ +package sealevel + +import ( + "encoding/binary" + "github.com/Overclock-Validator/mithril/pkg/accounts" + "github.com/Overclock-Validator/mithril/pkg/sbpf" + "github.com/stretchr/testify/require" + "math" + "testing" +) + +// Replace the old sysvars.so fixture, which expected packed EpochSchedule +// u64 fields at offsets 17/25. The syscall ABI is repr(C): offsets 24/32. +func TestSyscallSysvarLayouts(t *testing.T) { + vm, ctx := newMemSyscallVM(t, make([]byte, 512), nil) + ctx.Accounts = accounts.NewMemAccounts() + clock := SysvarClock{Slot: 1234, EpochStartTimestamp: 2222, Epoch: 1111, LeaderScheduleEpoch: 100000, UnixTimestamp: 3} + rent := SysvarRent{LamportsPerUint8Year: 12, ExemptionThreshold: 34, BurnPercent: 56} + schedule := SysvarEpochSchedule{SlotsPerEpoch: 1111, LeaderScheduleSlotOffset: 2222, Warmup: true, FirstNormalEpoch: 4444, FirstNormalSlot: 5555} + rewards := SysvarEpochRewards{DistributionStartingBlockHeight: 1234, NumPartitions: 4321, TotalRewards: 5656, DistributedRewards: 6767, Active: true} + rewards.TotalPoints.Lo = 0x0123456789abcdef + rewards.TotalPoints.Hi = 0xfedcba9876543210 + rewards.ParentBlockhash[0] = 0x73 + for _, key := range [][32]byte{SysvarClockAddr, SysvarRentAddr, SysvarEpochScheduleAddr, SysvarEpochRewardsAddr, SysvarLastRestartSlotAddr} { + require.NoError(t, ctx.Accounts.SetAccount(&key, &accounts.Account{Key: key, Lamports: 1})) + } + WriteClockSysvar(&ctx.Accounts, clock) + WriteRentSysvar(&ctx.Accounts, rent) + WriteEpochScheduleSysvar(&ctx.Accounts, schedule) + WriteEpochRewardsSysvar(&ctx.Accounts, rewards) + WriteLastRestartSlotSysvar(&ctx.Accounts, SysvarLastRestartSlot{LastRestartSlot: 989898}) + for _, tc := range []struct { + name string + call func(sbpf.VM, uint64) (uint64, error) + want []byte + }{ + {"clock", SyscallGetClockSysvarImpl, appendU64s(1234, 2222, 1111, 100000, 3)}, + {"rent", SyscallGetRentSysvarImpl, append(appendU64s(12, math.Float64bits(34)), 56, 0, 0, 0, 0, 0, 0, 0)}, + {"schedule", SyscallGetEpochScheduleSysvarImpl, appendU64s(1111, 2222, 1, 4444, 5555)}, + {"last restart", SyscallGetLastRestartSlotSysvarImpl, appendU64s(989898)}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.call(vm, sbpf.VaddrInput) + require.NoError(t, err) + got, err := vm.Translate(sbpf.VaddrInput, uint64(len(tc.want)), false) + require.NoError(t, err) + require.Equal(t, tc.want, got) + clear(got) + }) + } + _, err := SyscallGetEpochRewardsSysvarImpl(vm, sbpf.VaddrInput) + require.NoError(t, err) + got, err := vm.Translate(sbpf.VaddrInput, 96, false) + require.NoError(t, err) + want := make([]byte, 96) + copy(want, appendU64s(1234, 4321)) + copy(want[16:48], rewards.ParentBlockhash[:]) + copy(want[48:], appendU64s(rewards.TotalPoints.Lo, rewards.TotalPoints.Hi, 5656, 6767)) + want[80] = 1 + require.Equal(t, want, got) +} +func appendU64s(values ...uint64) []byte { + var b []byte + for _, v := range values { + b = binary.LittleEndian.AppendUint64(b, v) + } + return b +} diff --git a/pkg/sealevel/sysvar_instructions_test.go b/pkg/sealevel/sysvar_instructions_test.go index 568fa8d98..67fb040cd 100644 --- a/pkg/sealevel/sysvar_instructions_test.go +++ b/pkg/sealevel/sysvar_instructions_test.go @@ -113,7 +113,7 @@ func TestExecute_Tx_Sysvar_Instructions_Bpf_Test(t *testing.T) { err = execCtx.Accounts.SetAccount(&pk, &programDataAcct) assert.NoError(t, err) - execCtx.SlotCtx = new(SlotCtx) + initializeLegacyBankFixture(t, &execCtx) execCtx.SlotCtx.Slot = 1337 err = execCtx.ProcessInstruction(instrData, instructionAccts, []uint64{0}) diff --git a/pkg/sealevel/types.go b/pkg/sealevel/types.go index 558429374..6d00a25f7 100644 --- a/pkg/sealevel/types.go +++ b/pkg/sealevel/types.go @@ -208,12 +208,12 @@ func (accountMeta *SolAccountMetaC) Marshal() ([]byte, error) { return nil, err } - err = binary.Write(buf, binary.LittleEndian, accountMeta.IsSigner) + err = binary.Write(buf, binary.LittleEndian, accountMeta.IsWritable) if err != nil { return nil, err } - err = binary.Write(buf, binary.LittleEndian, accountMeta.IsWritable) + err = binary.Write(buf, binary.LittleEndian, accountMeta.IsSigner) if err != nil { return nil, err } diff --git a/pkg/statsd/statsd.go b/pkg/statsd/statsd.go index 2418cc025..5a9eda429 100644 --- a/pkg/statsd/statsd.go +++ b/pkg/statsd/statsd.go @@ -132,6 +132,8 @@ var ( TurbineEarlyPreparationWait = Metric{"turbine_early_preparation_wait_seconds"} TurbineEarlyVerifiedTransactions = Metric{"turbine_early_verified_transactions_total"} TurbineFullToReady = Metric{"turbine_full_to_ready_duration_seconds"} + // ReplayFullToReplayed: last shred assembled -> replay result handed to consensus. + ReplayFullToReplayed = Metric{"replay_full_to_replayed_duration_seconds"} // ReplaySigverifyGroup times one drained group of transaction signatures // and ReplaySigverifyGroupSignatures counts how many signatures were in it. // The pair is what tells an operator whether batching is actually happening: @@ -259,6 +261,7 @@ var MetricToType = map[Metric]metricType{ TurbineEarlyPreparationWait: TimingT, TurbineEarlyVerifiedTransactions: CountT, TurbineFullToReady: TimingT, + ReplayFullToReplayed: TimingT, ReplaySigverifyGroup: TimingT, ReplaySigverifyGroupSignatures: CountT, TurbineReplayAdmission: TimingT, @@ -373,6 +376,7 @@ var MetricToLabels = map[Metric][]string{ TurbineEarlyPreparationWait: {}, TurbineEarlyVerifiedTransactions: {}, TurbineFullToReady: {}, + ReplayFullToReplayed: {}, ReplaySigverifyGroup: {}, ReplaySigverifyGroupSignatures: {}, TurbineReplayAdmission: {}, @@ -414,6 +418,7 @@ var MetricToBuckets = map[Metric][]float64{ TurbineEarlyTransactionSigverify: turbinePipelineDurationBuckets, TurbineEarlyPreparationWait: turbinePipelineDurationBuckets, TurbineFullToReady: turbinePipelineDurationBuckets, + ReplayFullToReplayed: turbinePipelineDurationBuckets, ReplaySigverifyGroup: turbinePipelineDurationBuckets, TurbineReplayAdmission: turbinePipelineDurationBuckets, AlpenglowVoteRewards: turbinePipelineDurationBuckets, diff --git a/pkg/turbine/assembler.go b/pkg/turbine/assembler.go index 0dcff2fa6..6ef1d89d8 100644 --- a/pkg/turbine/assembler.go +++ b/pkg/turbine/assembler.go @@ -80,6 +80,15 @@ type SlotAssembler struct { // Production uses the process-wide bounded transaction verifier. verifyTransactions func(context.Context, *block.Block) error entryPrefetch *entryPrefetchPool + // Streaming feed subscriber (see stream.go); nil when nothing consumes + // batches before completion. + streamSubscriber chan<- StreamEvent + streamDroppedEvents uint64 + streamRepairParent *slotState + streamRepairChild *slotState + streamRepairInvalidChild *slotState + streamRepairUntil time.Time + streamRepairWake chan<- struct{} } type SlotRepairRequest struct { @@ -124,6 +133,11 @@ type slotState struct { // flow is usually poisoned state — the latest error names the poison. errCount int lastErr string + // streamCompleted marks a generation whose complete block was accepted, so + // the feed's release event says "completed" rather than "cancelled"; + // streamCancelReason names the discard path otherwise. + streamCompleted bool + streamCancelReason string } func (s *slotState) noteError(err error) { @@ -132,6 +146,8 @@ func (s *slotState) noteError(err error) { } type slotCompletionWork struct { + // Captured under mu: cancelled prefetch readers are not completion inputs. + ignorePrefetch bool state *slotState queuedAt time.Time observeCollection bool @@ -150,10 +166,11 @@ type processedSlotCompletion struct { } type slotCompletionResult struct { - block *block.Block - err error - hydrated bool - pending bool + generation StreamGeneration + block *block.Block + err error + hydrated bool + pending bool } type fecLayout struct { @@ -259,6 +276,10 @@ func (a *SlotAssembler) addShredFrom(shred *Shred, fromRepair bool) (*slotComple // of this immutable shred. Unauthenticated callers and spool hydration use nil // and retain the normal root-computation fallback. func (a *SlotAssembler) addShredFromWithRoot(shred *Shred, fromRepair bool, root *solana.Hash) (*slotCompletionWork, error) { + var admissionEntered int64 + if shred != nil && entryTraceSelected(shred.Slot) { + admissionEntered = entryTraceNow() + } if shred == nil { return nil, nil } @@ -282,6 +303,16 @@ func (a *SlotAssembler) addShredFromWithRoot(shred *Shred, fromRepair bool, root a.ignoredOldShreds++ return nil, nil } + // Diagnostic only; the deferred observation runs while a.mu is still held. + var repairTrace *entryRepairTrace + if fromRepair && admissionEntered != 0 { + repairTrace = &entryRepairTrace{Event: "repair_admission", Origin: entryTraceOrigin.UnixNano(), Slot: shred.Slot, Index: shred.Index, FEC: shred.FECSetIndex, AdmissionStart: admissionEntered, DeficitBefore: traceFECDeficit(state, shred.FECSetIndex), Outcome: "rejected"} + defer func() { + repairTrace.ResponseAt = entryTraceNow() + repairTrace.DeficitAfter = traceFECDeficit(state, shred.FECSetIndex) + emitRepairTrace(*repairTrace) + }() + } var err error switch shred.Type { case ShredTypeData: @@ -302,12 +333,19 @@ func (a *SlotAssembler) addShredFromWithRoot(shred *Shred, fromRepair bool, root } if err != nil { if errors.Is(err, ErrDuplicateShred) { + if repairTrace != nil { + repairTrace.Outcome = "duplicate" + } return nil, nil } state.noteError(err) return nil, err } - state.traceAcceptedShred(shred) + source := entryShredSource{Path: "non_repair", FEC: shred.FECSetIndex, TriggerIndex: shred.Index, TriggerCoding: shred.Type == ShredTypeCode, TriggerRepair: fromRepair, AdmissionEntered: admissionEntered} + if fromRepair { + source.Path = "repair" + } + state.traceAcceptedShred(shred, source) a.notePrefetchShredLocked(state, shred) if state.firstShredAt.IsZero() { state.firstShredAt = time.Now() @@ -330,12 +368,17 @@ func (a *SlotAssembler) addShredFromWithRoot(shred *Shred, fromRepair bool, root return nil, err } if err == nil { - state.traceAcceptedShred(recoveredShred) + source.Path = "fec_recovery" + state.traceAcceptedShred(recoveredShred, source) a.notePrefetchShredLocked(state, recoveredShred) a.recoveredDataShreds++ } } + if repairTrace != nil { + repairTrace.Outcome = "accepted" + repairTrace.Recovered = len(recovered) + } a.prefetchEntriesLocked(state) if !state.complete() { return nil, nil @@ -365,6 +408,7 @@ func (a *SlotAssembler) claimCompletionLocked(state *slotState, reportNonCanonic } return &slotCompletionWork{ state: state, + ignorePrefetch: state.prefetch != nil && state.prefetch.released, queuedAt: now, observeCollection: observeCollection, reportNonCanonical: reportNonCanonical, @@ -410,7 +454,7 @@ func (a *SlotAssembler) processCompletion(ctx context.Context, work *slotComplet decodeStartedAt := time.Now() decodeTimings := entryDecodeTimings{ctx: ctx} - if work.state.prefetch != nil { + if work.state.prefetch != nil && !work.ignorePrefetch { decodeTimings.prefetched = work.state.prefetch.batches } blk, parentInfo, roots, err := work.state.decodeBlock(&decodeTimings) @@ -487,6 +531,10 @@ func (a *SlotAssembler) finalizeCompletion(work *slotCompletionWork, processed p // state so catchup diagnostics report poison instead of a missing slot. state.noteError(processed.err) state.completing = false + // Diagnostics retain the poisoned slot, not a usable stream. Cancel + // readers now; cleanup returns capacity only after they have joined. + state.streamCancelReason = "completion_failed" + a.releasePrefetchLocked(state) a.mu.Unlock() return nil, processed.err } @@ -496,6 +544,7 @@ func (a *SlotAssembler) finalizeCompletion(work *slotCompletionWork, processed p if !a.acceptAlpenglowBlockIDLocked(blk) { a.trackNonCanonicalBlockIDLocked(blk) a.recordPartialObsLocked(state) + state.streamCancelReason = "non_canonical" a.releasePrefetchLocked(state) delete(a.slots, state.slot) a.mu.Unlock() @@ -505,6 +554,7 @@ func (a *SlotAssembler) finalizeCompletion(work *slotCompletionWork, processed p return nil, nil } + state.streamCompleted = true a.releasePrefetchLocked(state) delete(a.slots, state.slot) a.completedSlots[state.slot] = struct{}{} @@ -608,9 +658,19 @@ func (a *SlotAssembler) RejectAlpenglowBlockID(slot uint64, blockID solana.Hash) func (a *SlotAssembler) ResetSlot(slot uint64) { a.mu.Lock() defer a.mu.Unlock() + if a.streamRepairParent != nil && a.streamRepairParent.slot == slot { + a.streamRepairParent = nil + a.streamRepairChild = nil + } + if a.streamRepairChild != nil && a.streamRepairChild.slot == slot { + a.streamRepairChild = nil + } a.retentionDirty = true a.recordPartialObsLocked(a.slots[slot]) + if state := a.slots[slot]; state != nil { + state.streamCancelReason = "reset" + } a.releasePrefetchLocked(a.slots[slot]) delete(a.slots, slot) delete(a.completedSlots, slot) @@ -621,8 +681,13 @@ func (a *SlotAssembler) PrioritizeRepairSlot(slot uint64) { } func (a *SlotAssembler) PrioritizeRepairRange(start, end uint64) { + a.prioritizeRepairRange(start, end) +} + +// Report only newly installed pins, so repeated replay hints do not wake repair. +func (a *SlotAssembler) prioritizeRepairRange(start, end uint64) bool { if start == 0 { - return + return false } if end < start { end = start @@ -634,11 +699,13 @@ func (a *SlotAssembler) PrioritizeRepairRange(start, end uint64) { a.mu.Lock() defer a.mu.Unlock() + changed := false for slot := start; ; slot++ { if _, completed := a.completedSlots[slot]; !completed { if _, exists := a.priorityRepairSlots[slot]; !exists { a.priorityRepairSlots[slot] = struct{}{} a.priorityRepairOrder = append(a.priorityRepairOrder, slot) + changed = true } } if slot == end { @@ -646,6 +713,7 @@ func (a *SlotAssembler) PrioritizeRepairRange(start, end uint64) { } } a.prunePriorityRepairSlotsLocked() + return changed } func (a *SlotAssembler) slotState(slot uint64, version uint16) *slotState { @@ -724,6 +792,9 @@ func (a *SlotAssembler) pruneOldSlotsLocked() { return } a.recordPartialObsLocked(a.slots[victim]) + if state := a.slots[victim]; state != nil { + state.streamCancelReason = "evicted" + } a.releasePrefetchLocked(a.slots[victim]) delete(a.slots, victim) a.evictedSlots++ @@ -739,6 +810,7 @@ func (a *SlotAssembler) sweepRetentionMapsLocked() { for slot, state := range a.slots { if slot < minSlot && !state.completing { a.recordPartialObsLocked(state) + state.streamCancelReason = "retention" a.releasePrefetchLocked(a.slots[slot]) delete(a.slots, slot) a.evictedSlots++ @@ -894,6 +966,10 @@ func (s *slotState) addDataShred(shred *Shred) error { } func (s *slotState) repairRequest(maxMissing int) (SlotRepairRequest, bool) { + return s.repairRequestWithPrefix(maxMissing, false) +} + +func (s *slotState) repairRequestWithPrefix(maxMissing int, prefix bool) (SlotRepairRequest, bool) { req := SlotRepairRequest{Slot: s.slot} var maxObserved uint32 @@ -910,7 +986,7 @@ func (s *slotState) repairRequest(maxMissing int) (SlotRepairRequest, bool) { req.HighestDataShredIndex = maxObserved + 1 } - req.MissingDataShreds = s.missingDataForRepair(maxObserved, maxMissing) + req.MissingDataShreds = s.missingDataForRepairWithPrefix(maxObserved, maxMissing, prefix) if len(req.MissingDataShreds) == 0 && !req.NeedHighestDataShred { return SlotRepairRequest{}, false @@ -959,6 +1035,10 @@ func (span codedSpan) requestsToUnlock() int { // promises more — without that, the tail waits on a HighestWindowIndex // round trip to be discovered. func (s *slotState) missingDataForRepair(maxObserved uint32, maxMissing int) []uint32 { + return s.missingDataForRepairWithPrefix(maxObserved, maxMissing, false) +} + +func (s *slotState) missingDataForRepairWithPrefix(maxObserved uint32, maxMissing int, prefix bool) []uint32 { spans := make([]codedSpan, 0, len(s.fecSets)) for _, fec := range s.fecSets { if !fec.haveLayout || fec.layout.dataShreds == 0 { @@ -1018,7 +1098,38 @@ func (s *slotState) missingDataForRepair(maxObserved uint32, maxMissing int) []u return spans[order[a]].start < spans[order[b]].start }) + // For a streaming head, one earliest hole gates every later batch. Move + // just that span ahead of cheapest-unlock order; retain deficit capping + // and every existing request/admission limit. Without a known layout, + // prioritize one earliest missing data index rather than guessing a span. + var first []uint32 + if prefix { + earliest := -1 + for _, i := range order { + if earliest < 0 || spans[i].missing[0] < spans[earliest].missing[0] { + earliest = i + } + } + if len(uncovered) > 0 && (earliest < 0 || uncovered[0] < spans[earliest].missing[0]) { + first = uncovered[:1] + uncovered = uncovered[1:] + } else if earliest >= 0 { + first = spans[earliest].missing[:spans[earliest].requestsToUnlock()] + for j, i := range order { + if i == earliest { + order = append(order[:j], order[j+1:]...) + break + } + } + } + } missing := make([]uint32, 0, min(maxMissing, 64)) + for _, index := range first { + if len(missing) >= maxMissing { + return missing + } + missing = append(missing, index) + } for _, i := range order { span := spans[i] for _, index := range span.missing[:span.requestsToUnlock()] { @@ -1307,7 +1418,7 @@ func (a *SlotAssembler) RepairRequestsTiered(maxSlots int, maxMissingPerSlot int HighestDataShredIndex: 0, }) } - if req, ok := state.repairRequest(maxMissing); ok { + if req, ok := state.repairRequestWithPrefix(maxMissing, priorityPin && len(dst) == 0 && a.streamSubscriber != nil); ok { seen[slot] = struct{}{} return append(dst, req) } @@ -1315,7 +1426,11 @@ func (a *SlotAssembler) RepairRequestsTiered(maxSlots int, maxMissingPerSlot int } a.prunePriorityRepairSlotsLocked() - for _, slot := range a.priorityRepairOrder { + // Pin insertion order is retention policy, not dependency order. An older + // parent can be discovered after its child; give that parent the head share. + ordered := append([]uint64(nil), a.priorityRepairOrder...) + sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] }) + for _, slot := range ordered { // HEAD FIRST: the first priority slot — the one gating emission — // may list up to repairHeadMaxMissing, several times the per-slot // cap, so its admission share stays full at any response latency. @@ -1643,7 +1758,6 @@ func (s *slotState) sortedShreds() []*Shred { for _, idx := range indexes { out = append(out, s.shreds[uint32(idx)]) } - sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index }) return out } diff --git a/pkg/turbine/child_repair.go b/pkg/turbine/child_repair.go new file mode 100644 index 000000000..786d3ee2d --- /dev/null +++ b/pkg/turbine/child_repair.go @@ -0,0 +1,110 @@ +package turbine + +import "time" + +const childRepairLimit = 4 +const childRepairLifetime = 2 * time.Second + +// SetStreamRepairParent anchors lookahead to the generation currently executing. +// Zero clears the hint on discard/finalize. This grants fetching, never execution +// or fork choice: the child's parent block ID may not be verifiable until full. +func (a *SlotAssembler) SetStreamRepairParent(g StreamGeneration) { + a.mu.Lock() + defer a.mu.Unlock() + a.streamRepairInvalidChild = nil + a.streamRepairParent = nil + a.streamRepairChild = nil + slot := g.Slot() + if g.IsZero() || slot == 0 || a.streamSubscriber == nil { + return + } + p := g.state + if a.streamStatusLocked(g) == StreamGone { + return + } + a.streamRepairParent = p + a.streamRepairUntil = time.Now().Add(childRepairLifetime) + // Reconcile a header published before replay installed the anchor. + if slot == ^uint64(0) { + return + } + c := a.slots[slot+1] + if c == nil || c.prefetch == nil { + return + } + b := c.prefetch.batches[0] + if b == nil || b.ready == nil { + return + } + select { + case <-b.ready: + a.noteChildRepairHeaderLocked(c, b) + default: + } +} + +// Called by the asynchronous decoder, not replay's busy execution goroutine. +func (a *SlotAssembler) noteChildRepairHeaderLocked(s *slotState, b *prefetchedShredBatch) { + p := a.streamRepairParent + if p == nil || s == nil || a.slots[s.slot] != s || b == nil || !b.marker || b.parent == nil { + return + } + if s == p && b.parent.FromUpdateParent { + a.streamRepairParent = nil + a.streamRepairChild = nil + return + } + if p.slot == ^uint64(0) || s.slot != p.slot+1 { + return + } + if b.parent.FromUpdateParent || b.parent.ParentSlot != p.slot { + a.streamRepairInvalidChild = s + a.streamRepairChild = nil + return + } + if a.streamRepairInvalidChild == s || b.start != 0 || b.err != nil || b.parent.ParentSlot != p.slot || a.streamRepairChild == s { + return + } + if time.Now().After(a.streamRepairUntil) || a.streamStatusLocked(StreamGeneration{slot: p.slot, state: p}) == StreamGone { + return + } + a.streamRepairChild = s + // Nonblocking channel send has no callback or lock acquisition. It uses the + // existing coalesced/minimum-spacing repair scheduler, including under a.mu. + select { + case a.streamRepairWake <- struct{}{}: + default: + } +} + +func (a *SlotAssembler) childRepairRequest(now time.Time) (SlotRepairRequest, bool) { + a.mu.Lock() + defer a.mu.Unlock() + p, c := a.streamRepairParent, a.streamRepairChild + if a.streamSubscriber == nil || p == nil || c == nil || now.After(a.streamRepairUntil) { + return SlotRepairRequest{}, false + } + if a.streamStatusLocked(StreamGeneration{slot: p.slot, state: p}) == StreamGone || a.slots[c.slot] != c || c.completing { + return SlotRepairRequest{}, false + } + req, ok := c.repairRequestWithPrefix(childRepairLimit, true) + if !ok || len(req.MissingDataShreds) == 0 { + return SlotRepairRequest{}, false + } + // Only the earliest span, not four unrelated holes or highest-index probes. + first := req.MissingDataShreds[0] + end := first + 1 + for _, f := range c.fecSets { + if f.haveLayout && f.fecSetIndex <= first && first < f.fecSetIndex+uint32(f.layout.dataShreds) { + end = f.fecSetIndex + uint32(f.layout.dataShreds) + break + } + } + n := 1 + for n < len(req.MissingDataShreds) && req.MissingDataShreds[n] < end { + n++ + } + req.MissingDataShreds = req.MissingDataShreds[:n] + req.NeedHighestDataShred = false + return req, true +} diff --git a/pkg/turbine/child_repair_test.go b/pkg/turbine/child_repair_test.go new file mode 100644 index 000000000..b6718bd97 --- /dev/null +++ b/pkg/turbine/child_repair_test.go @@ -0,0 +1,160 @@ +package turbine + +import ( + "errors" + "github.com/Overclock-Validator/mithril/pkg/gossip" + "github.com/stretchr/testify/require" + "net" + "testing" + "time" +) + +func childRepairFixture(t *testing.T) (*SlotAssembler, *slotState, *slotState, *prefetchedShredBatch) { + t.Helper() + a := NewSlotAssembler() + a.SubscribeStream(make(chan StreamEvent, 1)) + p := newRepairSelectionSlot(100) + c := newRepairSelectionSlot(101) + addCodedSet(c, 0, 32, 32, seq(0, 19), 8) // deficit4 + addCodedSet(c, 32, 32, 32, seq(32, 56), 6) // cheaper later span + a.slots[100] = p + a.slots[101] = c + a.maxObservedSlot = 101 + done := make(chan struct{}) + close(done) + b := &prefetchedShredBatch{start: 0, marker: true, parent: &AlpenglowParentInfo{ParentSlot: 100}, ready: done} + c.prefetch = &slotEntryPrefetch{batches: map[uint32]*prefetchedShredBatch{0: b}} + a.SetStreamRepairParent(StreamGeneration{slot: 100, state: p}) + return a, p, c, b +} +func TestChildRepairEarlyHeaderAndDroppedEvents(t *testing.T) { + a, _, c, b := childRepairFixture(t) + req, ok := a.childRepairRequest(time.Now()) + require.True(t, ok) + require.Equal(t, []uint32{20, 21, 22, 23}, req.MissingDataShreds) + require.False(t, req.NeedHighestDataShred) + a.streamRepairChild = nil + a.streamSubscriber <- StreamEvent{} // full notification channel + wake := make(chan struct{}, 1) + a.streamRepairWake = wake + a.mu.Lock() + a.publishStreamBatchReadyLocked(c, b) + a.mu.Unlock() + require.Len(t, wake, 1) + require.Equal(t, uint64(1), a.StreamDroppedEvents()) + _, ok = a.childRepairRequest(time.Now()) + require.True(t, ok) + // Repeated publication is not a new repair wakeup. + <-wake + a.mu.Lock() + a.publishStreamBatchReadyLocked(c, b) + a.mu.Unlock() + require.Empty(t, wake) +} +func TestChildRepairLifecycle(t *testing.T) { + for _, kind := range []string{"expiry", "clear", "child-reset", "parent-reset", "child-complete", "parent-replaced", "unsubscribe", "update-parent", "wrong-parent"} { + t.Run(kind, func(t *testing.T) { + a, p, c, b := childRepairFixture(t) + switch kind { + case "expiry": + a.streamRepairUntil = time.Now().Add(-time.Second) + case "clear": + a.SetStreamRepairParent(StreamGeneration{}) + case "child-reset": + c.prefetch = nil // fixture has no live prefetch workers + a.ResetSlot(c.slot) + case "parent-reset": + a.ResetSlot(p.slot) + case "child-complete": + c.completing = true + case "parent-replaced": + a.slots[p.slot] = newRepairSelectionSlot(p.slot) + case "unsubscribe": + a.SubscribeStream(nil) + case "update-parent", "wrong-parent": + changed := prefetchedShredBatch{start: b.start, end: b.end, parent: b.parent, marker: b.marker, ready: b.ready} + parent := *b.parent + changed.parent = &parent + if kind == "update-parent" { + changed.start = 32 + parent.FromUpdateParent = true + } else { + parent.ParentSlot = 99 + } + a.mu.Lock() + a.noteChildRepairHeaderLocked(c, &changed) + a.noteChildRepairHeaderLocked(c, b) + a.mu.Unlock() + } + _, ok := a.childRepairRequest(time.Now()) + require.False(t, ok) + }) + } + // Parent assembly can finish while replay is still executing it. + a, p, _, _ := childRepairFixture(t) + delete(a.slots, p.slot) + p.streamCompleted = true + _, ok := a.childRepairRequest(time.Now()) + require.True(t, ok) + a.ResetSlot(p.slot) + _, ok = a.childRepairRequest(time.Now()) + require.False(t, ok) +} +func TestChildRepairRejectsUnrelatedAndInvalidHeader(t *testing.T) { + a, _, c, b := childRepairFixture(t) + a.streamRepairChild = nil + bad := prefetchedShredBatch{start: b.start, end: b.end, parent: b.parent, marker: b.marker, ready: b.ready} + bad.err = errors.New("invalid header") + a.mu.Lock() + a.noteChildRepairHeaderLocked(c, &bad) + a.mu.Unlock() + _, ok := a.childRepairRequest(time.Now()) + require.False(t, ok) + other := newRepairSelectionSlot(102) + a.mu.Lock() + a.noteChildRepairHeaderLocked(other, b) + a.mu.Unlock() + _, ok = a.childRepairRequest(time.Now()) + require.False(t, ok) +} +func TestChildRepairUsesOnlyRemainingBudget(t *testing.T) { + sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + defer sink.Close() + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + defer conn.Close() + c := newPacingTestClient(t) + peers := []gossip.RepairPeer{{Addr: sink.LocalAddr().(*net.UDPAddr)}} + req := SlotRepairRequest{Slot: 101, MissingDataShreds: []uint32{20, 21, 22, 23, 24, 25}, NeedHighestDataShred: true} + require.Zero(t, c.sendChildRepair(conn, peers, req, 0, time.Second)) + require.Equal(t, 2, c.sendChildRepair(conn, peers, req, 2, time.Second)) + require.Equal(t, 2, c.sendChildRepair(conn, peers, req, 100, time.Second)) + require.Zero(t, c.sendChildRepair(conn, peers, req, 100, time.Second)) + require.Len(t, c.outstanding, 4) + for k := range c.outstanding { + require.Equal(t, repairRequestWindowIndex, k.kind) + require.Zero(t, k.attempt) + } +} + +func TestChildRepairRejectsStaleParentAnchor(t *testing.T) { + a, p, _, _ := childRepairFixture(t) + a.slots[p.slot] = newRepairSelectionSlot(p.slot) + a.SetStreamRepairParent(StreamGeneration{slot: p.slot, state: p}) + require.Nil(t, a.streamRepairParent) +} + +func TestChildRepairParentUpdateClearsLookahead(t *testing.T) { + a, p, _, b := childRepairFixture(t) + update := prefetchedShredBatch{start: b.start, end: b.end, parent: b.parent, marker: b.marker, ready: b.ready} + info := *b.parent + info.FromUpdateParent = true + update.parent = &info + update.start = 32 + a.mu.Lock() + a.noteChildRepairHeaderLocked(p, &update) + a.mu.Unlock() + _, ok := a.childRepairRequest(time.Now()) + require.False(t, ok) +} diff --git a/pkg/turbine/completion_pool.go b/pkg/turbine/completion_pool.go index 451b0095c..e493b681d 100644 --- a/pkg/turbine/completion_pool.go +++ b/pkg/turbine/completion_pool.go @@ -83,10 +83,11 @@ func newSlotCompletionPool(assembler *SlotAssembler, resetGate *sync.RWMutex, on p.resetGate.RUnlock() } p.results <- slotCompletionResult{ - block: blk, - err: err, - hydrated: queued.hydrated, - pending: pending, + generation: StreamGeneration{slot: queued.work.state.slot, state: queued.work.state}, + block: blk, + err: err, + hydrated: queued.hydrated, + pending: pending, } } }() diff --git a/pkg/turbine/entry_pipeline_trace.go b/pkg/turbine/entry_pipeline_trace.go index c2c83b454..38ea1055e 100644 --- a/pkg/turbine/entry_pipeline_trace.go +++ b/pkg/turbine/entry_pipeline_trace.go @@ -23,6 +23,7 @@ type entryTraceSettings struct { modulo uint64 until time.Time reports chan entryPipelineReport + repairs chan entryRepairTrace } func configureEntryTrace() entryTraceSettings { @@ -44,18 +45,27 @@ func configureEntryTrace() entryTraceSettings { return entryTraceSettings{} } ch := make(chan entryPipelineReport, 8) + repairs := make(chan entryRepairTrace, 256) go func() { defer f.Close() enc := json.NewEncoder(f) - for r := range ch { - r.Dropped = entryTraceDropped.Load() - r.finish() - if err := enc.Encode(r); err != nil { - entryTraceDropped.Add(1) + for { + select { + case r := <-repairs: + r.Dropped = entryTraceDropped.Load() + if err := enc.Encode(r); err != nil { + entryTraceDropped.Add(1) + } + case r := <-ch: + r.Dropped = entryTraceDropped.Load() + r.finish() + if err := enc.Encode(r); err != nil { + entryTraceDropped.Add(1) + } } } }() - return entryTraceSettings{n, time.Now().Add(time.Duration(seconds) * time.Second), ch} + return entryTraceSettings{modulo: n, until: time.Now().Add(time.Duration(seconds) * time.Second), reports: ch, repairs: repairs} } func entryTraceNow() int64 { return time.Since(entryTraceOrigin).Nanoseconds() } @@ -75,13 +85,14 @@ func entryTraceContext(ctx context.Context) bool { type entryPipelineTrace struct { arrivals map[uint32]int64 + sources map[uint32]entryShredSource discovered map[uint32]int64 sealed bool // frozen at first completion claim, including retry/error paths } // Called only after successful admission, under the assembler lock. Includes // FEC-reconstructed data. This is local availability, not a NIC timestamp. -func (s *slotState) traceAcceptedShred(sh *Shred) { +func (s *slotState) traceAcceptedShred(sh *Shred, source ...entryShredSource) { if sh.Type != ShredTypeData { return } @@ -93,7 +104,16 @@ func (s *slotState) traceAcceptedShred(sh *Shred) { s.pipelineTrace = &entryPipelineTrace{arrivals: make(map[uint32]int64), discovered: make(map[uint32]int64)} } if !s.pipelineTrace.sealed { + if _, exists := s.pipelineTrace.arrivals[sh.Index]; exists { + return + } s.pipelineTrace.arrivals[sh.Index] = entryTraceNow() + if len(source) > 0 { + if s.pipelineTrace.sources == nil { + s.pipelineTrace.sources = make(map[uint32]entryShredSource) + } + s.pipelineTrace.sources[sh.Index] = source[0] + } } } @@ -125,6 +145,9 @@ func (t *entryVerificationTrace) observe(j *transactionVerifyJob) { } type entryBatchTraceReport struct { + CriticalIndex *uint32 `json:"critical_shred_index,omitempty"` + CriticalSource *entryShredSource `json:"critical_shred_source,omitempty"` + CriticalTies int `json:"critical_timestamp_ties"` Start uint32 `json:"start"` End uint32 `json:"end"` Transactions int `json:"transactions"` @@ -190,7 +213,18 @@ func (r *entryPipelineReport) finish() { if !ok { row.AvailabilityKnown = false } - row.Available = max(row.Available, at) + if ok && (row.CriticalIndex == nil || at > row.Available) { + index := i + row.CriticalIndex = &index + row.CriticalTies = 1 + row.CriticalSource = nil + if source, exists := r.source.sources[i]; exists { + row.CriticalSource = &source + } + row.Available = at + } else if ok && at == row.Available { + row.CriticalTies++ + } } r.Batches = append(r.Batches, row) } @@ -210,3 +244,90 @@ func queueEntryPipelineReport(s *slotState, b *block.Block, d *entryDecodeTiming entryTraceDropped.Add(1) } } + +// Attribution starts at assembler entry, not socket receipt. +// "non_repair" includes direct/spooled admission, not proof of socket origin. +// A recovered shred records the packet that triggered reconstruction; it is not itself a +// received repair response. Missing source fields in older reports mean unknown. +type entryShredSource struct { + Path string `json:"path"` + FEC uint32 `json:"fec_set"` + TriggerIndex uint32 `json:"trigger_index"` + TriggerCoding bool `json:"trigger_coding"` + TriggerRepair bool `json:"trigger_repair"` + AdmissionEntered int64 `json:"admission_entered_ns"` +} + +// Separate JSONL records join by origin/slot/index. The time pair brackets the +// UDP write syscall, NOT delivery. Attempt IDs can reset; order by timestamps. +// Highest-index probes are not exact requests for the returned shred index. +type entryRepairTrace struct { + AdmissionStart int64 `json:"admission_start_ns,omitempty"` + Peer string `json:"peer,omitempty"` + Nonce uint32 `json:"nonce"` + ResponseAt int64 `json:"response_ns,omitempty"` + RequestedAt int64 `json:"requested_ns,omitempty"` + ReturnedIndex uint32 `json:"returned_index"` + Late bool `json:"late"` + FEC uint32 `json:"fec_set"` + DeficitBefore int `json:"deficit_before"` + DeficitAfter int `json:"deficit_after"` + Recovered int `json:"recovered"` + Outcome string `json:"outcome,omitempty"` + Event string `json:"event"` + Origin int64 `json:"origin_unix_ns"` + Slot uint64 `json:"slot"` + Index uint32 `json:"index"` + Highest bool `json:"highest_index_probe"` + Attempt uint8 `json:"attempt"` + SendStart int64 `json:"send_start_ns"` + SendEnd int64 `json:"send_end_ns"` + Success bool `json:"success"` + Dropped uint64 `json:"dropped_reports"` +} + +func entryTraceSelected(slot uint64) bool { + c := entryTraceConfig + return c.modulo != 0 && slot%c.modulo == 0 && time.Now().Before(c.until) +} +func traceRepairSend(slot uint64, index uint32, kind repairRequestKind, attempt uint8, start int64, success bool, binding ...entryRepairTrace) { + if start == 0 || entryTraceConfig.repairs == nil { + return + } + r := entryRepairTrace{Event: "repair_send", Origin: entryTraceOrigin.UnixNano(), Slot: slot, Index: index, Highest: kind == repairRequestHighestWindowIndex, Attempt: attempt, SendStart: start, SendEnd: entryTraceNow(), Success: success} + if len(binding) > 0 { + r.Peer = binding[0].Peer + r.Nonce = binding[0].Nonce + } + emitRepairTrace(r) +} + +func emitRepairTrace(r entryRepairTrace) { + if entryTraceConfig.repairs == nil { + return + } + select { + case entryTraceConfig.repairs <- r: + default: + entryTraceDropped.Add(1) + } +} + +// -1 means no authenticated coding layout is known. Zero means sufficient +// shards, not that reconstruction necessarily succeeded (see outcome/recovered). +func traceFECDeficit(s *slotState, index uint32) int { + if s == nil { + return -1 + } + f := s.fecSets[index] + if f == nil || !f.haveLayout { + return -1 + } + return max(0, int(f.layout.dataShreds)-len(f.data)-len(f.coding)) +} +func traceRepairResponse(rec outstandingRepairRequest, sh *Shred, peer string, late bool) { + if !entryTraceSelected(sh.Slot) { + return + } + emitRepairTrace(entryRepairTrace{Event: "repair_response", Origin: entryTraceOrigin.UnixNano(), Slot: sh.Slot, Index: rec.key.index, Highest: rec.key.kind == repairRequestHighestWindowIndex, Attempt: rec.key.attempt, Nonce: rec.nonce, Peer: peer, RequestedAt: entryTraceTime(rec.sentAt), ResponseAt: entryTraceNow(), ReturnedIndex: sh.Index, FEC: sh.FECSetIndex, Late: late}) +} diff --git a/pkg/turbine/entry_pipeline_trace_test.go b/pkg/turbine/entry_pipeline_trace_test.go index 6aa493601..5816287d4 100644 --- a/pkg/turbine/entry_pipeline_trace_test.go +++ b/pkg/turbine/entry_pipeline_trace_test.go @@ -2,7 +2,9 @@ package turbine import ( "context" + "net" "testing" + "time" "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/require" @@ -66,3 +68,110 @@ func TestEntryPipelineTraceSealedGeneration(t *testing.T) { s.traceAcceptedShred(&Shred{Type: ShredTypeData, Index: 2}) require.Len(t, s.pipelineTrace.arrivals, 1, "completion/retry cannot mutate a report's frozen arrival map") } + +func TestEntryCriticalShredIncludesBoundaryAndSource(t *testing.T) { + source := &entryPipelineTrace{arrivals: map[uint32]int64{2: 100, 3: 20, 4: 30}, sources: map[uint32]entryShredSource{2: {Path: "fec_recovery", FEC: 0, TriggerIndex: 12, TriggerCoding: true, TriggerRepair: true, AdmissionEntered: 80}}} + r := entryPipelineReport{source: source, all: []*prefetchedShredBatch{{start: 3, end: 4}}} + r.finish() + require.Equal(t, uint32(2), *r.Batches[0].CriticalIndex) + require.Equal(t, "fec_recovery", r.Batches[0].CriticalSource.Path) + require.True(t, r.Batches[0].CriticalSource.TriggerRepair) + require.Equal(t, 1, r.Batches[0].CriticalTies) + source.arrivals[3] = 100 + r.Batches = nil + r.finish() + require.Equal(t, 2, r.Batches[0].CriticalTies) + require.Equal(t, uint32(2), *r.Batches[0].CriticalIndex, "ties select the lowest index deterministically") +} + +func TestEntryTracePreservesFirstAdmission(t *testing.T) { + s := &slotState{pipelineTrace: &entryPipelineTrace{arrivals: make(map[uint32]int64)}} + sh := &Shred{Type: ShredTypeData, Index: 1} + s.traceAcceptedShred(sh, entryShredSource{Path: "repair"}) + first := s.pipelineTrace.arrivals[1] + s.traceAcceptedShred(sh, entryShredSource{Path: "non_repair"}) + require.Equal(t, first, s.pipelineTrace.arrivals[1]) + require.Equal(t, "repair", s.pipelineTrace.sources[1].Path) + s.pipelineTrace.sealed = true + s.traceAcceptedShred(&Shred{Type: ShredTypeData, Index: 2}, entryShredSource{Path: "repair"}) + require.Len(t, s.pipelineTrace.sources, 1) +} + +func TestEntryRepairTraceBoundedAndExplicit(t *testing.T) { + old := entryTraceConfig + defer func() { entryTraceConfig = old }() + entryTraceConfig = entryTraceSettings{repairs: make(chan entryRepairTrace, 1)} + traceRepairSend(15, 10, repairRequestWindowIndex, 2, 100, true) + r := <-entryTraceConfig.repairs + require.Equal(t, "repair_send", r.Event) + require.Equal(t, uint64(15), r.Slot) + require.False(t, r.Highest) + require.True(t, r.Success) + require.Equal(t, uint8(2), r.Attempt) + traceRepairSend(15, 10, repairRequestHighestWindowIndex, 0, 100, false) + dropped := entryTraceDropped.Load() + traceRepairSend(15, 11, repairRequestWindowIndex, 0, 100, true) + require.Equal(t, dropped+1, entryTraceDropped.Load(), "full diagnostic queue never blocks repair") + r = <-entryTraceConfig.repairs + require.True(t, r.Highest) + require.False(t, r.Success) + traceRepairSend(15, 1, repairRequestWindowIndex, 0, 0, true) + require.Empty(t, entryTraceConfig.repairs, "unsampled sends are ignored") +} + +func TestEntryTraceSelectionIsBounded(t *testing.T) { + old := entryTraceConfig + defer func() { entryTraceConfig = old }() + entryTraceConfig = entryTraceSettings{modulo: 5, until: time.Now().Add(time.Minute)} + require.True(t, entryTraceSelected(15)) + require.False(t, entryTraceSelected(16)) + entryTraceConfig.until = time.Now().Add(-time.Second) + require.False(t, entryTraceSelected(15)) + entryTraceConfig = entryTraceSettings{} + require.False(t, entryTraceSelected(15)) +} + +func TestEntryRepairResponseCorrelation(t *testing.T) { + old := entryTraceConfig + defer func() { entryTraceConfig = old }() + entryTraceConfig = entryTraceSettings{modulo: 1, until: time.Now().Add(time.Minute), repairs: make(chan entryRepairTrace, 8)} + from := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8000} + addr, _ := repairAddressKeyFromUDP(from) + for _, late := range []bool{false, true} { + c := newPacingTestClient(t) + key := repairRequestKey{kind: repairRequestWindowIndex, slot: 42, index: 3} + rec := outstandingRepairRequest{key: key, nonce: 777, addr: addr, sentAt: time.Now().Add(-time.Second), accountAt: time.Now().Add(time.Second)} + responseKey := repairResponseKey{addr: addr, nonce: 777} + if late { + c.expiredCur[responseKey] = rec + } else { + c.outstanding[key] = rec + c.byResponse[responseKey] = key + } + require.False(t, observeRepairForTest(c, nil, nonceTrailer(777), from, &Shred{Slot: 42, Index: 4, Type: ShredTypeData})) + require.Empty(t, entryTraceConfig.repairs, "wrong index cannot be reported as matched") + require.True(t, observeRepairForTest(c, nil, nonceTrailer(777), from, &Shred{Slot: 42, Index: 3, Type: ShredTypeData})) + r := <-entryTraceConfig.repairs + require.Equal(t, "repair_response", r.Event) + require.Equal(t, uint32(777), r.Nonce) + require.Equal(t, from.String(), r.Peer) + require.Equal(t, late, r.Late) + require.Equal(t, uint32(3), r.ReturnedIndex) + require.Greater(t, r.ResponseAt, r.RequestedAt) + require.False(t, observeRepairForTest(c, nil, nonceTrailer(777), from, &Shred{Slot: 42, Index: 3, Type: ShredTypeData})) + require.Empty(t, entryTraceConfig.repairs, "consumed nonce cannot count twice") + } +} + +func TestEntryFECDeficit(t *testing.T) { + s := newRepairSelectionSlot(42) + require.Equal(t, -1, traceFECDeficit(s, 0)) + addCodedSet(s, 0, 32, 32, seq(0, 19), 8) + require.Equal(t, 4, traceFECDeficit(s, 0)) + s.fecSets[0].data[20] = &Shred{} + require.Equal(t, 3, traceFECDeficit(s, 0)) + for i := uint32(21); i < 32; i++ { + s.fecSets[0].data[i] = &Shred{} + } + require.Zero(t, traceFECDeficit(s, 0), "enough shards is not a negative deficit") +} diff --git a/pkg/turbine/entry_prefetch.go b/pkg/turbine/entry_prefetch.go index 2b082e52b..28076cfb3 100644 --- a/pkg/turbine/entry_prefetch.go +++ b/pkg/turbine/entry_prefetch.go @@ -3,6 +3,7 @@ package turbine import ( "context" "errors" + "sort" "sync" "time" @@ -26,9 +27,13 @@ type shredBatchRange struct{ start, end uint32 } // Fields are immutable after ready closes; signature readers own its decoded // transactions until verification.done closes. type prefetchedShredBatch struct { + viewOnce sync.Once // protects the immutable stream view, including concurrent Resolve + view *StreamBatch + readyAt time.Time // set before ready closes start, end uint32 raw []byte entries []Entry + transactions []*solana.Transaction // immutable pointer view, built once before ready closes parent *AlpenglowParentInfo footer *BlockFooter marker bool @@ -51,6 +56,7 @@ type slotEntryPrefetch struct { queued, released bool queueDone chan struct{} // closed after the queued/running token retires bytes int + budgetBlocked bool } // All scheduling and accounting use assembler.mu. The packet reader only @@ -64,6 +70,7 @@ type entryPrefetchPool struct { jobs chan *slotState workers, cleanup sync.WaitGroup slots, bytes int + active map[*slotState]struct{} // at most entryPrefetchSlots admitted generations closed bool close sync.Once } @@ -83,7 +90,7 @@ func newEntryPrefetchPool(ctx context.Context, a *SlotAssembler, verifier *trans func (a *SlotAssembler) prefetchEntriesLocked(s *slotState) { p := a.entryPrefetch - if p == nil || p.closed || p.ctx.Err() != nil { + if p == nil || p.closed || p.ctx.Err() != nil || s.streamCancelReason != "" { return } if s.batchIndex == nil && len(s.shreds) != 0 { @@ -96,6 +103,10 @@ func (a *SlotAssembler) prefetchEntriesLocked(s *slotState) { ctx, cancel := context.WithCancel(withEntryPipelineTrace(p.ctx, s.pipelineTrace)) s.prefetch = &slotEntryPrefetch{pool: p, ctx: ctx, cancel: cancel, batches: make(map[uint32]*prefetchedShredBatch)} p.slots++ + if p.active == nil { + p.active = make(map[*slotState]struct{}) + } + p.active[s] = struct{}{} } p.enqueueLocked(s) } @@ -124,6 +135,7 @@ func (p *entryPrefetchPool) run() { p.a.mu.Unlock() continue } + f.budgetBlocked = false var batch *prefetchedShredBatch var shreds []*Shred var rawSize int @@ -137,10 +149,14 @@ func (p *entryPrefetchPool) run() { } } if size > entryPrefetchBatchBytes { - f.next++ - continue + // Never publish a prefix with an unfillable hole. Completion + // still decodes and verifies the entire valid block normally. + s.streamCancelReason = "prefetch_batch_too_large" + p.a.releasePrefetchLocked(s) + break } if p.bytes+size > entryPrefetchBytes { + f.budgetBlocked = true break } f.next++ @@ -178,17 +194,24 @@ func (p *entryPrefetchPool) run() { if s.pipelineTrace != nil { batch.traceDecodeEnd = entryTraceNow() } + if batch.err == nil && !batch.marker { + batch.transactions = entryBatchTransactions(batch.entries) + } if batch.err == nil && !batch.marker && f.ctx.Err() == nil { - txs := entryBatchTransactions(batch.entries) + txs := batch.transactions if len(txs) > 0 { batch.submittedAt = time.Now() batch.verification, batch.submitErr = p.verifier.submitPrefetchTransactions(f.ctx, txs) } } + batch.readyAt = time.Now() close(ready) p.a.mu.Lock() f.queued = false close(f.queueDone) + if !f.released && p.a.slots[s.slot] == s { + p.a.publishStreamBatchReadyLocked(s, batch) + } p.enqueueLocked(s) p.a.mu.Unlock() } @@ -219,6 +242,11 @@ func (a *SlotAssembler) releasePrefetchLocked(s *slotState) { f := s.prefetch f.released = true f.cancel() + reason := s.streamCancelReason + if reason == "" { + reason = "released" + } + a.publishStreamReleaseLocked(s, reason) p := f.pool queueDone := f.queueDone p.cleanup.Add(1) @@ -236,10 +264,30 @@ func (a *SlotAssembler) releasePrefetchLocked(s *slotState) { p.a.mu.Lock() p.slots-- p.bytes -= f.bytes + delete(p.active, s) + p.retryBudgetBlockedLocked() p.a.mu.Unlock() }() } +// Retry only admitted generations, oldest slot first, when readers release bytes. +// This avoids both waiting for another shred and scanning all retained slots. +func (p *entryPrefetchPool) retryBudgetBlockedLocked() { + if p.closed || p.ctx.Err() != nil { + return + } + waiting := make([]*slotState, 0, len(p.active)) + for s := range p.active { + if s.prefetch.budgetBlocked && p.a.slots[s.slot] == s { + waiting = append(waiting, s) + } + } + sort.Slice(waiting, func(i, j int) bool { return waiting[i].slot < waiting[j].slot }) + for _, s := range waiting { + p.enqueueLocked(s) + } +} + func (p *entryPrefetchPool) closeAndWait() { p.close.Do(func() { p.cancel() @@ -250,6 +298,9 @@ func (p *entryPrefetchPool) closeAndWait() { } for _, s := range p.a.slots { if s.prefetch != nil && s.prefetch.pool == p { + if s.streamCancelReason == "" { + s.streamCancelReason = "shutdown" + } p.a.releasePrefetchLocked(s) } } diff --git a/pkg/turbine/entry_prefetch_bounds_test.go b/pkg/turbine/entry_prefetch_bounds_test.go index 189ff25c3..f7c9aef4a 100644 --- a/pkg/turbine/entry_prefetch_bounds_test.go +++ b/pkg/turbine/entry_prefetch_bounds_test.go @@ -2,6 +2,7 @@ package turbine import ( "context" + "fmt" "testing" "time" @@ -38,6 +39,15 @@ func TestEntryPrefetchByteBoundsFallBackToCompleteVerification(t *testing.T) { f := a.slots[slot].prefetch return f != nil && !f.queued && len(f.batches) == 0 }, 3*time.Second, time.Millisecond) + if mode == "oversized_component" { + a.mu.Lock() + state := a.slots[slot] + reason, released := state.streamCancelReason, state.prefetch.released + a.mu.Unlock() + require.Equal(t, "prefetch_batch_too_large", reason) + require.True(t, released) + require.Equal(t, StreamGone, a.StreamStatusOf(StreamGeneration{slot: slot, state: state})) + } blk := feedPrefetchShreds(t, a, batches[1]) require.NotNil(t, blk) require.Len(t, blk.Transactions, 3) @@ -48,3 +58,78 @@ func TestEntryPrefetchByteBoundsFallBackToCompleteVerification(t *testing.T) { }) } } + +// A closed range needs no additional shred to become eligible after another +// generation releases its reservation. Cancellation must not revive stale work. +func TestEntryPrefetchRetriesByteBudgetOnRelease(t *testing.T) { + for _, cancelWaiting := range []bool{false, true} { + t.Run(fmt.Sprint(cancelWaiting), func(t *testing.T) { + v := newTransactionVerifier(2, 16, nil) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + holderCtx, cancel := context.WithCancel(context.Background()) + holder := &slotState{slot: 399, prefetch: &slotEntryPrefetch{pool: p, ctx: holderCtx, cancel: cancel, bytes: entryPrefetchBytes}} + a.mu.Lock() + a.slots[399] = holder + p.slots = 1 + p.bytes = entryPrefetchBytes + p.active = map[*slotState]struct{}{holder: {}} + a.mu.Unlock() + batches := prefetchTestShreds(t, 400, prefetchTestPayload(t, verifierSignedTransactions(t, 3)), buildAlpenglowEndingTick(t)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + require.Eventually(t, func() bool { + a.mu.Lock() + defer a.mu.Unlock() + f := a.slots[400].prefetch + return f != nil && f.budgetBlocked && !f.queued + }, 3*time.Second, time.Millisecond) + if cancelWaiting { + a.ResetSlot(400) + } + a.ResetSlot(399) + if cancelWaiting { + require.Eventually(t, func() bool { + a.mu.Lock() + defer a.mu.Unlock() + return p.slots == 0 && p.bytes == 0 && len(p.active) == 0 + }, 3*time.Second, time.Millisecond) + } else { + batch := waitPrefetchedBatch(t, a, 400, 0) + _, err := batch.verification.wait() + require.NoError(t, err) + require.Len(t, entryBatchTransactions(batch.entries), 3) + } + }) + } +} + +func TestEntryPrefetchOversizedRangeAfterVerifiedPrefix(t *testing.T) { + v := newTransactionVerifier(2, 16, nil) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + raw := prefetchTestPayload(t, verifierSignedTransactions(t, 3)) + large := prefetchTestPayload(t, verifierSignedTransactions(t, 3)) + large = append(large, make([]byte, entryPrefetchBatchBytes+1-len(large))...) + batches := prefetchTestShreds(t, 401, raw, large, buildAlpenglowEndingTick(t)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + prefix := waitPrefetchedBatch(t, a, 401, 0) + _, err := prefix.verification.wait() + require.NoError(t, err) + require.Nil(t, feedPrefetchShreds(t, a, batches[1])) + require.Eventually(t, func() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.slots[401].prefetch.released + }, 3*time.Second, time.Millisecond) + blk := feedPrefetchShreds(t, a, batches[2]) + require.NotNil(t, blk) + require.Len(t, blk.Transactions, 6) + require.True(t, blk.TransactionSignaturesVerified()) + timings, ok := blk.TurbineIngressTimings() + require.True(t, ok) + require.Zero(t, timings.EarlyVerifiedTransactions, "cancelled prefix cache is not reused") +} diff --git a/pkg/turbine/entry_prefetch_test.go b/pkg/turbine/entry_prefetch_test.go index 679ee18b5..9dbbd9ada 100644 --- a/pkg/turbine/entry_prefetch_test.go +++ b/pkg/turbine/entry_prefetch_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "runtime" + "strings" "sync" "sync/atomic" "testing" @@ -273,6 +275,14 @@ func TestEntryPrefetchInvalidRetainedTransactionFailsClosed(t *testing.T) { } require.ErrorContains(t, finalErr, "transaction 1") require.False(t, a.SlotCompleted(300)) + p.cleanup.Wait() + a.mu.Lock() + g := StreamGeneration{slot: 300, state: a.slots[300]} + slots, bytes := p.slots, p.bytes + a.mu.Unlock() + require.Equal(t, StreamGone, a.StreamStatusOf(g)) + require.Zero(t, slots) + require.Zero(t, bytes) } func TestEntryPrefetchUpdateParentDiscardsInvalidOptimisticPrefix(t *testing.T) { @@ -336,7 +346,15 @@ func TestEntryPrefetchCanceledCompletionCanRetrySameGeneration(t *testing.T) { go func() { done <- a.processCompletion(ctx, work) }() // The completion has no expensive decode left and blocks joining this // one already-prepared future; cancel while it owns those transactions. - time.Sleep(20 * time.Millisecond) + require.Eventually(t, func() bool { + stack := make([]byte, 2<<20) + for _, goroutine := range strings.Split(string(stack[:runtime.Stack(stack, true)]), "\n\n") { + if strings.Contains(goroutine, "(*SlotAssembler).processCompletion") && strings.Contains(goroutine, "(*transactionVerification).waitContext") { + return true + } + } + return false + }, 3*time.Second, time.Millisecond, "completion must enter the owning verification join") cancel() waitSignal(t, canceled, "completion canceled its signature request") releaseOnce.Do(func() { close(release) }) @@ -526,3 +544,148 @@ func TestEntryPrefetchResetRetainsQueuedReservations(t *testing.T) { a.mu.Unlock() require.Zero(t, slots) } + +// Failed full blocks remain available for diagnostics, but must not consume +// the prefetch budget or remain usable streaming generations until retention. +func TestEntryPrefetchFailedCompletionReleasesCapacity(t *testing.T) { + for _, dropEvents := range []bool{false, true} { + t.Run(fmt.Sprintf("drop_events=%v", dropEvents), func(t *testing.T) { + v := newTransactionVerifier(2, 16, nil) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + capacity := 16 + if dropEvents { + capacity = 0 + } + events := make(chan StreamEvent, capacity) + a.SubscribeStream(events) + payload := prefetchTestPayload(t, verifierSignedTransactions(t, 1)) + for i := 0; i <= entryPrefetchSlots; i++ { + slot := uint64(900 + i) + // Zero entry count plus trailing bytes is an invalid component. + batches := prefetchTestShreds(t, slot, payload, make([]byte, 16)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + batch := waitPrefetchedBatch(t, a, slot, 0) + _, err := batch.verification.wait() + require.NoError(t, err) + a.mu.Lock() + s := a.slots[slot] + g := StreamGeneration{slot: slot, state: s} + a.mu.Unlock() + var failure error + for _, sh := range batches[1] { + blk, err := a.AddShred(sh) + require.Nil(t, blk) + if err != nil { + failure = err + } + } + require.Error(t, failure) + count, last := a.SlotAssemblyErrors(slot) + require.Positive(t, count) + require.Equal(t, failure.Error(), last) + require.False(t, a.SlotCompleted(slot)) + require.Equal(t, StreamGone, a.StreamStatusOf(g)) + require.Empty(t, a.PendingStreamBatches(g, 0)) + if !dropEvents { + event := nextStreamEvent(t, events, StreamCancelled) + require.Equal(t, g, event.Generation) + require.Equal(t, "completion_failed", event.Reason) + } + p.cleanup.Wait() + a.mu.Lock() + retained, slots, bytes := a.slots[slot], p.slots, p.bytes + // Repeated release/admission cannot double-refund or resurrect it. + a.releasePrefetchLocked(s) + a.prefetchEntriesLocked(s) + afterSlots := p.slots + a.mu.Unlock() + require.Same(t, s, retained, "preserve poisoned-slot diagnostics") + require.Zero(t, slots) + require.Zero(t, bytes) + require.Zero(t, afterSlots) + } + good := prefetchTestShreds(t, 920, payload, buildAlpenglowEndingTick(t)) + require.Nil(t, feedPrefetchShreds(t, a, good[0])) + waitPrefetchedBatch(t, a, 920, 0) + blk := feedPrefetchShreds(t, a, good[1]) + require.NotNil(t, blk) + require.True(t, blk.TransactionSignaturesVerified()) + }) + } +} + +func TestEntryPrefetchFailedCompletionJoinsReaders(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + v := newTransactionVerifier(1, 8, func(*solana.Transaction) error { + close(started) + <-release + return nil + }) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + defer once.Do(func() { close(release) }) + batches := prefetchTestShreds(t, 930, prefetchTestPayload(t, verifierSignedTransactions(t, 1)), make([]byte, 16)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + waitSignal(t, started, "prefetch verifier") + waitPrefetchedBatch(t, a, 930, 0) + var failure error + for _, sh := range batches[1] { + blk, err := a.AddShred(sh) + require.Nil(t, blk) + if err != nil { + failure = err + } + } + require.Error(t, failure) + a.mu.Lock() + s := a.slots[930] + released, ctxErr, slots, bytes := s.prefetch.released, s.prefetch.ctx.Err(), p.slots, p.bytes + a.mu.Unlock() + require.True(t, released) + require.ErrorIs(t, ctxErr, context.Canceled) + require.Equal(t, 1, slots, "reader still owns the reservation") + require.Positive(t, bytes) + require.Equal(t, StreamGone, a.StreamStatusOf(StreamGeneration{slot: 930, state: s})) + once.Do(func() { close(release) }) + p.cleanup.Wait() + a.mu.Lock() + slots, bytes = p.slots, p.bytes + a.mu.Unlock() + require.Zero(t, slots) + require.Zero(t, bytes) +} + +// A failed generation that never received a reservation must not acquire one +// later when capacity becomes available (or prefetch is attached). +func TestEntryPrefetchFailedCompletionWithoutReservation(t *testing.T) { + a := NewSlotAssembler() + batches := prefetchTestShreds(t, 940, prefetchTestPayload(t, verifierSignedTransactions(t, 1)), make([]byte, 16)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + var failure error + for _, sh := range batches[1] { + blk, err := a.AddShred(sh) + require.Nil(t, blk) + if err != nil { + failure = err + } + } + require.Error(t, failure) + v := newTransactionVerifier(1, 8, nil) + defer v.closeAndWait() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + a.mu.Lock() + s := a.slots[940] + a.prefetchEntriesLocked(s) + reserved, slots := s.prefetch, p.slots + a.mu.Unlock() + require.Nil(t, reserved) + require.Zero(t, slots) + require.Equal(t, StreamGone, a.StreamStatusOf(StreamGeneration{slot: 940, state: s})) +} diff --git a/pkg/turbine/receiver.go b/pkg/turbine/receiver.go index f5588bf6a..2e197980a 100644 --- a/pkg/turbine/receiver.go +++ b/pkg/turbine/receiver.go @@ -208,6 +208,9 @@ func (r *UDPReceiver) SetRepairPeerSource(identity ed25519.PrivateKey, source fu return err } r.repairClient = client + r.assembler.mu.Lock() + r.assembler.streamRepairWake = client.priorityWake + r.assembler.mu.Unlock() return nil } @@ -379,14 +382,40 @@ func (r *UDPReceiver) PrioritizeRepairSlot(slot uint64) { if r == nil || r.assembler == nil { return } - r.assembler.PrioritizeRepairSlot(slot) + r.PrioritizeRepairRange(slot, slot) } func (r *UDPReceiver) PrioritizeRepairRange(start, end uint64) { if r == nil || r.assembler == nil { return } - r.assembler.PrioritizeRepairRange(start, end) + if r.assembler.prioritizeRepairRange(start, end) && r.repairClient != nil { + r.repairClient.wakePriority() + } +} + +// SubscribeStream installs the streaming-execution feed subscriber on this +// receiver's assembler (see stream.go). Only one subscriber is supported. +func (r *UDPReceiver) SubscribeStream(ch chan<- StreamEvent) { + r.assembler.SubscribeStream(ch) +} + +// StreamStatusOf reports whether a streaming generation is still the slot's +// current assembly, completed into a block, or gone. +func (r *UDPReceiver) StreamStatusOf(g StreamGeneration) StreamStatus { + return r.assembler.StreamStatusOf(g) +} + +// StreamDroppedEvents reports feed wake-ups dropped because the subscriber +// was full; the subscriber recovers through PendingStreamBatches. +func (r *UDPReceiver) StreamDroppedEvents() uint64 { + return r.assembler.StreamDroppedEvents() +} + +// PendingStreamBatches returns the generation's decoded batches starting at +// or after fromStart, in shred-index order. +func (r *UDPReceiver) PendingStreamBatches(g StreamGeneration, fromStart uint32) []*StreamBatch { + return r.assembler.PendingStreamBatches(g, fromStart) } func (r *UDPReceiver) Blocks() <-chan *block.Block { @@ -726,9 +755,9 @@ func (r *UDPReceiver) processPacket(ctx context.Context, conn *net.UDPConn, pack } authenticatedRoot = &root } - matchedRepair := false + matchedRepair, highestRepair := false, false if onRepairSocket && r.repairClient != nil { - matchedRepair = r.repairClient.observeShredResponse(conn, packet, addr, shred) + matchedRepair, highestRepair = r.repairClient.matchShredResponse(packet, addr, shred) } if onRepairSocket && !matchedRepair { r.repairSocketUnmatched.Add(1) @@ -789,6 +818,9 @@ func (r *UDPReceiver) processPacket(ctx context.Context, conn *net.UDPConn, pack } return true } + if highestRepair { + r.repairClient.followupHighestResponse(conn, r.assembler, shred.Slot) + } return r.submitCompletion(ctx, work, false) } @@ -815,16 +847,18 @@ func (r *UDPReceiver) submitCompletion(ctx context.Context, work *slotCompletion } r.slotResetMu.RUnlock() return r.handleCompletionResult(ctx, slotCompletionResult{ - block: blk, - err: err, - hydrated: hydrated, - pending: pending, + generation: StreamGeneration{slot: work.state.slot, state: work.state}, + block: blk, + err: err, + hydrated: hydrated, + pending: pending, }) } func (r *UDPReceiver) consumeCompletionResults(ctx context.Context, results <-chan slotCompletionResult) { for result := range results { if ctx.Err() != nil { + r.assembler.cancelUndeliveredStream(result.generation) if result.pending && result.block != nil { r.finishPendingBlock(result.block.Slot) } @@ -852,10 +886,16 @@ func (r *UDPReceiver) handleCompletionResult(ctx context.Context, result slotCom if result.hydrated { r.hydratedFromDisk.Add(1) } + var emitted bool if result.pending { - return r.emitPendingAssembled(ctx, result.block) + emitted = r.emitPendingAssembled(ctx, result.block) + } else { + emitted = r.emitAssembled(ctx, result.block) } - return r.emitAssembled(ctx, result.block) + if !emitted { + r.assembler.cancelUndeliveredStream(result.generation) + } + return emitted } // skipAssemblyForSpool implements the catchup RAM policy: with a hydration @@ -960,3 +1000,15 @@ func (r *UDPReceiver) hydrateLoop(ctx context.Context) { } } } + +// PrioritizeStreamRepair also anchors bounded asynchronous child lookahead. +func (r *UDPReceiver) PrioritizeStreamRepair(g StreamGeneration) { + if r == nil || r.assembler == nil { + return + } + r.assembler.SetStreamRepairParent(g) + slot := g.Slot() + if slot != 0 { + r.PrioritizeRepairSlot(slot) + } +} diff --git a/pkg/turbine/repair.go b/pkg/turbine/repair.go index f9be294af..7e19baa1d 100644 --- a/pkg/turbine/repair.go +++ b/pkg/turbine/repair.go @@ -304,8 +304,9 @@ type RepairPeerReport struct { } type repairClient struct { - identity ed25519.PrivateKey - peerSource RepairPeerSource + priorityWake chan struct{} // initialized before the receiver starts; coalesced hints + identity ed25519.PrivateKey + peerSource RepairPeerSource mu sync.Mutex outstanding map[repairRequestKey]outstandingRepairRequest @@ -375,28 +376,77 @@ func newRepairClient(identity ed25519.PrivateKey, peerSource RepairPeerSource) ( return nil, fmt.Errorf("repair peer source is required") } c := &repairClient{ - identity: append(ed25519.PrivateKey(nil), identity...), - peerSource: peerSource, - outstanding: make(map[repairRequestKey]outstandingRepairRequest), - byResponse: make(map[repairResponseKey]repairRequestKey), - inflight: make(map[shredKey]*shredInflight), - perPeer: make(map[repairAddressKey]*peerRecord), - expiredCur: make(map[repairResponseKey]outstandingRepairRequest, repairExpiredGenMin), + priorityWake: make(chan struct{}, 1), + identity: append(ed25519.PrivateKey(nil), identity...), + peerSource: peerSource, + outstanding: make(map[repairRequestKey]outstandingRepairRequest), + byResponse: make(map[repairResponseKey]repairRequestKey), + inflight: make(map[shredKey]*shredInflight), + perPeer: make(map[repairAddressKey]*peerRecord), + expiredCur: make(map[repairResponseKey]outstandingRepairRequest, repairExpiredGenMin), } c.timeoutNanos.Store(int64(repairMinRequestTimeout)) return c, nil } +// wakePriority does not send requests or mint rate tokens. It only asks the +// single repair loop to reconsider newly prioritized work sooner. +func (c *repairClient) wakePriority() { + select { + case c.priorityWake <- struct{}{}: + default: + } +} + func (c *repairClient) run(ctx context.Context, conn *net.UDPConn, assembler *SlotAssembler) { - ticker := time.NewTicker(repairScanInterval) + runRepairSchedule(ctx, c.priorityWake, repairScanInterval, 20*time.Millisecond, func() { + c.expireOutstanding(time.Now()) + c.repairOnce(conn, assembler) + }) +} + +// Keep periodic scans for retries/freshness. Coalesce urgent hints and bound +// scan frequency; all sends still use the existing token bucket, admission, +// retry, fanout and peer budgets. The loop remains the sole sender. +func runRepairSchedule(ctx context.Context, wake <-chan struct{}, interval, minSpacing time.Duration, scan func()) { + ticker := time.NewTicker(interval) defer ticker.Stop() + var timer *time.Timer + var urgent <-chan time.Time + var last time.Time + stopTimer := func() { + if timer != nil { + timer.Stop() + } + urgent = nil + } + defer stopTimer() + run := func() { + stopTimer() + if ctx.Err() != nil { + return + } + scan() + last = time.Now() + } + schedule := func() { + if delay := minSpacing - time.Since(last); delay <= 0 { + run() + } else if urgent == nil { + timer = time.NewTimer(delay) + urgent = timer.C + } + } for { select { case <-ctx.Done(): return case <-ticker.C: - c.expireOutstanding(time.Now()) - c.repairOnce(conn, assembler) + schedule() + case <-urgent: + run() + case <-wake: + schedule() } } } @@ -407,7 +457,8 @@ func (c *repairClient) repairOnce(conn *net.UDPConn, assembler *SlotAssembler) { return } priority, edge := assembler.RepairRequestsTiered(repairMaxSlotsPerScan, repairMaxMissingPerSlot) - if len(priority)+len(edge) == 0 { + child, haveChild := assembler.childRepairRequest(time.Now()) + if len(priority)+len(edge) == 0 && !haveChild { return } @@ -433,6 +484,9 @@ func (c *repairClient) repairOnce(conn *net.UDPConn, assembler *SlotAssembler) { } edgeDemand := tierSendDemand(edge, 1) want := tierSendDemand(priority, headInitial) + edgeDemand + if haveChild { + want += len(child.MissingDataShreds) + } if want > repairMaxOutstanding { want = repairMaxOutstanding } @@ -456,6 +510,10 @@ func (c *repairClient) repairOnce(conn *net.UDPConn, assembler *SlotAssembler) { // below what the edge can use; leftover head budget flows to the edge. spent := c.sendTier(conn, peers, priority, splitRepairBudget(budget, edgeDemand), headPol, acct) spent += c.sendTier(conn, peers, edge, budget-spent, nil, acct) + // Parent/normal repair and freshness keep first claim on every token. + if haveChild { + spent += c.sendChildRepair(conn, peers, child, budget-spent, acct) + } c.returnRateTokens(budget - spent) } @@ -563,21 +621,23 @@ func shredSatisfiesRequest(key repairRequestKey, shred *Shred) bool { } } -// observeShredResponse matches an incoming packet against outstanding repair +// matchShredResponse matches an incoming packet against outstanding repair // requests (responder address + nonce). Returns true when the shred was // delivered BY REPAIR — it answers one of our requests — so the caller can -// attribute it in per-slot repair accounting. -func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, from *net.UDPAddr, shred *Shred) bool { +// attribute it in per-slot repair accounting. highest is a discovery hint for +// followup selection only AFTER the receiver admits the shred; matching and +// peer credit alone do not authorize requests for the claimed range. +func (c *repairClient) matchShredResponse(packet []byte, from *net.UDPAddr, shred *Shred) (matched, highest bool) { if from == nil || shred == nil { - return false + return false, false } nonce, ok := repairproto.ResponseNonce(packet) if !ok { - return false + return false, false } addrKey, ok := repairAddressKeyFromUDP(from) if !ok { - return false + return false, false } responseKey := repairResponseKey{addr: addrKey, nonce: nonce} @@ -611,7 +671,7 @@ func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, fr // so it still expires into a deserved timeout and keeps its in-flight // slot for retry. The peer gets nothing. c.mu.Unlock() - return false + return false, false } if late { // The expired record lives in exactly one generation; deleting from @@ -641,68 +701,15 @@ func (c *repairClient) observeShredResponse(conn *net.UDPConn, packet []byte, fr c.observeLatencyLocked(latency) c.mu.Unlock() + if entryTraceSelected(shred.Slot) { + traceRepairResponse(outstanding, shred, from.String(), late) + } if late { c.lateResponses.Add(1) } else { c.responses.Add(1) } - // shredSatisfiesRequest already guaranteed slot match and a data shred; the - // gap-backfill path below is HWI-only. - if outstanding.key.kind != repairRequestHighestWindowIndex { - return true - } - - peers := c.peerSnapshot(time.Now()) - if len(peers) == 0 { - return true - } - start := outstanding.key.index - gap := 0 - if shred.Index > start { - gap = int(shred.Index - start) - } - ask := gap - if ask > repairMaxFollowupRequests { - ask = repairMaxFollowupRequests - } - chainProbe := !shred.LastInSlot() && shred.Index < maxDataShredsPerSlot-1 - if chainProbe { - ask++ - } - if ask == 0 { - return true - } - // Followups draw from the SAME token bucket as the scan. This path used - // to be unmetered — with hundreds of probed slots it pushed the total - // send rate ~70% past the cap, which is exactly the flood the peer-side - // QoS ban punishes. When the bucket is dry the scan's deficit-aware - // selection covers the slot on its own cadence. - grant := c.takeRateTokens(ask) - if grant <= 0 { - return true - } - windowBudget := grant - if chainProbe && windowBudget > 0 { - windowBudget-- // reserve the chained probe's token - } - // Discovery followups are bulk-paced and go through the same inflight - // dedup as the scan, so a window index already being repaired is not - // re-sent here. - bulk := bulkPolicy() - acct := c.accountingTimeout() - followups := 0 - for index := start; index < shred.Index && followups < windowBudget; index++ { - if c.sendShredAttempt(conn, peers, repairRequestWindowIndex, shred.Slot, index, bulk, acct) { - followups++ - } - } - if chainProbe && followups < grant { - if c.sendShredAttempt(conn, peers, repairRequestHighestWindowIndex, shred.Slot, shred.Index+1, bulk, acct) { - followups++ - } - } - c.returnRateTokens(grant - followups) - return true + return true, outstanding.key.kind == repairRequestHighestWindowIndex } // observeLatencyLocked folds one request->response latency into the EWMA and @@ -760,7 +767,7 @@ func bulkPolicy() retryPolicy { // satisfyDataShred retires WindowIndex requests satisfied by a verified data // shred arriving through any path: a matched repair response, Turbine // broadcast, FEC/spool hydration, or a duplicate response. Request nonce -// matching still happens first in observeShredResponse so the answering peer +// matching still happens first in matchShredResponse so the answering peer // receives its proper timely/late credit. func (c *repairClient) satisfyDataShred(shred *Shred) { if c == nil || shred == nil || shred.Type != ShredTypeData { @@ -889,7 +896,7 @@ func (c *repairClient) sendShredAttempt(conn *net.UDPConn, peers []gossip.Repair // count), then release BEFORE signing. Ed25519 signing is ~tens of // microseconds; at tens of thousands of req/s, holding the lock across it // serialized every send against the response-processing path - // (observeShredResponse needs the same lock) and could stall the UDP + // (matchShredResponse needs the same lock) and could stall the UDP // receive loop into kernel drops. The reserve is enough for coherence: a // response for this attempt cannot arrive until after we WriteToUDP below, // which is strictly after we register outstanding/byResponse. @@ -933,7 +940,14 @@ func (c *repairClient) sendShredAttempt(conn *net.UDPConn, peers []gossip.Repair c.byResponse[responseKey] = key c.mu.Unlock() + var traceStart int64 + var traceBinding entryRepairTrace + if entryTraceSelected(slot) { + traceStart = entryTraceNow() + traceBinding = entryRepairTrace{Peer: peer.Addr.String(), Nonce: nonce} + } if _, err := conn.WriteToUDP(packet, peer.Addr); err != nil { + traceRepairSend(slot, index, kind, attempt, traceStart, false, traceBinding) c.mu.Lock() delete(c.outstanding, key) delete(c.byResponse, responseKey) @@ -944,6 +958,7 @@ func (c *repairClient) sendShredAttempt(conn *net.UDPConn, peers []gossip.Repair return false } + traceRepairSend(slot, index, kind, attempt, traceStart, true, traceBinding) c.requests.Add(1) return true } @@ -1506,3 +1521,19 @@ func (c *repairClient) stats() RepairStats { AvgResponseMillis: avgResponseMillis, } } + +// Called after normal priority and freshness work. Existing in-flight child +// requests count against lookahead capacity; no fanout or highest-index probes. +func (c *repairClient) sendChildRepair(conn *net.UDPConn, peers []gossip.RepairPeer, req SlotRepairRequest, budget int, acct time.Duration) int { + if budget <= 0 { + return 0 + } + c.mu.Lock() + room := childRepairLimit - c.outstandingForSlotLocked(req.Slot) + c.mu.Unlock() + if room <= 0 { + return 0 + } + req.NeedHighestDataShred = false + return c.sendTier(conn, peers, []SlotRepairRequest{req}, min(room, budget), nil, acct) +} diff --git a/pkg/turbine/repair_followup.go b/pkg/turbine/repair_followup.go new file mode 100644 index 000000000..659ad728d --- /dev/null +++ b/pkg/turbine/repair_followup.go @@ -0,0 +1,73 @@ +package turbine + +import ( + "net" + "time" +) + +// highestRepairFollowup snapshots current deficits AFTER response admission and +// FEC recovery. It never treats a highest-index response as proof that earlier +// pieces are missing. The returned selection can race with later arrivals, but +// no assembler lock is held during signing, network sends, or repair locking. +func (a *SlotAssembler) highestRepairFollowup(slot uint64) (SlotRepairRequest, bool) { + a.mu.Lock() + defer a.mu.Unlock() + if a.slotTooOldLocked(slot) { + return SlotRepairRequest{}, false + } + if _, done := a.completedSlots[slot]; done { + return SlotRepairRequest{}, false + } + s := a.slots[slot] + if s == nil || s.completing { + return SlotRepairRequest{}, false + } + return s.repairRequest(repairMaxFollowupRequests) +} + +// Only invoked for a matched highest-index response which passed admission. +// Disk-only catchup slots defer selection until hydration supplies assembler +// state; blindly backfilling those would ignore data already held in the spool. +func (c *repairClient) followupHighestResponse(conn *net.UDPConn, a *SlotAssembler, slot uint64) { + req, ok := a.highestRepairFollowup(slot) + if !ok { + return + } + peers := c.peerSnapshot(time.Now()) + if len(peers) == 0 { + return + } + // A matched discovery response can request at most 256 missing pieces plus + // one highest-index probe. This response-driven burst shares the global + // token bucket (not the periodic head-share quota); inflight dedup suppresses + // repeat sends and unused tokens are returned below. + ask := len(req.MissingDataShreds) + if req.NeedHighestDataShred { + ask++ + } + grant := c.takeRateTokens(ask) + if grant <= 0 { + return + } + // Reserve discovery capacity as before, even when missing data fills the cap. + window := grant + if req.NeedHighestDataShred { + window-- + } + pol, acct := bulkPolicy(), c.accountingTimeout() + sent := 0 + for _, index := range req.MissingDataShreds { + if sent >= window { + break + } + if c.sendShredAttempt(conn, peers, repairRequestWindowIndex, slot, index, pol, acct) { + sent++ + } + } + if req.NeedHighestDataShred && sent < grant { + if c.sendShredAttempt(conn, peers, repairRequestHighestWindowIndex, slot, req.HighestDataShredIndex, pol, acct) { + sent++ + } + } + c.returnRateTokens(grant - sent) +} diff --git a/pkg/turbine/repair_followup_test.go b/pkg/turbine/repair_followup_test.go new file mode 100644 index 000000000..a8bb6a21f --- /dev/null +++ b/pkg/turbine/repair_followup_test.go @@ -0,0 +1,222 @@ +package turbine + +import ( + "context" + "fmt" + "github.com/Overclock-Validator/mithril/fixtures" + "github.com/Overclock-Validator/mithril/pkg/gossip" + "github.com/stretchr/testify/require" + "net" + "sync" + "testing" + "time" +) + +// Existing protocol/pacing fixtures model cold admission without packet parsing. +// Production invokes followups only after the full receiver admission path. +func observeRepairForTest(c *repairClient, conn *net.UDPConn, packet []byte, from *net.UDPAddr, sh *Shred) bool { + matched, highest := c.matchShredResponse(packet, from, sh) + if highest { + a := NewSlotAssembler() + s := newRepairSelectionSlot(sh.Slot) + s.shreds[sh.Index] = sh + s.haveLast, s.lastIndex = sh.LastInSlot(), sh.Index + a.slots[sh.Slot] = s + c.followupHighestResponse(conn, a, sh.Slot) + } + return matched +} + +func TestHighestRepairFollowupSelection(t *testing.T) { + a := NewSlotAssembler() + s := newRepairSelectionSlot(50) + a.slots[50] = s + // A recovered first span must not be requested again; the next span + // lacks twelve data but has eight coding, so only four repairs are needed. + addCodedSet(s, 0, 32, 32, seq(0, 31), 0) + addCodedSet(s, 32, 32, 32, seq(32, 51), 8) + s.haveLast, s.lastIndex = true, 63 + r, ok := a.highestRepairFollowup(50) + require.True(t, ok) + require.Equal(t, []uint32{52, 53, 54, 55}, r.MissingDataShreds) + require.False(t, r.NeedHighestDataShred) + for i := uint32(52); i <= 63; i++ { + s.shreds[i] = &Shred{Index: i} + } + _, ok = a.highestRepairFollowup(50) + require.False(t, ok) + delete(s.shreds, 55) + s.completing = true + _, ok = a.highestRepairFollowup(50) + require.False(t, ok) + s.completing = false + a.completedSlots[50] = struct{}{} + _, ok = a.highestRepairFollowup(50) + require.False(t, ok) + delete(a.completedSlots, 50) + a.ResetSlot(50) + _, ok = a.highestRepairFollowup(50) + require.False(t, ok) +} + +func TestHighestRepairFollowupColdAndDiscovery(t *testing.T) { + a := NewSlotAssembler() + s := newRepairSelectionSlot(50) + a.slots[50] = s + s.shreds[600] = &Shred{Index: 600} + r, ok := a.highestRepairFollowup(50) + require.True(t, ok) + require.Len(t, r.MissingDataShreds, 256) + require.Equal(t, uint32(0), r.MissingDataShreds[0]) + require.True(t, r.NeedHighestDataShred) + require.Equal(t, uint32(601), r.HighestDataShredIndex) + // Possession holes only, even without a coding layout. + for i := uint32(0); i < 600; i++ { + s.shreds[i] = &Shred{Index: i} + } + delete(s.shreds, 299) + r, ok = a.highestRepairFollowup(50) + require.True(t, ok) + require.Equal(t, []uint32{299}, r.MissingDataShreds) +} + +func TestHighestRepairFollowupSendsOnlyDeficit(t *testing.T) { + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + defer conn.Close() + sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + defer sink.Close() + c := newPacingTestClient(t) + c.peerCache = []gossip.RepairPeer{{Addr: sink.LocalAddr().(*net.UDPAddr)}} + c.peerCacheAt = time.Now() + a := NewSlotAssembler() + s := newRepairSelectionSlot(50) + a.slots[50] = s + addCodedSet(s, 0, 32, 32, seq(0, 19), 8) + s.haveLast = true + s.lastIndex = 31 + c.followupHighestResponse(conn, a, 50) + require.Equal(t, uint64(4), c.requests.Load()) + c.followupHighestResponse(conn, a, 50) + require.Equal(t, uint64(4), c.requests.Load()) // same inflight dedupe + // Race a reset with read-only selection; no old slot is recreated. + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + a.highestRepairFollowup(50) + } + }() + a.ResetSlot(50) + wg.Wait() + c.followupHighestResponse(conn, a, 50) + require.Equal(t, uint64(4), c.requests.Load()) +} + +func TestReceiverHighestFollowupUsesAdmittedState(t *testing.T) { + packets := fixtures.DataShreds(t, "mainnet", 102815960) + require.Greater(t, len(packets), 12) + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + defer conn.Close() + sink, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + require.NoError(t, err) + defer sink.Close() + r := NewUDPReceiver("127.0.0.1:0") + c := newPacingTestClient(t) + r.repairClient = c + c.peerCache = []gossip.RepairPeer{{Addr: sink.LocalAddr().(*net.UDPAddr)}} + c.peerCacheAt = time.Now() + for _, p := range packets[:11] { + require.True(t, r.processPacket(context.Background(), nil, p, nil, false)) + } + from := &net.UDPAddr{IP: net.IPv4(10, 0, 0, 9), Port: 8009} + addr, _ := repairAddressKeyFromUDP(from) + key := repairRequestKey{kind: repairRequestHighestWindowIndex, slot: 102815960, index: 0} + c.outstanding[key] = outstandingRepairRequest{key: key, nonce: 42, addr: addr, sentAt: time.Now()} + c.byResponse[repairResponseKey{addr: addr, nonce: 42}] = key + packet := append(append([]byte(nil), packets[11]...), nonceTrailer(42)...) + require.True(t, r.processPacket(context.Background(), conn, packet, from, true)) + require.Equal(t, uint64(1), c.requests.Load(), "only continued discovery, no already held indices") + for key := range c.outstanding { + require.Equal(t, repairRequestHighestWindowIndex, key.kind) + require.Equal(t, uint32(12), key.index) + } +} + +// Disk-only discovery must remain repairable after hydration enters the slot. +func TestHighestRepairFollowupAfterDiskOnlyHydration(t *testing.T) { + const slot = uint64(102815960) + packets := fixtures.DataShreds(t, "mainnet", slot) + r := NewUDPReceiver("127.0.0.1:0") + spool, err := OpenShredSpool(t.TempDir(), 0) + require.NoError(t, err) + defer spool.Close() + r.SetShredSpool(spool) + r.assembler.maxObservedSlot = slot + 1000 + r.SetHydrationWindow(slot-8, slot-1) + require.True(t, r.skipAssemblyForSpool(slot)) + // Seed only a partial range on disk; an authentic highest response extends it. + for _, p := range packets[:10] { + require.True(t, r.processPacket(context.Background(), nil, p, nil, false)) + } + c := newPacingTestClient(t) + r.repairClient = c + from := &net.UDPAddr{IP: net.IPv4(10, 0, 0, 9), Port: 8009} + addr, _ := repairAddressKeyFromUDP(from) + key := repairRequestKey{kind: repairRequestHighestWindowIndex, slot: slot, index: 0} + c.outstanding[key] = outstandingRepairRequest{key: key, nonce: 43, addr: addr, sentAt: time.Now()} + c.byResponse[repairResponseKey{addr: addr, nonce: 43}] = key + packet := append(append([]byte(nil), packets[11]...), nonceTrailer(43)...) + require.True(t, r.processPacket(context.Background(), nil, packet, from, true)) + require.Equal(t, uint64(0), c.requests.Load()) + _, live := r.assembler.HeadShredDetail(slot) + require.False(t, live) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { defer close(done); r.hydrateLoop(ctx) }() + defer func() { cancel(); <-done }() + r.SetRetentionFloor(slot) // replay protects the hydration window during catchup + r.assembler.PrioritizeRepairSlot(slot) + r.SetHydrationWindow(slot, slot) + require.Eventually(t, func() bool { return r.hydratedSlots.Load() > 0 }, time.Second, time.Millisecond) + req, ok := r.assembler.highestRepairFollowup(slot) + require.True(t, ok) + require.Equal(t, []uint32{10}, req.MissingDataShreds) + require.True(t, req.NeedHighestDataShred) + require.Equal(t, uint32(12), req.HighestDataShredIndex) + // Replay priority makes the hole eligible for the ordinary scheduler. + r.assembler.PrioritizeRepairSlot(slot) + priority, _ := r.assembler.RepairRequestsTiered(64, 2048) + require.NotEmpty(t, priority) + require.Equal(t, slot, priority[0].Slot) + require.Equal(t, []uint32{10}, priority[0].MissingDataShreds) +} + +func BenchmarkHighestRepairFollowup(b *testing.B) { + for _, n := range []int{16384, 65536} { + for _, fragmented := range []bool{false, true} { + b.Run(fmt.Sprintf("shreds%d/fragmented%t", n, fragmented), func(b *testing.B) { + a := NewSlotAssembler() + s := newRepairSelectionSlot(50) + a.slots[50] = s + for start := 0; start < n; start += 32 { + last := start + 31 + coding := 0 + if fragmented { + last = start + 19 + coding = 8 + } + addCodedSet(s, uint32(start), 32, 32, seq(uint32(start), uint32(last)), coding) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.highestRepairFollowup(50) + } + }) + } + } +} diff --git a/pkg/turbine/repair_pacing_test.go b/pkg/turbine/repair_pacing_test.go index 8391915a5..ad747cb1a 100644 --- a/pkg/turbine/repair_pacing_test.go +++ b/pkg/turbine/repair_pacing_test.go @@ -90,7 +90,7 @@ func TestLateResponseMatchedAfterExpiry(t *testing.T) { packet := nonceTrailer(777) shred := &Shred{Slot: 42, Index: 3, Type: ShredTypeData} - if !c.observeShredResponse(nil, packet, from, shred) { + if !observeRepairForTest(c, nil, packet, from, shred) { t.Fatal("late answer must be attributed as a repair delivery") } if c.lateResponses.Load() != 1 { @@ -104,7 +104,7 @@ func TestLateResponseMatchedAfterExpiry(t *testing.T) { } // Second delivery of the same nonce: entry consumed, ordinary broadcast. - if c.observeShredResponse(nil, packet, from, shred) { + if observeRepairForTest(c, nil, packet, from, shred) { t.Fatal("expired entry must be single-use") } } @@ -122,7 +122,7 @@ func TestLateResponseWrongSlotRejected(t *testing.T) { c.byResponse[repairResponseKey{addr: addrKey, nonce: 900}] = reqKey c.expireOutstanding(time.Now()) - if c.observeShredResponse(nil, nonceTrailer(900), from, &Shred{Slot: 43, Index: 3, Type: ShredTypeData}) { + if observeRepairForTest(c, nil, nonceTrailer(900), from, &Shred{Slot: 43, Index: 3, Type: ShredTypeData}) { t.Fatal("wrong-slot late answer must not be attributed as repair") } if c.lateResponses.Load() != 0 { @@ -165,7 +165,7 @@ func TestNonConformingResponseRejected(t *testing.T) { c.addInflightLocked(tc.key.shred(), time.Now()) c.mu.Unlock() - if c.observeShredResponse(nil, nonceTrailer(111), from, tc.shred) { + if observeRepairForTest(c, nil, nonceTrailer(111), from, tc.shred) { t.Fatal("non-conforming answer must not be attributed as a repair delivery") } if c.responses.Load() != 0 || c.lateResponses.Load() != 0 { @@ -211,7 +211,7 @@ func TestLateHighestResponseFiresFollowups(t *testing.T) { c.byResponse[repairResponseKey{addr: addrKey, nonce: 6}] = reqKey c.expireOutstanding(time.Now()) - if !c.observeShredResponse(conn, nonceTrailer(6), from, &Shred{Slot: 50, Index: 200, Type: ShredTypeData}) { + if !observeRepairForTest(c, conn, nonceTrailer(6), from, &Shred{Slot: 50, Index: 200, Type: ShredTypeData}) { t.Fatal("late HWI answer must match") } if c.lateResponses.Load() != 1 || c.responses.Load() != 0 { @@ -448,7 +448,7 @@ func TestRepairAnswerCancelsSiblingAttempts(t *testing.T) { // The ORIGINAL peer answers timely; the sibling is neutral-cancelled. from := &net.UDPAddr{IP: sinkAddr.IP, Port: sinkAddr.Port} - if !c.observeShredResponse(conn, nonceTrailer(o0.nonce), from, &Shred{Slot: 60, Index: 3, Type: ShredTypeData}) { + if !observeRepairForTest(c, conn, nonceTrailer(o0.nonce), from, &Shred{Slot: 60, Index: 3, Type: ShredTypeData}) { t.Fatal("original attempt's answer must match") } c.mu.Lock() @@ -568,7 +568,7 @@ func TestFollowupsAreMeteredByTokenBucket(t *testing.T) { // by the primed outstanding entry, leaving a stray token. c.takeRateTokens(repairMaxRequestsPerSecond) c.takeRateTokens(repairMaxRequestsPerSecond) - if !c.observeShredResponse(conn, packet, from, shred) { + if !observeRepairForTest(c, conn, packet, from, shred) { t.Fatal("response itself must match") } if got := c.requests.Load(); got != 0 { @@ -580,7 +580,7 @@ func TestFollowupsAreMeteredByTokenBucket(t *testing.T) { c.rateRefillAt = time.Now() c.rateTokens = repairMaxRequestsPerSecond c.mu.Unlock() - if !c.observeShredResponse(conn, packet, from, shred) { + if !observeRepairForTest(c, conn, packet, from, shred) { t.Fatal("response itself must match") } // A full bucket sends the whole revealed gap: under the adaptive per-peer diff --git a/pkg/turbine/repair_selection_test.go b/pkg/turbine/repair_selection_test.go index 76ebf157f..39cf4985a 100644 --- a/pkg/turbine/repair_selection_test.go +++ b/pkg/turbine/repair_selection_test.go @@ -251,3 +251,77 @@ func TestHeadPolicy(t *testing.T) { t.Fatalf("bulk policy = %+v, want no concurrent duplicate attempts", bulk) } } + +func TestRepairSelectionPrefixBeforeCheapest(t *testing.T) { + s := newRepairSelectionSlot(9) + addCodedSet(s, 0, 32, 32, seq(0, 9), 12) + addCodedSet(s, 32, 32, 32, seq(32, 56), 6) + s.haveLast = true + s.lastIndex = 63 + got := s.missingDataForRepairWithPrefix(63, 4, true) + if !reflect.DeepEqual(got, seq(10, 13)) { + t.Fatalf("prefix %v", got) + } + all := s.missingDataForRepairWithPrefix(63, 256, true) + if !reflect.DeepEqual(all, append(seq(10, 19), 57)) { + t.Fatalf("remaining order %v", all) + } +} + +func TestRepairSelectionUncodedPrefixBeforeCoded(t *testing.T) { + s := newRepairSelectionSlot(9) + addUncodedData(s, seq(1, 31)...) + addCodedSet(s, 32, 32, 32, seq(32, 56), 6) + s.haveLast = true + s.lastIndex = 63 + got := s.missingDataForRepairWithPrefix(63, 256, true) + if !reflect.DeepEqual(got, []uint32{0, 57}) { + t.Fatalf("uncoded prefix %v", got) + } + if got := s.missingDataForRepairWithPrefix(63, 0, true); len(got) != 0 { + t.Fatalf("zero budget: %v", got) + } +} + +func TestRepairSelectionPrefixOnlyForStreamingPriorityHead(t *testing.T) { + a := NewSlotAssembler() + for _, slot := range []uint64{9, 10} { + s := newRepairSelectionSlot(slot) + addCodedSet(s, 0, 32, 32, seq(0, 9), 12) + addCodedSet(s, 32, 32, 32, seq(32, 56), 6) + s.haveLast = true + s.lastIndex = 63 + a.slots[slot] = s + } + a.maxObservedSlot = 11 + a.PrioritizeRepairRange(9, 10) + p, _ := a.RepairRequestsTiered(2, 256) + if len(p) != 2 || p[0].MissingDataShreds[0] != 57 { + t.Fatalf("nonstreaming order %v", p) + } + a.SubscribeStream(make(chan StreamEvent, 1)) + p, _ = a.RepairRequestsTiered(2, 256) + if len(p) != 2 || p[0].MissingDataShreds[0] != 10 || p[1].MissingDataShreds[0] != 57 { + t.Fatalf("streaming head scope %v", p) + } + a.SubscribeStream(nil) + p, _ = a.RepairRequestsTiered(2, 256) + if p[0].MissingDataShreds[0] != 57 { + t.Fatal("disabled streaming retained prefix policy") + } +} + +func TestRepairPriorityParentPinnedAfterChild(t *testing.T) { + a := NewSlotAssembler() + a.maxObservedSlot = 101 + a.retentionFloor = 100 + a.PrioritizeRepairRange(101, 101) + a.PrioritizeRepairRange(100, 100) + priority, _ := a.RepairRequestsTiered(2, 16) + if len(priority) != 2 || priority[0].Slot != 100 || priority[1].Slot != 101 { + t.Fatalf("parent must precede earlier-pinned child: %+v", priority) + } + if a.priorityRepairOrder[0] != 101 { + t.Fatal("selection changed pin retention order") + } +} diff --git a/pkg/turbine/repair_wakeup_test.go b/pkg/turbine/repair_wakeup_test.go new file mode 100644 index 000000000..d9669534a --- /dev/null +++ b/pkg/turbine/repair_wakeup_test.go @@ -0,0 +1,111 @@ +package turbine + +import ( + "context" + "testing" + "time" +) + +func TestPriorityRepairWakeOnlyForNewPins(t *testing.T) { + r := NewUDPReceiver("127.0.0.1:0") + r.repairClient = &repairClient{priorityWake: make(chan struct{}, 1)} + r.PrioritizeRepairSlot(10) + if len(r.repairClient.priorityWake) != 1 { + t.Fatal("new pin did not wake repair") + } + <-r.repairClient.priorityWake + for i := 0; i < 100; i++ { + r.PrioritizeRepairSlot(10) + } + if len(r.repairClient.priorityWake) != 0 { + t.Fatal("duplicate pins caused wakeups") + } + r.PrioritizeRepairRange(10, 12) + r.PrioritizeRepairSlot(13) + if len(r.repairClient.priorityWake) != 1 { + t.Fatal("new pins should coalesce") + } + <-r.repairClient.priorityWake + r.assembler.completedSlots[14] = struct{}{} + r.PrioritizeRepairSlot(14) + r.PrioritizeRepairSlot(0) + if len(r.repairClient.priorityWake) != 0 { + t.Fatal("completed/invalid slot woke repair") + } +} + +func TestRepairScheduleWakeCoalescingAndCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + wake := make(chan struct{}, 1) + calls := make(chan time.Time, 4) + entered := make(chan struct{}) + release := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + first := true + runRepairSchedule(ctx, wake, time.Hour, 20*time.Millisecond, func() { + calls <- time.Now() + if first { + first = false + close(entered) + <-release + } + }) + }() + wake <- struct{}{} + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("wake did not bypass periodic timer") + } + for i := 0; i < 100; i++ { + select { + case wake <- struct{}{}: + default: + } + } + first := <-calls + close(release) + select { + case second := <-calls: + if second.Sub(first) < 20*time.Millisecond { + t.Fatal("unbounded scan frequency") + } + case <-time.After(time.Second): + t.Fatal("pending wake lost") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("scheduler did not stop") + } + if len(calls) != 0 { + t.Fatal("wake burst caused extra scans") + } +} + +func TestRepairSchedulePeriodicWithoutWake(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := make(chan struct{}, 1) + done := make(chan struct{}) + go func() { + defer close(done) + runRepairSchedule(ctx, nil, time.Millisecond, time.Millisecond, func() { + select { + case calls <- struct{}{}: + default: + } + }) + }() + select { + case <-calls: + case <-time.After(time.Second): + t.Fatal("periodic repair stopped") + } + cancel() + <-done +} diff --git a/pkg/turbine/repairsim/prefix_repair_test.go b/pkg/turbine/repairsim/prefix_repair_test.go new file mode 100644 index 000000000..9077b0649 --- /dev/null +++ b/pkg/turbine/repairsim/prefix_repair_test.go @@ -0,0 +1,107 @@ +package repairsim + +import ( + "fmt" + "reflect" + "testing" + "time" + + "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/Overclock-Validator/mithril/pkg/turbine" +) + +// Logical-time experiment using production selection, authenticated packets and +// FEC recovery. A fixed request budget per 20ms round; this measures when the +// first data span is restored, not execution latency or production retry policy. +func TestStreamingPrefixRepairUnderLimitedBudget(t *testing.T) { + ledger := testLedger(t, 1, 8) + for _, budget := range []int{1, 2, 4, 16} { + t.Run(fmt.Sprint(budget), func(t *testing.T) { + type result struct { + prefix, complete time.Duration + requests int + block *block.Block + } + run := func(stream bool) result { + a := turbine.NewSlotAssembler() + if stream { + a.SubscribeStream(make(chan turbine.StreamEvent, 256)) + } + slot := ledger.Slots[0] + feed := func(p Packet) *block.Block { + sh, err := parseAndVerify(p, ledger) + if err != nil { + t.Fatal(err) + } + b, err := a.AddShred(sh) + if err != nil { + t.Fatal(err) + } + return b + } + for j, f := range slot.FECs { + for _, p := range f.Data[:29] { + feed(p) + } + n := 2 + if j == 0 { + n = 1 + } + for _, p := range f.Coding[:n] { + feed(p) + } + } + a.PrioritizeRepairSlot(slot.Number) + r := result{} + for round := 1; round <= 20; round++ { + req := a.RepairRequests(1, 256) + if len(req) == 0 { + t.Fatal("unfinished slot has no repair request") + } + indices := req[0].MissingDataShreds + if len(indices) > budget { + indices = indices[:budget] + } + if len(indices) == 0 { + t.Fatal("no exact repair work") + } + for _, idx := range indices { + r.requests++ + if b := feed(slot.Data[idx]); b != nil { + r.block = b + r.complete = time.Duration(round) * 20 * time.Millisecond + } + } + remaining := a.RepairRequests(1, 256) + hole := false + for _, q := range remaining { + for _, idx := range q.MissingDataShreds { + if idx < 32 { + hole = true + } + } + } + if !hole && r.prefix == 0 { + r.prefix = time.Duration(round) * 20 * time.Millisecond + } + if r.block != nil { + return r + } + } + t.Fatal("did not complete") + return r + } + before, after := run(false), run(true) + t.Logf("first span %v -> %v; full block %v -> %v; requests %d -> %d", before.prefix, after.prefix, before.complete, after.complete, before.requests, after.requests) + if after.prefix > before.prefix || (budget < 9 && after.prefix == before.prefix) { + t.Fatal("prefix did not improve") + } + if after.requests != before.requests || after.complete > before.complete { + t.Fatal("request count or completion regressed") + } + if !reflect.DeepEqual(before.block.Transactions, after.block.Transactions) || !reflect.DeepEqual(before.block.Entries, after.block.Entries) { + t.Fatal("assembled payload mismatch") + } + }) + } +} diff --git a/pkg/turbine/shredspool.go b/pkg/turbine/shredspool.go index 0170b0f76..cebb0c837 100644 --- a/pkg/turbine/shredspool.go +++ b/pkg/turbine/shredspool.go @@ -426,7 +426,9 @@ func (s *ShredSpool) recomputeHighestLocked() { // DiscardSlot removes both persisted packets and the completeness marker for // a poisoned or rejected slot. The journal tombstone prevents an older // completion record from being resurrected if repair immediately recreates a -// partial file with the same slot number. +// partial file with the same slot number. If the journal cannot durably fence +// the old marker, retain the old file and reject replacement writes until a +// retry succeeds; deleting it would permit stale completeness to certify new data. func (s *ShredSpool) DiscardSlot(slot uint64) { if s == nil { return diff --git a/pkg/turbine/stream.go b/pkg/turbine/stream.go new file mode 100644 index 000000000..9e8def5a6 --- /dev/null +++ b/pkg/turbine/stream.go @@ -0,0 +1,400 @@ +package turbine + +import ( + "context" + "errors" + "sort" + "time" + "weak" + + "github.com/Overclock-Validator/mithril/pkg/txverify" + "github.com/gagliardetto/solana-go" +) + +// Streaming feed: the assembler already decodes and signature-verifies every +// closed DATA_COMPLETE range of a slot while its shreds arrive (the entry +// prefetch). The feed exposes those batches, in the order they become ready, +// to one subscriber — replay's streaming executor — as immutable views, so the +// block can be executed while the rest of it is still in flight. +// +// The feed is advisory. Events are wake-ups: a full subscriber channel drops +// the event, and the subscriber recovers by asking PendingStreamBatches and +// StreamStatus, which read the assembler's own state under its lock. Nothing +// here changes how a slot completes, how its identity is attached, or how the +// complete block is emitted; the complete block remains the authority. + +// StreamGeneration identifies one assembly of a slot. A slot that is reset +// and assembled again is a different generation. It is opaque: consumers +// compare it for equality and pass it back to the assembler. +type StreamGeneration struct { + slot uint64 + state *slotState +} + +// Slot returns the generation's slot. +func (g StreamGeneration) Slot() uint64 { return g.slot } + +// IsZero reports whether the generation was never set. +func (g StreamGeneration) IsZero() bool { return g.state == nil } + +// NewDetachedStreamGeneration returns a non-zero generation for slot that no +// assembler knows about. It exists so consumers (replay's streaming executor) +// can unit-test their state machines with a fake feed; a real assembler +// reports it as StreamGone. +func NewDetachedStreamGeneration(slot uint64) StreamGeneration { + return StreamGeneration{slot: slot, state: &slotState{slot: slot}} +} + +// NewDetachedStreamBatch builds a ready entry-batch view for consumers' unit +// tests: txs are its transactions and identities, when non-nil, is a +// completed verification result for exactly those transactions (as +// txverify.BatchVerifier.VerifyWithMessageIdentities produces). With nil +// identities the batch reports itself unverified. The assembler never builds +// batches this way. +func NewDetachedStreamBatch(g StreamGeneration, start, end uint32, txs []*solana.Transaction, identities []txverify.VerifiedMessageIdentity) *StreamBatch { + ready := make(chan struct{}) + close(ready) + batch := &prefetchedShredBatch{start: start, end: end, ready: ready} + if identities != nil { + batch.verification = &transactionVerification{done: ready, cancel: func() {}, identities: identities, finishedAt: time.Now()} + } + view := newStreamBatch(g, batch) + view.Transactions = txs + return view +} + +// NewDetachedStreamMarker builds a marker batch view (header, update-parent +// or footer) for consumers' unit tests. +func NewDetachedStreamMarker(g StreamGeneration, start, end uint32, kind StreamMarkerKind, parentSlot uint64, parentBlockID solana.Hash) *StreamBatch { + ready := make(chan struct{}) + close(ready) + view := newStreamBatch(g, &prefetchedShredBatch{start: start, end: end, ready: ready, marker: true}) + view.Marker = kind + view.ParentSlot = parentSlot + view.ParentBlockID = parentBlockID + return view +} + +// StreamMarkerKind classifies a batch that carries an Alpenglow block +// component instead of entries. +type StreamMarkerKind uint8 + +const ( + // StreamMarkerNone is an ordinary entry batch. + StreamMarkerNone StreamMarkerKind = iota + // StreamMarkerHeader is the block header (FEC set 0): parent slot and ID. + StreamMarkerHeader + // StreamMarkerUpdateParent selects an older parent and abandons every + // batch before ReplayFECSetIndex (the optimistic prefix). + StreamMarkerUpdateParent + // StreamMarkerFooter is the block footer (certificates, bank hash, clock). + StreamMarkerFooter +) + +// StreamBatch is an immutable view of one decoded DATA_COMPLETE range. Its +// transactions are the same objects the complete block will reference when +// completion reuses this batch (byte-identical shreds), which is what lets a +// streaming consumer prove its executed prefix is the block by identity. +type StreamBatch struct { + Slot uint64 + Generation StreamGeneration + Start, End uint32 + Marker StreamMarkerKind + // Parent fields are set for header and UpdateParent markers. + ParentSlot uint64 + ParentBlockID solana.Hash + ReplayFECSetIndex uint32 + // Transactions is empty for markers and for batches that failed to decode. + Transactions []*solana.Transaction + // Err is the decode error; a batch with Err makes the whole slot invalid. + Err error + // ReadyAt is the original publication time, preserved across polling and + // notification recovery. It is not the time a consumer looked up the batch. + ReadyAt time.Time + + batch *prefetchedShredBatch +} + +// ErrStreamBatchUnverified reports that no signature-verification result is +// attached to the batch (no transactions, or admission was refused); the +// consumer must verify signatures itself. +var ErrStreamBatchUnverified = errors.New("stream batch has no verification result") + +// WaitVerification observes the batch's asynchronous signature verification and +// returns the verifier's message identities, one per transaction, bound to +// Transactions (see block.PrepareVerifiedTransactionMessageIdentities). A +// nil error with verified == false means no result is attached and the +// caller must verify itself; any other error means a signature failed (the +// slot is invalid) or ctx ended. Unlike the owning verifier wait, a context +// timeout returns without cancelling or joining the job: turbine retains the +// immutable transaction storage and joins readers before releasing reservations. +// A caller timing out must not mutate the batch or its transactions. +func (sb *StreamBatch) WaitVerification(ctx context.Context) (identities []txverify.VerifiedMessageIdentity, verified bool, err error) { + if sb == nil || sb.batch == nil { + return nil, false, ErrStreamBatchUnverified + } + if sb.batch.verification == nil { + return nil, false, nil + } + if ctx == nil { + ctx = context.Background() + } + future := sb.batch.verification + select { + case <-ctx.Done(): + return nil, false, ctx.Err() + case <-future.done: + } + if err := ctx.Err(); err != nil { + return nil, false, err + } + if future.err != nil { + return nil, false, future.err + } + if len(sb.batch.verification.identities) != len(sb.Transactions) { + return nil, false, nil + } + return sb.batch.verification.identities, true, nil +} + +// StreamEventKind is the kind of a feed wake-up. +type StreamEventKind uint8 + +const ( + // StreamBatchReady: Batch was decoded (and its verification submitted). + StreamBatchReady StreamEventKind = iota + // StreamCancelled: the generation's state is gone without a complete + // block (reset, eviction, invalid identity, shutdown). Reason says why. + StreamCancelled + // StreamCompleted: the generation assembled and the complete block is on + // its way through the normal emission path. + StreamCompleted +) + +// StreamEvent is an advisory wake-up. Call Resolve before inspecting Generation +// or Batch. Queued notifications hold only weak references, so a stalled +// subscriber cannot retain retired slot buffers outside the prefetch budget. +type StreamEvent struct { + Kind StreamEventKind + Slot uint64 + Generation StreamGeneration + Batch *StreamBatch + Reason string + state weak.Pointer[slotState] + batch weak.Pointer[prefetchedShredBatch] +} + +// Resolve acquires ownership of a still-live notification. A false result means +// the opportunity has expired; whole-block replay remains authoritative. Resolved +// generations retain their state for polling, including after completion. Detached +// events supplied by test feeds are already resolved. +func (e StreamEvent) Resolve() (StreamEvent, bool) { + if !e.Generation.IsZero() { + return e, true + } + state := e.state.Value() + if state == nil { + return StreamEvent{}, false + } + e.Generation = StreamGeneration{slot: e.Slot, state: state} + if e.Kind == StreamBatchReady { + batch := e.batch.Value() + if batch == nil { + return StreamEvent{}, false + } + e.Batch = newStreamBatch(e.Generation, batch) + } + return e, true +} + +// StreamStatus is the assembler's view of a generation. +type StreamStatus uint8 + +const ( + // StreamActive: the generation is the slot's current assembly. + StreamActive StreamStatus = iota + // StreamDone: the generation completed and its block was (or is being) + // emitted. + StreamDone + // StreamGone: the generation was discarded without a block. + StreamGone +) + +// SubscribeStream installs the single feed subscriber. Events are sent +// without blocking; a full channel drops the event and counts it. +func (a *SlotAssembler) SubscribeStream(ch chan<- StreamEvent) { + a.mu.Lock() + defer a.mu.Unlock() + a.streamSubscriber = ch + if ch == nil { + a.streamRepairParent = nil + a.streamRepairChild = nil + a.streamRepairInvalidChild = nil + } +} + +// StreamDroppedEvents reports wake-ups dropped because the subscriber was +// full; the subscriber polls PendingStreamBatches after any wake-up, so a +// non-zero count is a sizing hint, not a correctness problem. +func (a *SlotAssembler) StreamDroppedEvents() uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return a.streamDroppedEvents +} + +func (a *SlotAssembler) publishStreamLocked(event StreamEvent) { + if a.streamSubscriber == nil { + return + } + select { + case a.streamSubscriber <- event: + default: + a.streamDroppedEvents++ + } +} + +// StreamStatusOf reports whether a generation is still the slot's current +// assembly, completed into a block, or gone. +func (a *SlotAssembler) StreamStatusOf(g StreamGeneration) StreamStatus { + if g.state == nil { + return StreamGone + } + a.mu.Lock() + defer a.mu.Unlock() + return a.streamStatusLocked(g) +} + +func (a *SlotAssembler) streamStatusLocked(g StreamGeneration) StreamStatus { + if a.slots[g.slot] == g.state { + // Failed completions retain state for diagnostics. Polling must still + // see cancellation when the bounded event channel dropped its wake-up. + if g.state.streamCancelReason != "" { + return StreamGone + } + return StreamActive + } + if g.state.streamCompleted { + return StreamDone + } + return StreamGone +} + +// cancelUndeliveredStream closes a completed generation whose result was +// abandoned during receiver shutdown. Identity binding avoids cancelling a +// replacement generation assembled for the same slot. +func (a *SlotAssembler) cancelUndeliveredStream(g StreamGeneration) { + if g.state == nil { + return + } + a.mu.Lock() + defer a.mu.Unlock() + if !g.state.streamCompleted { + return + } + g.state.streamCompleted = false + g.state.streamCancelReason = "delivery_cancelled" + a.publishStreamReleaseLocked(g.state, g.state.streamCancelReason) +} + +// PendingStreamBatches returns every decoded batch of the generation whose +// range starts at or after fromStart, in shred-index order. It reads the +// prefetch state directly, so it is the authoritative recovery path after a +// dropped wake-up. A completed generation still owns its immutable ready +// results, so completion does not hide batches behind queued/lost notifications. +// Cancelled generations return nothing. No new prefetch work is scheduled here. +func (a *SlotAssembler) PendingStreamBatches(g StreamGeneration, fromStart uint32) []*StreamBatch { + if g.state == nil { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + status := a.streamStatusLocked(g) + if status == StreamGone || g.state.prefetch == nil || (g.state.prefetch.released && status != StreamDone) { + return nil + } + var out []*StreamBatch + for start, batch := range g.state.prefetch.batches { + if start < fromStart { + continue + } + select { + case <-batch.ready: + default: + continue + } + out = append(out, newStreamBatch(g, batch)) + } + sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start }) + return out +} + +// newStreamBatch builds the immutable view; it must only be called after the +// batch's ready channel closed (its fields are immutable from then on). +func newStreamBatch(g StreamGeneration, batch *prefetchedShredBatch) *StreamBatch { + batch.viewOnce.Do(func() { + batch.view = buildStreamBatch(g, batch) + }) + return batch.view +} + +func buildStreamBatch(g StreamGeneration, batch *prefetchedShredBatch) *StreamBatch { + readyAt := batch.readyAt + if readyAt.IsZero() { // detached test batches have no prefetch publication + readyAt = time.Now() + } + view := &StreamBatch{ + Slot: g.slot, + Generation: g, + Start: batch.start, + End: batch.end, + Err: batch.err, + ReadyAt: readyAt, + batch: batch, + } + switch { + case batch.err != nil: + case batch.marker && batch.parent != nil: + view.ParentSlot = batch.parent.ParentSlot + view.ParentBlockID = batch.parent.ParentBlockID + view.ReplayFECSetIndex = batch.parent.ReplayFECSetIndex + if batch.parent.FromUpdateParent { + view.Marker = StreamMarkerUpdateParent + } else { + view.Marker = StreamMarkerHeader + } + case batch.marker && batch.footer != nil: + view.Marker = StreamMarkerFooter + case batch.marker: + // A marker without decoded content is treated like a footer-less + // component boundary: nothing to execute, nothing to select. + view.Marker = StreamMarkerFooter + default: + view.Transactions = batch.transactions + } + return view +} + +// publishStreamBatchReady is called by the prefetch worker, under the +// assembler lock, after the batch's ready channel closed. +func (a *SlotAssembler) publishStreamBatchReadyLocked(s *slotState, batch *prefetchedShredBatch) { + if a.streamSubscriber == nil || s == nil || batch == nil { + return + } + a.noteChildRepairHeaderLocked(s, batch) + a.publishStreamLocked(StreamEvent{Kind: StreamBatchReady, Slot: s.slot, state: weak.Make(s), batch: weak.Make(batch)}) +} + +// publishStreamReleaseLocked is called from releasePrefetchLocked, i.e. from +// every path that drops a slot generation, and tells the subscriber whether a +// complete block follows (finalizeCompletion marked it) or the state is gone. +func (a *SlotAssembler) publishStreamReleaseLocked(s *slotState, reason string) { + if a.streamSubscriber == nil || s == nil { + return + } + state := weak.Make(s) + if s.streamCompleted { + a.publishStreamLocked(StreamEvent{Kind: StreamCompleted, Slot: s.slot, state: state}) + return + } + a.publishStreamLocked(StreamEvent{Kind: StreamCancelled, Slot: s.slot, state: state, Reason: reason}) +} diff --git a/pkg/turbine/stream_test.go b/pkg/turbine/stream_test.go new file mode 100644 index 000000000..bb0277916 --- /dev/null +++ b/pkg/turbine/stream_test.go @@ -0,0 +1,323 @@ +package turbine + +import ( + "context" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + "weak" + + "github.com/Overclock-Validator/mithril/pkg/block" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +func nextStreamEvent(t *testing.T, ch <-chan StreamEvent, kind StreamEventKind) StreamEvent { + t.Helper() + deadline := time.After(3 * time.Second) + for { + select { + case event := <-ch: + event, live := event.Resolve() + if !live { + continue + } + if event.Kind == kind { + return event + } + case <-deadline: + t.Fatalf("no stream event of kind %d", kind) + } + } +} + +// The feed publishes each prefetched batch once it is decoded — the header +// marker with its parent identity, then entry batches whose transactions are +// the very objects the completed block references — and ends with a +// completion event for the same generation. The final component (the ending +// tick) is decoded by completion, never by the prefetch, so it is not fed. +func TestStreamFeedPublishesBatchesAndCompletion(t *testing.T) { + // The production verifier (nil hook) is the one that attaches message + // identities; a per-transaction hook verifies without producing them. + v := newTransactionVerifier(2, 16, nil) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + events := make(chan StreamEvent, 64) + a.SubscribeStream(events) + + const slot = 300 + parentID := solana.Hash{9, 9, 9} + batches := prefetchTestShreds(t, slot, + testAlpenglowParentMarkerBytes(blockMarkerVariantHeader, slot-1, parentID), + prefetchTestPayload(t, verifierSignedTransactions(t, 3)), + prefetchTestPayload(t, verifierSignedTransactions(t, 4)), + buildAlpenglowEndingTick(t)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + header := nextStreamEvent(t, events, StreamBatchReady) + require.Equal(t, uint64(slot), header.Slot) + require.False(t, header.Generation.IsZero()) + require.Equal(t, StreamMarkerHeader, header.Batch.Marker) + require.Equal(t, uint64(slot-1), header.Batch.ParentSlot) + require.Equal(t, parentID, header.Batch.ParentBlockID) + require.Empty(t, header.Batch.Transactions) + _, verified, err := header.Batch.WaitVerification(context.Background()) + require.NoError(t, err) + require.False(t, verified, "markers carry no verification") + + require.Nil(t, feedPrefetchShreds(t, a, batches[1])) + first := nextStreamEvent(t, events, StreamBatchReady) + require.Equal(t, header.Generation, first.Generation) + require.Equal(t, batches[1][0].Index, first.Batch.Start) + require.Equal(t, StreamMarkerNone, first.Batch.Marker) + require.Len(t, first.Batch.Transactions, 3) + require.NoError(t, first.Batch.Err) + require.Equal(t, StreamActive, a.StreamStatusOf(first.Generation)) + + identities, verified, err := first.Batch.WaitVerification(context.Background()) + require.NoError(t, err) + require.True(t, verified) + require.Len(t, identities, 3) + prepared, err := block.PrepareVerifiedTransactionMessageIdentities(first.Batch.Transactions, identities) + require.NoError(t, err) + require.Equal(t, 3, prepared.Len()) + + // Recovery path: the pending list must show the same batches by range. + pending := a.PendingStreamBatches(first.Generation, 0) + require.Len(t, pending, 2) + require.Equal(t, header.Batch.Start, pending[0].Start) + require.Equal(t, first.Batch.Start, pending[1].Start) + require.Equal(t, first.Batch.End, pending[1].End) + require.Empty(t, a.PendingStreamBatches(first.Generation, first.Batch.End+1)) + + require.Nil(t, feedPrefetchShreds(t, a, batches[2])) + second := nextStreamEvent(t, events, StreamBatchReady) + require.Equal(t, first.Generation, second.Generation) + require.Len(t, second.Batch.Transactions, 4) + // Make sure the prefetch has retained both entry batches before the last + // component completes the slot; completion then reuses them by identity. + waitPrefetchedBatch(t, a, slot, second.Batch.Start) + + blk := feedPrefetchShreds(t, a, batches[3]) + require.NotNil(t, blk) + done := nextStreamEvent(t, events, StreamCompleted) + require.Equal(t, first.Generation, done.Generation) + require.Equal(t, StreamDone, a.StreamStatusOf(first.Generation)) + retained := a.PendingStreamBatches(first.Generation, first.Batch.Start) + require.Len(t, retained, 2, "completion preserves already-ready entry batches") + for i, batch := range retained { + ids, ok, err := batch.WaitVerification(context.Background()) + require.NoError(t, err) + require.True(t, ok) + require.Len(t, ids, len(batch.Transactions)) + offset := 0 + if i == 1 { + offset = 3 + } + for j, tx := range batch.Transactions { + require.Same(t, blk.Transactions[offset+j], tx) + } + } + + // Pointer identity: the prefix a streaming consumer executed is the block. + require.Len(t, blk.Transactions, 7) + for i, tx := range first.Batch.Transactions { + require.Same(t, tx, blk.Transactions[i]) + } + for i, tx := range second.Batch.Transactions { + require.Same(t, tx, blk.Transactions[3+i]) + } + require.Equal(t, uint64(slot-1), blk.SourceParentSlot) + require.True(t, blk.HasAlpenglowParentBlockID) + require.Equal(t, parentID, solana.Hash(blk.AlpenglowParentBlockID)) + require.Zero(t, a.StreamDroppedEvents()) +} + +// A reset while a slot is streaming cancels the generation; re-assembling the +// slot produces a different generation. +func TestStreamFeedCancelsOnResetAndRenewsGeneration(t *testing.T) { + v := newTransactionVerifier(2, 16, func(*solana.Transaction) error { return nil }) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + events := make(chan StreamEvent, 64) + a.SubscribeStream(events) + + const slot = 301 + batches := prefetchTestShreds(t, slot, + prefetchTestPayload(t, verifierSignedTransactions(t, 2)), + prefetchTestPayload(t, verifierSignedTransactions(t, 2))) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + first := nextStreamEvent(t, events, StreamBatchReady) + + a.ResetSlot(slot) + cancelled := nextStreamEvent(t, events, StreamCancelled) + require.Equal(t, first.Generation, cancelled.Generation) + require.Equal(t, "reset", cancelled.Reason) + require.Equal(t, StreamGone, a.StreamStatusOf(first.Generation)) + require.Empty(t, a.PendingStreamBatches(first.Generation, 0)) + + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + renewed := nextStreamEvent(t, events, StreamBatchReady) + require.NotEqual(t, first.Generation, renewed.Generation) + require.Equal(t, StreamActive, a.StreamStatusOf(renewed.Generation)) +} + +// Dropped wake-ups are counted and never lose state: the batches remain +// discoverable through PendingStreamBatches. +func TestStreamFeedDropsWakeupsWhenSubscriberIsFull(t *testing.T) { + v := newTransactionVerifier(2, 16, func(*solana.Transaction) error { return nil }) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + events := make(chan StreamEvent) // unbuffered and never drained: every send drops + a.SubscribeStream(events) + + const slot = 302 + batches := prefetchTestShreds(t, slot, + prefetchTestPayload(t, verifierSignedTransactions(t, 2)), + prefetchTestPayload(t, verifierSignedTransactions(t, 2))) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + cached := waitPrefetchedBatch(t, a, slot, 0) + require.NotNil(t, cached) + require.Eventually(t, func() bool { return a.StreamDroppedEvents() >= 1 }, 3*time.Second, time.Millisecond) + + a.mu.Lock() + state := a.slots[slot] + a.mu.Unlock() + g := StreamGeneration{slot: slot, state: state} + pending := a.PendingStreamBatches(g, 0) + require.Len(t, pending, 1) + require.Len(t, pending[0].Transactions, 2) +} + +// Notifications must not own retired slots or decoded payloads. Conversely, +// resolving a live event gives the consumer a strong polling handle. +func TestStreamQueuedEventsDoNotRetainRetiredBuffers(t *testing.T) { + events := make(chan StreamEvent, 4) + publish := func() (weak.Pointer[slotState], weak.Pointer[prefetchedShredBatch]) { + a := NewSlotAssembler() + a.SubscribeStream(events) + batch := &prefetchedShredBatch{raw: make([]byte, 1<<20), marker: true} + state := &slotState{slot: 42, prefetch: &slotEntryPrefetch{batches: map[uint32]*prefetchedShredBatch{0: batch}}} + a.mu.Lock() + a.publishStreamBatchReadyLocked(state, batch) + a.publishStreamReleaseLocked(state, "reset") + a.mu.Unlock() + return weak.Make(state), weak.Make(batch) + } + state, batch := publish() + require.Eventually(t, func() bool { + runtime.GC() + return state.Value() == nil && batch.Value() == nil + }, 3*time.Second, time.Millisecond) + require.Len(t, events, 2) + for len(events) > 0 { + _, live := (<-events).Resolve() + require.False(t, live) + } +} + +func TestStreamResolvedEventRetainsCompletedPollingState(t *testing.T) { + a := NewSlotAssembler() + events := make(chan StreamEvent, 2) + a.SubscribeStream(events) + ready := make(chan struct{}) + close(ready) + batch := &prefetchedShredBatch{ready: ready, marker: true} + state := &slotState{slot: 42, streamCompleted: true, prefetch: &slotEntryPrefetch{batches: map[uint32]*prefetchedShredBatch{0: batch}, released: true}} + a.mu.Lock() + a.publishStreamBatchReadyLocked(state, batch) + a.publishStreamReleaseLocked(state, "") + a.mu.Unlock() + event, live := (<-events).Resolve() + require.True(t, live) + runtime.GC() + require.Equal(t, StreamDone, a.StreamStatusOf(event.Generation)) + require.Len(t, a.PendingStreamBatches(event.Generation, 0), 1) + done, live := (<-events).Resolve() + require.True(t, live) + require.Equal(t, event.Generation, done.Generation) + require.Equal(t, StreamCompleted, done.Kind) +} + +// The streaming observer must return while the owning request is still running. +// Completion/cleanup retain the separate joining wait and own buffer lifetime. +func TestStreamVerificationTimeoutDoesNotCancelOrJoinOwner(t *testing.T) { + done := make(chan struct{}) + var closeOnce sync.Once + defer closeOnce.Do(func() { close(done) }) + var cancelled atomic.Bool + future := &transactionVerification{done: done, cancel: func() { cancelled.Store(true) }} + batch := &StreamBatch{batch: &prefetchedShredBatch{verification: future}} + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + returned := make(chan error, 1) + go func() { _, _, err := batch.WaitVerification(ctx); returned <- err }() + select { + case err := <-returned: + require.ErrorIs(t, err, context.DeadlineExceeded) + case <-time.After(3 * time.Second): + t.Fatal("observer waited for unfinished owner") + } + require.False(t, cancelled.Load(), "observer must not cancel completion's shared work") + closeOnce.Do(func() { close(done) }) + _, verified, err := batch.WaitVerification(context.Background()) + require.NoError(t, err) + require.True(t, verified, "same request remains usable after the observer leaves") +} + +func TestStreamPollingReusesTransactionView(t *testing.T) { + v := newTransactionVerifier(2, 16, nil) + defer v.closeAndWait() + a := NewSlotAssembler() + p := newEntryPrefetchPool(context.Background(), a, v) + defer p.closeAndWait() + batches := prefetchTestShreds(t, 812, prefetchTestPayload(t, verifierSignedTransactions(t, 3)), buildAlpenglowEndingTick(t)) + require.Nil(t, feedPrefetchShreds(t, a, batches[0])) + waitPrefetchedBatch(t, a, 812, 0) + a.mu.Lock() + g := StreamGeneration{slot: 812, state: a.slots[812]} + a.mu.Unlock() + first, second := a.PendingStreamBatches(g, 0), a.PendingStreamBatches(g, 0) + require.Len(t, first, 1) + require.Len(t, second, 1) + require.Len(t, first[0].Transactions, 3) + require.Same(t, &first[0].Transactions[0], &second[0].Transactions[0]) +} + +func TestCompletedStreamCancelledWhenDeliveryAbandoned(t *testing.T) { + for _, queued := range []bool{false, true} { + a := &SlotAssembler{slots: make(map[uint64]*slotState)} + events := make(chan StreamEvent, 2) + a.SubscribeStream(events) + g := NewDetachedStreamGeneration(101) + g.state.streamCompleted = true + replacement := NewDetachedStreamGeneration(101) + a.slots[101] = replacement.state + r := &UDPReceiver{assembler: a, blocks: make(chan *block.Block), pendingBlocks: make(map[uint64]int)} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result := slotCompletionResult{block: &block.Block{Slot: 101}, generation: g, pending: true} + r.startPendingBlock(101) + if queued { + results := make(chan slotCompletionResult, 1) + results <- result + close(results) + r.consumeCompletionResults(ctx, results) + } else { + require.False(t, r.handleCompletionResult(ctx, result)) + } + require.Equal(t, StreamGone, a.StreamStatusOf(g)) + require.Equal(t, StreamActive, a.StreamStatusOf(replacement)) + require.Empty(t, r.pendingBlocks) + event := nextStreamEvent(t, events, StreamCancelled) + require.Equal(t, g, event.Generation) + require.Equal(t, "delivery_cancelled", event.Reason) + } +} diff --git a/pkg/turbine/stream_view_test.go b/pkg/turbine/stream_view_test.go new file mode 100644 index 000000000..8f861fb3c --- /dev/null +++ b/pkg/turbine/stream_view_test.go @@ -0,0 +1,33 @@ +package turbine + +import ( + "sync" + "testing" + "time" +) + +func TestReadyStreamViewReused(t *testing.T) { + g := NewDetachedStreamGeneration(42) + ready := make(chan struct{}) + at := time.Now().Add(-time.Second) + b := &prefetchedShredBatch{ready: ready, readyAt: at, start: 1, end: 2} + close(ready) + expected := newStreamBatch(g, b) + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + got := newStreamBatch(g, b) + if got != expected || !got.ReadyAt.Equal(at) { + t.Error("view or readiness time changed") + } + } + }() + } + wg.Wait() + if n := testing.AllocsPerRun(100, func() { _ = newStreamBatch(g, b) }); n != 0 { + t.Fatalf("cached view allocates: %v", n) + } +}