Skip to content

Commit 93db435

Browse files
CopilotachamayouCopiloteddyashton
authored
Add configurable backup snapshot fetching from primary (#7695)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> Co-authored-by: Amaury Chamayou <amchamay@microsoft.com> Co-authored-by: Amaury Chamayou <amaury@xargs.fr> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Eddy Ashton <edashton@microsoft.com>
1 parent e0872fe commit 93db435

File tree

11 files changed

+444
-8
lines changed

11 files changed

+444
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
### Added
1313

14+
- Backup nodes can now be configured to automatically fetch snapshots from the primary when snapshot evidence is detected. This is controlled by the `snapshots.backup_fetch` configuration section, with `enabled`, `max_attempts`, `retry_interval`, `max_size` and `target_rpc_interface` options. Note that the target RPC interface selected must have the `SnapshotRead` operator feature enabled.
1415
- Added `ccf::IdentityHistoryNotFetched` exception type to distinguish identity-history-fetching errors from other logic errors in the network identity subsystem (#7708).
1516
- Added `ccf::describe_cose_receipt_v1(receipt)` to obtain COSE receipts with Merkle proof in unprotected header for non-signature TXs, and empty unprotected header for signature TXs (#7700).
1617
- `NetworkIdentitySubsystemInterface` now exposes `get_trusted_keys()`, returning all trusted network identity keys as a `TrustedKeys` map (#7690).

doc/host_config_schema/cchost_config.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,39 @@
498498
"read_only_directory": {
499499
"type": ["string", "null"],
500500
"description": "Path to read-only snapshots directory"
501+
},
502+
"backup_fetch": {
503+
"type": "object",
504+
"properties": {
505+
"enabled": {
506+
"type": "boolean",
507+
"default": false,
508+
"description": "If true, backup nodes will automatically fetch snapshots from the primary when snapshot evidence is detected"
509+
},
510+
"max_attempts": {
511+
"type": "integer",
512+
"default": 3,
513+
"description": "Maximum number of fetch attempts before giving up",
514+
"minimum": 1
515+
},
516+
"retry_interval": {
517+
"type": "string",
518+
"default": "1000ms",
519+
"description": "Delay between retry attempts"
520+
},
521+
"target_rpc_interface": {
522+
"type": "string",
523+
"default": "primary_rpc_interface",
524+
"description": "Name of the RPC interface on the primary node to use for downloading snapshots. Must have the SnapshotRead feature enabled."
525+
},
526+
"max_size": {
527+
"type": "string",
528+
"default": "200MB",
529+
"description": "Maximum size of snapshot this node is willing to fetch"
530+
}
531+
},
532+
"description": "Configuration for automatic snapshot fetching by backup nodes",
533+
"additionalProperties": false
501534
}
502535
},
503536
"description": "This section includes configuration for the snapshot directories and files",

include/ccf/node/startup_config.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ namespace ccf
9999
size_t tx_count = 10'000;
100100
std::optional<std::string> read_only_directory = std::nullopt;
101101

102+
struct BackupFetch
103+
{
104+
bool enabled = false;
105+
size_t max_attempts = 3;
106+
ccf::ds::TimeString retry_interval = {"1000ms"};
107+
std::string target_rpc_interface = ccf::PRIMARY_RPC_INTERFACE;
108+
ccf::ds::SizeString max_size = {"200MB"};
109+
110+
bool operator==(const BackupFetch&) const = default;
111+
};
112+
BackupFetch backup_fetch = {};
113+
102114
bool operator==(const Snapshots&) const = default;
103115
};
104116
Snapshots snapshots = {};

src/common/configuration.h

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,24 @@ namespace ccf
9393
snp_uvm_endorsements_file,
9494
snp_endorsements_file);
9595

96+
DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::Snapshots::BackupFetch);
97+
DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::Snapshots::BackupFetch);
98+
DECLARE_JSON_OPTIONAL_FIELDS(
99+
CCFConfig::Snapshots::BackupFetch,
100+
enabled,
101+
max_attempts,
102+
retry_interval,
103+
target_rpc_interface,
104+
max_size);
105+
96106
DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::Snapshots);
97107
DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::Snapshots);
98108
DECLARE_JSON_OPTIONAL_FIELDS(
99-
CCFConfig::Snapshots, directory, tx_count, read_only_directory);
109+
CCFConfig::Snapshots,
110+
directory,
111+
tx_count,
112+
read_only_directory,
113+
backup_fetch);
100114

