-
Notifications
You must be signed in to change notification settings - Fork 368
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
Fluent interface for atomic file writes #4331
Conversation
Looks nice. os.Rename isn't atomic on windows btw. I saw some implementation that did the replacing on Something like: import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"time"
)
var errEmptyWrite = errors.New("atomicwriter: wrote no data")
type atomicWriter struct {
target string
w *os.File
wrErr error
closed bool
n int
}
// OpenAtomic opens a file for writing, creating a temporary file in the same directory as the target file which is
// used to write the contents. When the file is closed, the temporary file is renamed to the target file, ensuring
// that the target file is only updated if the write completes successfully. If the write fails, the temporary file
// is removed. The caller is expected to call Close on the returned atomicWriter when done writing. The writer is
// not concurrency safe and should not be used concurrently.
func OpenAtomic(name string, mode fs.FileMode) (*atomicWriter, error) {
name = filepath.Abs(name)
tmp := filepath.Join(filepath.Dir(name), "."+filepath.Base(name)+"."+strconv.Itoa(int(time.Now().UnixNano()))+".tmp")
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return nil, fmt.Errorf("atomicwriter: open: %w", err)
}
return &atomicWriter{target: name, w: f}, nil
}
// Write the data to the temporary file. If the write fails, the temporary file is removed.
// The number of bytes written is returned and any error encountered.
func (a *atomicWriter) Write(p []byte) (n int, err error) {
if a.closed {
return 0, fmt.Errorf("atomicwriter: close: %w", fs.ErrClosed)
}
n, err = a.w.Write(p)
if err != nil && !errors.Is(err, io.EOF) {
// Write failed abnormally, clean up.
a.cleanup()
a.wrErr = err
return n, fmt.Errorf("atomicwriter: write: %w", err)
}
a.n += n
return n, err
}
func (a *atomicWriter) cleanup() {
if !a.closed {
_ = a.w.Close()
a.closed = true
}
_ = os.Remove(a.w.Name())
}
// Close the file and rename the temporary file to the target file if the write was successful.
func (a *atomicWriter) Close() error {
if a.wrErr != nil {
return fmt.Errorf("atomicwriter: close: write error occured: %w", a.wrErr)
}
if a.closed {
return fmt.Errorf("atomicwriter: close: %w", fs.ErrClosed)
}
if err := a.w.Close(); err != nil {
// Close failed, clean up.
a.cleanup()
return fmt.Errorf("atomicwriter: close: %w", err)
}
a.closed = true
if a.n <= 0 {
// Nothing written, expect the write block to have failed and the file to be invalid, clean up.
a.cleanup()
return errEmptyWrite
}
if err := os.Rename(a.w.Name(), a.target); err != nil {
// Replacing the target failed, clean up.
a.cleanup()
return fmt.Errorf("atomicwriter: close: %w", err)
}
return nil
} |
Indeed, as mentioned in the godocs: // Note that this function is only best-effort on Windows:
// https://github.com/golang/go/issues/22397#issuecomment-498856679
I've briefly thought about that as well, but felt that the callback approach is easier to reason about, and also has less possibilities to be misused, like double-close, usage from multiple goroutines and such things. Anyhow, I'll give it a spin and then we can decide by comparing both approaches. |
One thing that I overlooked here: With the "return a writer and rename the file on close" approach, we'd need to wrap the write calls and track if there was an error so that we know if we should actually replace the target file when Close() is called. This is actually a bit cumbersome. |
The caller is usually responsible for calling
Add a |
fb471a5
to
363144c
Compare
There you go. I'm not too convinced, though. Especially the core logic is not as easy to grasp as it has been before, as it's now split into multiple methods, and the |
Yes, I meant to suggest dropping the callback and making it a regular writer that just replaces the final target on The expected usage pattern would be the same as with any file ops: a, err := OpenAtomic("foo", 0o600)
if err != nil {
return err
}
defer a.Close()
// do the writing, return error or nil |
That pattern doesn't work, because something™ needs to tell the Close method in the defer statement if it's on the happy path (do the rename), or the err path (discard the temp file). But this could work: a, err := OpenAtomic("foo", 0o600)
if err != nil {
return err
}
defer a.Close()
// do the writing, return error when hitting problems
return a.Finish() // or a.Rename() .... This is "Close on happy path with rename", rendering Close in the defer into a no-op. |
This takes an |
8b41451
to
3ded417
Compare
Next try. This is better than the atomicFile.Failed stuff.
I'd really like to keep the callback functions as higher-level abstractions. They're now implemented in terms of Open/Finish/Close and come in quite handy, as they capture the idiomatic usage pattern, which is 9 lines of code. Callers can still use the Atomic directly if they prefer. Removing those would mean that we'd need to refactor all of the current call sites, which I tried to avoid here. We could mark the Write...Atomically functions as deprecated, though, if you prefer.
Interesting, although that'd be not really useful for e.g. writing templates. Anyhow, I'll have a look. There's always something to learn. |
Btw, the claim that MoveFileEx is atomic under Windows is not correct according to |
That said, I'm not sure if having the Atomic with its Open/Finish/Close approach adds any value here. What would be a scenario that'd benefit from having this, instead of only having the callback using an internalized implementation as before? It's conceptually nice to have something that "feels" like a regular io.File, but in the end, the only thing that's really needed is the io.Writer. The Open/Defer/Finish/Close dance is a bit lengthy. Anyhow, having the fluent interface here is the main point for the PR, whether it's using a callback or returning a file-like struct. So, happy to move forward in either direction. |
3ded417
to
9ab39d1
Compare
If there's a callback then the user shouldn't have to bother with anything else, I mentioned the replace on |
Needs a rebase and adaption for #4289. |
fd78b0c
to
1d73bf2
Compare
This pull request has merge conflicts that need to be resolved. |
This pull request has merge conflicts that need to be resolved. |
7eec188
to
a6ec5e4
Compare
26e4401
to
4285a19
Compare
internal/pkg/file/atomic.go
Outdated
var opened bool | ||
defer func() { | ||
if !opened { | ||
err = errors.Join(err, f.fd.Close(), remove(f.fd)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if these errors should be returned, any errors from trying to close or remove a broken fd here are not why the open failed.
Maybe just something like _ = f.fd.Close(); _ = remove(f.fd)
before returning the error below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe the variable name open
is not chosen wisely. Perhaps success
would be better. In fact, at this point in the code, the file has already been successfully opened, but we want to do something with it, i.e. change the permissions before writing the contents. This defer basically ensures that if subsequent operations fail before we return to the caller, we still clean everything up.
If the file opening itself had failed, it would indeed be useless to close/remove the file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe that whole chmod business after CreateTemp is futile anyways. Need to check.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like it is. https://cs.opensource.google/go/go/+/refs/tags/go1.22.3:src/os/tempfile.go;l=33
...
for {
name := prefix + nextRandom() + suffix
f, err := OpenFile(name, O_RDWR|O_CREATE|O_EXCL, 0600)
...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Killed with 🔥
internal/pkg/file/atomic.go
Outdated
} | ||
|
||
n, err = f.fd.Write(p) | ||
if err == fs.ErrInvalid { //nolint:errorlint // os.File won't return wrapped errors |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
errors.Is
should still work :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is now gone.
They build a logical unit and separating those from the other file functions makes that clear. Signed-off-by: Tom Wieczorek <twieczorek@mirantis.com>
The temporary file was properly closed on panic, but not removed from disk. Add an additional boolean that indicates whether the write call returned. Use this boolean as an additional criterion in the defer func to find out if the file needs to be removed or not. Signed-off-by: Tom Wieczorek <twieczorek@mirantis.com>
Atomic file writes could get confused when used with non-absolute paths, and the working directory would change while writing files. This can be easily mitigated by using the absolute path internally. Signed-off-by: Tom Wieczorek <twieczorek@mirantis.com>
This allows the atomic write functions to be extended in the future without breaking the existing API. Introduces the atomicOpener struct, which implements the fluent interface and collects all the desired information for an atomic file write. It provides some terminal methods that do the actual writing. The old workhorse function WriteAtomically has been splitted up in AtomicOpener.Do, AtomicOpener.Open and its companion struct Atomic, especially its Finish and Close methods. Atomic implements io.WriteCloser and io.ReaderFrom and may be used more or less like a usual write-only io.File. The magic happens when it's finished/closed. The write callback of AtomicOpener.Do now accepts the purpose-built interface file.AtomicWriter. This allows the interface to be extended with new methods without having to change all call sites. Setting permissions is now optional, i.e. callers have to opt in by calling WithPermissions(...). Signed-off-by: Tom Wieczorek <twieczorek@mirantis.com>
Eventually, this might need to get some additional toggles if it's okay to fail to set permissions because k0s doesn't have the appropriate privileges (CAP_CHOWN). This is also the reason why there are no unit tests for this: The test outcome depends on the test process's permissions. Signed-off-by: Tom Wieczorek <twieczorek@mirantis.com>
Use file.AtomicOpener directly when writing the keepalived config file. This was the only hack that relied on using *os.File directly. Signed-off-by: Tom Wieczorek <twieczorek@mirantis.com>
4285a19
to
be12301
Compare
Description
This allows the atomic write functions to be extended in the future without breaking the existing API. Introduces the
AtomicOpener
struct, which implements the fluent interface and collects all the desired information for an atomic file write. It provides some terminal methods that do the actual writing.The old workhorse function
WriteAtomically
has been splitted up inAtomicOpener.Do
,AtomicOpener.Open
and its companion structAtomic
, especially itsFinish
andClose
methods.Atomic
implementsio.WriteCloser
andio.ReaderFrom
and may be used more or less like a usual write-onlyio.File
. The magic happens when it's finished/closed. The write callback ofAtomicOpener.Do
now accepts the purpose-built interfacefile.AtomicWriter
. This allows the interface to be extended with new methods without having to change all call sites. Setting permissions is now optional, i.e. callers have to opt in by callingWithPermissions(...)
.Also address some other shortcomings:
Type of change
How Has This Been Tested?
Checklist: