Skip to content

Commit ccd211c

Browse files
authored
Add stellar snapshot merge to merge several snapshots into one file. (#2304)
1 parent e7dd939 commit ccd211c

File tree

5 files changed

+457
-4
lines changed

5 files changed

+457
-4
lines changed

FULL_HELP_DOCS.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1593,6 +1593,7 @@ Download a snapshot of a ledger from an archive
15931593
###### **Subcommands:**
15941594

15951595
- `create` — Create a ledger snapshot using a history archive
1596+
- `merge` — Merge multiple ledger snapshots into a single snapshot file
15961597

15971598
## `stellar snapshot create`
15981599

@@ -1608,7 +1609,7 @@ If a contract is a Stellar asset contract, it includes the asset issuer's accoun
16081609

16091610
Any invalid contract id passed as `--address` will be ignored.
16101611

1611-
**Usage:** `stellar snapshot create [OPTIONS] --output <OUTPUT>`
1612+
**Usage:** `stellar snapshot create [OPTIONS]`
16121613

16131614
###### **Filter Options:**
16141615

@@ -1620,6 +1621,8 @@ Any invalid contract id passed as `--address` will be ignored.
16201621
- `--ledger <LEDGER>` — The ledger sequence number to snapshot. Defaults to latest history archived ledger
16211622
- `--output <OUTPUT>` — Format of the out file
16221623

1624+
Default value: `json`
1625+
16231626
Possible values: `json`
16241627

16251628
- `--out <OUT>` — Out path that the snapshot is written to
@@ -1642,6 +1645,28 @@ Any invalid contract id passed as `--address` will be ignored.
16421645
- `--network-passphrase <NETWORK_PASSPHRASE>` — Network passphrase to sign the transaction sent to the rpc server
16431646
- `-n`, `--network <NETWORK>` — Name of network to use from config
16441647

1648+
## `stellar snapshot merge`
1649+
1650+
Merge multiple ledger snapshots into a single snapshot file.
1651+
1652+
When the same ledger key appears in multiple snapshots, the entry from the last snapshot in the argument list takes precedence. Metadata (protocol_version, sequence_number, timestamp, etc.) is taken from the last snapshot.
1653+
1654+
Example: stellar snapshot merge A.json B.json --out merged.json
1655+
1656+
This allows combining snapshots from different contract deployments or manually edited snapshots without regenerating from scratch.
1657+
1658+
**Usage:** `stellar snapshot merge [OPTIONS] <SNAPSHOTS> <SNAPSHOTS>...`
1659+
1660+
###### **Arguments:**
1661+
1662+
- `<SNAPSHOTS>` — Snapshot files to merge (at least 2 required)
1663+
1664+
###### **Options:**
1665+
1666+
- `-o`, `--out <OUT>` — Output path for the merged snapshot
1667+
1668+
Default value: `snapshot.json`
1669+
16451670
## `stellar tx`
16461671

16471672
Sign, Simulate, and Send transactions

cmd/crates/soroban-test/tests/it/integration/snapshot.rs

Lines changed: 294 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use assert_fs::prelude::*;
22
use predicates::prelude::*;
3+
use soroban_ledger_snapshot::LedgerSnapshot;
34
use soroban_test::{AssertExt, TestEnv};
45

56
#[test]
@@ -67,7 +68,6 @@ fn snapshot() {
6768
sandbox
6869
.new_assert_cmd("snapshot")
6970
.arg("create")
70-
.arg("--output=json")
7171
.arg("--address")
7272
.arg(&account_a)
7373
.arg("--address")
@@ -84,3 +84,296 @@ fn snapshot() {
8484
.assert(predicates::str::contains(&contract_b))
8585
.assert(predicates::str::contains(&contract_a).not());
8686
}
87+
88+
#[test]
89+
#[allow(clippy::too_many_lines)]
90+
fn snapshot_merge() {
91+
let sandbox = &TestEnv::new();
92+
93+
// Create accounts and contracts for two separate snapshots
94+
sandbox
95+
.new_assert_cmd("keys")
96+
.arg("generate")
97+
.arg("--fund")
98+
.arg("a")
99+
.assert()
100+
.success();
101+
let account_a = sandbox
102+
.new_assert_cmd("keys")
103+
.arg("address")
104+
.arg("a")
105+
.assert()
106+
.success()
107+
.stdout_as_str();
108+
109+
sandbox
110+
.new_assert_cmd("keys")
111+
.arg("generate")
112+
.arg("--fund")
113+
.arg("b")
114+
.assert()
115+
.success();
116+
let account_b = sandbox
117+
.new_assert_cmd("keys")
118+
.arg("address")
119+
.arg("b")
120+
.assert()
121+
.success()
122+
.stdout_as_str();
123+
124+
let contract_a = sandbox
125+
.new_assert_cmd("contract")
126+
.arg("asset")
127+
.arg("deploy")
128+
.arg(format!("--asset=A1:{account_a}"))
129+
.assert()
130+
.success()
131+
.stdout_as_str();
132+
133+
let contract_b = sandbox
134+
.new_assert_cmd("contract")
135+
.arg("asset")
136+
.arg("deploy")
137+
.arg(format!("--asset=A2:{account_b}"))
138+
.assert()
139+
.success()
140+
.stdout_as_str();
141+
142+
// Wait 8 ledgers for a checkpoint
143+
for i in 1..=8 {
144+
sandbox
145+
.new_assert_cmd("keys")
146+
.arg("generate")
147+
.arg("--fund")
148+
.arg(format!("k{i}"))
149+
.assert()
150+
.success();
151+
}
152+
153+
// Create first snapshot with account_a and contract_a
154+
sandbox
155+
.new_assert_cmd("snapshot")
156+
.arg("create")
157+
.arg("--address")
158+
.arg(&account_a)
159+
.arg("--address")
160+
.arg(&contract_a)
161+
.arg("--out=snapshot_a.json")
162+
.assert()
163+
.success();
164+
165+
// Create second snapshot with account_b and contract_b
166+
sandbox
167+
.new_assert_cmd("snapshot")
168+
.arg("create")
169+
.arg("--address")
170+
.arg(&account_b)
171+
.arg("--address")
172+
.arg(&contract_b)
173+
.arg("--out=snapshot_b.json")
174+
.assert()
175+
.success();
176+
177+
// Merge the two snapshots
178+
sandbox
179+
.new_assert_cmd("snapshot")
180+
.arg("merge")
181+
.arg("snapshot_a.json")
182+
.arg("snapshot_b.json")
183+
.arg("--out=merged.json")
184+
.assert()
185+
.success();
186+
187+
// Verify the merged snapshot contains all accounts and contracts
188+
sandbox
189+
.dir()
190+
.child("merged.json")
191+
.assert(predicates::str::contains(&account_a))
192+
.assert(predicates::str::contains(&account_b))
193+
.assert(predicates::str::contains(&contract_a))
194+
.assert(predicates::str::contains(&contract_b));
195+
196+
let snapshot_a_path = sandbox.dir().join("snapshot_a.json");
197+
let snapshot_b_path = sandbox.dir().join("snapshot_b.json");
198+
let merged_path = sandbox.dir().join("merged.json");
199+
200+
let snapshot_a = LedgerSnapshot::read_file(snapshot_a_path).unwrap();
201+
let snapshot_b = LedgerSnapshot::read_file(snapshot_b_path).unwrap();
202+
let merged = LedgerSnapshot::read_file(merged_path).unwrap();
203+
204+
assert_eq!(merged.protocol_version, snapshot_b.protocol_version);
205+
assert_eq!(merged.sequence_number, snapshot_b.sequence_number);
206+
assert_eq!(merged.timestamp, snapshot_b.timestamp);
207+
assert_eq!(merged.network_id, snapshot_b.network_id);
208+
209+
// Verify that we have more entries in merged than in either individual snapshot
210+
assert!(merged.ledger_entries.len() > snapshot_a.ledger_entries.len());
211+
assert!(merged.ledger_entries.len() > snapshot_b.ledger_entries.len());
212+
}
213+
214+
#[test]
215+
fn snapshot_merge_conflict_resolution() {
216+
let sandbox = &TestEnv::new();
217+
let identity = "ineffable-serval-3633";
218+
219+
// Create an account
220+
sandbox
221+
.new_assert_cmd("keys")
222+
.arg("generate")
223+
.arg("--fund")
224+
.arg(identity)
225+
.assert()
226+
.success();
227+
let account = sandbox
228+
.new_assert_cmd("keys")
229+
.arg("address")
230+
.arg(identity)
231+
.assert()
232+
.success()
233+
.stdout_as_str();
234+
235+
// Wait 8 ledgers for a checkpoint
236+
for i in 1..=8 {
237+
sandbox
238+
.new_assert_cmd("keys")
239+
.arg("generate")
240+
.arg("--fund")
241+
.arg(format!("k{i}"))
242+
.assert()
243+
.success();
244+
}
245+
246+
// Create first snapshot with the account
247+
sandbox
248+
.new_assert_cmd("snapshot")
249+
.arg("create")
250+
.arg("--address")
251+
.arg(&account)
252+
.arg("--out=snapshot_1.json")
253+
.assert()
254+
.success();
255+
256+
// Wait for another checkpoint to get a different ledger sequence
257+
for i in 9..=16 {
258+
sandbox
259+
.new_assert_cmd("keys")
260+
.arg("generate")
261+
.arg("--fund")
262+
.arg(format!("k{i}"))
263+
.assert()
264+
.success();
265+
}
266+
267+
// Create second snapshot with the same account at a later ledger sequence
268+
sandbox
269+
.new_assert_cmd("snapshot")
270+
.arg("create")
271+
.arg("--address")
272+
.arg(&account)
273+
.arg("--out=snapshot_2.json")
274+
.assert()
275+
.success();
276+
277+
// Merge the snapshots - snapshot_2 should win
278+
sandbox
279+
.new_assert_cmd("snapshot")
280+
.arg("merge")
281+
.arg("snapshot_1.json")
282+
.arg("snapshot_2.json")
283+
.arg("--out=merged_conflict.json")
284+
.assert()
285+
.success();
286+
287+
// Read snapshots and verify the merged one has the same sequence as snapshot_2
288+
let snapshot_2_path = sandbox.dir().join("snapshot_2.json");
289+
let merged_path = sandbox.dir().join("merged_conflict.json");
290+
291+
let snapshot_2 = LedgerSnapshot::read_file(snapshot_2_path).unwrap();
292+
let merged = LedgerSnapshot::read_file(merged_path).unwrap();
293+
294+
// The merged snapshot should have metadata from snapshot_2 (last wins)
295+
assert_eq!(merged.sequence_number, snapshot_2.sequence_number);
296+
assert!(merged.sequence_number > 0);
297+
}
298+
299+
#[test]
300+
fn snapshot_merge_multiple() {
301+
let sandbox = &TestEnv::new();
302+
303+
// Create three accounts
304+
let mut accounts = Vec::new();
305+
for name in ["x", "y", "z"] {
306+
sandbox
307+
.new_assert_cmd("keys")
308+
.arg("generate")
309+
.arg("--fund")
310+
.arg(name)
311+
.assert()
312+
.success();
313+
let account = sandbox
314+
.new_assert_cmd("keys")
315+
.arg("address")
316+
.arg(name)
317+
.assert()
318+
.success()
319+
.stdout_as_str();
320+
accounts.push(account.trim().to_string());
321+
}
322+
323+
// Wait 8 ledgers for a checkpoint
324+
for i in 1..=8 {
325+
sandbox
326+
.new_assert_cmd("keys")
327+
.arg("generate")
328+
.arg("--fund")
329+
.arg(format!("k{i}"))
330+
.assert()
331+
.success();
332+
}
333+
334+
// Create three snapshots, one for each account
335+
for (i, account) in accounts.iter().enumerate() {
336+
sandbox
337+
.new_assert_cmd("snapshot")
338+
.arg("create")
339+
.arg("--address")
340+
.arg(account)
341+
.arg(format!("--out=snapshot_{}.json", i))
342+
.assert()
343+
.success();
344+
}
345+
346+
// Merge all three snapshots at once
347+
sandbox
348+
.new_assert_cmd("snapshot")
349+
.arg("merge")
350+
.arg("snapshot_0.json")
351+
.arg("snapshot_1.json")
352+
.arg("snapshot_2.json")
353+
.arg("--out=merged_multiple.json")
354+
.assert()
355+
.success();
356+
357+
// Read the individual snapshots and merged snapshot to verify
358+
let snapshot_0_path = sandbox.dir().join("snapshot_0.json");
359+
let snapshot_1_path = sandbox.dir().join("snapshot_1.json");
360+
let snapshot_2_path = sandbox.dir().join("snapshot_2.json");
361+
let merged_path = sandbox.dir().join("merged_multiple.json");
362+
363+
let snapshot_0 = LedgerSnapshot::read_file(snapshot_0_path).unwrap();
364+
let snapshot_1 = LedgerSnapshot::read_file(snapshot_1_path).unwrap();
365+
let snapshot_2 = LedgerSnapshot::read_file(snapshot_2_path).unwrap();
366+
let merged = LedgerSnapshot::read_file(merged_path).unwrap();
367+
368+
// Verify that metadata comes from the last snapshot (snapshot_2)
369+
assert_eq!(merged.sequence_number, snapshot_2.sequence_number);
370+
assert_eq!(merged.network_id, snapshot_2.network_id);
371+
372+
// Verify that merged has at least as many entries as the largest individual snapshot
373+
let max_individual = snapshot_0
374+
.ledger_entries
375+
.len()
376+
.max(snapshot_1.ledger_entries.len())
377+
.max(snapshot_2.ledger_entries.len());
378+
assert!(merged.ledger_entries.len() >= max_individual);
379+
}

cmd/soroban-cli/src/commands/snapshot/create.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ fn default_out_path() -> PathBuf {
6060
///
6161
#[derive(Parser, Debug, Clone)]
6262
#[group(skip)]
63-
#[command(arg_required_else_help = true)]
6463
pub struct Cmd {
6564
/// The ledger sequence number to snapshot. Defaults to latest history archived ledger.
6665
#[arg(long)]
@@ -75,7 +74,7 @@ pub struct Cmd {
7574
wasm_hashes: Vec<Hash>,
7675

7776
/// Format of the out file.
78-
#[arg(long)]
77+
#[arg(long, value_enum, default_value_t)]
7978
output: Output,
8079

8180
/// Out path that the snapshot is written to.

0 commit comments

Comments
 (0)