Skip to content

Commit 3eb0552

Browse files
artifact optimistic locking
1 parent 5653d42 commit 3eb0552

File tree

6 files changed

+425
-13
lines changed

6 files changed

+425
-13
lines changed

artifact/config.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ type Config struct {
4848
// the root.
4949
Ignore []string
5050

51-
ignoreSet utils.StringSet
52-
tree TreeNodeTree
53-
configDir string
54-
commitFn func() error
51+
ignoreSet utils.StringSet
52+
tree TreeNodeTree
53+
treeVersion int64
54+
configDir string
55+
commitFn func() error
5556
}
5657

5758
// Lookup looks an artifact up by its path and returns its

artifact/config_load.go

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package artifact
22

33
import (
44
"encoding/json"
5+
"io"
56
"os"
67
"path/filepath"
78

@@ -88,6 +89,48 @@ func LoadConfigFromFile(path string) (*Config, error) {
8889
treePath := filepath.Join(pathDir, TreeName)
8990
config.configDir = pathDir
9091
config.commitFn = func() error {
92+
// Read current version from disk to detect concurrent modifications
93+
var currentVersion int64
94+
var fileExists bool
95+
//nolint:gosec
96+
if existingFile, err := os.Open(treePath); err == nil {
97+
defer utils.UncheckedErrorFunc(existingFile.Close)
98+
99+
fileExists = true
100+
var existingTreeFile TreeFile
101+
if err := json.NewDecoder(existingFile).Decode(&existingTreeFile); err != nil {
102+
// If we get EOF or decode error, the file might be being written by another process
103+
// Treat this as a concurrent modification
104+
if err.Error() == "EOF" || errors.Is(err, os.ErrClosed) {
105+
return NewConflictError(treePath, config.treeVersion, -1)
106+
}
107+
108+
// Try backward compatible read
109+
if _, seekErr := existingFile.Seek(0, 0); seekErr == nil {
110+
var tree TreeNodeTree
111+
if decodeErr := json.NewDecoder(existingFile).Decode(&tree); decodeErr == nil {
112+
currentVersion = 0 // Old format
113+
} else {
114+
return errors.Wrap(err, "failed to read existing tree version")
115+
}
116+
} else {
117+
return errors.Wrap(err, "failed to read existing tree version")
118+
}
119+
} else {
120+
currentVersion = existingTreeFile.Version
121+
}
122+
}
123+
124+
// Check for concurrent modification (optimistic locking)
125+
// Only check if file existed when we tried to read it
126+
if fileExists && currentVersion != config.treeVersion {
127+
return NewConflictError(treePath, config.treeVersion, currentVersion)
128+
}
129+
130+
// Increment version for this write
131+
newVersion := config.treeVersion + 1
132+
133+
// Write new tree with incremented version
91134
//nolint:gosec
92135
newTreeFile, err := os.OpenFile(treePath, os.O_RDWR|os.O_CREATE, 0o600)
93136
if err != nil {
@@ -97,25 +140,56 @@ func LoadConfigFromFile(path string) (*Config, error) {
97140
if err := newTreeFile.Truncate(0); err != nil {
98141
return err
99142
}
143+
144+
treeFile := TreeFile{
145+
Version: newVersion,
146+
Tree: config.tree,
147+
}
148+
100149
enc := json.NewEncoder(newTreeFile)
101150
enc.SetIndent("", " ")
102-
return enc.Encode(config.tree)
151+
if err := enc.Encode(treeFile); err != nil {
152+
return err
153+
}
154+
155+
// Update in-memory version on successful write
156+
config.treeVersion = newVersion
157+
return nil
103158
}
104159

105160
//nolint:gosec
106-
treeFile, err := os.Open(treePath)
161+
treeFileHandle, err := os.Open(treePath)
107162
if err == nil {
108-
defer utils.UncheckedErrorFunc(treeFile.Close)
163+
defer utils.UncheckedErrorFunc(treeFileHandle.Close)
109164

110-
treeDec := json.NewDecoder(treeFile)
165+
// Read file contents for potential backward compatibility
166+
var fileData []byte
167+
fileData, err = io.ReadAll(treeFileHandle)
168+
if err != nil {
169+
if err.Error() == "EOF" {
170+
return nil, errors.New("tree file is being modified by another process, please retry")
171+
}
172+
return nil, errors.Wrap(err, "failed to read tree file")
173+
}
111174

112-
var tree TreeNodeTree
113-
if err := treeDec.Decode(&tree); err != nil {
114-
return nil, err
175+
// Try to decode as new format (TreeFile with version)
176+
var treeFile TreeFile
177+
if err := json.Unmarshal(fileData, &treeFile); err == nil && treeFile.Tree != nil && len(treeFile.Tree) > 0 {
178+
// Successfully decoded as new format with content
179+
config.tree = treeFile.Tree
180+
config.treeVersion = treeFile.Version
181+
} else {
182+
// Try backward compatibility: decode as raw tree without version wrapper
183+
var tree TreeNodeTree
184+
if err := json.Unmarshal(fileData, &tree); err != nil {
185+
return nil, errors.Wrap(err, "failed to decode tree file in old or new format")
186+
}
187+
config.tree = tree
188+
config.treeVersion = 0 // Old format, start at version 0
115189
}
116-
config.tree = tree
117190
} else {
118191
config.tree = TreeNodeTree{}
192+
config.treeVersion = 0
119193
}
120194

121195
return &config, nil

artifact/config_load_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ func TestLoadConfigFromFile(t *testing.T) {
207207
Ignore: []string{"one", "two"},
208208
ignoreSet: utils.NewStringSet("one", "two"),
209209
configDir: dir,
210+
treeVersion: 1, // Version increments after commit
210211
tree: TreeNodeTree{
211212
"one": &TreeNode{
212213
internal: TreeNodeTree{
@@ -299,6 +300,7 @@ func TestLoadConfigFromFile(t *testing.T) {
299300
Ignore: []string{"one", "two"},
300301
ignoreSet: utils.NewStringSet("one", "two"),
301302
configDir: dir,
303+
treeVersion: 1, // Version increments after commit (old tree starts at 0, becomes 1)
302304
tree: TreeNodeTree{
303305
"one": &TreeNode{
304306
internal: TreeNodeTree{

0 commit comments

Comments
 (0)