core: MachineIndex has precision locking for entries

This commit is contained in:
Mitchell Hashimoto 2014-03-02 22:50:45 +01:00
parent 016afc7922
commit ac032db6a6
4 changed files with 200 additions and 51 deletions

View File

@ -408,6 +408,10 @@ module Vagrant
error_key(:machine_guest_not_ready) error_key(:machine_guest_not_ready)
end end
class MachineLocked < VagrantError
error_key(:machine_locked)
end
class MachineNotFound < VagrantError class MachineNotFound < VagrantError
error_key(:machine_not_found) error_key(:machine_not_found)
end end

View File

@ -1,6 +1,7 @@
require "json" require "json"
require "pathname" require "pathname"
require "securerandom" require "securerandom"
require "thread"
module Vagrant module Vagrant
# MachineIndex is able to manage the index of created Vagrant environments # MachineIndex is able to manage the index of created Vagrant environments
@ -35,44 +36,81 @@ module Vagrant
class MachineIndex class MachineIndex
# Initializes a MachineIndex at the given file location. # Initializes a MachineIndex at the given file location.
# #
# @param [Pathname] data_file Path to the file that should be used # @param [Pathname] data_dir Path to the directory where data for the
# to maintain the machine index. This file doesn't have to exist # index can be stored. This folder should exist and must be writable.
# but this location must be writable. def initialize(data_dir)
def initialize(data_file) @data_dir = data_dir
@data_file = data_file @index_file = data_dir.join("index")
@lock = Mutex.new
@machines = {} @machines = {}
@machine_locks = {}
if @data_file.file? with_index_lock do
data = nil unlocked_reload
begin
data = JSON.load(@data_file.read)
rescue JSON::ParserError
raise Errors::CorruptMachineIndex, path: data_file.to_s
end
if data
if !data["version"] || data["version"].to_i != 1
raise Errors::CorruptMachineIndex, path: data_file.to_s
end
@machines = data["machines"] || {}
end
end end
end end
# Accesses a machine by UUID and returns a {MachineIndex::Entry} # Accesses a machine by UUID and returns a {MachineIndex::Entry}
# #
# The entry returned is locked and can't be read again or updated by
# this process or any other. To unlock the machine, call {#release}
# with the entry.
#
# You can only {#set} an entry (update) when the lock is held.
#
# @param [String] uuid UUID for the machine to access. # @param [String] uuid UUID for the machine to access.
# @return [MachineIndex::Entry] # @return [MachineIndex::Entry]
def [](uuid) def get(uuid)
return nil if !@machines[uuid] entry = nil
Entry.new(uuid, @machines[uuid].merge("id" => uuid))
@lock.synchronize do
with_index_lock do
return nil if !@machines[uuid]
entry = Entry.new(uuid, @machines[uuid].merge("id" => uuid))
# Lock this machine
lock_file = lock_machine(uuid)
if !lock_file
raise Errors::MachineLocked,
name: entry.name,
provider: entry.provider
end
@machine_locks[uuid] = lock_file
end
end
entry
end
# Releases an entry, unlocking it.
#
# This is an idempotent operation. It is safe to call this even if you're
# unsure if an entry is locked or not.
#
# After calling this, the previous entry should no longer be used.
#
# @param [Entry] entry
def release(entry)
@lock.synchronize do
lock_file = @machine_locks[entry.id]
if lock_file
lock_file.close
@machine_locks.delete(entry.id)
end
end
end end
# Creates/updates an entry object and returns the resulting entry. # Creates/updates an entry object and returns the resulting entry.
# #
# If the entry was new (no UUID), then the UUID will be set on the # If the entry was new (no UUID), then the UUID will be set on the
# resulting entry and can be used. # resulting entry and can be used. Additionally, the a lock will
# be created for the resulting entry, so you must {#release} it
# if you want others to be able to access it.
#
# If the entry isn't new (has a UUID). then this process must hold
# that entry's lock or else this set will fail.
# #
# @param [Entry] entry # @param [Entry] entry
# @return [Entry] # @return [Entry]
@ -82,21 +120,82 @@ module Vagrant
# Set an ID if there isn't one already set # Set an ID if there isn't one already set
id = entry.id id = entry.id
id ||= SecureRandom.uuid
# Store the data @lock.synchronize do
@machines[id] = struct # Verify the machine is locked so we can safely write
save # to it.
if !id
id = SecureRandom.uuid
lock_file = lock_machine(id)
if !lock_file
raise "Failed to lock new machine: #{entry.name}"
end
@machine_locks[id] = lock_file
end
if !@machine_locks[id]
raise "Unlocked write on machine: #{id}"
end
with_index_lock do
# Reload so we have the latest machine data, then update
# this particular machine, then write. This allows other processes
# to update their own machines without conflicting with our own.
unlocked_reload
@machines[id] = struct
unlocked_save
end
end
Entry.new(id, struct) Entry.new(id, struct)
end end
# Saves the index. protected
# Locks a machine exclusively to us, returning the file handle
# that holds the lock.
# #
# This doesn't usually need to be called because {#set} will # If the lock cannot be acquired, then nil is returned.
# automatically save as well. #
def save # @return [File]
@data_file.open("w") do |f| def lock_machine(uuid)
lock_path = @data_dir.join("#{uuid}.lock")
lock_file = lock_path.open("w+")
if lock_file.flock(File::LOCK_EX | File::LOCK_NB) === false
lock_file.close
lock_file = nil
end
lock_file
end
# This will reload the data without locking the index. It is assumed
# the caller with lock the index outside of this call.
#
# @param [File] f
def unlocked_reload
return if !@index_file.file?
data = nil
begin
data = JSON.load(@index_file.read)
rescue JSON::ParserError
raise Errors::CorruptMachineIndex, path: @index_file.to_s
end
if data
if !data["version"] || data["version"].to_i != 1
raise Errors::CorruptMachineIndex, path: @index_file.to_s
end
@machines = data["machines"] || {}
end
end
# Saves the index.
def unlocked_save
@index_file.open("w") do |f|
f.write(JSON.dump({ f.write(JSON.dump({
"version" => 1, "version" => 1,
"machines" => @machines, "machines" => @machines,
@ -104,6 +203,16 @@ module Vagrant
end end
end end
# This will hold a lock to the index so it can be read or updated.
def with_index_lock
lock_path = "#{@index_file}.lock"
File.open(lock_path, "w+") do |f|
f.flock(File::LOCK_EX)
yield
end
end
# An entry in the MachineIndex. # An entry in the MachineIndex.
class Entry class Entry
# The unique ID for this entry. This is _not_ the ID for the # The unique ID for this entry. This is _not_ the ID for the

View File

@ -729,6 +729,14 @@ en:
Guest-specific operations were attempted on a machine that is not Guest-specific operations were attempted on a machine that is not
ready for guest communication. This should not happen and a bug ready for guest communication. This should not happen and a bug
should be reported. should be reported.
machine_locked: |-
Vagrant can't use the requested machine because it is locekd! This
means that another Vagrant process is currently reading or modifying
the machine. Please wait for that Vagrant process to end and try
again. Details about the machine are shown below:
Name: %{name}
Provider: %{provider}
machine_not_found: |- machine_not_found: |-
The machine with the name '%{name}' was not found configured for The machine with the name '%{name}' was not found configured for
this Vagrant environment. this Vagrant environment.

View File

@ -9,12 +9,12 @@ require "vagrant/machine_index"
describe Vagrant::MachineIndex do describe Vagrant::MachineIndex do
include_context "unit" include_context "unit"
let(:data_file) { temporary_file } let(:data_dir) { temporary_dir }
subject { described_class.new(data_file) } subject { described_class.new(data_dir) }
it "raises an exception if the data file is corrupt" do it "raises an exception if the data file is corrupt" do
data_file.open("w") do |f| data_dir.join("index").open("w") do |f|
f.write(JSON.dump({})) f.write(JSON.dump({}))
end end
@ -23,7 +23,7 @@ describe Vagrant::MachineIndex do
end end
it "raises an exception if the JSON is invalid" do it "raises an exception if the JSON is invalid" do
data_file.open("w") do |f| data_dir.join("index").open("w") do |f|
f.write("foo") f.write("foo")
end end
@ -31,7 +31,7 @@ describe Vagrant::MachineIndex do
to raise_error(Vagrant::Errors::CorruptMachineIndex) to raise_error(Vagrant::Errors::CorruptMachineIndex)
end end
describe "#[]" do describe "#get and #release" do
before do before do
data = { data = {
"version" => 1, "version" => 1,
@ -46,17 +46,17 @@ describe Vagrant::MachineIndex do
} }
} }
data_file.open("w") do |f| data_dir.join("index").open("w") do |f|
f.write(JSON.dump(data)) f.write(JSON.dump(data))
end end
end end
it "returns nil if the machine doesn't exist" do it "returns nil if the machine doesn't exist" do
expect(subject["foo"]).to be_nil expect(subject.get("foo")).to be_nil
end end
it "returns a valid entry if the machine exists" do it "returns a valid entry if the machine exists" do
result = subject["bar"] result = subject.get("bar")
expect(result.id).to eq("bar") expect(result.id).to eq("bar")
expect(result.name).to eq("default") expect(result.name).to eq("default")
@ -65,22 +65,47 @@ describe Vagrant::MachineIndex do
expect(result.state).to eq("running") expect(result.state).to eq("running")
expect(result.updated_at).to eq("foo") expect(result.updated_at).to eq("foo")
end end
it "locks the entry so subsequent gets fail" do
result = subject.get("bar")
expect(result).to_not be_nil
expect { subject.get("bar") }.
to raise_error(Vagrant::Errors::MachineLocked)
end
it "can unlock a machine" do
result = subject.get("bar")
expect(result).to_not be_nil
subject.release(result)
result = subject.get("bar")
expect(result).to_not be_nil
end
end end
describe "#set and #[]" do describe "#set and #get" do
let(:entry_klass) { Vagrant::MachineIndex::Entry } let(:entry_klass) { Vagrant::MachineIndex::Entry }
it "adds a new entry" do let(:new_entry) do
entry = entry_klass.new entry_klass.new.tap do |e|
entry.name = "foo" e.name = "foo"
entry.vagrantfile_path = "/bar" e.vagrantfile_path = "/bar"
end
end
result = subject.set(entry) it "adds a new entry" do
result = subject.set(new_entry)
expect(result.id).to_not be_empty expect(result.id).to_not be_empty
# It should be locked
expect { subject.get(result.id) }.
to raise_error(Vagrant::Errors::MachineLocked)
# Get it froma new class and check the results # Get it froma new class and check the results
subject = described_class.new(data_file) subject.release(result)
entry = subject[result.id] subject = described_class.new(data_dir)
entry = subject.get(result.id)
expect(entry).to_not be_nil expect(entry).to_not be_nil
expect(entry.name).to eq("foo") expect(entry.name).to eq("foo")
@ -100,9 +125,12 @@ describe Vagrant::MachineIndex do
nextresult = subject.set(result) nextresult = subject.set(result)
expect(nextresult.id).to eq(result.id) expect(nextresult.id).to eq(result.id)
# Release it so we can test the contents
subject.release(nextresult)
# Get it froma new class and check the results # Get it froma new class and check the results
subject = described_class.new(data_file) subject = described_class.new(data_dir)
entry = subject[result.id] entry = subject.get(result.id)
expect(entry).to_not be_nil expect(entry).to_not be_nil
expect(entry.name).to eq("bar") expect(entry.name).to eq("bar")
end end