-
Notifications
You must be signed in to change notification settings - Fork 54
Add State Persistence for Crash Recovery #321
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
Merged
Merged
Changes from 5 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
665faae
feat: add state persistence for crash recovery
bupd 6a7f22f
test: add crash recovery e2e tests
bupd a12e5ae
fix: protect SaveState with mutex in reconcileRemoteConfig
bupd 0e054ed
refactor: remove zero-value fields and redundant comment
bupd 13ee56d
fix: protect currentConfigDigest write with mutex
bupd 4a6bc91
fix: add fsync before rename in SaveState for crash durability
bupd 02de814
fix: log warning on corrupted state file instead of silent discard
bupd 6723ed0
fix: correct 'entites' typo to 'entities' in log and e2e tests
bupd 2c032ca
fix: persist state immediately after updateStateMap removes groups
bupd 34d39b5
refactor: stream layers individually during replication
bupd c3108ec
fix: explicitly ignore cleanup errors in error paths
bupd b750c80
refactor: use defer for cleanup with proper error handling
bupd 8c4c5bc
feat: skip already-present blobs before pulling from source
bupd 3c2efa1
test: add replicator tests with mock OCI registries
bupd 329ada3
test: verify layer-level resume on replication restart
bupd 6356cf7
fix: apply custom TLS transport to push options
bupd 07a0d7c
fix: detect group URL swaps in updateStateMap for persistence
bupd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package state | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| ) | ||
|
|
||
| // PersistedGroupState is the serializable form of a group's replicated entities. | ||
| type PersistedGroupState struct { | ||
| URL string `json:"url"` | ||
| Entities []Entity `json:"entities"` | ||
| } | ||
|
|
||
| // PersistedState is the top-level struct written to state.json. | ||
| type PersistedState struct { | ||
| ConfigDigest string `json:"config_digest,omitempty"` | ||
| Groups []PersistedGroupState `json:"groups"` | ||
| } | ||
|
|
||
| // SaveState writes the current stateMap and configDigest to disk. | ||
| func SaveState(path string, stateMap []StateMap, configDigest string) error { | ||
| persisted := PersistedState{ | ||
| ConfigDigest: configDigest, | ||
| Groups: make([]PersistedGroupState, 0, len(stateMap)), | ||
| } | ||
| for _, sm := range stateMap { | ||
| persisted.Groups = append(persisted.Groups, PersistedGroupState{ | ||
| URL: sm.url, | ||
| Entities: sm.Entities, | ||
| }) | ||
| } | ||
|
|
||
| data, err := json.MarshalIndent(persisted, "", " ") | ||
| if err != nil { | ||
| return fmt.Errorf("marshal state: %w", err) | ||
| } | ||
|
|
||
| dir := filepath.Dir(path) | ||
| tmp, err := os.CreateTemp(dir, "state-*.json.tmp") | ||
| if err != nil { | ||
| return fmt.Errorf("create temp file: %w", err) | ||
| } | ||
| tmpName := tmp.Name() | ||
|
|
||
| if _, err := tmp.Write(data); err != nil { | ||
| tmp.Close() | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("write temp file: %w", err) | ||
| } | ||
| if err := tmp.Close(); err != nil { | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("close temp file: %w", err) | ||
| } | ||
| if err := os.Rename(tmpName, path); err != nil { | ||
| os.Remove(tmpName) | ||
| return fmt.Errorf("rename temp to state file: %w", err) | ||
| } | ||
|
bupd marked this conversation as resolved.
Outdated
|
||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // LoadState reads the persisted state from disk. | ||
| // Returns nil, nil if the file does not exist. | ||
| func LoadState(path string) (*PersistedState, error) { | ||
| data, err := os.ReadFile(path) | ||
| if err != nil { | ||
| if errors.Is(err, os.ErrNotExist) { | ||
| return nil, nil | ||
| } | ||
| return nil, fmt.Errorf("read state file: %w", err) | ||
| } | ||
|
|
||
| var persisted PersistedState | ||
| if err := json.Unmarshal(data, &persisted); err != nil { | ||
| return nil, fmt.Errorf("unmarshal state file: %w", err) | ||
| } | ||
|
bupd marked this conversation as resolved.
|
||
|
|
||
| return &persisted, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| package state | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestSaveAndLoadRoundTrip(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "state.json") | ||
|
|
||
| stateMap := []StateMap{ | ||
| { | ||
| url: "http://registry.example.com/group1", | ||
| Entities: []Entity{ | ||
| {Name: "alpine", Repository: "library", Tag: "latest", Digest: "sha256:abc123"}, | ||
| {Name: "nginx", Repository: "library", Tag: "1.25", Digest: "sha256:def456"}, | ||
| }, | ||
| }, | ||
| { | ||
| url: "http://registry.example.com/group2", | ||
| Entities: []Entity{ | ||
| {Name: "redis", Repository: "library", Tag: "7", Digest: "sha256:ghi789"}, | ||
| }, | ||
| }, | ||
| } | ||
| configDigest := "sha256:config123" | ||
|
|
||
| if err := SaveState(path, stateMap, configDigest); err != nil { | ||
| t.Fatalf("SaveState failed: %v", err) | ||
| } | ||
|
|
||
| loaded, err := LoadState(path) | ||
| if err != nil { | ||
| t.Fatalf("LoadState failed: %v", err) | ||
| } | ||
| if loaded == nil { | ||
| t.Fatal("LoadState returned nil") | ||
| } | ||
|
|
||
| if loaded.ConfigDigest != configDigest { | ||
| t.Errorf("ConfigDigest = %q, want %q", loaded.ConfigDigest, configDigest) | ||
| } | ||
|
|
||
| if len(loaded.Groups) != len(stateMap) { | ||
| t.Fatalf("Groups count = %d, want %d", len(loaded.Groups), len(stateMap)) | ||
| } | ||
|
|
||
| for i, g := range loaded.Groups { | ||
| if g.URL != stateMap[i].url { | ||
| t.Errorf("Group[%d].URL = %q, want %q", i, g.URL, stateMap[i].url) | ||
| } | ||
| if len(g.Entities) != len(stateMap[i].Entities) { | ||
| t.Fatalf("Group[%d].Entities count = %d, want %d", i, len(g.Entities), len(stateMap[i].Entities)) | ||
| } | ||
| for j, e := range g.Entities { | ||
| want := stateMap[i].Entities[j] | ||
| if e.Name != want.Name || e.Repository != want.Repository || e.Tag != want.Tag || e.Digest != want.Digest { | ||
| t.Errorf("Group[%d].Entities[%d] = %+v, want %+v", i, j, e, want) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestLoadNonexistentFile(t *testing.T) { | ||
| path := filepath.Join(t.TempDir(), "does-not-exist.json") | ||
|
|
||
| loaded, err := LoadState(path) | ||
| if err != nil { | ||
| t.Fatalf("LoadState returned error for missing file: %v", err) | ||
| } | ||
| if loaded != nil { | ||
| t.Fatalf("LoadState returned non-nil for missing file: %+v", loaded) | ||
| } | ||
| } | ||
|
|
||
| func TestSaveEmptyState(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "state.json") | ||
|
|
||
| if err := SaveState(path, nil, ""); err != nil { | ||
| t.Fatalf("SaveState failed for empty state: %v", err) | ||
| } | ||
|
|
||
| loaded, err := LoadState(path) | ||
| if err != nil { | ||
| t.Fatalf("LoadState failed: %v", err) | ||
| } | ||
| if loaded == nil { | ||
| t.Fatal("LoadState returned nil for empty state") | ||
| } | ||
| if loaded.ConfigDigest != "" { | ||
| t.Errorf("ConfigDigest = %q, want empty", loaded.ConfigDigest) | ||
| } | ||
| if len(loaded.Groups) != 0 { | ||
| t.Errorf("Groups count = %d, want 0", len(loaded.Groups)) | ||
| } | ||
|
|
||
| if _, err := os.Stat(path); err != nil { | ||
| t.Errorf("state file should exist: %v", err) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.