Skip to content

Commit d7aa75c

Browse files
authored
feat: add --force-resync and --purge-db CLI flags (#10871)
* feat: add --force-resync and --purge-db CLI flags --force-resync deletes all database files except peer and discovery data (peers, discoveryNodes, discoveryV5Nodes), forcing a full chain resync while preserving the peer list. --purge-db deletes the entire database directory including network data. * fix: address --force-resync / --purge-db review feedback - Error when both flags are provided instead of silently picking one - Add safety guard against deleting filesystem roots - Wrap deletions in error handling for locked/read-only files - Extract DatabasePurger to testable static class with 6 unit tests covering preserve-network, full-purge, loose files, and nonexistent directory scenarios * fix: address Rubo's review feedback on CLI flags - Sort options alphabetically in BasicOptions and CreateRootCommand - Use Validators.Add API for mutual exclusion of --force-resync and --purge-db instead of runtime if/else check - Fix description casing: "Delete" -> "Deletes", add comma before "including" * fix: address second round of review feedback - Remove unused `using Nethermind.Db` from Program.cs - Drop path length heuristic from safety guard, keep only root check - Use logger.Error(message, ex) overload to preserve stack trace - Assert all chain DB directories are deleted in ForceResync test
1 parent d89e379 commit d7aa75c

File tree

4 files changed

+241
-1
lines changed

4 files changed

+241
-1
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
2+
// SPDX-License-Identifier: LGPL-3.0-only
3+
4+
using System.IO;
5+
using Nethermind.Db;
6+
using Nethermind.Logging;
7+
using NUnit.Framework;
8+
9+
namespace Nethermind.Runner.Test;
10+
11+
public class DatabasePurgerTests
12+
{
13+
private string _tempDir = null!;
14+
private readonly ILogger _logger = LimboLogs.Instance.GetClassLogger();
15+
16+
[SetUp]
17+
public void Setup()
18+
{
19+
_tempDir = Path.Combine(Path.GetTempPath(), $"nethermind-test-{Path.GetRandomFileName()}");
20+
CreateTestDbLayout(_tempDir);
21+
}
22+
23+
[TearDown]
24+
public void TearDown()
25+
{
26+
if (Directory.Exists(_tempDir))
27+
Directory.Delete(_tempDir, recursive: true);
28+
}
29+
30+
[Test]
31+
public void ForceResync_preserves_peer_and_discovery_directories()
32+
{
33+
DatabasePurger.Purge(_tempDir, preserveNetwork: true, _logger);
34+
35+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.PeersDb)), Is.True, "peers should be preserved");
36+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.DiscoveryNodes)), Is.True, "discoveryNodes should be preserved");
37+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.DiscoveryV5Nodes)), Is.True, "discoveryV5Nodes should be preserved");
38+
}
39+
40+
[Test]
41+
public void ForceResync_deletes_chain_databases()
42+
{
43+
DatabasePurger.Purge(_tempDir, preserveNetwork: true, _logger);
44+
45+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.State)), Is.False, "state should be deleted");
46+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Blocks)), Is.False, "blocks should be deleted");
47+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Headers)), Is.False, "headers should be deleted");
48+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Receipts)), Is.False, "receipts should be deleted");
49+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.BlockInfos)), Is.False, "blockInfos should be deleted");
50+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.BlockNumbers)), Is.False, "blockNumbers should be deleted");
51+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.BlockAccessLists)), Is.False, "blockAccessLists should be deleted");
52+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Metadata)), Is.False, "metadata should be deleted");
53+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Flat)), Is.False, "flat should be deleted");
54+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Code)), Is.False, "code should be deleted");
55+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.Bloom)), Is.False, "bloom should be deleted");
56+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.BadBlocks)), Is.False, "badBlocks should be deleted");
57+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.BlobTransactions)), Is.False, "blobTransactions should be deleted");
58+
}
59+
60+
[Test]
61+
public void ForceResync_deletes_loose_files()
62+
{
63+
File.WriteAllText(Path.Combine(_tempDir, "LOCK"), "");
64+
File.WriteAllText(Path.Combine(_tempDir, "CURRENT"), "");
65+
66+
DatabasePurger.Purge(_tempDir, preserveNetwork: true, _logger);
67+
68+
Assert.That(Directory.EnumerateFiles(_tempDir), Is.Empty, "loose files should be deleted");
69+
}
70+
71+
[Test]
72+
public void PurgeDb_deletes_entire_directory()
73+
{
74+
DatabasePurger.Purge(_tempDir, preserveNetwork: false, _logger);
75+
76+
Assert.That(Directory.Exists(_tempDir), Is.False, "entire directory should be deleted");
77+
}
78+
79+
[Test]
80+
public void PurgeDb_deletes_network_directories()
81+
{
82+
DatabasePurger.Purge(_tempDir, preserveNetwork: false, _logger);
83+
84+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.PeersDb)), Is.False);
85+
Assert.That(Directory.Exists(Path.Combine(_tempDir, DbNames.DiscoveryNodes)), Is.False);
86+
}
87+
88+
[Test]
89+
public void Purge_does_nothing_for_nonexistent_directory()
90+
{
91+
string missing = Path.Combine(Path.GetTempPath(), $"missing-{Path.GetRandomFileName()}");
92+
93+
Assert.DoesNotThrow(() => DatabasePurger.Purge(missing, preserveNetwork: false, _logger));
94+
Assert.DoesNotThrow(() => DatabasePurger.Purge(missing, preserveNetwork: true, _logger));
95+
}
96+
97+
private static void CreateTestDbLayout(string basePath)
98+
{
99+
// Chain databases
100+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.State));
101+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Blocks));
102+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Headers));
103+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Receipts));
104+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.BlockInfos));
105+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.BlockNumbers));
106+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Metadata));
107+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Flat));
108+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Code));
109+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.Bloom));
110+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.BadBlocks));
111+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.BlobTransactions));
112+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.BlockAccessLists));
113+
114+
// Network databases (should be preserved by --force-resync)
115+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.PeersDb));
116+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.DiscoveryNodes));
117+
Directory.CreateDirectory(Path.Combine(basePath, DbNames.DiscoveryV5Nodes));
118+
119+
// Add a dummy file in each to make them non-empty
120+
foreach (string dir in Directory.EnumerateDirectories(basePath))
121+
{
122+
File.WriteAllText(Path.Combine(dir, "dummy.dat"), "test");
123+
}
124+
}
125+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// SPDX-FileCopyrightText: 2026 Demerzel Solutions Limited
2+
// SPDX-License-Identifier: LGPL-3.0-only
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using Nethermind.Db;
8+
using Nethermind.Logging;
9+
10+
namespace Nethermind.Runner;
11+
12+
internal static class DatabasePurger
13+
{
14+
private static readonly HashSet<string> NetworkDbNames = new(StringComparer.OrdinalIgnoreCase)
15+
{
16+
DbNames.PeersDb,
17+
DbNames.DiscoveryNodes,
18+
DbNames.DiscoveryV5Nodes
19+
};
20+
21+
/// <summary>
22+
/// Deletes database files from <paramref name="basePath"/>.
23+
/// When <paramref name="preserveNetwork"/> is true, peer and discovery directories are kept.
24+
/// </summary>
25+
public static void Purge(string basePath, bool preserveNetwork, ILogger logger)
26+
{
27+
string fullPath = Path.GetFullPath(basePath);
28+
29+
// Safety guard: refuse to delete filesystem roots
30+
if (Path.GetPathRoot(fullPath) == fullPath)
31+
{
32+
logger.Error($"Refusing to delete path that looks like a filesystem root: {fullPath}");
33+
return;
34+
}
35+
36+
if (!Directory.Exists(fullPath))
37+
return;
38+
39+
string action = preserveNetwork ? "Force resync" : "Purge";
40+
41+
try
42+
{
43+
if (preserveNetwork)
44+
{
45+
foreach (string dir in Directory.EnumerateDirectories(fullPath))
46+
{
47+
if (!NetworkDbNames.Contains(Path.GetFileName(dir)))
48+
{
49+
if (logger.IsInfo) logger.Info($"{action}: deleting {dir}");
50+
Directory.Delete(dir, recursive: true);
51+
}
52+
}
53+
54+
foreach (string file in Directory.EnumerateFiles(fullPath))
55+
{
56+
if (logger.IsInfo) logger.Info($"{action}: deleting {file}");
57+
File.Delete(file);
58+
}
59+
}
60+
else
61+
{
62+
if (logger.IsInfo) logger.Info($"{action}: deleting {fullPath}");
63+
Directory.Delete(fullPath, recursive: true);
64+
}
65+
}
66+
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
67+
{
68+
logger.Error($"{action} failed on {fullPath}", ex);
69+
throw new InvalidOperationException($"Database {action.ToLowerInvariant()} failed. Some files may be locked or read-only.", ex);
70+
}
71+
}
72+
}