101115
DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig);
102116
DECLARE_JSON_REQUIRED_FIELDS(CCFConfig, network);

src/node/node_state.h

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
#include <algorithm>
6767
#include <atomic>
6868
#include <chrono>
69+
#include <limits>
6970
#define FMT_HEADER_ONLY
7071
#include <fmt/format.h>
7172
#include <nlohmann/json.hpp>
@@ -202,6 +203,153 @@ namespace ccf
202203
}
203204
};
204205

206+
struct BackupSnapshotFetch : public ccf::tasks::BaseTask
207+
{
208+
const ccf::CCFConfig::Snapshots snapshot_config;
209+
ccf::kv::Version since_seqno;
210+
NodeState* owner;
211+
212+
BackupSnapshotFetch(
213+
ccf::CCFConfig::Snapshots snapshot_config_,
214+
ccf::kv::Version since_seqno_,
215+
NodeState* owner_) :
216+
snapshot_config(std::move(snapshot_config_)),
217+
since_seqno(since_seqno_),
218+
owner(owner_)
219+
{}
220+
221+
void do_task_implementation() override
222+
{
223+
struct ClearOnExit
224+
{
225+
NodeState* owner;
226+
~ClearOnExit()
227+
{
228+
std::lock_guard<pal::Mutex> guard(owner->lock);
229+
owner->backup_snapshot_fetch_task = nullptr;
230+
}
231+
} clear_on_exit{owner};
232+
233+
// Resolve the primary's RPC address
234+
std::string primary_address;
235+
std::vector<uint8_t> service_cert;
236+
{
237+
auto primary_id = owner->consensus->primary();
238+
if (!primary_id.has_value())
239+
{
240+
LOG_INFO_FMT(
241+
"BackupSnapshotFetch: No known primary, skipping fetch");
242+
return;
243+
}
244+
245+
auto tx = owner->network.tables->create_read_only_tx();
246+
auto* nodes = tx.ro<ccf::Nodes>(Tables::NODES);
247+
auto node_info = nodes->get(primary_id.value());
248+
if (!node_info.has_value())
249+
{
250+
LOG_INFO_FMT(
251+
"BackupSnapshotFetch: Could not find primary node {} in nodes "
252+
"table",
253+
primary_id.value());
254+
return;
255+
}
256+
257+
// Use the configured RPC interface to find the primary's address
258+
const auto& target_interface =
259+
snapshot_config.backup_fetch.target_rpc_interface;
260+
auto iface_it = node_info->rpc_interfaces.find(target_interface);
261+
if (iface_it == node_info->rpc_interfaces.end())
262+
{
263+
LOG_INFO_FMT(
264+
"BackupSnapshotFetch: Primary node {} does not have RPC "
265+
"interface '{}' configured",
266+
primary_id.value(),
267+
target_interface);
268+
return;
269+
}
270+
primary_address = iface_it->second.published_address;
271+
272+
if (owner->network.identity == nullptr)
273+
{
274+
LOG_INFO_FMT(
275+
"BackupSnapshotFetch: No service identity available, cannot "
276+
"construct TLS credentials for fetching snapshot");
277+
return;
278+
}
279+
280+
service_cert = owner->network.identity->cert.raw();
281+
}
282+
283+
LOG_INFO_FMT(
284+
"BackupSnapshotFetch: Attempting to fetch snapshot from primary at "
285+
"{}",
286+
primary_address);
287+
288+
const auto& bf = snapshot_config.backup_fetch;
289+
290+
auto latest_peer_snapshot = snapshots::fetch_from_peer(
291+
primary_address,
292+
service_cert,
293+
bf.max_attempts,
294+
bf.retry_interval.count_ms(),
295+
bf.max_size.count_bytes(),
296+
since_seqno);
297+
298+
if (latest_peer_snapshot.has_value())
299+
{
300+
LOG_INFO_FMT(
301+
"BackupSnapshotFetch: Received snapshot {} from primary (size: "
302+
"{})",
303+
latest_peer_snapshot->snapshot_name,
304+
latest_peer_snapshot->snapshot_data.size());
305+
306+
const auto snapshot_path =
307+
std::filesystem::path(latest_peer_snapshot->snapshot_name);
308+
309+
if (
310+
snapshot_path.empty() || snapshot_path.is_absolute() ||
311+
snapshot_path.has_parent_path() ||
312+
snapshot_path.filename() != snapshot_path)
313+
{
314+
LOG_FAIL_FMT(
315+
"BackupSnapshotFetch: Rejecting snapshot with invalid name "
316+
"'{}' from primary",
317+
latest_peer_snapshot->snapshot_name);
318+
return;
319+
}
320+
321+
const auto dst_path =
322+
std::filesystem::path(snapshot_config.directory) / snapshot_path;
323+
324+
if (files::exists(dst_path))
325+
{
326+
LOG_INFO_FMT(
327+
"BackupSnapshotFetch: Snapshot {} already exists locally, "
328+
"skipping write",
329+
dst_path.string());
330+
return;
331+
}
332+
333+
files::dump(latest_peer_snapshot->snapshot_data, dst_path);
334+
LOG_INFO_FMT(
335+
"BackupSnapshotFetch: Wrote snapshot {} ({} bytes)",
336+
dst_path.string(),
337+
latest_peer_snapshot->snapshot_data.size());
338+
}
339+
else
340+
{
341+
LOG_INFO_FMT(
342+
"BackupSnapshotFetch: No snapshot available from primary");
343+
}
344+
}
345+
346+
[[nodiscard]] const std::string& get_name() const override
347+
{
348+
static const std::string name = "BackupSnapshotFetch";
349+
return name;
350+
}
351+
};
352+
205353
private:
206354
//
207355
// this node's core state
@@ -280,6 +428,7 @@ namespace ccf
280428

