diff --git a/db/downloader/README.md b/db/downloader/README.md index 56f88f6847b..848d9df2b36 100644 --- a/db/downloader/README.md +++ b/db/downloader/README.md @@ -1,5 +1,12 @@ # Downloader Components +Main properties: + +- initial download complete (`preverified.toml` on disk): the local `data-file` outranks the remote manifest and is never moved aside. A missing `data-file` is still downloaded. A local `.torrent` with a different infohash is dropped on this path (`addPreverifiedSnapshotForDownload` retains one that is already loaded and seeds under its hash), and only its sizes are used to check the file: on a mismatch the data backs neither manifest, so the download goes ahead under the preverified hash and the client completes the file by length, re-fetching only if the length disagrees. Otherwise the preverified download is skipped. +- initial download not complete: the remote manifest wins. Fetch the chain manifest (`chain.toml`) and align the datadir to it (remote `.torrent` and `data-file`s); data whose local `.torrent` infohash does not match the preverified one — including data with no readable `.torrent` at all — is renamed to `.part` and re-fetched. `preverified.toml` is written locally once the set is complete, never downloaded. + +Deriving a `.torrent` from a bare `data-file` happens on the seeding path (`AddNewSeedableFile`), not on either path above. + The diagram below shows the components used to manage downloads between torrents and WebSeeds. ![components](components.png) diff --git a/db/downloader/download-batch.go b/db/downloader/download-batch.go index 59ac66e13e3..1b96f81e0cb 100644 --- a/db/downloader/download-batch.go +++ b/db/downloader/download-batch.go @@ -5,12 +5,20 @@ import ( "context" "errors" "fmt" + "runtime" "sync/atomic" "github.com/anacrolix/sync" "github.com/anacrolix/torrent" + + "github.com/erigontech/erigon/common/log/v3" ) +// Seeding a kept snapshot hashes the whole file, so the work is CPU-bound with a sequential read: +// GOMAXPROCS sets the scale and the doubling covers read stalls. BuildTorrentFilesIfNeed can afford +// far more because most of its files already have a .torrent and short-circuit; these rarely do. +func defaultSeedConcurrency() int { return max(1, runtime.GOMAXPROCS(-1)*2) } + type downloadBatch struct { d *Downloader cancel context.CancelCauseFunc @@ -22,6 +30,11 @@ type downloadBatch struct { finishedMetadataTasks atomic.Bool // These must be run even if the batch is abandoned. afterTasks chan func() + // Cancelled only when the caller goes away, unlike cancel which also fires on ordinary completion. + seedCancel context.CancelCauseFunc + seedCtx context.Context + seedDropped atomic.Int64 + ended sync.Once } // Waits for all the fetches to complete then fires off the thread-safe Torrent methods to configure @@ -39,22 +52,46 @@ func (me *downloadBatch) taskWaiter() { } func (me *downloadBatch) addDownload(item preverifiedSnapshot) error { - t, first, miOpt, err := me.d.addPreverifiedSnapshotForDownload(item.InfoHash, item.Name) + snapshotTorrent, first, localMetainfo, keptLocal, err := me.d.addPreverifiedSnapshotForDownload(item.InfoHash, item.Name) if err != nil { return err } + if keptLocal { + me.goSeed(func() error { return me.d.seedKeptSnapshot(me.seedCtx, item.Name) }) + } + if !snapshotTorrent.Ok { + return nil + } + t := snapshotTorrent.Value me.torrents = append(me.torrents, t) if !first { return nil } me.metainfoTasks.Go(func() { me.doMetainfoTask(func() func() { - return me.d.addedFirstDownloader(me.d.ctx, t, miOpt, item.Name, item.InfoHash) + return me.d.addedFirstDownloader(me.d.ctx, t, localMetainfo, item.Name, item.InfoHash) }) }) return nil } +// goSeed runs f under the downloader's seeding cap. The cap is on concurrent hashing, not on +// goroutines: every kept item still gets one, parked in Acquire until a slot frees or seedCtx goes. +func (me *downloadBatch) goSeed(f func() error) { + me.all.Go(func() { + if me.d.seedSem.Acquire(me.seedCtx, 1) != nil { + me.seedDropped.Add(1) + return + } + defer me.d.seedSem.Release(1) + // Only f's outcome shows abandonment. A pre-call ctx check races the cancel: f can pass it + // and then bail at its own entry check, seeding nothing and never being counted. + if err := f(); err != nil && errors.Is(err, context.Cause(me.seedCtx)) { + me.seedDropped.Add(1) + } + }) +} + func (me *downloadBatch) addAllItems(ctx context.Context, items []preverifiedSnapshot) error { defer func() { go me.taskWaiter() @@ -81,14 +118,32 @@ func (me *downloadBatch) doMetainfoTask(task func() func()) { } } -func (me *downloadBatch) abandon() { - me.cancel(errors.New("download batch abandoned")) - me.all.Wait() - me.d.decDownloadRequests() +var errBatchEnded = errors.New("download batch ended") + +// end joins the batch and returns the cause it ended with. Queued seeding is dropped only when ctx +// goes away, including a cancel arriving during the join, which is why the cause is read after it. +// A batch that failed for its own reasons still seeds what it holds. +func (me *downloadBatch) end(ctx context.Context, cause error) error { + me.ended.Do(func() { + ended := cmp.Or(cause, errBatchEnded) + me.cancel(ended) + stop := context.AfterFunc(ctx, func() { me.seedCancel(context.Cause(ctx)) }) + defer stop() + // seedCtx is a d.ctx child, so it outlives the batch unless it is always released. + defer me.seedCancel(ended) + me.all.Wait() + if dropped := me.seedDropped.Load(); dropped > 0 { + me.d.log(log.LvlWarn, "dropped queued kept-local seeding", "count", dropped) + } + me.d.decDownloadRequests() + }) + return cmp.Or(cause, context.Cause(ctx)) } -func (me *downloadBatch) wait(ctx context.Context) error { - defer me.abandon() +func (me *downloadBatch) wait(ctx context.Context) (err error) { + // An all-kept-local batch has no torrents, so the loop below never samples ctx: a cancelled + // caller must still surface as an error, or seeding it dropped reports success. + defer func() { err = me.end(ctx, err) }() for _, t := range me.torrents { select { case <-t.Complete().On(): diff --git a/db/downloader/downloader.go b/db/downloader/downloader.go index 5ba8b10d980..c9767ea1575 100644 --- a/db/downloader/downloader.go +++ b/db/downloader/downloader.go @@ -57,6 +57,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/downloader/downloadercfg" @@ -104,6 +105,9 @@ type Downloader struct { activeDownloadRequests int zeroActiveDownloadRequests sync.Cond + // Caps concurrent whole-file hashing by kept-snapshot seeding across all batches. + seedSem *semaphore.Weighted + // Synchronizes state-sensitive changes to things affected by Downloader.Close. lock sync.RWMutex torrentClient *torrent.Client @@ -328,6 +332,7 @@ func New(ctx context.Context, cfg *downloadercfg.Cfg, logger log.Logger) (*Downl d.logConfig() d.ctx, d.stop = context.WithCancel(context.Background()) + d.seedSem = semaphore.NewWeighted(int64(defaultSeedConcurrency())) return d, nil } @@ -493,8 +498,18 @@ func (d *Downloader) StartTorrentPeerManager(ctx context.Context) { }) } -// Check snapshot data looks right. -func (d *Downloader) snapshotDataLooksComplete(info *metainfo.Info) bool { +// preverified.toml is written once the initial snapshot set completes, pinning the local hash set. +// Read on every call: the snapshot stage writes the file mid-run, and from that moment local data +// must be kept. +func (d *Downloader) initialDownloadComplete() (bool, error) { + complete, err := dir.FileExist(d.cfg.Dirs.PreverifiedPath()) + if err != nil { + return false, fmt.Errorf("checking %v: %w", d.cfg.Dirs.PreverifiedPath(), err) + } + return complete, nil +} + +func (d *Downloader) snapshotDataSizesMatch(info *metainfo.Info) bool { for f := range info.UpvertedFilesIter() { pathParts := append([]string{info.BestName()}, f.BestPath()...) slashPath := path.Join(pathParts...) @@ -774,7 +789,7 @@ func (d *Downloader) loadMetainfoFromDisk(name string) (mi *metainfo.MetaInfo, e // Loads metainfo from disk, removing it if it's invalid. Returns Some metainfo if it's valid. Logs // errors. -func (d *Downloader) maybeLoadMetainfoFromDisk(name string) (miOpt g.Option[*metainfo.MetaInfo], err error) { +func (d *Downloader) maybeLoadMetainfoFromDisk(name string) (localMetainfo g.Option[*metainfo.MetaInfo], err error) { miPath := d.metainfoFilePathForName(name) mi, err := metainfo.LoadFromFile(miPath) if err != nil { @@ -783,7 +798,7 @@ func (d *Downloader) maybeLoadMetainfoFromDisk(name string) (miOpt g.Option[*met } return } - miOpt.Set(mi) + localMetainfo.Set(mi) return } @@ -832,6 +847,7 @@ func (d *Downloader) startSnapshotsDownload( g.MakeChanWithLen(&batch.afterTasks, len(items)) var batchCtx context.Context batchCtx, batch.cancel = context.WithCancelCause(d.ctx) + batch.seedCtx, batch.seedCancel = context.WithCancelCause(d.ctx) batch.all.Go(func() { d.logDownload( @@ -855,7 +871,7 @@ func (d *Downloader) startSnapshotsDownload( defer func() { if err != nil { - batch.abandon() + err = batch.end(ctx, err) } }() err = batch.addAllItems(ctx, items) @@ -1000,39 +1016,58 @@ func (d *Downloader) testStartSingleDownloadNoWait( return err } -func (d *Downloader) invalidateData(name snapshotName, infoHash metainfo.Hash) (err error) { - _, ok := d.torrentClient.Torrent(infoHash) +// Moves data aside so a download can't reuse it. Only legal while the initial download is +// incomplete: after that the local files are what this node built or restored, and nothing may +// remove them. +func (d *Downloader) invalidateData(name snapshotName, preverifiedInfoHash metainfo.Hash) (err error) { + complete, err := d.initialDownloadComplete() + if err != nil { + return err + } + if complete { + return fmt.Errorf("refusing to invalidate %q: initial download is complete", name) + } + _, ok := d.torrentClient.Torrent(preverifiedInfoHash) // Torrent in use, bad idea to proceed. This shouldn't happen since we should have found // the existing name earlier. panicif.True(ok) // Ensure the data isn't reused. We're presuming the storage in use, but we can't afford // to wait until another torrent is fetched, and then we mistake a non-partial file with // the correct size as being complete. - err = os.Rename(d.filePathForName(name), d.filePathForName(name+".part")) - if err != nil && errors.Is(err, os.ErrNotExist) { - err = nil + from := d.filePathForName(name) + to := d.filePathForName(name + ".part") + err = os.Rename(from, to) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + err = nil + } + return } + d.log(log.LvlWarn, "invalidated local snapshot data, will re-download", + "name", name, "renamed_to", to, "preverified", preverifiedInfoHash) return } // Download a preverified file. That means it has a published manifest (metainfo), and a known info // hash. Caller is responsible for flushing missing metainfos to disk when complete. func (d *Downloader) addPreverifiedSnapshotForDownload( - infoHash metainfo.Hash, + preverifiedInfoHash metainfo.Hash, name string, ) ( - t *torrent.Torrent, - // First add of this Torrent that asked to download. The caller is responsible for adding - // download tasks. - firstDownloader bool, - miOpt g.Option[*metainfo.MetaInfo], + // None when local data wins. + snapshotTorrent g.Option[*torrent.Torrent], + firstDownloader bool, // First add of this Torrent that asked to download. The caller is responsible for adding download tasks. + localMetainfo g.Option[*metainfo.MetaInfo], + // Local data was kept and no torrent is registered for it. The caller seeds it, off d.lock: + // deriving a metainfo hashes the whole file. + keptLocal bool, err error, ) { // Prevent anyone else from trying to add a torrent in the meanwhile, so we can do data // invalidation, and identify the first downloader. d.lock.Lock() defer d.lock.Unlock() - t, ok, err := d.getExistingSnapshotTorrent(name, infoHash) + t, ok, err := d.getExistingSnapshotTorrent(name, preverifiedInfoHash) if err != nil { // If a torrent for this name is already loaded with a different infohash, keep the // existing local torrent and skip the preverified download. This handles the case where @@ -1043,12 +1078,12 @@ func (d *Downloader) addPreverifiedSnapshotForDownload( // by AddTorrentsFromDisk before initial sync runs) is tracked separately. The proper fix // is to run initial sync before AddTorrentsFromDisk so the preverified TOML hashes can // always take precedence. See: https://github.com/erigontech/erigon/issues/19435 - if existingT, nameOk := d.torrentsByName[name]; nameOk && existingT.InfoHash() != infoHash { + if existingT, nameOk := d.torrentsByName[name]; nameOk && existingT.InfoHash() != preverifiedInfoHash { d.log(log.LvlWarn, "snapshot already loaded with different infohash, keeping existing local torrent (preverified skipped)", "name", name, "existing_infohash", existingT.InfoHash().HexString(), - "preverified_infohash", infoHash.HexString()) - t = existingT + "preverified_infohash", preverifiedInfoHash.HexString()) + snapshotTorrent.Set(existingT) err = nil return } @@ -1056,74 +1091,149 @@ func (d *Downloader) addPreverifiedSnapshotForDownload( } // We can invalidate data if a torrent isn't yet loaded. if !ok { - miOpt, err = d.loadMatchingMetainfoOrInvalidateData(infoHash, name) - if err != nil { - return - } - var new bool - t, new, err = d.addTorrent(name, infoHash) - if err != nil { + var isNew bool + var addedTorrent g.Option[*torrent.Torrent] + addedTorrent, isNew, localMetainfo, keptLocal, err = d.addTorrentForPreverifiedSnapshot(preverifiedInfoHash, name) + if err != nil || !addedTorrent.Ok { return } - panicif.False(new) + panicif.False(isNew) + t = addedTorrent.Value } g.MakeMapIfNil(&d.downloads) firstDownloader = !g.MapInsert(d.downloads, t, struct{}{}).Ok + snapshotTorrent.Set(t) return } -func (d *Downloader) loadMatchingMetainfoOrInvalidateData( - infoHash metainfo.Hash, +func (d *Downloader) addTorrentForPreverifiedSnapshot( + preverifiedInfoHash metainfo.Hash, name string, ) ( - miOpt g.Option[*metainfo.MetaInfo], + // None when local data wins. + addedTorrent g.Option[*torrent.Torrent], + isNew bool, + localMetainfo g.Option[*metainfo.MetaInfo], + keptLocal bool, err error, ) { - miOpt, err = d.maybeLoadMetainfoFromDisk(name) + var download bool + localMetainfo, download, err = d.prepareLocalDataForDownload(preverifiedInfoHash, name) + if err != nil || !download { + keptLocal = err == nil + return + } + t, isNew, err := d.addTorrent(name, preverifiedInfoHash) if err != nil { - d.log(log.LvlError, "error loading metainfo from disk", "err", err, "name", name) - err = nil + return } - if miOpt.Ok { - loadedIh := miOpt.Value.HashInfoBytes() - if loadedIh == infoHash { - return + addedTorrent.Set(t) + return +} + +// Reports whether the preverified download should go ahead. Data that backs no matching metainfo is +// invalidated, but only while the manifest is still authoritative. +func (d *Downloader) prepareLocalDataForDownload( + preverifiedInfoHash metainfo.Hash, + name string, +) ( + localMetainfo g.Option[*metainfo.MetaInfo], + download bool, + err error, +) { + // An unreadable metainfo is logged and then treated as missing: it says nothing about the data. + localMetainfo, loadErr := d.maybeLoadMetainfoFromDisk(name) + if loadErr != nil { + d.log(log.LvlWarn, "error loading metainfo from disk", "err", loadErr, "name", name) + } + localMetainfoUnbacked := false + if localMetainfo.Ok { + localInfoHash := localMetainfo.Value.HashInfoBytes() + if localInfoHash == preverifiedInfoHash { + return localMetainfo, true, nil } - // This is fine if we're doing initial sync. If we're not we shouldn't be here. d.log(log.LvlWarn, "preverified snapshot hash has changed", - "expected", infoHash, - "actual", loadedIh, + "preverified", preverifiedInfoHash, + "local", localInfoHash, "name", name) + info, infoErr := localMetainfo.Value.UnmarshalInfo() + if infoErr != nil { + d.log(log.LvlWarn, "error unmarshalling local metainfo", "err", infoErr, "name", name) + } + localMetainfoUnbacked = infoErr == nil && !d.snapshotDataSizesMatch(&info) // Forget the metainfo we loaded, it's wrong (probably changed hash but not name...) - miOpt.SetNone() + localMetainfo.SetNone() } else { d.log(log.LvlDebug, "snapshot metainfo missing", "name", name) } - err = d.invalidateData(name, infoHash) + + complete, err := d.initialDownloadComplete() if err != nil { - err = fmt.Errorf("invalidating old snapshot data: %w", err) - return + return localMetainfo, false, err } - return + if complete { + // Local data outranks the manifest from here on: keep whatever we have, download the rest. + exists, err := d.snapshotDataExists(name) + if err != nil { + return localMetainfo, false, err + } + if !exists { + return localMetainfo, true, nil + } + if localMetainfoUnbacked { + // The data backs neither manifest. Downloading hands it to the client, which + // completes the file by length, so a wrong length is re-fetched while + // same-length-different-bytes is not. + d.log(log.LvlWarn, "local snapshot does not match its own metainfo, downloading", + "name", name) + return localMetainfo, true, nil + } + d.log(log.LvlWarn, "keeping local snapshot, skipping preverified download", "name", name) + return localMetainfo, false, nil + } + + if err := d.invalidateData(name, preverifiedInfoHash); err != nil { + return localMetainfo, false, fmt.Errorf("invalidating old snapshot data: %w", err) + } + return localMetainfo, true, nil +} + +// seedKeptSnapshot registers a kept local snapshot so it is seeded, deriving the metainfo when +// none is on disk. Must run without d.lock: deriving it hashes the whole file. A ctx-caused +// failure is returned but not logged; the batch counts those into one drop total. +func (d *Downloader) seedKeptSnapshot(ctx context.Context, name string) error { + err := d.AddNewSeedableFile(ctx, name) + if err != nil && ctx.Err() == nil { + d.log(log.LvlWarn, "cannot seed kept local snapshot", "err", err, "name", name) + } + return err +} + +func (d *Downloader) snapshotDataExists(name string) (bool, error) { + exists, err := dir.FileExist(d.filePathForName(name)) + if err != nil { + return false, fmt.Errorf("checking snapshot data for %q: %w", name, err) + } + return exists, nil } func (d *Downloader) addedFirstDownloader( ctx context.Context, t *torrent.Torrent, - miOpt g.Option[*metainfo.MetaInfo], + localMetainfo g.Option[*metainfo.MetaInfo], name string, infoHash metainfo.Hash, ) (afterAdd func()) { - // Try again, we would have invalidated data for changed infohashes now. - if !miOpt.Ok { + // Try the webseeds for the metainfo that wasn't on disk. Nothing here relies on the data having + // been moved aside: after the initial download it never is, and the client completes the file + // by length. + if !localMetainfo.Ok { // Yes I mean for this error to be scoped here. err := d.fetchMetainfoFromWebseeds(ctx, name, infoHash) if err == nil { // Always reuse code paths to ensure no surprises later. I.e. load the metainfo again - // through the same path that is used on a good run. No data invalidation here, at this - // point we've added the torrent, and already invalidated if the metainfo was missing - // the first time. - miOpt, err = d.maybeLoadMetainfoFromDisk(name) + // through the same path that is used on a good run. + localMetainfo, err = d.maybeLoadMetainfoFromDisk(name) if err != nil { // Should this error be returned instead? d.log(log.LvlError, "error loading metainfo from disk", "err", err, "name", name) @@ -1133,10 +1243,10 @@ func (d *Downloader) addedFirstDownloader( } } - if miOpt.Ok { + if localMetainfo.Ok { // Good case: We have a metainfo with the right infohash, either just fetched from a // webseed, or it was cached on disk. - err := d.applyMetainfo(miOpt.Value, t) + err := d.applyMetainfo(localMetainfo.Value, t) if err != nil { d.log(log.LvlError, "error applying metainfo", "err", err, "name", name) } @@ -1285,7 +1395,7 @@ func (d *Downloader) addTorrentIfComplete( err = fmt.Errorf("unmarshalling info from metainfo: %w", err) return } - if !d.snapshotDataLooksComplete(&info) { + if !d.snapshotDataSizesMatch(&info) { err = nil return } diff --git a/db/downloader/downloader_test.go b/db/downloader/downloader_test.go index 1daf47124bf..92a638bd9b7 100644 --- a/db/downloader/downloader_test.go +++ b/db/downloader/downloader_test.go @@ -17,23 +17,33 @@ package downloader import ( + "bytes" "context" + "errors" "fmt" "io/fs" "os" "path/filepath" "runtime" "sync" + "sync/atomic" "testing" + "time" + g "github.com/anacrolix/generics" + "github.com/anacrolix/torrent/metainfo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" + "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/downloader/downloadercfg" "github.com/erigontech/erigon/db/snaptype" + "github.com/erigontech/erigon/node/gointerfaces" + "github.com/erigontech/erigon/node/gointerfaces/downloaderproto" ) func TestConcurrentDownload(t *testing.T) { @@ -346,3 +356,738 @@ func newDownloaderTest(t *testing.T) *downloaderTest { downloader: d, } } + +// logBuffer is a log sink readable while the Downloader's goroutines write to it. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func newLocalSnapshotTest(t *testing.T) (d *Downloader, logs *logBuffer, name, path string) { + d = newDownloaderTest(t).downloader + logs = &logBuffer{} + d.logger.SetHandler(log.LvlFilterHandler(log.LvlWarn, log.StreamHandler(logs, log.LogfmtFormat()))) + + name = "domain/v2.0-accounts.0-1024.kv" + path = d.filePathForName(name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("locally rebuilt"), 0o644)) + return +} + +// Renaming data to .part leaves a hole in the snapshot tier, so it must be logged at warn or louder. +func TestInvalidateDataRenamesLocalFile(t *testing.T) { + require := require.New(t) + d, logs, name, path := newLocalSnapshotTest(t) + differentPreverifiedInfoHash := snaptype.Hex2InfoHash("aa") + + _, download := prepareLocalDataForDownload(t, d, differentPreverifiedInfoHash, name) + require.True(download) + + require.NoFileExists(path) + require.FileExists(path + ".part") + require.Contains(logs.String(), "invalidated local snapshot data", "rename must be logged at warn or louder") + require.Contains(logs.String(), name) +} + +// A stale metainfo doesn't rescue the data while the initial download is incomplete. +func TestInvalidateDataWithStaleMetainfo(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + _, err := BuildTorrentIfNeed(t.Context(), name, d.snapDir(), d.torrentFS) + require.NoError(err) + + _, download := prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), name) + require.True(download) + + require.NoFileExists(path) + require.FileExists(path + ".part") +} + +// Once preverified.toml exists, local data is kept whatever the manifest says. +func TestKeepsLocalSnapshotAfterInitialDownload(t *testing.T) { + for _, withMetainfo := range []bool{false, true} { + t.Run(fmt.Sprint("withMetainfo=", withMetainfo), func(t *testing.T) { + require := require.New(t) + d, logs, name, path := newLocalSnapshotTest(t) + if withMetainfo { + _, err := BuildTorrentIfNeed(t.Context(), name, d.snapDir(), d.torrentFS) + require.NoError(err) + } + markInitialDownloadComplete(t, d) + + _, download := prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), name) + require.False(download, "preverified download must be skipped") + + require.FileExists(path) + require.NoFileExists(path + ".part") + require.Contains(logs.String(), "keeping local snapshot") + require.Contains(logs.String(), name) + }) + } +} + +// Data that no longer matches its own metainfo backs neither manifest. Keeping it unverified +// leaves a hole in the snapshot tier, so it goes to the client, which completes it by length. +func TestDownloadsLocalSnapshotNotMatchingItsMetainfo(t *testing.T) { + require := require.New(t) + d, logs, name, path := newLocalSnapshotTest(t) + _, err := BuildTorrentIfNeed(t.Context(), name, d.snapDir(), d.torrentFS) + require.NoError(err) + require.NoError(os.WriteFile(path, []byte("truncated"), 0o644)) + markInitialDownloadComplete(t, d) + + _, download := prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), name) + require.True(download, "a file that backs no metainfo must be re-fetched, not kept") + + require.FileExists(path, "the client completes in place, so the data must stay where it is") + require.NoFileExists(path+".part", "invalidation stays forbidden after the initial download") + require.Contains(logs.String(), "local snapshot does not match its own metainfo") + require.Contains(logs.String(), name) +} + +// Nothing local to protect: the preverified file is still downloaded. +func TestDownloadsMissingSnapshotAfterInitialDownload(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + require.NoError(dir.RemoveFile(path)) + markInitialDownloadComplete(t, d) + + preverifiedInfoHash := snaptype.Hex2InfoHash("aa") + _, download := prepareLocalDataForDownload(t, d, preverifiedInfoHash, name) + require.True(download) +} + +// Under d.lock, as production callers hold it. +func prepareLocalDataForDownload(t *testing.T, d *Downloader, preverifiedInfoHash metainfo.Hash, name string) ( + localMetainfo g.Option[*metainfo.MetaInfo], + download bool, +) { + t.Helper() + d.lock.Lock() + defer d.lock.Unlock() + localMetainfo, download, err := d.prepareLocalDataForDownload(preverifiedInfoHash, name) + require.NoError(t, err) + return localMetainfo, download +} + +func markInitialDownloadComplete(t *testing.T, d *Downloader) { + require.NoError(t, os.WriteFile(d.cfg.Dirs.PreverifiedPath(), nil, 0o644)) +} + +// The metainfo on disk is the preverified one: the file is used as-is. +func TestKeepsSnapshotMatchingPreverifiedHash(t *testing.T) { + for _, initialDownloadComplete := range []bool{false, true} { + t.Run(fmt.Sprint("initialDownloadComplete=", initialDownloadComplete), func(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + _, err := BuildTorrentIfNeed(t.Context(), name, d.snapDir(), d.torrentFS) + require.NoError(err) + if initialDownloadComplete { + markInitialDownloadComplete(t, d) + } + localInfoHash := loadLocalInfoHash(t, d, name) + + localMetainfo, download := prepareLocalDataForDownload(t, d, localInfoHash, name) + require.True(localMetainfo.Ok) + require.True(download) + + require.FileExists(path) + require.NoFileExists(path + ".part") + }) + } +} + +// A from-scratch sync has no data to invalidate, so it must stay quiet. +func TestInvalidateDataQuietWithoutLocalData(t *testing.T) { + require := require.New(t) + d, logs, name, path := newLocalSnapshotTest(t) + require.NoError(dir.RemoveFile(path)) + + _, download := prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), name) + require.True(download) + + require.NoFileExists(path + ".part") + require.Empty(logs.String()) +} + +// An unreadable metainfo costs the file only while the manifest is still authoritative. +func TestUnreadableMetainfoEvictsOnlyDuringInitialDownload(t *testing.T) { + for _, initialDownloadComplete := range []bool{false, true} { + t.Run(fmt.Sprint("initialDownloadComplete=", initialDownloadComplete), func(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + require.NoError(os.WriteFile(d.metainfoFilePathForName(name), []byte("not bencode"), 0o644)) + if initialDownloadComplete { + markInitialDownloadComplete(t, d) + } + + _, download := prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), name) + if initialDownloadComplete { + require.False(download) + require.FileExists(path) + } else { + require.True(download) + require.NoFileExists(path) + } + }) + } +} + +// The skip reaches the caller as None, so the batch has nothing to wait for. +func TestAddPreverifiedSnapshotSkipsAfterInitialDownload(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + markInitialDownloadComplete(t, d) + + snapshotTorrent, firstDownloader, _, _, err := d.addPreverifiedSnapshotForDownload(snaptype.Hex2InfoHash("aa"), name) + require.NoError(err) + require.False(snapshotTorrent.Ok) + require.False(firstDownloader) + require.FileExists(path) +} + +func loadLocalInfoHash(t *testing.T, d *Downloader, name string) metainfo.Hash { + t.Helper() + mi, err := metainfo.LoadFromFile(d.metainfoFilePathForName(name)) + require.NoError(t, err) + return mi.HashInfoBytes() +} + +type localSnapshotState struct { + data bool + metainfo string // "none", "matching", "stale", "corrupt" +} + +func (st localSnapshotState) name() string { + return fmt.Sprintf("domain/v2.0-accounts.%d-%d.kv", boolToInt(st.data), len(st.metainfo)) +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// Writes one snapshot in the requested state and returns its name and the hash a caller should pass +// as the preverified one. +func writeLocalSnapshot(t *testing.T, d *Downloader, name string, st localSnapshotState) metainfo.Hash { + t.Helper() + path := d.filePathForName(name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("locally rebuilt "+name), 0o644)) + preverified := snaptype.Hex2InfoHash("aa") + switch st.metainfo { + case "matching": + _, err := BuildTorrentIfNeed(t.Context(), name, d.snapDir(), d.torrentFS) + require.NoError(t, err) + preverified = loadLocalInfoHash(t, d, name) + case "stale": + _, err := BuildTorrentIfNeed(t.Context(), name, d.snapDir(), d.torrentFS) + require.NoError(t, err) + case "corrupt": + require.NoError(t, os.WriteFile(d.metainfoFilePathForName(name), []byte("not bencode"), 0o644)) + } + if !st.data { + require.NoError(t, dir.RemoveFile(path)) + } + return preverified +} + +func allLocalSnapshotStates() (all []localSnapshotState) { + for _, data := range []bool{false, true} { + for _, mi := range []string{"none", "matching", "stale", "corrupt"} { + all = append(all, localSnapshotState{data: data, metainfo: mi}) + } + } + return +} + +// Invariant: once preverified.toml exists, no state of the datadir may cost a data file. The +// download flag below is only the decision to add the torrent, not a decision to fetch bytes. +func TestNeverEvictsAfterInitialDownload(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + markInitialDownloadComplete(t, d) + + for _, st := range allLocalSnapshotStates() { + name := st.name() + preverified := writeLocalSnapshot(t, d, name, st) + + _, download := prepareLocalDataForDownload(t, d, preverified, name) + + require.NoFileExists(d.filePathForName(name)+".part", "%+v", st) + require.Equal(st.data, fileExists(d.filePathForName(name)), "%+v", st) + // The preverified file is the local file: adding it is free, the client sees it complete. + wantDownload := !st.data || st.metainfo == "matching" + require.Equal(wantDownload, download, "%+v", st) + } +} + +// The guard has to hold at the entry point that reaches it in production: cmd/downloader --seedbox +// issues a Download per preverified item whatever cfg.Local says, while erigon's own sync path is +// gated earlier. The bounded context is so a regression fails instead of waiting on a peer. +func TestGrpcDownloadKeepsLocalDataAfterInitialDownload(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + markInitialDownloadComplete(t, d) + svr, err := NewGrpcServer(d) + require.NoError(err) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + _, err = svr.Download(ctx, &downloaderproto.DownloadRequest{ + Items: []*downloaderproto.DownloadItem{{ + Path: name, + TorrentHash: gointerfaces.ConvertAddressToH160(snaptype.Hex2InfoHash("aa")), + }}, + }) + require.NoError(err) + + require.FileExists(path) + require.NoFileExists(path + ".part") +} + +// testStartSingleDownloadAndWait starts a snapshot download and waits for it with a live context, +// bounded so a hang fails this test instead of the whole package. +func (d *Downloader) testStartSingleDownloadAndWait(ctx context.Context, infoHash metainfo.Hash, name string) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + wait, err := d.startSnapshotsDownload(ctx, []preverifiedSnapshot{ + {infoHash, name}, + }, "testing") + if err != nil { + return err + } + return wait(ctx) +} + +// A kept snapshot still has to be seeded. With no torrent registered the name is absent from +// torrentsByName, so allActiveSnapshots and PublishLocalChainToml never see it, and a seedbox +// silently stops serving a file it holds. +func TestKeptLocalSnapshotIsSeeded(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + markInitialDownloadComplete(t, d) + + require.NoError(d.testStartSingleDownloadAndWait(t.Context(), snaptype.Hex2InfoHash("aa"), name)) + + require.FileExists(path) + require.NoFileExists(path + ".part") + d.lock.RLock() + _, registered := d.torrentsByName[name] + d.lock.RUnlock() + require.True(registered, "a kept snapshot must be registered, or it is never seeded") +} + +// goSeed must never exceed the seed semaphore's capacity. +func TestGoSeedBoundsConcurrency(t *testing.T) { + require := require.New(t) + const limit = 3 + batch := &downloadBatch{d: &Downloader{seedSem: semaphore.NewWeighted(limit)}} + batch.seedCtx, batch.seedCancel = context.WithCancelCause(context.Background()) + defer batch.seedCancel(nil) + + const n = 10 + var current, peak atomic.Int64 + release := make(chan struct{}) + closeRelease := sync.OnceFunc(func() { close(release) }) + defer closeRelease() + for range n { + batch.goSeed(func() error { + c := current.Add(1) + for { + p := peak.Load() + if c <= p || peak.CompareAndSwap(p, c) { + break + } + } + <-release + current.Add(-1) + return nil + }) + } + + require.Eventually(func() bool { return current.Load() == limit }, time.Second, time.Millisecond, + "only %d of %d tasks should be able to run concurrently", limit, n) + closeRelease() + batch.all.Wait() + require.EqualValues(limit, peak.Load()) +} + +// Cancelling seedCtx is genuine abandonment: it must drop queued goSeed tasks instead of running +// them, independently of the batch's own cancellation. +func TestGoSeedAbandonsQueuedOnCancel(t *testing.T) { + require := require.New(t) + const limit = 2 + batch := &downloadBatch{d: &Downloader{seedSem: semaphore.NewWeighted(limit)}} + batch.seedCtx, batch.seedCancel = context.WithCancelCause(context.Background()) + // Guaranteed even if the require.Eventually below fails and testify unwinds this goroutine + // with FailNow, which would otherwise strand every goroutine dispatched below. + defer batch.seedCancel(nil) + + const n = 50 + var started atomic.Int64 + release := make(chan struct{}) + closeRelease := sync.OnceFunc(func() { close(release) }) + defer closeRelease() + for range n { + batch.goSeed(func() error { + started.Add(1) + <-release + return nil + }) + } + + require.Eventually(func() bool { return started.Load() == limit }, time.Second, time.Millisecond, + "the first %d tasks should have started", limit) + batch.seedCancel(nil) + require.EqualValues(limit, started.Load(), "queued tasks must abandon rather than wait for a slot") + closeRelease() + + done := make(chan struct{}) + go func() { + batch.all.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("cancelling seedCtx did not release goSeed tasks still waiting for a slot") + } + require.EqualValues(n-limit, batch.seedDropped.Load(), + "every task that never seeded must be counted, or the warn under-reports") +} + +// A cancel landing after a task has started seeds nothing either, and only the task's own outcome +// shows it: a ctx check taken before the call races the cancel and misses the drop. +func TestGoSeedCountsCancelAfterStart(t *testing.T) { + require := require.New(t) + batch := &downloadBatch{d: &Downloader{seedSem: semaphore.NewWeighted(1)}} + batch.seedCtx, batch.seedCancel = context.WithCancelCause(context.Background()) + defer batch.seedCancel(nil) + + started, release := make(chan struct{}), make(chan struct{}) + batch.goSeed(func() error { + close(started) + <-release + // The wrapped cause the real seed path returns when it bails after the cancel. + return fmt.Errorf("building metainfo: %w", context.Cause(batch.seedCtx)) + }) + + <-started + batch.seedCancel(errors.New("caller went away")) + close(release) + batch.all.Wait() + + require.EqualValues(1, batch.seedDropped.Load(), + "a cancel arriving after the task started must still count as a drop") +} + +// goSeed waits on seedCtx, not the batch's own cancellation, so cancelling the batch alone must +// not drop seed work still queued behind a full semaphore — it must all eventually run. +func TestGoSeedRunsAllOnSuccess(t *testing.T) { + require := require.New(t) + const limit = 2 + batch := &downloadBatch{d: &Downloader{seedSem: semaphore.NewWeighted(limit)}} + _, batch.cancel = context.WithCancelCause(context.Background()) + batch.seedCtx, batch.seedCancel = context.WithCancelCause(context.Background()) + defer batch.seedCancel(nil) + + const n = 50 + var started, ran atomic.Int64 + release := make(chan struct{}) + closeRelease := sync.OnceFunc(func() { close(release) }) + defer closeRelease() + for range n { + batch.goSeed(func() error { + started.Add(1) + <-release + ran.Add(1) + return nil + }) + } + + require.Eventually(func() bool { return started.Load() == limit }, time.Second, time.Millisecond, + "only %d of %d tasks should be able to run concurrently", limit, n) + + batch.cancel(nil) + closeRelease() + + done := make(chan struct{}) + go func() { + batch.all.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("batch.all.Wait() did not return once the release gate opened") + } + require.EqualValues(n, ran.Load(), "all queued seed work must run when the batch succeeds") +} + +// writeKeptLocalSnapshots writes n snapshot files that keep-local under startSnapshotsDownload (no +// metainfo backs their preverified hash). Content embeds the name so distinct items hash to +// distinct infohashes; identical content would collide and one would appear to lose the race for a +// reason unrelated to the seed bound. +func writeKeptLocalSnapshots(t *testing.T, d *Downloader, n, size int) (items []preverifiedSnapshot, names []string) { + t.Helper() + for i := range n { + name := fmt.Sprintf("domain/v2.0-accounts.%d-%d.kv", i, i+1) + path := d.filePathForName(name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + buf := make([]byte, size) + copy(buf, name) + require.NoError(t, os.WriteFile(path, buf, 0o644)) + names = append(names, name) + items = append(items, preverifiedSnapshot{snaptype.Hex2InfoHash("aa"), name}) + } + return +} + +// startSnapshotsDownload wires d.seedSem into the real seeding path: at most that many kept-local +// snapshots seed concurrently, and abandoning the batch (a cancelled wait ctx) drops whatever is +// still queued behind the bound instead of leaking it past the batch's own lifetime. +func TestKeptLocalSeedingRespectsBoundAndAbandonCause(t *testing.T) { + const bound = 2 + const n = bound + 4 + + t.Run("success", func(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + d.seedSem = semaphore.NewWeighted(bound) + markInitialDownloadComplete(t, d) + + items, names := writeKeptLocalSnapshots(t, d, n, 64) + + wait, err := d.startSnapshotsDownload(t.Context(), items, "testing") + require.NoError(err) + require.NoError(wait(t.Context())) + + d.lock.RLock() + defer d.lock.RUnlock() + for _, name := range names { + _, registered := d.torrentsByName[name] + require.True(registered, name) + } + }) + + t.Run("abandoned", func(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + d.seedSem = semaphore.NewWeighted(bound) + markInitialDownloadComplete(t, d) + + // Hold every seed slot so each goSeed task deterministically queues behind the bound, + // whatever the scheduler does, instead of racing file hashing against the cancel below. + require.NoError(d.seedSem.Acquire(context.Background(), bound)) + defer d.seedSem.Release(bound) + + items, names := writeKeptLocalSnapshots(t, d, n, 64) + + wait, err := d.startSnapshotsDownload(t.Context(), items, "testing") + require.NoError(err) + + ctx, cancel := context.WithCancelCause(t.Context()) + dropped := errors.New("abandon queued kept-local seeding") + cancel(dropped) + + done := make(chan error, 1) + go func() { done <- wait(ctx) }() + select { + case err := <-done: + require.ErrorIs(err, dropped, + "dropping queued seeding must not report success, or the caller publishes chain.toml for it") + case <-time.After(30 * time.Second): + t.Fatal("wait did not return after the caller cancelled") + } + + d.lock.RLock() + defer d.lock.RUnlock() + for _, name := range names { + _, registered := d.torrentsByName[name] + require.False(registered, "abandonment must drop seeding still queued behind the held bound: %s", name) + } + }) +} + +// A batch that fails for its own reasons — here, one of its torrents closing — must still seed +// kept-local items it already queued: only the caller's own ctx may drop queued seeding. +func TestKeptLocalSeedingSurvivesTorrentClosed(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + markInitialDownloadComplete(t, d) + d.seedSem = semaphore.NewWeighted(1) + d.incDownloadRequests() + + _, names := writeKeptLocalSnapshots(t, d, 2, 64) + + pendingName := "a.seg" + snapshotTorrent, _, _, keptLocal, err := d.addPreverifiedSnapshotForDownload(snaptype.Hex2InfoHash("bb"), pendingName) + require.NoError(err) + require.False(keptLocal) + require.True(snapshotTorrent.Ok) + + batch := &downloadBatch{d: d} + batch.torrents = append(batch.torrents, snapshotTorrent.Value) + _, batch.cancel = context.WithCancelCause(d.ctx) + batch.seedCtx, batch.seedCancel = context.WithCancelCause(d.ctx) + + // Only one of the two can hold the seed semaphore's single slot; the other queues behind it, + // gated the same way so it stays queued until end()'s cancel-or-not decision has been made. + gate := make(chan struct{}) + for _, name := range names { + batch.goSeed(func() error { + <-gate + return d.seedKeptSnapshot(batch.seedCtx, name) + }) + } + + require.NoError(d.Delete(pendingName)) + + done := make(chan error, 1) + go func() { done <- batch.wait(t.Context()) }() + + require.Never(func() bool { return batch.seedCtx.Err() != nil }, 100*time.Millisecond, time.Millisecond, + "a torrent closing for its own reasons must not cancel queued kept-local seeding") + close(gate) + + select { + case err := <-done: + require.ErrorContains(err, "unexpectedly closed") + case <-time.After(30 * time.Second): + t.Fatal("wait did not return after the gate opened") + } + + d.lock.RLock() + defer d.lock.RUnlock() + for _, name := range names { + _, registered := d.torrentsByName[name] + require.True(registered, "kept-local seeding must survive an unrelated torrent closing: %s", name) + } +} + +// A second end() call must be a no-op: activeDownloadRequests tracks one increment per +// startSnapshotsDownload call and must not go negative from a duplicate decrement. +func TestEndIsIdempotent(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + d.incDownloadRequests() + + batch := &downloadBatch{d: d} + _, batch.cancel = context.WithCancelCause(d.ctx) + batch.seedCtx, batch.seedCancel = context.WithCancelCause(d.ctx) + + batch.end(t.Context(), errors.New("first end")) + batch.end(t.Context(), errors.New("second end")) + + d.activeDownloadRequestsLock.Lock() + defer d.activeDownloadRequestsLock.Unlock() + require.Zero(d.activeDownloadRequests, "a second end call must not decrement activeDownloadRequests again") +} + +// The snapshot stage writes preverified.toml mid-run, so the rule must be re-read, not cached from +// an earlier call. +func TestInitialDownloadCompletingMidRunKeepsLocalData(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + + beforeName := "domain/v2.0-accounts.0-1024.kv" + writeLocalSnapshot(t, d, beforeName, localSnapshotState{data: true, metainfo: "none"}) + _, download := prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), beforeName) + require.True(download) + require.FileExists(d.filePathForName(beforeName) + ".part") + + markInitialDownloadComplete(t, d) + + afterName := "domain/v2.0-accounts.1024-2048.kv" + writeLocalSnapshot(t, d, afterName, localSnapshotState{data: true, metainfo: "none"}) + _, download = prepareLocalDataForDownload(t, d, snaptype.Hex2InfoHash("aa"), afterName) + require.False(download) + require.FileExists(d.filePathForName(afterName)) + require.NoFileExists(d.filePathForName(afterName) + ".part") +} + +// The rename itself refuses to run after the initial download, whatever the caller decided. +func TestInvalidateDataRefusesAfterInitialDownload(t *testing.T) { + require := require.New(t) + d, _, name, path := newLocalSnapshotTest(t) + markInitialDownloadComplete(t, d) + + d.lock.Lock() + err := d.invalidateData(name, snaptype.Hex2InfoHash("aa")) + d.lock.Unlock() + + require.Error(err) + require.FileExists(path) + require.NoFileExists(path + ".part") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// end() joins the seed tasks, and a caller that goes away during that join has its queued seeding +// dropped by the AfterFunc. wait must report that, or DownloadSnapshots publishes chain.toml for +// snapshots it never seeded. +func TestKeptLocalSeedingReportsCancelDuringJoin(t *testing.T) { + require := require.New(t) + d := newDownloaderTest(t).downloader + markInitialDownloadComplete(t, d) + d.seedSem = semaphore.NewWeighted(1) + d.incDownloadRequests() + + // Hold the only slot so every goSeed task is parked in Acquire for the whole join. + require.NoError(d.seedSem.Acquire(context.Background(), 1)) + defer d.seedSem.Release(1) + + _, names := writeKeptLocalSnapshots(t, d, 2, 64) + + // No torrents: wait's loop returns at once, so the cancel below lands inside end()'s join. + batch := &downloadBatch{d: d} + _, batch.cancel = context.WithCancelCause(d.ctx) + batch.seedCtx, batch.seedCancel = context.WithCancelCause(d.ctx) + for _, name := range names { + batch.goSeed(func() error { return d.seedKeptSnapshot(batch.seedCtx, name) }) + } + + ctx, cancel := context.WithCancelCause(t.Context()) + defer cancel(nil) + done := make(chan error, 1) + go func() { done <- batch.wait(ctx) }() + + require.Never(func() bool { + select { + case <-done: + return true + default: + return false + } + }, 100*time.Millisecond, time.Millisecond, "wait must still be joining the parked seed tasks") + + dropped := errors.New("caller went away during the join") + cancel(dropped) + + select { + case err := <-done: + require.ErrorIs(err, dropped, + "seeding dropped mid-join must not report success, or the caller publishes chain.toml for it") + case <-time.After(30 * time.Second): + t.Fatal("wait did not return after the caller cancelled") + } +} diff --git a/db/downloader/downloadercfg/downloadercfg.go b/db/downloader/downloadercfg/downloadercfg.go index 715e7c9579a..19cbc1f7ba9 100644 --- a/db/downloader/downloadercfg/downloadercfg.go +++ b/db/downloader/downloadercfg/downloadercfg.go @@ -317,11 +317,11 @@ func LoadSnapshotsHashes(ctx context.Context, dirs datadir.Dirs, chainName strin } preverifiedPath := dirs.PreverifiedPath() - exists, err := dir.FileExist(preverifiedPath) + initialDownloadComplete, err := dir.FileExist(preverifiedPath) if err != nil { return err } - if exists { + if initialDownloadComplete { // Load hashes from local preverified.toml haveToml, err := os.ReadFile(preverifiedPath) if err != nil { @@ -336,7 +336,7 @@ func LoadSnapshotsHashes(ctx context.Context, dirs datadir.Dirs, chainName strin return fmt.Errorf("failed to fetch remote snapshot hashes for chain %s", chainName) } } - cfg.Local = exists + cfg.Local = initialDownloadComplete return nil }