Skip to content

Commit

Permalink
Add SugarUtils::File.atomic_write method
Browse files Browse the repository at this point in the history
  • Loading branch information
acant committed May 30, 2019
1 parent 24349b7 commit 004a872
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
characters out of a string
- SugarUtils::File.change_access, a wrapper for changing ownership and
permissions of a file
- SugarUtils::File.atomic_write, to atomically write a file
### Removed
- append support in SugarUtils::File.write (could have been specified by { mode: 'a })
### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ These methods are included:
* SugarUtils::File.change_access
* SugarUtils::File.read
* SugarUtils::File.write
* SugarUtils::File.atomic_write
* SugarUtils::File.read_json
* SugarUtils::File.write_json
* SugarUtils::File.append
Expand Down
46 changes: 46 additions & 0 deletions features/atomic_write_file.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Feature: Write to a file atomically

Scenario: Write a file atomically
When I run the following Ruby code:
"""ruby
require 'sugar_utils'
puts SugarUtils::File.atomic_write('dir/test', 'foobar')
"""
Then the file named "dir/test" should contain exactly:
"""
foobar
"""

Scenario: Overwrite a file atomically
Given a file named "dir/test" with "deadbeef"
When I run the following Ruby code:
"""ruby
require 'sugar_utils'
puts SugarUtils::File.atomic_write('dir/test', 'foobar')
"""
Then the file named "dir/test" should contain exactly:
"""
foobar
"""

# TODO: Fix the owner/group setting check
Scenario: Overwrite a file and reset its permissions atomically
Given a file named "dir/test" with "deadbeef"
When I run the following Ruby code:
"""ruby
require 'sugar_utils'
puts SugarUtils::File.atomic_write(
'dir/test',
'foobar',
# owner: 'nobody',
# group: 'nogroup',
mode: 0o777
)
"""
Then the file named "dir/test" should contain exactly:
"""
foobar
"""
And the file named "dir/test" should have permissions "777"
# And the file named "dir/test" should have owner "nobody"
# And the file named "dir/test" should have group "nogroup"
65 changes: 65 additions & 0 deletions lib/sugar_utils/file.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require 'fileutils'
require 'multi_json'
require 'timeout'
require 'tempfile'

require 'sugar_utils/file/write_options'

Expand Down Expand Up @@ -191,6 +192,70 @@ def self.write(filename, data, options = {}) # rubocop:disable MethodLength, Abc
raise(Error, "Unable to write #{filename} with #{e}")
end

# Atomically write to an existing file, overwriting it, or create the file
# if it does not exist.
#
# @note Either option :mode or :perm can be used to specific the permissions
# on the file being written to. This aliasing is used because both these
# names are used in the standard library, File.open uses :perm and FileUtils
# uses :mode. The user can choose whichever alias makes their code most
# readable.
#
# @param filename [String]
# @param data [#to_s]
# @param options [Hash]
# @option options [Integer] :timeout (10)
# @option options [Boolean] :flush (false)
# @option options [String, Integer] :owner
# @option options [String, Integer] :group
# @option options [Integer] :mode (0o644)
# @option options [Integer] :perm (0o644)
#
# @raise [SugarUtils::File::Error]
#
# @return [void]
def self.atomic_write(filename, data, options = {}) # rubocop:disable MethodLength, AbcSize
write_options = WriteOptions.new(filename, options)

# @note This method is similar to the atomic_write which is implemented in
# ActiveSupport. We re-implemented the method because of the following:
# * we needed the method, but wanted to avoid pulling in the entire
# ActiveSupport gem.
# * we wnated to keep the behaviour and interface consistent with the other
# SugarUtils write methods
#
# @see https://apidock.com/rails/File/atomic_write/class
FileUtils.mkdir_p(::File.dirname(filename))
Tempfile.open(::File.basename(filename, '.*'), ::File.dirname(filename)) do |temp_file|
temp_file.puts(data.to_s)
# Flush and fsync to be 100% sure we write this data out now because we
# are often reading it immediately and if the OS is buffering, it is
# possible we might read it before it is been physically written to
# disk. We are not worried about speed here, so this should be OKAY.
if write_options.flush?
temp_file.flush
temp_file.fsync
end
temp_file.close

::File.open(filename, 'w+', write_options.perm) do |file|
flock_exclusive(file, options)
FileUtils.move(temp_file.path, filename)
end
end

change_access(
filename,
write_options.owner,
write_options.group,
write_options.perm
)
rescue Timeout::Error
raise(Error, "Unable to write #{filename} because it is locked")
rescue SystemCallError, IOError => e
raise(Error, "Unable to write #{filename} with #{e}")
end

# Write the data parameter as JSON to the filename path.
#
# @note Either option :mode or :perm can be used to specific the permissions
Expand Down
103 changes: 103 additions & 0 deletions spec/sugar_utils/file_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,109 @@
end
end

describe '.atomic_write', :fakefs do
subject { described_class.atomic_write(filename, data, options) }

let(:data) { 'content' }
let(:filename) { 'dir1/dir2/filename' }

context 'when SystemCallError' do
let(:options) { {} }
let(:exception) { SystemCallError.new(nil) }

before { allow(File).to receive(:open).and_raise(exception) }

it { expect_raise_error("Unable to write #{filename} with #{exception}") }
end

context 'when IOError' do
let(:options) { {} }
let(:exception) { IOError.new(nil) }

before { allow(File).to receive(:open).and_raise(exception) }

it { expect_raise_error("Unable to write #{filename} with #{exception}") }
end

context 'when locked' do
let(:options) { {} }

before do
expect(described_class).to receive(:flock_exclusive)
.with(kind_of(File), options)
.and_raise(Timeout::Error)
end

it { expect_raise_error("Unable to write #{filename} because it is locked") }
end

shared_examples_for 'file is correctly written' do
before do
expect(described_class).to receive(:flock_exclusive)
.with(kind_of(File), options)
end

# rubocop:disable RSpec/NestedGroups
context 'without options' do
let(:options) { {} }

it { expect_not_to_raise_error }
its_side_effects_are do
expect(filename).to have_content(data)
expect(filename).to have_file_permission(0o100644)
end
end

context 'with options' do
let(:options) do
{ flush: true, owner: 'nobody', group: 'nogroup', mode_or_perm_key => 0o600 }
end

before do
# rubocop:disable RSpec/AnyInstance
expect_any_instance_of(File).to receive(:flush)
expect_any_instance_of(File).to receive(:fsync)
# rubocop:enable RSpec/AnyInstance
end

context 'with mode key' do
let(:mode_or_perm_key) { :mode }

it { expect_not_to_raise_error }
its_side_effects_are do
expect(filename).to have_content(data)
expect(filename).to have_owner('nobody')
expect(filename).to have_group('nogroup')
expect(filename).to have_file_permission(0o100600)
end
end

context 'with perm key' do
let(:mode_or_perm_key) { :perm }

it { expect_not_to_raise_error }
its_side_effects_are do
expect(filename).to have_content(data)
expect(filename).to have_owner('nobody')
expect(filename).to have_group('nogroup')
expect(filename).to have_file_permission(0o100600)
end
end
end
# rubocop:enable RSpec/NestedGroups
end

context 'when file does not exist' do
it_behaves_like 'file is correctly written'
end

context 'when file exists' do
before { write(filename, 'foobar', 0o777) }

it_behaves_like 'file is correctly written'
end
end

describe '.write_json', :fakefs do
subject { described_class.write_json(:filename, data, :options) }

Expand Down

0 comments on commit 004a872

Please sign in to comment.