Skip to content

Commit 6cd72b2

Browse files
[performance] cherry-pick: db/snapshotsync: resume caplin state dump per-type to avoid overlapping files (#21901) (#22124)
Cherry-pick of #21901 to `performance`.
1 parent 0656ee1 commit 6cd72b2

4 files changed

Lines changed: 211 additions & 18 deletions

File tree

cl/antiquary/state_antiquary.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,6 @@ func (s *Antiquary) IncrementBeaconState(ctx context.Context, to uint64) error {
594594
}
595595
if err := s.stateSn.DumpCaplinState(
596596
ctx,
597-
s.stateSn.BlocksAvailable()+1,
598597
to,
599598
blocksPerStatefulFile,
600599
s.sn.Salt,
@@ -605,16 +604,18 @@ func (s *Antiquary) IncrementBeaconState(ctx context.Context, to uint64) error {
605604
); err != nil {
606605
return err
607606
}
608-
paths := s.stateSn.SegFileNames(from, to)
607+
// Open the new files before collecting paths so the seeder sees them;
608+
// seed from 0 since a per-type resume can dump a new type from genesis.
609+
if err := s.stateSn.OpenFolder(); err != nil {
610+
return err
611+
}
609612
if s.downloader != nil {
613+
paths := s.stateSn.SegFileNames(0, to)
610614
// Notify bittorent to seed the new snapshots
611615
if err := s.downloader.Seed(s.ctx, paths); err != nil {
612616
s.logger.Warn("[Antiquary] Failed to add items to bittorent", "err", err)
613617
}
614618
}
615-
if err := s.stateSn.OpenFolder(); err != nil {
616-
return err
617-
}
618619
}
619620

620621
return nil

cmd/capcli/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1390,7 +1390,7 @@ func (c *DumpStateSnapshots) Run(ctx *Context) error {
13901390
r, _ := stateSn.Get(kv.BlockRoot, 999424)
13911391
fmt.Printf("%x\n", r)
13921392

1393-
if err := stateSn.DumpCaplinState(ctx, stateSn.BlocksAvailable(), to, c.StepSize, salt, dirs, runtime.NumCPU(), log.LvlInfo, log.Root()); err != nil {
1393+
if err := stateSn.DumpCaplinState(ctx, to, c.StepSize, salt, dirs, runtime.NumCPU(), log.LvlInfo, log.Root()); err != nil {
13941394
return err
13951395
}
13961396
if err := stateSn.OpenFolder(); err != nil {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright 2024 The Erigon Authors
2+
// This file is part of Erigon.
3+
//
4+
// Erigon is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Lesser General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Erigon is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Lesser General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Lesser General Public License
15+
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.
16+
17+
package snapshotsync
18+
19+
import (
20+
"testing"
21+
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func firstJobFrom(jobs []caplinStateDumpJob, name string) (uint64, bool) {
26+
for _, j := range jobs {
27+
if j.name == name {
28+
return j.from, true
29+
}
30+
}
31+
return 0, false
32+
}
33+
34+
func countJobs(jobs []caplinStateDumpJob, name string) uint64 {
35+
var n uint64
36+
for _, j := range jobs {
37+
if j.name == name {
38+
n++
39+
}
40+
}
41+
return n
42+
}
43+
44+
// overlapsCoverage reports whether any job lands inside an existing covered range.
45+
func overlapsCoverage(jobs []caplinStateDumpJob, name string, covered []Range) bool {
46+
for _, j := range jobs {
47+
if j.name != name {
48+
continue
49+
}
50+
for _, r := range covered {
51+
if j.from < r.to && r.from < j.to {
52+
return true
53+
}
54+
}
55+
}
56+
return false
57+
}
58+
59+
// A new state-snapshot type added to an already-populated datadir must not drag
60+
// the dump back to genesis for types that are already caught up: only missing
61+
// ranges are scheduled, so a full base file is never re-sliced into overlaps.
62+
func TestPlanStateDumpResumesPerType(t *testing.T) {
63+
const blocksPerFile = 50_000
64+
coverage := map[string][]Range{
65+
"Covered": {{from: 0, to: 10_400_000}}, // already at head
66+
"Lagging": {{from: 0, to: 2_700_000}}, // newly added type, behind
67+
"Empty": nil, // brand-new type, nothing on disk
68+
}
69+
70+
jobs := planStateDump(coverage, 10_400_000, blocksPerFile)
71+
72+
for _, j := range jobs {
73+
require.Equal(t, blocksPerFile, int(j.to-j.from), "every job must be exactly one full file")
74+
}
75+
for name, cov := range coverage {
76+
require.False(t, overlapsCoverage(jobs, name, cov), "type %s scheduled a job overlapping existing coverage", name)
77+
}
78+
79+
require.Zero(t, countJobs(jobs, "Covered"), "caught-up type must not be re-dumped")
80+
81+
laggingFrom, ok := firstJobFrom(jobs, "Lagging")
82+
require.True(t, ok, "lagging type must be dumped")
83+
require.Equal(t, uint64(2_700_000), laggingFrom, "lagging type must resume at its coverage end")
84+
require.Equal(t, uint64((10_400_000-2_700_000)/blocksPerFile), countJobs(jobs, "Lagging"))
85+
86+
emptyFrom, ok := firstJobFrom(jobs, "Empty")
87+
require.True(t, ok, "empty type must be dumped from genesis")
88+
require.Equal(t, uint64(0), emptyFrom)
89+
require.Equal(t, uint64(10_400_000/blocksPerFile), countJobs(jobs, "Empty"))
90+
}
91+
92+
// A gap between existing segments must be filled, without re-dumping (and thus
93+
// overlapping) a larger already-present segment that follows the gap.
94+
func TestPlanStateDumpFillsGapWithoutOverlap(t *testing.T) {
95+
const blocksPerFile = 50_000
96+
covered := []Range{
97+
{from: 0, to: 7_150_000},
98+
{from: 7_200_000, to: 10_400_000}, // 7.15M..7.2M missing; tail is one large segment
99+
}
100+
jobs := planStateDump(map[string][]Range{"X": covered}, 10_400_000, blocksPerFile)
101+
102+
require.Equal(t, uint64(1), countJobs(jobs, "X"), "only the single missing file should be scheduled")
103+
from, _ := firstJobFrom(jobs, "X")
104+
require.Equal(t, uint64(7_150_000), from)
105+
require.False(t, overlapsCoverage(jobs, "X", covered))
106+
}
107+
108+
// An unaligned coverage tail must resume exactly at its end, never flooring to a
109+
// file boundary below it (which would overlap the existing tail).
110+
func TestPlanStateDumpUnalignedResumeHasNoOverlap(t *testing.T) {
111+
const blocksPerFile = 50_000
112+
covered := []Range{{from: 0, to: 2_725_000}}
113+
jobs := planStateDump(map[string][]Range{"X": covered}, 2_825_000, blocksPerFile)
114+
115+
from, ok := firstJobFrom(jobs, "X")
116+
require.True(t, ok)
117+
require.Equal(t, uint64(2_725_000), from, "must resume at coverage end, not floor below it")
118+
require.False(t, overlapsCoverage(jobs, "X", covered))
119+
}
120+
121+
func TestMissingRanges(t *testing.T) {
122+
require.Equal(t, []Range(nil), missingRanges([]Range{{from: 0, to: 100}}, 100), "fully covered → nothing missing")
123+
require.Equal(t, []Range{{from: 0, to: 100}}, missingRanges(nil, 100), "empty → all missing")
124+
require.Equal(t, []Range{{from: 70, to: 100}}, missingRanges([]Range{{from: 0, to: 70}}, 100), "trailing tail")
125+
require.Equal(t, []Range{{from: 50, to: 60}}, missingRanges([]Range{{from: 0, to: 50}, {from: 60, to: 100}}, 100), "interior gap only")
126+
require.Equal(t, []Range(nil), missingRanges([]Range{{from: 0, to: 200}}, 100), "coverage beyond toSlot → nothing missing")
127+
}

db/snapshotsync/caplin_state_snapshots.go

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"os"
2626
"path/filepath"
2727
"runtime/debug"
28+
"sort"
2829
"strings"
2930
"sync"
3031
"sync/atomic"
@@ -264,6 +265,25 @@ func (s *CaplinStateSnapshots) BlocksAvailable() uint64 {
264265
return min(s.segmentsMax.Load(), s.idxMax.Load())
265266
}
266267

268+
func (s *CaplinStateSnapshots) coveredRangesForType(name string) []Range {
269+
s.visibleLock.RLock()
270+
defer s.visibleLock.RUnlock()
271+
272+
v, ok := s.visible.Load(name)
273+
if !ok {
274+
return nil
275+
}
276+
segs, ok := v.([]*VisibleSegment)
277+
if !ok {
278+
return nil
279+
}
280+
ranges := make([]Range, 0, len(segs))
281+
for _, seg := range segs {
282+
ranges = append(ranges, seg.Range)
283+
}
284+
return ranges
285+
}
286+
267287
func (s *CaplinStateSnapshots) Close() {
268288
if s == nil {
269289
return
@@ -690,22 +710,67 @@ func simpleIdx(ctx context.Context, sn snaptype.FileInfo, salt uint32, tmpDir st
690710
return nil
691711
}
692712

693-
func (s *CaplinStateSnapshots) DumpCaplinState(ctx context.Context, fromSlot, toSlot, blocksPerFile uint64, salt uint32, dirs datadir.Dirs, workers int, lvl log.Lvl, logger log.Logger) error {
694-
fromSlot = (fromSlot / blocksPerFile) * blocksPerFile
713+
type caplinStateDumpJob struct {
714+
name string
715+
from, to uint64
716+
}
717+
718+
// missingRanges returns the sub-ranges of [0, toSlot) not covered by `covered`
719+
// (the type's existing segment ranges, sorted by `from`).
720+
func missingRanges(covered []Range, toSlot uint64) []Range {
721+
var missing []Range
722+
var cur uint64
723+
for _, r := range covered {
724+
if r.from > cur {
725+
gapEnd := min(r.from, toSlot)
726+
missing = append(missing, Range{from: cur, to: gapEnd})
727+
}
728+
cur = max(cur, r.to)
729+
if cur >= toSlot {
730+
return missing
731+
}
732+
}
733+
if cur < toSlot {
734+
missing = append(missing, Range{from: cur, to: toSlot})
735+
}
736+
return missing
737+
}
738+
739+
// planStateDump schedules only the ranges each type is missing within
740+
// [0, toSlot), starting every full file at a gap boundary so it fills holes and
741+
// the trailing tail without overlapping an existing segment.
742+
func planStateDump(coverage map[string][]Range, toSlot, blocksPerFile uint64) []caplinStateDumpJob {
695743
toSlot = (toSlot / blocksPerFile) * blocksPerFile
696-
for snapName, kvGetter := range s.snapshotTypes.KeyValueGetters {
697-
for i := fromSlot; i < toSlot; i += blocksPerFile {
698-
if toSlot-i < blocksPerFile {
699-
break
700-
}
701-
// keep beaconblocks here but whatever....
702-
to := i + blocksPerFile
703-
logger.Log(lvl, "Dumping "+snapName, "from", i, "to", to)
704-
if err := dumpCaplinState(ctx, snapName, kvGetter, i, to, blocksPerFile, salt, dirs, workers, lvl, logger, s.snapshotTypes.Compression[snapName]); err != nil {
705-
return err
744+
745+
names := make([]string, 0, len(coverage))
746+
for name := range coverage {
747+
names = append(names, name)
748+
}
749+
sort.Strings(names)
750+
751+
jobs := make([]caplinStateDumpJob, 0)
752+
for _, name := range names {
753+
for _, gap := range missingRanges(coverage[name], toSlot) {
754+
for i := gap.from; i+blocksPerFile <= gap.to; i += blocksPerFile {
755+
jobs = append(jobs, caplinStateDumpJob{name: name, from: i, to: i + blocksPerFile})
706756
}
707757
}
708758
}
759+
return jobs
760+
}
761+
762+
func (s *CaplinStateSnapshots) DumpCaplinState(ctx context.Context, toSlot, blocksPerFile uint64, salt uint32, dirs datadir.Dirs, workers int, lvl log.Lvl, logger log.Logger) error {
763+
coverage := make(map[string][]Range, len(s.snapshotTypes.KeyValueGetters))
764+
for name := range s.snapshotTypes.KeyValueGetters {
765+
coverage[name] = s.coveredRangesForType(name)
766+
}
767+
768+
for _, job := range planStateDump(coverage, toSlot, blocksPerFile) {
769+
logger.Log(lvl, "Dumping "+job.name, "from", job.from, "to", job.to)
770+
if err := dumpCaplinState(ctx, job.name, s.snapshotTypes.KeyValueGetters[job.name], job.from, job.to, blocksPerFile, salt, dirs, workers, lvl, logger, s.snapshotTypes.Compression[job.name]); err != nil {
771+
return err
772+
}
773+
}
709774
return nil
710775
}
711776

0 commit comments

Comments
 (0)