Skip to content

Commit 0a783a4

Browse files
authored
[Windows] Rotate (rename) files with retries (#333)
* Add unit test * Implement a retrying rename * Running goimports * Assert on no error * Add variadic options to SafeFileRotate * Inlining implementation * Add explanatory comment * Fixing up license header * Reducing visibility * Fix logic for non-retrying rename * Enable retries by default on Windows * Revert "Enable retries by default on Windows" This reverts commit 1a887d7. * Retry by default for Windows
1 parent e05664f commit 0a783a4

File tree

5 files changed

+155
-10
lines changed

5 files changed

+155
-10
lines changed

file/helper_aix.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ import (
2323
)
2424

2525
// SafeFileRotate safely rotates an existing file under path and replaces it with the tempfile
26-
func SafeFileRotate(path, tempfile string) error {
26+
func SafeFileRotate(path, tempfile string, opts ...RotateOpt) error {
27+
options := rotateOpts{}
28+
for _, opt := range opts {
29+
opt(&options)
30+
}
2731

28-
if e := os.Rename(tempfile, path); e != nil {
29-
return e
32+
if err := rename(tempfile, path, options); err != nil {
33+
return err
3034
}
3135

3236
// best-effort fsync on parent directory. The fsync is required by some

file/helper_common.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package file
19+
20+
import (
21+
"fmt"
22+
"os"
23+
"time"
24+
)
25+
26+
type rotateOpts struct {
27+
renameRetryDuration time.Duration
28+
renameRetryInterval time.Duration
29+
}
30+
31+
type RotateOpt func(*rotateOpts)
32+
33+
func WithRenameRetries(duration, interval time.Duration) RotateOpt {
34+
return func(opts *rotateOpts) {
35+
opts.renameRetryDuration = duration
36+
opts.renameRetryInterval = interval
37+
}
38+
}
39+
40+
func rename(src, dst string, options rotateOpts) error {
41+
// Perform a regular (non-retrying) rename unless all retry options are specified.
42+
if options.renameRetryDuration == 0 || options.renameRetryInterval == 0 {
43+
return os.Rename(src, dst)
44+
}
45+
46+
// Attempt rename with retries every options.RenameRetryInterval until options.RenameRetryDuration
47+
// has elapsed. This is useful in cases where the destination file may be locked or in use.
48+
var err error
49+
for start := time.Now(); time.Since(start) < options.renameRetryDuration; time.Sleep(options.renameRetryInterval) {
50+
err = os.Rename(src, dst)
51+
if err == nil {
52+
// Rename succeeded; no more retries needed
53+
return nil
54+
}
55+
}
56+
57+
if err != nil {
58+
return fmt.Errorf("failed to rename %s to %s after %v: %w", src, dst, options.renameRetryDuration, err)
59+
}
60+
61+
return nil
62+
}

file/helper_other.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,14 @@ import (
2525
)
2626

2727
// SafeFileRotate safely rotates an existing file under path and replaces it with the tempfile
28-
func SafeFileRotate(path, tempfile string) error {
28+
func SafeFileRotate(path, tempfile string, opts ...RotateOpt) error {
29+
options := rotateOpts{}
30+
for _, opt := range opts {
31+
opt(&options)
32+
}
2933

30-
if e := os.Rename(tempfile, path); e != nil {
31-
return e
34+
if err := rename(tempfile, path, options); err != nil {
35+
return err
3236
}
3337

3438
// best-effort fsync on parent directory. The fsync is required by some

file/helper_windows.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,25 @@ package file
2020
import (
2121
"os"
2222
"path/filepath"
23+
"time"
2324
)
2425

26+
const windowsRenameRetryInterval = 50 * time.Millisecond
27+
const windowsRenameRetryDuration = 2 * time.Second
28+
2529
// SafeFileRotate safely rotates an existing file under path and replaces it with the tempfile
26-
func SafeFileRotate(path, tempfile string) error {
30+
func SafeFileRotate(path, tempfile string, opts ...RotateOpt) error {
31+
// On Windows, retry the rename operation by default. This is useful in cases where
32+
// path, the destination file, may be locked or in use.
33+
options := rotateOpts{
34+
renameRetryDuration: windowsRenameRetryDuration,
35+
renameRetryInterval: windowsRenameRetryInterval,
36+
}
37+
for _, opt := range opts {
38+
opt(&options)
39+
}
40+
2741
old := path + ".old"
28-
var e error
2942

3043
// In Windows, one cannot rename a file if the destination already exists, at least
3144
// not with using the os.Rename function that Golang offers.
@@ -37,8 +50,8 @@ func SafeFileRotate(path, tempfile string) error {
3750
// ignore error in case path doesn't exist
3851
_ = os.Rename(path, old)
3952

40-
if e = os.Rename(tempfile, path); e != nil {
41-
return e
53+
if err := rename(tempfile, path, options); err != nil {
54+
return err
4255
}
4356

4457
// .old file will still exist if path file is already there, it should be removed

file/helper_windows_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Licensed to Elasticsearch B.V. under one or more contributor
2+
// license agreements. See the NOTICE file distributed with
3+
// this work for additional information regarding copyright
4+
// ownership. Elasticsearch B.V. licenses this file to you under
5+
// the Apache License, Version 2.0 (the "License"); you may
6+
// not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package file
19+
20+
import (
21+
"os"
22+
"path/filepath"
23+
"testing"
24+
"time"
25+
26+
"github.com/stretchr/testify/require"
27+
)
28+
29+
// TestSafeFileRotate creates two files, dest and src, and calls
30+
// SafeFileRotate to replace dest with src. However, before the test
31+
// makes that call, it deliberately keeps a handle open on dest for
32+
// a short period of time to ensure that the rotation takes place anyway
33+
// after the handle has been released.
34+
func TestSafeFileRotate(t *testing.T) {
35+
// Create destination file
36+
tmpDir := t.TempDir()
37+
dest := filepath.Join(tmpDir, "dest.txt")
38+
err := os.WriteFile(dest, []byte("existing content"), 0644)
39+
require.NoError(t, err)
40+
41+
// Create source file
42+
src := filepath.Join(tmpDir, "src.txt")
43+
err = os.WriteFile(src, []byte("new content"), 0644)
44+
require.NoError(t, err)
45+
46+
// Open handle on dest file for 1.5 seconds
47+
destFile, err := os.Open(dest)
48+
require.NoError(t, err)
49+
time.AfterFunc(1500*time.Millisecond, func() {
50+
destFile.Close() // Close the handle after 1.5 seconds
51+
})
52+
defer destFile.Close()
53+
54+
// Try to replace dest with new
55+
err = SafeFileRotate(dest, src, WithRenameRetries(2*time.Second, 100*time.Millisecond))
56+
require.NoError(t, err)
57+
58+
// Check that dest file has been replaced with new file
59+
data, err := os.ReadFile(dest)
60+
require.NoError(t, err)
61+
require.Equal(t, "new content", string(data))
62+
}

0 commit comments

Comments
 (0)