Skip to content

Commit d5a23c2

Browse files
authored
Fix concurrency/dir for Windows (#135)
Signed-off-by: joshvanl <[email protected]>
1 parent 8b780b4 commit d5a23c2

File tree

3 files changed

+124
-10
lines changed

3 files changed

+124
-10
lines changed

concurrency/dir/dir.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@ type Options struct {
2727
Target string
2828
}
2929

30-
// Dir atomically writes files to a given directory.
30+
// Dir atomically (best-effort on Windows) writes files to a given directory.
3131
type Dir struct {
3232
log logger.Logger
3333

3434
base string
3535
target string
3636
targetDir string
3737

38+
// prev holds a path we should delete on the *next* successful Write.
39+
// On Unix: the previously active versioned dir.
40+
// On Windows: the last backup directory (target renamed aside).
3841
prev *string
3942
}
4043

@@ -50,41 +53,45 @@ func New(opts Options) *Dir {
5053
func (d *Dir) Write(files map[string][]byte) error {
5154
newDir := filepath.Join(d.base, fmt.Sprintf("%d-%s", time.Now().UTC().UnixNano(), d.targetDir))
5255

56+
// Ensure base exists
5357
if err := os.MkdirAll(d.base, 0o700); err != nil {
5458
return err
5559
}
56-
60+
// Create the new versioned directory
5761
if err := os.MkdirAll(newDir, 0o700); err != nil {
5862
return err
5963
}
6064

65+
// Write all files into the new versioned directory
6166
for file, b := range files {
6267
path := filepath.Join(newDir, file)
68+
// Ensure parent directories exist for nested files
69+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
70+
return err
71+
}
6372
if err := os.WriteFile(path, b, 0o600); err != nil {
6473
return err
6574
}
6675
d.log.Infof("Written file %s", file)
6776
}
6877

69-
if err := os.Symlink(newDir, d.target+".new"); err != nil {
70-
return err
71-
}
72-
73-
d.log.Infof("Syslink %s to %s.new", newDir, d.target)
74-
75-
if err := os.Rename(d.target+".new", d.target); err != nil {
78+
// Platform-specific switch into place. It returns what we should delete on the NEXT run.
79+
nextPrev, err := d.switchTo(newDir)
80+
if err != nil {
7681
return err
7782
}
7883

7984
d.log.Infof("Atomic write to %s", d.target)
8085

86+
// Best-effort cleanup from the *previous* run
8187
if d.prev != nil {
8288
if err := os.RemoveAll(*d.prev); err != nil {
8389
return err
8490
}
8591
}
8692

87-
d.prev = &newDir
93+
// Set what to delete on the *next* run.
94+
d.prev = nextPrev
8895

8996
return nil
9097
}

concurrency/dir/switch_unix.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//go:build !windows && !plan9
2+
3+
/*
4+
Copyright 2025 The Dapr Authors
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package dir
17+
18+
import (
19+
"os"
20+
)
21+
22+
func (d *Dir) switchTo(newDir string) (*string, error) {
23+
// Create a symlink and atomically rename it into place.
24+
tmpLink := d.target + ".new"
25+
26+
// Remove any stale temp link
27+
_ = os.Remove(tmpLink)
28+
29+
if err := os.Symlink(newDir, tmpLink); err != nil {
30+
return nil, err
31+
}
32+
d.log.Debugf("Symlink %s -> %s", tmpLink, newDir)
33+
34+
// Atomically replace the target symlink (or create it if missing)
35+
// On POSIX, rename on the same filesystem is atomic.
36+
if err := os.Rename(tmpLink, d.target); err != nil {
37+
// Clean up temp link if rename fails
38+
_ = os.Remove(tmpLink)
39+
return nil, err
40+
}
41+
42+
d.log.Debugf("Atomic write to %s", d.target)
43+
44+
// On Unix we keep versioned dirs and delete the *previous* version on next run.
45+
return &newDir, nil
46+
}

concurrency/dir/switch_windows.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build windows
2+
3+
/*
4+
Copyright 2025 The Dapr Authors
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package dir
17+
18+
import (
19+
"fmt"
20+
"os"
21+
"path/filepath"
22+
"time"
23+
)
24+
25+
func (d *Dir) switchTo(newDir string) (*string, error) {
26+
// Windows notes:
27+
// - os.Rename does NOT replace existing directories.
28+
// - Directory symlinks/junctions are unreliable without privileges.
29+
// Strategy:
30+
// 1) If target exists, rename it to a timestamped backup alongside base.
31+
// 2) Rename newDir -> target.
32+
// 3) Return backup path so we delete it on the *next* run (avoids data loss if step 2 fails).
33+
var backup *string
34+
35+
// If target exists, rename it aside
36+
if fi, err := os.Lstat(d.target); err == nil && fi.IsDir() {
37+
bak := filepath.Join(d.base, fmt.Sprintf("backup-%d-%s", time.Now().UTC().UnixNano(), d.targetDir))
38+
// Be defensive: remove any stale leftover
39+
_ = os.RemoveAll(bak)
40+
41+
if err := os.Rename(d.target, bak); err != nil {
42+
return nil, err
43+
}
44+
d.log.Debugf("Renamed existing %s to backup %s", d.target, bak)
45+
backup = &bak
46+
}
47+
48+
// Move the freshly written versioned dir into place as the new target
49+
if err := os.Rename(newDir, d.target); err != nil {
50+
// Try to restore the backup if we created one
51+
if backup != nil {
52+
_ = os.Rename(*backup, d.target)
53+
}
54+
return nil, err
55+
}
56+
57+
d.log.Debugf("Replaced directory at %s (Windows best-effort atomicity)", d.target)
58+
59+
// On Windows we delete the backup on the *next* run (so we don't risk losing data if a crash happens now).
60+
return backup, nil
61+
}

0 commit comments

Comments
 (0)