src/Nethermind/Nethermind.Runner/Nethermind.Runner.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
<UserSecretsId>03db39d0-4200-473e-9ff8-4a48d496381f</UserSecretsId>
1717
</PropertyGroup>
1818

19+
<ItemGroup>
20+
<InternalsVisibleTo Include="Nethermind.Runner.Test" />
21+
</ItemGroup>
22+
1923
<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
2024
<PublishReadyToRun>true</PublishReadyToRun>
2125
<PublishReadyToRunComposite>true</PublishReadyToRunComposite>

src/Nethermind/Nethermind.Runner/Program.cs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ async Task<int> RunAsync(ParseResult parseResult, PluginLoader pluginLoader, Can
173173
ConfigureSeqLogger(configProvider);
174174
ResolveDatabaseDirectory(parseResult.GetValue(BasicOptions.DatabasePath), initConfig);
175175

176+
if (parseResult.GetValue(BasicOptions.PurgeDb))
177+
{
178+
PurgeDatabaseDirectory(initConfig.BaseDbPath);
179+
}
180+
else if (parseResult.GetValue(BasicOptions.ForceResync))
181+
{
182+
PurgeDatabaseDirectory(initConfig.BaseDbPath, preserveNetwork: true);
183+
}
184+
176185
logger.Info("Configuration complete");
177186

178187
EthereumJsonSerializer serializer = new();
@@ -425,9 +434,11 @@ RootCommand CreateRootCommand()
425434
BasicOptions.ConfigurationDirectory,
426435
BasicOptions.DatabasePath,
427436
BasicOptions.DataDirectory,
437+
BasicOptions.ForceResync,
428438
BasicOptions.LoggerConfigurationSource,
429439
BasicOptions.LogLevel,
430-
BasicOptions.PluginsDirectory
440+
BasicOptions.PluginsDirectory,
441+
BasicOptions.PurgeDb
431442
];
432443

