-
Notifications
You must be signed in to change notification settings - Fork 2
test(integration): validate a real state-sync round-trip + SDK state-sync node support #472
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,9 @@ package integration | |
| import ( | ||
| "context" | ||
| "net/http" | ||
| "net/url" | ||
| "os/signal" | ||
| "strconv" | ||
| "syscall" | ||
| "testing" | ||
| "time" | ||
|
|
@@ -32,13 +34,16 @@ var snapshotProductionConfig = map[string]string{ | |
| "storage.snapshot_keep_recent": "3", | ||
| } | ||
|
|
||
| // TestWorkflowStateSync drives the SeiNodeTaskWorkflow StateSync recipe through | ||
| // the SDK end to end: provision a chain + one caught-up RPC follower, then | ||
| // CreateWorkflow(StateSync) against the follower and WaitWorkflowTerminal. The | ||
| // recipe holds seid, wipes the follower's data directory, re-configures | ||
| // state-sync against the witnesses, and releases — so a Complete workflow with | ||
| // the follower caught up AGAIN is the re-bootstrap observable (wiped-then-synced, | ||
| // no manual data-directory surgery). Acceptance criterion 5. | ||
| // TestWorkflowStateSync drives the full state-sync procedure through the SDK end | ||
| // to end: provision a snapshot-producing chain, wait past the snapshot interval, | ||
| // then bring up a state-sync-bootstrapped follower and assert it started FROM a | ||
| // snapshot (earliest retained height > 1, not a genesis replay's 1). It then runs | ||
| // the SeiNodeTaskWorkflow StateSync recipe against THAT follower — the recipe | ||
| // holds seid, wipes the data directory, re-configures state-sync against the | ||
| // witnesses, and releases — and asserts a Complete workflow with the follower | ||
| // caught up AGAIN and its earliest height advanced past the pre-wipe floor: proof | ||
| // of a genuine wipe + fresh re-sync to a newer snapshot, not a no-op. Acceptance | ||
| // criterion 5. | ||
| // | ||
| // Witnesses: the recipe's RpcServers are the network's aggregate TM RPC (the | ||
| // validator service), passed as the >=2 the controller's fail-closed floor | ||
|
|
@@ -80,14 +85,75 @@ func TestWorkflowStateSync(t *testing.T) { | |
| if err != nil { | ||
| t.Fatalf("provision: %v", err) | ||
| } | ||
| node := ch.rpcNodes[0] | ||
| hc := &http.Client{Timeout: 10 * time.Second} | ||
|
|
||
| // Re-bootstrap the follower through the StateSync recipe. | ||
| // A follower can only state-sync from a snapshot that already exists. The | ||
| // witness validators emit one every storage.snapshot_interval blocks | ||
| // (snapshotProductionConfig); advance the aggregate RPC one interval + a | ||
| // margin to guarantee at least one snapshot boundary is crossed before we | ||
| // bootstrap from it. Derive the delta from the config so retuning the | ||
| // interval can't silently under-wait and make the state-sync assertion flaky. | ||
| interval, err := strconv.Atoi(snapshotProductionConfig["storage.snapshot_interval"]) | ||
| if err != nil { | ||
| t.Fatalf("snapshot_interval not an int: %v", err) | ||
| } | ||
| if err := sei.WaitHeightAdvances(ctx, hc, ch.network.TendermintRPC(), int64(interval)+10); err != nil { | ||
| t.Fatalf("network did not advance past the snapshot interval: %v", err) | ||
| } | ||
|
|
||
| // Witnesses are the aggregate validator TM RPC as BARE host:port (the CRD | ||
| // SnapshotSource.RpcServers shape); TendermintRPC() is a URL, so strip the | ||
| // scheme. One aggregate witness passed twice satisfies the >=2 floor — | ||
| // CometBFT dedups identical entries; a distinct N-witness topology is a later | ||
| // refinement. | ||
| u, err := url.Parse(ch.network.TendermintRPC()) | ||
| if err != nil { | ||
| t.Fatalf("parse network TM RPC %q: %v", ch.network.TendermintRPC(), err) | ||
| } | ||
| witness := u.Host | ||
|
|
||
| // Bring up a state-sync-bootstrapped follower: it must fetch a snapshot from a | ||
| // peer rather than replay from genesis. Appended to ch.rpcNodes so cleanupChain | ||
| // (registered above, evaluated at teardown) reaps it too. | ||
| ssNode, err := c.CreateNode(ctx, sei.NodeSpec{ | ||
| Name: "statesync-" + chainID, | ||
| Network: chainID, | ||
| Namespace: ns, | ||
| Image: seid, | ||
| Labels: map[string]string{runLabelKey: chainID}, | ||
| StateSync: &sei.NodeStateSync{RpcServers: []string{witness, witness}}, | ||
| }) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| if ssNode != nil { | ||
| ch.rpcNodes = append(ch.rpcNodes, ssNode) | ||
| } | ||
| if err != nil { | ||
| t.Fatalf("create state-sync node: %v", err) | ||
| } | ||
| if err := ssNode.WaitReady(ctx); err != nil { | ||
| t.Fatalf("state-sync node %q running: %v", ssNode.Name(), err) | ||
| } | ||
| if err := sei.WaitCaughtUp(ctx, hc, ssNode.TendermintRPC()); err != nil { | ||
| t.Fatalf("state-sync node %q caught up: %v", ssNode.Name(), err) | ||
| } | ||
|
|
||
| // A state-synced node starts from a snapshot, so its earliest retained height | ||
| // is > 1; a genesis replay would report 1. This is the proof the bootstrap | ||
| // took the state-sync path, and the pre-wipe floor the post-workflow earliest | ||
| // must exceed. | ||
| preEarliest, ok := sei.EarliestHeight(ctx, hc, ssNode.TendermintRPC()) | ||
| if !ok { | ||
| t.Fatalf("read earliest height of %q", ssNode.Name()) | ||
| } | ||
| if preEarliest <= 1 { | ||
| t.Fatalf("state-sync node %q earliest height = %d, want > 1 (a genesis replay reports 1)", ssNode.Name(), preEarliest) | ||
| } | ||
| t.Logf("state-sync node %s: bootstrapped from snapshot (earliest height %d)", ssNode.Name(), preEarliest) | ||
|
|
||
| // Re-bootstrap that state-sync follower through the StateSync recipe. | ||
| wf, err := c.CreateWorkflow(ctx, sei.WorkflowSpec{ | ||
| Name: "resync-" + chainID, | ||
| Namespace: ns, | ||
| Node: node.Name(), | ||
| Node: ssNode.Name(), | ||
| Kind: sei.WorkflowStateSync, | ||
| Labels: map[string]string{runLabelKey: chainID}, | ||
| StateSync: &sei.StateSyncWorkflow{ | ||
|
|
@@ -98,7 +164,7 @@ func TestWorkflowStateSync(t *testing.T) { | |
| t.Fatalf("create workflow: %v", err) | ||
| } | ||
| cleanupWorkflow(t, wf) | ||
| t.Logf("workflow %s: created against %s", wf.Name(), node.Name()) | ||
| t.Logf("workflow %s: created against %s", wf.Name(), ssNode.Name()) | ||
|
|
||
| if err := wf.WaitTerminal(ctx); err != nil { | ||
| t.Fatalf("workflow %s did not complete: %v", wf.Name(), err) | ||
|
|
@@ -111,13 +177,24 @@ func TestWorkflowStateSync(t *testing.T) { | |
| // Re-bootstrap observable: after the wipe + resync the follower rejoins and | ||
| // catches up again (it cannot catch up to a chain it is not synced with, so | ||
| // this covers the wiped-then-synced round trip) and serves EVM again. | ||
| if err := sei.WaitCaughtUp(ctx, hc, node.TendermintRPC()); err != nil { | ||
| t.Fatalf("follower %s not caught up after resync: %v", node.Name(), err) | ||
| if err := sei.WaitCaughtUp(ctx, hc, ssNode.TendermintRPC()); err != nil { | ||
| t.Fatalf("follower %s not caught up after resync: %v", ssNode.Name(), err) | ||
| } | ||
| if err := sei.WaitEVMServing(ctx, hc, ssNode.EVMRPC()); err != nil { | ||
| t.Errorf("follower %s EVM not serving after resync: %v", ssNode.Name(), err) | ||
| } | ||
|
|
||
| // A genuine wipe + fresh re-sync lands on a NEW (higher) snapshot, so the | ||
| // earliest retained height must rise past the pre-workflow floor; a no-op | ||
| // recipe would leave earliest unchanged. | ||
| postEarliest, ok := sei.EarliestHeight(ctx, hc, ssNode.TendermintRPC()) | ||
| if !ok { | ||
| t.Fatalf("read post-workflow earliest height of %q", ssNode.Name()) | ||
| } | ||
| if err := sei.WaitEVMServing(ctx, hc, node.EVMRPC()); err != nil { | ||
| t.Errorf("follower %s EVM not serving after resync: %v", node.Name(), err) | ||
| if postEarliest <= preEarliest { | ||
| t.Fatalf("follower %s earliest height = %d after resync, want > %d (a genuine wipe + re-sync lands on a newer snapshot)", ssNode.Name(), postEarliest, preEarliest) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Earliest advance asserted without new snapshotMedium Severity The post-workflow check requires Reviewed by Cursor Bugbot for commit 7831156. Configure here. |
||
| } | ||
| t.Logf("follower %s: caught up + EVM serving after state-sync re-bootstrap — TestWorkflowStateSync OK", node.Name()) | ||
| t.Logf("follower %s: caught up + EVM serving, earliest advanced %d -> %d after state-sync re-bootstrap — TestWorkflowStateSync OK", ssNode.Name(), preEarliest, postEarliest) | ||
|
|
||
| // Interrupt-resume variant (deferred): kill the follower pod mid-recipe and | ||
| // assert the workflow resumes to Complete. Deferred here — it needs a | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.