281429
ccf::tasks::Task join_periodic_task;
282430
ccf::tasks::Task snapshot_fetch_task;
431+
ccf::tasks::Task backup_snapshot_fetch_task;
283432

284433
std::shared_ptr<ccf::kv::AbstractTxEncryptor> make_encryptor()
285434
{
@@ -2924,6 +3073,49 @@ namespace ccf
29243073
return {nullptr};
29253074
}));
29263075

3076+
network.tables->set_global_hook(
3077+
network.snapshot_evidence.get_name(),
3078+
SnapshotEvidence::wrap_commit_hook(
3079+
[this](
3080+
[[maybe_unused]] ccf::kv::Version version,
3081+
const SnapshotEvidence::Write& w) {
3082+
if (!w.has_value())
3083+
{
3084+
return;
3085+
}
3086+
3087+
auto snapshot_evidence = w.value();
3088+
3089+
// If backup snapshot fetching is enabled and this node is a
3090+
// backup, schedule a fetch task
3091+
if (
3092+
config.snapshots.backup_fetch.enabled && consensus != nullptr &&
3093+
!consensus->is_primary())
3094+
{
3095+
std::lock_guard<pal::Mutex> guard(lock);
3096+
if (
3097+
backup_snapshot_fetch_task != nullptr &&
3098+
!backup_snapshot_fetch_task->is_cancelled())
3099+
{
3100+
LOG_DEBUG_FMT(
3101+
"Backup snapshot fetch already in progress, skipping");
3102+
}
3103+
else
3104+
{
3105+
LOG_INFO_FMT(
3106+
"Snapshot evidence detected on backup - scheduling "
3107+
"snapshot fetch from primary (since seqno: {})",
3108+
snapshot_evidence.version);
3109+
backup_snapshot_fetch_task =
3110+
std::make_shared<BackupSnapshotFetch>(
3111+
config.snapshots,
3112+
snapshot_evidence.version - 1 /* YIKES */,
3113+
this);
3114+
ccf::tasks::add_task(backup_snapshot_fetch_task);
3115+
}
3116+
}
3117+
}));
3118+
29273119
setup_basic_hooks();
29283120
}
29293121

