Compare commits

...

91 Commits

Author SHA1 Message Date
Preston Van Loon
5bec4ad6fc Add prysmctl backfill verify command for blob and data column verification
Implements a new verification tool to check that backfill successfully
retrieved blobs and data columns from the beacon chain. The tool:

- Auto-detects network configuration by querying beacon node genesis info
- Calculates blob retention window based on MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUEST
- Verifies blobs via /eth/v1/beacon/blob_sidecars/{slot} API
- Verifies data columns (post-Fulu) via /eth/v1/debug/beacon/data_column_sidecars/{slot}
- Provides configurable verbosity with progress updates every 1000 slots
- Generates comprehensive statistics summary

Usage:
  prysmctl backfill verify --beacon-node-host=http://localhost:3500
  prysmctl backfill verify --beacon-node-host=http://localhost:3500 --verbose

Supports Hoodi testnet and mainnet with automatic config detection.

Fix backfill verify tool
2025-11-29 16:32:07 -06:00
Preston Van Loon
a39507b2b1 Fix existing test for TestServiceInit now that the minimum slot option is working 2025-11-29 16:31:44 -06:00
Preston Van Loon
2f9ceda0a6 Add regression test for WithBlobRetentionEpoch bug fix
Adds TestWithBlobRetentionEpochPreserved which verifies that the
--blob-retention-epochs flag value is preserved through Start()
initialization. The test detects if the bug at line 264 (using
syncNeeds{} instead of s.syncNeeds) regresses.
2025-11-29 16:31:44 -06:00
Preston Van Loon
8720dc69ff Fix backfill bug 2025-11-29 16:31:44 -06:00
Kasey Kirkham
c615cc1c30 use currentNeeds in the da check multiplexer 2025-11-28 18:02:24 -06:00
Kasey Kirkham
ac299fbdc3 update column batch code to use currentNeeds 2025-11-28 17:25:21 -06:00
Kasey Kirkham
dc9aae3611 integration test for expiring batches 2025-11-28 16:31:42 -06:00
Kasey Kirkham
2fa8675079 test coverage over sync/currentNeeds 2025-11-28 15:48:18 -06:00
Kasey Kirkham
6e55adfcdf enable clear and comprehensive checks for whether resources are needed at a slot 2025-11-28 15:17:08 -06:00
Kasey Kirkham
8e390f94f4 fix broken ai slop test 2025-11-26 18:21:22 -06:00
Kasey Kirkham
df5e01fc24 fix lint errors that only showed in ci 2025-11-26 17:59:19 -06:00
Kasey Kirkham
c13780d85a lint fix 2025-11-26 17:44:15 -06:00
Kasey Kirkham
2f60005de2 conform to new linter requirements 2025-11-26 15:44:24 -06:00
Kasey Kirkham
e93f9874a1 Merge branch 'develop' into backfill-data-columns 2025-11-26 15:40:10 -06:00
Kasey Kirkham
cfc514355b reduce logs on batches waiting descendent batch 2025-11-26 15:06:31 -06:00
Kasey Kirkham
dcfd11309b fix bug introduced when changing retry logs 2025-11-26 14:48:45 -06:00
Kasey Kirkham
a5eaf59b4c downgrade peer selection error to warn 2025-11-26 14:05:27 -06:00
Kasey Kirkham
5a7897a693 reduce log spam for failed requests 2025-11-26 13:45:02 -06:00
Kasey Kirkham
665f8a9891 changelog updates 2025-11-26 12:35:13 -06:00
james-prysm
f97622b054 e2e support electra forkstart (#16048)
<!-- Thanks for sending a PR! Before submitting:

1. If this is your first PR, check out our contribution guide here
https://docs.prylabs.network/docs/contribute/contribution-guidelines
You will then need to sign our Contributor License Agreement (CLA),
which will show up as a comment from a bot in this pull request after
you open it. We cannot review code without a signed CLA.
2. Please file an associated tracking issue if this pull request is
non-trivial and requires context for our team to understand. All
features and most bug fixes should have
an associated issue with a design discussed and decided upon. Small bug
   fixes and documentation improvements don't need issues.
3. New features and bug fixes must have tests. Documentation may need to
be updated. If you're unsure what to update, send the PR, and we'll
discuss
   in review.
4. Note that PRs updating dependencies and new Go versions are not
accepted.
   Please file an issue instead.
5. A changelog entry is required for user facing issues.
-->

**What type of PR is this?**

Bug fix


**What does this PR do? Why is it needed?**

Allows for starting e2e tests from electra or a specific fork of
interest again. doesn't fix missing execution requests tests, nishant
reverted it.

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.

---------

Co-authored-by: Radosław Kapka <rkapka@wp.pl>
2025-11-26 16:33:24 +00:00
Fibonacci747
08d0f42725 beacon-chain/das: remove dead slot parameter (#16021)
The slot parameter in blobCacheEntry.filter was unused and redundant.
All slot/epoch-sensitive checks happen before filter
(commitmentsToCheck), and disk availability is handled via
BlobStorageSummary (epoch-aware).

Changes:
- Drop slot from blobCacheEntry.filter signature.
- Update call sites in availability_blobs.go and blob_cache_test.go.

Mirrors the data_column_cache.filter API (which does not take slot),
reduces API noise, and removes dead code without changing behavior.
2025-11-26 16:04:05 +00:00
james-prysm
74c8a25354 adding semi-supernode feature (#16029)
<!-- Thanks for sending a PR! Before submitting:

1. If this is your first PR, check out our contribution guide here
https://docs.prylabs.network/docs/contribute/contribution-guidelines
You will then need to sign our Contributor License Agreement (CLA),
which will show up as a comment from a bot in this pull request after
you open it. We cannot review code without a signed CLA.
2. Please file an associated tracking issue if this pull request is
non-trivial and requires context for our team to understand. All
features and most bug fixes should have
an associated issue with a design discussed and decided upon. Small bug
   fixes and documentation improvements don't need issues.
3. New features and bug fixes must have tests. Documentation may need to
be updated. If you're unsure what to update, send the PR, and we'll
discuss
   in review.
4. Note that PRs updating dependencies and new Go versions are not
accepted.
   Please file an issue instead.
5. A changelog entry is required for user facing issues.
-->

**What type of PR is this?**

Feature


**What does this PR do? Why is it needed?**

| Feature | Semi-Supernode | Supernode |
| ----------------------- | ------------------------- |
------------------------ |
| **Custody Groups** | 64 | 128 |
| **Data Columns** | 64 | 128 |
| **Storage** | ~50% | ~100% |
| **Blob Reconstruction** | Yes (via Reed-Solomon) | No reconstruction
needed |
| **Flag** | `--semi-supernode` | `--supernode` |
| **Can serve all blobs** | Yes (with reconstruction) | Yes (directly) |

**note** if your validator total effective balance results in more
custody than the semi-supernode it will override those those
requirements.

cgc=64 from @nalepae 
Pro:
- We are useful to the network
- Less disconnection likelihood
- Straight forward to implement

Con:
- We cannot revert to a full node
- We have to serve incoming RPC requests corresponding to 64 columns

Tested the following using this kurtosis setup

```
participants:
  # Super-nodes
  - el_type: geth
    el_image: ethpandaops/geth:master
    cl_type: prysm
    vc_image: gcr.io/offchainlabs/prysm/validator:latest
    cl_image: gcr.io/offchainlabs/prysm/beacon-chain:latest
    count: 2
    cl_extra_params:
      - --supernode
    vc_extra_params:
      - --verbosity=debug
  # Full-nodes
  - el_type: geth
    el_image: ethpandaops/geth:master
    cl_type: prysm
    vc_image: gcr.io/offchainlabs/prysm/validator:latest
    cl_image: gcr.io/offchainlabs/prysm/beacon-chain:latest
    count: 2
    validator_count: 1
    cl_extra_params:
      - --semi-supernode
    vc_extra_params:
      - --verbosity=debug

additional_services:
  - dora
  - spamoor

spamoor_params:
  image: ethpandaops/spamoor:master
  max_mem: 4000
  spammers:
    - scenario: eoatx
      config:
        throughput: 200
    - scenario: blobs
      config:
        throughput: 20

network_params:
  fulu_fork_epoch: 0
  withdrawal_type: "0x02"
  preset: mainnet

global_log_level: debug
```

```
curl -H "Accept: application/json" http://127.0.0.1:32961/eth/v1/node/identity
{"data":{"peer_id":"16Uiu2HAm7xzhnGwea8gkcxRSC6fzUkvryP6d9HdWNkoeTkj6RSqw","enr":"enr:-Ni4QIH5u2NQz17_pTe9DcCfUyG8TidDJJjIeBpJRRm4ACQzGBpCJdyUP9eGZzwwZ2HS1TnB9ACxFMQ5LP5njnMDLm-GAZqZEXjih2F0dG5ldHOIAAAAAAAwAACDY2djQIRldGgykLZy_whwAAA4__________-CaWSCdjSCaXCErBAAE4NuZmSEAAAAAIRxdWljgjLIiXNlY3AyNTZrMaECulJrXpSOBmCsQWcGYzQsst7r3-Owlc9iZbEcJTDkB6qIc3luY25ldHMFg3RjcIIyyIN1ZHCCLuA","p2p_addresses":["/ip4/172.16.0.19/tcp/13000/p2p/16Uiu2HAm7xzhnGwea8gkcxRSC6fzUkvryP6d9HdWNkoeTkj6RSqw","/ip4/172.16.0.19/udp/13000/quic-v1/p2p/16Uiu2HAm7xzhnGwea8gkcxRSC6fzUkvryP6d9HdWNkoeTkj6RSqw"],"discovery_addresses":["/ip4/172.16.0.19/udp/12000/p2p/16Uiu2HAm7xzhnGwea8gkcxRSC6fzUkvryP6d9HdWNkoeTkj6RSqw"],"metadata":{"seq_number":"3","attnets":"0x0000000000300000","syncnets":"0x05","custody_group_count":"64"}}}
```

```
curl -s http://127.0.0.1:32961/eth/v1/debug/beacon/data_column_sidecars/head | jq '.data | length'
64
```

```
curl -X 'GET' \
  'http://127.0.0.1:32961/eth/v1/beacon/blobs/head' \
  -H 'accept: application/json'
  ```

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read [CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for reviewers to understand this PR.

---------

Co-authored-by: Preston Van Loon <pvanloon@offchainlabs.com>
Co-authored-by: james-prysm <jhe@offchainlabs.com>
Co-authored-by: Manu NALEPA <enalepa@offchainlabs.com>
2025-11-26 15:31:15 +00:00
Potuz
a466c6db9c Fix linter (#16058)
Fix linter, otherwise #16049 fails the linter.
2025-11-26 15:03:26 +00:00
Preston Van Loon
4da6c4291f Fix metrics logging of http_error_count (#16055)
**What type of PR is this?**

Bug fix

**What does this PR do? Why is it needed?**

I am seeing massive metrics cardinality on error cases. 

Example:

```
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682952",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682953",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682954",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682955",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682956",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682957",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682958",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682959",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682960",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682961",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682962",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682966",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682967",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682968",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682969",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682970",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682971",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682972",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682973",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682974",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682975",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682976",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682977",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682978",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682980",method="GET"} 2
http_error_count{code="Not Found",endpoint="/eth/v1/beacon/blob_sidecars/1682983",method="GET"} 2
```

Now it looks like this:

```
# TYPE http_error_count counter
http_error_count{code="Not Found",endpoint="beacon.GetBlockV2",method="GET"} 606
http_error_count{code="Not Found",endpoint="blob.Blobs",method="GET"} 4304
```

**Which issues(s) does this PR fix?**

**Other notes for review**


Other uses of http metrics use the endpoint name rather than the request
URL.

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-11-26 04:22:35 +00:00
Kasey Kirkham
5ba9e5405c Don't oversample in backfill 2025-11-25 16:58:55 -06:00
Kasey Kirkham
510dadf2c9 gaz 2025-11-25 16:01:59 -06:00
Kasey Kirkham
ecc9d6f28e fix hist distributions and rename metrics 2025-11-25 16:00:14 -06:00
Kasey Kirkham
41e8793dfe test coverage for peer assigner 2025-11-25 15:53:24 -06:00
Kasey Kirkham
267abebf41 test coverage for columnsNotStored
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-25 14:52:34 -06:00
Kasey Kirkham
f44045b736 handle case where current batches fall outside the retention window
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-25 13:34:26 -06:00
Forostovec
2d242a8d09 execution: avoid redundant WithHttpEndpoint when JWT is provided (#16032)
This change ensures FlagOptions in cmd/beacon-chain/execution/options.go
appends only one endpoint option depending on whether a JWT secret is
present. Previously the code always appended WithHttpEndpoint and then
conditionally appended WithHttpEndpointAndJWTSecret which overwrote the
first option, adding unnecessary allocations and cognitive overhead.
Since WithHttpEndpointAndJWTSecret fully configures the endpoint,
including URL and Bearer auth needed by the Engine API, the initial
WithHttpEndpoint is redundant when a JWT is supplied. The refactor
preserves behavior while simplifying option composition and avoiding
redundant state churn.

---------

Co-authored-by: james-prysm <90280386+james-prysm@users.noreply.github.com>
2025-11-25 17:43:38 +00:00
Radosław Kapka
6be1541e57 Initialize the ExecutionRequests field in gossip block map (#16047)
**What type of PR is this?**

Other

**What does this PR do? Why is it needed?**

When unmarshaling a block with fastssz, if the target block's
`ExecutionRequests` field is nil, it will not get populated
```
if b.ExecutionRequests == nil {
	b.ExecutionRequests = new(v1.ExecutionRequests)
}
if err = b.ExecutionRequests.UnmarshalSSZ(buf); err != nil {
	return err
}
```
This is true for other fields and that's why we initialize them in our
gossip data maps. There is no bug at the moment because even if
execution requests are nil, we initialize them in
`consensus-types/blocks/proto.go`
```
er := pb.ExecutionRequests
if er == nil {
	er = &enginev1.ExecutionRequests{}
}
```
However, since we initialize other fields in the data map, it's safer to
do it for execution requests too, to avoid a bug in case the code in
`consensus-types/blocks/proto.go` changes in the future.

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-11-25 16:17:44 +00:00
Bastin
b845222ce7 Integrate state-diff into HasState() (#16045)
**What type of PR is this?**
Feature

**What does this PR do? Why is it needed?**
This PR adds integrates state-diff into `HasState()`. 

One thing to note: we are assuming that, for a given block root, that
has either a state summary or a block in db, and also falls in the state
diff tree, then there must exist a state. This function could return
true, even when there is no actual state saved due to any error.
But this is fine, because we have that assumption throughout the whole
state diff feature.
2025-11-25 13:22:57 +00:00
terence
5bbdebee22 Add Gloas consensus type block package (#15618)
Co-authored-by: Bastin <43618253+Inspector-Butters@users.noreply.github.com>
2025-11-25 09:21:19 +00:00
Kasey Kirkham
bc4dfc3338 Adjust MaxSafeEpoch -1, add coverage on "max safe" methods 2025-11-24 17:46:31 -06:00
Kasey Kirkham
2c99677cee test coverage for availability_columns.go 2025-11-24 16:13:30 -06:00
Preston Van Loon
8a898ba51c Add tests for data_column_assignment.go 2025-11-24 15:50:38 -06:00
Kasey Kirkham
86b9a2edc0 crank noisy log to trace (from warn) 2025-11-24 14:47:29 -06:00
Kasey Kirkham
e9c9ae8945 fix bug with earliest initialized at zero 2025-11-24 14:38:05 -06:00
Bastin
26100e074d Refactor slot by blockroot (#16040)
**Review after #16033 is merged**

**What type of PR is this?**
Other

**What does this PR do? Why is it needed?**
This PR refactors the code to find the corresponding slot of a given
block root using state summary or the block itself, into its own
function `SlotByBlockRoot(ctx, blockroot)`.

Note that there exists a function `slotByBlockRoot(ctx context.Context,
tx *bolt.Tx, blockRoot []byte)` immediately below the new function. Also
note that this function has two drawbacks, which led to creation of the
new function:
- the old function requires a boltdb tx, which is not necessarily
available to the caller.
- the old function does NOT make use of the state summary cache. 

edit: 
- the old function also uses the state bucket to retrieve the state and
it's slot. this is not something we want in the state diff feature,
since there is no state bucket.
2025-11-24 19:59:13 +00:00
Kasey Kirkham
c56f293f41 nit 2025-11-24 11:07:17 -06:00
Kasey Kirkham
79b9648bb9 test coverage on fulu_transtion.go
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-24 11:06:43 -06:00
satushh
768fa0e5a1 Revert "Metrics for eas" (#16043)
Reverts OffchainLabs/prysm#16008
2025-11-24 16:12:30 +00:00
Kasey Kirkham
96811bc2bf James' feedback 2025-11-24 10:11:14 -06:00
Kasey Kirkham
e368a0c12a more pr feedback 2025-11-24 10:07:51 -06:00
Kasey Kirkham
74e497f72e cleaning up tests 2025-11-24 10:07:51 -06:00
Kasey Kirkham
8ec3219b21 bound checking on dc req range and numerous small PR feedback cleanups 2025-11-24 10:07:51 -06:00
Bastin
11bb8542a4 Integrate state-diff into State() (#16033)
**What type of PR is this?**
Feature

**What does this PR do? Why is it needed?**
This PR integrates the state diff path into the `State()` function from
`db/kv`, which allows reading of states using the state diff db, when
the `EnableStateDiff` flag is enabled.

**Notes for reviewers:**
Files `kv/state_diff_test.go` and `config/features/config.go` only
contain renamings:
- `kv/state_diff_test.go`: rename `setDefaultExponents()` to
`setDefaultStateDiffExponents()` to be less vague.
- `config/features/config.go`: rename `enableStateDiff` to
`EnableStateDiff` to make it public.
2025-11-24 12:42:43 +00:00
Bharath Vedartham
b78c2c354b add a metric to measure the attestation gossip validator (#15785)
This PR adds a Summary metric to measure the p2p topic validation time
for attestations arriving from the network. It times the
`validateCommitteeIndexBeaconAttestation` method.

The metric is called `gossip_attestation_verification_milliseconds`

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.

---------

Co-authored-by: Kasey Kirkham <kasey@users.noreply.github.com>
2025-11-21 21:57:19 +00:00
Muzry
55e2001a0b Check the JWT secret length (#15939)
<!-- Thanks for sending a PR! Before submitting:

1. If this is your first PR, check out our contribution guide here
https://docs.prylabs.network/docs/contribute/contribution-guidelines
You will then need to sign our Contributor License Agreement (CLA),
which will show up as a comment from a bot in this pull request after
you open it. We cannot review code without a signed CLA.
2. Please file an associated tracking issue if this pull request is
non-trivial and requires context for our team to understand. All
features and most bug fixes should have
an associated issue with a design discussed and decided upon. Small bug
   fixes and documentation improvements don't need issues.
3. New features and bug fixes must have tests. Documentation may need to
be updated. If you're unsure what to update, send the PR, and we'll
discuss
   in review.
4. Note that PRs updating dependencies and new Go versions are not
accepted.
   Please file an issue instead.
5. A changelog entry is required for user facing issues.
-->

**What type of PR is this?**
 Bug fix

**What does this PR do? Why is it needed?**

Previously, JWT secrets longer than 256 bits could cause client
compatibility issues. For example, Prysm would accept longer secrets
while Geth strictly requires exactly 32 bytes, causing Geth startup
failures when using the same secret file.

This change enforces the Engine API specification requirement that JWT
secrets must be exactly 256 bits (32 bytes), ensuring consistent
behavior across different client implementations.

**Which issues(s) does this PR fix?**

Fixes #

**Other notes for review**

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [x] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.

---------

Co-authored-by: james-prysm <90280386+james-prysm@users.noreply.github.com>
2025-11-21 21:51:03 +00:00
sashaodessa
c093283b1b Replace fixed sleep delays with active polling in prometheus service test (#15828)
## **Description:**

**What type of PR is this?**

> Bug fix

**What does this PR do? Why is it needed?**

Replaces fixed `time.Sleep(time.Second)` delays in `TestLifecycle` with
active polling to wait for service readiness/shutdown. This improves
test reliability and reduces execution time by eliminating unnecessary
waits when services start/stop faster than expected.

**Which issues(s) does this PR fix?**

N/A - Minor test improvement

**Other notes for review**

- Uses 50ms polling interval with 3s timeout for both startup and
shutdown checks
- Maintains same test logic while making it more efficient and less
flaky
- No functional changes to the service itself

**Acknowledgements**

- [x] I have read
[CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md).
- [ ] I have included a uniquely named [changelog fragment
file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd).
- [x] I have added a description to this PR with sufficient context for
reviewers to understand this PR.
2025-11-21 21:39:24 +00:00
Preston Van Loon
6e498c4259 Reorganize columnBatch.needed() tests using t.Run
Consolidate 14 individual TestColumnBatchNeeded_* test functions into a
single TestColumnBatchNeeded function with hierarchical subtests organized
into 5 logical groups:

1. empty batch conditions (3 subtests)
2. single block scenarios (4 subtests + table-driven)
3. multiple block scenarios (3 subtests)
4. state transitions (2 subtests)
5. edge cases (2 subtests)

This improves test organization and readability while maintaining all
existing test coverage and assertions.
2025-11-21 13:19:52 -06:00
Kasey Kirkham
525bffc684 remove unnecessary indirection of peer.ID pointers 2025-11-21 12:04:37 -06:00
Kasey Kirkham
87173366ec test coverage for column request size capping 2025-11-21 12:04:22 -06:00
terence
5449fd0352 p2p: wire stategen into service for last finalized state (#16034)
This PR removes the last production usage for: `LastArchivedRoot` by

- extend the P2P config to accept a `StateGen` dependency and wire it up
from the beacon node
- update gossip scoring to read the active validator count via stategen
using last finalized block root

note: i think the correct implementation should process last finalizes
state to current slot, but that's a bigger change i dont want to make in
this PR, i just want to remove usages for `LastArchivedRoot`
2025-11-21 16:13:00 +00:00
Bastin
3d7f7b588b Fix state diff repetitive anchor slot bug (#16037)
**What type of PR is this?**
 Bug fix

**What does this PR do? Why is it needed?**
This PR fixes a bug in the state diff `getBaseAndDiffChain()` method. In
the process of finding the diff chain indices, there could be a scenario
where multiple levels return the same diff slot number not equal to the
base (full snapshot) slot number.
This resulted in multiple diff items being added to the diff chain for
the same slot, but on different levels, which resulted in errors reading
the diff.

Fix: we keep a `lastSeenAnchorSlot` equal to the `BaseAnchorSlot` and
update it every time we see a new anchor slot. We ignore if the current
found anchor slot is equal to the `lastSeenAnchorSlot`.

Scenario example: 
exponents: [20, 14, 10, 7, 5]
offset: 0
slots saved: slot 2^11, and slot (2^11 + 2^5)
slot to read: slot (2^11 + 2^5)
resulting list of anchor slots (diff chain indices): [0, 0, 2^11, 2^11,
2^11 + 2^5]
2025-11-21 15:42:27 +00:00
Kasey Kirkham
8d6b3a9fb2 Limit columns requested instead of making block batches small 2025-11-20 18:15:52 -06:00
Kasey Kirkham
1438a351d7 remove slice arg from NotBusy filter 2025-11-20 13:32:18 -06:00
Kasey Kirkham
0f47577662 Test coverage for log.go and some small improvements
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 13:32:18 -06:00
terence
2f067c4164 Add supported / unsupported version for fork enum (#16030)
* gate unreleased forks

* Preston + Bastin's feedback

* Rename back to all versions

* Clean up, mark PR ready for review

* Changelog
2025-11-20 14:58:41 +00:00
Preston Van Loon
345d587204 Add comprehensive mutation test coverage for columns.go
This commit adds 25 new test cases to improve mutation testing coverage for
the backfill columns sync functionality, addressing 38 previously escaped
mutants.

Test coverage added:
- buildColumnBatch(): Array indexing edge cases, fork epoch boundaries,
  pre/post-Fulu block handling, and control flow mutations
- countedValidation(): Validation error paths, commitment mismatches,
  and state update verification (Unset/addPeerColumns calls)
- validate(): Metrics recording wrapper and error propagation
- newColumnSync(): Initialization paths and nil columnBatch handling
- currentCustodiedColumns(): Column indices retrieval
- columnSync wrapper methods: Nil checks and delegation logic

The new tests specifically target:
- Array indexing bugs (len-1, len+1, len-0 mutations)
- Boundary conditions at Fulu fork epoch (< vs <=)
- Branch coverage for error handling paths
- Statement removal detection for critical state updates
- Expression and comparison operator mutations

All 25 new test cases pass successfully, bringing function coverage from
14% (1/7 functions) to 100% (7/7 functions) and estimated mutation
coverage from ~0% to ~95%+.
2025-11-20 08:54:07 -06:00
Preston Van Loon
013d8ca4fd TestColumnBatch 2025-11-20 08:54:07 -06:00
Kasey Kirkham
85b1414119 Test coverage for log.go and some small improvements
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-20 00:21:59 -06:00
Radosław Kapka
81266f60af Move BlockGossipReceived event to the end of gossip validation. (#16031)
* Move `BlockGossipReceived` event to the end of gossip validation.

* changelog <3

* tests
2025-11-19 22:34:02 +00:00
Kasey Kirkham
b7e999d651 Test coverage for verify_column.go
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-19 16:17:56 -06:00
Bastin
207f36065a state-diff configs & kv functions (#15903)
* state-diff configs

* state diff kv functions

* potuz's comments

* Update config.go

* fix merge conflicts

* apply bazel's suggestion and fix some bugs

* preston's feedback
2025-11-19 19:27:56 +00:00
Kasey Kirkham
d6209ae5c3 rm extra tick 2025-11-19 13:18:04 -06:00
Kasey Kirkham
36e8947068 more comment fixes 2025-11-19 13:01:33 -06:00
Kasey Kirkham
0a827f17d5 more naming etc feedback 2025-11-19 12:24:07 -06:00
Kasey Kirkham
8d126196d9 update changelog to include message about flag default changing 2025-11-19 11:42:02 -06:00
Kasey Kirkham
094cee25ac more comment cleanup 2025-11-19 11:41:01 -06:00
Kasey Kirkham
bbd856fe6f extra BisectionIterator as a separate interface 2025-11-19 11:20:22 -06:00
terence
eb9feabd6f stop emitting payload attribute events during late block handling (#16026)
* stop emitting payload attribute events during late block handling when we are not proposing the next slot

* Change the behavior to not even enter FCU if we are not proposing next slot
2025-11-19 16:51:46 +00:00
Kasey Kirkham
b9a7cb3764 more manu feedback 2025-11-19 10:40:49 -06:00
Kasey Kirkham
61d4a6c105 don't try to cache peerdas.Info 2025-11-18 17:44:38 -06:00
Kasey Kirkham
1037e56238 manu feedback 2025-11-18 17:39:20 -06:00
Kasey Kirkham
ac0b3cb593 remove "feature" to slice result from BestFinalized 2025-11-18 16:22:31 -06:00
Kasey Kirkham
d156168712 Avoid requesting blocks from peer that gave us an invalid batch 2025-11-18 10:48:52 -06:00
terence
bc0868e232 Add Gloas beacon state package (#15611)
* Add Gloas protobuf definitions with spec tests

Add Gloas state fields to beacon state implementation

* Remove shared field for pending payment

* Radek's feedback

* Potuz feedback

* use slice concat

* Fix comment

* Fix concat

* Fix comment

* Fix correct index
2025-11-18 15:08:31 +00:00
Kasey Kirkham
1644dc6323 decrease default batch size to compensate for data column overhead 2025-11-13 13:14:21 -06:00
Kasey Kirkham
29257b10ec avoid debug log spam that comes from computing custody info pre-fulu 2025-11-12 16:35:40 -06:00
Kasey Kirkham
6849302288 re-enable backfill for fulu 2025-11-12 16:35:40 -06:00
Kasey Kirkham
58f6b3ff3c fixing rebase 2025-11-12 16:35:37 -06:00
Kasey Kirkham
51bca0d08c make daChecker less ambiguously stateful 2025-11-12 16:00:39 -06:00
Kasey Kirkham
3697b1db50 multiStore/Checker to validate dependencies/state up front 2025-11-12 16:00:39 -06:00
Kasey Kirkham
c1b361ce0c remove or rewrite non-actionable TODOs 2025-11-12 16:00:39 -06:00
Kasey Kirkham
20bbc60efe replace panic that "shouldn't happen" with a safe shutdown 2025-11-12 16:00:39 -06:00
Kasey Kirkham
0f9b87cb59 downscore peers on block batch failures 2025-11-12 16:00:39 -06:00
Kasey Kirkham
d6ce7e0b9f potuz' feedback 2025-11-12 16:00:39 -06:00
Kasey Kirkham
9d8f45940a filter locally available columns from backfill batch 2025-11-12 16:00:39 -06:00
Kasey
4424cce30d DataColumnSidecar backfill 2025-11-12 16:00:39 -06:00
196 changed files with 16481 additions and 1285 deletions

View File

@@ -134,7 +134,7 @@ func getStateVersionAndPayload(st state.BeaconState) (int, interfaces.ExecutionD
return preStateVersion, preStateHeader, nil
}
func (s *Service) onBlockBatch(ctx context.Context, blks []consensusblocks.ROBlock, avs das.AvailabilityStore) error {
func (s *Service) onBlockBatch(ctx context.Context, blks []consensusblocks.ROBlock, avs das.AvailabilityChecker) error {
ctx, span := trace.StartSpan(ctx, "blockChain.onBlockBatch")
defer span.End()
@@ -306,7 +306,7 @@ func (s *Service) onBlockBatch(ctx context.Context, blks []consensusblocks.ROBlo
return s.saveHeadNoDB(ctx, lastB, lastBR, preState, !isValidPayload)
}
func (s *Service) areSidecarsAvailable(ctx context.Context, avs das.AvailabilityStore, roBlock consensusblocks.ROBlock) error {
func (s *Service) areSidecarsAvailable(ctx context.Context, avs das.AvailabilityChecker, roBlock consensusblocks.ROBlock) error {
blockVersion := roBlock.Version()
block := roBlock.Block()
slot := block.Slot()
@@ -948,13 +948,6 @@ func (s *Service) lateBlockTasks(ctx context.Context) {
attribute := s.getPayloadAttribute(ctx, headState, s.CurrentSlot()+1, headRoot[:])
// return early if we are not proposing next slot
if attribute.IsEmpty() {
headBlock, err := s.headBlock()
if err != nil {
log.WithError(err).WithField("head_root", headRoot).Error("Unable to retrieve head block to fire payload attributes event")
}
// notifyForkchoiceUpdate fires the payload attribute event. But in this case, we won't
// call notifyForkchoiceUpdate, so the event is fired here.
go s.firePayloadAttributesEvent(s.cfg.StateNotifier.StateFeed(), headBlock, headRoot, s.CurrentSlot()+1)
return
}

View File

@@ -2805,6 +2805,10 @@ func TestProcessLightClientUpdate(t *testing.T) {
require.NoError(t, s.cfg.BeaconDB.SaveHeadBlockRoot(ctx, [32]byte{1, 2}))
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
l := util.NewTestLightClient(t, testVersion)

View File

@@ -39,8 +39,8 @@ var epochsSinceFinalityExpandCache = primitives.Epoch(4)
// BlockReceiver interface defines the methods of chain service for receiving and processing new blocks.
type BlockReceiver interface {
ReceiveBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock, blockRoot [32]byte, avs das.AvailabilityStore) error
ReceiveBlockBatch(ctx context.Context, blocks []blocks.ROBlock, avs das.AvailabilityStore) error
ReceiveBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock, blockRoot [32]byte, avs das.AvailabilityChecker) error
ReceiveBlockBatch(ctx context.Context, blocks []blocks.ROBlock, avs das.AvailabilityChecker) error
HasBlock(ctx context.Context, root [32]byte) bool
RecentBlockSlot(root [32]byte) (primitives.Slot, error)
BlockBeingSynced([32]byte) bool
@@ -69,7 +69,7 @@ type SlashingReceiver interface {
// 1. Validate block, apply state transition and update checkpoints
// 2. Apply fork choice to the processed block
// 3. Save latest head info
func (s *Service) ReceiveBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock, blockRoot [32]byte, avs das.AvailabilityStore) error {
func (s *Service) ReceiveBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock, blockRoot [32]byte, avs das.AvailabilityChecker) error {
ctx, span := trace.StartSpan(ctx, "blockChain.ReceiveBlock")
defer span.End()
// Return early if the block is blacklisted
@@ -242,7 +242,7 @@ func (s *Service) validateExecutionAndConsensus(
return postState, isValidPayload, nil
}
func (s *Service) handleDA(ctx context.Context, avs das.AvailabilityStore, block blocks.ROBlock) (time.Duration, error) {
func (s *Service) handleDA(ctx context.Context, avs das.AvailabilityChecker, block blocks.ROBlock) (time.Duration, error) {
var err error
start := time.Now()
if avs != nil {
@@ -332,7 +332,7 @@ func (s *Service) executePostFinalizationTasks(ctx context.Context, finalizedSta
// ReceiveBlockBatch processes the whole block batch at once, assuming the block batch is linear ,transitioning
// the state, performing batch verification of all collected signatures and then performing the appropriate
// actions for a block post-transition.
func (s *Service) ReceiveBlockBatch(ctx context.Context, blocks []blocks.ROBlock, avs das.AvailabilityStore) error {
func (s *Service) ReceiveBlockBatch(ctx context.Context, blocks []blocks.ROBlock, avs das.AvailabilityChecker) error {
ctx, span := trace.StartSpan(ctx, "blockChain.ReceiveBlockBatch")
defer span.End()

View File

@@ -14,6 +14,7 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/cache"
statefeed "github.com/OffchainLabs/prysm/v7/beacon-chain/core/feed/state"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/helpers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
coreTime "github.com/OffchainLabs/prysm/v7/beacon-chain/core/time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/transition"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db"
@@ -470,30 +471,35 @@ func (s *Service) removeStartupState() {
// UpdateCustodyInfoInDB updates the custody information in the database.
// It returns the (potentially updated) custody group count and the earliest available slot.
func (s *Service) updateCustodyInfoInDB(slot primitives.Slot) (primitives.Slot, uint64, error) {
isSubscribedToAllDataSubnets := flags.Get().SubscribeAllDataSubnets
isSupernode := flags.Get().Supernode
isSemiSupernode := flags.Get().SemiSupernode
cfg := params.BeaconConfig()
custodyRequirement := cfg.CustodyRequirement
// Check if the node was previously subscribed to all data subnets, and if so,
// store the new status accordingly.
wasSubscribedToAllDataSubnets, err := s.cfg.BeaconDB.UpdateSubscribedToAllDataSubnets(s.ctx, isSubscribedToAllDataSubnets)
wasSupernode, err := s.cfg.BeaconDB.UpdateSubscribedToAllDataSubnets(s.ctx, isSupernode)
if err != nil {
log.WithError(err).Error("Could not update subscription status to all data subnets")
return 0, 0, errors.Wrap(err, "update subscribed to all data subnets")
}
// Warn the user if the node was previously subscribed to all data subnets and is not any more.
if wasSubscribedToAllDataSubnets && !isSubscribedToAllDataSubnets {
log.Warnf(
"Because the flag `--%s` was previously used, the node will still subscribe to all data subnets.",
flags.SubscribeAllDataSubnets.Name,
)
// Compute the target custody group count based on current flag configuration.
targetCustodyGroupCount := custodyRequirement
// Supernode: custody all groups (either currently set or previously enabled)
if isSupernode {
targetCustodyGroupCount = cfg.NumberOfCustodyGroups
}
// Compute the custody group count.
custodyGroupCount := custodyRequirement
if isSubscribedToAllDataSubnets {
custodyGroupCount = cfg.NumberOfCustodyGroups
// Semi-supernode: custody minimum needed for reconstruction, or custody requirement if higher
if isSemiSupernode {
semiSupernodeCustody, err := peerdas.MinimumCustodyGroupCountToReconstruct()
if err != nil {
return 0, 0, errors.Wrap(err, "minimum custody group count")
}
targetCustodyGroupCount = max(custodyRequirement, semiSupernodeCustody)
}
// Safely compute the fulu fork slot.
@@ -510,12 +516,23 @@ func (s *Service) updateCustodyInfoInDB(slot primitives.Slot) (primitives.Slot,
}
}
earliestAvailableSlot, custodyGroupCount, err := s.cfg.BeaconDB.UpdateCustodyInfo(s.ctx, slot, custodyGroupCount)
earliestAvailableSlot, actualCustodyGroupCount, err := s.cfg.BeaconDB.UpdateCustodyInfo(s.ctx, slot, targetCustodyGroupCount)
if err != nil {
return 0, 0, errors.Wrap(err, "update custody info")
}
return earliestAvailableSlot, custodyGroupCount, nil
if isSupernode {
log.WithFields(logrus.Fields{
"current": actualCustodyGroupCount,
"target": cfg.NumberOfCustodyGroups,
}).Info("Supernode mode enabled. Will custody all data columns going forward.")
}
if wasSupernode && !isSupernode {
log.Warningf("Because the `--%s` flag was previously used, the node will continue to act as a super node.", flags.Supernode.Name)
}
return earliestAvailableSlot, actualCustodyGroupCount, nil
}
func spawnCountdownIfPreGenesis(ctx context.Context, genesisTime time.Time, db db.HeadAccessDatabase) {

View File

@@ -642,7 +642,7 @@ func TestUpdateCustodyInfoInDB(t *testing.T) {
resetFlags := flags.Get()
gFlags := new(flags.GlobalFlags)
gFlags.SubscribeAllDataSubnets = true
gFlags.Supernode = true
flags.Init(gFlags)
defer flags.Init(resetFlags)
@@ -680,7 +680,7 @@ func TestUpdateCustodyInfoInDB(t *testing.T) {
// ----------
resetFlags := flags.Get()
gFlags := new(flags.GlobalFlags)
gFlags.SubscribeAllDataSubnets = true
gFlags.Supernode = true
flags.Init(gFlags)
defer flags.Init(resetFlags)
@@ -695,4 +695,121 @@ func TestUpdateCustodyInfoInDB(t *testing.T) {
require.Equal(t, slot, actualEas)
require.Equal(t, numberOfCustodyGroups, actualCgc)
})
t.Run("Supernode downgrade prevented", func(t *testing.T) {
service, requirements := minimalTestService(t)
err = requirements.db.SaveBlock(ctx, roBlock)
require.NoError(t, err)
// Enable supernode
resetFlags := flags.Get()
gFlags := new(flags.GlobalFlags)
gFlags.Supernode = true
flags.Init(gFlags)
slot := fuluForkEpoch*primitives.Slot(cfg.SlotsPerEpoch) + 1
actualEas, actualCgc, err := service.updateCustodyInfoInDB(slot)
require.NoError(t, err)
require.Equal(t, slot, actualEas)
require.Equal(t, numberOfCustodyGroups, actualCgc)
// Try to downgrade by removing flag
gFlags.Supernode = false
flags.Init(gFlags)
defer flags.Init(resetFlags)
// Should still be supernode
actualEas, actualCgc, err = service.updateCustodyInfoInDB(slot + 2)
require.NoError(t, err)
require.Equal(t, slot, actualEas)
require.Equal(t, numberOfCustodyGroups, actualCgc) // Still 64, not downgraded
})
t.Run("Semi-supernode downgrade prevented", func(t *testing.T) {
service, requirements := minimalTestService(t)
err = requirements.db.SaveBlock(ctx, roBlock)
require.NoError(t, err)
// Enable semi-supernode
resetFlags := flags.Get()
gFlags := new(flags.GlobalFlags)
gFlags.SemiSupernode = true
flags.Init(gFlags)
slot := fuluForkEpoch*primitives.Slot(cfg.SlotsPerEpoch) + 1
actualEas, actualCgc, err := service.updateCustodyInfoInDB(slot)
require.NoError(t, err)
require.Equal(t, slot, actualEas)
semiSupernodeCustody := numberOfCustodyGroups / 2 // 64
require.Equal(t, semiSupernodeCustody, actualCgc) // Semi-supernode custodies 64 groups
// Try to downgrade by removing flag
gFlags.SemiSupernode = false
flags.Init(gFlags)
defer flags.Init(resetFlags)
// UpdateCustodyInfo should prevent downgrade - custody count should remain at 64
actualEas, actualCgc, err = service.updateCustodyInfoInDB(slot + 2)
require.NoError(t, err)
require.Equal(t, slot, actualEas)
require.Equal(t, semiSupernodeCustody, actualCgc) // Still 64 due to downgrade prevention by UpdateCustodyInfo
})
t.Run("Semi-supernode to supernode upgrade allowed", func(t *testing.T) {
service, requirements := minimalTestService(t)
err = requirements.db.SaveBlock(ctx, roBlock)
require.NoError(t, err)
// Start with semi-supernode
resetFlags := flags.Get()
gFlags := new(flags.GlobalFlags)
gFlags.SemiSupernode = true
flags.Init(gFlags)
slot := fuluForkEpoch*primitives.Slot(cfg.SlotsPerEpoch) + 1
actualEas, actualCgc, err := service.updateCustodyInfoInDB(slot)
require.NoError(t, err)
require.Equal(t, slot, actualEas)
semiSupernodeCustody := numberOfCustodyGroups / 2 // 64
require.Equal(t, semiSupernodeCustody, actualCgc) // Semi-supernode custodies 64 groups
// Upgrade to full supernode
gFlags.SemiSupernode = false
gFlags.Supernode = true
flags.Init(gFlags)
defer flags.Init(resetFlags)
// Should upgrade to full supernode
upgradeSlot := slot + 2
actualEas, actualCgc, err = service.updateCustodyInfoInDB(upgradeSlot)
require.NoError(t, err)
require.Equal(t, upgradeSlot, actualEas) // Earliest slot updates when upgrading
require.Equal(t, numberOfCustodyGroups, actualCgc) // Upgraded to 128
})
t.Run("Semi-supernode with high validator requirements uses higher custody", func(t *testing.T) {
service, requirements := minimalTestService(t)
err = requirements.db.SaveBlock(ctx, roBlock)
require.NoError(t, err)
// Enable semi-supernode
resetFlags := flags.Get()
gFlags := new(flags.GlobalFlags)
gFlags.SemiSupernode = true
flags.Init(gFlags)
defer flags.Init(resetFlags)
// Mock a high custody requirement (simulating many validators)
// We need to override the custody requirement calculation
// For this test, we'll verify the logic by checking if custodyRequirement > 64
// Since custodyRequirement in minimalTestService is 4, we can't test the high case here
// This would require a different test setup with actual validators
slot := fuluForkEpoch*primitives.Slot(cfg.SlotsPerEpoch) + 1
actualEas, actualCgc, err := service.updateCustodyInfoInDB(slot)
require.NoError(t, err)
require.Equal(t, slot, actualEas)
semiSupernodeCustody := numberOfCustodyGroups / 2 // 64
// With low validator requirements (4), should use semi-supernode minimum (64)
require.Equal(t, semiSupernodeCustody, actualCgc)
})
}

View File

@@ -275,7 +275,7 @@ func (s *ChainService) ReceiveBlockInitialSync(ctx context.Context, block interf
}
// ReceiveBlockBatch processes blocks in batches from initial-sync.
func (s *ChainService) ReceiveBlockBatch(ctx context.Context, blks []blocks.ROBlock, _ das.AvailabilityStore) error {
func (s *ChainService) ReceiveBlockBatch(ctx context.Context, blks []blocks.ROBlock, _ das.AvailabilityChecker) error {
if s.State == nil {
return ErrNilState
}
@@ -305,7 +305,7 @@ func (s *ChainService) ReceiveBlockBatch(ctx context.Context, blks []blocks.ROBl
}
// ReceiveBlock mocks ReceiveBlock method in chain service.
func (s *ChainService) ReceiveBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock, _ [32]byte, _ das.AvailabilityStore) error {
func (s *ChainService) ReceiveBlock(ctx context.Context, block interfaces.ReadOnlySignedBeaconBlock, _ [32]byte, _ das.AvailabilityChecker) error {
if s.ReceiveBlockMockErr != nil {
return s.ReceiveBlockMockErr
}

View File

@@ -90,6 +90,9 @@ func IsExecutionEnabled(st state.ReadOnlyBeaconState, body interfaces.ReadOnlyBe
if st == nil || body == nil {
return false, errors.New("nil state or block body")
}
if st.Version() >= version.Capella {
return true, nil
}
if IsPreBellatrixVersion(st.Version()) {
return false, nil
}

View File

@@ -260,11 +260,12 @@ func Test_IsExecutionBlockCapella(t *testing.T) {
func Test_IsExecutionEnabled(t *testing.T) {
tests := []struct {
name string
payload *enginev1.ExecutionPayload
header interfaces.ExecutionData
useAltairSt bool
want bool
name string
payload *enginev1.ExecutionPayload
header interfaces.ExecutionData
useAltairSt bool
useCapellaSt bool
want bool
}{
{
name: "use older than bellatrix state",
@@ -331,6 +332,17 @@ func Test_IsExecutionEnabled(t *testing.T) {
}(),
want: true,
},
{
name: "capella state always enabled",
payload: emptyPayload(),
header: func() interfaces.ExecutionData {
h, err := emptyPayloadHeader()
require.NoError(t, err)
return h
}(),
useCapellaSt: true,
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -342,6 +354,8 @@ func Test_IsExecutionEnabled(t *testing.T) {
require.NoError(t, err)
if tt.useAltairSt {
st, _ = util.DeterministicGenesisStateAltair(t, 1)
} else if tt.useCapellaSt {
st, _ = util.DeterministicGenesisStateCapella(t, 1)
}
got, err := blocks.IsExecutionEnabled(st, body)
require.NoError(t, err)

View File

@@ -45,12 +45,13 @@ go_test(
"p2p_interface_test.go",
"reconstruction_helpers_test.go",
"reconstruction_test.go",
"semi_supernode_test.go",
"utils_test.go",
"validator_test.go",
"verification_test.go",
],
embed = [":go_default_library"],
deps = [
":go_default_library",
"//beacon-chain/blockchain/kzg:go_default_library",
"//beacon-chain/state/state-native:go_default_library",
"//config/fieldparams:go_default_library",

View File

@@ -2,6 +2,7 @@ package peerdas
import (
"encoding/binary"
"maps"
"sync"
"github.com/ethereum/go-ethereum/p2p/enode"
@@ -107,3 +108,102 @@ func computeInfoCacheKey(nodeID enode.ID, custodyGroupCount uint64) [nodeInfoCac
return key
}
// ColumnIndices represents as a set of ColumnIndices. This could be the set of indices that a node is required to custody,
// the set that a peer custodies, missing indices for a given block, indices that are present on disk, etc.
type ColumnIndices map[uint64]struct{}
// Has returns true if the index is present in the ColumnIndices.
func (ci ColumnIndices) Has(index uint64) bool {
_, ok := ci[index]
return ok
}
// Count returns the number of indices present in the ColumnIndices.
func (ci ColumnIndices) Count() int {
return len(ci)
}
// Set sets the index in the ColumnIndices.
func (ci ColumnIndices) Set(index uint64) {
ci[index] = struct{}{}
}
// Unset removes the index from the ColumnIndices.
func (ci ColumnIndices) Unset(index uint64) {
delete(ci, index)
}
// Copy creates a copy of the ColumnIndices.
func (ci ColumnIndices) Copy() ColumnIndices {
newCi := make(ColumnIndices, len(ci))
maps.Copy(newCi, ci)
return newCi
}
// Intersection returns a new ColumnIndices that contains only the indices that are present in both ColumnIndices.
func (ci ColumnIndices) Intersection(other ColumnIndices) ColumnIndices {
result := make(ColumnIndices)
for index := range ci {
if other.Has(index) {
result.Set(index)
}
}
return result
}
// Merge mutates the receiver so that any index that is set in either of
// the two ColumnIndices is set in the receiver after the function finishes.
// It does not mutate the other ColumnIndices given as a function argument.
func (ci ColumnIndices) Merge(other ColumnIndices) {
for index := range other {
ci.Set(index)
}
}
// ToMap converts a ColumnIndices into a map[uint64]struct{}.
// In the future ColumnIndices may be changed to a bit map, so using
// ToMap will ensure forwards-compatibility.
func (ci ColumnIndices) ToMap() map[uint64]struct{} {
return ci.Copy()
}
// ToSlice converts a ColumnIndices into a slice of uint64 indices.
func (ci ColumnIndices) ToSlice() []uint64 {
indices := make([]uint64, 0, len(ci))
for index := range ci {
indices = append(indices, index)
}
return indices
}
// NewColumnIndicesFromSlice creates a ColumnIndices from a slice of uint64.
func NewColumnIndicesFromSlice(indices []uint64) ColumnIndices {
ci := make(ColumnIndices, len(indices))
for _, index := range indices {
ci[index] = struct{}{}
}
return ci
}
// NewColumnIndicesFromMap creates a ColumnIndices from a map[uint64]bool. This kind of map
// is used in several places in peerdas code. Converting from this map type to ColumnIndices
// will allow us to move ColumnIndices underlying type to a bitmap in the future and avoid
// lots of loops for things like intersections/unions or copies.
func NewColumnIndicesFromMap(indices map[uint64]bool) ColumnIndices {
ci := make(ColumnIndices, len(indices))
for index, set := range indices {
if !set {
continue
}
ci[index] = struct{}{}
}
return ci
}
// NewColumnIndices creates an empty ColumnIndices.
// In the future ColumnIndices may change from a reference type to a value type,
// so using this constructor will ensure forwards-compatibility.
func NewColumnIndices() ColumnIndices {
return make(ColumnIndices)
}

View File

@@ -25,3 +25,10 @@ func TestInfo(t *testing.T) {
require.DeepEqual(t, expectedDataColumnsSubnets, actual.DataColumnsSubnets)
}
}
func TestNewColumnIndicesFromMap(t *testing.T) {
t.Run("nil map", func(t *testing.T) {
ci := peerdas.NewColumnIndicesFromMap(nil)
require.Equal(t, 0, ci.Count())
})
}

View File

@@ -29,6 +29,38 @@ func MinimumColumnCountToReconstruct() uint64 {
return (params.BeaconConfig().NumberOfColumns + 1) / 2
}
// MinimumCustodyGroupCountToReconstruct returns the minimum number of custody groups needed to
// custody enough data columns for reconstruction. This accounts for the relationship between
// custody groups and columns, making it future-proof if these values change.
// Returns an error if the configuration values are invalid (zero or would cause division by zero).
func MinimumCustodyGroupCountToReconstruct() (uint64, error) {
cfg := params.BeaconConfig()
// Validate configuration values
if cfg.NumberOfColumns == 0 {
return 0, errors.New("NumberOfColumns cannot be zero")
}
if cfg.NumberOfCustodyGroups == 0 {
return 0, errors.New("NumberOfCustodyGroups cannot be zero")
}
minimumColumnCount := MinimumColumnCountToReconstruct()
// Calculate how many columns each custody group represents
columnsPerGroup := cfg.NumberOfColumns / cfg.NumberOfCustodyGroups
// If there are more groups than columns (columnsPerGroup = 0), this is an invalid configuration
// for reconstruction purposes as we cannot determine a meaningful custody group count
if columnsPerGroup == 0 {
return 0, errors.Errorf("invalid configuration: NumberOfCustodyGroups (%d) exceeds NumberOfColumns (%d)",
cfg.NumberOfCustodyGroups, cfg.NumberOfColumns)
}
// Use ceiling division to ensure we have enough groups to cover the minimum columns
// ceiling(a/b) = (a + b - 1) / b
return (minimumColumnCount + columnsPerGroup - 1) / columnsPerGroup, nil
}
// recoverCellsForBlobs reconstructs cells for specified blobs from the given data column sidecars.
// This is optimized to only recover cells without computing proofs.
// Returns a map from blob index to recovered cells.

View File

@@ -0,0 +1,160 @@
package peerdas
import (
"testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/ethereum/go-ethereum/p2p/enode"
)
func TestSemiSupernodeCustody(t *testing.T) {
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig()
cfg.NumberOfCustodyGroups = 128
cfg.NumberOfColumns = 128
params.OverrideBeaconConfig(cfg)
// Create a test node ID
nodeID := enode.ID([32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32})
t.Run("semi-supernode custodies exactly 64 columns", func(t *testing.T) {
// Semi-supernode uses 64 custody groups (half of 128)
const semiSupernodeCustodyGroupCount = 64
// Get custody groups for semi-supernode
custodyGroups, err := CustodyGroups(nodeID, semiSupernodeCustodyGroupCount)
require.NoError(t, err)
require.Equal(t, semiSupernodeCustodyGroupCount, len(custodyGroups))
// Verify we get exactly 64 custody columns
custodyColumns, err := CustodyColumns(custodyGroups)
require.NoError(t, err)
require.Equal(t, semiSupernodeCustodyGroupCount, len(custodyColumns))
// Verify the columns are valid (within 0-127 range)
for columnIndex := range custodyColumns {
if columnIndex >= cfg.NumberOfColumns {
t.Fatalf("Invalid column index %d, should be less than %d", columnIndex, cfg.NumberOfColumns)
}
}
})
t.Run("64 columns is exactly the minimum for reconstruction", func(t *testing.T) {
minimumCount := MinimumColumnCountToReconstruct()
require.Equal(t, uint64(64), minimumCount)
})
t.Run("semi-supernode vs supernode custody", func(t *testing.T) {
// Semi-supernode (64 custody groups)
semiSupernodeGroups, err := CustodyGroups(nodeID, 64)
require.NoError(t, err)
semiSupernodeColumns, err := CustodyColumns(semiSupernodeGroups)
require.NoError(t, err)
// Supernode (128 custody groups = all groups)
supernodeGroups, err := CustodyGroups(nodeID, 128)
require.NoError(t, err)
supernodeColumns, err := CustodyColumns(supernodeGroups)
require.NoError(t, err)
// Verify semi-supernode has exactly half the columns of supernode
require.Equal(t, 64, len(semiSupernodeColumns))
require.Equal(t, 128, len(supernodeColumns))
require.Equal(t, len(supernodeColumns)/2, len(semiSupernodeColumns))
// Verify all semi-supernode columns are a subset of supernode columns
for columnIndex := range semiSupernodeColumns {
if !supernodeColumns[columnIndex] {
t.Fatalf("Semi-supernode column %d not found in supernode columns", columnIndex)
}
}
})
}
func TestMinimumCustodyGroupCountToReconstruct(t *testing.T) {
tests := []struct {
name string
numberOfColumns uint64
numberOfGroups uint64
expectedResult uint64
}{
{
name: "Standard 1:1 ratio (128 columns, 128 groups)",
numberOfColumns: 128,
numberOfGroups: 128,
expectedResult: 64, // Need half of 128 groups
},
{
name: "2 columns per group (128 columns, 64 groups)",
numberOfColumns: 128,
numberOfGroups: 64,
expectedResult: 32, // Need 64 columns, which is 32 groups (64/2)
},
{
name: "4 columns per group (128 columns, 32 groups)",
numberOfColumns: 128,
numberOfGroups: 32,
expectedResult: 16, // Need 64 columns, which is 16 groups (64/4)
},
{
name: "Odd number requiring ceiling division (100 columns, 30 groups)",
numberOfColumns: 100,
numberOfGroups: 30,
expectedResult: 17, // Need 50 columns, 3 columns per group (100/30), ceiling(50/3) = 17
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig()
cfg.NumberOfColumns = tt.numberOfColumns
cfg.NumberOfCustodyGroups = tt.numberOfGroups
params.OverrideBeaconConfig(cfg)
result, err := MinimumCustodyGroupCountToReconstruct()
require.NoError(t, err)
require.Equal(t, tt.expectedResult, result)
})
}
}
func TestMinimumCustodyGroupCountToReconstruct_ErrorCases(t *testing.T) {
t.Run("Returns error when NumberOfColumns is zero", func(t *testing.T) {
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig()
cfg.NumberOfColumns = 0
cfg.NumberOfCustodyGroups = 128
params.OverrideBeaconConfig(cfg)
_, err := MinimumCustodyGroupCountToReconstruct()
require.NotNil(t, err)
require.Equal(t, true, err.Error() == "NumberOfColumns cannot be zero")
})
t.Run("Returns error when NumberOfCustodyGroups is zero", func(t *testing.T) {
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig()
cfg.NumberOfColumns = 128
cfg.NumberOfCustodyGroups = 0
params.OverrideBeaconConfig(cfg)
_, err := MinimumCustodyGroupCountToReconstruct()
require.NotNil(t, err)
require.Equal(t, true, err.Error() == "NumberOfCustodyGroups cannot be zero")
})
t.Run("Returns error when NumberOfCustodyGroups exceeds NumberOfColumns", func(t *testing.T) {
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig()
cfg.NumberOfColumns = 128
cfg.NumberOfCustodyGroups = 256
params.OverrideBeaconConfig(cfg)
_, err := MinimumCustodyGroupCountToReconstruct()
require.NotNil(t, err)
// Just check that we got an error about the configuration
require.Equal(t, true, len(err.Error()) > 0)
})
}

View File

@@ -4,14 +4,18 @@ go_library(
name = "go_default_library",
srcs = [
"availability_blobs.go",
"availability_columns.go",
"bisect.go",
"blob_cache.go",
"data_column_cache.go",
"iface.go",
"log.go",
"mock.go",
],
importpath = "github.com/OffchainLabs/prysm/v7/beacon-chain/das",
visibility = ["//visibility:public"],
deps = [
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/db/filesystem:go_default_library",
"//beacon-chain/verification:go_default_library",
"//config/fieldparams:go_default_library",
@@ -21,6 +25,7 @@ go_library(
"//runtime/logging:go_default_library",
"//runtime/version:go_default_library",
"//time/slots:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enode:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
],
@@ -30,11 +35,13 @@ go_test(
name = "go_default_test",
srcs = [
"availability_blobs_test.go",
"availability_columns_test.go",
"blob_cache_test.go",
"data_column_cache_test.go",
],
embed = [":go_default_library"],
deps = [
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/db/filesystem:go_default_library",
"//beacon-chain/verification:go_default_library",
"//config/fieldparams:go_default_library",
@@ -45,6 +52,7 @@ go_test(
"//testing/require:go_default_library",
"//testing/util:go_default_library",
"//time/slots:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enode:go_default_library",
"@com_github_pkg_errors//:go_default_library",
],
)

View File

@@ -13,7 +13,7 @@ import (
"github.com/OffchainLabs/prysm/v7/runtime/version"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
)
var (
@@ -29,7 +29,7 @@ type LazilyPersistentStoreBlob struct {
verifier BlobBatchVerifier
}
var _ AvailabilityStore = &LazilyPersistentStoreBlob{}
var _ AvailabilityChecker = &LazilyPersistentStoreBlob{}
// BlobBatchVerifier enables LazyAvailabilityStore to manage the verification process
// going from ROBlob->VerifiedROBlob, while avoiding the decision of which individual verifications
@@ -81,7 +81,16 @@ func (s *LazilyPersistentStoreBlob) Persist(current primitives.Slot, sidecars ..
// IsDataAvailable returns nil if all the commitments in the given block are persisted to the db and have been verified.
// BlobSidecars already in the db are assumed to have been previously verified against the block.
func (s *LazilyPersistentStoreBlob) IsDataAvailable(ctx context.Context, current primitives.Slot, b blocks.ROBlock) error {
func (s *LazilyPersistentStoreBlob) IsDataAvailable(ctx context.Context, current primitives.Slot, blks ...blocks.ROBlock) error {
for _, b := range blks {
if err := s.checkOne(ctx, current, b); err != nil {
return err
}
}
return nil
}
func (s *LazilyPersistentStoreBlob) checkOne(ctx context.Context, current primitives.Slot, b blocks.ROBlock) error {
blockCommitments, err := commitmentsToCheck(b, current)
if err != nil {
return errors.Wrapf(err, "could not check data availability for block %#x", b.Root())
@@ -100,7 +109,7 @@ func (s *LazilyPersistentStoreBlob) IsDataAvailable(ctx context.Context, current
// Verify we have all the expected sidecars, and fail fast if any are missing or inconsistent.
// We don't try to salvage problematic batches because this indicates a misbehaving peer and we'd rather
// ignore their response and decrease their peer score.
sidecars, err := entry.filter(root, blockCommitments, b.Block().Slot())
sidecars, err := entry.filter(root, blockCommitments)
if err != nil {
return errors.Wrap(err, "incomplete BlobSidecar batch")
}
@@ -112,7 +121,7 @@ func (s *LazilyPersistentStoreBlob) IsDataAvailable(ctx context.Context, current
ok := errors.As(err, &me)
if ok {
fails := me.Failures()
lf := make(log.Fields, len(fails))
lf := make(logrus.Fields, len(fails))
for i := range fails {
lf[fmt.Sprintf("fail_%d", i)] = fails[i].Error()
}

View File

@@ -170,7 +170,7 @@ func TestLazyPersistOnceCommitted(t *testing.T) {
// stashes as expected
require.NoError(t, as.Persist(ds, blobSidecars...))
// ignores duplicates
require.ErrorIs(t, as.Persist(ds, blobSidecars...), ErrDuplicateSidecar)
require.ErrorIs(t, as.Persist(ds, blobSidecars...), errDuplicateSidecar)
// ignores index out of bound
blobSidecars[0].Index = 6

View File

@@ -0,0 +1,245 @@
package das
import (
"context"
"io"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
"github.com/OffchainLabs/prysm/v7/beacon-chain/verification"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/ethereum/go-ethereum/p2p/enode"
errors "github.com/pkg/errors"
)
// LazilyPersistentStoreColumn is an implementation of AvailabilityStore to be used when batch syncing data columns.
// This implementation will hold any data columns passed to Persist until the IsDataAvailable is called for their
// block, at which time they will undergo full verification and be saved to the disk.
type LazilyPersistentStoreColumn struct {
store *filesystem.DataColumnStorage
cache *dataColumnCache
newDataColumnsVerifier verification.NewDataColumnsVerifier
custody *custodyRequirement
bisector Bisector
}
var _ AvailabilityChecker = &LazilyPersistentStoreColumn{}
// DataColumnsVerifier enables LazilyPersistentStoreColumn to manage the verification process
// going from RODataColumn->VerifiedRODataColumn, while avoiding the decision of which individual verifications
// to run and in what order. Since LazilyPersistentStoreColumn always tries to verify and save data columns only when
// they are all available, the interface takes a slice of data column sidecars.
type DataColumnsVerifier interface {
VerifiedRODataColumns(ctx context.Context, blk blocks.ROBlock, scs []blocks.RODataColumn) ([]blocks.VerifiedRODataColumn, error)
}
// NewLazilyPersistentStoreColumn creates a new LazilyPersistentStoreColumn.
// WARNING: The resulting LazilyPersistentStoreColumn is NOT thread-safe.
func NewLazilyPersistentStoreColumn(
store *filesystem.DataColumnStorage,
newDataColumnsVerifier verification.NewDataColumnsVerifier,
nodeID enode.ID,
cgc uint64,
bisector Bisector,
) *LazilyPersistentStoreColumn {
return &LazilyPersistentStoreColumn{
store: store,
cache: newDataColumnCache(),
newDataColumnsVerifier: newDataColumnsVerifier,
custody: &custodyRequirement{nodeID: nodeID, cgc: cgc},
bisector: bisector,
}
}
// PersistColumns adds columns to the working column cache. Columns stored in this cache will be persisted
// for at least as long as the node is running. Once IsDataAvailable succeeds, all columns referenced
// by the given block are guaranteed to be persisted for the remainder of the retention period.
func (s *LazilyPersistentStoreColumn) Persist(current primitives.Slot, sidecars ...blocks.RODataColumn) error {
currentEpoch := slots.ToEpoch(current)
for _, sidecar := range sidecars {
if !params.WithinDAPeriod(slots.ToEpoch(sidecar.Slot()), currentEpoch) {
continue
}
if err := s.cache.stash(sidecar); err != nil {
return errors.Wrap(err, "stash DataColumnSidecar")
}
}
return nil
}
// IsDataAvailable returns nil if all the commitments in the given block are persisted to the db and have been verified.
// DataColumnsSidecars already in the db are assumed to have been previously verified against the block.
func (s *LazilyPersistentStoreColumn) IsDataAvailable(ctx context.Context, current primitives.Slot, blks ...blocks.ROBlock) error {
currentEpoch := slots.ToEpoch(current)
toVerify := make([]blocks.RODataColumn, 0)
for _, block := range blks {
indices, err := s.required(block, currentEpoch)
if err != nil {
return errors.Wrapf(err, "full commitments to check with block root `%#x` and current slot `%d`", block.Root(), current)
}
if indices.Count() == 0 {
continue
}
key := keyFromBlock(block)
entry := s.cache.entry(key)
toVerify, err = entry.append(toVerify, IndicesNotStored(s.store.Summary(block.Root()), indices))
if err != nil {
return errors.Wrap(err, "entry filter")
}
}
if err := s.verifyAndSave(toVerify); err != nil {
log.Warn("Batch verification failed, bisecting columns by peer")
if err := s.bisectVerification(toVerify); err != nil {
return errors.Wrap(err, "bisect verification")
}
}
s.cache.cleanup(blks)
return nil
}
// required returns the set of column indices to check for a given block.
func (s *LazilyPersistentStoreColumn) required(block blocks.ROBlock, current primitives.Epoch) (peerdas.ColumnIndices, error) {
eBlk := slots.ToEpoch(block.Block().Slot())
eFulu := params.BeaconConfig().FuluForkEpoch
if current < eFulu || eBlk < eFulu || !params.WithinDAPeriod(eBlk, current) {
return peerdas.NewColumnIndices(), nil
}
// If there are any commitments in the block, there are blobs,
// and if there are blobs, we need the columns bisecting those blobs.
commitments, err := block.Block().Body().BlobKzgCommitments()
if err != nil {
return nil, errors.Wrap(err, "blob KZG commitments")
}
// No DA check needed if the block has no blobs.
if len(commitments) == 0 {
return peerdas.NewColumnIndices(), nil
}
return s.custody.required(current)
}
// verifyAndSave calls Save on the column store if the columns pass verification.
func (s *LazilyPersistentStoreColumn) verifyAndSave(columns []blocks.RODataColumn) error {
verified, err := s.verifyColumns(columns)
if err != nil {
return errors.Wrap(err, "verify columns")
}
if err := s.store.Save(verified); err != nil {
return errors.Wrap(err, "save data column sidecars")
}
return nil
}
func (s *LazilyPersistentStoreColumn) verifyColumns(columns []blocks.RODataColumn) ([]blocks.VerifiedRODataColumn, error) {
verifier := s.newDataColumnsVerifier(columns, verification.ByRangeRequestDataColumnSidecarRequirements)
if err := verifier.ValidFields(); err != nil {
return nil, errors.Wrap(err, "valid fields")
}
if err := verifier.SidecarInclusionProven(); err != nil {
return nil, errors.Wrap(err, "sidecar inclusion proven")
}
if err := verifier.SidecarKzgProofVerified(); err != nil {
return nil, errors.Wrap(err, "sidecar KZG proof verified")
}
return verifier.VerifiedRODataColumns()
}
// bisectVerification is used when verification of a batch of columns fails. Since the batch could
// span multiple blocks or have been fetched from multiple peers, this pattern enables code using the
// store to break the verification into smaller units and learn the results, in order to plan to retry
// retrieval of the unusable columns.
func (s *LazilyPersistentStoreColumn) bisectVerification(columns []blocks.RODataColumn) error {
if len(columns) == 0 {
return nil
}
if s.bisector == nil {
return errors.New("bisector not initialized")
}
iter, err := s.bisector.Bisect(columns)
if err != nil {
return errors.Wrap(err, "Bisector.Bisect")
}
// It's up to the bisector how to chunk up columns for verification,
// which could be by block, or by peer, or any other strategy.
// For the purposes of range syncing or backfill this will be by peer,
// so that the node can learn which peer is giving us bad data and downscore them.
for columns, err := iter.Next(); columns != nil; columns, err = iter.Next() {
if err != nil {
if !errors.Is(err, io.EOF) {
return errors.Wrap(err, "Bisector.Next")
}
break // io.EOF signals end of iteration
}
// We save the parts of the batch that have been verified successfully even though we don't know
// if all columns for the block will be available until the block is imported.
if err := s.verifyAndSave(s.columnsNotStored(columns)); err != nil {
iter.OnError(err)
continue
}
}
// This should give us a single error representing any unresolved errors seen via onError.
return iter.Error()
}
// columnsNotStored filters the list of ROColumnSidecars to only include those that are not found in the storage summary.
func (s *LazilyPersistentStoreColumn) columnsNotStored(sidecars []blocks.RODataColumn) []blocks.RODataColumn {
// We use this method to filter a set of sidecars that were previously seen to be unavailable on disk. So our base assumption
// is that they are still available and we don't need to copy the list. Instead we make a slice of any indices that are unexpectedly
// stored and only when we find that the storage view has changed do we need to create a new slice.
stored := make(map[int]struct{}, 0)
lastRoot := [32]byte{}
var sum filesystem.DataColumnStorageSummary
for i, sc := range sidecars {
if sc.BlockRoot() != lastRoot {
sum = s.store.Summary(sc.BlockRoot())
lastRoot = sc.BlockRoot()
}
if sum.HasIndex(sc.Index) {
stored[i] = struct{}{}
}
}
// If the view on storage hasn't changed, return the original list.
if len(stored) == 0 {
return sidecars
}
shift := 0
for i := range sidecars {
if _, ok := stored[i]; ok {
// If the index is stored, skip and overwrite it.
// Track how many spaces down to shift unseen sidecars (to overwrite the previously shifted or seen).
shift++
continue
}
if shift > 0 {
// If the index is not stored and we have seen stored indices,
// we need to shift the current index down.
sidecars[i-shift] = sidecars[i]
}
}
return sidecars[:len(sidecars)-shift]
}
type custodyRequirement struct {
nodeID enode.ID
cgc uint64 // custody group count
indices peerdas.ColumnIndices
}
func (c *custodyRequirement) required(current primitives.Epoch) (peerdas.ColumnIndices, error) {
peerInfo, _, err := peerdas.Info(c.nodeID, c.cgc)
if err != nil {
return peerdas.NewColumnIndices(), errors.Wrap(err, "peer info")
}
return peerdas.NewColumnIndicesFromMap(peerInfo.CustodyColumns), nil
}

View File

@@ -0,0 +1,898 @@
package das
import (
"context"
"fmt"
"io"
"testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
"github.com/OffchainLabs/prysm/v7/beacon-chain/verification"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/pkg/errors"
)
var commitments = [][]byte{
bytesutil.PadTo([]byte("a"), 48),
bytesutil.PadTo([]byte("b"), 48),
bytesutil.PadTo([]byte("c"), 48),
bytesutil.PadTo([]byte("d"), 48),
}
func TestPersist(t *testing.T) {
t.Run("no sidecars", func(t *testing.T) {
dataColumnStorage := filesystem.NewEphemeralDataColumnStorage(t)
lazilyPersistentStoreColumns := NewLazilyPersistentStoreColumn(dataColumnStorage, nil, enode.ID{}, 0, nil)
err := lazilyPersistentStoreColumns.Persist(0)
require.NoError(t, err)
require.Equal(t, 0, len(lazilyPersistentStoreColumns.cache.entries))
})
t.Run("outside DA period", func(t *testing.T) {
dataColumnStorage := filesystem.NewEphemeralDataColumnStorage(t)
dataColumnParamsByBlockRoot := []util.DataColumnParam{
{Slot: 1, Index: 1},
}
roSidecars, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, dataColumnParamsByBlockRoot)
lazilyPersistentStoreColumns := NewLazilyPersistentStoreColumn(dataColumnStorage, nil, enode.ID{}, 0, nil)
err := lazilyPersistentStoreColumns.Persist(1_000_000, roSidecars...)
require.NoError(t, err)
require.Equal(t, 0, len(lazilyPersistentStoreColumns.cache.entries))
})
t.Run("nominal", func(t *testing.T) {
const slot = 42
store := filesystem.NewEphemeralDataColumnStorage(t)
dataColumnParamsByBlockRoot := []util.DataColumnParam{
{Slot: slot, Index: 1},
{Slot: slot, Index: 5},
}
roSidecars, roDataColumns := util.CreateTestVerifiedRoDataColumnSidecars(t, dataColumnParamsByBlockRoot)
avs := NewLazilyPersistentStoreColumn(store, nil, enode.ID{}, 0, nil)
err := avs.Persist(slot, roSidecars...)
require.NoError(t, err)
require.Equal(t, 1, len(avs.cache.entries))
key := cacheKey{slot: slot, root: roDataColumns[0].BlockRoot()}
entry, ok := avs.cache.entries[key]
require.Equal(t, true, ok)
summary := store.Summary(key.root)
// A call to Persist does NOT save the sidecars to disk.
require.Equal(t, uint64(0), summary.Count())
require.Equal(t, len(roSidecars), len(entry.scs))
idx1 := entry.scs[1]
require.NotNil(t, idx1)
require.DeepSSZEqual(t, roDataColumns[0].BlockRoot(), idx1.BlockRoot())
idx5 := entry.scs[5]
require.NotNil(t, idx5)
require.DeepSSZEqual(t, roDataColumns[1].BlockRoot(), idx5.BlockRoot())
for i, roDataColumn := range entry.scs {
if map[uint64]bool{1: true, 5: true}[i] {
continue
}
require.IsNil(t, roDataColumn)
}
})
}
func TestIsDataAvailable(t *testing.T) {
params.SetupTestConfigCleanup(t)
params.BeaconConfig().FuluForkEpoch = params.BeaconConfig().ElectraForkEpoch + 4096*2
newDataColumnsVerifier := func(dataColumnSidecars []blocks.RODataColumn, _ []verification.Requirement) verification.DataColumnsVerifier {
return &mockDataColumnsVerifier{t: t, dataColumnSidecars: dataColumnSidecars}
}
ctx := t.Context()
t.Run("without commitments", func(t *testing.T) {
signedBeaconBlockFulu := util.NewBeaconBlockFulu()
signedRoBlock := newSignedRoBlock(t, signedBeaconBlockFulu)
dataColumnStorage := filesystem.NewEphemeralDataColumnStorage(t)
lazilyPersistentStoreColumns := NewLazilyPersistentStoreColumn(dataColumnStorage, newDataColumnsVerifier, enode.ID{}, 0, nil)
err := lazilyPersistentStoreColumns.IsDataAvailable(ctx, 0 /*current slot*/, signedRoBlock)
require.NoError(t, err)
})
t.Run("with commitments", func(t *testing.T) {
signedBeaconBlockFulu := util.NewBeaconBlockFulu()
signedBeaconBlockFulu.Block.Slot = primitives.Slot(params.BeaconConfig().FuluForkEpoch) * params.BeaconConfig().SlotsPerEpoch
signedBeaconBlockFulu.Block.Body.BlobKzgCommitments = commitments
signedRoBlock := newSignedRoBlock(t, signedBeaconBlockFulu)
block := signedRoBlock.Block()
slot := block.Slot()
proposerIndex := block.ProposerIndex()
parentRoot := block.ParentRoot()
stateRoot := block.StateRoot()
bodyRoot, err := block.Body().HashTreeRoot()
require.NoError(t, err)
root := signedRoBlock.Root()
storage := filesystem.NewEphemeralDataColumnStorage(t)
indices := []uint64{1, 17, 19, 42, 75, 87, 102, 117}
avs := NewLazilyPersistentStoreColumn(storage, newDataColumnsVerifier, enode.ID{}, uint64(len(indices)), nil)
dcparams := make([]util.DataColumnParam, 0, len(indices))
for _, index := range indices {
dataColumnParams := util.DataColumnParam{
Index: index,
KzgCommitments: commitments,
Slot: slot,
ProposerIndex: proposerIndex,
ParentRoot: parentRoot[:],
StateRoot: stateRoot[:],
BodyRoot: bodyRoot[:],
}
dcparams = append(dcparams, dataColumnParams)
}
_, verifiedRoDataColumns := util.CreateTestVerifiedRoDataColumnSidecars(t, dcparams)
key := keyFromBlock(signedRoBlock)
entry := avs.cache.entry(key)
defer avs.cache.delete(key)
for _, verifiedRoDataColumn := range verifiedRoDataColumns {
err := entry.stash(verifiedRoDataColumn.RODataColumn)
require.NoError(t, err)
}
err = avs.IsDataAvailable(ctx, slot, signedRoBlock)
require.NoError(t, err)
actual, err := storage.Get(root, indices)
require.NoError(t, err)
//summary := storage.Summary(root)
require.Equal(t, len(verifiedRoDataColumns), len(actual))
//require.Equal(t, uint64(len(indices)), summary.Count())
//require.DeepSSZEqual(t, verifiedRoDataColumns, actual)
})
}
func TestFullCommitmentsToCheck(t *testing.T) {
windowSlots, err := slots.EpochEnd(params.BeaconConfig().MinEpochsForDataColumnSidecarsRequest)
require.NoError(t, err)
testCases := []struct {
name string
commitments [][]byte
block func(*testing.T) blocks.ROBlock
slot primitives.Slot
}{
{
name: "Pre-Fulu block",
block: func(t *testing.T) blocks.ROBlock {
return newSignedRoBlock(t, util.NewBeaconBlockElectra())
},
},
{
name: "Commitments outside data availability window",
block: func(t *testing.T) blocks.ROBlock {
beaconBlockElectra := util.NewBeaconBlockElectra()
// Block is from slot 0, "current slot" is window size +1 (so outside the window)
beaconBlockElectra.Block.Body.BlobKzgCommitments = commitments
return newSignedRoBlock(t, beaconBlockElectra)
},
slot: windowSlots + 1,
},
{
name: "Commitments within data availability window",
block: func(t *testing.T) blocks.ROBlock {
signedBeaconBlockFulu := util.NewBeaconBlockFulu()
signedBeaconBlockFulu.Block.Body.BlobKzgCommitments = commitments
signedBeaconBlockFulu.Block.Slot = 100
return newSignedRoBlock(t, signedBeaconBlockFulu)
},
commitments: commitments,
slot: 100,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
numberOfColumns := params.BeaconConfig().NumberOfColumns
b := tc.block(t)
s := NewLazilyPersistentStoreColumn(nil, nil, enode.ID{}, numberOfColumns, nil)
commitmentsArray, err := s.required(b, slots.ToEpoch(tc.slot))
require.NoError(t, err)
for _, commitments := range commitmentsArray {
require.DeepEqual(t, tc.commitments, commitments)
}
})
}
}
func newSignedRoBlock(t *testing.T, signedBeaconBlock any) blocks.ROBlock {
sb, err := blocks.NewSignedBeaconBlock(signedBeaconBlock)
require.NoError(t, err)
rb, err := blocks.NewROBlock(sb)
require.NoError(t, err)
return rb
}
type mockDataColumnsVerifier struct {
t *testing.T
dataColumnSidecars []blocks.RODataColumn
validCalled, SidecarInclusionProvenCalled, SidecarKzgProofVerifiedCalled bool
}
var _ verification.DataColumnsVerifier = &mockDataColumnsVerifier{}
func (m *mockDataColumnsVerifier) VerifiedRODataColumns() ([]blocks.VerifiedRODataColumn, error) {
require.Equal(m.t, true, m.validCalled && m.SidecarInclusionProvenCalled && m.SidecarKzgProofVerifiedCalled)
verifiedDataColumnSidecars := make([]blocks.VerifiedRODataColumn, 0, len(m.dataColumnSidecars))
for _, dataColumnSidecar := range m.dataColumnSidecars {
verifiedDataColumnSidecar := blocks.NewVerifiedRODataColumn(dataColumnSidecar)
verifiedDataColumnSidecars = append(verifiedDataColumnSidecars, verifiedDataColumnSidecar)
}
return verifiedDataColumnSidecars, nil
}
func (m *mockDataColumnsVerifier) SatisfyRequirement(verification.Requirement) {}
func (m *mockDataColumnsVerifier) ValidFields() error {
m.validCalled = true
return nil
}
func (m *mockDataColumnsVerifier) CorrectSubnet(dataColumnSidecarSubTopic string, expectedTopics []string) error {
return nil
}
func (m *mockDataColumnsVerifier) NotFromFutureSlot() error { return nil }
func (m *mockDataColumnsVerifier) SlotAboveFinalized() error { return nil }
func (m *mockDataColumnsVerifier) ValidProposerSignature(ctx context.Context) error { return nil }
func (m *mockDataColumnsVerifier) SidecarParentSeen(parentSeen func([fieldparams.RootLength]byte) bool) error {
return nil
}
func (m *mockDataColumnsVerifier) SidecarParentValid(badParent func([fieldparams.RootLength]byte) bool) error {
return nil
}
func (m *mockDataColumnsVerifier) SidecarParentSlotLower() error { return nil }
func (m *mockDataColumnsVerifier) SidecarDescendsFromFinalized() error { return nil }
func (m *mockDataColumnsVerifier) SidecarInclusionProven() error {
m.SidecarInclusionProvenCalled = true
return nil
}
func (m *mockDataColumnsVerifier) SidecarKzgProofVerified() error {
m.SidecarKzgProofVerifiedCalled = true
return nil
}
func (m *mockDataColumnsVerifier) SidecarProposerExpected(ctx context.Context) error { return nil }
// Mock implementations for bisectVerification tests
// mockBisectionIterator simulates a BisectionIterator for testing.
type mockBisectionIterator struct {
chunks [][]blocks.RODataColumn
chunkErrors []error
finalError error
chunkIndex int
nextCallCount int
onErrorCallCount int
onErrorErrors []error
}
func (m *mockBisectionIterator) Next() ([]blocks.RODataColumn, error) {
if m.chunkIndex >= len(m.chunks) {
return nil, io.EOF
}
chunk := m.chunks[m.chunkIndex]
var err error
if m.chunkIndex < len(m.chunkErrors) {
err = m.chunkErrors[m.chunkIndex]
}
m.chunkIndex++
m.nextCallCount++
if err != nil {
return chunk, err
}
return chunk, nil
}
func (m *mockBisectionIterator) OnError(err error) {
m.onErrorCallCount++
m.onErrorErrors = append(m.onErrorErrors, err)
}
func (m *mockBisectionIterator) Error() error {
return m.finalError
}
// mockBisector simulates a Bisector for testing.
type mockBisector struct {
shouldError bool
bisectErr error
iterator *mockBisectionIterator
}
func (m *mockBisector) Bisect(columns []blocks.RODataColumn) (BisectionIterator, error) {
if m.shouldError {
return nil, m.bisectErr
}
return m.iterator, nil
}
// testDataColumnsVerifier implements verification.DataColumnsVerifier for testing.
type testDataColumnsVerifier struct {
t *testing.T
shouldFail bool
columns []blocks.RODataColumn
}
func (v *testDataColumnsVerifier) VerifiedRODataColumns() ([]blocks.VerifiedRODataColumn, error) {
verified := make([]blocks.VerifiedRODataColumn, len(v.columns))
for i, col := range v.columns {
verified[i] = blocks.NewVerifiedRODataColumn(col)
}
return verified, nil
}
func (v *testDataColumnsVerifier) SatisfyRequirement(verification.Requirement) {}
func (v *testDataColumnsVerifier) ValidFields() error {
if v.shouldFail {
return errors.New("verification failed")
}
return nil
}
func (v *testDataColumnsVerifier) CorrectSubnet(string, []string) error { return nil }
func (v *testDataColumnsVerifier) NotFromFutureSlot() error { return nil }
func (v *testDataColumnsVerifier) SlotAboveFinalized() error { return nil }
func (v *testDataColumnsVerifier) ValidProposerSignature(context.Context) error { return nil }
func (v *testDataColumnsVerifier) SidecarParentSeen(func([fieldparams.RootLength]byte) bool) error {
return nil
}
func (v *testDataColumnsVerifier) SidecarParentValid(func([fieldparams.RootLength]byte) bool) error {
return nil
}
func (v *testDataColumnsVerifier) SidecarParentSlotLower() error { return nil }
func (v *testDataColumnsVerifier) SidecarDescendsFromFinalized() error { return nil }
func (v *testDataColumnsVerifier) SidecarInclusionProven() error { return nil }
func (v *testDataColumnsVerifier) SidecarKzgProofVerified() error { return nil }
func (v *testDataColumnsVerifier) SidecarProposerExpected(context.Context) error { return nil }
// Helper function to create test data columns
func makeTestDataColumns(t *testing.T, count int, blockRoot [32]byte, startIndex uint64) []blocks.RODataColumn {
columns := make([]blocks.RODataColumn, 0, count)
for i := range count {
params := util.DataColumnParam{
Index: startIndex + uint64(i),
KzgCommitments: commitments,
Slot: primitives.Slot(params.BeaconConfig().FuluForkEpoch) * params.BeaconConfig().SlotsPerEpoch,
}
_, verifiedCols := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params})
if len(verifiedCols) > 0 {
columns = append(columns, verifiedCols[0].RODataColumn)
}
}
return columns
}
// Helper function to create test verifier factory with failure pattern
func makeTestVerifierFactory(failurePattern []bool) verification.NewDataColumnsVerifier {
callIndex := 0
return func(cols []blocks.RODataColumn, _ []verification.Requirement) verification.DataColumnsVerifier {
shouldFail := callIndex < len(failurePattern) && failurePattern[callIndex]
callIndex++
return &testDataColumnsVerifier{
shouldFail: shouldFail,
columns: cols,
}
}
}
// TestBisectVerification tests the bisectVerification method with comprehensive table-driven test cases.
func TestBisectVerification(t *testing.T) {
params.SetupTestConfigCleanup(t)
params.BeaconConfig().FuluForkEpoch = params.BeaconConfig().ElectraForkEpoch + 4096*2
cases := []struct {
expectedError bool
bisectorNil bool
expectedOnErrorCallCount int
expectedNextCallCount int
inputCount int
iteratorFinalError error
bisectorError error
name string
storedColumnIndices []uint64
verificationFailurePattern []bool
chunkErrors []error
chunks [][]blocks.RODataColumn
}{
{
name: "EmptyColumns",
inputCount: 0,
expectedError: false,
expectedNextCallCount: 0,
expectedOnErrorCallCount: 0,
},
{
name: "NilBisector",
inputCount: 3,
bisectorNil: true,
expectedError: true,
expectedNextCallCount: 0,
expectedOnErrorCallCount: 0,
},
{
name: "BisectError",
inputCount: 5,
bisectorError: errors.New("bisect failed"),
expectedError: true,
expectedNextCallCount: 0,
expectedOnErrorCallCount: 0,
},
{
name: "SingleChunkSuccess",
inputCount: 4,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "SingleChunkFails",
inputCount: 4,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{true},
iteratorFinalError: errors.New("chunk failed"),
expectedError: true,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 1,
},
{
name: "TwoChunks_BothPass",
inputCount: 8,
chunks: [][]blocks.RODataColumn{{}, {}},
verificationFailurePattern: []bool{false, false},
expectedError: false,
expectedNextCallCount: 3,
expectedOnErrorCallCount: 0,
},
{
name: "TwoChunks_FirstFails",
inputCount: 8,
chunks: [][]blocks.RODataColumn{{}, {}},
verificationFailurePattern: []bool{true, false},
iteratorFinalError: errors.New("first failed"),
expectedError: true,
expectedNextCallCount: 3,
expectedOnErrorCallCount: 1,
},
{
name: "TwoChunks_SecondFails",
inputCount: 8,
chunks: [][]blocks.RODataColumn{{}, {}},
verificationFailurePattern: []bool{false, true},
iteratorFinalError: errors.New("second failed"),
expectedError: true,
expectedNextCallCount: 3,
expectedOnErrorCallCount: 1,
},
{
name: "TwoChunks_BothFail",
inputCount: 8,
chunks: [][]blocks.RODataColumn{{}, {}},
verificationFailurePattern: []bool{true, true},
iteratorFinalError: errors.New("both failed"),
expectedError: true,
expectedNextCallCount: 3,
expectedOnErrorCallCount: 2,
},
{
name: "ManyChunks_AllPass",
inputCount: 16,
chunks: [][]blocks.RODataColumn{{}, {}, {}, {}},
verificationFailurePattern: []bool{false, false, false, false},
expectedError: false,
expectedNextCallCount: 5,
expectedOnErrorCallCount: 0,
},
{
name: "ManyChunks_MixedFail",
inputCount: 16,
chunks: [][]blocks.RODataColumn{{}, {}, {}, {}},
verificationFailurePattern: []bool{false, true, false, true},
iteratorFinalError: errors.New("mixed failures"),
expectedError: true,
expectedNextCallCount: 5,
expectedOnErrorCallCount: 2,
},
{
name: "FilterStoredColumns_PartialFilter",
inputCount: 6,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
storedColumnIndices: []uint64{1, 3},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "FilterStoredColumns_AllStored",
inputCount: 6,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
storedColumnIndices: []uint64{0, 1, 2, 3, 4, 5},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "FilterStoredColumns_MixedAccess",
inputCount: 10,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
storedColumnIndices: []uint64{1, 5, 9},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "IteratorNextError",
inputCount: 4,
chunks: [][]blocks.RODataColumn{{}, {}},
chunkErrors: []error{nil, errors.New("next error")},
verificationFailurePattern: []bool{false},
expectedError: true,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "IteratorNextEOF",
inputCount: 4,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "LargeChunkSize",
inputCount: 128,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "ManySmallChunks",
inputCount: 32,
chunks: [][]blocks.RODataColumn{{}, {}, {}, {}, {}, {}, {}, {}},
verificationFailurePattern: []bool{false, false, false, false, false, false, false, false},
expectedError: false,
expectedNextCallCount: 9,
expectedOnErrorCallCount: 0,
},
{
name: "ChunkWithSomeStoredColumns",
inputCount: 6,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{false},
storedColumnIndices: []uint64{0, 2, 4},
expectedError: false,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 0,
},
{
name: "OnErrorDoesNotStopIteration",
inputCount: 8,
chunks: [][]blocks.RODataColumn{{}, {}},
verificationFailurePattern: []bool{true, false},
iteratorFinalError: errors.New("first failed"),
expectedError: true,
expectedNextCallCount: 3,
expectedOnErrorCallCount: 1,
},
{
name: "VerificationErrorWrapping",
inputCount: 4,
chunks: [][]blocks.RODataColumn{{}},
verificationFailurePattern: []bool{true},
iteratorFinalError: errors.New("verification failed"),
expectedError: true,
expectedNextCallCount: 2,
expectedOnErrorCallCount: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Setup storage
var store *filesystem.DataColumnStorage
if len(tc.storedColumnIndices) > 0 {
mocker, s := filesystem.NewEphemeralDataColumnStorageWithMocker(t)
blockRoot := [32]byte{1, 2, 3}
slot := primitives.Slot(params.BeaconConfig().FuluForkEpoch) * params.BeaconConfig().SlotsPerEpoch
require.NoError(t, mocker.CreateFakeIndices(blockRoot, slot, tc.storedColumnIndices...))
store = s
} else {
store = filesystem.NewEphemeralDataColumnStorage(t)
}
// Create test columns
blockRoot := [32]byte{1, 2, 3}
columns := makeTestDataColumns(t, tc.inputCount, blockRoot, 0)
// Setup iterator with chunks
iterator := &mockBisectionIterator{
chunks: tc.chunks,
chunkErrors: tc.chunkErrors,
finalError: tc.iteratorFinalError,
}
// Setup bisector
var bisector Bisector
if tc.bisectorNil || tc.inputCount == 0 {
bisector = nil
} else if tc.bisectorError != nil {
bisector = &mockBisector{
shouldError: true,
bisectErr: tc.bisectorError,
}
} else {
bisector = &mockBisector{
shouldError: false,
iterator: iterator,
}
}
// Create store with verifier
verifierFactory := makeTestVerifierFactory(tc.verificationFailurePattern)
lazilyPersistentStore := &LazilyPersistentStoreColumn{
store: store,
cache: newDataColumnCache(),
newDataColumnsVerifier: verifierFactory,
custody: &custodyRequirement{},
bisector: bisector,
}
// Execute
err := lazilyPersistentStore.bisectVerification(columns)
// Assert
if tc.expectedError {
require.NotNil(t, err)
} else {
require.NoError(t, err)
}
// Verify iterator interactions for non-error cases
if tc.inputCount > 0 && bisector != nil && tc.bisectorError == nil && !tc.expectedError {
require.NotEqual(t, 0, iterator.nextCallCount, "iterator Next() should have been called")
require.Equal(t, tc.expectedOnErrorCallCount, iterator.onErrorCallCount, "OnError() call count mismatch")
}
})
}
}
func allIndicesExcept(total int, excluded []uint64) []uint64 {
excludeMap := make(map[uint64]bool)
for _, idx := range excluded {
excludeMap[idx] = true
}
var result []uint64
for i := range total {
if !excludeMap[uint64(i)] {
result = append(result, uint64(i))
}
}
return result
}
// TestColumnsNotStored tests the columnsNotStored method.
func TestColumnsNotStored(t *testing.T) {
params.SetupTestConfigCleanup(t)
params.BeaconConfig().FuluForkEpoch = params.BeaconConfig().ElectraForkEpoch + 4096*2
cases := []struct {
name string
count int
stored []uint64 // Column indices marked as stored
expected []uint64 // Expected column indices in returned result
}{
// Empty cases
{
name: "EmptyInput",
count: 0,
stored: []uint64{},
expected: []uint64{},
},
// Single element cases
{
name: "SingleElement_NotStored",
count: 1,
stored: []uint64{},
expected: []uint64{0},
},
{
name: "SingleElement_Stored",
count: 1,
stored: []uint64{0},
expected: []uint64{},
},
// All not stored cases
{
name: "AllNotStored_FiveElements",
count: 5,
stored: []uint64{},
expected: []uint64{0, 1, 2, 3, 4},
},
// All stored cases
{
name: "AllStored",
count: 5,
stored: []uint64{0, 1, 2, 3, 4},
expected: []uint64{},
},
// Partial storage - beginning
{
name: "StoredAtBeginning",
count: 5,
stored: []uint64{0, 1},
expected: []uint64{2, 3, 4},
},
// Partial storage - end
{
name: "StoredAtEnd",
count: 5,
stored: []uint64{3, 4},
expected: []uint64{0, 1, 2},
},
// Partial storage - middle
{
name: "StoredInMiddle",
count: 5,
stored: []uint64{2},
expected: []uint64{0, 1, 3, 4},
},
// Partial storage - scattered
{
name: "StoredScattered",
count: 8,
stored: []uint64{1, 3, 5},
expected: []uint64{0, 2, 4, 6, 7},
},
// Alternating pattern
{
name: "AlternatingPattern",
count: 8,
stored: []uint64{0, 2, 4, 6},
expected: []uint64{1, 3, 5, 7},
},
// Consecutive stored
{
name: "ConsecutiveStored",
count: 10,
stored: []uint64{3, 4, 5, 6},
expected: []uint64{0, 1, 2, 7, 8, 9},
},
// Large slice cases
{
name: "LargeSlice_NoStored",
count: 64,
stored: []uint64{},
expected: allIndicesExcept(64, []uint64{}),
},
{
name: "LargeSlice_SingleStored",
count: 64,
stored: []uint64{32},
expected: allIndicesExcept(64, []uint64{32}),
},
}
slot := primitives.Slot(params.BeaconConfig().FuluForkEpoch) * params.BeaconConfig().SlotsPerEpoch
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Create test columns first to get the actual block root
var columns []blocks.RODataColumn
if tc.count > 0 {
columns = makeTestDataColumns(t, tc.count, [32]byte{}, 0)
}
// Get the actual block root from the first column (if any)
var blockRoot [32]byte
if len(columns) > 0 {
blockRoot = columns[0].BlockRoot()
}
// Setup storage
var store *filesystem.DataColumnStorage
if len(tc.stored) > 0 {
mocker, s := filesystem.NewEphemeralDataColumnStorageWithMocker(t)
require.NoError(t, mocker.CreateFakeIndices(blockRoot, slot, tc.stored...))
store = s
} else {
store = filesystem.NewEphemeralDataColumnStorage(t)
}
// Create store instance
lazilyPersistentStore := &LazilyPersistentStoreColumn{
store: store,
}
// Execute
result := lazilyPersistentStore.columnsNotStored(columns)
// Assert count
require.Equal(t, len(tc.expected), len(result),
fmt.Sprintf("expected %d columns, got %d", len(tc.expected), len(result)))
// Verify that no stored columns are in the result
if len(tc.stored) > 0 {
resultIndices := make(map[uint64]bool)
for _, col := range result {
resultIndices[col.Index] = true
}
for _, storedIdx := range tc.stored {
require.Equal(t, false, resultIndices[storedIdx],
fmt.Sprintf("stored column index %d should not be in result", storedIdx))
}
}
// If expectedIndices is specified, verify the exact column indices in order
if len(tc.expected) > 0 && len(tc.stored) == 0 {
// Only check exact order for non-stored cases (where we know they stay in same order)
for i, expectedIdx := range tc.expected {
require.Equal(t, columns[expectedIdx].Index, result[i].Index,
fmt.Sprintf("column %d: expected index %d, got %d", i, columns[expectedIdx].Index, result[i].Index))
}
}
// Verify optimization: if nothing stored, should return original slice
if len(tc.stored) == 0 && tc.count > 0 {
require.Equal(t, &columns[0], &result[0],
"when no columns stored, should return original slice (same pointer)")
}
// Verify optimization: if some stored, result should use in-place shifting
if len(tc.stored) > 0 && len(tc.expected) > 0 && tc.count > 0 {
require.Equal(t, cap(columns), cap(result),
"result should be in-place shifted from original (same capacity)")
}
})
}
}

View File

@@ -0,0 +1,40 @@
package das
import (
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
)
// Bisector describes a type that takes a set of RODataColumns via the Bisect method
// and returns a BisectionIterator that returns batches of those columns to be
// verified together.
type Bisector interface {
// Bisect initializes the BisectionIterator and returns the result.
Bisect([]blocks.RODataColumn) (BisectionIterator, error)
}
// BisectionIterator describes an iterator that returns groups of columns to verify.
// It is up to the bisector implementation to decide how to chunk up the columns,
// whether by block, by peer, or any other strategy. For example, backfill implements
// a bisector that keeps track of the source of each sidecar by peer, and groups
// sidecars by peer in the Next method, enabling it to track which peers, out of all
// the peers contributing to a batch, gave us bad data.
// When a batch fails, the OnError method should be used so that the bisector can
// keep track of the failed groups of columns and eg apply that knowledge in peer scoring.
// The same column will be returned multiple times by Next; first as part of a larger batch,
// and again as part of a more fine grained batch if there was an error in the large batch.
// For example, first as part of a batch of all columns spanning peers, and then again
// as part of a batch of columns from a single peer if some column in the larger batch
// failed verification.
type BisectionIterator interface {
// Next returns the next group of columns to verify.
// When the iteration is complete, Next should return (nil, io.EOF).
Next() ([]blocks.RODataColumn, error)
// OnError should be called when verification of a group of columns obtained via Next() fails.
OnError(error)
// Error can be used at the end of the iteration to get a single error result. It will return
// nil if OnError was never called, or an error of the implementers choosing representing the set
// of errors seen during iteration. For instance when bisecting from columns spanning peers to columns
// from a single peer, the broader error could be dropped, and then the more specific error
// (for a single peer's response) returned after bisecting to it.
Error() error
}

View File

@@ -76,7 +76,7 @@ func (e *blobCacheEntry) stash(sc *blocks.ROBlob) error {
e.scs = make([]*blocks.ROBlob, maxBlobsPerBlock)
}
if e.scs[sc.Index] != nil {
return errors.Wrapf(ErrDuplicateSidecar, "root=%#x, index=%d, commitment=%#x", sc.BlockRoot(), sc.Index, sc.KzgCommitment)
return errors.Wrapf(errDuplicateSidecar, "root=%#x, index=%d, commitment=%#x", sc.BlockRoot(), sc.Index, sc.KzgCommitment)
}
e.scs[sc.Index] = sc
return nil
@@ -88,7 +88,7 @@ func (e *blobCacheEntry) stash(sc *blocks.ROBlob) error {
// commitments were found in the cache and the sidecar slice return value can be used
// to perform a DA check against the cached sidecars.
// filter only returns blobs that need to be checked. Blobs already available on disk will be excluded.
func (e *blobCacheEntry) filter(root [32]byte, kc [][]byte, slot primitives.Slot) ([]blocks.ROBlob, error) {
func (e *blobCacheEntry) filter(root [32]byte, kc [][]byte) ([]blocks.ROBlob, error) {
count := len(kc)
if e.diskSummary.AllAvailable(count) {
return nil, nil

View File

@@ -113,7 +113,7 @@ func TestFilterDiskSummary(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
entry, commits, expected := c.setup(t)
// first (root) argument doesn't matter, it is just for logs
got, err := entry.filter([32]byte{}, commits, 100)
got, err := entry.filter([32]byte{}, commits)
require.NoError(t, err)
require.Equal(t, len(expected), len(got))
})
@@ -195,7 +195,7 @@ func TestFilter(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
entry, commits, expected := c.setup(t)
// first (root) argument doesn't matter, it is just for logs
got, err := entry.filter([32]byte{}, commits, 100)
got, err := entry.filter([32]byte{}, commits)
if c.err != nil {
require.ErrorIs(t, err, c.err)
return

View File

@@ -1,9 +1,7 @@
package das
import (
"bytes"
"slices"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
@@ -11,9 +9,9 @@ import (
)
var (
ErrDuplicateSidecar = errors.New("duplicate sidecar stashed in AvailabilityStore")
errDuplicateSidecar = errors.New("duplicate sidecar stashed in AvailabilityStore")
errColumnIndexTooHigh = errors.New("column index too high")
errCommitmentMismatch = errors.New("KzgCommitment of sidecar in cache did not match block commitment")
errCommitmentMismatch = errors.New("commitment of sidecar in cache did not match block commitment")
errMissingSidecar = errors.New("no sidecar in cache for block commitment")
)
@@ -25,107 +23,80 @@ func newDataColumnCache() *dataColumnCache {
return &dataColumnCache{entries: make(map[cacheKey]*dataColumnCacheEntry)}
}
// ensure returns the entry for the given key, creating it if it isn't already present.
func (c *dataColumnCache) ensure(key cacheKey) *dataColumnCacheEntry {
// entry returns the entry for the given key, creating it if it isn't already present.
func (c *dataColumnCache) entry(key cacheKey) *dataColumnCacheEntry {
entry, ok := c.entries[key]
if !ok {
entry = &dataColumnCacheEntry{}
entry = newDataColumnCacheEntry(key.root)
c.entries[key] = entry
}
return entry
}
func (c *dataColumnCache) cleanup(blks []blocks.ROBlock) {
for _, block := range blks {
key := cacheKey{slot: block.Block().Slot(), root: block.Root()}
c.delete(key)
}
}
// delete removes the cache entry from the cache.
func (c *dataColumnCache) delete(key cacheKey) {
delete(c.entries, key)
}
// dataColumnCacheEntry holds a fixed-length cache of BlobSidecars.
type dataColumnCacheEntry struct {
scs [fieldparams.NumberOfColumns]*blocks.RODataColumn
diskSummary filesystem.DataColumnStorageSummary
func (c *dataColumnCache) stash(sc blocks.RODataColumn) error {
key := cacheKey{slot: sc.Slot(), root: sc.BlockRoot()}
entry := c.entry(key)
return entry.stash(sc)
}
func (e *dataColumnCacheEntry) setDiskSummary(sum filesystem.DataColumnStorageSummary) {
e.diskSummary = sum
func newDataColumnCacheEntry(root [32]byte) *dataColumnCacheEntry {
return &dataColumnCacheEntry{scs: make(map[uint64]blocks.RODataColumn), root: &root}
}
// dataColumnCacheEntry is the set of RODataColumns for a given block.
type dataColumnCacheEntry struct {
root *[32]byte
scs map[uint64]blocks.RODataColumn
}
// stash adds an item to the in-memory cache of DataColumnSidecars.
// Only the first DataColumnSidecar of a given Index will be kept in the cache.
// stash will return an error if the given data colunn is already in the cache, or if the Index is out of bounds.
func (e *dataColumnCacheEntry) stash(sc *blocks.RODataColumn) error {
// stash will return an error if the given data column Index is out of bounds.
// It will overwrite any existing entry for the same index.
func (e *dataColumnCacheEntry) stash(sc blocks.RODataColumn) error {
if sc.Index >= fieldparams.NumberOfColumns {
return errors.Wrapf(errColumnIndexTooHigh, "index=%d", sc.Index)
}
if e.scs[sc.Index] != nil {
return errors.Wrapf(ErrDuplicateSidecar, "root=%#x, index=%d, commitment=%#x", sc.BlockRoot(), sc.Index, sc.KzgCommitments)
}
e.scs[sc.Index] = sc
return nil
}
func (e *dataColumnCacheEntry) filter(root [32]byte, commitmentsArray *safeCommitmentsArray) ([]blocks.RODataColumn, error) {
nonEmptyIndices := commitmentsArray.nonEmptyIndices()
if e.diskSummary.AllAvailable(nonEmptyIndices) {
return nil, nil
// append appends the requested root and indices from the cache to the given sidecars slice and returns the result.
// If any of the given indices are missing, an error will be returned and the sidecars slice will be unchanged.
func (e *dataColumnCacheEntry) append(sidecars []blocks.RODataColumn, indices peerdas.ColumnIndices) ([]blocks.RODataColumn, error) {
needed := indices.ToMap()
for col := range needed {
_, ok := e.scs[col]
if !ok {
return nil, errors.Wrapf(errMissingSidecar, "root=%#x, index=%#x", e.root, col)
}
}
commitmentsCount := commitmentsArray.count()
sidecars := make([]blocks.RODataColumn, 0, commitmentsCount)
for i := range nonEmptyIndices {
if e.diskSummary.HasIndex(i) {
continue
}
if e.scs[i] == nil {
return nil, errors.Wrapf(errMissingSidecar, "root=%#x, index=%#x", root, i)
}
if !sliceBytesEqual(commitmentsArray[i], e.scs[i].KzgCommitments) {
return nil, errors.Wrapf(errCommitmentMismatch, "root=%#x, index=%#x, commitment=%#x, block commitment=%#x", root, i, e.scs[i].KzgCommitments, commitmentsArray[i])
}
sidecars = append(sidecars, *e.scs[i])
// Loop twice so we can avoid touching the slice if any of the blobs are missing.
for col := range needed {
sidecars = append(sidecars, e.scs[col])
}
return sidecars, nil
}
// safeCommitmentsArray is a fixed size array of commitments.
// This is helpful for avoiding gratuitous bounds checks.
type safeCommitmentsArray [fieldparams.NumberOfColumns][][]byte
// count returns the number of commitments in the array.
func (s *safeCommitmentsArray) count() int {
count := 0
for i := range s {
if s[i] != nil {
count++
// IndicesNotStored filters the list of indices to only include those that are not found in the storage summary.
func IndicesNotStored(sum filesystem.DataColumnStorageSummary, indices peerdas.ColumnIndices) peerdas.ColumnIndices {
indices = indices.Copy()
for col := range indices {
if sum.HasIndex(col) {
indices.Unset(col)
}
}
return count
}
// nonEmptyIndices returns a map of indices that are non-nil in the array.
func (s *safeCommitmentsArray) nonEmptyIndices() map[uint64]bool {
columns := make(map[uint64]bool)
for i := range s {
if s[i] != nil {
columns[uint64(i)] = true
}
}
return columns
}
func sliceBytesEqual(a, b [][]byte) bool {
return slices.EqualFunc(a, b, bytes.Equal)
return indices
}

View File

@@ -1,8 +1,10 @@
package das
import (
"slices"
"testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
@@ -13,124 +15,105 @@ import (
func TestEnsureDeleteSetDiskSummary(t *testing.T) {
c := newDataColumnCache()
key := cacheKey{}
entry := c.ensure(key)
require.DeepEqual(t, dataColumnCacheEntry{}, *entry)
entry := c.entry(key)
require.Equal(t, 0, len(entry.scs))
diskSummary := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{true})
entry.setDiskSummary(diskSummary)
entry = c.ensure(key)
require.DeepEqual(t, dataColumnCacheEntry{diskSummary: diskSummary}, *entry)
nonDupe := c.entry(key)
require.Equal(t, entry, nonDupe) // same pointer
expect, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: 1}})
require.NoError(t, entry.stash(expect[0]))
require.Equal(t, 1, len(entry.scs))
cols, err := nonDupe.append([]blocks.RODataColumn{}, peerdas.NewColumnIndicesFromSlice([]uint64{expect[0].Index}))
require.NoError(t, err)
require.DeepEqual(t, expect[0], cols[0])
c.delete(key)
entry = c.ensure(key)
require.DeepEqual(t, dataColumnCacheEntry{}, *entry)
entry = c.entry(key)
require.Equal(t, 0, len(entry.scs))
require.NotEqual(t, entry, nonDupe) // different pointer
}
func TestStash(t *testing.T) {
t.Run("Index too high", func(t *testing.T) {
roDataColumns, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: 10_000}})
columns, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: 10_000}})
var entry dataColumnCacheEntry
err := entry.stash(&roDataColumns[0])
err := entry.stash(columns[0])
require.NotNil(t, err)
})
t.Run("Nominal and already existing", func(t *testing.T) {
roDataColumns, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: 1}})
var entry dataColumnCacheEntry
err := entry.stash(&roDataColumns[0])
entry := newDataColumnCacheEntry(roDataColumns[0].BlockRoot())
err := entry.stash(roDataColumns[0])
require.NoError(t, err)
require.DeepEqual(t, roDataColumns[0], entry.scs[1])
err = entry.stash(&roDataColumns[0])
require.NotNil(t, err)
require.NoError(t, entry.stash(roDataColumns[0]))
// stash simply replaces duplicate values now
require.DeepEqual(t, roDataColumns[0], entry.scs[1])
})
}
func TestFilterDataColumns(t *testing.T) {
func TestAppendDataColumns(t *testing.T) {
t.Run("All available", func(t *testing.T) {
commitmentsArray := safeCommitmentsArray{nil, [][]byte{[]byte{1}}, nil, [][]byte{[]byte{3}}}
diskSummary := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{false, true, false, true})
dataColumnCacheEntry := dataColumnCacheEntry{diskSummary: diskSummary}
actual, err := dataColumnCacheEntry.filter([fieldparams.RootLength]byte{}, &commitmentsArray)
sum := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{false, true, false, true})
notStored := IndicesNotStored(sum, peerdas.NewColumnIndicesFromSlice([]uint64{1, 3}))
actual, err := newDataColumnCacheEntry([32]byte{}).append([]blocks.RODataColumn{}, notStored)
require.NoError(t, err)
require.IsNil(t, actual)
require.Equal(t, 0, len(actual))
})
t.Run("Some scs missing", func(t *testing.T) {
commitmentsArray := safeCommitmentsArray{nil, [][]byte{[]byte{1}}}
sum := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{})
diskSummary := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{})
dataColumnCacheEntry := dataColumnCacheEntry{diskSummary: diskSummary}
_, err := dataColumnCacheEntry.filter([fieldparams.RootLength]byte{}, &commitmentsArray)
require.NotNil(t, err)
})
t.Run("Commitments not equal", func(t *testing.T) {
commitmentsArray := safeCommitmentsArray{nil, [][]byte{[]byte{1}}}
roDataColumns, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: 1}})
var scs [fieldparams.NumberOfColumns]*blocks.RODataColumn
scs[1] = &roDataColumns[0]
dataColumnCacheEntry := dataColumnCacheEntry{scs: scs}
_, err := dataColumnCacheEntry.filter(roDataColumns[0].BlockRoot(), &commitmentsArray)
notStored := IndicesNotStored(sum, peerdas.NewColumnIndicesFromSlice([]uint64{1}))
actual, err := newDataColumnCacheEntry([32]byte{}).append([]blocks.RODataColumn{}, notStored)
require.Equal(t, 0, len(actual))
require.NotNil(t, err)
})
t.Run("Nominal", func(t *testing.T) {
commitmentsArray := safeCommitmentsArray{nil, [][]byte{[]byte{1}}, nil, [][]byte{[]byte{3}}}
diskSummary := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{false, true})
indices := peerdas.NewColumnIndicesFromSlice([]uint64{1, 3})
expected, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{{Index: 3, KzgCommitments: [][]byte{[]byte{3}}}})
var scs [fieldparams.NumberOfColumns]*blocks.RODataColumn
scs[3] = &expected[0]
scs := map[uint64]blocks.RODataColumn{
3: expected[0],
}
sum := filesystem.NewDataColumnStorageSummary(42, [fieldparams.NumberOfColumns]bool{false, true})
entry := dataColumnCacheEntry{scs: scs}
dataColumnCacheEntry := dataColumnCacheEntry{scs: scs, diskSummary: diskSummary}
actual, err := dataColumnCacheEntry.filter(expected[0].BlockRoot(), &commitmentsArray)
actual, err := entry.append([]blocks.RODataColumn{}, IndicesNotStored(sum, indices))
require.NoError(t, err)
require.DeepEqual(t, expected, actual)
})
}
func TestCount(t *testing.T) {
s := safeCommitmentsArray{nil, [][]byte{[]byte{1}}, nil, [][]byte{[]byte{3}}}
require.Equal(t, 2, s.count())
}
t.Run("Append does not mutate the input", func(t *testing.T) {
indices := peerdas.NewColumnIndicesFromSlice([]uint64{1, 2})
expected, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{
{Index: 0, KzgCommitments: [][]byte{[]byte{1}}},
{Index: 1, KzgCommitments: [][]byte{[]byte{2}}},
{Index: 2, KzgCommitments: [][]byte{[]byte{3}}},
})
func TestNonEmptyIndices(t *testing.T) {
s := safeCommitmentsArray{nil, [][]byte{[]byte{10}}, nil, [][]byte{[]byte{20}}}
actual := s.nonEmptyIndices()
require.DeepEqual(t, map[uint64]bool{1: true, 3: true}, actual)
}
scs := map[uint64]blocks.RODataColumn{
1: expected[1],
2: expected[2],
}
entry := dataColumnCacheEntry{scs: scs}
func TestSliceBytesEqual(t *testing.T) {
t.Run("Different lengths", func(t *testing.T) {
a := [][]byte{[]byte{1, 2, 3}}
b := [][]byte{[]byte{1, 2, 3}, []byte{4, 5, 6}}
require.Equal(t, false, sliceBytesEqual(a, b))
})
t.Run("Same length but different content", func(t *testing.T) {
a := [][]byte{[]byte{1, 2, 3}, []byte{4, 5, 6}}
b := [][]byte{[]byte{1, 2, 3}, []byte{4, 5, 7}}
require.Equal(t, false, sliceBytesEqual(a, b))
})
t.Run("Equal slices", func(t *testing.T) {
a := [][]byte{[]byte{1, 2, 3}, []byte{4, 5, 6}}
b := [][]byte{[]byte{1, 2, 3}, []byte{4, 5, 6}}
require.Equal(t, true, sliceBytesEqual(a, b))
original := []blocks.RODataColumn{expected[0]}
actual, err := entry.append(original, indices)
require.NoError(t, err)
require.Equal(t, len(expected), len(actual))
slices.SortFunc(actual, func(i, j blocks.RODataColumn) int {
return int(i.Index) - int(j.Index)
})
for i := range expected {
require.Equal(t, expected[i].Index, actual[i].Index)
}
require.Equal(t, 1, len(original))
})
}

View File

@@ -7,13 +7,10 @@ import (
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
)
// AvailabilityStore describes a component that can verify and save sidecars for a given block, and confirm previously
// verified and saved sidecars.
// Persist guarantees that the sidecar will be available to perform a DA check
// for the life of the beacon node process.
// IsDataAvailable guarantees that all blobs committed to in the block have been
// durably persisted before returning a non-error value.
type AvailabilityStore interface {
IsDataAvailable(ctx context.Context, current primitives.Slot, b blocks.ROBlock) error
Persist(current primitives.Slot, blobSidecar ...blocks.ROBlob) error
// AvailabilityChecker is the minimum interface needed to check if data is available for a block.
// By convention there is a concept of an AvailabilityStore that implements a method to persist
// blobs or data columns to prepare for Availability checking, but since those methods are different
// for different forms of blob data, they are not included in the interface.
type AvailabilityChecker interface {
IsDataAvailable(ctx context.Context, current primitives.Slot, b ...blocks.ROBlock) error
}

5
beacon-chain/das/log.go Normal file
View File

@@ -0,0 +1,5 @@
package das
import "github.com/sirupsen/logrus"
var log = logrus.WithField("prefix", "das")

View File

@@ -9,16 +9,20 @@ import (
// MockAvailabilityStore is an implementation of AvailabilityStore that can be used by other packages in tests.
type MockAvailabilityStore struct {
VerifyAvailabilityCallback func(ctx context.Context, current primitives.Slot, b blocks.ROBlock) error
VerifyAvailabilityCallback func(ctx context.Context, current primitives.Slot, b ...blocks.ROBlock) error
ErrIsDataAvailable error
PersistBlobsCallback func(current primitives.Slot, blobSidecar ...blocks.ROBlob) error
}
var _ AvailabilityStore = &MockAvailabilityStore{}
var _ AvailabilityChecker = &MockAvailabilityStore{}
// IsDataAvailable satisfies the corresponding method of the AvailabilityStore interface in a way that is useful for tests.
func (m *MockAvailabilityStore) IsDataAvailable(ctx context.Context, current primitives.Slot, b blocks.ROBlock) error {
func (m *MockAvailabilityStore) IsDataAvailable(ctx context.Context, current primitives.Slot, b ...blocks.ROBlock) error {
if m.ErrIsDataAvailable != nil {
return m.ErrIsDataAvailable
}
if m.VerifyAvailabilityCallback != nil {
return m.VerifyAvailabilityCallback(ctx, current, b)
return m.VerifyAvailabilityCallback(ctx, current, b...)
}
return nil
}

View File

@@ -128,9 +128,9 @@ type NoHeadAccessDatabase interface {
BackfillFinalizedIndex(ctx context.Context, blocks []blocks.ROBlock, finalizedChildRoot [32]byte) error
// Custody operations.
UpdateSubscribedToAllDataSubnets(ctx context.Context, subscribed bool) (bool, error)
UpdateCustodyInfo(ctx context.Context, earliestAvailableSlot primitives.Slot, custodyGroupCount uint64) (primitives.Slot, uint64, error)
UpdateEarliestAvailableSlot(ctx context.Context, earliestAvailableSlot primitives.Slot) error
UpdateSubscribedToAllDataSubnets(ctx context.Context, subscribed bool) (bool, error)
// P2P Metadata operations.
SaveMetadataSeqNum(ctx context.Context, seqNum uint64) error

View File

@@ -27,6 +27,9 @@ go_library(
"p2p.go",
"schema.go",
"state.go",
"state_diff.go",
"state_diff_cache.go",
"state_diff_helpers.go",
"state_summary.go",
"state_summary_cache.go",
"utils.go",
@@ -41,10 +44,12 @@ go_library(
"//beacon-chain/db/iface:go_default_library",
"//beacon-chain/state:go_default_library",
"//beacon-chain/state/state-native:go_default_library",
"//cmd/beacon-chain/flags:go_default_library",
"//config/features:go_default_library",
"//config/fieldparams:go_default_library",
"//config/params:go_default_library",
"//consensus-types/blocks:go_default_library",
"//consensus-types/hdiff:go_default_library",
"//consensus-types/interfaces:go_default_library",
"//consensus-types/light-client:go_default_library",
"//consensus-types/primitives:go_default_library",
@@ -53,6 +58,7 @@ go_library(
"//encoding/ssz/detect:go_default_library",
"//genesis:go_default_library",
"//io/file:go_default_library",
"//math:go_default_library",
"//monitoring/progress:go_default_library",
"//monitoring/tracing:go_default_library",
"//monitoring/tracing/trace:go_default_library",
@@ -98,6 +104,7 @@ go_test(
"migration_block_slot_index_test.go",
"migration_state_validators_test.go",
"p2p_test.go",
"state_diff_test.go",
"state_summary_test.go",
"state_test.go",
"utils_test.go",
@@ -111,6 +118,7 @@ go_test(
"//beacon-chain/db/iface:go_default_library",
"//beacon-chain/state:go_default_library",
"//beacon-chain/state/state-native:go_default_library",
"//cmd/beacon-chain/flags:go_default_library",
"//config/features:go_default_library",
"//config/fieldparams:go_default_library",
"//config/params:go_default_library",
@@ -120,6 +128,7 @@ go_test(
"//consensus-types/primitives:go_default_library",
"//encoding/bytesutil:go_default_library",
"//genesis:go_default_library",
"//math:go_default_library",
"//proto/dbval:go_default_library",
"//proto/engine/v1:go_default_library",
"//proto/prysm/v1alpha1:go_default_library",
@@ -133,6 +142,7 @@ go_test(
"@com_github_golang_snappy//:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
"@com_github_sirupsen_logrus//hooks/test:go_default_library",
"@io_bazel_rules_go//go/tools/bazel:go_default_library",
"@io_etcd_go_bbolt//:go_default_library",
"@org_golang_google_protobuf//proto:go_default_library",

View File

@@ -146,9 +146,9 @@ func (s *Store) UpdateEarliestAvailableSlot(ctx context.Context, earliestAvailab
return nil
}
// UpdateSubscribedToAllDataSubnets updates the "subscribed to all data subnets" status in the database
// only if `subscribed` is `true`.
// It returns the previous subscription status.
// UpdateSubscribedToAllDataSubnets updates whether the node is subscribed to all data subnets (supernode mode).
// This is a one-way flag - once set to true, it cannot be reverted to false.
// Returns the previous state.
func (s *Store) UpdateSubscribedToAllDataSubnets(ctx context.Context, subscribed bool) (bool, error) {
_, span := trace.StartSpan(ctx, "BeaconDB.UpdateSubscribedToAllDataSubnets")
defer span.End()
@@ -156,13 +156,11 @@ func (s *Store) UpdateSubscribedToAllDataSubnets(ctx context.Context, subscribed
result := false
if !subscribed {
if err := s.db.View(func(tx *bolt.Tx) error {
// Retrieve the custody bucket.
bucket := tx.Bucket(custodyBucket)
if bucket == nil {
return nil
}
// Retrieve the subscribe all data subnets flag.
bytes := bucket.Get(subscribeAllDataSubnetsKey)
if len(bytes) == 0 {
return nil
@@ -181,7 +179,6 @@ func (s *Store) UpdateSubscribedToAllDataSubnets(ctx context.Context, subscribed
}
if err := s.db.Update(func(tx *bolt.Tx) error {
// Retrieve the custody bucket.
bucket, err := tx.CreateBucketIfNotExists(custodyBucket)
if err != nil {
return errors.Wrap(err, "create custody bucket")

View File

@@ -67,6 +67,7 @@ func getSubscriptionStatusFromDB(t *testing.T, db *Store) bool {
return subscribed
}
func TestUpdateCustodyInfo(t *testing.T) {
ctx := t.Context()
@@ -274,6 +275,17 @@ func TestUpdateSubscribedToAllDataSubnets(t *testing.T) {
require.Equal(t, false, stored)
})
t.Run("initial update with empty database - set to true", func(t *testing.T) {
db := setupDB(t)
prev, err := db.UpdateSubscribedToAllDataSubnets(ctx, true)
require.NoError(t, err)
require.Equal(t, false, prev)
stored := getSubscriptionStatusFromDB(t, db)
require.Equal(t, true, stored)
})
t.Run("attempt to update from true to false (should not change)", func(t *testing.T) {
db := setupDB(t)
@@ -288,7 +300,7 @@ func TestUpdateSubscribedToAllDataSubnets(t *testing.T) {
require.Equal(t, true, stored)
})
t.Run("attempt to update from true to false (should not change)", func(t *testing.T) {
t.Run("update from true to true (no change)", func(t *testing.T) {
db := setupDB(t)
_, err := db.UpdateSubscribedToAllDataSubnets(ctx, true)

View File

@@ -2,6 +2,13 @@ package kv
import "bytes"
func hasPhase0Key(enc []byte) bool {
if len(phase0Key) >= len(enc) {
return false
}
return bytes.Equal(enc[:len(phase0Key)], phase0Key)
}
// In order for an encoding to be Altair compatible, it must be prefixed with altair key.
func hasAltairKey(enc []byte) bool {
if len(altairKey) >= len(enc) {

View File

@@ -91,6 +91,7 @@ type Store struct {
blockCache *ristretto.Cache[string, interfaces.ReadOnlySignedBeaconBlock]
validatorEntryCache *ristretto.Cache[[]byte, *ethpb.Validator]
stateSummaryCache *stateSummaryCache
stateDiffCache *stateDiffCache
ctx context.Context
}
@@ -112,6 +113,7 @@ var Buckets = [][]byte{
lightClientUpdatesBucket,
lightClientBootstrapBucket,
lightClientSyncCommitteeBucket,
stateDiffBucket,
// Indices buckets.
blockSlotIndicesBucket,
stateSlotIndicesBucket,
@@ -201,6 +203,14 @@ func NewKVStore(ctx context.Context, dirPath string, opts ...KVStoreOption) (*St
return nil, err
}
if features.Get().EnableStateDiff {
sdCache, err := newStateDiffCache(kv)
if err != nil {
return nil, err
}
kv.stateDiffCache = sdCache
}
return kv, nil
}

View File

@@ -216,6 +216,10 @@ func TestStore_LightClientUpdate_CanSaveRetrieve(t *testing.T) {
db := setupDB(t)
ctx := t.Context()
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
update, err := createUpdate(t, testVersion)
require.NoError(t, err)
@@ -572,6 +576,10 @@ func TestStore_LightClientBootstrap_CanSaveRetrieve(t *testing.T) {
require.IsNil(t, retrievedBootstrap)
})
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
bootstrap, err := createDefaultLightClientBootstrap(primitives.Slot(uint64(params.BeaconConfig().VersionToForkEpochMap()[testVersion]) * uint64(params.BeaconConfig().SlotsPerEpoch)))
require.NoError(t, err)

View File

@@ -16,6 +16,7 @@ var (
stateValidatorsBucket = []byte("state-validators")
feeRecipientBucket = []byte("fee-recipient")
registrationBucket = []byte("registration")
stateDiffBucket = []byte("state-diff")
// Light Client Updates Bucket
lightClientUpdatesBucket = []byte("light-client-updates")
@@ -46,6 +47,7 @@ var (
// Below keys are used to identify objects are to be fork compatible.
// Objects that are only compatible with specific forks should be prefixed with such keys.
phase0Key = []byte("phase0")
altairKey = []byte("altair")
bellatrixKey = []byte("merge")
bellatrixBlindKey = []byte("blind-bellatrix")

View File

@@ -8,7 +8,6 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
statenative "github.com/OffchainLabs/prysm/v7/beacon-chain/state/state-native"
"github.com/OffchainLabs/prysm/v7/config/features"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/genesis"
@@ -28,6 +27,17 @@ func (s *Store) State(ctx context.Context, blockRoot [32]byte) (state.BeaconStat
ctx, span := trace.StartSpan(ctx, "BeaconDB.State")
defer span.End()
startTime := time.Now()
// If state diff is enabled, we get the state from the state-diff db.
if features.Get().EnableStateDiff {
st, err := s.getStateUsingStateDiff(ctx, blockRoot)
if err != nil {
return nil, err
}
stateReadingTime.Observe(float64(time.Since(startTime).Milliseconds()))
return st, nil
}
enc, err := s.stateBytes(ctx, blockRoot)
if err != nil {
return nil, err
@@ -417,6 +427,16 @@ func (s *Store) storeValidatorEntriesSeparately(ctx context.Context, tx *bolt.Tx
func (s *Store) HasState(ctx context.Context, blockRoot [32]byte) bool {
_, span := trace.StartSpan(ctx, "BeaconDB.HasState")
defer span.End()
if features.Get().EnableStateDiff {
hasState, err := s.hasStateUsingStateDiff(ctx, blockRoot)
if err != nil {
log.WithError(err).Error(fmt.Sprintf("error checking state existence using state-diff"))
return false
}
return hasState
}
hasState := false
err := s.db.View(func(tx *bolt.Tx) error {
bkt := tx.Bucket(stateBucket)
@@ -470,7 +490,7 @@ func (s *Store) DeleteState(ctx context.Context, blockRoot [32]byte) error {
return nil
}
slot, err := s.slotByBlockRoot(ctx, tx, blockRoot[:])
slot, err := s.SlotByBlockRoot(ctx, blockRoot)
if err != nil {
return err
}
@@ -812,50 +832,45 @@ func (s *Store) stateBytes(ctx context.Context, blockRoot [32]byte) ([]byte, err
return dst, err
}
// slotByBlockRoot retrieves the corresponding slot of the input block root.
func (s *Store) slotByBlockRoot(ctx context.Context, tx *bolt.Tx, blockRoot []byte) (primitives.Slot, error) {
ctx, span := trace.StartSpan(ctx, "BeaconDB.slotByBlockRoot")
// SlotByBlockRoot returns the slot of the input block root, based on state summary, block, or state.
// Check for state is only done if state diff feature is not enabled.
func (s *Store) SlotByBlockRoot(ctx context.Context, blockRoot [32]byte) (primitives.Slot, error) {
ctx, span := trace.StartSpan(ctx, "BeaconDB.SlotByBlockRoot")
defer span.End()
bkt := tx.Bucket(stateSummaryBucket)
enc := bkt.Get(blockRoot)
if enc == nil {
// Fall back to check the block.
bkt := tx.Bucket(blocksBucket)
enc := bkt.Get(blockRoot)
if enc == nil {
// Fallback and check the state.
bkt = tx.Bucket(stateBucket)
enc = bkt.Get(blockRoot)
if enc == nil {
return 0, errors.New("state enc can't be nil")
}
// no need to construct the validator entries as it is not used here.
s, err := s.unmarshalState(ctx, enc, nil)
if err != nil {
return 0, errors.Wrap(err, "could not unmarshal state")
}
if s == nil || s.IsNil() {
return 0, errors.New("state can't be nil")
}
return s.Slot(), nil
}
b, err := unmarshalBlock(ctx, enc)
if err != nil {
return 0, errors.Wrap(err, "could not unmarshal block")
}
if err := blocks.BeaconBlockIsNil(b); err != nil {
return 0, err
}
return b.Block().Slot(), nil
// check state summary first
stateSummary, err := s.StateSummary(ctx, blockRoot)
if err != nil {
return 0, err
}
stateSummary := &ethpb.StateSummary{}
if err := decode(ctx, enc, stateSummary); err != nil {
return 0, errors.Wrap(err, "could not unmarshal state summary")
if stateSummary != nil {
return stateSummary.Slot, nil
}
return stateSummary.Slot, nil
// fall back to block if state summary is not found
blk, err := s.Block(ctx, blockRoot)
if err != nil {
return 0, err
}
if blk != nil && !blk.IsNil() {
return blk.Block().Slot(), nil
}
// fall back to state, only if state diff feature is not enabled
if features.Get().EnableStateDiff {
return 0, errors.New("neither state summary nor block found")
}
st, err := s.State(ctx, blockRoot)
if err != nil {
return 0, err
}
if st != nil && !st.IsNil() {
return st.Slot(), nil
}
// neither state summary, block nor state found
return 0, errors.New("neither state summary, block nor state found")
}
// HighestSlotStatesBelow returns the states with the highest slot below the input slot
@@ -1031,3 +1046,30 @@ func (s *Store) isStateValidatorMigrationOver() (bool, error) {
}
return returnFlag, nil
}
func (s *Store) getStateUsingStateDiff(ctx context.Context, blockRoot [32]byte) (state.BeaconState, error) {
slot, err := s.SlotByBlockRoot(ctx, blockRoot)
if err != nil {
return nil, err
}
st, err := s.stateByDiff(ctx, slot)
if err != nil {
return nil, err
}
if st == nil || st.IsNil() {
return nil, errors.New("state not found")
}
return st, nil
}
func (s *Store) hasStateUsingStateDiff(ctx context.Context, blockRoot [32]byte) (bool, error) {
slot, err := s.SlotByBlockRoot(ctx, blockRoot)
if err != nil {
return false, err
}
stateLvl := computeLevel(s.getOffset(), slot)
return stateLvl != -1, nil
}

View File

@@ -0,0 +1,232 @@
package kv
import (
"context"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"github.com/OffchainLabs/prysm/v7/consensus-types/hdiff"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/monitoring/tracing/trace"
"github.com/pkg/errors"
bolt "go.etcd.io/bbolt"
)
const (
stateSuffix = "_s"
validatorSuffix = "_v"
balancesSuffix = "_b"
)
/*
We use a level-based approach to save state diffs. Each level corresponds to an exponent of 2 (exponents[lvl]).
The data at level 0 is saved every 2**exponent[0] slots and always contains a full state snapshot that is used as a base for the delta saved at other levels.
*/
// saveStateByDiff takes a state and decides between saving a full state snapshot or a diff.
func (s *Store) saveStateByDiff(ctx context.Context, st state.ReadOnlyBeaconState) error {
_, span := trace.StartSpan(ctx, "BeaconDB.saveStateByDiff")
defer span.End()
if st == nil {
return errors.New("state is nil")
}
slot := st.Slot()
offset := s.getOffset()
if uint64(slot) < offset {
return ErrSlotBeforeOffset
}
// Find the level to save the state.
lvl := computeLevel(offset, slot)
if lvl == -1 {
return nil
}
// Save full state if level is 0.
if lvl == 0 {
return s.saveFullSnapshot(st)
}
// Get anchor state to compute the diff from.
anchorState, err := s.getAnchorState(offset, lvl, slot)
if err != nil {
return err
}
return s.saveHdiff(lvl, anchorState, st)
}
// stateByDiff retrieves the full state for a given slot.
func (s *Store) stateByDiff(ctx context.Context, slot primitives.Slot) (state.BeaconState, error) {
offset := s.getOffset()
if uint64(slot) < offset {
return nil, ErrSlotBeforeOffset
}
snapshot, diffChain, err := s.getBaseAndDiffChain(offset, slot)
if err != nil {
return nil, err
}
for _, diff := range diffChain {
if err := ctx.Err(); err != nil {
return nil, err
}
snapshot, err = hdiff.ApplyDiff(ctx, snapshot, diff)
if err != nil {
return nil, err
}
}
return snapshot, nil
}
// saveHdiff computes the diff between the anchor state and the current state and saves it to the database.
// This function needs to be called only with the latest finalized state, and in a strictly increasing slot order.
func (s *Store) saveHdiff(lvl int, anchor, st state.ReadOnlyBeaconState) error {
slot := uint64(st.Slot())
key := makeKeyForStateDiffTree(lvl, slot)
diff, err := hdiff.Diff(anchor, st)
if err != nil {
return err
}
err = s.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bolt.ErrBucketNotFound
}
buf := append(key, stateSuffix...)
if err := bucket.Put(buf, diff.StateDiff); err != nil {
return err
}
buf = append(key, validatorSuffix...)
if err := bucket.Put(buf, diff.ValidatorDiffs); err != nil {
return err
}
buf = append(key, balancesSuffix...)
if err := bucket.Put(buf, diff.BalancesDiff); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// Save the full state to the cache (if not the last level).
if lvl != len(flags.Get().StateDiffExponents)-1 {
err = s.stateDiffCache.setAnchor(lvl, st)
if err != nil {
return err
}
}
return nil
}
// SaveFullSnapshot saves the full level 0 state snapshot to the database.
func (s *Store) saveFullSnapshot(st state.ReadOnlyBeaconState) error {
slot := uint64(st.Slot())
key := makeKeyForStateDiffTree(0, slot)
stateBytes, err := st.MarshalSSZ()
if err != nil {
return err
}
// add version key to value
enc, err := addKey(st.Version(), stateBytes)
if err != nil {
return err
}
err = s.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bolt.ErrBucketNotFound
}
if err := bucket.Put(key, enc); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// Save the full state to the cache, and invalidate other levels.
s.stateDiffCache.clearAnchors()
err = s.stateDiffCache.setAnchor(0, st)
if err != nil {
return err
}
return nil
}
func (s *Store) getDiff(lvl int, slot uint64) (hdiff.HdiffBytes, error) {
key := makeKeyForStateDiffTree(lvl, slot)
var stateDiff []byte
var validatorDiff []byte
var balancesDiff []byte
err := s.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bolt.ErrBucketNotFound
}
buf := append(key, stateSuffix...)
stateDiff = bucket.Get(buf)
if stateDiff == nil {
return errors.New("state diff not found")
}
buf = append(key, validatorSuffix...)
validatorDiff = bucket.Get(buf)
if validatorDiff == nil {
return errors.New("validator diff not found")
}
buf = append(key, balancesSuffix...)
balancesDiff = bucket.Get(buf)
if balancesDiff == nil {
return errors.New("balances diff not found")
}
return nil
})
if err != nil {
return hdiff.HdiffBytes{}, err
}
return hdiff.HdiffBytes{
StateDiff: stateDiff,
ValidatorDiffs: validatorDiff,
BalancesDiff: balancesDiff,
}, nil
}
func (s *Store) getFullSnapshot(slot uint64) (state.BeaconState, error) {
key := makeKeyForStateDiffTree(0, slot)
var enc []byte
err := s.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bolt.ErrBucketNotFound
}
enc = bucket.Get(key)
if enc == nil {
return errors.New("state not found")
}
return nil
})
if err != nil {
return nil, err
}
return decodeStateSnapshot(enc)
}

View File

@@ -0,0 +1,77 @@
package kv
import (
"encoding/binary"
"errors"
"sync"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"go.etcd.io/bbolt"
)
type stateDiffCache struct {
sync.RWMutex
anchors []state.ReadOnlyBeaconState
offset uint64
}
func newStateDiffCache(s *Store) (*stateDiffCache, error) {
var offset uint64
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bbolt.ErrBucketNotFound
}
offsetBytes := bucket.Get([]byte("offset"))
if offsetBytes == nil {
return errors.New("state diff cache: offset not found")
}
offset = binary.LittleEndian.Uint64(offsetBytes)
return nil
})
if err != nil {
return nil, err
}
return &stateDiffCache{
anchors: make([]state.ReadOnlyBeaconState, len(flags.Get().StateDiffExponents)-1), // -1 because last level doesn't need to be cached
offset: offset,
}, nil
}
func (c *stateDiffCache) getAnchor(level int) state.ReadOnlyBeaconState {
c.RLock()
defer c.RUnlock()
return c.anchors[level]
}
func (c *stateDiffCache) setAnchor(level int, anchor state.ReadOnlyBeaconState) error {
c.Lock()
defer c.Unlock()
if level >= len(c.anchors) || level < 0 {
return errors.New("state diff cache: anchor level out of range")
}
c.anchors[level] = anchor
return nil
}
func (c *stateDiffCache) getOffset() uint64 {
c.RLock()
defer c.RUnlock()
return c.offset
}
func (c *stateDiffCache) setOffset(offset uint64) {
c.Lock()
defer c.Unlock()
c.offset = offset
}
func (c *stateDiffCache) clearAnchors() {
c.Lock()
defer c.Unlock()
c.anchors = make([]state.ReadOnlyBeaconState, len(flags.Get().StateDiffExponents)-1) // -1 because last level doesn't need to be cached
}

View File

@@ -0,0 +1,250 @@
package kv
import (
"context"
"encoding/binary"
"errors"
"fmt"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
statenative "github.com/OffchainLabs/prysm/v7/beacon-chain/state/state-native"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"github.com/OffchainLabs/prysm/v7/consensus-types/hdiff"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/math"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/OffchainLabs/prysm/v7/runtime/version"
"go.etcd.io/bbolt"
)
var (
offsetKey = []byte("offset")
ErrSlotBeforeOffset = errors.New("slot is before root offset")
)
func makeKeyForStateDiffTree(level int, slot uint64) []byte {
buf := make([]byte, 16)
buf[0] = byte(level)
binary.LittleEndian.PutUint64(buf[1:], slot)
return buf
}
func (s *Store) getAnchorState(offset uint64, lvl int, slot primitives.Slot) (anchor state.ReadOnlyBeaconState, err error) {
if lvl <= 0 || lvl > len(flags.Get().StateDiffExponents) {
return nil, errors.New("invalid value for level")
}
if uint64(slot) < offset {
return nil, ErrSlotBeforeOffset
}
relSlot := uint64(slot) - offset
prevExp := flags.Get().StateDiffExponents[lvl-1]
if prevExp < 2 || prevExp >= 64 {
return nil, fmt.Errorf("state diff exponent %d out of range for uint64", prevExp)
}
span := math.PowerOf2(uint64(prevExp))
anchorSlot := primitives.Slot(uint64(slot) - relSlot%span)
// anchorLvl can be [0, lvl-1]
anchorLvl := computeLevel(offset, anchorSlot)
if anchorLvl == -1 {
return nil, errors.New("could not compute anchor level")
}
// Check if we have the anchor in cache.
anchor = s.stateDiffCache.getAnchor(anchorLvl)
if anchor != nil {
return anchor, nil
}
// If not, load it from the database.
anchor, err = s.stateByDiff(context.Background(), anchorSlot)
if err != nil {
return nil, err
}
// Save it in the cache.
err = s.stateDiffCache.setAnchor(anchorLvl, anchor)
if err != nil {
return nil, err
}
return anchor, nil
}
// computeLevel computes the level in the diff tree. Returns -1 in case slot should not be in tree.
func computeLevel(offset uint64, slot primitives.Slot) int {
rel := uint64(slot) - offset
for i, exp := range flags.Get().StateDiffExponents {
if exp < 2 || exp >= 64 {
return -1
}
span := math.PowerOf2(uint64(exp))
if rel%span == 0 {
return i
}
}
// If rel isnt on any of the boundaries, we should ignore saving it.
return -1
}
func (s *Store) setOffset(slot primitives.Slot) error {
err := s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bbolt.ErrBucketNotFound
}
offsetBytes := bucket.Get(offsetKey)
if offsetBytes != nil {
return fmt.Errorf("offset already set to %d", binary.LittleEndian.Uint64(offsetBytes))
}
offsetBytes = make([]byte, 8)
binary.LittleEndian.PutUint64(offsetBytes, uint64(slot))
if err := bucket.Put(offsetKey, offsetBytes); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// Save the offset in the cache.
s.stateDiffCache.setOffset(uint64(slot))
return nil
}
func (s *Store) getOffset() uint64 {
return s.stateDiffCache.getOffset()
}
func keyForSnapshot(v int) ([]byte, error) {
switch v {
case version.Fulu:
return fuluKey, nil
case version.Electra:
return ElectraKey, nil
case version.Deneb:
return denebKey, nil
case version.Capella:
return capellaKey, nil
case version.Bellatrix:
return bellatrixKey, nil
case version.Altair:
return altairKey, nil
case version.Phase0:
return phase0Key, nil
default:
return nil, errors.New("unsupported fork")
}
}
func addKey(v int, bytes []byte) ([]byte, error) {
key, err := keyForSnapshot(v)
if err != nil {
return nil, err
}
enc := make([]byte, len(key)+len(bytes))
copy(enc, key)
copy(enc[len(key):], bytes)
return enc, nil
}
func decodeStateSnapshot(enc []byte) (state.BeaconState, error) {
switch {
case hasFuluKey(enc):
var fuluState ethpb.BeaconStateFulu
if err := fuluState.UnmarshalSSZ(enc[len(fuluKey):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafeFulu(&fuluState)
case HasElectraKey(enc):
var electraState ethpb.BeaconStateElectra
if err := electraState.UnmarshalSSZ(enc[len(ElectraKey):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafeElectra(&electraState)
case hasDenebKey(enc):
var denebState ethpb.BeaconStateDeneb
if err := denebState.UnmarshalSSZ(enc[len(denebKey):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafeDeneb(&denebState)
case hasCapellaKey(enc):
var capellaState ethpb.BeaconStateCapella
if err := capellaState.UnmarshalSSZ(enc[len(capellaKey):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafeCapella(&capellaState)
case hasBellatrixKey(enc):
var bellatrixState ethpb.BeaconStateBellatrix
if err := bellatrixState.UnmarshalSSZ(enc[len(bellatrixKey):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafeBellatrix(&bellatrixState)
case hasAltairKey(enc):
var altairState ethpb.BeaconStateAltair
if err := altairState.UnmarshalSSZ(enc[len(altairKey):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafeAltair(&altairState)
case hasPhase0Key(enc):
var phase0State ethpb.BeaconState
if err := phase0State.UnmarshalSSZ(enc[len(phase0Key):]); err != nil {
return nil, err
}
return statenative.InitializeFromProtoUnsafePhase0(&phase0State)
default:
return nil, errors.New("unsupported fork")
}
}
func (s *Store) getBaseAndDiffChain(offset uint64, slot primitives.Slot) (state.BeaconState, []hdiff.HdiffBytes, error) {
if uint64(slot) < offset {
return nil, nil, ErrSlotBeforeOffset
}
rel := uint64(slot) - offset
lvl := computeLevel(offset, slot)
if lvl == -1 {
return nil, nil, errors.New("slot not in tree")
}
exponents := flags.Get().StateDiffExponents
baseSpan := math.PowerOf2(uint64(exponents[0]))
baseAnchorSlot := uint64(slot) - rel%baseSpan
type diffItem struct {
level int
slot uint64
}
var diffChainItems []diffItem
lastSeenAnchorSlot := baseAnchorSlot
for i, exp := range exponents[1 : lvl+1] {
span := math.PowerOf2(uint64(exp))
diffSlot := rel / span * span
if diffSlot == lastSeenAnchorSlot {
continue
}
diffChainItems = append(diffChainItems, diffItem{level: i + 1, slot: diffSlot + offset})
lastSeenAnchorSlot = diffSlot
}
baseSnapshot, err := s.getFullSnapshot(baseAnchorSlot)
if err != nil {
return nil, nil, err
}
diffChain := make([]hdiff.HdiffBytes, 0, len(diffChainItems))
for _, item := range diffChainItems {
diff, err := s.getDiff(item.level, item.slot)
if err != nil {
return nil, nil, err
}
diffChain = append(diffChain, diff)
}
return baseSnapshot, diffChain, nil
}

View File

@@ -0,0 +1,662 @@
package kv
import (
"context"
"encoding/binary"
"fmt"
"math/rand"
"testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/math"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/OffchainLabs/prysm/v7/runtime/version"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"go.etcd.io/bbolt"
)
func TestStateDiff_LoadOrInitOffset(t *testing.T) {
setDefaultStateDiffExponents()
db := setupDB(t)
err := setOffsetInDB(db, 10)
require.NoError(t, err)
offset := db.getOffset()
require.Equal(t, uint64(10), offset)
err = db.setOffset(10)
require.ErrorContains(t, "offset already set", err)
offset = db.getOffset()
require.Equal(t, uint64(10), offset)
}
func TestStateDiff_ComputeLevel(t *testing.T) {
db := setupDB(t)
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
offset := db.getOffset()
// 2 ** 21
lvl := computeLevel(offset, primitives.Slot(math.PowerOf2(21)))
require.Equal(t, 0, lvl)
// 2 ** 21 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(21)*3))
require.Equal(t, 0, lvl)
// 2 ** 18
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(18)))
require.Equal(t, 1, lvl)
// 2 ** 18 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(18)*3))
require.Equal(t, 1, lvl)
// 2 ** 16
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(16)))
require.Equal(t, 2, lvl)
// 2 ** 16 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(16)*3))
require.Equal(t, 2, lvl)
// 2 ** 13
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(13)))
require.Equal(t, 3, lvl)
// 2 ** 13 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(13)*3))
require.Equal(t, 3, lvl)
// 2 ** 11
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(11)))
require.Equal(t, 4, lvl)
// 2 ** 11 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(11)*3))
require.Equal(t, 4, lvl)
// 2 ** 9
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(9)))
require.Equal(t, 5, lvl)
// 2 ** 9 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(9)*3))
require.Equal(t, 5, lvl)
// 2 ** 5
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(5)))
require.Equal(t, 6, lvl)
// 2 ** 5 * 3
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(5)*3))
require.Equal(t, 6, lvl)
// 2 ** 7
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(7)))
require.Equal(t, 6, lvl)
// 2 ** 5 + 1
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(5)+1))
require.Equal(t, -1, lvl)
// 2 ** 5 + 16
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(5)+16))
require.Equal(t, -1, lvl)
// 2 ** 5 + 32
lvl = computeLevel(offset, primitives.Slot(math.PowerOf2(5)+32))
require.Equal(t, 6, lvl)
}
func TestStateDiff_SaveFullSnapshot(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
// Create state with slot 0
st, enc := createState(t, 0, v)
err := setOffsetInDB(db, 0)
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
err = db.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bbolt.ErrBucketNotFound
}
s := bucket.Get(makeKeyForStateDiffTree(0, uint64(0)))
if s == nil {
return bbolt.ErrIncompatibleValue
}
require.DeepSSZEqual(t, enc, s)
return nil
})
require.NoError(t, err)
})
}
}
func TestStateDiff_SaveAndReadFullSnapshot(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
st, _ := createState(t, 0, v)
err := setOffsetInDB(db, 0)
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err := db.stateByDiff(context.Background(), 0)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
}
func TestStateDiff_SaveDiff(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
// Create state with slot 2**21
slot := primitives.Slot(math.PowerOf2(21))
st, enc := createState(t, slot, v)
err := setOffsetInDB(db, uint64(slot))
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
err = db.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bbolt.ErrBucketNotFound
}
s := bucket.Get(makeKeyForStateDiffTree(0, uint64(slot)))
if s == nil {
return bbolt.ErrIncompatibleValue
}
require.DeepSSZEqual(t, enc, s)
return nil
})
require.NoError(t, err)
// create state with slot 2**18 (+2**21)
slot = primitives.Slot(math.PowerOf2(18) + math.PowerOf2(21))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
key := makeKeyForStateDiffTree(1, uint64(slot))
err = db.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bbolt.ErrBucketNotFound
}
buf := append(key, "_s"...)
s := bucket.Get(buf)
if s == nil {
return bbolt.ErrIncompatibleValue
}
buf = append(key, "_v"...)
v := bucket.Get(buf)
if v == nil {
return bbolt.ErrIncompatibleValue
}
buf = append(key, "_b"...)
b := bucket.Get(buf)
if b == nil {
return bbolt.ErrIncompatibleValue
}
return nil
})
require.NoError(t, err)
})
}
}
func TestStateDiff_SaveAndReadDiff(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
st, _ := createState(t, 0, v)
err := setOffsetInDB(db, 0)
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot := primitives.Slot(math.PowerOf2(5))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err := db.stateByDiff(context.Background(), slot)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
}
func TestStateDiff_SaveAndReadDiff_WithRepetitiveAnchorSlots(t *testing.T) {
globalFlags := flags.GlobalFlags{
StateDiffExponents: []int{20, 14, 10, 7, 5},
}
flags.Init(&globalFlags)
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
err := setOffsetInDB(db, 0)
st, _ := createState(t, 0, v)
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot := primitives.Slot(math.PowerOf2(11))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot = primitives.Slot(math.PowerOf2(11) + math.PowerOf2(5))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err := db.stateByDiff(context.Background(), slot)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
}
func TestStateDiff_SaveAndReadDiff_MultipleLevels(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
st, _ := createState(t, 0, v)
err := setOffsetInDB(db, 0)
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot := primitives.Slot(math.PowerOf2(11))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err := db.stateByDiff(context.Background(), slot)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
slot = primitives.Slot(math.PowerOf2(11) + math.PowerOf2(9))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err = db.stateByDiff(context.Background(), slot)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err = st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err = readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
slot = primitives.Slot(math.PowerOf2(11) + math.PowerOf2(9) + math.PowerOf2(5))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err = db.stateByDiff(context.Background(), slot)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err = st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err = readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
}
func TestStateDiff_SaveAndReadDiffForkTransition(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All()[:len(version.All())-1] {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
st, _ := createState(t, 0, v)
err := setOffsetInDB(db, 0)
require.NoError(t, err)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot := primitives.Slot(math.PowerOf2(5))
st, _ = createState(t, slot, v+1)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
readSt, err := db.stateByDiff(context.Background(), slot)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
}
func TestStateDiff_OffsetCache(t *testing.T) {
setDefaultStateDiffExponents()
// test for slot numbers 0 and 1 for every version
for slotNum := range 2 {
for v := range version.All() {
t.Run(fmt.Sprintf("slotNum=%d,%s", slotNum, version.String(v)), func(t *testing.T) {
db := setupDB(t)
slot := primitives.Slot(slotNum)
err := setOffsetInDB(db, uint64(slot))
require.NoError(t, err)
st, _ := createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
offset := db.stateDiffCache.getOffset()
require.Equal(t, uint64(slotNum), offset)
slot2 := primitives.Slot(uint64(slotNum) + math.PowerOf2(uint64(flags.Get().StateDiffExponents[0])))
st2, _ := createState(t, slot2, v)
err = db.saveStateByDiff(context.Background(), st2)
require.NoError(t, err)
offset = db.stateDiffCache.getOffset()
require.Equal(t, uint64(slot), offset)
})
}
}
}
func TestStateDiff_AnchorCache(t *testing.T) {
setDefaultStateDiffExponents()
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
exponents := flags.Get().StateDiffExponents
localCache := make([]state.ReadOnlyBeaconState, len(exponents)-1)
db := setupDB(t)
err := setOffsetInDB(db, 0) // lvl 0
require.NoError(t, err)
// at first the cache should be empty
for i := 0; i < len(flags.Get().StateDiffExponents)-1; i++ {
anchor := db.stateDiffCache.getAnchor(i)
require.IsNil(t, anchor)
}
// add level 0
slot := primitives.Slot(0) // offset 0 is already set
st, _ := createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
localCache[0] = st
// level 0 should be the same
require.DeepEqual(t, localCache[0], db.stateDiffCache.getAnchor(0))
// rest of the cache should be nil
for i := 1; i < len(exponents)-1; i++ {
require.IsNil(t, db.stateDiffCache.getAnchor(i))
}
// skip last level as it does not get cached
for i := len(exponents) - 2; i > 0; i-- {
slot = primitives.Slot(math.PowerOf2(uint64(exponents[i])))
st, _ := createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
localCache[i] = st
// anchor cache must match local cache
for i := 0; i < len(exponents)-1; i++ {
if localCache[i] == nil {
require.IsNil(t, db.stateDiffCache.getAnchor(i))
continue
}
localSSZ, err := localCache[i].MarshalSSZ()
require.NoError(t, err)
anchorSSZ, err := db.stateDiffCache.getAnchor(i).MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, localSSZ, anchorSSZ)
}
}
// moving to a new tree should invalidate the cache except for level 0
twoTo21 := math.PowerOf2(21)
slot = primitives.Slot(twoTo21)
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
localCache = make([]state.ReadOnlyBeaconState, len(exponents)-1)
localCache[0] = st
// level 0 should be the same
require.DeepEqual(t, localCache[0], db.stateDiffCache.getAnchor(0))
// rest of the cache should be nil
for i := 1; i < len(exponents)-1; i++ {
require.IsNil(t, db.stateDiffCache.getAnchor(i))
}
})
}
}
func TestStateDiff_EncodingAndDecoding(t *testing.T) {
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
st, enc := createState(t, 0, v) // this has addKey called inside
stDecoded, err := decodeStateSnapshot(enc)
require.NoError(t, err)
st1ssz, err := st.MarshalSSZ()
require.NoError(t, err)
st2ssz, err := stDecoded.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, st1ssz, st2ssz)
})
}
}
func createState(t *testing.T, slot primitives.Slot, v int) (state.ReadOnlyBeaconState, []byte) {
p := params.BeaconConfig()
var st state.BeaconState
var err error
switch v {
case version.Phase0:
st, err = util.NewBeaconState()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.GenesisForkVersion,
CurrentVersion: p.GenesisForkVersion,
Epoch: 0,
})
require.NoError(t, err)
case version.Altair:
st, err = util.NewBeaconStateAltair()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.GenesisForkVersion,
CurrentVersion: p.AltairForkVersion,
Epoch: p.AltairForkEpoch,
})
require.NoError(t, err)
case version.Bellatrix:
st, err = util.NewBeaconStateBellatrix()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.AltairForkVersion,
CurrentVersion: p.BellatrixForkVersion,
Epoch: p.BellatrixForkEpoch,
})
require.NoError(t, err)
case version.Capella:
st, err = util.NewBeaconStateCapella()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.BellatrixForkVersion,
CurrentVersion: p.CapellaForkVersion,
Epoch: p.CapellaForkEpoch,
})
require.NoError(t, err)
case version.Deneb:
st, err = util.NewBeaconStateDeneb()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.CapellaForkVersion,
CurrentVersion: p.DenebForkVersion,
Epoch: p.DenebForkEpoch,
})
require.NoError(t, err)
case version.Electra:
st, err = util.NewBeaconStateElectra()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.DenebForkVersion,
CurrentVersion: p.ElectraForkVersion,
Epoch: p.ElectraForkEpoch,
})
require.NoError(t, err)
case version.Fulu:
st, err = util.NewBeaconStateFulu()
require.NoError(t, err)
err = st.SetFork(&ethpb.Fork{
PreviousVersion: p.ElectraForkVersion,
CurrentVersion: p.FuluForkVersion,
Epoch: p.FuluForkEpoch,
})
require.NoError(t, err)
default:
t.Fatalf("unsupported version: %d", v)
}
err = st.SetSlot(slot)
require.NoError(t, err)
slashings := make([]uint64, 8192)
slashings[0] = uint64(rand.Intn(10))
err = st.SetSlashings(slashings)
require.NoError(t, err)
stssz, err := st.MarshalSSZ()
require.NoError(t, err)
enc, err := addKey(v, stssz)
require.NoError(t, err)
return st, enc
}
func setOffsetInDB(s *Store, offset uint64) error {
err := s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(stateDiffBucket)
if bucket == nil {
return bbolt.ErrBucketNotFound
}
offsetBytes := bucket.Get(offsetKey)
if offsetBytes != nil {
return fmt.Errorf("offset already set to %d", binary.LittleEndian.Uint64(offsetBytes))
}
offsetBytes = make([]byte, 8)
binary.LittleEndian.PutUint64(offsetBytes, offset)
if err := bucket.Put(offsetKey, offsetBytes); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
sdCache, err := newStateDiffCache(s)
if err != nil {
return err
}
s.stateDiffCache = sdCache
return nil
}
func setDefaultStateDiffExponents() {
globalFlags := flags.GlobalFlags{
StateDiffExponents: []int{21, 18, 16, 13, 11, 9, 5},
}
flags.Init(&globalFlags)
}

View File

@@ -1,6 +1,7 @@
package kv
import (
"context"
"crypto/rand"
"encoding/binary"
mathRand "math/rand"
@@ -9,6 +10,7 @@ import (
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"github.com/OffchainLabs/prysm/v7/config/features"
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/config/params"
@@ -17,11 +19,14 @@ import (
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/genesis"
"github.com/OffchainLabs/prysm/v7/math"
enginev1 "github.com/OffchainLabs/prysm/v7/proto/engine/v1"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/OffchainLabs/prysm/v7/runtime/version"
"github.com/OffchainLabs/prysm/v7/testing/assert"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
logTest "github.com/sirupsen/logrus/hooks/test"
bolt "go.etcd.io/bbolt"
)
@@ -1329,3 +1334,297 @@ func TestStore_CleanUpDirtyStates_NoOriginRoot(t *testing.T) {
}
}
}
func TestStore_CanSaveRetrieveStateUsingStateDiff(t *testing.T) {
t.Run("No state summary or block", func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
readSt, err := db.State(context.Background(), [32]byte{'A'})
require.IsNil(t, readSt)
require.ErrorContains(t, "neither state summary nor block found", err)
})
t.Run("Slot not in tree", func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
r := bytesutil.ToBytes32([]byte{'A'})
ss := &ethpb.StateSummary{Slot: 1, Root: r[:]} // slot 1 not in tree
err = db.SaveStateSummary(context.Background(), ss)
require.NoError(t, err)
readSt, err := db.State(context.Background(), r)
require.ErrorContains(t, "slot not in tree", err)
require.IsNil(t, readSt)
})
t.Run("State not found", func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
r := bytesutil.ToBytes32([]byte{'A'})
ss := &ethpb.StateSummary{Slot: 32, Root: r[:]} // slot 32 is in tree
err = db.SaveStateSummary(context.Background(), ss)
require.NoError(t, err)
readSt, err := db.State(context.Background(), r)
require.ErrorContains(t, "state not found", err)
require.IsNil(t, readSt)
})
t.Run("Full state snapshot", func(t *testing.T) {
t.Run("using state summary", func(t *testing.T) {
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
st, _ := createState(t, 0, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
r := bytesutil.ToBytes32([]byte{'A'})
ss := &ethpb.StateSummary{Slot: 0, Root: r[:]}
err = db.SaveStateSummary(context.Background(), ss)
require.NoError(t, err)
readSt, err := db.State(context.Background(), r)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
})
t.Run("using block", func(t *testing.T) {
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
st, _ := createState(t, 0, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
blk := util.NewBeaconBlock()
blk.Block.Slot = 0
signedBlk, err := blocks.NewSignedBeaconBlock(blk)
require.NoError(t, err)
err = db.SaveBlock(context.Background(), signedBlk)
require.NoError(t, err)
r, err := signedBlk.Block().HashTreeRoot()
require.NoError(t, err)
readSt, err := db.State(context.Background(), r)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
})
})
t.Run("Diffed state", func(t *testing.T) {
t.Run("using state summary", func(t *testing.T) {
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
exponents := flags.Get().StateDiffExponents
err := setOffsetInDB(db, 0)
require.NoError(t, err)
st, _ := createState(t, 0, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot := primitives.Slot(math.PowerOf2(uint64(exponents[len(exponents)-2])))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot = primitives.Slot(math.PowerOf2(uint64(exponents[len(exponents)-2])) + math.PowerOf2(uint64(exponents[len(exponents)-1])))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
r := bytesutil.ToBytes32([]byte{'A'})
ss := &ethpb.StateSummary{Slot: slot, Root: r[:]}
err = db.SaveStateSummary(context.Background(), ss)
require.NoError(t, err)
readSt, err := db.State(context.Background(), r)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
})
t.Run("using block", func(t *testing.T) {
for v := range version.All() {
t.Run(version.String(v), func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
exponents := flags.Get().StateDiffExponents
err := setOffsetInDB(db, 0)
require.NoError(t, err)
st, _ := createState(t, 0, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot := primitives.Slot(math.PowerOf2(uint64(exponents[len(exponents)-2])))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
slot = primitives.Slot(math.PowerOf2(uint64(exponents[len(exponents)-2])) + math.PowerOf2(uint64(exponents[len(exponents)-1])))
st, _ = createState(t, slot, v)
err = db.saveStateByDiff(context.Background(), st)
require.NoError(t, err)
blk := util.NewBeaconBlock()
blk.Block.Slot = slot
signedBlk, err := blocks.NewSignedBeaconBlock(blk)
require.NoError(t, err)
err = db.SaveBlock(context.Background(), signedBlk)
require.NoError(t, err)
r, err := signedBlk.Block().HashTreeRoot()
require.NoError(t, err)
readSt, err := db.State(context.Background(), r)
require.NoError(t, err)
require.NotNil(t, readSt)
stSSZ, err := st.MarshalSSZ()
require.NoError(t, err)
readStSSZ, err := readSt.MarshalSSZ()
require.NoError(t, err)
require.DeepSSZEqual(t, stSSZ, readStSSZ)
})
}
})
})
}
func TestStore_HasStateUsingStateDiff(t *testing.T) {
t.Run("No state summary or block", func(t *testing.T) {
hook := logTest.NewGlobal()
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
hasSt := db.HasState(t.Context(), [32]byte{'A'})
require.Equal(t, false, hasSt)
require.LogsContain(t, hook, "neither state summary nor block found")
})
t.Run("slot in tree or not", func(t *testing.T) {
db := setupDB(t)
featCfg := &features.Flags{}
featCfg.EnableStateDiff = true
reset := features.InitWithReset(featCfg)
defer reset()
setDefaultStateDiffExponents()
err := setOffsetInDB(db, 0)
require.NoError(t, err)
testCases := []struct {
slot primitives.Slot
expected bool
}{
{slot: 1, expected: false}, // slot 1 not in tree
{slot: 32, expected: true}, // slot 32 in tree
{slot: 0, expected: true}, // slot 0 in tree
{slot: primitives.Slot(math.PowerOf2(21)), expected: true}, // slot in tree
{slot: primitives.Slot(math.PowerOf2(21) - 1), expected: false}, // slot not in tree
{slot: primitives.Slot(math.PowerOf2(22)), expected: true}, // slot in tree
}
for _, tc := range testCases {
r := bytesutil.ToBytes32([]byte{'A'})
ss := &ethpb.StateSummary{Slot: tc.slot, Root: r[:]}
err = db.SaveStateSummary(t.Context(), ss)
require.NoError(t, err)
hasSt := db.HasState(t.Context(), r)
require.Equal(t, tc.expected, hasSt)
}
})
}

View File

@@ -33,6 +33,10 @@ func TestLightClient_NewLightClientOptimisticUpdateFromBeaconState(t *testing.T)
params.OverrideBeaconConfig(cfg)
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
l := util.NewTestLightClient(t, testVersion)

View File

@@ -280,7 +280,10 @@ func configureBeacon(cliCtx *cli.Context) error {
return errors.Wrap(err, "could not configure beacon chain")
}
flags.ConfigureGlobalFlags(cliCtx)
err := flags.ConfigureGlobalFlags(cliCtx)
if err != nil {
return errors.Wrap(err, "could not configure global flags")
}
if err := configureChainConfig(cliCtx); err != nil {
return errors.Wrap(err, "could not configure chain config")
@@ -660,6 +663,7 @@ func (b *BeaconNode) registerP2P(cliCtx *cli.Context) error {
EnableUPnP: cliCtx.Bool(cmd.EnableUPnPFlag.Name),
StateNotifier: b,
DB: b.db,
StateGen: b.stateGen,
ClockWaiter: b.clockWaiter,
})
if err != nil {
@@ -1124,7 +1128,7 @@ func (b *BeaconNode) registerPrunerService(cliCtx *cli.Context) error {
func (b *BeaconNode) RegisterBackfillService(cliCtx *cli.Context, bfs *backfill.Store) error {
pa := peers.NewAssigner(b.fetchP2P().Peers(), b.forkChoicer)
bf, err := backfill.NewService(cliCtx.Context, bfs, b.BlobStorage, b.clockWaiter, b.fetchP2P(), pa, b.BackfillOpts...)
bf, err := backfill.NewService(cliCtx.Context, bfs, b.BlobStorage, b.DataColumnStorage, b.clockWaiter, b.fetchP2P(), pa, b.BackfillOpts...)
if err != nil {
return errors.Wrap(err, "error initializing backfill service")
}

View File

@@ -56,6 +56,7 @@ go_library(
"//beacon-chain/p2p/peers/scorers:go_default_library",
"//beacon-chain/p2p/types:go_default_library",
"//beacon-chain/startup:go_default_library",
"//beacon-chain/state/stategen:go_default_library",
"//cmd/beacon-chain/flags:go_default_library",
"//config/features:go_default_library",
"//config/fieldparams:go_default_library",
@@ -153,6 +154,7 @@ go_test(
"//beacon-chain/core/helpers:go_default_library",
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/core/signing:go_default_library",
"//beacon-chain/db/iface:go_default_library",
"//beacon-chain/db/testing:go_default_library",
"//beacon-chain/p2p/encoder:go_default_library",
"//beacon-chain/p2p/peers:go_default_library",
@@ -161,6 +163,7 @@ go_test(
"//beacon-chain/p2p/testing:go_default_library",
"//beacon-chain/p2p/types:go_default_library",
"//beacon-chain/startup:go_default_library",
"//beacon-chain/state/stategen/mock:go_default_library",
"//cmd/beacon-chain/flags:go_default_library",
"//config/fieldparams:go_default_library",
"//config/params:go_default_library",

View File

@@ -7,6 +7,7 @@ import (
statefeed "github.com/OffchainLabs/prysm/v7/beacon-chain/core/feed/state"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db"
"github.com/OffchainLabs/prysm/v7/beacon-chain/startup"
"github.com/OffchainLabs/prysm/v7/beacon-chain/state/stategen"
"github.com/sirupsen/logrus"
)
@@ -49,6 +50,7 @@ type Config struct {
IPColocationWhitelist []*net.IPNet
StateNotifier statefeed.Notifier
DB db.ReadOnlyDatabaseWithSeqNum
StateGen stategen.StateManager
ClockWaiter startup.ClockWaiter
}

View File

@@ -156,29 +156,13 @@ func (s *Service) retrieveActiveValidators() (uint64, error) {
if s.activeValidatorCount != 0 {
return s.activeValidatorCount, nil
}
rt := s.cfg.DB.LastArchivedRoot(s.ctx)
if rt == params.BeaconConfig().ZeroHash {
genState, err := s.cfg.DB.GenesisState(s.ctx)
if err != nil {
return 0, err
}
if genState == nil || genState.IsNil() {
return 0, errors.New("no genesis state exists")
}
activeVals, err := helpers.ActiveValidatorCount(context.Background(), genState, coreTime.CurrentEpoch(genState))
if err != nil {
return 0, err
}
// Cache active validator count
s.activeValidatorCount = activeVals
return activeVals, nil
}
bState, err := s.cfg.DB.State(s.ctx, rt)
finalizedCheckpoint, err := s.cfg.DB.FinalizedCheckpoint(s.ctx)
if err != nil {
return 0, err
}
if bState == nil || bState.IsNil() {
return 0, errors.Errorf("no state with root %#x exists", rt)
bState, err := s.cfg.StateGen.StateByRoot(s.ctx, [32]byte(finalizedCheckpoint.Root))
if err != nil {
return 0, err
}
activeVals, err := helpers.ActiveValidatorCount(context.Background(), bState, coreTime.CurrentEpoch(bState))
if err != nil {

View File

@@ -1,10 +1,14 @@
package p2p
import (
"context"
"testing"
iface "github.com/OffchainLabs/prysm/v7/beacon-chain/db/iface"
dbutil "github.com/OffchainLabs/prysm/v7/beacon-chain/db/testing"
mockstategen "github.com/OffchainLabs/prysm/v7/beacon-chain/state/stategen/mock"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/OffchainLabs/prysm/v7/testing/assert"
"github.com/OffchainLabs/prysm/v7/testing/require"
@@ -20,9 +24,11 @@ func TestCorrect_ActiveValidatorsCount(t *testing.T) {
params.OverrideBeaconConfig(cfg)
db := dbutil.SetupDB(t)
wrappedDB := &finalizedCheckpointDB{ReadOnlyDatabaseWithSeqNum: db}
stateGen := mockstategen.NewService()
s := &Service{
ctx: t.Context(),
cfg: &Config{DB: db},
cfg: &Config{DB: wrappedDB, StateGen: stateGen},
}
bState, err := util.NewBeaconState(func(state *ethpb.BeaconState) error {
validators := make([]*ethpb.Validator, params.BeaconConfig().MinGenesisActiveValidatorCount)
@@ -39,6 +45,10 @@ func TestCorrect_ActiveValidatorsCount(t *testing.T) {
})
require.NoError(t, err)
require.NoError(t, db.SaveGenesisData(s.ctx, bState))
checkpoint, err := db.FinalizedCheckpoint(s.ctx)
require.NoError(t, err)
wrappedDB.finalized = checkpoint
stateGen.AddStateForRoot(bState, bytesutil.ToBytes32(checkpoint.Root))
vals, err := s.retrieveActiveValidators()
assert.NoError(t, err, "genesis state not retrieved")
@@ -52,7 +62,10 @@ func TestCorrect_ActiveValidatorsCount(t *testing.T) {
}))
}
require.NoError(t, bState.SetSlot(10000))
require.NoError(t, db.SaveState(s.ctx, bState, [32]byte{'a'}))
rootA := [32]byte{'a'}
require.NoError(t, db.SaveState(s.ctx, bState, rootA))
wrappedDB.finalized = &ethpb.Checkpoint{Root: rootA[:]}
stateGen.AddStateForRoot(bState, rootA)
// Reset count
s.activeValidatorCount = 0
@@ -77,3 +90,15 @@ func TestLoggingParameters(_ *testing.T) {
logGossipParameters("testing", defaultLightClientOptimisticUpdateTopicParams())
logGossipParameters("testing", defaultLightClientFinalityUpdateTopicParams())
}
type finalizedCheckpointDB struct {
iface.ReadOnlyDatabaseWithSeqNum
finalized *ethpb.Checkpoint
}
func (f *finalizedCheckpointDB) FinalizedCheckpoint(ctx context.Context) (*ethpb.Checkpoint, error) {
if f.finalized != nil {
return f.finalized, nil
}
return f.ReadOnlyDatabaseWithSeqNum.FinalizedCheckpoint(ctx)
}

View File

@@ -47,6 +47,7 @@ go_test(
],
embed = [":go_default_library"],
deps = [
"//beacon-chain/forkchoice/types:go_default_library",
"//beacon-chain/p2p/peers/peerdata:go_default_library",
"//beacon-chain/p2p/peers/scorers:go_default_library",
"//cmd/beacon-chain/flags:go_default_library",

View File

@@ -4,11 +4,18 @@ import (
forkchoicetypes "github.com/OffchainLabs/prysm/v7/beacon-chain/forkchoice/types"
"github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// StatusProvider describes the minimum capability that Assigner needs from peer status tracking.
// That is, the ability to retrieve the best peers by finalized checkpoint.
type StatusProvider interface {
BestFinalized(ourFinalized primitives.Epoch) (primitives.Epoch, []peer.ID)
}
// FinalizedCheckpointer describes the minimum capability that Assigner needs from forkchoice.
// That is, the ability to retrieve the latest finalized checkpoint to help with peer evaluation.
type FinalizedCheckpointer interface {
@@ -17,9 +24,9 @@ type FinalizedCheckpointer interface {
// NewAssigner assists in the correct construction of an Assigner by code in other packages,
// assuring all the important private member fields are given values.
// The FinalizedCheckpointer is used to retrieve the latest finalized checkpoint each time peers are requested.
// The StatusProvider is used to retrieve best peers, and FinalizedCheckpointer is used to retrieve the latest finalized checkpoint each time peers are requested.
// Peers that report an older finalized checkpoint are filtered out.
func NewAssigner(s *Status, fc FinalizedCheckpointer) *Assigner {
func NewAssigner(s StatusProvider, fc FinalizedCheckpointer) *Assigner {
return &Assigner{
ps: s,
fc: fc,
@@ -28,7 +35,7 @@ func NewAssigner(s *Status, fc FinalizedCheckpointer) *Assigner {
// Assigner uses the "BestFinalized" peer scoring method to pick the next-best peer to receive rpc requests.
type Assigner struct {
ps *Status
ps StatusProvider
fc FinalizedCheckpointer
}
@@ -38,38 +45,42 @@ type Assigner struct {
var ErrInsufficientSuitable = errors.New("no suitable peers")
func (a *Assigner) freshPeers() ([]peer.ID, error) {
required := min(flags.Get().MinimumSyncPeers, params.BeaconConfig().MaxPeersToSync)
_, peers := a.ps.BestFinalized(params.BeaconConfig().MaxPeersToSync, a.fc.FinalizedCheckpoint().Epoch)
required := min(flags.Get().MinimumSyncPeers, min(flags.Get().MinimumSyncPeers, params.BeaconConfig().MaxPeersToSync))
_, peers := a.ps.BestFinalized(a.fc.FinalizedCheckpoint().Epoch)
if len(peers) < required {
log.WithFields(logrus.Fields{
"suitable": len(peers),
"required": required}).Warn("Unable to assign peer while suitable peers < required ")
"required": required}).Trace("Unable to assign peer while suitable peers < required")
return nil, ErrInsufficientSuitable
}
return peers, nil
}
// AssignmentFilter describes a function that takes a list of peer.IDs and returns a filtered subset.
// An example is the NotBusy filter.
type AssignmentFilter func([]peer.ID) []peer.ID
// Assign uses the "BestFinalized" method to select the best peers that agree on a canonical block
// for the configured finalized epoch. At most `n` peers will be returned. The `busy` param can be used
// to filter out peers that we know we don't want to connect to, for instance if we are trying to limit
// the number of outbound requests to each peer from a given component.
func (a *Assigner) Assign(busy map[peer.ID]bool, n int) ([]peer.ID, error) {
func (a *Assigner) Assign(filter AssignmentFilter) ([]peer.ID, error) {
best, err := a.freshPeers()
if err != nil {
return nil, err
}
return pickBest(busy, n, best), nil
return filter(best), nil
}
func pickBest(busy map[peer.ID]bool, n int, best []peer.ID) []peer.ID {
ps := make([]peer.ID, 0, n)
for _, p := range best {
if len(ps) == n {
return ps
}
if !busy[p] {
ps = append(ps, p)
// NotBusy is a filter that returns the list of peer.IDs that are not in the `busy` map.
func NotBusy(busy map[peer.ID]bool) AssignmentFilter {
return func(peers []peer.ID) []peer.ID {
ps := make([]peer.ID, 0, len(peers))
for _, p := range peers {
if !busy[p] {
ps = append(ps, p)
}
}
return ps
}
return ps
}

View File

@@ -5,6 +5,8 @@ import (
"slices"
"testing"
forkchoicetypes "github.com/OffchainLabs/prysm/v7/beacon-chain/forkchoice/types"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/libp2p/go-libp2p/core/peer"
)
@@ -14,82 +16,68 @@ func TestPickBest(t *testing.T) {
cases := []struct {
name string
busy map[peer.ID]bool
n int
best []peer.ID
expected []peer.ID
}{
{
name: "",
n: 0,
name: "don't limit",
expected: best,
},
{
name: "none busy",
n: 1,
expected: best[0:1],
expected: best,
},
{
name: "all busy except last",
n: 1,
busy: testBusyMap(best[0 : len(best)-1]),
expected: best[len(best)-1:],
},
{
name: "all busy except i=5",
n: 1,
busy: testBusyMap(slices.Concat(best[0:5], best[6:])),
expected: []peer.ID{best[5]},
},
{
name: "all busy - 0 results",
n: 1,
busy: testBusyMap(best),
},
{
name: "first half busy",
n: 5,
busy: testBusyMap(best[0:5]),
expected: best[5:],
},
{
name: "back half busy",
n: 5,
busy: testBusyMap(best[5:]),
expected: best[0:5],
},
{
name: "pick all ",
n: 10,
expected: best,
},
{
name: "none available",
n: 10,
best: []peer.ID{},
},
{
name: "not enough",
n: 10,
best: best[0:1],
expected: best[0:1],
},
{
name: "not enough, some busy",
n: 10,
best: best[0:6],
busy: testBusyMap(best[0:5]),
expected: best[5:6],
},
}
for _, c := range cases {
name := fmt.Sprintf("n=%d", c.n)
if c.name != "" {
name += " " + c.name
}
t.Run(name, func(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
if c.best == nil {
c.best = best
}
pb := pickBest(c.busy, c.n, c.best)
filt := NotBusy(c.busy)
pb := filt(c.best)
require.Equal(t, len(c.expected), len(pb))
for i := range c.expected {
require.Equal(t, c.expected[i], pb[i])
@@ -113,3 +101,310 @@ func testPeerIds(n int) []peer.ID {
}
return pids
}
// MockStatus is a test mock for the Status interface used in Assigner.
type MockStatus struct {
bestFinalizedEpoch primitives.Epoch
bestPeers []peer.ID
}
func (m *MockStatus) BestFinalized(ourFinalized primitives.Epoch) (primitives.Epoch, []peer.ID) {
return m.bestFinalizedEpoch, m.bestPeers
}
// MockFinalizedCheckpointer is a test mock for FinalizedCheckpointer interface.
type MockFinalizedCheckpointer struct {
checkpoint *forkchoicetypes.Checkpoint
}
func (m *MockFinalizedCheckpointer) FinalizedCheckpoint() *forkchoicetypes.Checkpoint {
return m.checkpoint
}
// TestAssign_HappyPath tests the Assign method with sufficient peers and various filters.
func TestAssign_HappyPath(t *testing.T) {
peers := testPeerIds(10)
cases := []struct {
name string
bestPeers []peer.ID
finalizedEpoch primitives.Epoch
filter AssignmentFilter
expectedCount int
}{
{
name: "sufficient peers with identity filter",
bestPeers: peers,
finalizedEpoch: 10,
filter: func(p []peer.ID) []peer.ID { return p },
expectedCount: 10,
},
{
name: "sufficient peers with NotBusy filter (no busy)",
bestPeers: peers,
finalizedEpoch: 10,
filter: NotBusy(make(map[peer.ID]bool)),
expectedCount: 10,
},
{
name: "sufficient peers with NotBusy filter (some busy)",
bestPeers: peers,
finalizedEpoch: 10,
filter: NotBusy(testBusyMap(peers[0:5])),
expectedCount: 5,
},
{
name: "minimum threshold exactly met",
bestPeers: peers[0:5],
finalizedEpoch: 10,
filter: func(p []peer.ID) []peer.ID { return p },
expectedCount: 5,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockStatus := &MockStatus{
bestFinalizedEpoch: tc.finalizedEpoch,
bestPeers: tc.bestPeers,
}
mockCheckpointer := &MockFinalizedCheckpointer{
checkpoint: &forkchoicetypes.Checkpoint{Epoch: tc.finalizedEpoch},
}
assigner := NewAssigner(mockStatus, mockCheckpointer)
result, err := assigner.Assign(tc.filter)
require.NoError(t, err)
require.Equal(t, tc.expectedCount, len(result),
fmt.Sprintf("expected %d peers, got %d", tc.expectedCount, len(result)))
})
}
}
// TestAssign_InsufficientPeers tests error handling when not enough suitable peers are available.
// Note: The actual peer threshold depends on config values MaxPeersToSync and MinimumSyncPeers.
func TestAssign_InsufficientPeers(t *testing.T) {
cases := []struct {
name string
bestPeers []peer.ID
expectedErr error
description string
}{
{
name: "exactly at minimum threshold",
bestPeers: testPeerIds(5),
expectedErr: nil,
description: "5 peers should meet the minimum threshold",
},
{
name: "well above minimum threshold",
bestPeers: testPeerIds(50),
expectedErr: nil,
description: "50 peers should easily meet requirements",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockStatus := &MockStatus{
bestFinalizedEpoch: 10,
bestPeers: tc.bestPeers,
}
mockCheckpointer := &MockFinalizedCheckpointer{
checkpoint: &forkchoicetypes.Checkpoint{Epoch: 10},
}
assigner := NewAssigner(mockStatus, mockCheckpointer)
result, err := assigner.Assign(NotBusy(make(map[peer.ID]bool)))
if tc.expectedErr != nil {
require.NotNil(t, err, tc.description)
require.Equal(t, tc.expectedErr, err)
} else {
require.NoError(t, err, tc.description)
require.Equal(t, len(tc.bestPeers), len(result))
}
})
}
}
// TestAssign_FilterApplication verifies that filters are correctly applied to peer lists.
func TestAssign_FilterApplication(t *testing.T) {
peers := testPeerIds(10)
cases := []struct {
name string
bestPeers []peer.ID
filterToApply AssignmentFilter
expectedCount int
description string
}{
{
name: "identity filter returns all peers",
bestPeers: peers,
filterToApply: func(p []peer.ID) []peer.ID { return p },
expectedCount: 10,
description: "identity filter should not change peer list",
},
{
name: "filter removes all peers (all busy)",
bestPeers: peers,
filterToApply: NotBusy(testBusyMap(peers)),
expectedCount: 0,
description: "all peers busy should return empty list",
},
{
name: "filter removes first 5 peers",
bestPeers: peers,
filterToApply: NotBusy(testBusyMap(peers[0:5])),
expectedCount: 5,
description: "should only return non-busy peers",
},
{
name: "filter removes last 5 peers",
bestPeers: peers,
filterToApply: NotBusy(testBusyMap(peers[5:])),
expectedCount: 5,
description: "should only return non-busy peers from beginning",
},
{
name: "custom filter selects every other peer",
bestPeers: peers,
filterToApply: func(p []peer.ID) []peer.ID {
result := make([]peer.ID, 0)
for i := 0; i < len(p); i += 2 {
result = append(result, p[i])
}
return result
},
expectedCount: 5,
description: "custom filter selecting every other peer",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockStatus := &MockStatus{
bestFinalizedEpoch: 10,
bestPeers: tc.bestPeers,
}
mockCheckpointer := &MockFinalizedCheckpointer{
checkpoint: &forkchoicetypes.Checkpoint{Epoch: 10},
}
assigner := NewAssigner(mockStatus, mockCheckpointer)
result, err := assigner.Assign(tc.filterToApply)
require.NoError(t, err, fmt.Sprintf("unexpected error: %v", err))
require.Equal(t, tc.expectedCount, len(result),
fmt.Sprintf("%s: expected %d peers, got %d", tc.description, tc.expectedCount, len(result)))
})
}
}
// TestAssign_FinalizedCheckpointUsage verifies that the finalized checkpoint is correctly used.
func TestAssign_FinalizedCheckpointUsage(t *testing.T) {
peers := testPeerIds(10)
cases := []struct {
name string
finalizedEpoch primitives.Epoch
bestPeers []peer.ID
expectedCount int
description string
}{
{
name: "epoch 0",
finalizedEpoch: 0,
bestPeers: peers,
expectedCount: 10,
description: "epoch 0 should work",
},
{
name: "epoch 100",
finalizedEpoch: 100,
bestPeers: peers,
expectedCount: 10,
description: "high epoch number should work",
},
{
name: "epoch changes between calls",
finalizedEpoch: 50,
bestPeers: testPeerIds(5),
expectedCount: 5,
description: "epoch value should be used in checkpoint",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockStatus := &MockStatus{
bestFinalizedEpoch: tc.finalizedEpoch,
bestPeers: tc.bestPeers,
}
mockCheckpointer := &MockFinalizedCheckpointer{
checkpoint: &forkchoicetypes.Checkpoint{Epoch: tc.finalizedEpoch},
}
assigner := NewAssigner(mockStatus, mockCheckpointer)
result, err := assigner.Assign(NotBusy(make(map[peer.ID]bool)))
require.NoError(t, err)
require.Equal(t, tc.expectedCount, len(result),
fmt.Sprintf("%s: expected %d peers, got %d", tc.description, tc.expectedCount, len(result)))
})
}
}
// TestAssign_EdgeCases tests boundary conditions and edge cases.
func TestAssign_EdgeCases(t *testing.T) {
cases := []struct {
name string
bestPeers []peer.ID
filter AssignmentFilter
expectedCount int
description string
}{
{
name: "filter returns empty from sufficient peers",
bestPeers: testPeerIds(10),
filter: func(p []peer.ID) []peer.ID { return []peer.ID{} },
expectedCount: 0,
description: "filter can return empty list even if sufficient peers available",
},
{
name: "filter selects subset from sufficient peers",
bestPeers: testPeerIds(10),
filter: func(p []peer.ID) []peer.ID { return p[0:2] },
expectedCount: 2,
description: "filter can return subset of available peers",
},
{
name: "filter selects single peer from many",
bestPeers: testPeerIds(20),
filter: func(p []peer.ID) []peer.ID { return p[0:1] },
expectedCount: 1,
description: "filter can select single peer from many available",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockStatus := &MockStatus{
bestFinalizedEpoch: 10,
bestPeers: tc.bestPeers,
}
mockCheckpointer := &MockFinalizedCheckpointer{
checkpoint: &forkchoicetypes.Checkpoint{Epoch: 10},
}
assigner := NewAssigner(mockStatus, mockCheckpointer)
result, err := assigner.Assign(tc.filter)
require.NoError(t, err, fmt.Sprintf("%s: unexpected error: %v", tc.description, err))
require.Equal(t, tc.expectedCount, len(result),
fmt.Sprintf("%s: expected %d peers, got %d", tc.description, tc.expectedCount, len(result)))
})
}
}

View File

@@ -704,76 +704,54 @@ func (p *Status) deprecatedPrune() {
p.tallyIPTracker()
}
// BestFinalized returns the highest finalized epoch equal to or higher than `ourFinalizedEpoch`
// that is agreed upon by the majority of peers, and the peers agreeing on this finalized epoch.
// This method may not return the absolute highest finalized epoch, but the finalized epoch in which
// most peers can serve blocks (plurality voting). Ideally, all peers would be reporting the same
// finalized epoch but some may be behind due to their own latency, or because of their finalized
// epoch at the time we queried them.
func (p *Status) BestFinalized(maxPeers int, ourFinalizedEpoch primitives.Epoch) (primitives.Epoch, []peer.ID) {
// Retrieve all connected peers.
// BestFinalized groups all peers by their last known finalized epoch
// and selects the epoch of the largest group as best.
// Any peer with a finalized epoch < ourFinalized is excluded from consideration.
// In the event of a tie in largest group size, the higher epoch is the tie breaker.
// The selected epoch is returned, along with a list of peers with a finalized epoch >= the selected epoch.
func (p *Status) BestFinalized(ourFinalized primitives.Epoch) (primitives.Epoch, []peer.ID) {
connected := p.Connected()
pids := make([]peer.ID, 0, len(connected))
views := make(map[peer.ID]*pb.StatusV2, len(connected))
// key: finalized epoch, value: number of peers that support this finalized epoch.
finalizedEpochVotes := make(map[primitives.Epoch]uint64)
// key: peer ID, value: finalized epoch of the peer.
pidEpoch := make(map[peer.ID]primitives.Epoch, len(connected))
// key: peer ID, value: head slot of the peer.
pidHead := make(map[peer.ID]primitives.Slot, len(connected))
potentialPIDs := make([]peer.ID, 0, len(connected))
votes := make(map[primitives.Epoch]uint64)
winner := primitives.Epoch(0)
for _, pid := range connected {
peerChainState, err := p.ChainState(pid)
// Skip if the peer's finalized epoch is not defined, or if the peer's finalized epoch is
// lower than ours.
if err != nil || peerChainState == nil || peerChainState.FinalizedEpoch < ourFinalizedEpoch {
view, err := p.ChainState(pid)
if err != nil || view == nil || view.FinalizedEpoch < ourFinalized {
continue
}
pids = append(pids, pid)
views[pid] = view
finalizedEpochVotes[peerChainState.FinalizedEpoch]++
pidEpoch[pid] = peerChainState.FinalizedEpoch
pidHead[pid] = peerChainState.HeadSlot
potentialPIDs = append(potentialPIDs, pid)
}
// Select the target epoch, which is the epoch most peers agree upon.
// If there is a tie, select the highest epoch.
targetEpoch, mostVotes := primitives.Epoch(0), uint64(0)
for epoch, count := range finalizedEpochVotes {
if count > mostVotes || (count == mostVotes && epoch > targetEpoch) {
mostVotes = count
targetEpoch = epoch
votes[view.FinalizedEpoch]++
if winner == 0 {
winner = view.FinalizedEpoch
continue
}
e, v := view.FinalizedEpoch, votes[view.FinalizedEpoch]
if v > votes[winner] || v == votes[winner] && e > winner {
winner = e
}
}
// Sort PIDs by finalized (epoch, head), in decreasing order.
sort.Slice(potentialPIDs, func(i, j int) bool {
if pidEpoch[potentialPIDs[i]] == pidEpoch[potentialPIDs[j]] {
return pidHead[potentialPIDs[i]] > pidHead[potentialPIDs[j]]
// Descending sort by (finalized, head).
sort.Slice(pids, func(i, j int) bool {
iv, jv := views[pids[i]], views[pids[j]]
if iv.FinalizedEpoch == jv.FinalizedEpoch {
return iv.HeadSlot > jv.HeadSlot
}
return pidEpoch[potentialPIDs[i]] > pidEpoch[potentialPIDs[j]]
return iv.FinalizedEpoch > jv.FinalizedEpoch
})
// Trim potential peers to those on or after target epoch.
for i, pid := range potentialPIDs {
if pidEpoch[pid] < targetEpoch {
potentialPIDs = potentialPIDs[:i]
break
}
}
// Find the first peer with finalized epoch < winner, trim and all following (lower) peers.
trim := sort.Search(len(pids), func(i int) bool {
return views[pids[i]].FinalizedEpoch < winner
})
pids = pids[:trim]
// Trim potential peers to at most maxPeers.
if len(potentialPIDs) > maxPeers {
potentialPIDs = potentialPIDs[:maxPeers]
}
return targetEpoch, potentialPIDs
return winner, pids
}
// BestNonFinalized returns the highest known epoch, higher than ours,

View File

@@ -654,9 +654,10 @@ func TestTrimmedOrderedPeers(t *testing.T) {
FinalizedRoot: mockroot2[:],
})
target, pids := p.BestFinalized(maxPeers, 0)
target, pids := p.BestFinalized(0)
assert.Equal(t, expectedTarget, target, "Incorrect target epoch retrieved")
assert.Equal(t, maxPeers, len(pids), "Incorrect number of peers retrieved")
// addPeer called 5 times above
assert.Equal(t, 5, len(pids), "Incorrect number of peers retrieved")
// Expect the returned list to be ordered by finalized epoch and trimmed to max peers.
assert.Equal(t, pid3, pids[0], "Incorrect first peer")
@@ -1017,7 +1018,10 @@ func TestStatus_BestPeer(t *testing.T) {
HeadSlot: peerConfig.headSlot,
})
}
epoch, pids := p.BestFinalized(tt.limitPeers, tt.ourFinalizedEpoch)
epoch, pids := p.BestFinalized(tt.ourFinalizedEpoch)
if len(pids) > tt.limitPeers {
pids = pids[:tt.limitPeers]
}
assert.Equal(t, tt.targetEpoch, epoch, "Unexpected epoch retrieved")
assert.Equal(t, tt.targetEpochSupport, len(pids), "Unexpected number of peers supporting retrieved epoch")
})
@@ -1044,7 +1048,10 @@ func TestBestFinalized_returnsMaxValue(t *testing.T) {
})
}
_, pids := p.BestFinalized(maxPeers, 0)
_, pids := p.BestFinalized(0)
if len(pids) > maxPeers {
pids = pids[:maxPeers]
}
assert.Equal(t, maxPeers, len(pids), "Wrong number of peers returned")
}

View File

@@ -77,12 +77,12 @@ func InitializeDataMaps() {
},
bytesutil.ToBytes4(params.BeaconConfig().ElectraForkVersion): func() (interfaces.ReadOnlySignedBeaconBlock, error) {
return blocks.NewSignedBeaconBlock(
&ethpb.SignedBeaconBlockElectra{Block: &ethpb.BeaconBlockElectra{Body: &ethpb.BeaconBlockBodyElectra{ExecutionPayload: &enginev1.ExecutionPayloadDeneb{}}}},
&ethpb.SignedBeaconBlockElectra{Block: &ethpb.BeaconBlockElectra{Body: &ethpb.BeaconBlockBodyElectra{ExecutionPayload: &enginev1.ExecutionPayloadDeneb{}, ExecutionRequests: &enginev1.ExecutionRequests{}}}},
)
},
bytesutil.ToBytes4(params.BeaconConfig().FuluForkVersion): func() (interfaces.ReadOnlySignedBeaconBlock, error) {
return blocks.NewSignedBeaconBlock(
&ethpb.SignedBeaconBlockFulu{Block: &ethpb.BeaconBlockElectra{Body: &ethpb.BeaconBlockBodyElectra{ExecutionPayload: &enginev1.ExecutionPayloadDeneb{}}}},
&ethpb.SignedBeaconBlockFulu{Block: &ethpb.BeaconBlockElectra{Body: &ethpb.BeaconBlockBodyElectra{ExecutionPayload: &enginev1.ExecutionPayloadDeneb{}, ExecutionRequests: &enginev1.ExecutionRequests{}}}},
)
},
}

View File

@@ -73,7 +73,7 @@ func (e *endpoint) handlerWithMiddleware() http.HandlerFunc {
handler.ServeHTTP(rw, r)
if rw.statusCode >= 400 {
httpErrorCount.WithLabelValues(r.URL.Path, http.StatusText(rw.statusCode), r.Method).Inc()
httpErrorCount.WithLabelValues(e.name, http.StatusText(rw.statusCode), r.Method).Inc()
}
}
}

View File

@@ -47,6 +47,10 @@ func TestLightClientHandler_GetLightClientBootstrap(t *testing.T) {
params.OverrideBeaconConfig(cfg)
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
l := util.NewTestLightClient(t, testVersion)
@@ -178,6 +182,10 @@ func TestLightClientHandler_GetLightClientByRange(t *testing.T) {
t.Run("can save retrieve", func(t *testing.T) {
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
slot := primitives.Slot(params.BeaconConfig().VersionToForkEpochMap()[testVersion] * primitives.Epoch(config.SlotsPerEpoch)).Add(1)
@@ -732,6 +740,10 @@ func TestLightClientHandler_GetLightClientFinalityUpdate(t *testing.T) {
})
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
ctx := t.Context()
@@ -827,6 +839,10 @@ func TestLightClientHandler_GetLightClientOptimisticUpdate(t *testing.T) {
})
for _, testVersion := range version.All()[1:] {
if testVersion == version.Gloas {
// TODO(16027): Unskip light client tests for Gloas
continue
}
t.Run(version.String(testVersion), func(t *testing.T) {
ctx := t.Context()
l := util.NewTestLightClient(t, testVersion)

View File

@@ -450,7 +450,7 @@ func (p *BeaconDbBlocker) blobsDataFromStoredDataColumns(root [fieldparams.RootL
if count < peerdas.MinimumColumnCountToReconstruct() {
// There is no way to reconstruct the data columns.
return nil, &core.RpcError{
Err: errors.Errorf("the node does not custody enough data columns to reconstruct blobs - please start the beacon node with the `--%s` flag to ensure this call to succeed, or retry later if it is already the case", flags.SubscribeAllDataSubnets.Name),
Err: errors.Errorf("the node does not custody enough data columns to reconstruct blobs - please start the beacon node with the `--%s` flag to ensure this call to succeed, or retry later if it is already the case", flags.Supernode.Name),
Reason: core.NotFound,
}
}
@@ -555,7 +555,7 @@ func (p *BeaconDbBlocker) blobSidecarsFromStoredDataColumns(block blocks.ROBlock
if count < peerdas.MinimumColumnCountToReconstruct() {
// There is no way to reconstruct the data columns.
return nil, &core.RpcError{
Err: errors.Errorf("the node does not custody enough data columns to reconstruct blobs - please start the beacon node with the `--%s` flag to ensure this call to succeed, or retry later if it is already the case", flags.SubscribeAllDataSubnets.Name),
Err: errors.Errorf("the node does not custody enough data columns to reconstruct blobs - please start the beacon node with the `--%s` flag to ensure this call to succeed, or retry later if it is already the case", flags.Supernode.Name),
Reason: core.NotFound,
}
}

View File

@@ -23,6 +23,7 @@ go_library(
"getters_sync_committee.go",
"getters_validator.go",
"getters_withdrawal.go",
"gloas.go",
"hasher.go",
"multi_value_slices.go",
"proofs.go",

View File

@@ -70,6 +70,14 @@ type BeaconState struct {
pendingConsolidations []*ethpb.PendingConsolidation // pending_consolidations: List[PendingConsolidation, PENDING_CONSOLIDATIONS_LIMIT]
proposerLookahead []primitives.ValidatorIndex // proposer_look_ahead: List[uint64, (MIN_LOOKAHEAD + 1)*SLOTS_PER_EPOCH]
// Gloas fields
latestExecutionPayloadBid *ethpb.ExecutionPayloadBid
executionPayloadAvailability []byte
builderPendingPayments []*ethpb.BuilderPendingPayment
builderPendingWithdrawals []*ethpb.BuilderPendingWithdrawal
latestBlockHash []byte
latestWithdrawalsRoot []byte
id uint64
lock sync.RWMutex
dirtyFields map[types.FieldIndex]bool
@@ -125,6 +133,12 @@ type beaconStateMarshalable struct {
PendingPartialWithdrawals []*ethpb.PendingPartialWithdrawal `json:"pending_partial_withdrawals" yaml:"pending_partial_withdrawals"`
PendingConsolidations []*ethpb.PendingConsolidation `json:"pending_consolidations" yaml:"pending_consolidations"`
ProposerLookahead []primitives.ValidatorIndex `json:"proposer_look_ahead" yaml:"proposer_look_ahead"`
LatestExecutionPayloadBid *ethpb.ExecutionPayloadBid `json:"latest_execution_payload_bid" yaml:"latest_execution_payload_bid"`
ExecutionPayloadAvailability []byte `json:"execution_payload_availability" yaml:"execution_payload_availability"`
BuilderPendingPayments []*ethpb.BuilderPendingPayment `json:"builder_pending_payments" yaml:"builder_pending_payments"`
BuilderPendingWithdrawals []*ethpb.BuilderPendingWithdrawal `json:"builder_pending_withdrawals" yaml:"builder_pending_withdrawals"`
LatestBlockHash []byte `json:"latest_block_hash" yaml:"latest_block_hash"`
LatestWithdrawalsRoot []byte `json:"latest_withdrawals_root" yaml:"latest_withdrawals_root"`
}
func (b *BeaconState) MarshalJSON() ([]byte, error) {
@@ -179,6 +193,12 @@ func (b *BeaconState) MarshalJSON() ([]byte, error) {
PendingPartialWithdrawals: b.pendingPartialWithdrawals,
PendingConsolidations: b.pendingConsolidations,
ProposerLookahead: b.proposerLookahead,
LatestExecutionPayloadBid: b.latestExecutionPayloadBid,
ExecutionPayloadAvailability: b.executionPayloadAvailability,
BuilderPendingPayments: b.builderPendingPayments,
BuilderPendingWithdrawals: b.builderPendingWithdrawals,
LatestBlockHash: b.latestBlockHash,
LatestWithdrawalsRoot: b.latestWithdrawalsRoot,
}
return json.Marshal(marshalable)
}

View File

@@ -259,6 +259,57 @@ func (b *BeaconState) ToProtoUnsafe() any {
PendingConsolidations: b.pendingConsolidations,
ProposerLookahead: lookahead,
}
case version.Gloas:
lookahead := make([]uint64, len(b.proposerLookahead))
for i, v := range b.proposerLookahead {
lookahead[i] = uint64(v)
}
return &ethpb.BeaconStateGloas{
GenesisTime: b.genesisTime,
GenesisValidatorsRoot: gvrCopy[:],
Slot: b.slot,
Fork: b.fork,
LatestBlockHeader: b.latestBlockHeader,
BlockRoots: br,
StateRoots: sr,
HistoricalRoots: b.historicalRoots.Slice(),
Eth1Data: b.eth1Data,
Eth1DataVotes: b.eth1DataVotes,
Eth1DepositIndex: b.eth1DepositIndex,
Validators: vals,
Balances: bals,
RandaoMixes: rm,
Slashings: b.slashings,
PreviousEpochParticipation: b.previousEpochParticipation,
CurrentEpochParticipation: b.currentEpochParticipation,
JustificationBits: b.justificationBits,
PreviousJustifiedCheckpoint: b.previousJustifiedCheckpoint,
CurrentJustifiedCheckpoint: b.currentJustifiedCheckpoint,
FinalizedCheckpoint: b.finalizedCheckpoint,
InactivityScores: inactivityScores,
CurrentSyncCommittee: b.currentSyncCommittee,
NextSyncCommittee: b.nextSyncCommittee,
LatestExecutionPayloadBid: b.latestExecutionPayloadBid,
NextWithdrawalIndex: b.nextWithdrawalIndex,
NextWithdrawalValidatorIndex: b.nextWithdrawalValidatorIndex,
HistoricalSummaries: b.historicalSummaries,
DepositRequestsStartIndex: b.depositRequestsStartIndex,
DepositBalanceToConsume: b.depositBalanceToConsume,
ExitBalanceToConsume: b.exitBalanceToConsume,
EarliestExitEpoch: b.earliestExitEpoch,
ConsolidationBalanceToConsume: b.consolidationBalanceToConsume,
EarliestConsolidationEpoch: b.earliestConsolidationEpoch,
PendingDeposits: b.pendingDeposits,
PendingPartialWithdrawals: b.pendingPartialWithdrawals,
PendingConsolidations: b.pendingConsolidations,
ProposerLookahead: lookahead,
ExecutionPayloadAvailability: b.executionPayloadAvailability,
BuilderPendingPayments: b.builderPendingPayments,
BuilderPendingWithdrawals: b.builderPendingWithdrawals,
LatestBlockHash: b.latestBlockHash,
LatestWithdrawalsRoot: b.latestWithdrawalsRoot,
}
default:
return nil
}
@@ -510,6 +561,57 @@ func (b *BeaconState) ToProto() any {
PendingConsolidations: b.pendingConsolidationsVal(),
ProposerLookahead: lookahead,
}
case version.Gloas:
lookahead := make([]uint64, len(b.proposerLookahead))
for i, v := range b.proposerLookahead {
lookahead[i] = uint64(v)
}
return &ethpb.BeaconStateGloas{
GenesisTime: b.genesisTime,
GenesisValidatorsRoot: gvrCopy[:],
Slot: b.slot,
Fork: b.forkVal(),
LatestBlockHeader: b.latestBlockHeaderVal(),
BlockRoots: br,
StateRoots: sr,
HistoricalRoots: b.historicalRoots.Slice(),
Eth1Data: b.eth1DataVal(),
Eth1DataVotes: b.eth1DataVotesVal(),
Eth1DepositIndex: b.eth1DepositIndex,
Validators: b.validatorsVal(),
Balances: b.balancesVal(),
RandaoMixes: rm,
Slashings: b.slashingsVal(),
PreviousEpochParticipation: b.previousEpochParticipationVal(),
CurrentEpochParticipation: b.currentEpochParticipationVal(),
JustificationBits: b.justificationBitsVal(),
PreviousJustifiedCheckpoint: b.previousJustifiedCheckpointVal(),
CurrentJustifiedCheckpoint: b.currentJustifiedCheckpointVal(),
FinalizedCheckpoint: b.finalizedCheckpointVal(),
InactivityScores: b.inactivityScoresVal(),
CurrentSyncCommittee: b.currentSyncCommitteeVal(),
NextSyncCommittee: b.nextSyncCommitteeVal(),
LatestExecutionPayloadBid: b.latestExecutionPayloadBid.Copy(),
NextWithdrawalIndex: b.nextWithdrawalIndex,
NextWithdrawalValidatorIndex: b.nextWithdrawalValidatorIndex,
HistoricalSummaries: b.historicalSummariesVal(),
DepositRequestsStartIndex: b.depositRequestsStartIndex,
DepositBalanceToConsume: b.depositBalanceToConsume,
ExitBalanceToConsume: b.exitBalanceToConsume,
EarliestExitEpoch: b.earliestExitEpoch,
ConsolidationBalanceToConsume: b.consolidationBalanceToConsume,
EarliestConsolidationEpoch: b.earliestConsolidationEpoch,
PendingDeposits: b.pendingDepositsVal(),
PendingPartialWithdrawals: b.pendingPartialWithdrawalsVal(),
PendingConsolidations: b.pendingConsolidationsVal(),
ProposerLookahead: lookahead,
ExecutionPayloadAvailability: b.executionPayloadAvailabilityVal(),
BuilderPendingPayments: b.builderPendingPaymentsVal(),
BuilderPendingWithdrawals: b.builderPendingWithdrawalsVal(),
LatestBlockHash: b.latestBlockHashVal(),
LatestWithdrawalsRoot: b.latestWithdrawalsRootVal(),
}
default:
return nil
}

View File

@@ -0,0 +1,74 @@
package state_native
import (
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
)
// executionPayloadAvailabilityVal returns a copy of the execution payload availability.
// This assumes that a lock is already held on BeaconState.
func (b *BeaconState) executionPayloadAvailabilityVal() []byte {
if b.executionPayloadAvailability == nil {
return nil
}
availability := make([]byte, len(b.executionPayloadAvailability))
copy(availability, b.executionPayloadAvailability)
return availability
}
// builderPendingPaymentsVal returns a copy of the builder pending payments.
// This assumes that a lock is already held on BeaconState.
func (b *BeaconState) builderPendingPaymentsVal() []*ethpb.BuilderPendingPayment {
if b.builderPendingPayments == nil {
return nil
}
payments := make([]*ethpb.BuilderPendingPayment, len(b.builderPendingPayments))
for i, payment := range b.builderPendingPayments {
payments[i] = payment.Copy()
}
return payments
}
// builderPendingWithdrawalsVal returns a copy of the builder pending withdrawals.
// This assumes that a lock is already held on BeaconState.
func (b *BeaconState) builderPendingWithdrawalsVal() []*ethpb.BuilderPendingWithdrawal {
if b.builderPendingWithdrawals == nil {
return nil
}
withdrawals := make([]*ethpb.BuilderPendingWithdrawal, len(b.builderPendingWithdrawals))
for i, withdrawal := range b.builderPendingWithdrawals {
withdrawals[i] = withdrawal.Copy()
}
return withdrawals
}
// latestBlockHashVal returns a copy of the latest block hash.
// This assumes that a lock is already held on BeaconState.
func (b *BeaconState) latestBlockHashVal() []byte {
if b.latestBlockHash == nil {
return nil
}
hash := make([]byte, len(b.latestBlockHash))
copy(hash, b.latestBlockHash)
return hash
}
// latestWithdrawalsRootVal returns a copy of the latest withdrawals root.
// This assumes that a lock is already held on BeaconState.
func (b *BeaconState) latestWithdrawalsRootVal() []byte {
if b.latestWithdrawalsRoot == nil {
return nil
}
root := make([]byte, len(b.latestWithdrawalsRoot))
copy(root, b.latestWithdrawalsRoot)
return root
}

View File

@@ -43,6 +43,8 @@ func ComputeFieldRootsWithHasher(ctx context.Context, state *BeaconState) ([][]b
fieldRoots = make([][]byte, params.BeaconConfig().BeaconStateElectraFieldCount)
case version.Fulu:
fieldRoots = make([][]byte, params.BeaconConfig().BeaconStateFuluFieldCount)
case version.Gloas:
fieldRoots = make([][]byte, params.BeaconConfig().BeaconStateGloasFieldCount)
default:
return nil, fmt.Errorf("unknown state version %s", version.String(state.version))
}
@@ -245,7 +247,7 @@ func ComputeFieldRootsWithHasher(ctx context.Context, state *BeaconState) ([][]b
fieldRoots[types.LatestExecutionPayloadHeaderCapella.RealPosition()] = executionPayloadRoot[:]
}
if state.version >= version.Deneb {
if state.version >= version.Deneb && state.version < version.Gloas {
// Execution payload root.
executionPayloadRoot, err := state.latestExecutionPayloadHeaderDeneb.HashTreeRoot()
if err != nil {
@@ -254,6 +256,16 @@ func ComputeFieldRootsWithHasher(ctx context.Context, state *BeaconState) ([][]b
fieldRoots[types.LatestExecutionPayloadHeaderDeneb.RealPosition()] = executionPayloadRoot[:]
}
if state.version >= version.Gloas {
// Execution payload bid root for Gloas.
bidRoot, err := state.latestExecutionPayloadBid.HashTreeRoot()
if err != nil {
return nil, err
}
fieldRoots[types.LatestExecutionPayloadBid.RealPosition()] = bidRoot[:]
}
if state.version >= version.Capella {
// Next withdrawal index root.
nextWithdrawalIndexRoot := make([]byte, 32)
@@ -328,5 +340,34 @@ func ComputeFieldRootsWithHasher(ctx context.Context, state *BeaconState) ([][]b
}
fieldRoots[types.ProposerLookahead.RealPosition()] = proposerLookaheadRoot[:]
}
if state.version >= version.Gloas {
epaRoot, err := stateutil.ExecutionPayloadAvailabilityRoot(state.executionPayloadAvailability)
if err != nil {
return nil, errors.Wrap(err, "could not compute execution payload availability merkleization")
}
fieldRoots[types.ExecutionPayloadAvailability.RealPosition()] = epaRoot[:]
bppRoot, err := stateutil.BuilderPendingPaymentsRoot(state.builderPendingPayments)
if err != nil {
return nil, errors.Wrap(err, "could not compute builder pending payments merkleization")
}
fieldRoots[types.BuilderPendingPayments.RealPosition()] = bppRoot[:]
bpwRoot, err := stateutil.BuilderPendingWithdrawalsRoot(state.builderPendingWithdrawals)
if err != nil {
return nil, errors.Wrap(err, "could not compute builder pending withdrawals merkleization")
}
fieldRoots[types.BuilderPendingWithdrawals.RealPosition()] = bpwRoot[:]
lbhRoot := bytesutil.ToBytes32(state.latestBlockHash)
fieldRoots[types.LatestBlockHash.RealPosition()] = lbhRoot[:]
lwrRoot := bytesutil.ToBytes32(state.latestWithdrawalsRoot)
fieldRoots[types.LatestWithdrawalsRoot.RealPosition()] = lwrRoot[:]
}
return fieldRoots, nil
}

View File

@@ -79,24 +79,25 @@ var (
bellatrixFields = append(altairFields, types.LatestExecutionPayloadHeader)
capellaFields = append(
altairFields,
types.LatestExecutionPayloadHeaderCapella,
withdrawalAndHistoricalSummaryFields = []types.FieldIndex{
types.NextWithdrawalIndex,
types.NextWithdrawalValidatorIndex,
types.HistoricalSummaries,
)
}
denebFields = append(
capellaFields = slices.Concat(
altairFields,
types.LatestExecutionPayloadHeaderDeneb,
types.NextWithdrawalIndex,
types.NextWithdrawalValidatorIndex,
types.HistoricalSummaries,
[]types.FieldIndex{types.LatestExecutionPayloadHeaderCapella},
withdrawalAndHistoricalSummaryFields,
)
electraFields = append(
denebFields,
denebFields = slices.Concat(
altairFields,
[]types.FieldIndex{types.LatestExecutionPayloadHeaderDeneb},
withdrawalAndHistoricalSummaryFields,
)
electraAdditionalFields = []types.FieldIndex{
types.DepositRequestsStartIndex,
types.DepositBalanceToConsume,
types.ExitBalanceToConsume,
@@ -106,12 +107,34 @@ var (
types.PendingDeposits,
types.PendingPartialWithdrawals,
types.PendingConsolidations,
}
electraFields = slices.Concat(
denebFields,
electraAdditionalFields,
)
fuluFields = append(
electraFields,
types.ProposerLookahead,
)
gloasAdditionalFields = []types.FieldIndex{
types.ExecutionPayloadAvailability,
types.BuilderPendingPayments,
types.BuilderPendingWithdrawals,
types.LatestBlockHash,
types.LatestWithdrawalsRoot,
}
gloasFields = slices.Concat(
altairFields,
[]types.FieldIndex{types.LatestExecutionPayloadBid},
withdrawalAndHistoricalSummaryFields,
electraAdditionalFields,
[]types.FieldIndex{types.ProposerLookahead},
gloasAdditionalFields,
)
)
const (
@@ -122,6 +145,7 @@ const (
denebSharedFieldRefCount = 7
electraSharedFieldRefCount = 10
fuluSharedFieldRefCount = 11
gloasSharedFieldRefCount = 12 // Adds PendingBuilderWithdrawal to the shared-ref set and LatestExecutionPayloadHeader is removed
)
// InitializeFromProtoPhase0 the beacon state from a protobuf representation.
@@ -159,6 +183,11 @@ func InitializeFromProtoFulu(st *ethpb.BeaconStateFulu) (state.BeaconState, erro
return InitializeFromProtoUnsafeFulu(proto.Clone(st).(*ethpb.BeaconStateFulu))
}
// InitializeFromProtoGloas the beacon state from a protobuf representation.
func InitializeFromProtoGloas(st *ethpb.BeaconStateGloas) (state.BeaconState, error) {
return InitializeFromProtoUnsafeGloas(proto.Clone(st).(*ethpb.BeaconStateGloas))
}
// InitializeFromProtoUnsafePhase0 directly uses the beacon state protobuf fields
// and sets them as fields of the BeaconState type.
func InitializeFromProtoUnsafePhase0(st *ethpb.BeaconState) (state.BeaconState, error) {
@@ -736,6 +765,111 @@ func InitializeFromProtoUnsafeFulu(st *ethpb.BeaconStateFulu) (state.BeaconState
return b, nil
}
// InitializeFromProtoUnsafeGloas directly uses the beacon state protobuf fields
// and sets them as fields of the BeaconState type.
func InitializeFromProtoUnsafeGloas(st *ethpb.BeaconStateGloas) (state.BeaconState, error) {
if st == nil {
return nil, errors.New("received nil state")
}
hRoots := customtypes.HistoricalRoots(make([][32]byte, len(st.HistoricalRoots)))
for i, r := range st.HistoricalRoots {
hRoots[i] = bytesutil.ToBytes32(r)
}
proposerLookahead := make([]primitives.ValidatorIndex, len(st.ProposerLookahead))
for i, v := range st.ProposerLookahead {
proposerLookahead[i] = primitives.ValidatorIndex(v)
}
fieldCount := params.BeaconConfig().BeaconStateGloasFieldCount
b := &BeaconState{
version: version.Gloas,
genesisTime: st.GenesisTime,
genesisValidatorsRoot: bytesutil.ToBytes32(st.GenesisValidatorsRoot),
slot: st.Slot,
fork: st.Fork,
latestBlockHeader: st.LatestBlockHeader,
historicalRoots: hRoots,
eth1Data: st.Eth1Data,
eth1DataVotes: st.Eth1DataVotes,
eth1DepositIndex: st.Eth1DepositIndex,
slashings: st.Slashings,
previousEpochParticipation: st.PreviousEpochParticipation,
currentEpochParticipation: st.CurrentEpochParticipation,
justificationBits: st.JustificationBits,
previousJustifiedCheckpoint: st.PreviousJustifiedCheckpoint,
currentJustifiedCheckpoint: st.CurrentJustifiedCheckpoint,
finalizedCheckpoint: st.FinalizedCheckpoint,
currentSyncCommittee: st.CurrentSyncCommittee,
nextSyncCommittee: st.NextSyncCommittee,
nextWithdrawalIndex: st.NextWithdrawalIndex,
nextWithdrawalValidatorIndex: st.NextWithdrawalValidatorIndex,
historicalSummaries: st.HistoricalSummaries,
depositRequestsStartIndex: st.DepositRequestsStartIndex,
depositBalanceToConsume: st.DepositBalanceToConsume,
exitBalanceToConsume: st.ExitBalanceToConsume,
earliestExitEpoch: st.EarliestExitEpoch,
consolidationBalanceToConsume: st.ConsolidationBalanceToConsume,
earliestConsolidationEpoch: st.EarliestConsolidationEpoch,
pendingDeposits: st.PendingDeposits,
pendingPartialWithdrawals: st.PendingPartialWithdrawals,
pendingConsolidations: st.PendingConsolidations,
proposerLookahead: proposerLookahead,
latestExecutionPayloadBid: st.LatestExecutionPayloadBid,
executionPayloadAvailability: st.ExecutionPayloadAvailability,
builderPendingPayments: st.BuilderPendingPayments,
builderPendingWithdrawals: st.BuilderPendingWithdrawals,
latestBlockHash: st.LatestBlockHash,
latestWithdrawalsRoot: st.LatestWithdrawalsRoot,
dirtyFields: make(map[types.FieldIndex]bool, fieldCount),
dirtyIndices: make(map[types.FieldIndex][]uint64, fieldCount),
stateFieldLeaves: make(map[types.FieldIndex]*fieldtrie.FieldTrie, fieldCount),
rebuildTrie: make(map[types.FieldIndex]bool, fieldCount),
valMapHandler: stateutil.NewValMapHandler(st.Validators),
}
b.blockRootsMultiValue = NewMultiValueBlockRoots(st.BlockRoots)
b.stateRootsMultiValue = NewMultiValueStateRoots(st.StateRoots)
b.randaoMixesMultiValue = NewMultiValueRandaoMixes(st.RandaoMixes)
b.balancesMultiValue = NewMultiValueBalances(st.Balances)
b.validatorsMultiValue = NewMultiValueValidators(st.Validators)
b.inactivityScoresMultiValue = NewMultiValueInactivityScores(st.InactivityScores)
b.sharedFieldReferences = make(map[types.FieldIndex]*stateutil.Reference, gloasSharedFieldRefCount)
for _, f := range gloasFields {
b.dirtyFields[f] = true
b.rebuildTrie[f] = true
b.dirtyIndices[f] = []uint64{}
trie, err := fieldtrie.NewFieldTrie(f, types.BasicArray, nil, 0)
if err != nil {
return nil, err
}
b.stateFieldLeaves[f] = trie
}
// Initialize field reference tracking for shared data.
b.sharedFieldReferences[types.HistoricalRoots] = stateutil.NewRef(1)
b.sharedFieldReferences[types.Eth1DataVotes] = stateutil.NewRef(1)
b.sharedFieldReferences[types.Slashings] = stateutil.NewRef(1)
b.sharedFieldReferences[types.PreviousEpochParticipationBits] = stateutil.NewRef(1)
b.sharedFieldReferences[types.CurrentEpochParticipationBits] = stateutil.NewRef(1)
b.sharedFieldReferences[types.HistoricalSummaries] = stateutil.NewRef(1)
b.sharedFieldReferences[types.PendingDeposits] = stateutil.NewRef(1)
b.sharedFieldReferences[types.PendingPartialWithdrawals] = stateutil.NewRef(1)
b.sharedFieldReferences[types.PendingConsolidations] = stateutil.NewRef(1)
b.sharedFieldReferences[types.ProposerLookahead] = stateutil.NewRef(1)
b.sharedFieldReferences[types.BuilderPendingWithdrawals] = stateutil.NewRef(1) // New in Gloas.
state.Count.Inc()
// Finalizer runs when dst is being destroyed in garbage collection.
runtime.SetFinalizer(b, finalizerCleanup)
return b, nil
}
// Copy returns a deep copy of the beacon state.
func (b *BeaconState) Copy() state.BeaconState {
b.lock.RLock()
@@ -757,6 +891,8 @@ func (b *BeaconState) Copy() state.BeaconState {
fieldCount = params.BeaconConfig().BeaconStateElectraFieldCount
case version.Fulu:
fieldCount = params.BeaconConfig().BeaconStateFuluFieldCount
case version.Gloas:
fieldCount = params.BeaconConfig().BeaconStateGloasFieldCount
}
dst := &BeaconState{
@@ -811,6 +947,12 @@ func (b *BeaconState) Copy() state.BeaconState {
latestExecutionPayloadHeader: b.latestExecutionPayloadHeader.Copy(),
latestExecutionPayloadHeaderCapella: b.latestExecutionPayloadHeaderCapella.Copy(),
latestExecutionPayloadHeaderDeneb: b.latestExecutionPayloadHeaderDeneb.Copy(),
latestExecutionPayloadBid: b.latestExecutionPayloadBid.Copy(),
executionPayloadAvailability: b.executionPayloadAvailabilityVal(),
builderPendingPayments: b.builderPendingPaymentsVal(),
builderPendingWithdrawals: b.builderPendingWithdrawalsVal(),
latestBlockHash: b.latestBlockHashVal(),
latestWithdrawalsRoot: b.latestWithdrawalsRootVal(),
id: types.Enumerator.Inc(),
@@ -847,6 +989,8 @@ func (b *BeaconState) Copy() state.BeaconState {
dst.sharedFieldReferences = make(map[types.FieldIndex]*stateutil.Reference, electraSharedFieldRefCount)
case version.Fulu:
dst.sharedFieldReferences = make(map[types.FieldIndex]*stateutil.Reference, fuluSharedFieldRefCount)
case version.Gloas:
dst.sharedFieldReferences = make(map[types.FieldIndex]*stateutil.Reference, gloasSharedFieldRefCount)
}
for field, ref := range b.sharedFieldReferences {
@@ -942,6 +1086,8 @@ func (b *BeaconState) initializeMerkleLayers(ctx context.Context) error {
b.dirtyFields = make(map[types.FieldIndex]bool, params.BeaconConfig().BeaconStateElectraFieldCount)
case version.Fulu:
b.dirtyFields = make(map[types.FieldIndex]bool, params.BeaconConfig().BeaconStateFuluFieldCount)
case version.Gloas:
b.dirtyFields = make(map[types.FieldIndex]bool, params.BeaconConfig().BeaconStateGloasFieldCount)
default:
return fmt.Errorf("unknown state version (%s) when computing dirty fields in merklization", version.String(b.version))
}
@@ -1180,6 +1326,19 @@ func (b *BeaconState) rootSelector(ctx context.Context, field types.FieldIndex)
return stateutil.PendingConsolidationsRoot(b.pendingConsolidations)
case types.ProposerLookahead:
return stateutil.ProposerLookaheadRoot(b.proposerLookahead)
case types.LatestExecutionPayloadBid:
return b.latestExecutionPayloadBid.HashTreeRoot()
case types.ExecutionPayloadAvailability:
return stateutil.ExecutionPayloadAvailabilityRoot(b.executionPayloadAvailability)
case types.BuilderPendingPayments:
return stateutil.BuilderPendingPaymentsRoot(b.builderPendingPayments)
case types.BuilderPendingWithdrawals:
return stateutil.BuilderPendingWithdrawalsRoot(b.builderPendingWithdrawals)
case types.LatestBlockHash:
return bytesutil.ToBytes32(b.latestBlockHash), nil
case types.LatestWithdrawalsRoot:
return bytesutil.ToBytes32(b.latestWithdrawalsRoot), nil
}
return [32]byte{}, errors.New("invalid field index provided")
}

View File

@@ -88,6 +88,8 @@ func (f FieldIndex) String() string {
return "latestExecutionPayloadHeaderCapella"
case LatestExecutionPayloadHeaderDeneb:
return "latestExecutionPayloadHeaderDeneb"
case LatestExecutionPayloadBid:
return "latestExecutionPayloadBid"
case NextWithdrawalIndex:
return "nextWithdrawalIndex"
case NextWithdrawalValidatorIndex:
@@ -114,6 +116,16 @@ func (f FieldIndex) String() string {
return "pendingConsolidations"
case ProposerLookahead:
return "proposerLookahead"
case ExecutionPayloadAvailability:
return "executionPayloadAvailability"
case BuilderPendingPayments:
return "builderPendingPayments"
case BuilderPendingWithdrawals:
return "builderPendingWithdrawals"
case LatestBlockHash:
return "latestBlockHash"
case LatestWithdrawalsRoot:
return "latestWithdrawalsRoot"
default:
return fmt.Sprintf("unknown field index number: %d", f)
}
@@ -171,7 +183,7 @@ func (f FieldIndex) RealPosition() int {
return 22
case NextSyncCommittee:
return 23
case LatestExecutionPayloadHeader, LatestExecutionPayloadHeaderCapella, LatestExecutionPayloadHeaderDeneb:
case LatestExecutionPayloadHeader, LatestExecutionPayloadHeaderCapella, LatestExecutionPayloadHeaderDeneb, LatestExecutionPayloadBid:
return 24
case NextWithdrawalIndex:
return 25
@@ -199,6 +211,16 @@ func (f FieldIndex) RealPosition() int {
return 36
case ProposerLookahead:
return 37
case ExecutionPayloadAvailability:
return 38
case BuilderPendingPayments:
return 39
case BuilderPendingWithdrawals:
return 40
case LatestBlockHash:
return 41
case LatestWithdrawalsRoot:
return 42
default:
return -1
}
@@ -251,6 +273,7 @@ const (
LatestExecutionPayloadHeader
LatestExecutionPayloadHeaderCapella
LatestExecutionPayloadHeaderDeneb
LatestExecutionPayloadBid // Gloas: EIP-7732
NextWithdrawalIndex
NextWithdrawalValidatorIndex
HistoricalSummaries
@@ -264,6 +287,11 @@ const (
PendingPartialWithdrawals // Electra: EIP-7251
PendingConsolidations // Electra: EIP-7251
ProposerLookahead // Fulu: EIP-7917
ExecutionPayloadAvailability // Gloas: EIP-7732
BuilderPendingPayments // Gloas: EIP-7732
BuilderPendingWithdrawals // Gloas: EIP-7732
LatestBlockHash // Gloas: EIP-7732
LatestWithdrawalsRoot // Gloas: EIP-7732
)
// Enumerator keeps track of the number of states created since the node's start.

View File

@@ -4,7 +4,10 @@ go_library(
name = "go_default_library",
srcs = [
"block_header_root.go",
"builder_pending_payments_root.go",
"builder_pending_withdrawals_root.go",
"eth1_root.go",
"execution_payload_availability_root.go",
"field_root_attestation.go",
"field_root_eth1.go",
"field_root_validator.go",

View File

@@ -0,0 +1,22 @@
package stateutil
import (
"github.com/OffchainLabs/prysm/v7/encoding/ssz"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
)
// BuilderPendingPaymentsRoot computes the merkle root of a slice of BuilderPendingPayment.
func BuilderPendingPaymentsRoot(slice []*ethpb.BuilderPendingPayment) ([32]byte, error) {
roots := make([][32]byte, len(slice))
for i, payment := range slice {
r, err := payment.HashTreeRoot()
if err != nil {
return [32]byte{}, err
}
roots[i] = r
}
return ssz.MerkleizeVector(roots, uint64(len(roots))), nil
}

View File

@@ -0,0 +1,12 @@
package stateutil
import (
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/encoding/ssz"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
)
// BuilderPendingWithdrawalsRoot computes the SSZ root of a slice of BuilderPendingWithdrawal.
func BuilderPendingWithdrawalsRoot(slice []*ethpb.BuilderPendingWithdrawal) ([32]byte, error) {
return ssz.SliceRoot(slice, fieldparams.BuilderPendingWithdrawalsLimit)
}

View File

@@ -0,0 +1,25 @@
package stateutil
import (
"fmt"
"github.com/OffchainLabs/prysm/v7/encoding/ssz"
)
// ExecutionPayloadAvailabilityRoot computes the merkle root of an execution payload availability bitvector.
func ExecutionPayloadAvailabilityRoot(bitvector []byte) ([32]byte, error) {
chunkCount := (len(bitvector) + 31) / 32
chunks := make([][32]byte, chunkCount)
for i := range chunks {
start := i * 32
end := min(start+32, len(bitvector))
copy(chunks[i][:], bitvector[start:end])
}
root, err := ssz.BitwiseMerkleize(chunks, uint64(len(chunks)), uint64(len(chunks)))
if err != nil {
return [32]byte{}, fmt.Errorf("could not merkleize execution payload availability: %w", err)
}
return root, nil
}

View File

@@ -7,6 +7,7 @@ go_library(
"block_batcher.go",
"context.go",
"custody.go",
"data_column_assignment.go",
"data_column_sidecars.go",
"data_columns_reconstruct.go",
"deadlines.go",
@@ -135,6 +136,7 @@ go_library(
"//time:go_default_library",
"//time/slots:go_default_library",
"@com_github_ethereum_go_ethereum//common/hexutil:go_default_library",
"@com_github_ethereum_go_ethereum//p2p/enode:go_default_library",
"@com_github_hashicorp_golang_lru//:go_default_library",
"@com_github_libp2p_go_libp2p//core:go_default_library",
"@com_github_libp2p_go_libp2p//core/host:go_default_library",
@@ -167,6 +169,7 @@ go_test(
"block_batcher_test.go",
"context_test.go",
"custody_test.go",
"data_column_assignment_test.go",
"data_column_sidecars_test.go",
"data_columns_reconstruct_test.go",
"decode_pubsub_test.go",

View File

@@ -6,17 +6,23 @@ go_library(
"batch.go",
"batcher.go",
"blobs.go",
"columns.go",
"error.go",
"fulu_transition.go",
"log.go",
"metrics.go",
"needs.go",
"pool.go",
"service.go",
"status.go",
"verify.go",
"verify_column.go",
"worker.go",
],
importpath = "github.com/OffchainLabs/prysm/v7/beacon-chain/sync/backfill",
visibility = ["//visibility:public"],
deps = [
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/core/signing:go_default_library",
"//beacon-chain/das:go_default_library",
"//beacon-chain/db:go_default_library",
@@ -37,7 +43,6 @@ go_library(
"//proto/dbval:go_default_library",
"//proto/prysm/v1alpha1:go_default_library",
"//runtime:go_default_library",
"//runtime/version:go_default_library",
"//time/slots:go_default_library",
"@com_github_libp2p_go_libp2p//core/peer:go_default_library",
"@com_github_pkg_errors//:go_default_library",
@@ -51,19 +56,27 @@ go_test(
name = "go_default_test",
srcs = [
"batch_test.go",
"batcher_expiration_test.go",
"batcher_test.go",
"blobs_test.go",
"columns_test.go",
"fulu_transition_test.go",
"log_test.go",
"needs_test.go",
"pool_test.go",
"service_test.go",
"status_test.go",
"verify_column_test.go",
"verify_test.go",
],
embed = [":go_default_library"],
deps = [
"//beacon-chain/core/peerdas:go_default_library",
"//beacon-chain/core/signing:go_default_library",
"//beacon-chain/das:go_default_library",
"//beacon-chain/db:go_default_library",
"//beacon-chain/db/filesystem:go_default_library",
"//beacon-chain/p2p/peers:go_default_library",
"//beacon-chain/p2p/testing:go_default_library",
"//beacon-chain/startup:go_default_library",
"//beacon-chain/state:go_default_library",
@@ -85,5 +98,7 @@ go_test(
"@com_github_ethereum_go_ethereum//common/hexutil:go_default_library",
"@com_github_libp2p_go_libp2p//core/peer:go_default_library",
"@com_github_pkg_errors//:go_default_library",
"@com_github_sirupsen_logrus//:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
],
)

View File

@@ -6,9 +6,7 @@ import (
"sort"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/das"
"github.com/OffchainLabs/prysm/v7/beacon-chain/sync"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
eth "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/libp2p/go-libp2p/core/peer"
@@ -16,9 +14,13 @@ import (
"github.com/sirupsen/logrus"
)
// ErrChainBroken indicates a backfill batch can't be imported to the db because it is not known to be the ancestor
// of the canonical chain.
var ErrChainBroken = errors.New("batch is not the ancestor of a known finalized root")
var errChainBroken = errors.New("batch is not the ancestor of a known finalized root")
// retryLogMod defines how often retryable errors are logged at debug level instead of trace.
const retryLogMod = 5
// retryDelay defines the delay between retry attempts for a batch.
const retryDelay = time.Second
type batchState int
@@ -30,16 +32,20 @@ func (s batchState) String() string {
return "init"
case batchSequenced:
return "sequenced"
case batchErrRetryable:
return "error_retryable"
case batchSyncBlobs:
return "sync_blobs"
case batchSyncColumns:
return "sync_columns"
case batchImportable:
return "importable"
case batchImportComplete:
return "import_complete"
case batchEndSequence:
return "end_sequence"
case batchBlobSync:
return "blob_sync"
case batchErrRetryable:
return "error_retryable"
case batchErrFatal:
return "error_fatal"
default:
return "unknown"
}
@@ -49,15 +55,15 @@ const (
batchNil batchState = iota
batchInit
batchSequenced
batchErrRetryable
batchBlobSync
batchSyncBlobs
batchSyncColumns
batchImportable
batchImportComplete
batchErrRetryable
batchErrFatal // if this is received in the main loop, the worker pool will be shut down.
batchEndSequence
)
var retryDelay = time.Second
type batchId string
type batch struct {
@@ -67,35 +73,52 @@ type batch struct {
retries int
retryAfter time.Time
begin primitives.Slot
end primitives.Slot // half-open interval, [begin, end), ie >= start, < end.
results verifiedROBlocks
end primitives.Slot // half-open interval, [begin, end), ie >= begin, < end.
blocks verifiedROBlocks
err error
state batchState
busy peer.ID
blockPid peer.ID
blobPid peer.ID
bs *blobSync
// `assignedPeer` is used by the worker pool to assign and unassign peer.IDs to serve requests for the current batch state.
// Depending on the state it will be copied to blockPeer, columns.Peer, blobs.Peer.
assignedPeer peer.ID
blockPeer peer.ID
nextReqCols []uint64
blobs *blobSync
columns *columnSync
}
func (b batch) logFields() logrus.Fields {
f := map[string]any{
"batchId": b.id(),
"state": b.state.String(),
"scheduled": b.scheduled.String(),
"seq": b.seq,
"retries": b.retries,
"begin": b.begin,
"end": b.end,
"busyPid": b.busy,
"blockPid": b.blockPid,
"blobPid": b.blobPid,
"batchId": b.id(),
"state": b.state.String(),
"scheduled": b.scheduled.String(),
"seq": b.seq,
"retries": b.retries,
"retryAfter": b.retryAfter.String(),
"begin": b.begin,
"end": b.end,
"busyPid": b.assignedPeer,
"blockPid": b.blockPeer,
}
if b.blobs != nil {
f["blobPid"] = b.blobs.peer
}
if b.columns != nil {
f["colPid"] = b.columns.peer
}
if b.retries > 0 {
f["retryAfter"] = b.retryAfter.String()
}
if b.state == batchSyncColumns {
f["nextColumns"] = fmt.Sprintf("%v", b.nextReqCols)
}
if b.state == batchErrRetryable && b.blobs != nil {
f["blobsMissing"] = b.blobs.needed()
}
return f
}
// replaces returns true if `r` is a version of `b` that has been updated by a worker,
// meaning it should replace `b` in the batch sequencing queue.
func (b batch) replaces(r batch) bool {
if r.state == batchImportComplete {
return false
@@ -114,9 +137,9 @@ func (b batch) id() batchId {
}
func (b batch) ensureParent(expected [32]byte) error {
tail := b.results[len(b.results)-1]
tail := b.blocks[len(b.blocks)-1]
if tail.Root() != expected {
return errors.Wrapf(ErrChainBroken, "last parent_root=%#x, tail root=%#x", expected, tail.Root())
return errors.Wrapf(errChainBroken, "last parent_root=%#x, tail root=%#x", expected, tail.Root())
}
return nil
}
@@ -136,21 +159,15 @@ func (b batch) blobRequest() *eth.BlobSidecarsByRangeRequest {
}
}
func (b batch) withResults(results verifiedROBlocks, bs *blobSync) batch {
b.results = results
b.bs = bs
if bs.blobsNeeded() > 0 {
return b.withState(batchBlobSync)
func (b batch) transitionToNext() batch {
if len(b.blocks) == 0 {
return b.withState(batchSequenced)
}
return b.withState(batchImportable)
}
func (b batch) postBlobSync() batch {
if b.blobsNeeded() > 0 {
log.WithFields(b.logFields()).WithField("blobsMissing", b.blobsNeeded()).Error("Batch still missing blobs after downloading from peer")
b.bs = nil
b.results = []blocks.ROBlock{}
return b.withState(batchErrRetryable)
if len(b.columns.columnsNeeded()) > 0 {
return b.withState(batchSyncColumns)
}
if b.blobs != nil && b.blobs.needed() > 0 {
return b.withState(batchSyncBlobs)
}
return b.withState(batchImportable)
}
@@ -159,10 +176,6 @@ func (b batch) withState(s batchState) batch {
if s == batchSequenced {
b.scheduled = time.Now()
switch b.state {
case batchErrRetryable:
b.retries += 1
b.retryAfter = time.Now().Add(retryDelay)
log.WithFields(b.logFields()).Info("Sequencing batch for retry after delay")
case batchInit, batchNil:
b.firstScheduled = b.scheduled
}
@@ -176,27 +189,77 @@ func (b batch) withState(s batchState) batch {
return b
}
func (b batch) withPeer(p peer.ID) batch {
b.blockPid = p
backfillBatchTimeWaiting.Observe(float64(time.Since(b.scheduled).Milliseconds()))
return b
}
func (b batch) withRetryableError(err error) batch {
b.err = err
b.retries += 1
b.retryAfter = time.Now().Add(retryDelay)
msg := "Could not proceed with batch processing due to error"
logBase := log.WithFields(b.logFields()).WithError(err)
// Log at trace level to limit log noise,
// but escalate to debug level every nth attempt for batches that have some peristent issue.
if b.retries&retryLogMod != 0 {
logBase.Trace(msg)
} else {
logBase.Debug(msg)
}
return b.withState(batchErrRetryable)
}
func (b batch) blobsNeeded() int {
return b.bs.blobsNeeded()
func (b batch) withFatalError(err error) batch {
log.WithFields(b.logFields()).WithError(err).Error("Fatal batch processing error")
b.err = err
return b.withState(batchErrFatal)
}
func (b batch) blobResponseValidator() sync.BlobResponseValidation {
return b.bs.validateNext
func (b batch) withError(err error) batch {
if isRetryable(err) {
return b.withRetryableError(err)
}
return b.withFatalError(err)
}
func (b batch) availabilityStore() das.AvailabilityStore {
return b.bs.store
func (b batch) validatingColumnRequest(cb *columnBisector) (*validatingColumnRequest, error) {
req, err := b.columns.request(b.nextReqCols, columnRequestLimit)
if err != nil {
return nil, errors.Wrap(err, "columns request")
}
if req == nil {
return nil, nil
}
return &validatingColumnRequest{
req: req,
columnSync: b.columns,
bisector: cb,
}, nil
}
// resetToRetryColumns is called after a partial batch failure. It adds column indices back
// to the toDownload structure for any blocks where those columns failed, and resets the bisector state.
// Note that this method will also prune any columns that have expired, meaning we no longer need them
// per spec and/or our backfill & retention settings.
func resetToRetryColumns(b batch, needs currentNeeds) batch {
// return the given batch as-is if it isn't in a state that this func should handle.
if b.columns == nil || b.columns.bisector == nil || len(b.columns.bisector.errs) == 0 {
return b.transitionToNext()
}
pruned := make(map[[32]byte]struct{})
b.columns.pruneExpired(needs, pruned)
// clear out failed column state in the bisector and add back to
bisector := b.columns.bisector
roots := bisector.failingRoots()
// Add all the failed columns back to the toDownload structure and reset the bisector state.
for _, root := range roots {
if _, rm := pruned[root]; rm {
continue
}
bc := b.columns.toDownload[root]
bc.remaining.Merge(bisector.failuresFor(root))
}
b.columns.bisector.reset()
return b.transitionToNext()
}
var batchBlockUntil = func(ctx context.Context, untilRetry time.Duration, b batch) error {
@@ -223,6 +286,26 @@ func (b batch) waitUntilReady(ctx context.Context) error {
return nil
}
func (b batch) workComplete() bool {
return b.state == batchImportable
}
func (b batch) expired(needs currentNeeds) bool {
if !needs.block.at(b.end - 1) {
log.WithFields(b.logFields()).WithField("retentionStartSlot", needs.block.begin).Debug("Batch outside retention window")
return true
}
return false
}
func (b batch) selectPeer(picker *sync.PeerPicker, busy map[peer.ID]bool) (peer.ID, []uint64, error) {
if b.state == batchSyncColumns {
return picker.ForColumns(b.columns.columnsNeeded(), busy)
}
peer, err := picker.ForBlocks(busy)
return peer, nil, err
}
func sortBatchDesc(bb []batch) {
sort.Slice(bb, func(i, j int) bool {
return bb[i].end > bb[j].end

View File

@@ -24,17 +24,16 @@ func TestSortBatchDesc(t *testing.T) {
}
func TestWaitUntilReady(t *testing.T) {
b := batch{}.withState(batchErrRetryable)
require.Equal(t, time.Time{}, b.retryAfter)
var got time.Duration
wur := batchBlockUntil
var got time.Duration
var errDerp = errors.New("derp")
batchBlockUntil = func(_ context.Context, ur time.Duration, _ batch) error {
got = ur
return errDerp
}
// retries counter and timestamp are set when we mark the batch for sequencing, if it is in the retry state
b = b.withState(batchSequenced)
b := batch{}.withRetryableError(errors.New("test error"))
require.ErrorIs(t, b.waitUntilReady(t.Context()), errDerp)
require.Equal(t, true, retryDelay-time.Until(b.retryAfter) < time.Millisecond)
require.Equal(t, true, got < retryDelay && got > retryDelay-time.Millisecond)

View File

@@ -10,8 +10,9 @@ var errEndSequence = errors.New("sequence has terminated, no more backfill batch
var errCannotDecreaseMinimum = errors.New("the minimum backfill slot can only be increased, not decreased")
type batchSequencer struct {
batcher batcher
seq []batch
batcher batcher
seq []batch
currentNeeds func() currentNeeds
}
// sequence() is meant as a verb "arrange in a particular order".
@@ -19,32 +20,38 @@ type batchSequencer struct {
// in its internal view. sequence relies on update() for updates to its view of the
// batches it has previously sequenced.
func (c *batchSequencer) sequence() ([]batch, error) {
needs := c.currentNeeds()
s := make([]batch, 0)
// batch start slots are in descending order, c.seq[n].begin == c.seq[n+1].end
for i := range c.seq {
switch c.seq[i].state {
case batchInit, batchErrRetryable:
c.seq[i] = c.seq[i].withState(batchSequenced)
s = append(s, c.seq[i])
case batchNil:
if c.seq[i].state == batchNil {
// batchNil is the zero value of the batch type.
// This case means that we are initializing a batch that was created by the
// initial allocation of the seq slice, so batcher need to compute its bounds.
var b batch
if i == 0 {
// The first item in the list is a special case, subsequent items are initialized
// relative to the preceding batches.
b = c.batcher.before(c.batcher.max)
c.seq[i] = c.batcher.before(c.batcher.max)
} else {
b = c.batcher.beforeBatch(c.seq[i-1])
c.seq[i] = c.batcher.beforeBatch(c.seq[i-1])
}
c.seq[i] = b.withState(batchSequenced)
s = append(s, c.seq[i])
case batchEndSequence:
if len(s) == 0 {
}
if c.seq[i].state == batchInit || c.seq[i].state == batchErrRetryable {
// This means the batch has fallen outside the retention window so we no longer need to sync it.
// Since we always create batches from high to low, we can assume we've already created the
// descendent batches from the batch we're dropping, so there won't be another batch depending on
// this one - we can stop adding batches and mark put this one in the batchEndSequence state.
// When all batches are in batchEndSequence, worker pool spins down and marks backfill complete.
if c.seq[i].expired(needs) {
c.seq[i] = c.seq[i].withState(batchEndSequence)
} else {
c.seq[i] = c.seq[i].withState(batchSequenced)
s = append(s, c.seq[i])
continue
}
default:
}
if c.seq[i].state == batchEndSequence && len(s) == 0 {
s = append(s, c.seq[i])
continue
}
}
@@ -62,6 +69,7 @@ func (c *batchSequencer) sequence() ([]batch, error) {
// seq with new batches that are ready to be worked on.
func (c *batchSequencer) update(b batch) {
done := 0
needs := c.currentNeeds()
for i := 0; i < len(c.seq); i++ {
if b.replaces(c.seq[i]) {
c.seq[i] = b
@@ -73,16 +81,23 @@ func (c *batchSequencer) update(b batch) {
done += 1
continue
}
if c.seq[i].expired(needs) {
c.seq[i] = c.seq[i].withState(batchEndSequence)
done += 1
continue
}
// Move the unfinished batches to overwrite the finished ones.
// eg consider [a,b,c,d,e] where a,b are done
// when i==2, done==2 (since done was incremented for a and b)
// so we want to copy c to a, then on i=3, d to b, then on i=4 e to c.
c.seq[i-done] = c.seq[i]
}
if done == 1 && len(c.seq) == 1 {
if done == len(c.seq) {
c.seq[0] = c.batcher.beforeBatch(c.seq[0])
return
}
// Overwrite the moved batches with the next ones in the sequence.
// Continuing the example in the comment above, len(c.seq)==5, done=2, so i=3.
// We want to replace index 3 with the batch that should be processed after index 2,
@@ -113,18 +128,6 @@ func (c *batchSequencer) importable() []batch {
return imp
}
// moveMinimum enables the backfill service to change the slot where the batcher will start replying with
// batch state batchEndSequence (signaling that no new batches will be produced). This is done in response to
// epochs advancing, which shrinks the gap between <checkpoint slot> and <current slot>-MIN_EPOCHS_FOR_BLOCK_REQUESTS,
// allowing the node to download a smaller number of blocks.
func (c *batchSequencer) moveMinimum(min primitives.Slot) error {
if min < c.batcher.min {
return errCannotDecreaseMinimum
}
c.batcher.min = min
return nil
}
// countWithState provides a view into how many batches are in a particular state
// to be used for logging or metrics purposes.
func (c *batchSequencer) countWithState(s batchState) int {
@@ -158,23 +161,24 @@ func (c *batchSequencer) numTodo() int {
return todo
}
func newBatchSequencer(seqLen int, min, max, size primitives.Slot) *batchSequencer {
b := batcher{min: min, max: max, size: size}
func newBatchSequencer(seqLen int, max, size primitives.Slot, needsCb func() currentNeeds) *batchSequencer {
b := batcher{currentNeeds: needsCb, max: max, size: size}
seq := make([]batch, seqLen)
return &batchSequencer{batcher: b, seq: seq}
return &batchSequencer{batcher: b, seq: seq, currentNeeds: needsCb}
}
type batcher struct {
min primitives.Slot
max primitives.Slot
size primitives.Slot
currentNeeds func() currentNeeds
max primitives.Slot
size primitives.Slot
}
func (r batcher) remaining(upTo primitives.Slot) int {
if r.min >= upTo {
needs := r.currentNeeds()
if !needs.block.at(upTo) {
return 0
}
delta := upTo - r.min
delta := upTo - needs.block.begin
if delta%r.size != 0 {
return int(delta/r.size) + 1
}
@@ -186,13 +190,18 @@ func (r batcher) beforeBatch(upTo batch) batch {
}
func (r batcher) before(upTo primitives.Slot) batch {
// upTo is an exclusive upper bound. Requesting a batch before the lower bound of backfill signals the end of the
// backfill process.
if upTo <= r.min {
// upTo is an exclusive upper bound. If we do not need the block at the upTo slot,
// we don't have anything left to sync, signaling the end of the backfill process.
needs := r.currentNeeds()
// The upper bound is exclusive, so we shouldn't return in this case where the previous
// batch beginning sits at the exact slot of the start of the retention window. In that case
// we've actually hit the end of the sync sequence.
if !needs.block.at(upTo) || needs.block.begin == upTo {
return batch{begin: upTo, end: upTo, state: batchEndSequence}
}
begin := r.min
if upTo > r.size+r.min {
begin := needs.block.begin
if upTo > r.size+needs.block.begin {
begin = upTo - r.size
}

View File

@@ -0,0 +1,830 @@
package backfill
import (
"testing"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/testing/require"
)
// dynamicNeeds provides a mutable currentNeeds callback for testing scenarios
// where the retention window changes over time.
type dynamicNeeds struct {
blockBegin primitives.Slot
blockEnd primitives.Slot
blobBegin primitives.Slot
blobEnd primitives.Slot
colBegin primitives.Slot
colEnd primitives.Slot
}
func newDynamicNeeds(blockBegin, blockEnd primitives.Slot) *dynamicNeeds {
return &dynamicNeeds{
blockBegin: blockBegin,
blockEnd: blockEnd,
blobBegin: blockBegin,
blobEnd: blockEnd,
colBegin: blockBegin,
colEnd: blockEnd,
}
}
func (d *dynamicNeeds) get() currentNeeds {
return currentNeeds{
block: needSpan{begin: d.blockBegin, end: d.blockEnd},
blob: needSpan{begin: d.blobBegin, end: d.blobEnd},
col: needSpan{begin: d.colBegin, end: d.colEnd},
}
}
// advance moves the retention window forward by the given number of slots.
func (d *dynamicNeeds) advance(slots primitives.Slot) {
d.blockBegin += slots
d.blockEnd += slots
d.blobBegin += slots
d.blobEnd += slots
d.colBegin += slots
d.colEnd += slots
}
// setBlockBegin sets only the block retention start slot.
func (d *dynamicNeeds) setBlockBegin(begin primitives.Slot) {
d.blockBegin = begin
}
// ============================================================================
// Category 1: Basic Expiration During sequence()
// ============================================================================
func TestSequenceExpiration_SingleBatchExpires_Init(t *testing.T) {
// Single batch in batchInit expires when needs.block.begin moves past it
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(1, 200, 50, dn.get)
// Initialize batch: [150, 200)
seq.seq[0] = batch{begin: 150, end: 200, state: batchInit}
// Move retention window past the batch
dn.setBlockBegin(200)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchEndSequence, got[0].state)
}
func TestSequenceExpiration_SingleBatchExpires_ErrRetryable(t *testing.T) {
// Single batch in batchErrRetryable expires when needs change
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(1, 200, 50, dn.get)
seq.seq[0] = batch{begin: 150, end: 200, state: batchErrRetryable}
// Move retention window past the batch
dn.setBlockBegin(200)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchEndSequence, got[0].state)
}
func TestSequenceExpiration_MultipleBatchesExpire_Partial(t *testing.T) {
// 4 batches, 2 expire when needs change
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(4, 400, 50, dn.get)
// Batches: [350,400), [300,350), [250,300), [200,250)
seq.seq[0] = batch{begin: 350, end: 400, state: batchInit}
seq.seq[1] = batch{begin: 300, end: 350, state: batchInit}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[3] = batch{begin: 200, end: 250, state: batchInit}
// Move retention to 300 - batches [250,300) and [200,250) should expire
dn.setBlockBegin(300)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 2, len(got))
// First two batches should be sequenced (not expired)
require.Equal(t, batchSequenced, got[0].state)
require.Equal(t, primitives.Slot(350), got[0].begin)
require.Equal(t, batchSequenced, got[1].state)
require.Equal(t, primitives.Slot(300), got[1].begin)
// Verify expired batches are marked batchEndSequence in seq
require.Equal(t, batchEndSequence, seq.seq[2].state)
require.Equal(t, batchEndSequence, seq.seq[3].state)
}
func TestSequenceExpiration_AllBatchesExpire(t *testing.T) {
// All batches expire, returns one batchEndSequence
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
// Move retention past all batches
dn.setBlockBegin(350)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchEndSequence, got[0].state)
}
func TestSequenceExpiration_BatchAtExactBoundary(t *testing.T) {
// Batch with end == needs.block.begin should expire
// Because expired() checks !needs.block.at(b.end - 1)
// If batch.end = 200 and needs.block.begin = 200, then at(199) = false → expired
dn := newDynamicNeeds(200, 500)
seq := newBatchSequencer(1, 250, 50, dn.get)
// Batch [150, 200) - end is exactly at retention start
seq.seq[0] = batch{begin: 150, end: 200, state: batchInit}
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchEndSequence, got[0].state)
}
func TestSequenceExpiration_BatchJustInsideBoundary(t *testing.T) {
// Batch with end == needs.block.begin + 1 should NOT expire
// at(200) with begin=200 returns true
dn := newDynamicNeeds(200, 500)
seq := newBatchSequencer(1, 251, 50, dn.get)
// Batch [200, 251) - end-1 = 250 which is inside [200, 500)
seq.seq[0] = batch{begin: 200, end: 251, state: batchInit}
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchSequenced, got[0].state)
}
// ============================================================================
// Category 2: Expiration During update()
// ============================================================================
func TestUpdateExpiration_UpdateCausesExpiration(t *testing.T) {
// Update a batch while needs have changed, causing other batches to expire
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchSequenced}
seq.seq[1] = batch{begin: 200, end: 250, state: batchSequenced}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
// Move retention window
dn.setBlockBegin(200)
seq.batcher.currentNeeds = dn.get
// Update first batch (should still be valid)
updated := batch{begin: 250, end: 300, state: batchImportable, seq: 1}
seq.update(updated)
// First batch should be updated
require.Equal(t, batchImportable, seq.seq[0].state)
// Third batch should have expired during update
require.Equal(t, batchEndSequence, seq.seq[2].state)
}
func TestUpdateExpiration_MultipleExpireDuringUpdate(t *testing.T) {
// Several batches expire when needs advance significantly
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(4, 400, 50, dn.get)
seq.seq[0] = batch{begin: 350, end: 400, state: batchSequenced}
seq.seq[1] = batch{begin: 300, end: 350, state: batchSequenced}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[3] = batch{begin: 200, end: 250, state: batchInit}
// Move retention to expire last two batches
dn.setBlockBegin(300)
seq.batcher.currentNeeds = dn.get
// Update first batch
updated := batch{begin: 350, end: 400, state: batchImportable, seq: 1}
seq.update(updated)
// Check that expired batches are marked
require.Equal(t, batchEndSequence, seq.seq[2].state)
require.Equal(t, batchEndSequence, seq.seq[3].state)
}
func TestUpdateExpiration_UpdateCompleteWhileExpiring(t *testing.T) {
// Mark batch complete while other batches expire
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchImportable}
seq.seq[1] = batch{begin: 200, end: 250, state: batchSequenced}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
// Move retention to expire last batch
dn.setBlockBegin(200)
seq.batcher.currentNeeds = dn.get
// Mark first batch complete
completed := batch{begin: 250, end: 300, state: batchImportComplete, seq: 1}
seq.update(completed)
// Completed batch removed, third batch should have expired
// Check that we still have 3 batches (shifted + new ones added)
require.Equal(t, 3, len(seq.seq))
// The batch that was at index 2 should now be expired
foundExpired := false
for _, b := range seq.seq {
if b.state == batchEndSequence {
foundExpired = true
break
}
}
require.Equal(t, true, foundExpired, "should have an expired batch")
}
func TestUpdateExpiration_ExpiredBatchNotShiftedIncorrectly(t *testing.T) {
// Verify expired batches don't get incorrectly shifted
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchImportComplete}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
// Move retention to expire all remaining init batches
dn.setBlockBegin(250)
seq.batcher.currentNeeds = dn.get
// Update with the completed batch
completed := batch{begin: 250, end: 300, state: batchImportComplete, seq: 1}
seq.update(completed)
// Verify sequence integrity
require.Equal(t, 3, len(seq.seq))
}
func TestUpdateExpiration_NewBatchCreatedRespectsNeeds(t *testing.T) {
// When new batch is created after expiration, it should respect current needs
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(2, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchImportable}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
// Mark first batch complete to trigger new batch creation
completed := batch{begin: 250, end: 300, state: batchImportComplete, seq: 1}
seq.update(completed)
// New batch should be created - verify it respects the needs
require.Equal(t, 2, len(seq.seq))
// New batch should have proper bounds
for _, b := range seq.seq {
if b.state == batchNil {
continue
}
require.Equal(t, true, b.begin < b.end, "batch bounds should be valid")
}
}
// ============================================================================
// Category 3: Progressive Slot Advancement
// ============================================================================
func TestProgressiveAdvancement_SlotAdvancesGradually(t *testing.T) {
// Simulate gradual slot advancement with batches expiring one by one
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(4, 400, 50, dn.get)
// Initialize batches
seq.seq[0] = batch{begin: 350, end: 400, state: batchInit}
seq.seq[1] = batch{begin: 300, end: 350, state: batchInit}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[3] = batch{begin: 200, end: 250, state: batchInit}
// First sequence - all should be returned
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 4, len(got))
// Advance by 50 slots - last batch should expire
dn.setBlockBegin(250)
seq.batcher.currentNeeds = dn.get
// Mark first batch importable and update
seq.seq[0].state = batchImportable
seq.update(seq.seq[0])
// Last batch should now be expired
require.Equal(t, batchEndSequence, seq.seq[3].state)
// Advance again
dn.setBlockBegin(300)
seq.batcher.currentNeeds = dn.get
seq.seq[1].state = batchImportable
seq.update(seq.seq[1])
// Count expired batches
expiredCount := 0
for _, b := range seq.seq {
if b.state == batchEndSequence {
expiredCount++
}
}
require.Equal(t, true, expiredCount >= 2, "expected at least 2 expired batches")
}
func TestProgressiveAdvancement_SlotAdvancesInBursts(t *testing.T) {
// Large jump in slots causes multiple batches to expire at once
dn := newDynamicNeeds(100, 600)
seq := newBatchSequencer(6, 500, 50, dn.get)
// Initialize batches: [450,500), [400,450), [350,400), [300,350), [250,300), [200,250)
for i := range 6 {
seq.seq[i] = batch{
begin: primitives.Slot(500 - (i+1)*50),
end: primitives.Slot(500 - i*50),
state: batchInit,
}
}
// Large jump - expire 4 batches at once
dn.setBlockBegin(400)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
// Should have 2 non-expired batches returned
nonExpired := 0
for _, b := range got {
if b.state == batchSequenced {
nonExpired++
}
}
require.Equal(t, 2, nonExpired)
}
func TestProgressiveAdvancement_WorkerProcessingDuringAdvancement(t *testing.T) {
// Batches in various processing states while needs advance
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(4, 400, 50, dn.get)
seq.seq[0] = batch{begin: 350, end: 400, state: batchSyncBlobs}
seq.seq[1] = batch{begin: 300, end: 350, state: batchSyncColumns}
seq.seq[2] = batch{begin: 250, end: 300, state: batchSequenced}
seq.seq[3] = batch{begin: 200, end: 250, state: batchInit}
// Advance past last batch
dn.setBlockBegin(250)
seq.batcher.currentNeeds = dn.get
// Call sequence - only batchInit should transition
got, err := seq.sequence()
require.NoError(t, err)
// batchInit batch should have expired
require.Equal(t, batchEndSequence, seq.seq[3].state)
// Batches in other states should not be returned by sequence (already dispatched)
for _, b := range got {
require.NotEqual(t, batchSyncBlobs, b.state)
require.NotEqual(t, batchSyncColumns, b.state)
}
}
func TestProgressiveAdvancement_CompleteBeforeExpiration(t *testing.T) {
// Batch completes just before it would expire
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(2, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchSequenced}
seq.seq[1] = batch{begin: 200, end: 250, state: batchImportable}
// Complete the second batch BEFORE advancing needs
completed := batch{begin: 200, end: 250, state: batchImportComplete, seq: 1}
seq.update(completed)
// Now advance needs past where the batch was
dn.setBlockBegin(250)
seq.batcher.currentNeeds = dn.get
// The completed batch should have been removed successfully
// Sequence should work normally
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, true, len(got) >= 1, "expected at least 1 batch")
}
// ============================================================================
// Category 4: Batch State Transitions Under Expiration
// ============================================================================
func TestStateExpiration_NilBatchNotExpired(t *testing.T) {
// batchNil should be initialized, not expired
dn := newDynamicNeeds(200, 500)
seq := newBatchSequencer(2, 300, 50, dn.get)
// Leave seq[0] as batchNil (zero value)
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
got, err := seq.sequence()
require.NoError(t, err)
// batchNil should have been initialized and sequenced
foundSequenced := false
for _, b := range got {
if b.state == batchSequenced {
foundSequenced = true
}
}
require.Equal(t, true, foundSequenced, "expected at least one sequenced batch")
}
func TestStateExpiration_InitBatchExpires(t *testing.T) {
// batchInit batches expire when outside retention
dn := newDynamicNeeds(200, 500)
seq := newBatchSequencer(1, 250, 50, dn.get)
seq.seq[0] = batch{begin: 150, end: 200, state: batchInit}
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchEndSequence, got[0].state)
}
func TestStateExpiration_SequencedBatchNotCheckedBySequence(t *testing.T) {
// batchSequenced batches are not returned by sequence() (already dispatched)
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(2, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchSequenced}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
// Move retention past second batch
dn.setBlockBegin(250)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
// Init batch should expire, sequenced batch not returned
for _, b := range got {
require.NotEqual(t, batchSequenced, b.state)
}
}
func TestStateExpiration_SyncBlobsBatchNotCheckedBySequence(t *testing.T) {
// batchSyncBlobs not returned by sequence
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(1, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchSyncBlobs}
_, err := seq.sequence()
require.ErrorIs(t, err, errMaxBatches) // No batch to return
}
func TestStateExpiration_SyncColumnsBatchNotCheckedBySequence(t *testing.T) {
// batchSyncColumns not returned by sequence
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(1, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchSyncColumns}
_, err := seq.sequence()
require.ErrorIs(t, err, errMaxBatches)
}
func TestStateExpiration_ImportableBatchNotCheckedBySequence(t *testing.T) {
// batchImportable not returned by sequence
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(1, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchImportable}
_, err := seq.sequence()
require.ErrorIs(t, err, errMaxBatches)
}
func TestStateExpiration_RetryableBatchExpires(t *testing.T) {
// batchErrRetryable batches can expire
dn := newDynamicNeeds(200, 500)
seq := newBatchSequencer(1, 250, 50, dn.get)
seq.seq[0] = batch{begin: 150, end: 200, state: batchErrRetryable}
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
require.Equal(t, batchEndSequence, got[0].state)
}
// ============================================================================
// Category 5: Edge Cases and Boundaries
// ============================================================================
func TestEdgeCase_NeedsSpanShrinks(t *testing.T) {
// Unusual case: retention window becomes smaller
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 400, 50, dn.get)
seq.seq[0] = batch{begin: 350, end: 400, state: batchInit}
seq.seq[1] = batch{begin: 300, end: 350, state: batchInit}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
// Shrink window from both ends
dn.blockBegin = 300
dn.blockEnd = 400
seq.batcher.currentNeeds = dn.get
_, err := seq.sequence()
require.NoError(t, err)
// Third batch should have expired
require.Equal(t, batchEndSequence, seq.seq[2].state)
}
func TestEdgeCase_EmptySequenceAfterExpiration(t *testing.T) {
// All batches in non-schedulable states, none can be sequenced
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(2, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchImportable}
seq.seq[1] = batch{begin: 200, end: 250, state: batchImportable}
// No batchInit or batchErrRetryable to sequence
_, err := seq.sequence()
require.ErrorIs(t, err, errMaxBatches)
}
func TestEdgeCase_EndSequenceChainReaction(t *testing.T) {
// When batches expire, subsequent calls should handle them correctly
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
// Expire all
dn.setBlockBegin(300)
seq.batcher.currentNeeds = dn.get
got1, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got1))
require.Equal(t, batchEndSequence, got1[0].state)
// Calling sequence again should still return batchEndSequence
got2, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got2))
require.Equal(t, batchEndSequence, got2[0].state)
}
func TestEdgeCase_MixedExpirationAndCompletion(t *testing.T) {
// Some batches complete while others expire simultaneously
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(4, 400, 50, dn.get)
seq.seq[0] = batch{begin: 350, end: 400, state: batchImportComplete}
seq.seq[1] = batch{begin: 300, end: 350, state: batchImportable}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[3] = batch{begin: 200, end: 250, state: batchInit}
// Expire last two batches
dn.setBlockBegin(300)
seq.batcher.currentNeeds = dn.get
// Update with completed batch to trigger processing
completed := batch{begin: 350, end: 400, state: batchImportComplete, seq: 1}
seq.update(completed)
// Verify expired batches are marked
expiredCount := 0
for _, b := range seq.seq {
if b.state == batchEndSequence {
expiredCount++
}
}
require.Equal(t, true, expiredCount >= 2, "expected at least 2 expired batches")
}
func TestEdgeCase_BatchExpiresAtSlotZero(t *testing.T) {
// Edge case with very low slot numbers
dn := newDynamicNeeds(50, 200)
seq := newBatchSequencer(2, 100, 50, dn.get)
seq.seq[0] = batch{begin: 50, end: 100, state: batchInit}
seq.seq[1] = batch{begin: 0, end: 50, state: batchInit}
// Move past first batch
dn.setBlockBegin(100)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
// Both batches should have expired
for _, b := range got {
require.Equal(t, batchEndSequence, b.state)
}
}
// ============================================================================
// Category 6: Integration with numTodo/remaining
// ============================================================================
func TestNumTodo_AfterExpiration(t *testing.T) {
// numTodo should correctly reflect expired batches
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchSequenced}
seq.seq[1] = batch{begin: 200, end: 250, state: batchSequenced}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
todoBefore := seq.numTodo()
// Expire last batch
dn.setBlockBegin(200)
seq.batcher.currentNeeds = dn.get
// Force expiration via sequence
_, err := seq.sequence()
require.NoError(t, err)
todoAfter := seq.numTodo()
// Todo count should have decreased
require.Equal(t, true, todoAfter < todoBefore, "expected todo count to decrease after expiration")
}
func TestRemaining_AfterNeedsChange(t *testing.T) {
// batcher.remaining() should use updated needs
dn := newDynamicNeeds(100, 500)
b := batcher{currentNeeds: dn.get, size: 50}
remainingBefore := b.remaining(300)
// Move retention window
dn.setBlockBegin(250)
b.currentNeeds = dn.get
remainingAfter := b.remaining(300)
// Remaining should have decreased
require.Equal(t, true, remainingAfter < remainingBefore, "expected remaining to decrease after needs change")
}
func TestCountWithState_AfterExpiration(t *testing.T) {
// State counts should be accurate after expiration
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
require.Equal(t, 3, seq.countWithState(batchInit))
require.Equal(t, 0, seq.countWithState(batchEndSequence))
// Expire all batches
dn.setBlockBegin(300)
seq.batcher.currentNeeds = dn.get
_, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 0, seq.countWithState(batchInit))
require.Equal(t, 3, seq.countWithState(batchEndSequence))
}
// ============================================================================
// Category 7: Fork Transition Scenarios (Blob/Column specific)
// ============================================================================
func TestForkTransition_BlobNeedsChange(t *testing.T) {
// Test when blob retention is different from block retention
dn := newDynamicNeeds(100, 500)
// Set blob begin to be further ahead
dn.blobBegin = 200
seq := newBatchSequencer(3, 300, 50, dn.get)
seq.seq[0] = batch{begin: 250, end: 300, state: batchInit}
seq.seq[1] = batch{begin: 200, end: 250, state: batchInit}
seq.seq[2] = batch{begin: 150, end: 200, state: batchInit}
// Sequence should work based on block needs
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 3, len(got))
}
func TestForkTransition_ColumnNeedsChange(t *testing.T) {
// Test when column retention is different from block retention
dn := newDynamicNeeds(100, 500)
// Set column begin to be further ahead
dn.colBegin = 300
seq := newBatchSequencer(3, 400, 50, dn.get)
seq.seq[0] = batch{begin: 350, end: 400, state: batchInit}
seq.seq[1] = batch{begin: 300, end: 350, state: batchInit}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
// Batch expiration is based on block needs, not column needs
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 3, len(got))
}
func TestForkTransition_BlockNeedsVsBlobNeeds(t *testing.T) {
// Blocks still needed but blobs have shorter retention
dn := newDynamicNeeds(100, 500)
dn.blobBegin = 300 // Blobs only needed from slot 300
dn.blobEnd = 500
seq := newBatchSequencer(3, 400, 50, dn.get)
seq.seq[0] = batch{begin: 350, end: 400, state: batchInit}
seq.seq[1] = batch{begin: 300, end: 350, state: batchInit}
seq.seq[2] = batch{begin: 250, end: 300, state: batchInit}
// All batches should be returned (block expiration, not blob)
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 3, len(got))
// Now change block needs to match blob needs
dn.blockBegin = 300
seq.batcher.currentNeeds = dn.get
// Re-sequence - last batch should expire
seq.seq[0].state = batchInit
seq.seq[1].state = batchInit
seq.seq[2].state = batchInit
got2, err := seq.sequence()
require.NoError(t, err)
// Should have 2 non-expired batches
nonExpired := 0
for _, b := range got2 {
if b.state == batchSequenced {
nonExpired++
}
}
require.Equal(t, 2, nonExpired)
}
func TestForkTransition_AllResourceTypesAdvance(t *testing.T) {
// Block, blob, and column spans all advance together
dn := newDynamicNeeds(100, 500)
seq := newBatchSequencer(4, 400, 50, dn.get)
// Batches: [350,400), [300,350), [250,300), [200,250)
for i := range 4 {
seq.seq[i] = batch{
begin: primitives.Slot(400 - (i+1)*50),
end: primitives.Slot(400 - i*50),
state: batchInit,
}
}
// Advance all needs together by 200 slots
// blockBegin moves from 100 to 300
dn.advance(200)
seq.batcher.currentNeeds = dn.get
got, err := seq.sequence()
require.NoError(t, err)
// Count non-expired
nonExpired := 0
for _, b := range got {
if b.state == batchSequenced {
nonExpired++
}
}
// With begin=300, batches [200,250) and [250,300) should have expired
// Batches [350,400) and [300,350) remain valid
require.Equal(t, 2, nonExpired)
}

View File

@@ -17,7 +17,7 @@ func TestBatcherBefore(t *testing.T) {
}{
{
name: "size 10",
b: batcher{min: 0, size: 10},
b: batcher{currentNeeds: mockCurrentNeedsFunc(0, 100), size: 10},
upTo: []primitives.Slot{33, 30, 10, 6},
expect: []batch{
{begin: 23, end: 33, state: batchInit},
@@ -28,7 +28,7 @@ func TestBatcherBefore(t *testing.T) {
},
{
name: "size 4",
b: batcher{min: 0, size: 4},
b: batcher{currentNeeds: mockCurrentNeedsFunc(0, 100), size: 4},
upTo: []primitives.Slot{33, 6, 4},
expect: []batch{
{begin: 29, end: 33, state: batchInit},
@@ -38,7 +38,7 @@ func TestBatcherBefore(t *testing.T) {
},
{
name: "trigger end",
b: batcher{min: 20, size: 10},
b: batcher{currentNeeds: mockCurrentNeedsFunc(20, 100), size: 10},
upTo: []primitives.Slot{33, 30, 25, 21, 20, 19},
expect: []batch{
{begin: 23, end: 33, state: batchInit},
@@ -71,7 +71,7 @@ func TestBatchSingleItem(t *testing.T) {
min = 0
max = 11235
size = 64
seq := newBatchSequencer(seqLen, min, max, size)
seq := newBatchSequencer(seqLen, max, size, mockCurrentNeedsFunc(min, max+1))
got, err := seq.sequence()
require.NoError(t, err)
require.Equal(t, 1, len(got))
@@ -99,7 +99,7 @@ func TestBatchSequencer(t *testing.T) {
min = 0
max = 11235
size = 64
seq := newBatchSequencer(seqLen, min, max, size)
seq := newBatchSequencer(seqLen, max, size, mockCurrentNeedsFunc(min, max+1))
expected := []batch{
{begin: 11171, end: 11235},
{begin: 11107, end: 11171},
@@ -212,7 +212,10 @@ func TestBatchSequencer(t *testing.T) {
// set the min for the batcher close to the lowest slot. This will force the next batch to be partial and the batch
// after that to be the final batch.
newMin := seq.seq[len(seq.seq)-1].begin - 30
seq.batcher.min = newMin
seq.currentNeeds = func() currentNeeds {
return currentNeeds{block: needSpan{begin: newMin, end: seq.batcher.max}}
}
seq.batcher.currentNeeds = seq.currentNeeds
first = seq.seq[0]
first.state = batchImportComplete
// update() with a complete state will cause the sequence to be extended with an additional batch
@@ -235,3 +238,863 @@ func TestBatchSequencer(t *testing.T) {
//require.ErrorIs(t, err, errEndSequence)
require.Equal(t, batchEndSequence, end.state)
}
// initializeBatchWithSlots sets the begin and end slot values for a batch
// in descending order (slot positions decrease as index increases)
func initializeBatchWithSlots(batches []batch, min primitives.Slot, size primitives.Slot) {
for i := range batches {
// Batches are ordered descending by slot: earliest batches have lower indices
// so batch[0] covers highest slots, batch[N] covers lowest slots
end := min + primitives.Slot((len(batches)-i)*int(size))
begin := end - size
batches[i].begin = begin
batches[i].end = end
}
}
// TestSequence tests the sequence() method with various batch states
func TestSequence(t *testing.T) {
testCases := []struct {
name string
seqLen int
min primitives.Slot
max primitives.Slot
size primitives.Slot
initialStates []batchState
expectedCount int
expectedErr error
stateTransform func([]batch) // optional: transform states before test
}{
{
name: "EmptySequence",
seqLen: 0,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{},
expectedCount: 0,
expectedErr: errMaxBatches,
},
{
name: "SingleBatchInit",
seqLen: 1,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{batchInit},
expectedCount: 1,
},
{
name: "SingleBatchErrRetryable",
seqLen: 1,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{batchErrRetryable},
expectedCount: 1,
},
{
name: "MultipleBatchesInit",
seqLen: 3,
min: 100,
max: 1000,
size: 200,
initialStates: []batchState{batchInit, batchInit, batchInit},
expectedCount: 3,
},
{
name: "MixedStates_InitAndSequenced",
seqLen: 2,
min: 100,
max: 1000,
size: 100,
initialStates: []batchState{batchInit, batchSequenced},
expectedCount: 1,
},
{
name: "MixedStates_SequencedFirst",
seqLen: 2,
min: 100,
max: 1000,
size: 100,
initialStates: []batchState{batchSequenced, batchInit},
expectedCount: 1,
},
{
name: "AllBatchesSequenced",
seqLen: 3,
min: 100,
max: 1000,
size: 200,
initialStates: []batchState{batchSequenced, batchSequenced, batchSequenced},
expectedCount: 0,
expectedErr: errMaxBatches,
},
{
name: "EndSequenceOnly",
seqLen: 1,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{batchEndSequence},
expectedCount: 1,
},
{
name: "EndSequenceWithOthers",
seqLen: 2,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{batchInit, batchEndSequence},
expectedCount: 1,
},
{
name: "ImportableNotSequenced",
seqLen: 1,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{batchImportable},
expectedCount: 0,
expectedErr: errMaxBatches,
},
{
name: "ImportCompleteNotSequenced",
seqLen: 1,
min: 100,
max: 1000,
size: 64,
initialStates: []batchState{batchImportComplete},
expectedCount: 0,
expectedErr: errMaxBatches,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seq := newBatchSequencer(tc.seqLen, tc.max, tc.size, mockCurrentNeedsFunc(tc.min, tc.max+1))
// Initialize batches with valid slot ranges
initializeBatchWithSlots(seq.seq, tc.min, tc.size)
// Set initial states
for i, state := range tc.initialStates {
seq.seq[i].state = state
}
// Apply any transformations
if tc.stateTransform != nil {
tc.stateTransform(seq.seq)
}
got, err := seq.sequence()
if tc.expectedErr != nil {
require.ErrorIs(t, err, tc.expectedErr)
} else {
require.NoError(t, err)
}
require.Equal(t, tc.expectedCount, len(got))
// Verify returned batches are in batchSequenced state
for _, b := range got {
if b.state != batchEndSequence {
require.Equal(t, batchSequenced, b.state)
}
}
})
}
}
// TestUpdate tests the update() method which: (1) updates batch state, (2) removes batchImportComplete batches,
// (3) shifts remaining batches down, and (4) adds new batches to fill vacated positions.
// NOTE: The sequence length can change! Completed batches are removed and new ones are added.
func TestUpdate(t *testing.T) {
testCases := []struct {
name string
seqLen int
batches []batchState
updateIdx int
newState batchState
expectedLen int // expected length after update
expected []batchState // expected states of first N batches after update
}{
{
name: "SingleBatchUpdate",
seqLen: 1,
batches: []batchState{batchInit},
updateIdx: 0,
newState: batchImportable,
expectedLen: 1,
expected: []batchState{batchImportable},
},
{
name: "RemoveFirstCompleted_ShiftOthers",
seqLen: 3,
batches: []batchState{batchImportComplete, batchInit, batchInit},
updateIdx: 0,
newState: batchImportComplete,
expectedLen: 3, // 1 removed + 2 new added
expected: []batchState{batchInit, batchInit}, // shifted down
},
{
name: "RemoveMultipleCompleted",
seqLen: 3,
batches: []batchState{batchImportComplete, batchImportComplete, batchInit},
updateIdx: 0,
newState: batchImportComplete,
expectedLen: 3, // 2 removed + 2 new added
expected: []batchState{batchInit}, // only 1 non-complete batch
},
{
name: "RemoveMiddleCompleted_AlsoShifts",
seqLen: 3,
batches: []batchState{batchInit, batchImportComplete, batchInit},
updateIdx: 1,
newState: batchImportComplete,
expectedLen: 3, // 1 removed + 1 new added
expected: []batchState{batchInit, batchInit}, // middle complete removed, last shifted to middle
},
{
name: "SingleBatchComplete_Replaced",
seqLen: 1,
batches: []batchState{batchInit},
updateIdx: 0,
newState: batchImportComplete,
expectedLen: 1, // special case: replaced with new batch
expected: []batchState{batchInit}, // new batch from beforeBatch
},
{
name: "UpdateNonMatchingBatch",
seqLen: 2,
batches: []batchState{batchInit, batchInit},
updateIdx: 0,
newState: batchImportable,
expectedLen: 2,
expected: []batchState{batchImportable, batchInit},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seq := newBatchSequencer(tc.seqLen, 1000, 64, mockCurrentNeedsFunc(0, 1000+1))
// Initialize batches with proper slot ranges
for i := range seq.seq {
seq.seq[i] = batch{
begin: primitives.Slot(1000 - (i+1)*64),
end: primitives.Slot(1000 - i*64),
state: tc.batches[i],
}
}
// Create batch to update (must match begin/end to be replaced)
updateBatch := seq.seq[tc.updateIdx]
updateBatch.state = tc.newState
seq.update(updateBatch)
// Verify expected length
if len(seq.seq) != tc.expectedLen {
t.Fatalf("expected length %d, got %d", tc.expectedLen, len(seq.seq))
}
// Verify expected states of first N batches
for i, expectedState := range tc.expected {
if i >= len(seq.seq) {
t.Fatalf("expected state at index %d but seq only has %d batches", i, len(seq.seq))
}
if seq.seq[i].state != expectedState {
t.Fatalf("batch[%d]: expected state %s, got %s", i, expectedState.String(), seq.seq[i].state.String())
}
}
// Verify slot contiguity for non-newly-generated batches
// (newly generated batches from beforeBatch() may not be contiguous with shifted batches)
// For this test, we just verify they're in valid slot ranges
for i := 0; i < len(seq.seq); i++ {
if seq.seq[i].begin >= seq.seq[i].end {
t.Fatalf("invalid batch[%d]: begin=%d should be < end=%d", i, seq.seq[i].begin, seq.seq[i].end)
}
}
})
}
}
// TestImportable tests the importable() method for contiguity checking
func TestImportable(t *testing.T) {
testCases := []struct {
name string
seqLen int
states []batchState
expectedCount int
expectedBreak int // index where importable chain breaks (-1 if none)
}{
{
name: "EmptySequence",
seqLen: 0,
states: []batchState{},
expectedCount: 0,
expectedBreak: -1,
},
{
name: "FirstBatchNotImportable",
seqLen: 2,
states: []batchState{batchInit, batchImportable},
expectedCount: 0,
expectedBreak: 0,
},
{
name: "FirstBatchImportable",
seqLen: 1,
states: []batchState{batchImportable},
expectedCount: 1,
expectedBreak: -1,
},
{
name: "TwoImportableConsecutive",
seqLen: 2,
states: []batchState{batchImportable, batchImportable},
expectedCount: 2,
expectedBreak: -1,
},
{
name: "ThreeImportableConsecutive",
seqLen: 3,
states: []batchState{batchImportable, batchImportable, batchImportable},
expectedCount: 3,
expectedBreak: -1,
},
{
name: "ImportsBreak_SecondNotImportable",
seqLen: 2,
states: []batchState{batchImportable, batchInit},
expectedCount: 1,
expectedBreak: 1,
},
{
name: "ImportsBreak_MiddleNotImportable",
seqLen: 4,
states: []batchState{batchImportable, batchImportable, batchInit, batchImportable},
expectedCount: 2,
expectedBreak: 2,
},
{
name: "EndSequenceAfterImportable",
seqLen: 3,
states: []batchState{batchImportable, batchImportable, batchEndSequence},
expectedCount: 2,
expectedBreak: 2,
},
{
name: "AllStatesNotImportable",
seqLen: 3,
states: []batchState{batchInit, batchSequenced, batchErrRetryable},
expectedCount: 0,
expectedBreak: 0,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seq := newBatchSequencer(tc.seqLen, 1000, 64, mockCurrentNeedsFunc(0, 1000+1))
for i, state := range tc.states {
seq.seq[i] = batch{
begin: primitives.Slot(1000 - (i+1)*64),
end: primitives.Slot(1000 - i*64),
state: state,
}
}
imp := seq.importable()
require.Equal(t, tc.expectedCount, len(imp))
})
}
}
// TestMoveMinimumWithNonImportableUpdate tests integration of moveMinimum with update()
func TestMoveMinimumWithNonImportableUpdate(t *testing.T) {
t.Run("UpdateBatchAfterMinimumChange", func(t *testing.T) {
seq := newBatchSequencer(3, 300, 50, mockCurrentNeedsFunc(100, 300+1))
// Initialize with batches
seq.seq[0] = batch{begin: 200, end: 250, state: batchInit}
seq.seq[1] = batch{begin: 150, end: 200, state: batchInit}
seq.seq[2] = batch{begin: 100, end: 150, state: batchInit}
seq.currentNeeds = mockCurrentNeedsFunc(150, 300+1)
seq.batcher.currentNeeds = seq.currentNeeds
// Update non-importable batch above new minimum
batchToUpdate := batch{begin: 200, end: 250, state: batchSequenced}
seq.update(batchToUpdate)
// Verify batch was updated
require.Equal(t, batchSequenced, seq.seq[0].state)
// Verify numTodo reflects updated minimum
todo := seq.numTodo()
require.NotEqual(t, 0, todo, "numTodo should be greater than 0 after moveMinimum and update")
})
}
// TestCountWithState tests state counting
func TestCountWithState(t *testing.T) {
testCases := []struct {
name string
seqLen int
states []batchState
queryState batchState
expectedCount int
}{
{
name: "CountInit_NoBatches",
seqLen: 0,
states: []batchState{},
queryState: batchInit,
expectedCount: 0,
},
{
name: "CountInit_OneBatch",
seqLen: 1,
states: []batchState{batchInit},
queryState: batchInit,
expectedCount: 1,
},
{
name: "CountInit_MultipleBatches",
seqLen: 3,
states: []batchState{batchInit, batchInit, batchInit},
queryState: batchInit,
expectedCount: 3,
},
{
name: "CountInit_MixedStates",
seqLen: 3,
states: []batchState{batchInit, batchSequenced, batchInit},
queryState: batchInit,
expectedCount: 2,
},
{
name: "CountSequenced",
seqLen: 3,
states: []batchState{batchInit, batchSequenced, batchImportable},
queryState: batchSequenced,
expectedCount: 1,
},
{
name: "CountImportable",
seqLen: 3,
states: []batchState{batchImportable, batchImportable, batchInit},
queryState: batchImportable,
expectedCount: 2,
},
{
name: "CountComplete",
seqLen: 3,
states: []batchState{batchImportComplete, batchImportComplete, batchInit},
queryState: batchImportComplete,
expectedCount: 2,
},
{
name: "CountEndSequence",
seqLen: 3,
states: []batchState{batchInit, batchEndSequence, batchInit},
queryState: batchEndSequence,
expectedCount: 1,
},
{
name: "CountZero_NonexistentState",
seqLen: 2,
states: []batchState{batchInit, batchInit},
queryState: batchImportable,
expectedCount: 0,
},
{
name: "CountNil",
seqLen: 3,
states: []batchState{batchNil, batchNil, batchInit},
queryState: batchNil,
expectedCount: 2,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seq := newBatchSequencer(tc.seqLen, 1000, 64, mockCurrentNeedsFunc(0, 1000+1))
for i, state := range tc.states {
seq.seq[i].state = state
}
count := seq.countWithState(tc.queryState)
require.Equal(t, tc.expectedCount, count)
})
}
}
// TestNumTodo tests remaining batch count calculation
func TestNumTodo(t *testing.T) {
testCases := []struct {
name string
seqLen int
min primitives.Slot
max primitives.Slot
size primitives.Slot
states []batchState
expectedTodo int
}{
{
name: "EmptySequence",
seqLen: 0,
min: 0,
max: 1000,
size: 64,
states: []batchState{},
expectedTodo: 0,
},
{
name: "SingleBatchComplete",
seqLen: 1,
min: 0,
max: 1000,
size: 64,
states: []batchState{batchImportComplete},
expectedTodo: 0,
},
{
name: "SingleBatchInit",
seqLen: 1,
min: 0,
max: 100,
size: 10,
states: []batchState{batchInit},
expectedTodo: 1,
},
{
name: "AllBatchesIgnored",
seqLen: 3,
min: 0,
max: 1000,
size: 64,
states: []batchState{batchImportComplete, batchImportComplete, batchNil},
expectedTodo: 0,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seq := newBatchSequencer(tc.seqLen, tc.max, tc.size, mockCurrentNeedsFunc(tc.min, tc.max+1))
for i, state := range tc.states {
seq.seq[i] = batch{
begin: primitives.Slot(tc.max - primitives.Slot((i+1)*10)),
end: primitives.Slot(tc.max - primitives.Slot(i*10)),
state: state,
}
}
// Just verify numTodo doesn't panic
_ = seq.numTodo()
})
}
}
// TestBatcherRemaining tests the remaining() calculation logic
func TestBatcherRemaining(t *testing.T) {
testCases := []struct {
name string
min primitives.Slot
upTo primitives.Slot
size primitives.Slot
expected int
}{
{
name: "UpToLessThanMin",
min: 100,
upTo: 50,
size: 10,
expected: 0,
},
{
name: "UpToEqualsMin",
min: 100,
upTo: 100,
size: 10,
expected: 0,
},
{
name: "ExactBoundary",
min: 100,
upTo: 110,
size: 10,
expected: 1,
},
{
name: "ExactBoundary_Multiple",
min: 100,
upTo: 150,
size: 10,
expected: 5,
},
{
name: "PartialBatch",
min: 100,
upTo: 115,
size: 10,
expected: 2,
},
{
name: "PartialBatch_Small",
min: 100,
upTo: 105,
size: 10,
expected: 1,
},
{
name: "LargeRange",
min: 100,
upTo: 500,
size: 10,
expected: 40,
},
{
name: "LargeRange_Partial",
min: 100,
upTo: 505,
size: 10,
expected: 41,
},
{
name: "PartialBatch_Size1",
min: 100,
upTo: 101,
size: 1,
expected: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
needs := func() currentNeeds {
return currentNeeds{block: needSpan{begin: tc.min, end: tc.upTo + 1}}
}
b := batcher{size: tc.size, currentNeeds: needs}
result := b.remaining(tc.upTo)
require.Equal(t, tc.expected, result)
})
}
}
// assertAllBatchesAboveMinimum verifies all returned batches have end > minimum
func assertAllBatchesAboveMinimum(t *testing.T, batches []batch, min primitives.Slot) {
for _, b := range batches {
if b.state != batchEndSequence {
if b.end <= min {
t.Fatalf("batch begin=%d end=%d has end <= minimum %d", b.begin, b.end, min)
}
}
}
}
// assertBatchesContiguous verifies contiguity of returned batches
func assertBatchesContiguous(t *testing.T, batches []batch) {
for i := 0; i < len(batches)-1; i++ {
require.Equal(t, batches[i].begin, batches[i+1].end,
"batch[%d] begin=%d not contiguous with batch[%d] end=%d", i, batches[i].begin, i+1, batches[i+1].end)
}
}
// assertBatchNotReturned verifies a specific batch is not in the returned list
func assertBatchNotReturned(t *testing.T, batches []batch, shouldNotBe batch) {
for _, b := range batches {
if b.begin == shouldNotBe.begin && b.end == shouldNotBe.end {
t.Fatalf("batch begin=%d end=%d should not be returned", shouldNotBe.begin, shouldNotBe.end)
}
}
}
// TestMoveMinimumFiltersOutOfRangeBatches tests that batches below new minimum are not returned by sequence()
// after moveMinimum is called. The sequence() method marks expired batches (end <= min) as batchEndSequence
// but does not return them (unless they're the only batches left).
func TestMoveMinimumFiltersOutOfRangeBatches(t *testing.T) {
testCases := []struct {
name string
seqLen int
min primitives.Slot
max primitives.Slot
size primitives.Slot
initialStates []batchState
newMinimum primitives.Slot
expectedReturned int
expectedAllAbove primitives.Slot // all returned batches should have end > this value (except batchEndSequence)
}{
// Category 1: Single Batch Below New Minimum
{
name: "BatchBelowMinimum_Init",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit, batchInit},
newMinimum: 175,
expectedReturned: 3, // [250-300], [200-250], [150-200] are returned
expectedAllAbove: 175,
},
{
name: "BatchBelowMinimum_ErrRetryable",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchSequenced, batchSequenced, batchErrRetryable, batchErrRetryable},
newMinimum: 175,
expectedReturned: 1, // only [150-200] (ErrRetryable) is returned; [100-150] is expired and not returned
expectedAllAbove: 175,
},
// Category 2: Multiple Batches Below New Minimum
{
name: "MultipleBatchesBelowMinimum",
seqLen: 8,
min: 100,
max: 500,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit, batchInit, batchInit, batchInit, batchInit, batchInit},
newMinimum: 320,
expectedReturned: 4, // [450-500], [400-450], [350-400], [300-350] returned; rest expired/not returned
expectedAllAbove: 320,
},
// Category 3: Batches at Boundary - batch.end == minimum is expired
{
name: "BatchExactlyAtMinimum",
seqLen: 3,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit},
newMinimum: 200,
expectedReturned: 1, // [250-300] returned; [200-250] (end==200) and [100-150] are expired
expectedAllAbove: 200,
},
{
name: "BatchJustAboveMinimum",
seqLen: 3,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit},
newMinimum: 199,
expectedReturned: 2, // [250-300], [200-250] returned; [100-150] (end<=199) is expired
expectedAllAbove: 199,
},
// Category 4: No Batches Affected
{
name: "MoveMinimumNoAffect",
seqLen: 3,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit},
newMinimum: 120,
expectedReturned: 3, // all batches returned, none below minimum
expectedAllAbove: 120,
},
// Category 5: Mixed States Below Minimum
{
name: "MixedStatesBelowMinimum",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchSequenced, batchInit, batchErrRetryable, batchInit},
newMinimum: 175,
expectedReturned: 2, // [200-250] (Init) and [150-200] (ErrRetryable) returned; others not in Init/ErrRetryable or expired
expectedAllAbove: 175,
},
// Category 6: Large moveMinimum
{
name: "LargeMoveMinimumSkipsMost",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit, batchInit},
newMinimum: 290,
expectedReturned: 1, // only [250-300] (end=300 > 290) returned
expectedAllAbove: 290,
},
// Category 7: All Batches Expired
{
name: "AllBatchesExpired",
seqLen: 3,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit},
newMinimum: 300,
expectedReturned: 1, // when all expire, one batchEndSequence is returned
expectedAllAbove: 0, // batchEndSequence may have any slot value, don't check
},
// Category 8: Contiguity after filtering
{
name: "ContiguityMaintained",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
initialStates: []batchState{batchInit, batchInit, batchInit, batchInit},
newMinimum: 150,
expectedReturned: 3, // [250-300], [200-250], [150-200] returned
expectedAllAbove: 150,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
seq := newBatchSequencer(tc.seqLen, tc.max, tc.size, mockCurrentNeedsFunc(tc.min, tc.max+1))
// Initialize batches with valid slot ranges
initializeBatchWithSlots(seq.seq, tc.min, tc.size)
// Set initial states
for i, state := range tc.initialStates {
seq.seq[i].state = state
}
// move minimum and call sequence to update set of batches
seq.currentNeeds = mockCurrentNeedsFunc(tc.newMinimum, tc.max+1)
seq.batcher.currentNeeds = seq.currentNeeds
got, err := seq.sequence()
require.NoError(t, err)
// Verify count
if len(got) != tc.expectedReturned {
t.Fatalf("expected %d batches returned, got %d", tc.expectedReturned, len(got))
}
// Verify all returned non-endSequence batches have end > newMinimum
// (batchEndSequence may be returned when all batches are expired, so exclude from check)
if tc.expectedAllAbove > 0 {
for _, b := range got {
if b.state != batchEndSequence && b.end <= tc.expectedAllAbove {
t.Fatalf("batch begin=%d end=%d has end <= %d (should be filtered)",
b.begin, b.end, tc.expectedAllAbove)
}
}
}
// Verify contiguity is maintained for returned batches
if len(got) > 1 {
assertBatchesContiguous(t, got)
}
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
@@ -30,13 +31,13 @@ type blobSummary struct {
}
type blobSyncConfig struct {
retentionStart primitives.Slot
nbv verification.NewBlobVerifier
store *filesystem.BlobStorage
nbv verification.NewBlobVerifier
store *filesystem.BlobStorage
currentNeeds func() currentNeeds
}
func newBlobSync(current primitives.Slot, vbs verifiedROBlocks, cfg *blobSyncConfig) (*blobSync, error) {
expected, err := vbs.blobIdents(cfg.retentionStart)
expected, err := vbs.blobIdents(cfg.currentNeeds)
if err != nil {
return nil, err
}
@@ -48,17 +49,24 @@ func newBlobSync(current primitives.Slot, vbs verifiedROBlocks, cfg *blobSyncCon
type blobVerifierMap map[[32]byte][]verification.BlobVerifier
type blobSync struct {
store das.AvailabilityStore
store *das.LazilyPersistentStoreBlob
expected []blobSummary
next int
bbv *blobBatchVerifier
current primitives.Slot
peer peer.ID
}
func (bs *blobSync) blobsNeeded() int {
func (bs *blobSync) needed() int {
return len(bs.expected) - bs.next
}
// validateNext is given to the RPC request code as one of the a validation callbacks.
// It orchestrates setting up the batch verifier (blobBatchVerifier) and calls Persist on the
// AvailabilityStore. This enables the rest of the code in between RPC and the AvailabilityStore
// to stay decoupled from each other. The AvailabilityStore holds the blobs in memory between the
// call to Persist, and the call to IsDataAvailable (where the blobs are actually written to disk
// if successfully verified).
func (bs *blobSync) validateNext(rb blocks.ROBlob) error {
if bs.next >= len(bs.expected) {
return errUnexpectedResponseSize
@@ -102,6 +110,7 @@ func newBlobBatchVerifier(nbv verification.NewBlobVerifier) *blobBatchVerifier {
return &blobBatchVerifier{newBlobVerifier: nbv, verifiers: make(blobVerifierMap)}
}
// blobBatchVerifier implements the BlobBatchVerifier interface required by the das store.
type blobBatchVerifier struct {
newBlobVerifier verification.NewBlobVerifier
verifiers blobVerifierMap
@@ -117,6 +126,7 @@ func (bbv *blobBatchVerifier) newVerifier(rb blocks.ROBlob) verification.BlobVer
return m[rb.Index]
}
// VerifiedROBlobs satisfies the BlobBatchVerifier interface expected by the AvailabilityChecker
func (bbv *blobBatchVerifier) VerifiedROBlobs(_ context.Context, blk blocks.ROBlock, _ []blocks.ROBlob) ([]blocks.VerifiedROBlob, error) {
m, ok := bbv.verifiers[blk.Root()]
if !ok {

View File

@@ -11,28 +11,43 @@ import (
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/OffchainLabs/prysm/v7/time/slots"
)
const testBlobGenBlobCount = 3
func testBlobGen(t *testing.T, start primitives.Slot, n int) ([]blocks.ROBlock, [][]blocks.ROBlob) {
blks := make([]blocks.ROBlock, n)
blobs := make([][]blocks.ROBlob, n)
for i := range n {
bk, bl := util.GenerateTestDenebBlockWithSidecar(t, [32]byte{}, start+primitives.Slot(i), 3)
bk, bl := util.GenerateTestDenebBlockWithSidecar(t, [32]byte{}, start+primitives.Slot(i), testBlobGenBlobCount)
blks[i] = bk
blobs[i] = bl
}
return blks, blobs
}
func setupCurrentNeeds(t *testing.T, current primitives.Slot) syncNeeds {
return syncNeeds{
current: func() primitives.Slot { return current },
deneb: slots.UnsafeEpochStart(params.BeaconConfig().DenebForkEpoch),
fulu: slots.UnsafeEpochStart(params.BeaconConfig().FuluForkEpoch),
}
}
func TestValidateNext_happy(t *testing.T) {
startSlot := util.SlotAtEpoch(t, params.BeaconConfig().DenebForkEpoch)
current := startSlot + 65
blks, blobs := testBlobGen(t, startSlot, 4)
cfg := &blobSyncConfig{
retentionStart: 0,
nbv: testNewBlobVerifier(),
store: filesystem.NewEphemeralBlobStorage(t),
nbv: testNewBlobVerifier(),
store: filesystem.NewEphemeralBlobStorage(t),
currentNeeds: mockCurrentNeedsFunc(0, current+1),
}
//expected :=
expected, err := verifiedROBlocks(blks).blobIdents(cfg.currentNeeds)
require.NoError(t, err)
require.Equal(t, len(blks)*testBlobGenBlobCount, len(expected))
bsync, err := newBlobSync(current, blks, cfg)
require.NoError(t, err)
nb := 0
@@ -50,12 +65,13 @@ func TestValidateNext_happy(t *testing.T) {
func TestValidateNext_cheapErrors(t *testing.T) {
current := primitives.Slot(128)
blks, blobs := testBlobGen(t, 63, 2)
syncNeeds := setupCurrentNeeds(t, current)
cfg := &blobSyncConfig{
retentionStart: 0,
nbv: testNewBlobVerifier(),
store: filesystem.NewEphemeralBlobStorage(t),
nbv: testNewBlobVerifier(),
store: filesystem.NewEphemeralBlobStorage(t),
currentNeeds: syncNeeds.currently,
}
blks, blobs := testBlobGen(t, syncNeeds.deneb, 2)
bsync, err := newBlobSync(current, blks, cfg)
require.NoError(t, err)
require.ErrorIs(t, bsync.validateNext(blobs[len(blobs)-1][0]), errUnexpectedResponseContent)
@@ -63,12 +79,13 @@ func TestValidateNext_cheapErrors(t *testing.T) {
func TestValidateNext_sigMatch(t *testing.T) {
current := primitives.Slot(128)
blks, blobs := testBlobGen(t, 63, 1)
syncNeeds := setupCurrentNeeds(t, current)
cfg := &blobSyncConfig{
retentionStart: 0,
nbv: testNewBlobVerifier(),
store: filesystem.NewEphemeralBlobStorage(t),
nbv: testNewBlobVerifier(),
store: filesystem.NewEphemeralBlobStorage(t),
currentNeeds: syncNeeds.currently,
}
blks, blobs := testBlobGen(t, syncNeeds.deneb, 1)
bsync, err := newBlobSync(current, blks, cfg)
require.NoError(t, err)
blobs[0][0].SignedBlockHeader.Signature = bytesutil.PadTo([]byte("derp"), 48)
@@ -79,6 +96,8 @@ func TestValidateNext_errorsFromVerifier(t *testing.T) {
ds := util.SlotAtEpoch(t, params.BeaconConfig().DenebForkEpoch)
current := primitives.Slot(ds + 96)
blks, blobs := testBlobGen(t, ds+31, 1)
cn := mockCurrentNeedsFunc(0, current+1)
cases := []struct {
name string
err error
@@ -109,9 +128,9 @@ func TestValidateNext_errorsFromVerifier(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
cfg := &blobSyncConfig{
retentionStart: 0,
nbv: testNewBlobVerifier(c.cb),
store: filesystem.NewEphemeralBlobStorage(t),
nbv: testNewBlobVerifier(c.cb),
store: filesystem.NewEphemeralBlobStorage(t),
currentNeeds: cn,
}
bsync, err := newBlobSync(current, blks, cfg)
require.NoError(t, err)

View File

@@ -0,0 +1,278 @@
package backfill
import (
"bytes"
"context"
"fmt"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/das"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/beacon-chain/sync"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
var (
errInvalidDataColumnResponse = errors.New("invalid DataColumnSidecar response")
errUnexpectedBlockRoot = errors.Wrap(errInvalidDataColumnResponse, "unexpected sidecar block root")
errCommitmentLengthMismatch = errors.Wrap(errInvalidDataColumnResponse, "sidecar has different commitment count than block")
errCommitmentValueMismatch = errors.Wrap(errInvalidDataColumnResponse, "sidecar commitments do not match block")
)
// tune the amount of columns we try to download from peers at once.
// The spec limit is 128 * 32, but connection errors are more likely when
// requesting so much at once.
const columnRequestLimit = 128 * 4
type columnBatch struct {
first primitives.Slot
last primitives.Slot
custodyGroups peerdas.ColumnIndices
toDownload map[[32]byte]*toDownload
}
type toDownload struct {
remaining peerdas.ColumnIndices
commitments [][]byte
slot primitives.Slot
}
func (cs *columnBatch) needed() peerdas.ColumnIndices {
// make a copy that we can modify to reduce search iterations.
search := cs.custodyGroups.ToMap()
ci := peerdas.ColumnIndices{}
for _, v := range cs.toDownload {
if len(search) == 0 {
return ci
}
for col := range search {
if v.remaining.Has(col) {
ci.Set(col)
// avoid iterating every single block+index by only searching for indices
// we haven't found yet.
delete(search, col)
}
}
}
return ci
}
// pruneExpired removes any columns from the batch that are no longer needed.
// If `pruned` is non-nil, it is populated with the roots that were removed.
func (cs *columnBatch) pruneExpired(needs currentNeeds, pruned map[[32]byte]struct{}) {
for root, td := range cs.toDownload {
if !needs.col.at(td.slot) {
delete(cs.toDownload, root)
if pruned != nil {
pruned[root] = struct{}{}
}
}
}
}
// neededSidecarCount returns the total number of sidecars still needed to complete the batch.
func (cs *columnBatch) neededSidecarCount() int {
count := 0
for _, v := range cs.toDownload {
count += v.remaining.Count()
}
return count
}
// neededSidecarsByColumn counts how many sidecars are still needed for each column index.
func (cs *columnBatch) neededSidecarsByColumn(peerHas peerdas.ColumnIndices) map[uint64]int {
need := make(map[uint64]int, len(peerHas))
for _, v := range cs.toDownload {
for idx := range v.remaining {
if peerHas.Has(idx) {
need[idx]++
}
}
}
return need
}
type columnSync struct {
*columnBatch
store *das.LazilyPersistentStoreColumn
current primitives.Slot
peer peer.ID
bisector *columnBisector
}
func newColumnSync(ctx context.Context, b batch, blks verifiedROBlocks, current primitives.Slot, p p2p.P2P, cfg *workerCfg) (*columnSync, error) {
cgc, err := p.CustodyGroupCount(ctx)
if err != nil {
return nil, errors.Wrap(err, "custody group count")
}
cb, err := buildColumnBatch(ctx, b, blks, p, cfg.colStore, cfg.currentNeeds())
if err != nil {
return nil, err
}
if cb == nil {
return &columnSync{}, nil
}
bisector := newColumnBisector(cfg.downscore)
return &columnSync{
columnBatch: cb,
current: current,
store: das.NewLazilyPersistentStoreColumn(cfg.colStore, cfg.newVC, p.NodeID(), cgc, bisector),
bisector: bisector,
}, nil
}
func (cs *columnSync) blockColumns(root [32]byte) *toDownload {
if cs.columnBatch == nil {
return nil
}
return cs.columnBatch.toDownload[root]
}
func (cs *columnSync) columnsNeeded() peerdas.ColumnIndices {
if cs.columnBatch == nil {
return peerdas.ColumnIndices{}
}
return cs.columnBatch.needed()
}
func (cs *columnSync) request(reqCols []uint64, limit int) (*ethpb.DataColumnSidecarsByRangeRequest, error) {
if len(reqCols) == 0 {
return nil, nil
}
// Use cheaper check to avoid allocating map and counting sidecars if under limit.
if cs.neededSidecarCount() <= limit {
return sync.DataColumnSidecarsByRangeRequest(reqCols, cs.first, cs.last)
}
// Re-slice b.nextReqCols to keep the number of requested sidecars under the limit.
reqCount := 0
peerHas := peerdas.NewColumnIndicesFromSlice(reqCols)
needed := cs.neededSidecarsByColumn(peerHas)
for i := range reqCols {
addSidecars := needed[reqCols[i]]
if reqCount+addSidecars > columnRequestLimit {
reqCols = reqCols[:i]
break
}
reqCount += addSidecars
}
return sync.DataColumnSidecarsByRangeRequest(reqCols, cs.first, cs.last)
}
type validatingColumnRequest struct {
req *ethpb.DataColumnSidecarsByRangeRequest
columnSync *columnSync
bisector *columnBisector
}
func (v *validatingColumnRequest) validate(cd blocks.RODataColumn) (err error) {
defer func(validity string, start time.Time) {
dataColumnSidecarVerifyMs.Observe(float64(time.Since(start).Milliseconds()))
if err != nil {
validity = "invalid"
}
dataColumnSidecarDownloadCount.WithLabelValues(fmt.Sprintf("%d", cd.Index), validity).Inc()
dataColumnSidecarDownloadBytes.Add(float64(cd.SizeSSZ()))
}("valid", time.Now())
return v.countedValidation(cd)
}
// When we call Persist we'll get the verification checks that are provided by the availability store.
// In addition to those checks this function calls rpcValidity which maintains a state machine across
// response values to ensure that the response is valid in the context of the overall request,
// like making sure that the block roots is one of the ones we expect based on the blocks we used to
// construct the request. It also does cheap sanity checks on the DataColumnSidecar values like
// ensuring that the commitments line up with the block.
func (v *validatingColumnRequest) countedValidation(cd blocks.RODataColumn) error {
root := cd.BlockRoot()
expected := v.columnSync.blockColumns(root)
if expected == nil {
return errors.Wrapf(errUnexpectedBlockRoot, "root=%#x, slot=%d", root, cd.Slot())
}
// We don't need this column, but we trust the column state machine verified we asked for it as part of a range request.
// So we can just skip over it and not try to persist it.
if !expected.remaining.Has(cd.Index) {
return nil
}
if len(cd.KzgCommitments) != len(expected.commitments) {
return errors.Wrapf(errCommitmentLengthMismatch, "root=%#x, slot=%d, index=%d", root, cd.Slot(), cd.Index)
}
for i, cmt := range cd.KzgCommitments {
if !bytes.Equal(cmt, expected.commitments[i]) {
return errors.Wrapf(errCommitmentValueMismatch, "root=%#x, slot=%d, index=%d", root, cd.Slot(), cd.Index)
}
}
if err := v.columnSync.store.Persist(v.columnSync.current, cd); err != nil {
return errors.Wrap(err, "persisting data column")
}
v.bisector.addPeerColumns(v.columnSync.peer, cd)
expected.remaining.Unset(cd.Index)
return nil
}
func currentCustodiedColumns(ctx context.Context, p p2p.P2P) (peerdas.ColumnIndices, error) {
cgc, err := p.CustodyGroupCount(ctx)
if err != nil {
return nil, errors.Wrap(err, "custody group count")
}
peerInfo, _, err := peerdas.Info(p.NodeID(), cgc)
if err != nil {
return nil, errors.Wrap(err, "peer info")
}
return peerdas.NewColumnIndicesFromMap(peerInfo.CustodyColumns), nil
}
func buildColumnBatch(ctx context.Context, b batch, blks verifiedROBlocks, p p2p.P2P, store *filesystem.DataColumnStorage, needs currentNeeds) (*columnBatch, error) {
if len(blks) == 0 {
return nil, nil
}
if !needs.col.at(b.begin) && !needs.col.at(b.end-1) {
return nil, nil
}
indices, err := currentCustodiedColumns(ctx, p)
if err != nil {
return nil, errors.Wrap(err, "current custodied columns")
}
summary := &columnBatch{
custodyGroups: indices,
toDownload: make(map[[32]byte]*toDownload, len(blks)),
}
for _, b := range blks {
slot := b.Block().Slot()
if !needs.col.at(slot) {
continue
}
cmts, err := b.Block().Body().BlobKzgCommitments()
if err != nil {
return nil, errors.Wrap(err, "failed to get blob kzg commitments")
}
if len(cmts) == 0 {
continue
}
// The last block this part of the loop sees will be the last one
// we need to download data columns for.
if len(summary.toDownload) == 0 {
// toDownload is only empty the first time through, so this is the first block with data columns.
summary.first = slot
}
summary.last = slot
summary.toDownload[b.Root()] = &toDownload{
remaining: das.IndicesNotStored(store.Summary(b.Root()), indices),
commitments: cmts,
slot: slot,
}
}
return summary, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
package backfill
import "github.com/pkg/errors"
var errUnrecoverable = errors.New("service in unrecoverable state")
func isRetryable(err error) bool {
return !errors.Is(err, errUnrecoverable)
}

View File

@@ -0,0 +1,130 @@
package backfill
import (
"context"
"github.com/OffchainLabs/prysm/v7/beacon-chain/das"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/pkg/errors"
)
var errMissingAvailabilityChecker = errors.Wrap(errUnrecoverable, "batch is missing required availability checker")
var errUnsafeRange = errors.Wrap(errUnrecoverable, "invalid slice indices")
type checkMultiplexer struct {
blobCheck das.AvailabilityChecker
colCheck das.AvailabilityChecker
currentNeeds currentNeeds
}
// Persist implements das.AvailabilityStore.
var _ das.AvailabilityChecker = &checkMultiplexer{}
// newCheckMultiplexer initializes an AvailabilityChecker that multiplexes to the BlobSidecar and DataColumnSidecar
// AvailabilityCheckers present in the batch.
func newCheckMultiplexer(needs currentNeeds, b batch) *checkMultiplexer {
s := &checkMultiplexer{currentNeeds: needs}
if b.blobs != nil && b.blobs.store != nil {
s.blobCheck = b.blobs.store
}
if b.columns != nil && b.columns.store != nil {
s.colCheck = b.columns.store
}
return s
}
// IsDataAvailable implements the das.AvailabilityStore interface.
func (m *checkMultiplexer) IsDataAvailable(ctx context.Context, current primitives.Slot, blks ...blocks.ROBlock) error {
needs, err := m.divideByChecker(blks)
if err != nil {
return errors.Wrap(errUnrecoverable, "failed to slice blocks by DA type")
}
if err := doAvailabilityCheck(ctx, m.blobCheck, current, needs.blobs); err != nil {
return errors.Wrap(err, "blob store availability check failed")
}
if err := doAvailabilityCheck(ctx, m.colCheck, current, needs.cols); err != nil {
return errors.Wrap(err, "column store availability check failed")
}
return nil
}
func doAvailabilityCheck(ctx context.Context, check das.AvailabilityChecker, current primitives.Slot, blks []blocks.ROBlock) error {
if len(blks) == 0 {
return nil
}
// Double check that the checker is non-nil.
if check == nil {
return errMissingAvailabilityChecker
}
return check.IsDataAvailable(ctx, current, blks...)
}
// daGroups is a helper type that groups blocks by their DA type.
type daGroups struct {
blobs []blocks.ROBlock
cols []blocks.ROBlock
}
// blocksByDaType slices the given blocks into two slices: one for deneb blocks (BlobSidecar)
// and one for fulu blocks (DataColumnSidecar). Blocks that are pre-deneb or have no
// blob commitments are skipped.
func (m *checkMultiplexer) divideByChecker(blks []blocks.ROBlock) (daGroups, error) {
needs := daGroups{}
for _, blk := range blks {
slot := blk.Block().Slot()
if !m.currentNeeds.blob.at(slot) && !m.currentNeeds.col.at(slot) {
continue
}
cmts, err := blk.Block().Body().BlobKzgCommitments()
if err != nil {
return needs, err
}
if len(cmts) == 0 {
continue
}
if m.currentNeeds.col.at(slot) {
needs.cols = append(needs.cols, blk)
continue
}
if m.currentNeeds.blob.at(slot) {
needs.blobs = append(needs.blobs, blk)
continue
}
}
return needs, nil
}
// safeRange is a helper type that enforces safe slicing.
type safeRange struct {
start uint
end uint
}
// isZero returns true if the range is zero-length.
func (r safeRange) isZero() bool {
return r.start == r.end
}
// subSlice returns the subslice of s defined by sub
// if it can be safely sliced, or an error if the range is invalid
// with respect to the slice.
func subSlice[T any](s []T, sub safeRange) ([]T, error) {
slen := uint(len(s))
if slen == 0 || sub.isZero() {
return nil, nil
}
// Check that minimum bound is safe.
if sub.end < sub.start {
return nil, errUnsafeRange
}
// Check that upper bound is safe.
if sub.start >= slen || sub.end > slen {
return nil, errUnsafeRange
}
return s[sub.start:sub.end], nil
}

View File

@@ -0,0 +1,822 @@
package backfill
import (
"context"
"testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/das"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/pkg/errors"
)
type mockChecker struct {
}
var mockAvailabilityFailure = errors.New("fake error from IsDataAvailable")
var mockColumnFailure = errors.Wrap(mockAvailabilityFailure, "column checker failure")
var mockBlobFailure = errors.Wrap(mockAvailabilityFailure, "blob checker failure")
// trackingAvailabilityChecker wraps a das.AvailabilityChecker and tracks calls
type trackingAvailabilityChecker struct {
checker das.AvailabilityChecker
callCount int
blocksSeenPerCall [][]blocks.ROBlock // Track blocks passed in each call
}
// NewTrackingAvailabilityChecker creates a wrapper that tracks calls to the underlying checker
func NewTrackingAvailabilityChecker(checker das.AvailabilityChecker) *trackingAvailabilityChecker {
return &trackingAvailabilityChecker{
checker: checker,
callCount: 0,
blocksSeenPerCall: [][]blocks.ROBlock{},
}
}
// IsDataAvailable implements das.AvailabilityChecker
func (t *trackingAvailabilityChecker) IsDataAvailable(ctx context.Context, current primitives.Slot, blks ...blocks.ROBlock) error {
t.callCount++
// Track a copy of the blocks passed in this call
blocksCopy := make([]blocks.ROBlock, len(blks))
copy(blocksCopy, blks)
t.blocksSeenPerCall = append(t.blocksSeenPerCall, blocksCopy)
// Delegate to the underlying checker
return t.checker.IsDataAvailable(ctx, current, blks...)
}
// GetCallCount returns how many times IsDataAvailable was called
func (t *trackingAvailabilityChecker) GetCallCount() int {
return t.callCount
}
// GetBlocksInCall returns the blocks passed in a specific call (0-indexed)
func (t *trackingAvailabilityChecker) GetBlocksInCall(callIndex int) []blocks.ROBlock {
if callIndex < 0 || callIndex >= len(t.blocksSeenPerCall) {
return nil
}
return t.blocksSeenPerCall[callIndex]
}
// GetTotalBlocksSeen returns total number of blocks seen across all calls
func (t *trackingAvailabilityChecker) GetTotalBlocksSeen() int {
total := 0
for _, blkSlice := range t.blocksSeenPerCall {
total += len(blkSlice)
}
return total
}
func TestNewCheckMultiplexer(t *testing.T) {
denebSlot, fuluSlot := testDenebAndFuluSlots(t)
cases := []struct {
name string
batch func() batch
setupChecker func(*checkMultiplexer)
current primitives.Slot
err error
}{
{
name: "no availability checkers, no blocks",
batch: func() batch { return batch{} },
},
{
name: "no blob availability checkers, deneb blocks",
batch: func() batch {
blks, _ := testBlobGen(t, denebSlot, 2)
return batch{
blocks: blks,
}
},
setupChecker: func(m *checkMultiplexer) {
// Provide a column checker which should be unused in this test.
m.colCheck = &das.MockAvailabilityStore{}
},
err: errMissingAvailabilityChecker,
},
{
name: "no column availability checker, fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot, 2)
return batch{
blocks: blks,
}
},
err: errMissingAvailabilityChecker,
setupChecker: func(m *checkMultiplexer) {
// Provide a blob checker which should be unused in this test.
m.blobCheck = &das.MockAvailabilityStore{}
},
},
{
name: "has column availability checker, fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot, 2)
return batch{
blocks: blks,
}
},
setupChecker: func(m *checkMultiplexer) {
// Provide a blob checker which should be unused in this test.
m.colCheck = &das.MockAvailabilityStore{}
},
},
{
name: "has blob availability checker, deneb blocks",
batch: func() batch {
blks, _ := testBlobGen(t, denebSlot, 2)
return batch{
blocks: blks,
}
},
setupChecker: func(m *checkMultiplexer) {
// Provide a blob checker which should be unused in this test.
m.blobCheck = &das.MockAvailabilityStore{}
},
},
{
name: "has blob but not col availability checker, deneb and fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot-2, 4) // spans deneb and fulu
return batch{
blocks: blks,
}
},
err: errMissingAvailabilityChecker, // fails because column store is not present
setupChecker: func(m *checkMultiplexer) {
m.blobCheck = &das.MockAvailabilityStore{}
},
},
{
name: "has col but not blob availability checker, deneb and fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot-2, 4) // spans deneb and fulu
return batch{
blocks: blks,
}
},
err: errMissingAvailabilityChecker, // fails because column store is not present
setupChecker: func(m *checkMultiplexer) {
m.colCheck = &das.MockAvailabilityStore{}
},
},
{
name: "both checkers, deneb and fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot-2, 4) // spans deneb and fulu
return batch{
blocks: blks,
}
},
setupChecker: func(m *checkMultiplexer) {
m.blobCheck = &das.MockAvailabilityStore{}
m.colCheck = &das.MockAvailabilityStore{}
},
},
{
name: "deneb checker fails, deneb and fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot-2, 4) // spans deneb and fulu
return batch{
blocks: blks,
}
},
err: mockBlobFailure,
setupChecker: func(m *checkMultiplexer) {
m.blobCheck = &das.MockAvailabilityStore{ErrIsDataAvailable: mockBlobFailure}
m.colCheck = &das.MockAvailabilityStore{}
},
},
{
name: "fulu checker fails, deneb and fulu blocks",
batch: func() batch {
blks, _ := testBlobGen(t, fuluSlot-2, 4) // spans deneb and fulu
return batch{
blocks: blks,
}
},
err: mockBlobFailure,
setupChecker: func(m *checkMultiplexer) {
m.blobCheck = &das.MockAvailabilityStore{}
m.colCheck = &das.MockAvailabilityStore{ErrIsDataAvailable: mockBlobFailure}
},
},
}
needs := mockCurrentSpecNeeds()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
b := tc.batch()
var checker *checkMultiplexer
checker = newCheckMultiplexer(needs, b)
if tc.setupChecker != nil {
tc.setupChecker(checker)
}
err := checker.IsDataAvailable(t.Context(), tc.current, b.blocks...)
if tc.err != nil {
require.ErrorIs(t, err, tc.err)
} else {
require.NoError(t, err)
}
})
}
}
func testBlocksWithCommitments(t *testing.T, startSlot primitives.Slot, count int) []blocks.ROBlock {
blks := make([]blocks.ROBlock, count)
for i := range count {
blk, _ := util.GenerateTestDenebBlockWithSidecar(t, [32]byte{}, startSlot+primitives.Slot(i), 1)
blks[i] = blk
}
return blks
}
func TestDaNeeds(t *testing.T) {
denebSlot, fuluSlot := testDenebAndFuluSlots(t)
mux := &checkMultiplexer{currentNeeds: mockCurrentSpecNeeds()}
cases := []struct {
name string
setup func() (daGroups, []blocks.ROBlock)
expect daGroups
err error
}{
{
name: "empty range",
setup: func() (daGroups, []blocks.ROBlock) {
return daGroups{}, testBlocksWithCommitments(t, 10, 5)
},
},
{
name: "single deneb block",
setup: func() (daGroups, []blocks.ROBlock) {
blks := testBlocksWithCommitments(t, denebSlot, 1)
return daGroups{
blobs: []blocks.ROBlock{blks[0]},
}, blks
},
},
{
name: "single fulu block",
setup: func() (daGroups, []blocks.ROBlock) {
blks := testBlocksWithCommitments(t, fuluSlot, 1)
return daGroups{
cols: []blocks.ROBlock{blks[0]},
}, blks
},
},
{
name: "deneb range",
setup: func() (daGroups, []blocks.ROBlock) {
blks := testBlocksWithCommitments(t, denebSlot, 3)
return daGroups{
blobs: blks,
}, blks
},
},
{
name: "one deneb one fulu",
setup: func() (daGroups, []blocks.ROBlock) {
deneb := testBlocksWithCommitments(t, denebSlot, 1)
fulu := testBlocksWithCommitments(t, fuluSlot, 1)
return daGroups{
blobs: []blocks.ROBlock{deneb[0]},
cols: []blocks.ROBlock{fulu[0]},
}, append(deneb, fulu...)
},
},
{
name: "deneb and fulu range",
setup: func() (daGroups, []blocks.ROBlock) {
deneb := testBlocksWithCommitments(t, denebSlot, 3)
fulu := testBlocksWithCommitments(t, fuluSlot, 3)
return daGroups{
blobs: deneb,
cols: fulu,
}, append(deneb, fulu...)
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expectNeeds, blks := tc.setup()
needs, err := mux.divideByChecker(blks)
if tc.err != nil {
require.ErrorIs(t, err, tc.err)
} else {
require.NoError(t, err)
}
expectBlob := make(map[[32]byte]struct{})
for _, blk := range expectNeeds.blobs {
expectBlob[blk.Root()] = struct{}{}
}
for _, blk := range needs.blobs {
_, ok := expectBlob[blk.Root()]
require.Equal(t, true, ok, "unexpected blob block root %#x", blk.Root())
delete(expectBlob, blk.Root())
}
require.Equal(t, 0, len(expectBlob), "missing blob blocks")
expectCol := make(map[[32]byte]struct{})
for _, blk := range expectNeeds.cols {
expectCol[blk.Root()] = struct{}{}
}
for _, blk := range needs.cols {
_, ok := expectCol[blk.Root()]
require.Equal(t, true, ok, "unexpected col block root %#x", blk.Root())
delete(expectCol, blk.Root())
}
require.Equal(t, 0, len(expectCol), "missing col blocks")
})
}
}
func testDenebAndFuluSlots(t *testing.T) (primitives.Slot, primitives.Slot) {
params.SetupTestConfigCleanup(t)
denebEpoch := params.BeaconConfig().DenebForkEpoch
if params.BeaconConfig().FuluForkEpoch == params.BeaconConfig().FarFutureEpoch {
params.BeaconConfig().FuluForkEpoch = denebEpoch + 4096*2
}
fuluEpoch := params.BeaconConfig().FuluForkEpoch
fuluSlot, err := slots.EpochStart(fuluEpoch)
require.NoError(t, err)
denebSlot, err := slots.EpochStart(denebEpoch)
require.NoError(t, err)
return denebSlot, fuluSlot
}
// Helper to create test blocks without blob commitments
// Uses 0 commitments instead of 1 like testBlocksWithCommitments
func testBlocksWithoutCommitments(t *testing.T, startSlot primitives.Slot, count int) []blocks.ROBlock {
blks := make([]blocks.ROBlock, count)
for i := range count {
blk, _ := util.GenerateTestDenebBlockWithSidecar(t, [32]byte{}, startSlot+primitives.Slot(i), 0)
blks[i] = blk
}
return blks
}
// TestBlockDaNeedsWithoutCommitments verifies blocks without commitments are skipped
func TestBlockDaNeedsWithoutCommitments(t *testing.T) {
denebSlot, fuluSlot := testDenebAndFuluSlots(t)
mux := &checkMultiplexer{currentNeeds: mockCurrentSpecNeeds()}
cases := []struct {
name string
setup func() (daGroups, []blocks.ROBlock)
expect daGroups
err error
}{
{
name: "deneb blocks without commitments",
setup: func() (daGroups, []blocks.ROBlock) {
blks := testBlocksWithoutCommitments(t, denebSlot, 3)
return daGroups{}, blks // Expect empty daNeeds
},
},
{
name: "fulu blocks without commitments",
setup: func() (daGroups, []blocks.ROBlock) {
blks := testBlocksWithoutCommitments(t, fuluSlot, 3)
return daGroups{}, blks // Expect empty daNeeds
},
},
{
name: "mixed: some deneb with commitments, some without",
setup: func() (daGroups, []blocks.ROBlock) {
withCommit := testBlocksWithCommitments(t, denebSlot, 2)
withoutCommit := testBlocksWithoutCommitments(t, denebSlot+2, 2)
blks := append(withCommit, withoutCommit...)
return daGroups{
blobs: withCommit, // Only the ones with commitments
}, blks
},
},
{
name: "pre-deneb blocks are skipped",
setup: func() (daGroups, []blocks.ROBlock) {
blks := testBlocksWithCommitments(t, denebSlot-10, 5)
return daGroups{}, blks // All pre-deneb, expect empty
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
expectNeeds, blks := tc.setup()
needs, err := mux.divideByChecker(blks)
if tc.err != nil {
require.ErrorIs(t, err, tc.err)
} else {
require.NoError(t, err)
}
// Verify blob blocks
require.Equal(t, len(expectNeeds.blobs), len(needs.blobs),
"expected %d blob blocks, got %d", len(expectNeeds.blobs), len(needs.blobs))
// Verify col blocks
require.Equal(t, len(expectNeeds.cols), len(needs.cols),
"expected %d col blocks, got %d", len(expectNeeds.cols), len(needs.cols))
})
}
}
// TestBlockDaNeedsAcrossEras verifies blocks spanning pre-deneb/deneb/fulu boundaries
func TestBlockDaNeedsAcrossEras(t *testing.T) {
denebSlot, fuluSlot := testDenebAndFuluSlots(t)
mux := &checkMultiplexer{currentNeeds: mockCurrentSpecNeeds()}
cases := []struct {
name string
setup func() (daGroups, []blocks.ROBlock)
expectBlobCount int
expectColCount int
}{
{
name: "pre-deneb, deneb, fulu sequence",
setup: func() (daGroups, []blocks.ROBlock) {
preDeneb := testBlocksWithCommitments(t, denebSlot-1, 1)
deneb := testBlocksWithCommitments(t, denebSlot, 2)
fulu := testBlocksWithCommitments(t, fuluSlot, 2)
blks := append(preDeneb, append(deneb, fulu...)...)
return daGroups{}, blks
},
expectBlobCount: 2, // Only deneb blocks
expectColCount: 2, // Only fulu blocks
},
{
name: "blocks at exact deneb boundary",
setup: func() (daGroups, []blocks.ROBlock) {
atBoundary := testBlocksWithCommitments(t, denebSlot, 1)
return daGroups{
blobs: atBoundary,
}, atBoundary
},
expectBlobCount: 1,
expectColCount: 0,
},
{
name: "blocks at exact fulu boundary",
setup: func() (daGroups, []blocks.ROBlock) {
atBoundary := testBlocksWithCommitments(t, fuluSlot, 1)
return daGroups{
cols: atBoundary,
}, atBoundary
},
expectBlobCount: 0,
expectColCount: 1,
},
{
name: "many deneb blocks before fulu transition",
setup: func() (daGroups, []blocks.ROBlock) {
deneb := testBlocksWithCommitments(t, denebSlot, 10)
fulu := testBlocksWithCommitments(t, fuluSlot, 5)
blks := append(deneb, fulu...)
return daGroups{}, blks
},
expectBlobCount: 10,
expectColCount: 5,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, blks := tc.setup()
needs, err := mux.divideByChecker(blks)
require.NoError(t, err)
require.Equal(t, tc.expectBlobCount, len(needs.blobs),
"expected %d blob blocks, got %d", tc.expectBlobCount, len(needs.blobs))
require.Equal(t, tc.expectColCount, len(needs.cols),
"expected %d col blocks, got %d", tc.expectColCount, len(needs.cols))
})
}
}
// TestDoAvailabilityCheckEdgeCases verifies edge cases in doAvailabilityCheck
func TestDoAvailabilityCheckEdgeCases(t *testing.T) {
denebSlot, _ := testDenebAndFuluSlots(t)
checkerErr := errors.New("checker error")
cases := []struct {
name string
checker das.AvailabilityChecker
blocks []blocks.ROBlock
expectErr error
setupTestBlocks func() []blocks.ROBlock
}{
{
name: "nil checker with empty blocks",
checker: nil,
blocks: []blocks.ROBlock{},
expectErr: nil, // Should succeed with no blocks
},
{
name: "nil checker with blocks",
checker: nil,
expectErr: errMissingAvailabilityChecker,
setupTestBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, denebSlot, 1)
},
},
{
name: "valid checker with empty blocks",
checker: &das.MockAvailabilityStore{},
blocks: []blocks.ROBlock{},
expectErr: nil,
},
{
name: "valid checker with blocks succeeds",
checker: &das.MockAvailabilityStore{},
expectErr: nil,
setupTestBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, denebSlot, 3)
},
},
{
name: "valid checker error is propagated",
checker: &das.MockAvailabilityStore{ErrIsDataAvailable: checkerErr},
expectErr: checkerErr,
setupTestBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, denebSlot, 1)
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
blks := tc.blocks
if tc.setupTestBlocks != nil {
blks = tc.setupTestBlocks()
}
err := doAvailabilityCheck(t.Context(), tc.checker, denebSlot, blks)
if tc.expectErr != nil {
require.NotNil(t, err)
require.ErrorIs(t, err, tc.expectErr)
} else {
require.NoError(t, err)
}
})
}
}
// TestBlockDaNeedsErrorWrapping verifies error messages are properly wrapped
func TestBlockDaNeedsErrorWrapping(t *testing.T) {
denebSlot, _ := testDenebAndFuluSlots(t)
mux := &checkMultiplexer{currentNeeds: mockCurrentSpecNeeds()}
// Test with a block that has commitments but in deneb range
blks := testBlocksWithCommitments(t, denebSlot, 2)
// This should succeed without errors
needs, err := mux.divideByChecker(blks)
require.NoError(t, err)
require.Equal(t, 2, len(needs.blobs))
require.Equal(t, 0, len(needs.cols))
}
// TestIsDataAvailableCallRouting verifies that blocks are routed to the correct checker
// based on their era (pre-deneb, deneb, fulu) and tests various block combinations
func TestIsDataAvailableCallRouting(t *testing.T) {
denebSlot, fuluSlot := testDenebAndFuluSlots(t)
cases := []struct {
name string
buildBlocks func() []blocks.ROBlock
expectedBlobCalls int
expectedBlobBlocks int
expectedColCalls int
expectedColBlocks int
}{
{
name: "PreDenebOnly",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, denebSlot-10, 3)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "DenebOnly",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, denebSlot, 3)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 3,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "FuluOnly",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, fuluSlot, 3)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 1,
expectedColBlocks: 3,
},
{
name: "PreDeneb_Deneb_Mix",
buildBlocks: func() []blocks.ROBlock {
preDeneb := testBlocksWithCommitments(t, denebSlot-10, 3)
deneb := testBlocksWithCommitments(t, denebSlot, 3)
return append(preDeneb, deneb...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 3,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "PreDeneb_Fulu_Mix",
buildBlocks: func() []blocks.ROBlock {
preDeneb := testBlocksWithCommitments(t, denebSlot-10, 3)
fulu := testBlocksWithCommitments(t, fuluSlot, 3)
return append(preDeneb, fulu...)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 1,
expectedColBlocks: 3,
},
{
name: "Deneb_Fulu_Mix",
buildBlocks: func() []blocks.ROBlock {
deneb := testBlocksWithCommitments(t, denebSlot, 3)
fulu := testBlocksWithCommitments(t, fuluSlot, 3)
return append(deneb, fulu...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 3,
expectedColCalls: 1,
expectedColBlocks: 3,
},
{
name: "PreDeneb_Deneb_Fulu_Mix",
buildBlocks: func() []blocks.ROBlock {
preDeneb := testBlocksWithCommitments(t, denebSlot-10, 3)
deneb := testBlocksWithCommitments(t, denebSlot, 4)
fulu := testBlocksWithCommitments(t, fuluSlot, 3)
return append(preDeneb, append(deneb, fulu...)...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 4,
expectedColCalls: 1,
expectedColBlocks: 3,
},
{
name: "DenebNoCommitments",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithoutCommitments(t, denebSlot, 3)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "FuluNoCommitments",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithoutCommitments(t, fuluSlot, 3)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "MixedCommitments_Deneb",
buildBlocks: func() []blocks.ROBlock {
with := testBlocksWithCommitments(t, denebSlot, 3)
without := testBlocksWithoutCommitments(t, denebSlot+3, 3)
return append(with, without...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 3,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "MixedCommitments_Fulu",
buildBlocks: func() []blocks.ROBlock {
with := testBlocksWithCommitments(t, fuluSlot, 3)
without := testBlocksWithoutCommitments(t, fuluSlot+3, 3)
return append(with, without...)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 1,
expectedColBlocks: 3,
},
{
name: "MixedCommitments_All",
buildBlocks: func() []blocks.ROBlock {
denebWith := testBlocksWithCommitments(t, denebSlot, 3)
denebWithout := testBlocksWithoutCommitments(t, denebSlot+3, 2)
fuluWith := testBlocksWithCommitments(t, fuluSlot, 3)
fuluWithout := testBlocksWithoutCommitments(t, fuluSlot+3, 2)
return append(denebWith, append(denebWithout, append(fuluWith, fuluWithout...)...)...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 3,
expectedColCalls: 1,
expectedColBlocks: 3,
},
{
name: "EmptyBlocks",
buildBlocks: func() []blocks.ROBlock {
return []blocks.ROBlock{}
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "SingleDeneb",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, denebSlot, 1)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 1,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "SingleFulu",
buildBlocks: func() []blocks.ROBlock {
return testBlocksWithCommitments(t, fuluSlot, 1)
},
expectedBlobCalls: 0,
expectedBlobBlocks: 0,
expectedColCalls: 1,
expectedColBlocks: 1,
},
{
name: "DenebAtBoundary",
buildBlocks: func() []blocks.ROBlock {
preDeneb := testBlocksWithCommitments(t, denebSlot-1, 1)
atBoundary := testBlocksWithCommitments(t, denebSlot, 1)
return append(preDeneb, atBoundary...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 1,
expectedColCalls: 0,
expectedColBlocks: 0,
},
{
name: "FuluAtBoundary",
buildBlocks: func() []blocks.ROBlock {
deneb := testBlocksWithCommitments(t, fuluSlot-1, 1)
atBoundary := testBlocksWithCommitments(t, fuluSlot, 1)
return append(deneb, atBoundary...)
},
expectedBlobCalls: 1,
expectedBlobBlocks: 1,
expectedColCalls: 1,
expectedColBlocks: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// Create tracking wrappers around mock checkers
blobTracker := NewTrackingAvailabilityChecker(&das.MockAvailabilityStore{})
colTracker := NewTrackingAvailabilityChecker(&das.MockAvailabilityStore{})
// Create multiplexer with tracked checkers
mux := &checkMultiplexer{
blobCheck: blobTracker,
colCheck: colTracker,
currentNeeds: mockCurrentSpecNeeds(),
}
// Build blocks and run availability check
blocks := tc.buildBlocks()
err := mux.IsDataAvailable(t.Context(), denebSlot, blocks...)
require.NoError(t, err)
// Assert blob checker was called the expected number of times
require.Equal(t, tc.expectedBlobCalls, blobTracker.GetCallCount(),
"blob checker call count mismatch for test %s", tc.name)
// Assert blob checker saw the expected number of blocks
require.Equal(t, tc.expectedBlobBlocks, blobTracker.GetTotalBlocksSeen(),
"blob checker block count mismatch for test %s", tc.name)
// Assert column checker was called the expected number of times
require.Equal(t, tc.expectedColCalls, colTracker.GetCallCount(),
"column checker call count mismatch for test %s", tc.name)
// Assert column checker saw the expected number of blocks
require.Equal(t, tc.expectedColBlocks, colTracker.GetTotalBlocksSeen(),
"column checker block count mismatch for test %s", tc.name)
})
}
}

View File

@@ -1,5 +1,115 @@
package backfill
import "github.com/sirupsen/logrus"
import (
"sync"
"sync/atomic"
"time"
"github.com/sirupsen/logrus"
)
var log = logrus.WithField("prefix", "backfill")
// intervalLogger only logs once for each interval. It only customizes a single
// instance of the entry/logger and should just be used to control the logging rate for
// *one specific line of code*.
type intervalLogger struct {
*logrus.Entry
base *logrus.Entry
mux sync.Mutex
seconds int64 // seconds is the number of seconds per logging interval
last *atomic.Int64 // last is the quantized representation of the last time a log was emitted
now func() time.Time
}
func newIntervalLogger(base *logrus.Entry, secondsBetweenLogs int64) *intervalLogger {
return &intervalLogger{
Entry: base,
base: base,
seconds: secondsBetweenLogs,
last: new(atomic.Int64),
now: time.Now,
}
}
// intervalNumber is a separate pure function because this helps tests determine
// proposer timestamp alignment.
func intervalNumber(t time.Time, seconds int64) int64 {
return t.Unix() / seconds
}
// intervalNumber is the integer division of the current unix timestamp
// divided by the number of seconds per interval.
func (l *intervalLogger) intervalNumber() int64 {
return intervalNumber(l.now(), l.seconds)
}
func (l *intervalLogger) copy() *intervalLogger {
return &intervalLogger{
Entry: l.Entry,
base: l.base,
seconds: l.seconds,
last: l.last,
now: l.now,
}
}
// Log overloads the Log() method of logrus.Entry, which is called under the hood
// when a log-level specific method (like Info(), Warn(), Error()) is invoked.
// By intercepting this call we can rate limit how often we log.
func (l *intervalLogger) Log(level logrus.Level, args ...any) {
n := l.intervalNumber()
// If Swap returns a different value that the current interval number, we haven't
// emitted a log yet this interval, so we can do so now.
if l.last.Swap(n) != n {
l.Entry.Log(level, args...)
}
// reset the Entry to the base so that any WithField/WithError calls
// don't persist across calls to Log()
}
func (l *intervalLogger) WithField(key string, value any) *intervalLogger {
cp := l.copy()
cp.Entry = cp.Entry.WithField(key, value)
return cp
}
func (l *intervalLogger) WithFields(fields logrus.Fields) *intervalLogger {
cp := l.copy()
cp.Entry = cp.Entry.WithFields(fields)
return cp
}
func (l *intervalLogger) WithError(err error) *intervalLogger {
cp := l.copy()
cp.Entry = cp.Entry.WithError(err)
return cp
}
func (l *intervalLogger) Trace(args ...any) {
l.Log(logrus.TraceLevel, args...)
}
func (l *intervalLogger) Debug(args ...any) {
l.Log(logrus.DebugLevel, args...)
}
func (l *intervalLogger) Print(args ...any) {
l.Info(args...)
}
func (l *intervalLogger) Info(args ...any) {
l.Log(logrus.InfoLevel, args...)
}
func (l *intervalLogger) Warn(args ...any) {
l.Log(logrus.WarnLevel, args...)
}
func (l *intervalLogger) Warning(args ...any) {
l.Warn(args...)
}
func (l *intervalLogger) Error(args ...any) {
l.Log(logrus.ErrorLevel, args...)
}

View File

@@ -0,0 +1,379 @@
package backfill
import (
"bytes"
"sync"
"testing"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
// trackingHook is a logrus hook that counts Log callCount for testing.
type trackingHook struct {
mu sync.RWMutex
entries []*logrus.Entry
}
func (h *trackingHook) Levels() []logrus.Level {
return logrus.AllLevels
}
func (h *trackingHook) Fire(entry *logrus.Entry) error {
h.mu.Lock()
defer h.mu.Unlock()
h.entries = append(h.entries, entry)
return nil
}
func (h *trackingHook) callCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.entries)
}
func (h *trackingHook) emitted(t *testing.T) []string {
h.mu.RLock()
defer h.mu.RUnlock()
e := make([]string, len(h.entries))
for i, entry := range h.entries {
entry.Buffer = new(bytes.Buffer)
serialized, err := entry.Logger.Formatter.Format(entry)
require.NoError(t, err)
e[i] = string(serialized)
}
return e
}
func entryWithHook() (*logrus.Entry, *trackingHook) {
logger := logrus.New()
logger.SetLevel(logrus.TraceLevel)
hook := &trackingHook{}
logger.AddHook(hook)
entry := logrus.NewEntry(logger)
return entry, hook
}
func intervalSecondsAndDuration(i int) (int64, time.Duration) {
return int64(i), time.Duration(i) * time.Second
}
// mockClock provides a controllable time source for testing.
// It allows tests to set the current time and advance it as needed.
type mockClock struct {
t time.Time
}
// now returns the current time.
func (c *mockClock) now() time.Time {
return c.t
}
func setupMockClock(il *intervalLogger) *mockClock {
// initialize now so that the time aligns with the start of the
// interval bucket. This ensures that adding less than an interval
// of time to the timestamp can never move into the next bucket.
interval := intervalNumber(time.Now(), il.seconds)
now := time.Unix(interval*il.seconds, 0)
clock := &mockClock{t: now}
il.now = clock.now
return clock
}
// TestNewIntervalLogger verifies logger is properly initialized
func TestNewIntervalLogger(t *testing.T) {
base := logrus.NewEntry(logrus.New())
intSec := int64(10)
il := newIntervalLogger(base, intSec)
require.NotNil(t, il)
require.Equal(t, intSec, il.seconds)
require.Equal(t, int64(0), il.last.Load())
require.Equal(t, base, il.Entry)
}
// TestLogOncePerInterval verifies that Log is called only once within an interval window
func TestLogOncePerInterval(t *testing.T) {
entry, hook := entryWithHook()
il := newIntervalLogger(entry, 10)
_ = setupMockClock(il) // use a fixed time to make sure no race is possible
// First log should call the underlying Log method
il.Log(logrus.InfoLevel, "test message 1")
require.Equal(t, 1, hook.callCount())
// Second log in same interval should not call Log
il.Log(logrus.InfoLevel, "test message 2")
require.Equal(t, 1, hook.callCount())
// Third log still in same interval should not call Log
il.Log(logrus.InfoLevel, "test message 3")
require.Equal(t, 1, hook.callCount())
// Verify last is set to current interval
require.Equal(t, il.intervalNumber(), il.last.Load())
}
// TestLogAcrossIntervalBoundary verifies logging at interval boundaries resets correctly
func TestLogAcrossIntervalBoundary(t *testing.T) {
iSec, iDur := intervalSecondsAndDuration(10)
entry, hook := entryWithHook()
il := newIntervalLogger(entry, iSec)
clock := setupMockClock(il)
il.Log(logrus.InfoLevel, "first interval")
require.Equal(t, 1, hook.callCount())
// Log in new interval should succeed
clock.t = clock.t.Add(2 * iDur)
il.Log(logrus.InfoLevel, "second interval")
require.Equal(t, 2, hook.callCount())
}
// TestWithFieldChaining verifies WithField returns logger and can be chained
func TestWithFieldChaining(t *testing.T) {
entry, hook := entryWithHook()
iSec, iDur := intervalSecondsAndDuration(10)
il := newIntervalLogger(entry, iSec)
clock := setupMockClock(il)
result := il.WithField("key1", "value1")
require.NotNil(t, result)
result.Info("test")
require.Equal(t, 1, hook.callCount())
// make sure there was no mutation of the base as a side effect
clock.t = clock.t.Add(iDur)
il.Info("another")
// Verify field is present in logged entry
emitted := hook.emitted(t)
require.Contains(t, emitted[0], "test")
require.Contains(t, emitted[0], "key1=value1")
require.Contains(t, emitted[1], "another")
require.NotContains(t, emitted[1], "key1=value1")
}
// TestWithFieldsChaining verifies WithFields properly adds multiple fields
func TestWithFieldsChaining(t *testing.T) {
entry, hook := entryWithHook()
iSec, iDur := intervalSecondsAndDuration(10)
il := newIntervalLogger(entry, iSec)
clock := setupMockClock(il)
fields := logrus.Fields{
"key1": "value1",
"key2": "value2",
}
result := il.WithFields(fields)
require.NotNil(t, result)
result.Info("test")
require.Equal(t, 1, hook.callCount())
// make sure there was no mutation of the base as a side effect
clock.t = clock.t.Add(iDur)
il.Info("another")
// Verify field is present in logged entry
emitted := hook.emitted(t)
require.Contains(t, emitted[0], "test")
require.Contains(t, emitted[0], "key1=value1")
require.Contains(t, emitted[0], "key2=value2")
require.Contains(t, emitted[1], "another")
require.NotContains(t, emitted[1], "key1=value1")
require.NotContains(t, emitted[1], "key2=value2")
}
// TestWithErrorChaining verifies WithError properly adds error field
func TestWithErrorChaining(t *testing.T) {
entry, hook := entryWithHook()
iSec, iDur := intervalSecondsAndDuration(10)
il := newIntervalLogger(entry, iSec)
clock := setupMockClock(il)
expected := errors.New("lowercase words")
result := il.WithError(expected)
require.NotNil(t, result)
result.Error("test")
require.Equal(t, 1, hook.callCount())
require.NotNil(t, result)
// make sure there was no mutation of the base as a side effect
clock.t = clock.t.Add(iDur)
il.Info("different")
// Verify field is present in logged entry
emitted := hook.emitted(t)
require.Contains(t, emitted[0], expected.Error())
require.Contains(t, emitted[0], "test")
require.Contains(t, emitted[1], "different")
require.NotContains(t, emitted[1], "test")
require.NotContains(t, emitted[1], "lowercase words")
}
// TestLogLevelMethods verifies all log level methods work and respect rate limiting
func TestLogLevelMethods(t *testing.T) {
entry, hook := entryWithHook()
il := newIntervalLogger(entry, 10)
_ = setupMockClock(il) // use a fixed time to make sure no race is possible
// First call from each level-specific method should succeed
il.Trace("trace message")
require.Equal(t, 1, hook.callCount())
// Subsequent callCount in same interval should be suppressed
il.Debug("debug message")
require.Equal(t, 1, hook.callCount())
il.Info("info message")
require.Equal(t, 1, hook.callCount())
il.Print("print message")
require.Equal(t, 1, hook.callCount())
il.Warn("warn message")
require.Equal(t, 1, hook.callCount())
il.Warning("warning message")
require.Equal(t, 1, hook.callCount())
il.Error("error message")
require.Equal(t, 1, hook.callCount())
}
// TestConcurrentLogging verifies multiple goroutines can safely call Log concurrently
func TestConcurrentLogging(t *testing.T) {
entry, hook := entryWithHook()
il := newIntervalLogger(entry, 10)
_ = setupMockClock(il) // use a fixed time to make sure no race is possible
var wg sync.WaitGroup
wait := make(chan struct{})
for range 10 {
wg.Add(1)
go func() {
<-wait
defer wg.Done()
il.Log(logrus.InfoLevel, "concurrent message")
}()
}
close(wait) // maximize raciness by unblocking goroutines together
wg.Wait()
// Only one Log call should succeed across all goroutines in the same interval
require.Equal(t, 1, hook.callCount())
}
// TestZeroInterval verifies behavior with small interval (logs every second)
func TestZeroInterval(t *testing.T) {
entry, hook := entryWithHook()
il := newIntervalLogger(entry, 1)
clock := setupMockClock(il)
il.Log(logrus.InfoLevel, "first")
require.Equal(t, 1, hook.callCount())
// Move to next second
clock.t = clock.t.Add(time.Second)
il.Log(logrus.InfoLevel, "second")
require.Equal(t, 2, hook.callCount())
}
// TestCompleteLoggingFlow tests realistic scenario with repeated logging
func TestCompleteLoggingFlow(t *testing.T) {
entry, hook := entryWithHook()
iSec, iDur := intervalSecondsAndDuration(10)
il := newIntervalLogger(entry, iSec)
clock := setupMockClock(il)
// Add field
il = il.WithField("request_id", "12345")
// Log multiple times in same interval - only first succeeds
il.Info("message 1")
require.Equal(t, 1, hook.callCount())
il.Warn("message 2")
require.Equal(t, 1, hook.callCount())
// Move to next interval
clock.t = clock.t.Add(iDur)
// Should be able to log again in new interval
il.Error("message 3")
require.Equal(t, 2, hook.callCount())
require.NotNil(t, il)
}
// TestAtomicSwapCorrectness verifies atomic swap works correctly
func TestAtomicSwapCorrectness(t *testing.T) {
il := newIntervalLogger(logrus.NewEntry(logrus.New()), 10)
_ = setupMockClock(il) // use a fixed time to make sure no race is possible
// Swap operation should return different value on first call
current := il.intervalNumber()
old := il.last.Swap(current)
require.Equal(t, int64(0), old) // initial value is 0
require.Equal(t, current, il.last.Load())
// Swap with same value should return the same value
old = il.last.Swap(current)
require.Equal(t, current, old)
}
// TestLogMethodsWithClockAdvancement verifies that log methods respect rate limiting
// within an interval but emit again after the interval passes.
func TestLogMethodsWithClockAdvancement(t *testing.T) {
entry, hook := entryWithHook()
iSec, iDur := intervalSecondsAndDuration(10)
il := newIntervalLogger(entry, iSec)
clock := setupMockClock(il)
// First Error call should log
il.Error("error 1")
require.Equal(t, 1, hook.callCount())
// Warn call in same interval should be suppressed
il.Warn("warn 1")
require.Equal(t, 1, hook.callCount())
// Info call in same interval should be suppressed
il.Info("info 1")
require.Equal(t, 1, hook.callCount())
// Debug call in same interval should be suppressed
il.Debug("debug 1")
require.Equal(t, 1, hook.callCount())
// Move forward 5 seconds - still in same 10-second interval
require.Equal(t, 5*time.Second, iDur/2)
clock.t = clock.t.Add(iDur / 2)
il.Error("error 2")
require.Equal(t, 1, hook.callCount(), "should still be suppressed within same interval")
firstInterval := il.intervalNumber()
// Move forward to next interval (10 second interval boundary)
clock.t = clock.t.Add(iDur / 2)
nextInterval := il.intervalNumber()
require.NotEqual(t, firstInterval, nextInterval, "should be in new interval now")
il.Error("error 3")
require.Equal(t, 2, hook.callCount(), "should emit in new interval")
// Another call in the new interval should be suppressed
il.Warn("warn 2")
require.Equal(t, 2, hook.callCount())
// Move forward to yet another interval
clock.t = clock.t.Add(iDur)
il.Info("info 2")
require.Equal(t, 3, hook.callCount(), "should emit in third interval")
}

View File

@@ -21,86 +21,117 @@ var (
Help: "Number of batches that are ready to be imported once they can be connected to the existing chain.",
},
)
backfillRemainingBatches = promauto.NewGauge(
batchesRemaining = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "backfill_remaining_batches",
Help: "Backfill remaining batches.",
},
)
backfillBatchesImported = promauto.NewCounter(
batchesImported = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_batches_imported",
Help: "Number of backfill batches downloaded and imported.",
},
)
backfillBlocksApproximateBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blocks_bytes_downloaded",
Help: "BeaconBlock bytes downloaded from peers for backfill.",
backfillBatchTimeWaiting = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_waiting_ms",
Help: "Time batch waited for a suitable peer in ms.",
Buckets: []float64{50, 100, 300, 1000, 2000},
},
)
backfillBlobsApproximateBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blobs_bytes_downloaded",
Help: "BlobSidecar bytes downloaded from peers for backfill.",
backfillBatchTimeRoundtrip = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_roundtrip_ms",
Help: "Total time to import batch, from first scheduled to imported.",
Buckets: []float64{1000, 2000, 4000, 6000, 10000},
},
)
backfillBlobsDownloadCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blobs_download_count",
Help: "Number of BlobSidecar values downloaded from peers for backfill.",
},
)
backfillBlocksDownloadCount = promauto.NewCounter(
blockDownloadCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blocks_download_count",
Help: "Number of BeaconBlock values downloaded from peers for backfill.",
},
)
backfillBatchTimeRoundtrip = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_time_roundtrip",
Help: "Total time to import batch, from first scheduled to imported.",
Buckets: []float64{400, 800, 1600, 3200, 6400, 12800},
blockDownloadBytesApprox = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blocks_downloaded_bytes",
Help: "BeaconBlock bytes downloaded from peers for backfill.",
},
)
backfillBatchTimeWaiting = promauto.NewHistogram(
blockDownloadMs = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_time_waiting",
Help: "Time batch waited for a suitable peer.",
Buckets: []float64{50, 100, 300, 1000, 2000},
},
)
backfillBatchTimeDownloadingBlocks = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_blocks_time_download",
Help: "Time, in milliseconds, batch spent downloading blocks from peer.",
Name: "backfill_batch_blocks_download_ms",
Help: "BeaconBlock download time, in ms.",
Buckets: []float64{100, 300, 1000, 2000, 4000, 8000},
},
)
backfillBatchTimeDownloadingBlobs = promauto.NewHistogram(
blockVerifyMs = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_blobs_time_download",
Help: "Time, in milliseconds, batch spent downloading blobs from peer.",
Name: "backfill_batch_verify_ms",
Help: "BeaconBlock verification time, in ms.",
Buckets: []float64{100, 300, 1000, 2000, 4000, 8000},
},
)
backfillBatchTimeVerifying = promauto.NewHistogram(
blobSidecarDownloadCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blobs_download_count",
Help: "Number of BlobSidecar values downloaded from peers for backfill.",
},
)
blobSidecarDownloadBytesApprox = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_blobs_downloaded_bytes",
Help: "BlobSidecar bytes downloaded from peers for backfill.",
},
)
blobSidecarDownloadMs = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_time_verify",
Help: "Time batch spent downloading blocks from peer.",
Name: "backfill_batch_blobs_download_ms",
Help: "BlobSidecar download time, in ms.",
Buckets: []float64{100, 300, 1000, 2000, 4000, 8000},
},
)
dataColumnSidecarDownloadCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "backfill_data_column_sidecar_downloaded",
Help: "Number of DataColumnSidecar values downloaded from peers for backfill.",
},
[]string{"index", "validity"},
)
dataColumnSidecarDownloadBytes = promauto.NewCounter(
prometheus.CounterOpts{
Name: "backfill_data_column_sidecar_downloaded_bytes",
Help: "DataColumnSidecar bytes downloaded from peers for backfill.",
},
)
dataColumnSidecarDownloadMs = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_columns_download_ms",
Help: "DataColumnSidecars download time, in ms.",
Buckets: []float64{100, 300, 1000, 2000, 4000, 8000},
},
)
dataColumnSidecarVerifyMs = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "backfill_batch_columns_verify_ms",
Help: "DataColumnSidecars verification time, in ms.",
Buckets: []float64{3, 5, 10, 20, 100, 200},
},
)
)
func blobValidationMetrics(_ blocks.ROBlob) error {
backfillBlobsDownloadCount.Inc()
blobSidecarDownloadCount.Inc()
return nil
}
func blockValidationMetrics(interfaces.ReadOnlySignedBeaconBlock) error {
backfillBlocksDownloadCount.Inc()
blockDownloadCount.Inc()
return nil
}

View File

@@ -0,0 +1,95 @@
package backfill
import (
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/time/slots"
)
// needSpan represents the need for a resource over a span of slots.
type needSpan struct {
begin primitives.Slot
end primitives.Slot
}
// at returns whether blocks/blobs/columns are needed at the given slot.
func (n needSpan) at(slot primitives.Slot) bool {
return slot >= n.begin && slot < n.end
}
// currentNeeds fields can be used to check whether the given resource type is needed
// at a given slot. The values are based on the current slot, so this value shouldn't
// be retained / reused across slots.
type currentNeeds struct {
block needSpan
blob needSpan
col needSpan
}
// syncNeeds holds configuration and state for determining what data is needed
// at any given slot during backfill based on the current slot.
type syncNeeds struct {
current func() primitives.Slot
deneb primitives.Slot
fulu primitives.Slot
oldestSlotFlagPtr *primitives.Slot
validOldestSlotPtr *primitives.Slot
blockRetention primitives.Epoch
blobRetentionFlag primitives.Epoch
blobRetention primitives.Epoch
colRetention primitives.Epoch
}
// initialize cleans up data and performs validation. Since syncNeeds is usable as a value (not a pointer),
// this method allows the backfill service initialization to collect relevant field values into a syncNeeds instance
// before performing validation, which requires access to current slot (which we don't have during flag processing).
func (c syncNeeds) initialize(current func() primitives.Slot, deneb, fulu primitives.Slot) syncNeeds {
c.current = current
c.deneb = deneb
c.fulu = fulu
// We apply the --blob-retention-epochs flag to both blob and column retention.
c.blobRetention = max(c.blobRetentionFlag, params.BeaconConfig().MinEpochsForBlobsSidecarsRequest)
c.colRetention = max(c.blobRetentionFlag, params.BeaconConfig().MinEpochsForDataColumnSidecarsRequest)
// Override spec minimum block retention with user-provided flag only if it is lower than the spec minimum.
c.blockRetention = primitives.Epoch(params.BeaconConfig().MinEpochsForBlockRequests)
if c.oldestSlotFlagPtr != nil {
oldestEpoch := slots.ToEpoch(*c.oldestSlotFlagPtr)
if oldestEpoch < c.blockRetention {
c.validOldestSlotPtr = c.oldestSlotFlagPtr
} else {
log.WithField("backfill-oldest-slot", *c.oldestSlotFlagPtr).
WithField("specMinSlot", syncEpochOffset(current(), c.blockRetention)).
Warn("Ignoring user-specified slot > MIN_EPOCHS_FOR_BLOCK_REQUESTS.")
c.oldestSlotFlagPtr = nil // unset so nothing uses the invalid value
c.validOldestSlotPtr = nil
}
}
return c
}
// currently is the main callback given to the different parts of backfill to determine
// what resources are needed at a given slot. It assumes the current instance of syncNeeds
// is the result of calling initialize.
func (n syncNeeds) currently() currentNeeds {
current := n.current()
c := currentNeeds{
block: n.blockSpan(current),
blob: needSpan{begin: syncEpochOffset(current, n.blobRetention), end: n.fulu},
col: needSpan{begin: syncEpochOffset(current, n.colRetention), end: current},
}
// Adjust the minimums forward to the slots where the sidecar types were introduced
c.blob.begin = max(c.blob.begin, n.deneb)
c.col.begin = max(c.col.begin, n.fulu)
return c
}
func (n syncNeeds) blockSpan(current primitives.Slot) needSpan {
if n.validOldestSlotPtr != nil { // assumes validation done in initialize()
return needSpan{begin: *n.validOldestSlotPtr, end: current}
}
return needSpan{begin: syncEpochOffset(current, n.blockRetention), end: current}
}

View File

@@ -0,0 +1,694 @@
package backfill
import (
"fmt"
"testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/time/slots"
)
// TestNeedSpanAt tests the needSpan.at() method for range checking.
func TestNeedSpanAt(t *testing.T) {
cases := []struct {
name string
span needSpan
slots []primitives.Slot
expected bool
}{
{
name: "within bounds",
span: needSpan{begin: 100, end: 200},
slots: []primitives.Slot{101, 150, 199},
expected: true,
},
{
name: "before begin / at end boundary (exclusive)",
span: needSpan{begin: 100, end: 200},
slots: []primitives.Slot{99, 200, 201},
expected: false,
},
{
name: "empty span (begin == end)",
span: needSpan{begin: 100, end: 100},
slots: []primitives.Slot{100},
expected: false,
},
{
name: "slot 0 with span starting at 0",
span: needSpan{begin: 0, end: 100},
slots: []primitives.Slot{0},
expected: true,
},
}
for _, tc := range cases {
for _, sl := range tc.slots {
t.Run(fmt.Sprintf("%s at slot %d, ", tc.name, sl), func(t *testing.T) {
result := tc.span.at(sl)
require.Equal(t, tc.expected, result)
})
}
}
}
// TestSyncEpochOffset tests the syncEpochOffset helper function.
func TestSyncEpochOffset(t *testing.T) {
slotsPerEpoch := params.BeaconConfig().SlotsPerEpoch
cases := []struct {
name string
current primitives.Slot
subtract primitives.Epoch
expected primitives.Slot
}{
{
name: "typical offset - 5 epochs back",
current: primitives.Slot(10000),
subtract: 5,
expected: primitives.Slot(10000 - 5*slotsPerEpoch),
},
{
name: "zero subtract returns current",
current: primitives.Slot(5000),
subtract: 0,
expected: primitives.Slot(5000),
},
{
name: "subtract 1 epoch from mid-range slot",
current: primitives.Slot(1000),
subtract: 1,
expected: primitives.Slot(1000 - slotsPerEpoch),
},
{
name: "offset equals current - underflow protection",
current: primitives.Slot(slotsPerEpoch),
subtract: 1,
expected: 1,
},
{
name: "offset exceeds current - underflow protection",
current: primitives.Slot(50),
subtract: 1000,
expected: 1,
},
{
name: "current very close to 0",
current: primitives.Slot(10),
subtract: 1,
expected: 1,
},
{
name: "subtract MaxSafeEpoch",
current: primitives.Slot(1000000),
subtract: slots.MaxSafeEpoch(),
expected: 1, // underflow protection
},
{
name: "result exactly at slot 1",
current: primitives.Slot(1 + slotsPerEpoch),
subtract: 1,
expected: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := syncEpochOffset(tc.current, tc.subtract)
require.Equal(t, tc.expected, result)
})
}
}
// TestSyncNeedsInitialize tests the syncNeeds.initialize() method.
func TestSyncNeedsInitialize(t *testing.T) {
params.SetupTestConfigCleanup(t)
slotsPerEpoch := params.BeaconConfig().SlotsPerEpoch
minBlobEpochs := params.BeaconConfig().MinEpochsForBlobsSidecarsRequest
minColEpochs := params.BeaconConfig().MinEpochsForDataColumnSidecarsRequest
currentSlot := primitives.Slot(10000)
currentFunc := func() primitives.Slot { return currentSlot }
denebSlot := primitives.Slot(1000)
fuluSlot := primitives.Slot(2000)
cases := []struct {
name string
input syncNeeds
expectValidOldest bool
expectedBlob primitives.Epoch
expectedCol primitives.Epoch
}{
{
name: "basic initialization with no flags",
input: syncNeeds{
blobRetentionFlag: 0,
},
expectValidOldest: false,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "blob retention flag less than spec minimum",
input: syncNeeds{
blobRetentionFlag: minBlobEpochs - 1,
},
expectValidOldest: false,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "blob retention flag greater than spec minimum",
input: syncNeeds{
blobRetentionFlag: minBlobEpochs + 10,
},
expectValidOldest: false,
expectedBlob: minBlobEpochs + 10,
expectedCol: minBlobEpochs + 10,
},
{
name: "oldestSlotFlagPtr is nil",
input: syncNeeds{
blobRetentionFlag: 0,
oldestSlotFlagPtr: nil,
},
expectValidOldest: false,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "valid oldestSlotFlagPtr (earlier than spec minimum)",
input: syncNeeds{
blobRetentionFlag: 0,
oldestSlotFlagPtr: func() *primitives.Slot {
slot := primitives.Slot(10)
return &slot
}(),
},
expectValidOldest: true,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "invalid oldestSlotFlagPtr (later than spec minimum)",
input: syncNeeds{
blobRetentionFlag: 0,
oldestSlotFlagPtr: func() *primitives.Slot {
// Make it way past the spec minimum
slot := currentSlot - primitives.Slot(params.BeaconConfig().MinEpochsForBlockRequests-1)*slotsPerEpoch
return &slot
}(),
},
expectValidOldest: false,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "oldestSlotFlagPtr at boundary (exactly at spec minimum)",
input: syncNeeds{
blobRetentionFlag: 0,
oldestSlotFlagPtr: func() *primitives.Slot {
slot := currentSlot - primitives.Slot(params.BeaconConfig().MinEpochsForBlockRequests)*slotsPerEpoch
return &slot
}(),
},
expectValidOldest: false,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "both blob retention flag and oldest slot set",
input: syncNeeds{
blobRetentionFlag: minBlobEpochs + 5,
oldestSlotFlagPtr: func() *primitives.Slot {
slot := primitives.Slot(100)
return &slot
}(),
},
expectValidOldest: true,
expectedBlob: minBlobEpochs + 5,
expectedCol: minBlobEpochs + 5,
},
{
name: "zero blob retention uses spec minimum",
input: syncNeeds{
blobRetentionFlag: 0,
},
expectValidOldest: false,
expectedBlob: minBlobEpochs,
expectedCol: minColEpochs,
},
{
name: "large blob retention value",
input: syncNeeds{
blobRetentionFlag: 5000,
},
expectValidOldest: false,
expectedBlob: 5000,
expectedCol: 5000,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := tc.input.initialize(currentFunc, denebSlot, fuluSlot)
// Check that current, deneb, fulu are set correctly
require.Equal(t, currentSlot, result.current())
require.Equal(t, denebSlot, result.deneb)
require.Equal(t, fuluSlot, result.fulu)
// Check retention calculations
require.Equal(t, tc.expectedBlob, result.blobRetention)
require.Equal(t, tc.expectedCol, result.colRetention)
// Check validOldestSlotPtr
if tc.expectValidOldest {
require.NotNil(t, result.validOldestSlotPtr)
} else {
require.Equal(t, (*primitives.Slot)(nil), result.validOldestSlotPtr)
}
// Check blockRetention is always spec minimum
require.Equal(t, primitives.Epoch(params.BeaconConfig().MinEpochsForBlockRequests), result.blockRetention)
})
}
}
// TestSyncNeedsBlockSpan tests the syncNeeds.blockSpan() method.
func TestSyncNeedsBlockSpan(t *testing.T) {
params.SetupTestConfigCleanup(t)
minBlockEpochs := params.BeaconConfig().MinEpochsForBlockRequests
cases := []struct {
name string
validOldest *primitives.Slot
blockRetention primitives.Epoch
current primitives.Slot
expectedBegin primitives.Slot
expectedEnd primitives.Slot
}{
{
name: "with validOldestSlotPtr set",
validOldest: func() *primitives.Slot { s := primitives.Slot(500); return &s }(),
blockRetention: primitives.Epoch(minBlockEpochs),
current: 10000,
expectedBegin: 500,
expectedEnd: 10000,
},
{
name: "without validOldestSlotPtr (nil)",
validOldest: nil,
blockRetention: primitives.Epoch(minBlockEpochs),
current: 10000,
expectedBegin: syncEpochOffset(10000, primitives.Epoch(minBlockEpochs)),
expectedEnd: 10000,
},
{
name: "very low current slot",
validOldest: nil,
blockRetention: primitives.Epoch(minBlockEpochs),
current: 100,
expectedBegin: 1, // underflow protection
expectedEnd: 100,
},
{
name: "very high current slot",
validOldest: nil,
blockRetention: primitives.Epoch(minBlockEpochs),
current: 1000000,
expectedBegin: syncEpochOffset(1000000, primitives.Epoch(minBlockEpochs)),
expectedEnd: 1000000,
},
{
name: "validOldestSlotPtr at boundary value",
validOldest: func() *primitives.Slot { s := primitives.Slot(1); return &s }(),
blockRetention: primitives.Epoch(minBlockEpochs),
current: 5000,
expectedBegin: 1,
expectedEnd: 5000,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sn := syncNeeds{
validOldestSlotPtr: tc.validOldest,
blockRetention: tc.blockRetention,
}
result := sn.blockSpan(tc.current)
require.Equal(t, tc.expectedBegin, result.begin)
require.Equal(t, tc.expectedEnd, result.end)
})
}
}
// TestSyncNeedsCurrently tests the syncNeeds.currently() method.
func TestSyncNeedsCurrently(t *testing.T) {
params.SetupTestConfigCleanup(t)
slotsPerEpoch := params.BeaconConfig().SlotsPerEpoch
denebSlot := primitives.Slot(1000)
fuluSlot := primitives.Slot(2000)
cases := []struct {
name string
current primitives.Slot
blobRetention primitives.Epoch
colRetention primitives.Epoch
blockRetention primitives.Epoch
validOldest *primitives.Slot
// Expected block span
expectBlockBegin primitives.Slot
expectBlockEnd primitives.Slot
// Expected blob span
expectBlobBegin primitives.Slot
expectBlobEnd primitives.Slot
// Expected column span
expectColBegin primitives.Slot
expectColEnd primitives.Slot
}{
{
name: "pre-Deneb - only blocks needed",
current: 500,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(500, 5),
expectBlockEnd: 500,
expectBlobBegin: denebSlot, // adjusted to deneb
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot, // adjusted to fulu
expectColEnd: 500,
},
{
name: "between Deneb and Fulu - blocks and blobs needed",
current: 1500,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(1500, 5),
expectBlockEnd: 1500,
expectBlobBegin: max(syncEpochOffset(1500, 10), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot, // adjusted to fulu
expectColEnd: 1500,
},
{
name: "post-Fulu - all resources needed",
current: 3000,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(3000, 5),
expectBlockEnd: 3000,
expectBlobBegin: max(syncEpochOffset(3000, 10), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: max(syncEpochOffset(3000, 10), fuluSlot),
expectColEnd: 3000,
},
{
name: "exactly at Deneb boundary",
current: denebSlot,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(denebSlot, 5),
expectBlockEnd: denebSlot,
expectBlobBegin: denebSlot,
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot,
expectColEnd: denebSlot,
},
{
name: "exactly at Fulu boundary",
current: fuluSlot,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(fuluSlot, 5),
expectBlockEnd: fuluSlot,
expectBlobBegin: max(syncEpochOffset(fuluSlot, 10), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot,
expectColEnd: fuluSlot,
},
{
name: "small retention periods",
current: 5000,
blobRetention: 1,
colRetention: 2,
blockRetention: 1,
validOldest: nil,
expectBlockBegin: syncEpochOffset(5000, 1),
expectBlockEnd: 5000,
expectBlobBegin: max(syncEpochOffset(5000, 1), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: max(syncEpochOffset(5000, 2), fuluSlot),
expectColEnd: 5000,
},
{
name: "large retention periods",
current: 10000,
blobRetention: 100,
colRetention: 100,
blockRetention: 50,
validOldest: nil,
expectBlockBegin: syncEpochOffset(10000, 50),
expectBlockEnd: 10000,
expectBlobBegin: max(syncEpochOffset(10000, 100), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: max(syncEpochOffset(10000, 100), fuluSlot),
expectColEnd: 10000,
},
{
name: "with validOldestSlotPtr for blocks",
current: 8000,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: func() *primitives.Slot { s := primitives.Slot(100); return &s }(),
expectBlockBegin: 100,
expectBlockEnd: 8000,
expectBlobBegin: max(syncEpochOffset(8000, 10), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: max(syncEpochOffset(8000, 10), fuluSlot),
expectColEnd: 8000,
},
{
name: "retention approaching current slot",
current: primitives.Slot(2000 + 5*slotsPerEpoch),
blobRetention: 5,
colRetention: 5,
blockRetention: 3,
validOldest: nil,
expectBlockBegin: syncEpochOffset(primitives.Slot(2000+5*slotsPerEpoch), 3),
expectBlockEnd: primitives.Slot(2000 + 5*slotsPerEpoch),
expectBlobBegin: max(syncEpochOffset(primitives.Slot(2000+5*slotsPerEpoch), 5), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: max(syncEpochOffset(primitives.Slot(2000+5*slotsPerEpoch), 5), fuluSlot),
expectColEnd: primitives.Slot(2000 + 5*slotsPerEpoch),
},
{
name: "current just after Deneb",
current: denebSlot + 10,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(denebSlot+10, 5),
expectBlockEnd: denebSlot + 10,
expectBlobBegin: denebSlot,
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot,
expectColEnd: denebSlot + 10,
},
{
name: "current just after Fulu",
current: fuluSlot + 10,
blobRetention: 10,
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(fuluSlot+10, 5),
expectBlockEnd: fuluSlot + 10,
expectBlobBegin: max(syncEpochOffset(fuluSlot+10, 10), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot,
expectColEnd: fuluSlot + 10,
},
{
name: "blob retention would start before Deneb",
current: denebSlot + primitives.Slot(5*slotsPerEpoch),
blobRetention: 100, // very large retention
colRetention: 10,
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(denebSlot+primitives.Slot(5*slotsPerEpoch), 5),
expectBlockEnd: denebSlot + primitives.Slot(5*slotsPerEpoch),
expectBlobBegin: denebSlot, // clamped to deneb
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot,
expectColEnd: denebSlot + primitives.Slot(5*slotsPerEpoch),
},
{
name: "column retention would start before Fulu",
current: fuluSlot + primitives.Slot(5*slotsPerEpoch),
blobRetention: 10,
colRetention: 100, // very large retention
blockRetention: 5,
validOldest: nil,
expectBlockBegin: syncEpochOffset(fuluSlot+primitives.Slot(5*slotsPerEpoch), 5),
expectBlockEnd: fuluSlot + primitives.Slot(5*slotsPerEpoch),
expectBlobBegin: max(syncEpochOffset(fuluSlot+primitives.Slot(5*slotsPerEpoch), 10), denebSlot),
expectBlobEnd: fuluSlot,
expectColBegin: fuluSlot, // clamped to fulu
expectColEnd: fuluSlot + primitives.Slot(5*slotsPerEpoch),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sn := syncNeeds{
current: func() primitives.Slot { return tc.current },
deneb: denebSlot,
fulu: fuluSlot,
validOldestSlotPtr: tc.validOldest,
blockRetention: tc.blockRetention,
blobRetention: tc.blobRetention,
colRetention: tc.colRetention,
}
result := sn.currently()
// Verify block span
require.Equal(t, tc.expectBlockBegin, result.block.begin,
"block.begin mismatch")
require.Equal(t, tc.expectBlockEnd, result.block.end,
"block.end mismatch")
// Verify blob span
require.Equal(t, tc.expectBlobBegin, result.blob.begin,
"blob.begin mismatch")
require.Equal(t, tc.expectBlobEnd, result.blob.end,
"blob.end mismatch")
// Verify column span
require.Equal(t, tc.expectColBegin, result.col.begin,
"col.begin mismatch")
require.Equal(t, tc.expectColEnd, result.col.end,
"col.end mismatch")
})
}
}
// TestCurrentNeedsIntegration verifies the complete currentNeeds workflow.
func TestCurrentNeedsIntegration(t *testing.T) {
params.SetupTestConfigCleanup(t)
denebSlot := primitives.Slot(1000)
fuluSlot := primitives.Slot(2000)
cases := []struct {
name string
current primitives.Slot
blobRetention primitives.Epoch
colRetention primitives.Epoch
testSlots []primitives.Slot
expectBlockAt []bool
expectBlobAt []bool
expectColAt []bool
}{
{
name: "pre-Deneb slot - only blocks",
current: 500,
blobRetention: 10,
colRetention: 10,
testSlots: []primitives.Slot{100, 250, 499, 500, 1000, 2000},
expectBlockAt: []bool{true, true, true, false, false, false},
expectBlobAt: []bool{false, false, false, false, true, false},
expectColAt: []bool{false, false, false, false, false, false},
},
{
name: "between Deneb and Fulu - blocks and blobs",
current: 1500,
blobRetention: 10,
colRetention: 10,
testSlots: []primitives.Slot{500, 1000, 1200, 1499, 1500, 2000},
expectBlockAt: []bool{true, true, true, true, false, false},
expectBlobAt: []bool{false, false, true, true, true, false},
expectColAt: []bool{false, false, false, false, false, false},
},
{
name: "post-Fulu - all resources",
current: 3000,
blobRetention: 10,
colRetention: 10,
testSlots: []primitives.Slot{1000, 1500, 2000, 2500, 2999, 3000},
expectBlockAt: []bool{true, true, true, true, true, false},
expectBlobAt: []bool{false, false, false, false, false, false},
expectColAt: []bool{false, false, false, false, true, false},
},
{
name: "at Deneb boundary",
current: denebSlot,
blobRetention: 5,
colRetention: 5,
testSlots: []primitives.Slot{500, 999, 1000, 1500, 2000},
expectBlockAt: []bool{true, true, false, false, false},
expectBlobAt: []bool{false, false, true, true, false},
expectColAt: []bool{false, false, false, false, false},
},
{
name: "at Fulu boundary",
current: fuluSlot,
blobRetention: 5,
colRetention: 5,
testSlots: []primitives.Slot{1000, 1500, 1999, 2000, 2001},
expectBlockAt: []bool{true, true, true, false, false},
expectBlobAt: []bool{false, false, true, false, false},
expectColAt: []bool{false, false, false, false, false},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sn := syncNeeds{
current: func() primitives.Slot { return tc.current },
deneb: denebSlot,
fulu: fuluSlot,
blockRetention: 100,
blobRetention: tc.blobRetention,
colRetention: tc.colRetention,
}
cn := sn.currently()
// Verify block.end == current
require.Equal(t, tc.current, cn.block.end, "block.end should equal current")
// Verify blob.end == fulu
require.Equal(t, fuluSlot, cn.blob.end, "blob.end should equal fulu")
// Verify col.end == current
require.Equal(t, tc.current, cn.col.end, "col.end should equal current")
// Test each slot
for i, slot := range tc.testSlots {
require.Equal(t, tc.expectBlockAt[i], cn.block.at(slot),
"block.at(%d) mismatch at index %d", slot, i)
require.Equal(t, tc.expectBlobAt[i], cn.blob.at(slot),
"blob.at(%d) mismatch at index %d", slot, i)
require.Equal(t, tc.expectColAt[i], cn.col.at(slot),
"col.at(%d) mismatch at index %d", slot, i)
}
})
}
}

View File

@@ -2,22 +2,23 @@ package backfill
import (
"context"
"maps"
"math"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/startup"
"github.com/OffchainLabs/prysm/v7/beacon-chain/sync"
"github.com/OffchainLabs/prysm/v7/beacon-chain/verification"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
type batchWorkerPool interface {
spawn(ctx context.Context, n int, clock *startup.Clock, a PeerAssigner, v *verifier, cm sync.ContextByteVersions, blobVerifier verification.NewBlobVerifier, bfs *filesystem.BlobStorage)
spawn(ctx context.Context, n int, a PeerAssigner, cfg *workerCfg)
todo(b batch)
complete() (batch, error)
}
@@ -26,47 +27,61 @@ type worker interface {
run(context.Context)
}
type newWorker func(id workerId, in, out chan batch, c *startup.Clock, v *verifier, cm sync.ContextByteVersions, nbv verification.NewBlobVerifier, bfs *filesystem.BlobStorage) worker
type newWorker func(id workerId, in, out chan batch, cfg *workerCfg) worker
func defaultNewWorker(p p2p.P2P) newWorker {
return func(id workerId, in, out chan batch, c *startup.Clock, v *verifier, cm sync.ContextByteVersions, nbv verification.NewBlobVerifier, bfs *filesystem.BlobStorage) worker {
return newP2pWorker(id, p, in, out, c, v, cm, nbv, bfs)
return func(id workerId, in, out chan batch, cfg *workerCfg) worker {
return newP2pWorker(id, p, in, out, cfg)
}
}
// minRequestInterval is the minimum amount of time between requests.
// ie a value of 1s means we'll make ~1 req/sec per peer.
const minReqInterval = time.Second
type p2pBatchWorkerPool struct {
maxBatches int
newWorker newWorker
toWorkers chan batch
fromWorkers chan batch
toRouter chan batch
fromRouter chan batch
shutdownErr chan error
endSeq []batch
ctx context.Context
cancel func()
maxBatches int
newWorker newWorker
toWorkers chan batch
fromWorkers chan batch
toRouter chan batch
fromRouter chan batch
shutdownErr chan error
endSeq []batch
ctx context.Context
cancel func()
earliest primitives.Slot // earliest is the earliest slot a worker is processing
peerCache *sync.DASPeerCache
p2p p2p.P2P
peerFailLogger *intervalLogger
needs func() currentNeeds
}
var _ batchWorkerPool = &p2pBatchWorkerPool{}
func newP2PBatchWorkerPool(p p2p.P2P, maxBatches int) *p2pBatchWorkerPool {
func newP2PBatchWorkerPool(p p2p.P2P, maxBatches int, needs func() currentNeeds) *p2pBatchWorkerPool {
nw := defaultNewWorker(p)
return &p2pBatchWorkerPool{
newWorker: nw,
toRouter: make(chan batch, maxBatches),
fromRouter: make(chan batch, maxBatches),
toWorkers: make(chan batch),
fromWorkers: make(chan batch),
maxBatches: maxBatches,
shutdownErr: make(chan error),
newWorker: nw,
toRouter: make(chan batch, maxBatches),
fromRouter: make(chan batch, maxBatches),
toWorkers: make(chan batch),
fromWorkers: make(chan batch),
maxBatches: maxBatches,
shutdownErr: make(chan error),
peerCache: sync.NewDASPeerCache(p),
p2p: p,
peerFailLogger: newIntervalLogger(log, 5),
earliest: primitives.Slot(math.MaxUint64),
needs: needs,
}
}
func (p *p2pBatchWorkerPool) spawn(ctx context.Context, n int, c *startup.Clock, a PeerAssigner, v *verifier, cm sync.ContextByteVersions, nbv verification.NewBlobVerifier, bfs *filesystem.BlobStorage) {
func (p *p2pBatchWorkerPool) spawn(ctx context.Context, n int, a PeerAssigner, cfg *workerCfg) {
p.ctx, p.cancel = context.WithCancel(ctx)
go p.batchRouter(a)
for i := range n {
go p.newWorker(workerId(i), p.toWorkers, p.fromWorkers, c, v, cm, nbv, bfs).run(p.ctx)
go p.newWorker(workerId(i), p.toWorkers, p.fromWorkers, cfg).run(p.ctx)
}
}
@@ -103,7 +118,6 @@ func (p *p2pBatchWorkerPool) batchRouter(pa PeerAssigner) {
busy := make(map[peer.ID]bool)
todo := make([]batch, 0)
rt := time.NewTicker(time.Second)
earliest := primitives.Slot(math.MaxUint64)
for {
select {
case b := <-p.toRouter:
@@ -115,51 +129,129 @@ func (p *p2pBatchWorkerPool) batchRouter(pa PeerAssigner) {
// This ticker exists to periodically break out of the channel select
// to retry failed assignments.
case b := <-p.fromWorkers:
pid := b.busy
busy[pid] = false
if b.state == batchBlobSync {
todo = append(todo, b)
sortBatchDesc(todo)
} else {
p.fromRouter <- b
if b.state == batchErrFatal {
p.shutdown(b.err)
}
pid := b.assignedPeer
delete(busy, pid)
if b.workComplete() {
p.fromRouter <- b
break
}
todo = append(todo, b)
sortBatchDesc(todo)
case <-p.ctx.Done():
log.WithError(p.ctx.Err()).Info("p2pBatchWorkerPool context canceled, shutting down")
p.shutdown(p.ctx.Err())
return
}
if len(todo) == 0 {
continue
}
// Try to assign as many outstanding batches as possible to peers and feed the assigned batches to workers.
assigned, err := pa.Assign(busy, len(todo))
var err error
todo, err = p.processTodo(todo, pa, busy)
if err != nil {
if errors.Is(err, peers.ErrInsufficientSuitable) {
// Transient error resulting from insufficient number of connected peers. Leave batches in
// queue and get to them whenever the peer situation is resolved.
continue
}
p.shutdown(err)
return
}
for _, pid := range assigned {
if err := todo[0].waitUntilReady(p.ctx); err != nil {
log.WithError(p.ctx.Err()).Info("p2pBatchWorkerPool context canceled, shutting down")
p.shutdown(p.ctx.Err())
return
}
busy[pid] = true
todo[0].busy = pid
p.toWorkers <- todo[0].withPeer(pid)
if todo[0].begin < earliest {
earliest = todo[0].begin
oldestBatch.Set(float64(earliest))
}
todo = todo[1:]
}
}
}
func (p *p2pBatchWorkerPool) processTodo(todo []batch, pa PeerAssigner, busy map[peer.ID]bool) ([]batch, error) {
if len(todo) == 0 {
return todo, nil
}
notBusy, err := pa.Assign(peers.NotBusy(busy))
if err != nil {
if errors.Is(err, peers.ErrInsufficientSuitable) {
// Transient error resulting from insufficient number of connected peers. Leave batches in
// queue and get to them whenever the peer situation is resolved.
return todo, nil
}
return nil, err
}
if len(notBusy) == 0 {
log.Debug("No suitable peers available for batch assignment")
return todo, nil
}
custodied := peerdas.NewColumnIndices()
if highestEpoch(todo) >= params.BeaconConfig().FuluForkEpoch {
custodied, err = currentCustodiedColumns(p.ctx, p.p2p)
if err != nil {
return nil, errors.Wrap(err, "current custodied columns")
}
}
picker, err := p.peerCache.NewPicker(notBusy, custodied, minReqInterval)
if err != nil {
log.WithError(err).Error("Failed to compute column-weighted peer scores")
return todo, nil
}
for i, b := range todo {
needs := p.needs()
if b.expired(needs) {
p.endSeq = append(p.endSeq, b.withState(batchEndSequence))
continue
}
excludePeers := busy
if b.state == batchErrFatal {
// Fatal error detected in batch, shut down the pool.
return nil, b.err
}
if b.state == batchErrRetryable {
// Columns can fail in a partial fashion, so we nee to reset
// components that track peer interactions for multiple columns
// to enable partial retries.
b = resetToRetryColumns(b, needs)
if b.state == batchSequenced {
// Transitioning to batchSequenced means we need to download a new block batch because there was
// a problem making or verifying the last block request, so we should try to pick a different peer this time.
excludePeers = busyCopy(busy)
excludePeers[b.blockPeer] = true
b.blockPeer = "" // reset block peer so we can fail back to it next time if there is an issue with assignment.
}
}
pid, cols, err := b.selectPeer(picker, excludePeers)
if err != nil {
p.peerFailLogger.WithField("notBusy", len(notBusy)).WithError(err).WithFields(b.logFields()).Warn("Failed to select peer for batch")
// Return the remaining todo items and allow the outer loop to control when we try again.
return todo[i:], nil
}
busy[pid] = true
b.assignedPeer = pid
b.nextReqCols = cols
backfillBatchTimeWaiting.Observe(float64(time.Since(b.scheduled).Milliseconds()))
p.toWorkers <- b
p.updateEarliest(b.begin)
}
return []batch{}, nil
}
func busyCopy(busy map[peer.ID]bool) map[peer.ID]bool {
busyCp := make(map[peer.ID]bool, len(busy))
maps.Copy(busyCp, busy)
return busyCp
}
func highestEpoch(batches []batch) primitives.Epoch {
highest := primitives.Epoch(0)
for _, b := range batches {
epoch := slots.ToEpoch(b.end - 1)
if epoch > highest {
highest = epoch
}
}
return highest
}
func (p *p2pBatchWorkerPool) updateEarliest(current primitives.Slot) {
if current >= p.earliest {
return
}
p.earliest = current
oldestBatch.Set(float64(p.earliest))
}
func (p *p2pBatchWorkerPool) shutdown(err error) {
p.cancel()
p.shutdownErr <- err

View File

@@ -6,11 +6,13 @@ import (
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
p2ptest "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/startup"
"github.com/OffchainLabs/prysm/v7/beacon-chain/sync"
"github.com/OffchainLabs/prysm/v7/beacon-chain/verification"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
@@ -24,7 +26,7 @@ type mockAssigner struct {
// Assign satisfies the PeerAssigner interface so that mockAssigner can be used in tests
// in place of the concrete p2p implementation of PeerAssigner.
func (m mockAssigner) Assign(busy map[peer.ID]bool, n int) ([]peer.ID, error) {
func (m mockAssigner) Assign(filter peers.AssignmentFilter) ([]peer.ID, error) {
if m.err != nil {
return nil, m.err
}
@@ -42,7 +44,8 @@ func TestPoolDetectAllEnded(t *testing.T) {
p2p := p2ptest.NewTestP2P(t)
ctx := t.Context()
ma := &mockAssigner{}
pool := newP2PBatchWorkerPool(p2p, nw)
needs := func() currentNeeds { return currentNeeds{block: needSpan{begin: 10, end: 10}} }
pool := newP2PBatchWorkerPool(p2p, nw, needs)
st, err := util.NewBeaconState()
require.NoError(t, err)
keys, err := st.PublicKeys()
@@ -53,8 +56,9 @@ func TestPoolDetectAllEnded(t *testing.T) {
ctxMap, err := sync.ContextByteVersionsForValRoot(bytesutil.ToBytes32(st.GenesisValidatorsRoot()))
require.NoError(t, err)
bfs := filesystem.NewEphemeralBlobStorage(t)
pool.spawn(ctx, nw, startup.NewClock(time.Now(), [32]byte{}), ma, v, ctxMap, mockNewBlobVerifier, bfs)
br := batcher{min: 10, size: 10}
wcfg := &workerCfg{clock: startup.NewClock(time.Now(), [32]byte{}), newVB: mockNewBlobVerifier, verifier: v, ctxMap: ctxMap, blobStore: bfs}
pool.spawn(ctx, nw, ma, wcfg)
br := batcher{size: 10, currentNeeds: needs}
endSeq := br.before(0)
require.Equal(t, batchEndSequence, endSeq.state)
for range nw {
@@ -72,7 +76,7 @@ type mockPool struct {
todoChan chan batch
}
func (m *mockPool) spawn(_ context.Context, _ int, _ *startup.Clock, _ PeerAssigner, _ *verifier, _ sync.ContextByteVersions, _ verification.NewBlobVerifier, _ *filesystem.BlobStorage) {
func (m *mockPool) spawn(_ context.Context, _ int, _ PeerAssigner, _ *workerCfg) {
}
func (m *mockPool) todo(b batch) {
@@ -89,3 +93,443 @@ func (m *mockPool) complete() (batch, error) {
}
var _ batchWorkerPool = &mockPool{}
// TestProcessTodoExpiresOlderBatches tests that processTodo correctly identifies and converts expired batches
func TestProcessTodoExpiresOlderBatches(t *testing.T) {
testCases := []struct {
name string
seqLen int
min primitives.Slot
max primitives.Slot
size primitives.Slot
updateMin primitives.Slot // what we'll set minChecker to
expectedEndSeq int // how many batches should be converted to endSeq
expectedProcessed int // how many batches should be processed (assigned to peers)
}{
{
name: "NoBatchesExpired",
seqLen: 3,
min: 100,
max: 1000,
size: 50,
updateMin: 120, // doesn't expire any batches
expectedEndSeq: 0,
expectedProcessed: 3,
},
{
name: "SomeBatchesExpired",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
updateMin: 175, // expires batches with end <= 175
expectedEndSeq: 1, // [100-150] will be expired
expectedProcessed: 3,
},
{
name: "AllBatchesExpired",
seqLen: 3,
min: 100,
max: 300,
size: 50,
updateMin: 300, // expires all batches
expectedEndSeq: 3,
expectedProcessed: 0,
},
{
name: "MultipleBatchesExpired",
seqLen: 8,
min: 100,
max: 500,
size: 50,
updateMin: 320, // expires multiple batches
expectedEndSeq: 4, // [300-350] (end=350 > 320 not expired), [250-300], [200-250], [150-200], [100-150] = 4 batches
expectedProcessed: 4,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create pool with minChecker
pool := &p2pBatchWorkerPool{
endSeq: make([]batch, 0),
}
needs := currentNeeds{block: needSpan{begin: tc.updateMin, end: tc.max + 1}}
// Create batches with valid slot ranges (descending order)
todo := make([]batch, tc.seqLen)
for i := 0; i < tc.seqLen; i++ {
end := tc.min + primitives.Slot((tc.seqLen-i)*int(tc.size))
begin := end - tc.size
todo[i] = batch{
begin: begin,
end: end,
state: batchInit,
}
}
// Process todo using processTodo logic (simulate without actual peer assignment)
endSeqCount := 0
processedCount := 0
for _, b := range todo {
if b.expired(needs) {
pool.endSeq = append(pool.endSeq, b.withState(batchEndSequence))
endSeqCount++
} else {
processedCount++
}
}
// Verify counts
if endSeqCount != tc.expectedEndSeq {
t.Fatalf("expected %d batches to expire, got %d", tc.expectedEndSeq, endSeqCount)
}
if processedCount != tc.expectedProcessed {
t.Fatalf("expected %d batches to be processed, got %d", tc.expectedProcessed, processedCount)
}
// Verify all expired batches are in batchEndSequence state
for _, b := range pool.endSeq {
if b.state != batchEndSequence {
t.Fatalf("expired batch should be batchEndSequence, got %s", b.state.String())
}
if b.end > tc.updateMin {
t.Fatalf("batch with end=%d should not be in endSeq when min=%d", b.end, tc.updateMin)
}
}
})
}
}
// TestExpirationAfterMoveMinimum tests that batches expire correctly after minimum is increased
func TestExpirationAfterMoveMinimum(t *testing.T) {
testCases := []struct {
name string
seqLen int
min primitives.Slot
max primitives.Slot
size primitives.Slot
firstMin primitives.Slot
secondMin primitives.Slot
expectedAfter1 int // expected expired after first processTodo
expectedAfter2 int // expected expired after second processTodo
}{
{
name: "IncrementalMinimumIncrease",
seqLen: 4,
min: 100,
max: 1000,
size: 50,
firstMin: 150, // batches with end <= 150 expire
secondMin: 200, // additional batches with end <= 200 expire
expectedAfter1: 1, // [100-150] expires
expectedAfter2: 1, // [150-200] also expires on second check (end=200 <= 200)
},
{
name: "LargeMinimumJump",
seqLen: 3,
min: 100,
max: 300,
size: 50,
firstMin: 120, // no expiration
secondMin: 300, // all expire
expectedAfter1: 0,
expectedAfter2: 3,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pool := &p2pBatchWorkerPool{
endSeq: make([]batch, 0),
}
// Create batches
todo := make([]batch, tc.seqLen)
for i := 0; i < tc.seqLen; i++ {
end := tc.min + primitives.Slot((tc.seqLen-i)*int(tc.size))
begin := end - tc.size
todo[i] = batch{
begin: begin,
end: end,
state: batchInit,
}
}
needs := currentNeeds{block: needSpan{begin: tc.firstMin, end: tc.max + 1}}
// First processTodo with firstMin
endSeq1 := 0
remaining1 := make([]batch, 0)
for _, b := range todo {
if b.expired(needs) {
pool.endSeq = append(pool.endSeq, b.withState(batchEndSequence))
endSeq1++
} else {
remaining1 = append(remaining1, b)
}
}
if endSeq1 != tc.expectedAfter1 {
t.Fatalf("after first update: expected %d expired, got %d", tc.expectedAfter1, endSeq1)
}
// Second processTodo with secondMin on remaining batches
needs.block.begin = tc.secondMin
endSeq2 := 0
for _, b := range remaining1 {
if b.expired(needs) {
pool.endSeq = append(pool.endSeq, b.withState(batchEndSequence))
endSeq2++
}
}
if endSeq2 != tc.expectedAfter2 {
t.Fatalf("after second update: expected %d expired, got %d", tc.expectedAfter2, endSeq2)
}
// Verify total endSeq count
totalExpected := tc.expectedAfter1 + tc.expectedAfter2
if len(pool.endSeq) != totalExpected {
t.Fatalf("expected total %d expired batches, got %d", totalExpected, len(pool.endSeq))
}
})
}
}
// TestTodoInterceptsBatchEndSequence tests that todo() correctly intercepts batchEndSequence batches
func TestTodoInterceptsBatchEndSequence(t *testing.T) {
testCases := []struct {
name string
batches []batch
expectedEndSeq int
expectedToRouter int
}{
{
name: "AllRegularBatches",
batches: []batch{
{state: batchInit},
{state: batchInit},
{state: batchErrRetryable},
},
expectedEndSeq: 0,
expectedToRouter: 3,
},
{
name: "MixedBatches",
batches: []batch{
{state: batchInit},
{state: batchEndSequence},
{state: batchInit},
{state: batchEndSequence},
},
expectedEndSeq: 2,
expectedToRouter: 2,
},
{
name: "AllEndSequence",
batches: []batch{
{state: batchEndSequence},
{state: batchEndSequence},
{state: batchEndSequence},
},
expectedEndSeq: 3,
expectedToRouter: 0,
},
{
name: "EmptyBatches",
batches: []batch{},
expectedEndSeq: 0,
expectedToRouter: 0,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pool := &p2pBatchWorkerPool{
endSeq: make([]batch, 0),
}
endSeqCount := 0
routerCount := 0
for _, b := range tc.batches {
if b.state == batchEndSequence {
pool.endSeq = append(pool.endSeq, b)
endSeqCount++
} else {
routerCount++
}
}
if endSeqCount != tc.expectedEndSeq {
t.Fatalf("expected %d batchEndSequence, got %d", tc.expectedEndSeq, endSeqCount)
}
if routerCount != tc.expectedToRouter {
t.Fatalf("expected %d batches to router, got %d", tc.expectedToRouter, routerCount)
}
if len(pool.endSeq) != tc.expectedEndSeq {
t.Fatalf("endSeq slice should have %d batches, got %d", tc.expectedEndSeq, len(pool.endSeq))
}
})
}
}
// TestCompleteShutdownCondition tests the complete() method shutdown behavior
func TestCompleteShutdownCondition(t *testing.T) {
testCases := []struct {
name string
maxBatches int
endSeqCount int
shouldShutdown bool
expectedMin primitives.Slot
}{
{
name: "AllEndSeq_Shutdown",
maxBatches: 3,
endSeqCount: 3,
shouldShutdown: true,
expectedMin: 200,
},
{
name: "PartialEndSeq_NoShutdown",
maxBatches: 3,
endSeqCount: 2,
shouldShutdown: false,
expectedMin: 200,
},
{
name: "NoEndSeq_NoShutdown",
maxBatches: 5,
endSeqCount: 0,
shouldShutdown: false,
expectedMin: 150,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pool := &p2pBatchWorkerPool{
maxBatches: tc.maxBatches,
endSeq: make([]batch, 0),
needs: func() currentNeeds {
return currentNeeds{block: needSpan{begin: tc.expectedMin}}
},
}
// Add endSeq batches
for i := 0; i < tc.endSeqCount; i++ {
pool.endSeq = append(pool.endSeq, batch{state: batchEndSequence})
}
// Check shutdown condition (this is what complete() checks)
shouldShutdown := len(pool.endSeq) == pool.maxBatches
if shouldShutdown != tc.shouldShutdown {
t.Fatalf("expected shouldShutdown=%v, got %v", tc.shouldShutdown, shouldShutdown)
}
pool.needs = func() currentNeeds {
return currentNeeds{block: needSpan{begin: tc.expectedMin}}
}
if pool.needs().block.begin != tc.expectedMin {
t.Fatalf("expected minimum %d, got %d", tc.expectedMin, pool.needs().block.begin)
}
})
}
}
// TestExpirationFlowEndToEnd tests the complete flow of batches from batcher through pool
func TestExpirationFlowEndToEnd(t *testing.T) {
testCases := []struct {
name string
seqLen int
min primitives.Slot
max primitives.Slot
size primitives.Slot
moveMinTo primitives.Slot
expired int
description string
}{
{
name: "SingleBatchExpires",
seqLen: 2,
min: 100,
max: 300,
size: 50,
moveMinTo: 150,
expired: 1,
description: "Initial [150-200] and [100-150]; moveMinimum(150) expires [100-150]",
},
/*
{
name: "ProgressiveExpiration",
seqLen: 4,
min: 100,
max: 500,
size: 50,
moveMinTo: 250,
description: "4 batches; moveMinimum(250) expires 2 of them",
},
*/
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Simulate the flow: batcher creates batches → sequence() → pool.todo() → pool.processTodo()
// Step 1: Create sequencer (simulating batcher)
seq := newBatchSequencer(tc.seqLen, tc.max, tc.size, mockCurrentNeedsFunc(tc.min, tc.max+1))
initializeBatchWithSlots(seq.seq, tc.min, tc.size)
for i := range seq.seq {
seq.seq[i].state = batchInit
}
// Step 2: Create pool
pool := &p2pBatchWorkerPool{
endSeq: make([]batch, 0),
}
// Step 3: Initial sequence() call - all batches should be returned (none expired yet)
batches1, err := seq.sequence()
if err != nil {
t.Fatalf("initial sequence() failed: %v", err)
}
if len(batches1) != tc.seqLen {
t.Fatalf("expected %d batches from initial sequence(), got %d", tc.seqLen, len(batches1))
}
// Step 4: Move minimum (simulating epoch advancement)
seq.currentNeeds = mockCurrentNeedsFunc(tc.moveMinTo, tc.max+1)
seq.batcher.currentNeeds = seq.currentNeeds
pool.needs = seq.currentNeeds
for i := range batches1 {
seq.update(batches1[i])
}
// Step 5: Process batches through pool (second sequence call would happen here in real code)
batches2, err := seq.sequence()
if err != nil && err != errMaxBatches {
t.Fatalf("second sequence() failed: %v", err)
}
require.Equal(t, tc.seqLen-tc.expired, len(batches2))
// Step 6: Simulate pool.processTodo() checking for expiration
processedCount := 0
for _, b := range batches2 {
if b.expired(pool.needs()) {
pool.endSeq = append(pool.endSeq, b.withState(batchEndSequence))
} else {
processedCount++
}
}
// Verify: All returned non-endSeq batches should have end > moveMinTo
for _, b := range batches2 {
if b.state != batchEndSequence && b.end <= tc.moveMinTo {
t.Fatalf("batch [%d-%d] should not be returned when min=%d", b.begin, b.end, tc.moveMinTo)
}
}
})
}
}

View File

@@ -5,8 +5,8 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/peers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/startup"
"github.com/OffchainLabs/prysm/v7/beacon-chain/sync"
"github.com/OffchainLabs/prysm/v7/beacon-chain/verification"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
@@ -21,51 +21,42 @@ import (
)
type Service struct {
ctx context.Context
enabled bool // service is disabled by default
clock *startup.Clock
store *Store
ms minimumSlotter
cw startup.ClockWaiter
verifierWaiter InitializerWaiter
newBlobVerifier verification.NewBlobVerifier
nWorkers int
batchSeq *batchSequencer
batchSize uint64
pool batchWorkerPool
verifier *verifier
ctxMap sync.ContextByteVersions
p2p p2p.P2P
pa PeerAssigner
batchImporter batchImporter
blobStore *filesystem.BlobStorage
initSyncWaiter func() error
complete chan struct{}
ctx context.Context
enabled bool // service is disabled by default
clock *startup.Clock
store *Store
syncNeeds syncNeeds
ms minimumSlotter
cw startup.ClockWaiter
verifierWaiter InitializerWaiter
nWorkers int
batchSeq *batchSequencer
batchSize uint64
pool batchWorkerPool
p2p p2p.P2P
pa PeerAssigner
batchImporter batchImporter
blobStore *filesystem.BlobStorage
dcStore *filesystem.DataColumnStorage
initSyncWaiter func() error
complete chan struct{}
workerCfg *workerCfg
fuluStart primitives.Slot
denebStart primitives.Slot
}
var _ runtime.Service = (*Service)(nil)
// PeerAssigner describes a type that provides an Assign method, which can assign the best peer
// to service an RPC blockRequest. The Assign method takes a map of peers that should be excluded,
// to service an RPC blockRequest. The Assign method takes a callback used to filter out peers,
// allowing the caller to avoid making multiple concurrent requests to the same peer.
type PeerAssigner interface {
Assign(busy map[peer.ID]bool, n int) ([]peer.ID, error)
Assign(filter peers.AssignmentFilter) ([]peer.ID, error)
}
type minimumSlotter func(primitives.Slot) primitives.Slot
type batchImporter func(ctx context.Context, current primitives.Slot, b batch, su *Store) (*dbval.BackfillStatus, error)
func defaultBatchImporter(ctx context.Context, current primitives.Slot, b batch, su *Store) (*dbval.BackfillStatus, error) {
status := su.status()
if err := b.ensureParent(bytesutil.ToBytes32(status.LowParentRoot)); err != nil {
return status, err
}
// Import blocks to db and update db state to reflect the newly imported blocks.
// Other parts of the beacon node may use the same StatusUpdater instance
// via the coverage.AvailableBlocker interface to safely determine if a given slot has been backfilled.
return su.fillBack(ctx, current, b.results, b.availabilityStore())
}
// ServiceOption represents a functional option for the backfill service constructor.
type ServiceOption func(*Service) error
@@ -122,64 +113,48 @@ func WithVerifierWaiter(viw InitializerWaiter) ServiceOption {
// WithMinimumSlot allows the user to specify a different backfill minimum slot than the spec default of current - MIN_EPOCHS_FOR_BLOCK_REQUESTS.
// If this value is greater than current - MIN_EPOCHS_FOR_BLOCK_REQUESTS, it will be ignored with a warning log.
func WithMinimumSlot(s primitives.Slot) ServiceOption {
ms := func(current primitives.Slot) primitives.Slot {
specMin := minimumBackfillSlot(current)
if s < specMin {
return s
}
log.WithField("userSlot", s).WithField("specMinSlot", specMin).
Warn("Ignoring user-specified slot > MIN_EPOCHS_FOR_BLOCK_REQUESTS.")
return specMin
}
func WithMinimumSlot(oldest primitives.Slot) ServiceOption {
return func(s *Service) error {
s.ms = ms
s.syncNeeds.oldestSlotFlagPtr = &oldest
return nil
}
}
// WithBlobRetentionEpoch is used to pass through the value of the
// --blob-retention-epochs flag to the backfill service.
func WithBlobRetentionEpoch(epoch primitives.Epoch) ServiceOption {
return func(s *Service) error {
s.syncNeeds.blobRetentionFlag = epoch
return nil
}
}
// NewService initializes the backfill Service. Like all implementations of the Service interface,
// the service won't begin its runloop until Start() is called.
func NewService(ctx context.Context, su *Store, bStore *filesystem.BlobStorage, cw startup.ClockWaiter, p p2p.P2P, pa PeerAssigner, opts ...ServiceOption) (*Service, error) {
func NewService(ctx context.Context, su *Store, bStore *filesystem.BlobStorage, dcStore *filesystem.DataColumnStorage, cw startup.ClockWaiter, p p2p.P2P, pa PeerAssigner, opts ...ServiceOption) (*Service, error) {
s := &Service{
ctx: ctx,
store: su,
blobStore: bStore,
cw: cw,
ms: minimumBackfillSlot,
p2p: p,
pa: pa,
batchImporter: defaultBatchImporter,
complete: make(chan struct{}),
ctx: ctx,
store: su,
blobStore: bStore,
dcStore: dcStore,
cw: cw,
p2p: p,
pa: pa,
complete: make(chan struct{}),
fuluStart: slots.SafeEpochStartOrMax(params.BeaconConfig().FuluForkEpoch),
denebStart: slots.SafeEpochStartOrMax(params.BeaconConfig().DenebForkEpoch),
}
s.batchImporter = s.defaultBatchImporter
for _, o := range opts {
if err := o(s); err != nil {
return nil, err
}
}
s.pool = newP2PBatchWorkerPool(p, s.nWorkers)
return s, nil
}
func (s *Service) initVerifier(ctx context.Context) (*verifier, sync.ContextByteVersions, error) {
cps, err := s.store.originState(ctx)
if err != nil {
return nil, nil, err
}
keys, err := cps.PublicKeys()
if err != nil {
return nil, nil, errors.Wrap(err, "unable to retrieve public keys for all validators in the origin state")
}
vr := cps.GenesisValidatorsRoot()
ctxMap, err := sync.ContextByteVersionsForValRoot(bytesutil.ToBytes32(vr))
if err != nil {
return nil, nil, errors.Wrapf(err, "unable to initialize context version map using genesis validator root %#x", vr)
}
v, err := newBackfillVerifier(vr, keys)
return v, ctxMap, err
}
func (s *Service) updateComplete() bool {
b, err := s.pool.complete()
if err != nil {
@@ -195,39 +170,49 @@ func (s *Service) updateComplete() bool {
}
func (s *Service) importBatches(ctx context.Context) {
importable := s.batchSeq.importable()
imported := 0
defer func() {
if imported == 0 {
return
}
backfillBatchesImported.Add(float64(imported))
}()
current := s.clock.CurrentSlot()
for i := range importable {
ib := importable[i]
if len(ib.results) == 0 {
imported := 0
importable := s.batchSeq.importable()
for _, ib := range importable {
if len(ib.blocks) == 0 {
log.WithFields(ib.logFields()).Error("Batch with no results, skipping importer")
s.batchSeq.update(ib.withError(errors.New("batch has no blocks")))
// This batch needs to be retried before we can continue importing subsequent batches.
break
}
_, err := s.batchImporter(ctx, current, ib, s.store)
if err != nil {
log.WithError(err).WithFields(ib.logFields()).Debug("Backfill batch failed to import")
s.downscorePeer(ib.blockPid, "backfillBatchImportError")
s.batchSeq.update(ib.withState(batchErrRetryable))
s.batchSeq.update(ib.withError(err))
// If a batch fails, the subsequent batches are no longer considered importable.
break
}
// Calling update with state=batchImportComplete will advance the batch list.
s.batchSeq.update(ib.withState(batchImportComplete))
imported += 1
// Calling update with state=batchImportComplete will advance the batch list.
}
nt := s.batchSeq.numTodo()
log.WithField("imported", imported).WithField("importable", len(importable)).
WithField("batchesRemaining", nt).
Info("Backfill batches processed")
batchesRemaining.Set(float64(nt))
if imported > 0 {
log.WithField("imported", imported).WithField("importable", len(importable)).
WithField("batchesRemaining", nt).
Info("Backfill batches imported")
batchesImported.Add(float64(imported))
}
}
backfillRemainingBatches.Set(float64(nt))
func (s *Service) defaultBatchImporter(ctx context.Context, current primitives.Slot, b batch, su *Store) (*dbval.BackfillStatus, error) {
status := su.status()
if err := b.ensureParent(bytesutil.ToBytes32(status.LowParentRoot)); err != nil {
return status, err
}
// Import blocks to db and update db state to reflect the newly imported blocks.
// Other parts of the beacon node may use the same StatusUpdater instance
// via the coverage.AvailableBlocker interface to safely determine if a given slot has been backfilled.
checker := newCheckMultiplexer(s.syncNeeds.currently(), b)
return su.fillBack(ctx, current, b.blocks, checker)
}
func (s *Service) scheduleTodos() {
@@ -249,18 +234,6 @@ func (s *Service) scheduleTodos() {
}
}
// fuluOrigin checks whether the origin block (ie the checkpoint sync block from which backfill
// syncs backwards) is in an unsupported fork, enabling the backfill service to shut down rather than
// run with buggy behavior.
// This will be removed once DataColumnSidecar support is released.
func fuluOrigin(cfg *params.BeaconChainConfig, status *dbval.BackfillStatus) bool {
originEpoch := slots.ToEpoch(primitives.Slot(status.OriginSlot))
if originEpoch < cfg.FuluForkEpoch {
return false
}
return true
}
// Start begins the runloop of backfill.Service in the current goroutine.
func (s *Service) Start() {
if !s.enabled {
@@ -273,47 +246,33 @@ func (s *Service) Start() {
log.Info("Backfill service is shutting down")
cancel()
}()
clock, err := s.cw.WaitForClock(ctx)
if err != nil {
log.WithError(err).Error("Backfill service failed to start while waiting for genesis data")
return
}
s.clock = clock
v, err := s.verifierWaiter.WaitForInitializer(ctx)
s.newBlobVerifier = newBlobVerifierFromInitializer(v)
if err != nil {
log.WithError(err).Error("Could not initialize blob verifier in backfill service")
return
}
if s.store.isGenesisSync() {
log.Info("Backfill short-circuit; node synced from genesis")
s.markComplete()
return
}
status := s.store.status()
if fuluOrigin(params.BeaconConfig(), status) {
log.WithField("originSlot", s.store.status().OriginSlot).
Warn("backfill disabled; DataColumnSidecar currently unsupported, for updates follow https://github.com/OffchainLabs/prysm/issues/15982")
s.markComplete()
clock, err := s.cw.WaitForClock(ctx)
if err != nil {
log.WithError(err).Error("Backfill service failed to start while waiting for genesis data")
return
}
s.clock = clock
// initialize() updates syncNeeds with validated values once we've got the clock
s.syncNeeds = s.syncNeeds.initialize(s.clock.CurrentSlot, s.denebStart, s.fuluStart)
status := s.store.status()
needs := s.syncNeeds.currently()
// Exit early if there aren't going to be any batches to backfill.
if primitives.Slot(status.LowSlot) <= s.ms(s.clock.CurrentSlot()) {
log.WithField("minimumRequiredSlot", s.ms(s.clock.CurrentSlot())).
if !needs.block.at(primitives.Slot(status.LowSlot)) {
log.WithField("minimumSlot", needs.block.begin).
WithField("backfillLowestSlot", status.LowSlot).
Info("Exiting backfill service; minimum block retention slot > lowest backfilled block")
s.markComplete()
return
}
s.verifier, s.ctxMap, err = s.initVerifier(ctx)
if err != nil {
log.WithError(err).Error("Unable to initialize backfill verifier")
return
}
if s.initSyncWaiter != nil {
log.Info("Backfill service waiting for initial-sync to reach head before starting")
if err := s.initSyncWaiter(); err != nil {
@@ -321,8 +280,28 @@ func (s *Service) Start() {
return
}
}
s.pool.spawn(ctx, s.nWorkers, clock, s.pa, s.verifier, s.ctxMap, s.newBlobVerifier, s.blobStore)
s.batchSeq = newBatchSequencer(s.nWorkers, s.ms(s.clock.CurrentSlot()), primitives.Slot(status.LowSlot), primitives.Slot(s.batchSize))
if s.workerCfg == nil {
s.workerCfg = &workerCfg{
clock: s.clock,
blobStore: s.blobStore,
colStore: s.dcStore,
downscore: s.downscorePeer,
currentNeeds: s.syncNeeds.currently,
}
if err = initWorkerCfg(ctx, s.workerCfg, s.verifierWaiter, s.store); err != nil {
log.WithError(err).Error("Could not initialize blob verifier in backfill service")
return
}
}
// Allow tests to inject a mock pool.
if s.pool == nil {
s.pool = newP2PBatchWorkerPool(s.p2p, s.nWorkers, s.syncNeeds.currently)
}
s.pool.spawn(ctx, s.nWorkers, s.pa, s.workerCfg)
s.batchSeq = newBatchSequencer(s.nWorkers, primitives.Slot(status.LowSlot), primitives.Slot(s.batchSize), s.syncNeeds.currently)
if err = s.initBatches(); err != nil {
log.WithError(err).Error("Non-recoverable error in backfill service")
return
@@ -338,9 +317,6 @@ func (s *Service) Start() {
}
s.importBatches(ctx)
batchesWaiting.Set(float64(s.batchSeq.countWithState(batchImportable)))
if err := s.batchSeq.moveMinimum(s.ms(s.clock.CurrentSlot())); err != nil {
log.WithError(err).Error("Non-recoverable error while adjusting backfill minimum slot")
}
s.scheduleTodos()
}
}
@@ -364,14 +340,16 @@ func (*Service) Status() error {
return nil
}
// minimumBackfillSlot determines the lowest slot that backfill needs to download based on looking back
// MIN_EPOCHS_FOR_BLOCK_REQUESTS from the current slot.
func minimumBackfillSlot(current primitives.Slot) primitives.Slot {
oe := min(primitives.Epoch(params.BeaconConfig().MinEpochsForBlockRequests), slots.MaxSafeEpoch())
offset := slots.UnsafeEpochStart(oe)
// syncEpochOffset subtracts a number of epochs as slots from the current slot, with underflow checks.
// It returns slot 1 if the result would be 0 or underflow. It doesn't return slot 0 because the
// genesis block needs to be specially synced (it doesn't have a valid signature).
func syncEpochOffset(current primitives.Slot, subtract primitives.Epoch) primitives.Slot {
minEpoch := min(subtract, slots.MaxSafeEpoch())
// compute slot offset - offset is a number of slots to go back from current (not an absolute slot).
offset := slots.UnsafeEpochStart(minEpoch)
// Undeflow protection: slot 0 is the genesis block, therefore the signature in it is invalid.
// To prevent us from rejecting a batch, we restrict the minimum backfill batch till only slot 1
if offset >= current {
// Slot 0 is the genesis block, therefore the signature in it is invalid.
// To prevent us from rejecting a batch, we restrict the minimum backfill batch till only slot 1
return 1
}
return current - offset
@@ -383,6 +361,12 @@ func newBlobVerifierFromInitializer(ini *verification.Initializer) verification.
}
}
func newDataColumnVerifierFromInitializer(ini *verification.Initializer) verification.NewDataColumnsVerifier {
return func(cols []blocks.RODataColumn, reqs []verification.Requirement) verification.DataColumnsVerifier {
return ini.NewDataColumnsVerifier(cols, reqs)
}
}
func (s *Service) markComplete() {
close(s.complete)
log.Info("Backfill service marked as complete")
@@ -397,7 +381,11 @@ func (s *Service) WaitForCompletion() error {
}
}
func (s *Service) downscorePeer(peerID peer.ID, reason string) {
func (s *Service) downscorePeer(peerID peer.ID, reason string, err error) {
newScore := s.p2p.Peers().Scorers().BadResponsesScorer().Increment(peerID)
log.WithFields(logrus.Fields{"peerID": peerID, "reason": reason, "newScore": newScore}).Debug("Downscore peer")
logArgs := log.WithFields(logrus.Fields{"peerID": peerID, "reason": reason, "newScore": newScore})
if err != nil {
logArgs = logArgs.WithError(err)
}
logArgs.Debug("Downscore peer")
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/OffchainLabs/prysm/v7/proto/dbval"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/OffchainLabs/prysm/v7/time/slots"
)
type mockMinimumSlotter struct {
@@ -40,9 +39,12 @@ func TestServiceInit(t *testing.T) {
su, err := NewUpdater(ctx, db)
require.NoError(t, err)
nWorkers := 5
var batchSize uint64 = 100
var batchSize uint64 = 4
nBatches := nWorkers * 2
var high uint64 = 11235
// With WithMinimumSlot(0), we backfill from high down to slot 0.
// To get exactly nBatches, we need: high = nBatches * batchSize
// (slot 0 is excluded since genesis block has invalid signature)
var high uint64 = batchSize * uint64(nBatches)
originRoot := [32]byte{}
origin, err := util.NewBeaconState()
require.NoError(t, err)
@@ -53,14 +55,16 @@ func TestServiceInit(t *testing.T) {
}
remaining := nBatches
cw := startup.NewClockSynchronizer()
require.NoError(t, cw.SetClock(startup.NewClock(time.Now(), [32]byte{})))
require.NoError(t, cw.SetClock(startup.NewClock(time.Now(), [32]byte{}, startup.WithSlotAsNow(primitives.Slot(high)+1))))
pool := &mockPool{todoChan: make(chan batch, nWorkers), finishedChan: make(chan batch, nWorkers)}
p2pt := p2ptest.NewTestP2P(t)
bfs := filesystem.NewEphemeralBlobStorage(t)
srv, err := NewService(ctx, su, bfs, cw, p2pt, &mockAssigner{},
WithBatchSize(batchSize), WithWorkerCount(nWorkers), WithEnableBackfill(true), WithVerifierWaiter(&mockInitalizerWaiter{}))
dcs := filesystem.NewEphemeralDataColumnStorage(t)
srv, err := NewService(ctx, su, bfs, dcs, cw, p2pt, &mockAssigner{},
WithBatchSize(batchSize), WithWorkerCount(nWorkers), WithEnableBackfill(true), WithVerifierWaiter(&mockInitalizerWaiter{}),
WithMinimumSlot(0))
require.NoError(t, err)
srv.ms = mockMinimumSlotter{min: primitives.Slot(high - batchSize*uint64(nBatches))}.minimumSlot
srv.pool = pool
srv.batchImporter = func(context.Context, primitives.Slot, batch, *Store) (*dbval.BackfillStatus, error) {
return &dbval.BackfillStatus{}, nil
@@ -74,6 +78,11 @@ func TestServiceInit(t *testing.T) {
if b.state == batchSequenced {
b.state = batchImportable
}
for i := b.begin; i < b.end; i++ {
blk, _ := util.GenerateTestDenebBlockWithSidecar(t, [32]byte{}, primitives.Slot(i), 0)
b.blocks = append(b.blocks, blk)
}
require.Equal(t, int(batchSize), len(b.blocks))
pool.finishedChan <- b
todo = testReadN(ctx, t, pool.todoChan, 1, todo)
}
@@ -83,18 +92,6 @@ func TestServiceInit(t *testing.T) {
}
}
func TestMinimumBackfillSlot(t *testing.T) {
oe := primitives.Epoch(params.BeaconConfig().MinEpochsForBlockRequests)
currSlot := (oe + 100).Mul(uint64(params.BeaconConfig().SlotsPerEpoch))
minSlot := minimumBackfillSlot(primitives.Slot(currSlot))
require.Equal(t, 100*params.BeaconConfig().SlotsPerEpoch, minSlot)
currSlot = oe.Mul(uint64(params.BeaconConfig().SlotsPerEpoch))
minSlot = minimumBackfillSlot(primitives.Slot(currSlot))
require.Equal(t, primitives.Slot(1), minSlot)
}
func testReadN(ctx context.Context, t *testing.T, c chan batch, n int, into []batch) []batch {
for range n {
select {
@@ -108,65 +105,84 @@ func testReadN(ctx context.Context, t *testing.T, c chan batch, n int, into []ba
return into
}
func TestBackfillMinSlotDefault(t *testing.T) {
oe := primitives.Epoch(params.BeaconConfig().MinEpochsForBlockRequests)
current := primitives.Slot((oe + 100).Mul(uint64(params.BeaconConfig().SlotsPerEpoch)))
s := &Service{}
specMin := minimumBackfillSlot(current)
// TestWithBlobRetentionEpochPreserved is a regression test for the bug where
// WithBlobRetentionEpoch values were lost during service Start().
// The bug was in service.go line 264 where `syncNeeds{}.initialize(...)` was
// creating a new empty struct instead of using `s.syncNeeds.initialize(...)`
// which would preserve the flag values set by service options.
func TestWithBlobRetentionEpochPreserved(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), time.Second*5)
defer cancel()
t.Run("equal to specMin", func(t *testing.T) {
opt := WithMinimumSlot(specMin)
require.NoError(t, opt(s))
require.Equal(t, specMin, s.ms(current))
})
t.Run("older than specMin", func(t *testing.T) {
opt := WithMinimumSlot(specMin - 1)
require.NoError(t, opt(s))
// if WithMinimumSlot is older than the spec minimum, we should use it.
require.Equal(t, specMin-1, s.ms(current))
})
t.Run("newer than specMin", func(t *testing.T) {
opt := WithMinimumSlot(specMin + 1)
require.NoError(t, opt(s))
// if WithMinimumSlot is newer than the spec minimum, we should use the spec minimum
require.Equal(t, specMin, s.ms(current))
})
}
func TestFuluOrigin(t *testing.T) {
cfg := params.BeaconConfig()
fuluEpoch := cfg.FuluForkEpoch
fuluSlot, err := slots.EpochStart(fuluEpoch)
db := &mockBackfillDB{}
su, err := NewUpdater(ctx, db)
require.NoError(t, err)
cases := []struct {
name string
origin primitives.Slot
isFulu bool
}{
{
name: "before fulu",
origin: fuluSlot - 1,
isFulu: false,
},
{
name: "at fulu",
origin: fuluSlot,
isFulu: true,
},
{
name: "after fulu",
origin: fuluSlot + 1,
isFulu: true,
},
// Current slot must be:
// 1. Beyond Fulu fork (mainnet: epoch 411392 = slot 13,164,544) so columns are relevant
// 2. High enough that custom retention (100,000 epochs = 3,200,000 slots) doesn't underflow
// Using slot 17,000,000 which is well past Fulu and allows meaningful retention math.
var currentSlot primitives.Slot = 17_000_000
originRoot := [32]byte{}
origin, err := util.NewBeaconState()
require.NoError(t, err)
db.states = map[[32]byte]state.BeaconState{originRoot: origin}
su.bs = &dbval.BackfillStatus{
LowSlot: uint64(currentSlot),
OriginRoot: originRoot[:],
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status := &dbval.BackfillStatus{
OriginSlot: uint64(tc.origin),
}
result := fuluOrigin(cfg, status)
require.Equal(t, tc.isFulu, result)
})
cw := startup.NewClockSynchronizer()
require.NoError(t, cw.SetClock(startup.NewClock(time.Now(), [32]byte{}, startup.WithSlotAsNow(currentSlot+1))))
p2pt := p2ptest.NewTestP2P(t)
bfs := filesystem.NewEphemeralBlobStorage(t)
dcs := filesystem.NewEphemeralDataColumnStorage(t)
// The key: set a custom retention epoch larger than spec minimum (4096)
customRetention := primitives.Epoch(100000)
srv, err := NewService(ctx, su, bfs, dcs, cw, p2pt, &mockAssigner{},
WithBatchSize(4),
WithWorkerCount(1),
WithEnableBackfill(true),
WithVerifierWaiter(&mockInitalizerWaiter{}),
WithBlobRetentionEpoch(customRetention),
)
require.NoError(t, err)
// Use a mock pool so we can detect when Start() reaches the main loop
pool := &mockPool{
todoChan: make(chan batch, 1),
finishedChan: make(chan batch, 1),
}
srv.pool = pool
// Start the service in background - it will initialize syncNeeds at line 264
go srv.Start()
// Wait for a batch to be scheduled (proves Start() got past line 264)
select {
case <-pool.todoChan:
// Got past initialization
case <-ctx.Done():
t.Fatal("timeout waiting for batch - Start() may have exited early")
}
// Now verify the retention was preserved
needs := srv.syncNeeds.currently()
// The col retention should be based on our custom epoch, not the spec minimum (4096).
// With current slot 17,000,001 and 100,000 epoch retention:
// retention slots = 100,000 * 32 = 3,200,000
// expected begin = 17,000,001 - 3,200,000 = 13,800,001
expectedColBegin := syncEpochOffset(currentSlot+1, customRetention)
require.Equal(t, expectedColBegin, needs.col.begin,
"column retention start slot should reflect custom retention epoch, not spec minimum")
// If the bug regresses (syncNeeds{} instead of s.syncNeeds), the retention would be
// the spec minimum of 4096 epochs = 131,072 slots, giving begin = 17,000,001 - 131,072 = 16,868,929
specMinimumBegin := syncEpochOffset(currentSlot+1, params.BeaconConfig().MinEpochsForDataColumnSidecarsRequest)
require.NotEqual(t, specMinimumBegin, needs.col.begin,
"column retention should NOT be using spec minimum - the custom flag should take precedence")
}

View File

@@ -45,9 +45,10 @@ type Store struct {
bs *dbval.BackfillStatus
}
// AvailableBlock determines if the given slot is covered by the current chain history.
// If the slot is <= backfill low slot, or >= backfill high slot, the result is true.
// If the slot is between the backfill low and high slots, the result is false.
// AvailableBlock determines if the given slot has been covered by backfill.
// If the node was synced from genesis, all slots are considered available.
// The genesis block at slot 0 is always available.
// Otherwise any slot between 0 and LowSlot are considered unavailable.
func (s *Store) AvailableBlock(sl primitives.Slot) bool {
s.RLock()
defer s.RUnlock()
@@ -71,10 +72,10 @@ func (s *Store) status() *dbval.BackfillStatus {
}
}
// fillBack saves the slice of blocks and updates the BackfillStatus LowSlot/Root/ParentRoot tracker to the values
// from the first block in the slice. This method assumes that the block slice has been fully validated and
// sorted in slot order by the calling function.
func (s *Store) fillBack(ctx context.Context, current primitives.Slot, blocks []blocks.ROBlock, store das.AvailabilityStore) (*dbval.BackfillStatus, error) {
// fillBack saves the slice of blocks and updates the BackfillStatus tracker to match the first block in the slice.
// This method assumes that the block slice has been fully validated and sorted in slot order by the calling function.
// It also performs the blob and/or data column availability check, which will persist blobs/DCs to disk once verified.
func (s *Store) fillBack(ctx context.Context, current primitives.Slot, blocks []blocks.ROBlock, store das.AvailabilityChecker) (*dbval.BackfillStatus, error) {
status := s.status()
if len(blocks) == 0 {
return status, nil
@@ -88,10 +89,8 @@ func (s *Store) fillBack(ctx context.Context, current primitives.Slot, blocks []
status.LowParentRoot, highest.Root(), status.LowSlot, highest.Block().Slot())
}
for i := range blocks {
if err := store.IsDataAvailable(ctx, current, blocks[i]); err != nil {
return nil, err
}
if err := store.IsDataAvailable(ctx, current, blocks...); err != nil {
return nil, errors.Wrap(err, "IsDataAvailable")
}
if err := s.store.SaveROBlocks(ctx, blocks, false); err != nil {

View File

@@ -5,33 +5,37 @@ import (
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/interfaces"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/crypto/bls"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/runtime/version"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/pkg/errors"
)
var errInvalidBatchChain = errors.New("parent_root of block does not match the previous block's root")
var errProposerIndexTooHigh = errors.New("proposer index not present in origin state")
var errUnknownDomain = errors.New("runtime error looking up signing domain for fork")
var (
errInvalidBlocks = errors.New("block validation failure")
errInvalidBatchChain = errors.Wrap(errInvalidBlocks, "parent_root of block does not match the previous block's root")
errProposerIndexTooHigh = errors.Wrap(errInvalidBlocks, "proposer index not present in origin state")
errUnknownDomain = errors.Wrap(errInvalidBlocks, "runtime error looking up signing domain for fork")
errBatchSignatureFailed = errors.Wrap(errInvalidBlocks, "failed to verify block signature in batch")
errInvalidSignatureData = errors.Wrap(errInvalidBlocks, "could not verify signatures in block batch due to invalid signature data")
errEmptyVerificationSet = errors.New("no blocks to verify in batch")
)
// verifiedROBlocks represents a slice of blocks that have passed signature verification.
type verifiedROBlocks []blocks.ROBlock
func (v verifiedROBlocks) blobIdents(retentionStart primitives.Slot) ([]blobSummary, error) {
// early return if the newest block is outside the retention window
if len(v) > 0 && v[len(v)-1].Block().Slot() < retentionStart {
func (v verifiedROBlocks) blobIdents(needed func() currentNeeds) ([]blobSummary, error) {
if len(v) == 0 {
return nil, nil
}
needs := needed()
bs := make([]blobSummary, 0)
for i := range v {
if v[i].Block().Slot() < retentionStart {
continue
}
if v[i].Block().Version() < version.Deneb {
slot := v[i].Block().Slot()
if !needs.blob.at(slot) {
continue
}
c, err := v[i].Block().Body().BlobKzgCommitments()
@@ -56,37 +60,37 @@ type verifier struct {
domain *domainCache
}
// TODO: rewrite this to use ROBlock.
func (vr verifier) verify(blks []interfaces.ReadOnlySignedBeaconBlock) (verifiedROBlocks, error) {
var err error
result := make([]blocks.ROBlock, len(blks))
func (vr verifier) verify(blks []blocks.ROBlock) (verifiedROBlocks, error) {
if len(blks) == 0 {
// Returning an error here simplifies handling in the caller.
// errEmptyVerificationSet should not cause the peer to be downscored.
return nil, errEmptyVerificationSet
}
sigSet := bls.NewSet()
for i := range blks {
result[i], err = blocks.NewROBlock(blks[i])
if err != nil {
return nil, err
}
if i > 0 && result[i-1].Root() != result[i].Block().ParentRoot() {
p, b := result[i-1], result[i]
if i > 0 && blks[i-1].Root() != blks[i].Block().ParentRoot() {
p, b := blks[i-1], blks[i]
return nil, errors.Wrapf(errInvalidBatchChain,
"slot %d parent_root=%#x, slot %d root=%#x",
b.Block().Slot(), b.Block().ParentRoot(),
p.Block().Slot(), p.Root())
}
set, err := vr.blockSignatureBatch(result[i])
set, err := vr.blockSignatureBatch(blks[i])
if err != nil {
return nil, err
return nil, errors.Wrap(err, "block signature batch")
}
sigSet.Join(set)
}
v, err := sigSet.Verify()
if err != nil {
return nil, errors.Wrap(err, "block signature verification error")
// The blst wrapper does not give us checkable errors, so we "reverse wrap"
// the error string to make it checkable for shouldDownscore.
return nil, errors.Wrap(errInvalidSignatureData, err.Error())
}
if !v {
return nil, errors.New("batch block signature verification failed")
return nil, errBatchSignatureFailed
}
return result, nil
return blks, nil
}
func (vr verifier) blockSignatureBatch(b blocks.ROBlock) (*bls.SignatureBatch, error) {

View File

@@ -0,0 +1,175 @@
package backfill
import (
"io"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/das"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
type columnBisector struct {
rootKeys map[[32]byte]rootKey
columnSource map[rootKey]map[uint64]peer.ID
bisected map[peer.ID][]blocks.RODataColumn
pidIter []peer.ID
current int
next int
downscore peerDownscorer
errs []error
failures map[rootKey]peerdas.ColumnIndices
}
type rootKey *[32]byte
var errColumnVerification = errors.New("column verification failed")
var errBisectInconsistent = errors.New("state of bisector inconsistent with columns to bisect")
func (c *columnBisector) addPeerColumns(pid peer.ID, columns ...blocks.RODataColumn) {
for _, col := range columns {
c.setColumnSource(c.rootKey(col.BlockRoot()), col.Index, pid)
}
}
// failuresFor returns the set of column indices that failed verification
// for the given block root.
func (c *columnBisector) failuresFor(root [32]byte) peerdas.ColumnIndices {
return c.failures[c.rootKey(root)]
}
func (c *columnBisector) failingRoots() [][32]byte {
roots := make([][32]byte, 0, len(c.failures))
for rk := range c.failures {
roots = append(roots, *rk)
}
return roots
}
func (c *columnBisector) setColumnSource(rk rootKey, idx uint64, pid peer.ID) {
if c.columnSource == nil {
c.columnSource = make(map[rootKey]map[uint64]peer.ID)
}
if c.columnSource[rk] == nil {
c.columnSource[rk] = make(map[uint64]peer.ID)
}
c.columnSource[rk][idx] = pid
}
func (c *columnBisector) clearColumnSource(rk rootKey, idx uint64) {
if c.columnSource == nil {
return
}
if c.columnSource[rk] == nil {
return
}
delete(c.columnSource[rk], idx)
if len(c.columnSource[rk]) == 0 {
delete(c.columnSource, rk)
}
}
func (c *columnBisector) rootKey(root [32]byte) rootKey {
ptr, ok := c.rootKeys[root]
if ok {
return ptr
}
c.rootKeys[root] = &root
return c.rootKeys[root]
}
func (c *columnBisector) peerFor(col blocks.RODataColumn) (peer.ID, error) {
r := c.columnSource[c.rootKey(col.BlockRoot())]
if len(r) == 0 {
return "", errors.Wrap(errBisectInconsistent, "root not tracked")
}
if pid, ok := r[col.Index]; ok {
return pid, nil
}
return "", errors.Wrap(errBisectInconsistent, "index not tracked for root")
}
// reset prepares the columnBisector to be used to retry failed columns.
// it resets the peer sources of the failed columns and clears the failure records.
func (c *columnBisector) reset() {
// reset all column sources for failed columns
for rk, indices := range c.failures {
for _, idx := range indices.ToSlice() {
c.clearColumnSource(rk, idx)
}
}
c.failures = make(map[rootKey]peerdas.ColumnIndices)
c.errs = nil
}
// Bisect initializes columnBisector with the set of columns to bisect.
func (c *columnBisector) Bisect(columns []blocks.RODataColumn) (das.BisectionIterator, error) {
for _, col := range columns {
pid, err := c.peerFor(col)
if err != nil {
return nil, errors.Wrap(err, "could not lookup peer for column")
}
c.bisected[pid] = append(c.bisected[pid], col)
}
c.pidIter = make([]peer.ID, 0, len(c.bisected))
for pid := range c.bisected {
c.pidIter = append(c.pidIter, pid)
}
// The implementation of Next() assumes these are equal in
// the base case.
c.current, c.next = 0, 0
return c, nil
}
// Next implements an iterator for the columnBisector.
// Each batch is from a single peer.
func (c *columnBisector) Next() ([]blocks.RODataColumn, error) {
if c.next >= len(c.pidIter) {
return nil, io.EOF
}
c.current = c.next
pid := c.pidIter[c.current]
cols := c.bisected[pid]
c.next += 1
return cols, nil
}
// Error implements das.Bisector.
func (c *columnBisector) Error() error {
if len(c.errs) > 0 {
return errColumnVerification
}
return nil
}
// OnError implements das.Bisector.
func (c *columnBisector) OnError(err error) {
c.errs = append(c.errs, err)
pid := c.pidIter[c.current]
c.downscore(pid, "column verification error", err)
// Track which roots failed by examining columns from the current peer
columns := c.bisected[pid]
for _, col := range columns {
root := col.BlockRoot()
rk := c.rootKey(root)
if c.failures[rk] == nil {
c.failures[rk] = make(peerdas.ColumnIndices)
}
c.failures[rk][col.Index] = struct{}{}
}
}
var _ das.Bisector = &columnBisector{}
var _ das.BisectionIterator = &columnBisector{}
func newColumnBisector(downscorer peerDownscorer) *columnBisector {
return &columnBisector{
rootKeys: make(map[[32]byte]rootKey),
columnSource: make(map[rootKey]map[uint64]peer.ID),
bisected: make(map[peer.ID][]blocks.RODataColumn),
failures: make(map[rootKey]peerdas.ColumnIndices),
downscore: downscorer,
}
}

View File

@@ -0,0 +1,587 @@
package backfill
import (
"io"
"slices"
"testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
// mockDownscorer is a simple downscorer that tracks calls
type mockDownscorer struct {
calls []struct {
pid peer.ID
msg string
err error
}
}
func (m *mockDownscorer) downscoreCall(pid peer.ID, msg string, err error) {
m.calls = append(m.calls, struct {
pid peer.ID
msg string
err error
}{pid, msg, err})
}
// createTestDataColumn creates a test data column with the given parameters.
// nBlobs determines the number of cells, commitments, and proofs.
func createTestDataColumn(t *testing.T, root [32]byte, index uint64, nBlobs int) util.DataColumnParam {
commitments := make([][]byte, nBlobs)
cells := make([][]byte, nBlobs)
proofs := make([][]byte, nBlobs)
for i := range nBlobs {
commitments[i] = make([]byte, 48)
cells[i] = make([]byte, 0)
proofs[i] = make([]byte, 48)
}
return util.DataColumnParam{
Index: index,
Column: cells,
KzgCommitments: commitments,
KzgProofs: proofs,
Slot: primitives.Slot(1),
BodyRoot: root[:],
StateRoot: make([]byte, 32),
ParentRoot: make([]byte, 32),
}
}
// createTestPeerID creates a test peer ID from a string seed.
func createTestPeerID(t *testing.T, seed string) peer.ID {
pid, err := peer.Decode(seed)
require.NoError(t, err)
return pid
}
// TestNewColumnBisector verifies basic initialization
func TestNewColumnBisector(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
require.NotNil(t, cb)
require.NotNil(t, cb.rootKeys)
require.NotNil(t, cb.columnSource)
require.NotNil(t, cb.bisected)
require.Equal(t, 0, cb.current)
require.Equal(t, 0, cb.next)
}
// TestAddAndIterateColumns demonstrates creating test columns and iterating
func TestAddAndIterateColumns(t *testing.T) {
root := [32]byte{1, 0, 0}
params := []util.DataColumnParam{
createTestDataColumn(t, root, 0, 2),
createTestDataColumn(t, root, 1, 2),
}
roColumns, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, params)
require.Equal(t, 2, len(roColumns))
// Create downscorer and bisector
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
// Create test peer ID
pid1 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
// Add columns from peer
cb.addPeerColumns(pid1, roColumns...)
// Bisect and verify iteration
iter, err := cb.Bisect(roColumns)
require.NoError(t, err)
require.NotNil(t, iter)
// Get first (and only) batch from the peer
batch, err := iter.Next()
require.NoError(t, err)
require.Equal(t, 2, len(batch))
// Next should return EOF
_, err = iter.Next()
require.Equal(t, io.EOF, err)
}
// TestRootKeyDeduplication verifies that rootKey returns the same pointer for identical roots
func TestRootKeyDeduplication(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 2, 3}
key1 := cb.rootKey(root)
key2 := cb.rootKey(root)
// Should be the same pointer
require.Equal(t, key1, key2)
}
// TestMultipleRootsAndPeers verifies handling of multiple distinct roots and peer IDs
func TestMultipleRootsAndPeers(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root1 := [32]byte{1, 0, 0}
root2 := [32]byte{2, 0, 0}
root3 := [32]byte{3, 0, 0}
pid1 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
pid2 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMr")
// Register multiple columns with different roots and peers
params1 := createTestDataColumn(t, root1, 0, 2)
params2 := createTestDataColumn(t, root2, 1, 2)
params3 := createTestDataColumn(t, root3, 2, 2)
cols1, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params1})
cols2, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params2})
cols3, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params3})
cb.addPeerColumns(pid1, cols1...)
cb.addPeerColumns(pid2, cols2...)
cb.addPeerColumns(pid1, cols3...)
// Verify roots and peers are tracked
require.Equal(t, 3, len(cb.rootKeys))
}
// TestSetColumnSource verifies that columns from different peers are properly tracked
func TestSetColumnSource(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
// Create multiple peers with columns
root1 := [32]byte{1, 0, 0}
root2 := [32]byte{2, 0, 0}
root3 := [32]byte{3, 0, 0}
pid1 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
pid2 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMr")
// Create columns for peer1: 2 columns
params1 := []util.DataColumnParam{
createTestDataColumn(t, root1, 0, 1),
createTestDataColumn(t, root2, 1, 1),
}
// Create columns for peer2: 2 columns
params2 := []util.DataColumnParam{
createTestDataColumn(t, root3, 0, 1),
createTestDataColumn(t, root1, 2, 1),
}
cols1, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, params1)
cols2, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, params2)
// Register columns from both peers
cb.addPeerColumns(pid1, cols1...)
cb.addPeerColumns(pid2, cols2...)
// Use Bisect to verify columns are grouped by peer
allCols := append(cols1, cols2...)
iter, err := cb.Bisect(allCols)
require.NoError(t, err)
// Sort the pidIter so batch order is deterministic
slices.Sort(cb.pidIter)
// Collect all batches (order is non-deterministic due to map iteration)
var batches [][]blocks.RODataColumn
for {
batch, err := iter.Next()
if err == io.EOF {
break
}
require.NoError(t, err)
batches = append(batches, batch)
}
// Verify we got exactly 2 batches
require.Equal(t, 2, len(batches))
// Find which batch is from which peer by examining the columns
pid1Batch := map[peer.ID][]blocks.RODataColumn{pid1: nil, pid2: nil}
for _, batch := range batches {
if len(batch) == 0 {
continue
}
// All columns in a batch are from the same peer
col := batch[0]
colPeer, err := cb.peerFor(col)
require.NoError(t, err)
// Compare dereferenced peer.ID values rather than pointers
if colPeer == pid1 {
pid1Batch[pid1] = batch
} else if colPeer == pid2 {
pid1Batch[pid2] = batch
}
}
// Verify peer1's batch has 2 columns
require.NotNil(t, pid1Batch[pid1])
require.Equal(t, 2, len(pid1Batch[pid1]))
for _, col := range pid1Batch[pid1] {
colPeer, err := cb.peerFor(col)
require.NoError(t, err)
require.Equal(t, pid1, colPeer)
}
// Verify peer2's batch has 2 columns
require.NotNil(t, pid1Batch[pid2])
require.Equal(t, 2, len(pid1Batch[pid2]))
for _, col := range pid1Batch[pid2] {
colPeer, err := cb.peerFor(col)
require.NoError(t, err)
require.Equal(t, pid2, colPeer)
}
}
// TestClearColumnSource verifies column removal and cleanup of empty maps
func TestClearColumnSource(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
rk := cb.rootKey(root)
pid := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
cb.setColumnSource(rk, 0, pid)
cb.setColumnSource(rk, 1, pid)
require.Equal(t, 2, len(cb.columnSource[rk]))
// Clear one column
cb.clearColumnSource(rk, 0)
require.Equal(t, 1, len(cb.columnSource[rk]))
// Clear the last column - should remove the root entry
cb.clearColumnSource(rk, 1)
_, exists := cb.columnSource[rk]
require.Equal(t, false, exists)
}
// TestClearNonexistentColumn ensures clearing non-existent columns doesn't crash
func TestClearNonexistentColumn(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
rk := cb.rootKey(root)
// Should not panic
cb.clearColumnSource(rk, 99)
}
// TestFailuresFor verifies failuresFor returns correct failures for a block root
func TestFailuresFor(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
rk := cb.rootKey(root)
// Initially no failures
failures := cb.failuresFor(root)
require.Equal(t, 0, len(failures.ToSlice()))
// Set some failures
cb.failures[rk] = peerdas.ColumnIndices{0: struct{}{}, 1: struct{}{}, 2: struct{}{}}
failures = cb.failuresFor(root)
require.Equal(t, 3, len(failures.ToSlice()))
}
// TestFailingRoots ensures failingRoots returns all roots with failures
func TestFailingRoots(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root1 := [32]byte{1, 0, 0}
root2 := [32]byte{2, 0, 0}
rk1 := cb.rootKey(root1)
rk2 := cb.rootKey(root2)
cb.failures[rk1] = peerdas.ColumnIndices{0: struct{}{}}
cb.failures[rk2] = peerdas.ColumnIndices{1: struct{}{}}
failingRoots := cb.failingRoots()
require.Equal(t, 2, len(failingRoots))
}
// TestPeerFor verifies peerFor correctly returns the peer for a column
func TestPeerFor(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
pid := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
params := createTestDataColumn(t, root, 0, 2)
cols, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params})
// Use addPeerColumns to properly register the column
cb.addPeerColumns(pid, cols[0])
peerKey, err := cb.peerFor(cols[0])
require.NoError(t, err)
require.NotNil(t, peerKey)
}
// TestPeerForNotTracked ensures error when root not tracked
func TestPeerForNotTracked(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
params := createTestDataColumn(t, root, 0, 2)
cols, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params})
// Don't add any columns - root is not tracked
_, err := cb.peerFor(cols[0])
require.ErrorIs(t, err, errBisectInconsistent)
}
// TestBisectGroupsByMultiplePeers ensures columns grouped by their peer source
func TestBisectGroupsByMultiplePeers(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
pid1 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
pid2 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMr")
params1 := createTestDataColumn(t, root, 0, 2)
params2 := createTestDataColumn(t, root, 1, 2)
cols1, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params1})
cols2, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params2})
cb.addPeerColumns(pid1, cols1...)
cb.addPeerColumns(pid2, cols2...)
// Bisect both columns
iter, err := cb.Bisect(append(cols1, cols2...))
require.NoError(t, err)
// Sort the pidIter so that batch order is deterministic
slices.Sort(cb.pidIter)
// Should get two separate batches, one from each peer
batch1, err := iter.Next()
require.NoError(t, err)
require.Equal(t, 1, len(batch1))
batch2, err := iter.Next()
require.NoError(t, err)
require.Equal(t, 1, len(batch2))
_, err = iter.Next()
require.Equal(t, io.EOF, err)
}
// TestOnError verifies OnError records errors and calls downscorer
func TestOnError(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
pid := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
cb.pidIter = append(cb.pidIter, pid)
cb.current = 0
testErr := errors.New("test error")
cb.OnError(testErr)
require.Equal(t, 1, len(cb.errs))
require.Equal(t, 1, len(downscorer.calls))
require.Equal(t, pid, downscorer.calls[0].pid)
}
// TestErrorReturnAfterOnError ensures Error() returns non-nil after OnError called
func TestErrorReturnAfterOnError(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
pid := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
cb.pidIter = append(cb.pidIter, pid)
cb.current = 0
require.NoError(t, cb.Error())
cb.OnError(errors.New("test error"))
require.NotNil(t, cb.Error())
}
// TestResetClearsFailures verifies reset clears all failures and errors
func TestResetClearsFailures(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
rk := cb.rootKey(root)
cb.failures[rk] = peerdas.ColumnIndices{0: struct{}{}, 1: struct{}{}}
cb.errs = []error{errors.New("test")}
cb.reset()
require.Equal(t, 0, len(cb.failures))
require.Equal(t, 0, len(cb.errs))
}
// TestResetClearsColumnSources ensures reset clears column sources for failed columns
func TestResetClearsColumnSources(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root := [32]byte{1, 0, 0}
rk := cb.rootKey(root)
pid := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
cb.setColumnSource(rk, 0, pid)
cb.setColumnSource(rk, 1, pid)
cb.failures[rk] = peerdas.ColumnIndices{0: struct{}{}, 1: struct{}{}}
cb.reset()
// Column sources for the failed root should be cleared
_, exists := cb.columnSource[rk]
require.Equal(t, false, exists)
}
// TestBisectResetBisectAgain tests end-to-end multiple bisect cycles with reset
func TestBisectResetBisectAgain(t *testing.T) {
downscorer := &mockDownscorer{}
root := [32]byte{1, 0, 0}
pid := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
params := createTestDataColumn(t, root, 0, 2)
cols, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, []util.DataColumnParam{params})
// First bisect with fresh bisector
cb1 := newColumnBisector(downscorer.downscoreCall)
cb1.addPeerColumns(pid, cols...)
iter, err := cb1.Bisect(cols)
require.NoError(t, err)
batch, err := iter.Next()
require.NoError(t, err)
require.Equal(t, 1, len(batch))
_, err = iter.Next()
require.Equal(t, io.EOF, err)
// Second bisect with a new bisector (simulating retry with reset)
cb2 := newColumnBisector(downscorer.downscoreCall)
cb2.addPeerColumns(pid, cols...)
iter, err = cb2.Bisect(cols)
require.NoError(t, err)
batch, err = iter.Next()
require.NoError(t, err)
require.Equal(t, 1, len(batch))
}
// TestBisectEmptyColumns tests Bisect with empty column list
func TestBisectEmptyColumns(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
var emptyColumns []util.DataColumnParam
roColumns, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, emptyColumns)
iter, err := cb.Bisect(roColumns)
// This should not error with empty columns
if err == nil {
_, err := iter.Next()
require.Equal(t, io.EOF, err)
}
}
// TestCompleteFailureFlow tests marking a peer as failed and tracking failure roots
func TestCompleteFailureFlow(t *testing.T) {
downscorer := &mockDownscorer{}
cb := newColumnBisector(downscorer.downscoreCall)
root1 := [32]byte{1, 0, 0}
root2 := [32]byte{2, 0, 0}
root3 := [32]byte{3, 0, 0}
pid1 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMq")
pid2 := createTestPeerID(t, "QmYyQSo1c1Ym7orWxLYvCrM2EmxFTSc34pP8r3hidQPQMr")
// Create columns: pid1 provides columns for root1 and root2, pid2 provides for root3
params1 := []util.DataColumnParam{
createTestDataColumn(t, root1, 0, 2),
createTestDataColumn(t, root2, 1, 2),
}
params2 := []util.DataColumnParam{
createTestDataColumn(t, root3, 2, 2),
}
cols1, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, params1)
cols2, _ := util.CreateTestVerifiedRoDataColumnSidecars(t, params2)
cb.addPeerColumns(pid1, cols1...)
cb.addPeerColumns(pid2, cols2...)
allCols := append(cols1, cols2...)
iter, err := cb.Bisect(allCols)
require.NoError(t, err)
// sort the pidIter so that the test is deterministic
slices.Sort(cb.pidIter)
batch1, err := iter.Next()
require.NoError(t, err)
require.Equal(t, 2, len(batch1))
// Determine which peer the first batch came from
firstBatchPeer := batch1[0]
colPeer, err := cb.peerFor(firstBatchPeer)
require.NoError(t, err)
expectedPeer := colPeer
// Extract the roots from batch1 to ensure we can track them
rootsInBatch1 := make(map[[32]byte]bool)
for _, col := range batch1 {
rootsInBatch1[col.BlockRoot()] = true
}
// Mark the first batch's peer as failed
cb.OnError(errors.New("peer verification failed"))
// Verify downscorer was called for the peer that had the first batch
require.Equal(t, 1, len(downscorer.calls))
require.Equal(t, expectedPeer, downscorer.calls[0].pid)
// Verify that failures contains the roots from batch1
require.Equal(t, len(rootsInBatch1), len(cb.failingRoots()))
// Get remaining batches until EOF
batch2, err := iter.Next()
require.NoError(t, err)
require.Equal(t, 1, len(batch2))
_, err = iter.Next()
require.Equal(t, io.EOF, err)
// Verify failingRoots matches the roots from the failed batch
failingRoots := cb.failingRoots()
require.Equal(t, len(rootsInBatch1), len(failingRoots))
// Verify the failing roots are exactly the ones from batch1
failingRootsMap := make(map[[32]byte]bool)
for _, root := range failingRoots {
failingRootsMap[root] = true
}
for root := range rootsInBatch1 {
require.Equal(t, true, failingRootsMap[root])
}
}

View File

@@ -8,16 +8,59 @@ import (
fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/consensus-types/interfaces"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
"github.com/OffchainLabs/prysm/v7/crypto/bls"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/OffchainLabs/prysm/v7/runtime/interop"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
"github.com/OffchainLabs/prysm/v7/time/slots"
"github.com/ethereum/go-ethereum/common/hexutil"
)
func mockCurrentNeeds(begin, end primitives.Slot) currentNeeds {
return currentNeeds{
block: needSpan{
begin: begin,
end: end,
},
blob: needSpan{
begin: begin,
end: end,
},
col: needSpan{
begin: begin,
end: end,
},
}
}
func mockCurrentSpecNeeds() currentNeeds {
cfg := params.BeaconConfig()
fuluSlot := slots.UnsafeEpochStart(cfg.FuluForkEpoch)
denebSlot := slots.UnsafeEpochStart(cfg.DenebForkEpoch)
return currentNeeds{
block: needSpan{
begin: 0,
end: primitives.Slot(math.MaxUint64),
},
blob: needSpan{
begin: denebSlot,
end: fuluSlot,
},
col: needSpan{
begin: fuluSlot,
end: primitives.Slot(math.MaxUint64),
},
}
}
func mockCurrentNeedsFunc(begin, end primitives.Slot) func() currentNeeds {
return func() currentNeeds {
return mockCurrentNeeds(begin, end)
}
}
func TestDomainCache(t *testing.T) {
cfg := params.MainnetConfig()
// This hack is needed not to have both Electra and Fulu fork epoch both set to the future max epoch.
@@ -70,12 +113,7 @@ func TestVerify(t *testing.T) {
}
v, err := newBackfillVerifier(vr, pubkeys)
require.NoError(t, err)
notrob := make([]interfaces.ReadOnlySignedBeaconBlock, len(blks))
// We have to unwrap the ROBlocks for this code because that's what it expects (for now).
for i := range blks {
notrob[i] = blks[i].ReadOnlySignedBeaconBlock
}
vbs, err := v.verify(notrob)
vbs, err := v.verify(blks)
require.NoError(t, err)
require.Equal(t, len(blks), len(vbs))
}

View File

@@ -9,9 +9,57 @@ import (
"github.com/OffchainLabs/prysm/v7/beacon-chain/startup"
"github.com/OffchainLabs/prysm/v7/beacon-chain/sync"
"github.com/OffchainLabs/prysm/v7/beacon-chain/verification"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/encoding/bytesutil"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
var errInvalidBatchState = errors.New("invalid batch state")
type peerDownscorer func(peer.ID, string, error)
type workerCfg struct {
clock *startup.Clock
verifier *verifier
ctxMap sync.ContextByteVersions
newVB verification.NewBlobVerifier
newVC verification.NewDataColumnsVerifier
blobStore *filesystem.BlobStorage
colStore *filesystem.DataColumnStorage
downscore peerDownscorer
currentNeeds func() currentNeeds
}
func initWorkerCfg(ctx context.Context, cfg *workerCfg, vw InitializerWaiter, store *Store) error {
vi, err := vw.WaitForInitializer(ctx)
if err != nil {
return errors.Wrap(err, "WaitForInitializer")
}
cps, err := store.originState(ctx)
if err != nil {
return errors.Wrap(err, "originState")
}
keys, err := cps.PublicKeys()
if err != nil {
return errors.Wrap(err, "unable to retrieve public keys for all validators in the origin state")
}
vr := cps.GenesisValidatorsRoot()
cm, err := sync.ContextByteVersionsForValRoot(bytesutil.ToBytes32(vr))
if err != nil {
return errors.Wrapf(err, "unable to initialize context version map using genesis validator root %#x", vr)
}
v, err := newBackfillVerifier(vr, keys)
if err != nil {
return errors.Wrapf(err, "newBackfillVerifier failed")
}
cfg.verifier = v
cfg.ctxMap = cm
cfg.newVB = newBlobVerifierFromInitializer(vi)
cfg.newVC = newDataColumnVerifierFromInitializer(vi)
return nil
}
type workerId int
type p2pWorker struct {
@@ -19,23 +67,46 @@ type p2pWorker struct {
todo chan batch
done chan batch
p2p p2p.P2P
v *verifier
c *startup.Clock
cm sync.ContextByteVersions
nbv verification.NewBlobVerifier
bfs *filesystem.BlobStorage
cfg *workerCfg
}
func newP2pWorker(id workerId, p p2p.P2P, todo, done chan batch, cfg *workerCfg) *p2pWorker {
return &p2pWorker{
id: id,
todo: todo,
done: done,
p2p: p,
cfg: cfg,
}
}
func (w *p2pWorker) run(ctx context.Context) {
for {
select {
case b := <-w.todo:
log.WithFields(b.logFields()).WithField("backfillWorker", w.id).Debug("Backfill worker received batch")
if b.state == batchBlobSync {
w.done <- w.handleBlobs(ctx, b)
} else {
w.done <- w.handleBlocks(ctx, b)
if err := b.waitUntilReady(ctx); err != nil {
log.WithField("batchId", b.id()).WithError(ctx.Err()).Info("Worker context canceled while waiting to retry")
continue
}
log.WithFields(b.logFields()).WithField("backfillWorker", w.id).Trace("Backfill worker received batch")
switch b.state {
case batchSequenced:
b = w.handleBlocks(ctx, b)
case batchSyncBlobs:
b = w.handleBlobs(ctx, b)
case batchSyncColumns:
b = w.handleColumns(ctx, b)
case batchImportable:
// This state indicates the batch got all the way to be imported and failed,
// so we need clear out the blocks to go all the way back to the start of the process.
b.blocks = nil
b = w.handleBlocks(ctx, b)
default:
// A batch in an unknown state represents an implementation error,
// so we treat it as a fatal error meaning the worker pool should shut down.
b = b.withFatalError(errors.Wrap(errInvalidBatchState, b.state.String()))
}
w.done <- b
case <-ctx.Done():
log.WithField("backfillWorker", w.id).Info("Backfill worker exiting after context canceled")
return
@@ -44,73 +115,125 @@ func (w *p2pWorker) run(ctx context.Context) {
}
func (w *p2pWorker) handleBlocks(ctx context.Context, b batch) batch {
cs := w.c.CurrentSlot()
blobRetentionStart, err := sync.BlobRPCMinValidSlot(cs)
if err != nil {
return b.withRetryableError(errors.Wrap(err, "configuration issue, could not compute minimum blob retention slot"))
}
b.blockPid = b.busy
current := w.cfg.clock.CurrentSlot()
b.blockPeer = b.assignedPeer
start := time.Now()
results, err := sync.SendBeaconBlocksByRangeRequest(ctx, w.c, w.p2p, b.blockPid, b.blockRequest(), blockValidationMetrics)
dlt := time.Now()
backfillBatchTimeDownloadingBlocks.Observe(float64(dlt.Sub(start).Milliseconds()))
results, err := sync.SendBeaconBlocksByRangeRequest(ctx, w.cfg.clock, w.p2p, b.blockPeer, b.blockRequest(), blockValidationMetrics)
if err != nil {
log.WithError(err).WithFields(b.logFields()).Debug("Batch requesting failed")
return b.withRetryableError(err)
}
vb, err := w.v.verify(results)
backfillBatchTimeVerifying.Observe(float64(time.Since(dlt).Milliseconds()))
dlt := time.Now()
blockDownloadMs.Observe(float64(dlt.Sub(start).Milliseconds()))
toVerify, err := blocks.NewROBlockSlice(results)
if err != nil {
log.WithError(err).WithFields(b.logFields()).Debug("Batch conversion to ROBlock failed")
return b.withRetryableError(err)
}
verified, err := w.cfg.verifier.verify(toVerify)
blockVerifyMs.Observe(float64(time.Since(dlt).Milliseconds()))
if err != nil {
if shouldDownscore(err) {
w.cfg.downscore(b.blockPeer, "invalid SignedBeaconBlock batch rpc response", err)
}
log.WithError(err).WithFields(b.logFields()).Debug("Batch validation failed")
return b.withRetryableError(err)
}
// This is a hack to get the rough size of the batch. This helps us approximate the amount of memory needed
// to hold batches and relative sizes between batches, but will be inaccurate when it comes to measuring actual
// bytes downloaded from peers, mainly because the p2p messages are snappy compressed.
bdl := 0
for i := range vb {
bdl += vb[i].SizeSSZ()
for i := range verified {
bdl += verified[i].SizeSSZ()
}
backfillBlocksApproximateBytes.Add(float64(bdl))
blockDownloadBytesApprox.Add(float64(bdl))
log.WithFields(b.logFields()).WithField("dlbytes", bdl).Debug("Backfill batch block bytes downloaded")
bs, err := newBlobSync(cs, vb, &blobSyncConfig{retentionStart: blobRetentionStart, nbv: w.nbv, store: w.bfs})
b.blocks = verified
bscfg := &blobSyncConfig{currentNeeds: w.cfg.currentNeeds, nbv: w.cfg.newVB, store: w.cfg.blobStore}
bs, err := newBlobSync(current, verified, bscfg)
if err != nil {
return b.withRetryableError(err)
}
return b.withResults(vb, bs)
cs, err := newColumnSync(ctx, b, verified, current, w.p2p, w.cfg)
if err != nil {
return b.withRetryableError(err)
}
b.blobs = bs
b.columns = cs
return b.transitionToNext()
}
func (w *p2pWorker) handleBlobs(ctx context.Context, b batch) batch {
b.blobPid = b.busy
b.blobs.peer = b.assignedPeer
start := time.Now()
// we don't need to use the response for anything other than metrics, because blobResponseValidation
// adds each of them to a batch AvailabilityStore once it is checked.
blobs, err := sync.SendBlobsByRangeRequest(ctx, w.c, w.p2p, b.blobPid, w.cm, b.blobRequest(), b.blobResponseValidator(), blobValidationMetrics)
blobs, err := sync.SendBlobsByRangeRequest(ctx, w.cfg.clock, w.p2p, b.blobs.peer, w.cfg.ctxMap, b.blobRequest(), b.blobs.validateNext, blobValidationMetrics)
if err != nil {
b.bs = nil
b.blobs = nil
return b.withRetryableError(err)
}
dlt := time.Now()
backfillBatchTimeDownloadingBlobs.Observe(float64(dlt.Sub(start).Milliseconds()))
blobSidecarDownloadMs.Observe(float64(dlt.Sub(start).Milliseconds()))
if len(blobs) > 0 {
// All blobs are the same size, so we can compute 1 and use it for all in the batch.
sz := blobs[0].SizeSSZ() * len(blobs)
backfillBlobsApproximateBytes.Add(float64(sz))
blobSidecarDownloadBytesApprox.Add(float64(sz))
log.WithFields(b.logFields()).WithField("dlbytes", sz).Debug("Backfill batch blob bytes downloaded")
}
return b.postBlobSync()
if b.blobs.needed() > 0 {
// If we are missing blobs after processing the blob step, this is an error and we need to scrap the batch and start over.
b.blobs = nil
b.blocks = []blocks.ROBlock{}
return b.withRetryableError(errors.New("missing blobs after blob download"))
}
return b.transitionToNext()
}
func newP2pWorker(id workerId, p p2p.P2P, todo, done chan batch, c *startup.Clock, v *verifier, cm sync.ContextByteVersions, nbv verification.NewBlobVerifier, bfs *filesystem.BlobStorage) *p2pWorker {
return &p2pWorker{
id: id,
todo: todo,
done: done,
p2p: p,
v: v,
c: c,
cm: cm,
nbv: nbv,
bfs: bfs,
func (w *p2pWorker) handleColumns(ctx context.Context, b batch) batch {
start := time.Now()
b.columns.peer = b.assignedPeer
// Bisector is used to keep track of the peer that provided each column, for scoring purposes.
// When verification of a batch of columns fails, bisector is used to retry verification with batches
// grouped by peer, to figure out if the failure is due to a specific peer.
vr, err := b.validatingColumnRequest(b.columns.bisector)
if err != nil {
return b.withRetryableError(errors.Wrap(err, "creating validating column request"))
}
p := sync.DataColumnSidecarsParams{
Ctx: ctx,
Tor: w.cfg.clock,
P2P: w.p2p,
CtxMap: w.cfg.ctxMap,
// DownscorePeerOnRPCFault is very aggressive and is only used for fetching origin blobs during startup.
DownscorePeerOnRPCFault: false,
// SendDataColumnSidecarsByRangeRequest uses the DataColumnSidecarsParams param struct to cover
// multiple different use cases. Some of them have different required fields. The following fields are
// not used in the methods that backfill invokes. SendDataColumnSidecarsByRangeRequest should be refactored
// to only require the minimum set of parameters.
//RateLimiter *leakybucket.Collector
//Storage: w.cfg.cfs,
//NewVerifier: vr.validate,
}
// The return is dropped because the validation code adds the columns
// to the columnSync AvailabilityStore under the hood.
_, err = sync.SendDataColumnSidecarsByRangeRequest(p, b.columns.peer, vr.req, vr.validate)
if err != nil {
if shouldDownscore(err) {
w.cfg.downscore(b.columns.peer, "invalid DataColumnSidecar rpc response", err)
}
return b.withRetryableError(errors.Wrap(err, "failed to request data column sidecars"))
}
dataColumnSidecarDownloadMs.Observe(float64(time.Since(start).Milliseconds()))
return b.transitionToNext()
}
func shouldDownscore(err error) bool {
return errors.Is(err, errInvalidDataColumnResponse) ||
errors.Is(err, sync.ErrInvalidFetchedData) ||
errors.Is(err, errInvalidBlocks)
}

View File

@@ -80,17 +80,10 @@ func (s *Service) updateCustodyInfoIfNeeded() error {
return errors.Wrap(err, "p2p update custody info")
}
// Update the p2p earliest available slot metric
earliestAvailableSlotP2P.Set(float64(storedEarliestSlot))
dbEarliestSlot, _, err := s.cfg.beaconDB.UpdateCustodyInfo(s.ctx, storedEarliestSlot, storedGroupCount)
if err != nil {
if _, _, err := s.cfg.beaconDB.UpdateCustodyInfo(s.ctx, storedEarliestSlot, storedGroupCount); err != nil {
return errors.Wrap(err, "beacon db update custody info")
}
// Update the DB earliest available slot metric
earliestAvailableSlotDB.Set(float64(dbEarliestSlot))
return nil
}
@@ -99,16 +92,30 @@ func (s *Service) updateCustodyInfoIfNeeded() error {
func (s *Service) custodyGroupCount(context.Context) (uint64, error) {
cfg := params.BeaconConfig()
if flags.Get().SubscribeAllDataSubnets {
if flags.Get().Supernode {
return cfg.NumberOfCustodyGroups, nil
}
// Calculate validator custody requirements
validatorsCustodyRequirement, err := s.validatorsCustodyRequirement()
if err != nil {
return 0, errors.Wrap(err, "validators custody requirement")
}
return max(cfg.CustodyRequirement, validatorsCustodyRequirement), nil
effectiveCustodyRequirement := max(cfg.CustodyRequirement, validatorsCustodyRequirement)
// If we're not in semi-supernode mode, just use the effective requirement.
if !flags.Get().SemiSupernode {
return effectiveCustodyRequirement, nil
}
// Semi-supernode mode custodies the minimum custody groups required for reconstruction.
// This is future-proof and works correctly even if custody groups != columns.
semiSupernodeTarget, err := peerdas.MinimumCustodyGroupCountToReconstruct()
if err != nil {
return 0, errors.Wrap(err, "minimum custody group count")
}
return max(effectiveCustodyRequirement, semiSupernodeTarget), nil
}
// validatorsCustodyRequirements computes the custody requirements based on the

View File

@@ -8,6 +8,7 @@ import (
mock "github.com/OffchainLabs/prysm/v7/beacon-chain/blockchain/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/cache"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db"
dbtesting "github.com/OffchainLabs/prysm/v7/beacon-chain/db/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
@@ -106,11 +107,20 @@ func (ts *testSetup) assertCustodyInfo(t *testing.T, expectedSlot primitives.Slo
}
func withSubscribeAllDataSubnets(t *testing.T, fn func()) {
originalFlag := flags.Get().SubscribeAllDataSubnets
originalFlag := flags.Get().Supernode
defer func() {
flags.Get().SubscribeAllDataSubnets = originalFlag
flags.Get().Supernode = originalFlag
}()
flags.Get().SubscribeAllDataSubnets = true
flags.Get().Supernode = true
fn()
}
func withSemiSupernode(t *testing.T, fn func()) {
originalFlag := flags.Get().SemiSupernode
defer func() {
flags.Get().SemiSupernode = originalFlag
}()
flags.Get().SemiSupernode = true
fn()
}
@@ -195,4 +205,150 @@ func TestCustodyGroupCount(t *testing.T) {
require.NoError(t, err)
require.Equal(t, config.CustodyRequirement, result)
})
t.Run("SemiSupernode enabled returns half of NumberOfCustodyGroups", func(t *testing.T) {
withSemiSupernode(t, func() {
service := &Service{
ctx: context.Background(),
}
result, err := service.custodyGroupCount(ctx)
require.NoError(t, err)
expected, err := peerdas.MinimumCustodyGroupCountToReconstruct()
require.NoError(t, err)
require.Equal(t, expected, result)
})
})
t.Run("Supernode takes precedence over SemiSupernode", func(t *testing.T) {
// Test that when both flags are set, supernode takes precedence
originalSupernode := flags.Get().Supernode
originalSemiSupernode := flags.Get().SemiSupernode
defer func() {
flags.Get().Supernode = originalSupernode
flags.Get().SemiSupernode = originalSemiSupernode
}()
flags.Get().Supernode = true
flags.Get().SemiSupernode = true
service := &Service{
ctx: context.Background(),
}
result, err := service.custodyGroupCount(ctx)
require.NoError(t, err)
require.Equal(t, config.NumberOfCustodyGroups, result)
})
t.Run("SemiSupernode with no tracked validators returns semi-supernode target", func(t *testing.T) {
withSemiSupernode(t, func() {
service := &Service{
ctx: context.Background(),
trackedValidatorsCache: cache.NewTrackedValidatorsCache(),
}
result, err := service.custodyGroupCount(ctx)
require.NoError(t, err)
expected, err := peerdas.MinimumCustodyGroupCountToReconstruct()
require.NoError(t, err)
require.Equal(t, expected, result)
})
})
}
func TestSemiSupernodeValidatorCustodyOverride(t *testing.T) {
params.SetupTestConfigCleanup(t)
config := params.BeaconConfig()
config.NumberOfCustodyGroups = 128
config.CustodyRequirement = 4
config.ValidatorCustodyRequirement = 8
config.BalancePerAdditionalCustodyGroup = 1000000000 // 1 ETH in Gwei
params.OverrideBeaconConfig(config)
ctx := t.Context()
t.Run("Semi-supernode returns target when validator requirement is lower", func(t *testing.T) {
// When validators require less custody than semi-supernode provides,
// use the semi-supernode target (64)
withSemiSupernode(t, func() {
// Setup with validators requiring only 32 groups (less than 64)
service := &Service{
ctx: context.Background(),
trackedValidatorsCache: cache.NewTrackedValidatorsCache(),
}
result, err := service.custodyGroupCount(ctx)
require.NoError(t, err)
// Should return semi-supernode target (64) since it's higher than validator requirement
require.Equal(t, uint64(64), result)
})
})
t.Run("Validator requirement calculation respects minimum and maximum bounds", func(t *testing.T) {
// Verify that the validator custody requirement respects:
// - Minimum: ValidatorCustodyRequirement (8 in our config)
// - Maximum: NumberOfCustodyGroups (128 in our config)
// This ensures the formula works correctly:
// result = min(max(count, ValidatorCustodyRequirement), NumberOfCustodyGroups)
require.Equal(t, uint64(8), config.ValidatorCustodyRequirement)
require.Equal(t, uint64(128), config.NumberOfCustodyGroups)
// Semi-supernode target should be 64 (half of 128)
semiSupernodeTarget, err := peerdas.MinimumCustodyGroupCountToReconstruct()
require.NoError(t, err)
require.Equal(t, uint64(64), semiSupernodeTarget)
})
t.Run("Semi-supernode respects base CustodyRequirement", func(t *testing.T) {
// Test that semi-supernode respects max(CustodyRequirement, validatorsCustodyRequirement)
// even when both are below the semi-supernode target
params.SetupTestConfigCleanup(t)
// Setup with high base custody requirement (but still less than 64)
testConfig := params.BeaconConfig()
testConfig.NumberOfCustodyGroups = 128
testConfig.CustodyRequirement = 32 // Higher than validator requirement
testConfig.ValidatorCustodyRequirement = 8
params.OverrideBeaconConfig(testConfig)
withSemiSupernode(t, func() {
service := &Service{
ctx: context.Background(),
trackedValidatorsCache: cache.NewTrackedValidatorsCache(),
}
result, err := service.custodyGroupCount(ctx)
require.NoError(t, err)
// Should return semi-supernode target (64) since
// max(CustodyRequirement=32, validatorsCustodyRequirement=0) = 32 < 64
require.Equal(t, uint64(64), result)
})
})
t.Run("Semi-supernode uses higher custody when base requirement exceeds target", func(t *testing.T) {
// Set CustodyRequirement higher than semi-supernode target (64)
params.SetupTestConfigCleanup(t)
testConfig := params.BeaconConfig()
testConfig.NumberOfCustodyGroups = 128
testConfig.CustodyRequirement = 80 // Higher than semi-supernode target of 64
testConfig.ValidatorCustodyRequirement = 8
params.OverrideBeaconConfig(testConfig)
withSemiSupernode(t, func() {
service := &Service{
ctx: context.Background(),
trackedValidatorsCache: cache.NewTrackedValidatorsCache(),
}
result, err := service.custodyGroupCount(ctx)
require.NoError(t, err)
// Should return CustodyRequirement (80) since it's higher than semi-supernode target (64)
// effectiveCustodyRequirement = max(80, 0) = 80 > 64
require.Equal(t, uint64(80), result)
})
})
}

View File

@@ -0,0 +1,247 @@
package sync
import (
"cmp"
"math"
"slices"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
"github.com/ethereum/go-ethereum/p2p/enode"
"github.com/libp2p/go-libp2p/core/peer"
"github.com/pkg/errors"
)
var (
// ErrNoPeersCoverNeeded is returned when no peers are able to cover the needed columns.
ErrNoPeersCoverNeeded = errors.New("no peers able to cover needed columns")
// ErrNoPeersAvailable is returned when no peers are available for block requests.
ErrNoPeersAvailable = errors.New("no peers available")
)
// DASPeerCache caches information about a set of peers DAS peering decisions.
type DASPeerCache struct {
p2pSvc p2p.P2P
peers map[peer.ID]*dasPeer
}
// dasPeer represents a peer's custody of columns and their coverage score.
type dasPeer struct {
pid peer.ID
enid enode.ID
custodied peerdas.ColumnIndices
lastAssigned time.Time
}
// dasPeerScore is used to build a slice of peer+score pairs for ranking purproses.
type dasPeerScore struct {
peer *dasPeer
score float64
}
// PeerPicker is a structure that maps out the intersection of peer custody and column indices
// to weight each peer based on the scarcity of the columns they custody. This allows us to prioritize
// requests for more scarce columns to peers that custody them, so that we don't waste our bandwidth allocation
// making requests for more common columns from peers that can provide the more scarce columns.
type PeerPicker struct {
scores []*dasPeerScore // scores is a set of generic scores, based on the full custody group set
ranker *rarityRanker
custodians map[uint64][]*dasPeer
toCustody peerdas.ColumnIndices // full set of columns this node will try to custody
reqInterval time.Duration
}
// NewDASPeerCache initializes a DASPeerCache. This type is not currently thread safe.
func NewDASPeerCache(p2pSvc p2p.P2P) *DASPeerCache {
return &DASPeerCache{
peers: make(map[peer.ID]*dasPeer),
p2pSvc: p2pSvc,
}
}
// NewColumnScarcityRanking computes the ColumnScarcityRanking based on the current view of columns custodied
// by the given set of peers. New PeerPickers should be created somewhat frequently, as the status of peers can
// change, including the set of columns each peer custodies.
// reqInterval sets the frequency that a peer can be picked in terms of time. A peer can be picked once per reqInterval,
// eg a value of time.Second would allow 1 request per second to the peer, or a value of 500 * time.Millisecond would allow
// 2 req/sec.
func (c *DASPeerCache) NewPicker(pids []peer.ID, toCustody peerdas.ColumnIndices, reqInterval time.Duration) (*PeerPicker, error) {
// For each of the given peers, refresh the cache's view of their currently custodied columns.
// Also populate 'custodians', which stores the set of peers that custody each column index.
custodians := make(map[uint64][]*dasPeer, len(toCustody))
scores := make([]*dasPeerScore, 0, len(pids))
for _, pid := range pids {
peer, err := c.refresh(pid, toCustody)
if err != nil {
log.WithField("peerID", pid).WithError(err).Debug("Failed to convert peer ID to node ID.")
continue
}
for col := range peer.custodied {
if toCustody.Has(col) {
custodians[col] = append(custodians[col], peer)
}
}
// set score to math.MaxFloat64 so we can tell that it hasn't been initialized
scores = append(scores, &dasPeerScore{peer: peer, score: math.MaxFloat64})
}
return &PeerPicker{
toCustody: toCustody,
ranker: newRarityRanker(toCustody, custodians),
custodians: custodians,
scores: scores,
reqInterval: reqInterval,
}, nil
}
// refresh supports NewPicker in getting the latest dasPeer view for the given peer.ID. It caches the result
// of the enode.ID computation but refreshes the custody group count each time it is called, leveraging the
// cache behind peerdas.Info.
func (c *DASPeerCache) refresh(pid peer.ID, toCustody peerdas.ColumnIndices) (*dasPeer, error) {
// Computing the enode.ID seems to involve multiple parseing and validation steps followed by a
// hash computation, so it seems worth trying to cache the result.
p, ok := c.peers[pid]
if !ok {
nodeID, err := p2p.ConvertPeerIDToNodeID(pid)
if err != nil {
// If we can't convert the peer ID to a node ID, remove peer from the cache.
delete(c.peers, pid)
return nil, errors.Wrap(err, "ConvertPeerIDToNodeID")
}
p = &dasPeer{enid: nodeID, pid: pid}
}
if len(toCustody) > 0 {
dasInfo, _, err := peerdas.Info(p.enid, c.p2pSvc.CustodyGroupCountFromPeer(pid))
if err != nil {
// If we can't get the peerDAS info, remove peer from the cache.
delete(c.peers, pid)
return nil, errors.Wrapf(err, "CustodyGroupCountFromPeer, peerID=%s, nodeID=%s", pid, p.enid)
}
p.custodied = peerdas.NewColumnIndicesFromMap(dasInfo.CustodyColumns)
} else {
p.custodied = peerdas.NewColumnIndices()
}
c.peers[pid] = p
return p, nil
}
// ForColumns returns the best peer to request columns from, based on the scarcity of the columns needed.
func (m *PeerPicker) ForColumns(needed peerdas.ColumnIndices, busy map[peer.ID]bool) (peer.ID, []uint64, error) {
// - find the custodied column with the lowest frequency
// - collect all the peers that have custody of that column
// - score the peers by the rarity of the needed columns they offer
var best *dasPeer
bestScore, bestCoverage := 0.0, []uint64{}
for _, col := range m.ranker.ascendingRarity(needed) {
for _, p := range m.custodians[col] {
// enforce a minimum interval between requests to the same peer
if p.lastAssigned.Add(m.reqInterval).After(time.Now()) {
continue
}
if busy[p.pid] {
continue
}
covered := p.custodied.Intersection(needed)
if len(covered) == 0 {
continue
}
// update best if any of the following:
// - current score better than previous best
// - scores are tied, and current coverage is better than best
// - scores are tied, coverage equal, pick the least-recently used peer
score := m.ranker.score(covered)
if score < bestScore {
continue
}
if score == bestScore && best != nil {
if len(covered) < len(bestCoverage) {
continue
}
if len(covered) == len(bestCoverage) && best.lastAssigned.Before(p.lastAssigned) {
continue
}
}
best, bestScore, bestCoverage = p, score, covered.ToSlice()
}
if best != nil {
best.lastAssigned = time.Now()
slices.Sort(bestCoverage)
return best.pid, bestCoverage, nil
}
}
return "", nil, ErrNoPeersCoverNeeded
}
// ForBlocks returns the lowest scoring peer in the set. This can be used to pick a peer
// for block requests, preserving the peers that have the highest coverage scores
// for column requests.
func (m *PeerPicker) ForBlocks(busy map[peer.ID]bool) (peer.ID, error) {
slices.SortFunc(m.scores, func(a, b *dasPeerScore) int {
// MaxFloat64 is used as a sentinel value for an uninitialized score;
// check and set scores while sorting for uber-lazy initialization.
if a.score == math.MaxFloat64 {
a.score = m.ranker.score(a.peer.custodied.Intersection(m.toCustody))
}
if b.score == math.MaxFloat64 {
b.score = m.ranker.score(b.peer.custodied.Intersection(m.toCustody))
}
return cmp.Compare(a.score, b.score)
})
for _, ds := range m.scores {
if !busy[ds.peer.pid] {
return ds.peer.pid, nil
}
}
return "", ErrNoPeersAvailable
}
// rarityRanker is initialized with the set of columns this node needs to custody, and the set of
// all peer custody columns. With that information it is able to compute a numeric representation of
// column rarity, and use that number to give each peer a score that represents how fungible their
// bandwidth likely is relative to other peers given a more specific set of needed columns.
type rarityRanker struct {
// rarity maps column indices to their rarity scores.
// The rarity score is defined as the inverse of the number of custodians: 1/custodians
// So the rarity of the columns a peer custodies can be simply added together for a score
// representing how unique their custody groups are; rarer columns contribute larger values to scores.
rarity map[uint64]float64
asc []uint64 // columns indices ordered by ascending rarity
}
// newRarityRanker precomputes data used for scoring and ranking. It should be reinitialized every time
// we refresh the set of peers or the view of the peers column custody.
func newRarityRanker(toCustody peerdas.ColumnIndices, custodians map[uint64][]*dasPeer) *rarityRanker {
rarity := make(map[uint64]float64, len(toCustody))
asc := make([]uint64, 0, len(toCustody))
for col := range toCustody.ToMap() {
rarity[col] = 1 / max(1, float64(len(custodians[col])))
asc = append(asc, col)
}
slices.SortFunc(asc, func(a, b uint64) int {
return cmp.Compare(rarity[a], rarity[b])
})
return &rarityRanker{rarity: rarity, asc: asc}
}
// rank returns the requested columns sorted by ascending rarity.
func (rr *rarityRanker) ascendingRarity(cols peerdas.ColumnIndices) []uint64 {
ranked := make([]uint64, 0, len(cols))
for _, col := range rr.asc {
if cols.Has(col) {
ranked = append(ranked, col)
}
}
return ranked
}
// score gives a score representing the sum of the rarity scores of the given columns. It can be used to
// score peers based on the set intersection of their custodied indices and the indices we need to request.
func (rr *rarityRanker) score(coverage peerdas.ColumnIndices) float64 {
score := 0.0
for col := range coverage.ToMap() {
score += rr.rarity[col]
}
return score
}

View File

@@ -0,0 +1,295 @@
package sync
import (
"testing"
"time"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/p2p"
p2ptest "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/libp2p/go-libp2p"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/libp2p/go-libp2p/core/peer"
)
// mockP2PForDAS wraps TestP2P to provide a known custody group count for any peer.
type mockP2PForDAS struct {
*p2ptest.TestP2P
custodyGroupCount uint64
}
func (m *mockP2PForDAS) CustodyGroupCountFromPeer(_ peer.ID) uint64 {
return m.custodyGroupCount
}
// testDASSetup provides test fixtures for DAS peer assignment tests.
type testDASSetup struct {
t *testing.T
cache *DASPeerCache
p2pService *mockP2PForDAS
peers []*p2ptest.TestP2P
peerIDs []peer.ID
}
// createSecp256k1Key generates a secp256k1 private key from a seed offset.
// These keys are compatible with ConvertPeerIDToNodeID.
func createSecp256k1Key(offset int) crypto.PrivKey {
privateKeyBytes := make([]byte, 32)
for i := range 32 {
privateKeyBytes[i] = byte(offset + i)
}
privKey, err := crypto.UnmarshalSecp256k1PrivateKey(privateKeyBytes)
if err != nil {
panic(err)
}
return privKey
}
// setupDASTest creates a test setup with the specified number of connected peers.
// custodyGroupCount is the custody count returned for all peers.
func setupDASTest(t *testing.T, peerCount int, custodyGroupCount uint64) *testDASSetup {
params.SetupTestConfigCleanup(t)
// Create main p2p service with secp256k1 key
testP2P := p2ptest.NewTestP2P(t, libp2p.Identity(createSecp256k1Key(0)))
mockP2P := &mockP2PForDAS{
TestP2P: testP2P,
custodyGroupCount: custodyGroupCount,
}
cache := NewDASPeerCache(mockP2P)
peers := make([]*p2ptest.TestP2P, peerCount)
peerIDs := make([]peer.ID, peerCount)
for i := range peerCount {
// Use offset starting at 100 to avoid collision with main p2p service
peers[i] = p2ptest.NewTestP2P(t, libp2p.Identity(createSecp256k1Key(100+i*50)))
peers[i].Connect(testP2P)
peerIDs[i] = peers[i].PeerID()
}
return &testDASSetup{
t: t,
cache: cache,
p2pService: mockP2P,
peers: peers,
peerIDs: peerIDs,
}
}
// getActualCustodyColumns returns the columns actually custodied by the test peers.
// This queries the same peerdas.Info logic used by the production code.
func (s *testDASSetup) getActualCustodyColumns() peerdas.ColumnIndices {
result := peerdas.NewColumnIndices()
custodyCount := s.p2pService.custodyGroupCount
for _, pid := range s.peerIDs {
nodeID, err := p2p.ConvertPeerIDToNodeID(pid)
if err != nil {
continue
}
info, _, err := peerdas.Info(nodeID, custodyCount)
if err != nil {
continue
}
for col := range info.CustodyColumns {
result.Set(col)
}
}
return result
}
func TestNewPicker(t *testing.T) {
custodyReq := params.BeaconConfig().CustodyRequirement
t.Run("valid peers with columns", func(t *testing.T) {
setup := setupDASTest(t, 3, custodyReq)
toCustody := setup.getActualCustodyColumns()
require.NotEqual(t, 0, toCustody.Count(), "test peers should custody some columns")
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
require.NotNil(t, picker)
require.Equal(t, 3, len(picker.scores))
})
t.Run("empty peer list", func(t *testing.T) {
setup := setupDASTest(t, 0, custodyReq)
toCustody := peerdas.NewColumnIndicesFromSlice([]uint64{0, 1})
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
require.NotNil(t, picker)
require.Equal(t, 0, len(picker.scores))
})
t.Run("empty custody columns", func(t *testing.T) {
setup := setupDASTest(t, 2, custodyReq)
toCustody := peerdas.NewColumnIndices()
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
require.NotNil(t, picker)
// With empty toCustody, peers are still added to scores but have no custodied columns
require.Equal(t, 2, len(picker.scores))
})
}
func TestForColumns(t *testing.T) {
custodyReq := params.BeaconConfig().CustodyRequirement
t.Run("basic selection returns covering peer", func(t *testing.T) {
setup := setupDASTest(t, 3, custodyReq)
toCustody := setup.getActualCustodyColumns()
require.NotEqual(t, 0, toCustody.Count(), "test peers must custody some columns")
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
// Request columns that we know peers custody
needed := toCustody
pid, cols, err := picker.ForColumns(needed, nil)
require.NoError(t, err)
require.NotEmpty(t, pid)
require.NotEmpty(t, cols)
// Verify the returned columns are a subset of what was needed
for _, col := range cols {
require.Equal(t, true, needed.Has(col), "returned column should be in needed set")
}
})
t.Run("skip busy peers", func(t *testing.T) {
setup := setupDASTest(t, 2, custodyReq)
toCustody := setup.getActualCustodyColumns()
require.NotEqual(t, 0, toCustody.Count(), "test peers must custody some columns")
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
// Mark first peer as busy
busy := map[peer.ID]bool{setup.peerIDs[0]: true}
pid, _, err := picker.ForColumns(toCustody, busy)
require.NoError(t, err)
// Should not return the busy peer
require.NotEqual(t, setup.peerIDs[0], pid)
})
t.Run("rate limiting respects reqInterval", func(t *testing.T) {
setup := setupDASTest(t, 1, custodyReq)
toCustody := setup.getActualCustodyColumns()
require.NotEqual(t, 0, toCustody.Count(), "test peers must custody some columns")
// Use a long interval so the peer can't be picked twice
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Hour)
require.NoError(t, err)
// First call should succeed
pid, _, err := picker.ForColumns(toCustody, nil)
require.NoError(t, err)
require.NotEmpty(t, pid)
// Second call should fail due to rate limiting
_, _, err = picker.ForColumns(toCustody, nil)
require.ErrorIs(t, err, ErrNoPeersCoverNeeded)
})
t.Run("no peers available returns error", func(t *testing.T) {
setup := setupDASTest(t, 2, custodyReq)
toCustody := setup.getActualCustodyColumns()
require.NotEqual(t, 0, toCustody.Count(), "test peers must custody some columns")
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
// Mark all peers as busy
busy := map[peer.ID]bool{
setup.peerIDs[0]: true,
setup.peerIDs[1]: true,
}
_, _, err = picker.ForColumns(toCustody, busy)
require.ErrorIs(t, err, ErrNoPeersCoverNeeded)
})
t.Run("empty needed columns returns error", func(t *testing.T) {
setup := setupDASTest(t, 2, custodyReq)
toCustody := setup.getActualCustodyColumns()
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
// Request empty set of columns
needed := peerdas.NewColumnIndices()
_, _, err = picker.ForColumns(needed, nil)
require.ErrorIs(t, err, ErrNoPeersCoverNeeded)
})
}
func TestForBlocks(t *testing.T) {
custodyReq := params.BeaconConfig().CustodyRequirement
t.Run("returns available peer", func(t *testing.T) {
setup := setupDASTest(t, 3, custodyReq)
toCustody := setup.getActualCustodyColumns()
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
pid, err := picker.ForBlocks(nil)
require.NoError(t, err)
require.NotEmpty(t, pid)
})
t.Run("skips busy peers", func(t *testing.T) {
setup := setupDASTest(t, 3, custodyReq)
toCustody := setup.getActualCustodyColumns()
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
// Mark first two peers as busy
busy := map[peer.ID]bool{
setup.peerIDs[0]: true,
setup.peerIDs[1]: true,
}
pid, err := picker.ForBlocks(busy)
require.NoError(t, err)
require.NotEmpty(t, pid)
// Verify returned peer is not busy
require.Equal(t, false, busy[pid], "returned peer should not be busy")
})
t.Run("all peers busy returns error", func(t *testing.T) {
setup := setupDASTest(t, 2, custodyReq)
toCustody := setup.getActualCustodyColumns()
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
// Mark all peers as busy
busy := map[peer.ID]bool{
setup.peerIDs[0]: true,
setup.peerIDs[1]: true,
}
_, err = picker.ForBlocks(busy)
require.ErrorIs(t, err, ErrNoPeersAvailable)
})
t.Run("no peers returns error", func(t *testing.T) {
setup := setupDASTest(t, 0, custodyReq)
toCustody := peerdas.NewColumnIndicesFromSlice([]uint64{0, 1, 2, 3})
picker, err := setup.cache.NewPicker(setup.peerIDs, toCustody, time.Millisecond)
require.NoError(t, err)
_, err = picker.ForBlocks(nil)
require.ErrorIs(t, err, ErrNoPeersAvailable)
})
}

View File

@@ -2,17 +2,17 @@ package sync
import (
"fmt"
"math/rand"
"testing"
"time"
"math/rand"
"github.com/OffchainLabs/prysm/v7/beacon-chain/blockchain/kzg"
mockChain "github.com/OffchainLabs/prysm/v7/beacon-chain/blockchain/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/peerdas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
p2ptest "github.com/OffchainLabs/prysm/v7/beacon-chain/p2p/testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/consensus-types/blocks"
"github.com/OffchainLabs/prysm/v7/testing/require"
"github.com/OffchainLabs/prysm/v7/testing/util"
)
@@ -136,3 +136,117 @@ func TestComputeRandomDelay(t *testing.T) {
fmt.Print(waitingTime)
require.Equal(t, expected, waitingTime)
}
func TestSemiSupernodeReconstruction(t *testing.T) {
const blobCount = 4
numberOfColumns := params.BeaconConfig().NumberOfColumns
ctx := t.Context()
// Start the trusted setup.
err := kzg.Start()
require.NoError(t, err)
roBlock, _, verifiedRoDataColumns := util.GenerateTestFuluBlockWithSidecars(t, blobCount)
require.Equal(t, numberOfColumns, uint64(len(verifiedRoDataColumns)))
minimumCount := peerdas.MinimumColumnCountToReconstruct()
t.Run("semi-supernode reconstruction with exactly 64 columns", func(t *testing.T) {
// Test that reconstruction works with exactly the minimum number of columns (64).
// This simulates semi-supernode mode which custodies exactly 64 columns.
require.Equal(t, uint64(64), minimumCount, "Expected minimum column count to be 64")
chainService := &mockChain.ChainService{}
p2p := p2ptest.NewTestP2P(t)
storage := filesystem.NewEphemeralDataColumnStorage(t)
service := NewService(
ctx,
WithP2P(p2p),
WithDataColumnStorage(storage),
WithChainService(chainService),
WithOperationNotifier(chainService.OperationNotifier()),
)
// Use exactly 64 columns (minimum for reconstruction) to simulate semi-supernode mode.
// Select the first 64 columns.
semiSupernodeColumns := verifiedRoDataColumns[:minimumCount]
err = service.receiveDataColumnSidecars(ctx, semiSupernodeColumns)
require.NoError(t, err)
err = storage.Save(semiSupernodeColumns)
require.NoError(t, err)
require.Equal(t, false, p2p.BroadcastCalled.Load())
// Check received indices before reconstruction.
require.Equal(t, minimumCount, uint64(len(chainService.DataColumns)))
for i, actual := range chainService.DataColumns {
require.Equal(t, uint64(i), actual.Index)
}
// Run the reconstruction.
err = service.processDataColumnSidecarsFromReconstruction(ctx, verifiedRoDataColumns[0])
require.NoError(t, err)
// Verify we can reconstruct all columns from just 64.
// The node should have received the initial 64 columns.
if len(chainService.DataColumns) < int(minimumCount) {
t.Fatalf("Expected at least %d columns but got %d", minimumCount, len(chainService.DataColumns))
}
block := roBlock.Block()
slot := block.Slot()
proposerIndex := block.ProposerIndex()
// Verify that we have seen at least the minimum number of columns.
seenCount := 0
for i := range numberOfColumns {
if service.hasSeenDataColumnIndex(slot, proposerIndex, i) {
seenCount++
}
}
if seenCount < int(minimumCount) {
t.Fatalf("Expected to see at least %d columns but saw %d", minimumCount, seenCount)
}
})
t.Run("semi-supernode reconstruction with random 64 columns", func(t *testing.T) {
// Test reconstruction with 64 non-contiguous columns to simulate a real scenario.
chainService := &mockChain.ChainService{}
p2p := p2ptest.NewTestP2P(t)
storage := filesystem.NewEphemeralDataColumnStorage(t)
service := NewService(
ctx,
WithP2P(p2p),
WithDataColumnStorage(storage),
WithChainService(chainService),
WithOperationNotifier(chainService.OperationNotifier()),
)
// Select every other column to get 64 non-contiguous columns.
semiSupernodeColumns := make([]blocks.VerifiedRODataColumn, 0, minimumCount)
for i := uint64(0); i < numberOfColumns && uint64(len(semiSupernodeColumns)) < minimumCount; i += 2 {
semiSupernodeColumns = append(semiSupernodeColumns, verifiedRoDataColumns[i])
}
require.Equal(t, minimumCount, uint64(len(semiSupernodeColumns)))
err = service.receiveDataColumnSidecars(ctx, semiSupernodeColumns)
require.NoError(t, err)
err = storage.Save(semiSupernodeColumns)
require.NoError(t, err)
// Run the reconstruction.
err = service.processDataColumnSidecarsFromReconstruction(ctx, semiSupernodeColumns[0])
require.NoError(t, err)
// Verify we received the columns.
if len(chainService.DataColumns) < int(minimumCount) {
t.Fatalf("Expected at least %d columns but got %d", minimumCount, len(chainService.DataColumns))
}
})
}

Some files were not shown because too many files have changed in this diff Show More