433444
rootCommand.Description = "Nethermind Ethereum execution client";
@@ -483,6 +494,9 @@ void ResolveDatabaseDirectory(string? path, IInitConfig initConfig)
483494
}
484495
}
485496

497+
void PurgeDatabaseDirectory(string basePath, bool preserveNetwork = false) =>
498+
DatabasePurger.Purge(basePath, preserveNetwork, logger);
499+
486500
void ResolveDataDirectory(string? path, IInitConfig initConfig, IKeyStoreConfig keyStoreConfig, ISnapshotConfig snapshotConfig)
487501
{
488502
if (string.IsNullOrWhiteSpace(path))
@@ -557,12 +571,37 @@ static class BasicOptions
557571
HelpName = "level"
558572
};
559573

574+
public static Option<bool> ForceResync { get; } = CreateForceResyncOption();
575+
576+
private static Option<bool> CreateForceResyncOption()
577+
{
578+
Option<bool> option = new("--force-resync")
579+
{
580+
Description = "Deletes all database files except peer and discovery data, forcing a full resync on startup.",
581+
DefaultValueFactory = _ => false
582+
};
583+
584+
option.Validators.Add(result =>
585+
{
586+
if (result.GetValueOrDefault<bool>() && result.Parent?.GetValue(PurgeDb) == true)
587+
result.AddError("Cannot use --force-resync and --purge-db together. Choose one.");
588+
});
589+
590+
return option;
591+
}
592+
560593
public static Option<string> PluginsDirectory { get; } =
561594
new("--plugins-dir", "--pluginsDirectory", "-pd")
562595
{
563596
Description = "The path to the Nethermind plugins directory.",
564597
HelpName = "path"
565598
};
599+
600+
public static Option<bool> PurgeDb { get; } = new("--purge-db")
601+
{
602+
Description = "Deletes the entire database directory, including peer and discovery data.",
603+
DefaultValueFactory = _ => false
604+
};
566605
}
567606

568607
class AsynchronousCommandLineAction(Func<ParseResult, int> action) : SynchronousCommandLineAction

0 commit comments

Comments
 (0)