src/snapshots/fetch.h

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ namespace snapshots
207207
static std::optional<SnapshotResponse> try_fetch_from_peer(
208208
const std::string& peer_address,
209209
const std::vector<uint8_t>& peer_ca,
210-
size_t max_size)
210+
size_t max_size,
211+
std::optional<size_t> since_seqno = std::nullopt)
211212
{
212213
try
213214
{
@@ -222,8 +223,16 @@ namespace snapshots
222223
// redirects terminate, the final response is likely to be extremely
223224
// large so is fetched over multiple requests for a sub-range, returning
224225
// PARTIAL_CONTENT each time.
225-
std::string snapshot_url =
226-
fmt::format("https://{}/node/snapshot", peer_address);
226+
std::string snapshot_url;
227+
if (since_seqno.has_value())
228+
{
229+
snapshot_url = fmt::format(
230+
"https://{}/node/snapshot?since={}", peer_address, *since_seqno);
231+
}
232+
else
233+
{
234+
snapshot_url = fmt::format("https://{}/node/snapshot", peer_address);
235+
}
227236

228237
// Fetch 4MB chunks at a time
229238
constexpr size_t range_size = 4L * 1024 * 1024;
@@ -440,13 +449,15 @@ namespace snapshots
440449
const std::vector<uint8_t>& peer_ca,
441450
size_t max_attempts,
442451
size_t retry_delay_ms,
443-
size_t max_size)
452+
size_t max_size,
453+
std::optional<size_t> since_seqno = std::nullopt)
444454
{
445455
for (size_t attempt = 0; attempt < max_attempts; ++attempt)
446456
{
447457
LOG_INFO_FMT(
448-
"Fetching snapshot from {} (attempt {}/{})",
458+
"Fetching snapshot from {} since {} (attempt {}/{})",
449459
peer_address,
460+
since_seqno.has_value() ? std::to_string(*since_seqno) : "any",
450461
attempt + 1,
451462
max_attempts);
452463

@@ -455,7 +466,8 @@ namespace snapshots
455466
std::this_thread::sleep_for(std::chrono::milliseconds(retry_delay_ms));
456467
}
457468

458-
auto response = try_fetch_from_peer(peer_address, peer_ca, max_size);
469+
auto response =
470+
try_fetch_from_peer(peer_address, peer_ca, max_size, since_seqno);
459471
if (response.has_value())
460472
{
461473
return response;

tests/config.jinja

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@
6363
{
6464
"directory": "{{ snapshots_dir }}",
6565
"tx_count": {{ snapshot_tx_interval }},
66-
"read_only_directory": {{ read_only_snapshots_dir|tojson }}
66+
"read_only_directory": {{ read_only_snapshots_dir|tojson }}{% if backup_snapshot_fetch_enabled %},
67+
"backup_fetch": {
68+
"enabled": true{% if backup_snapshot_fetch_max_attempts %},
69+
"max_attempts": {{ backup_snapshot_fetch_max_attempts }}{% endif %}{% if backup_snapshot_fetch_retry_interval %},
70+
"retry_interval": "{{ backup_snapshot_fetch_retry_interval }}"{% endif %}{% if backup_snapshot_fetch_target_rpc_interface %},
71+
"target_rpc_interface": "{{ backup_snapshot_fetch_target_rpc_interface }}"{% endif %}{% if backup_snapshot_fetch_max_size %},
72+
"max_size": "{{ backup_snapshot_fetch_max_size }}"{% endif %}
73+
}{% endif %}
6774
},
6875
"logging":
6976
{

0 commit comments

Comments
 (0)