Class: Agent::Lock::Manager

Inherits:
Object
  • Object
show all
Defined in:
lib/agent/lock/manager.rb

Overview

Every decision this gem makes, with nothing printed.

Each verb returns a Result: a status the caller switches on, the records involved, and the exit code the CLI should hand back. Keeping the decisions here and the wording in the commands is what lets the whole lifecycle be tested without capturing output.

Defined Under Namespace

Classes: Result

Constant Summary collapse

DEFAULT_STALE_MINUTES =
120

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(tree: Tree.for, identity: Identity.current, stale_minutes: nil, store: nil) ⇒ Manager

Returns a new instance of Manager.



31
32
33
34
35
36
# File 'lib/agent/lock/manager.rb', line 31

def initialize(tree: Tree.for, identity: Identity.current, stale_minutes: nil, store: nil)
  @tree = tree
  @store = store || Store.for(tree)
  @identity = identity
  @stale_minutes = stale_minutes
end

Instance Attribute Details

#identity ⇒ Object (readonly)

Returns the value of attribute identity.



29
30
31
# File 'lib/agent/lock/manager.rb', line 29

def identity
  @identity
end

#store ⇒ Object (readonly)

Returns the value of attribute store.



29
30
31
# File 'lib/agent/lock/manager.rb', line 29

def store
  @store
end

#tree ⇒ Object (readonly)

Returns the value of attribute tree.



29
30
31
# File 'lib/agent/lock/manager.rb', line 29

def tree
  @tree
end

Instance Method Details

#acquire(path, intent: "unspecified", enforce: false, force: false) ⇒ Result

Claim a scope, unless something overlapping stands in the way.

The scan for conflicts and the write it justifies happen inside the store's mutex. create refuses only an identical scope, so without it ten sessions claiming lib/** and lib/a.rb at once each saw a clear field and each wrote a lock, leaving overlapping claims that all reported success. Freezing stays outside: it walks the tree and runs chflags, and every other session would be waiting on it.

Parameters:

  • path (String) —

    a path or a glob

  • intent (String) (defaults to: "unspecified")
  • enforce (Boolean) (defaults to: false) —

    also make the matched files unwritable

  • force (Boolean) (defaults to: false) —

    freeze even a very wide scope

Returns:

  • (Result) —

    :acquired, or :already_mine, :held, :parent_scope or :interrupted with the records that explain why not



60
61
62
63
64
65
66
# File 'lib/agent/lock/manager.rb', line 60

def acquire(path, intent: "unspecified", enforce: false, force: false)
  scope = Scope.parse(path, tree: tree)
  outcome = store.synchronize { take(scope, intent) }
  return outcome unless enforce && outcome.status == :acquired

  enforce_on(outcome.record, scope, force: force)
end

#break_lock(path) ⇒ Result

Parameters:

  • path (String)

Returns:



118
119
120
121
122
123
124
125
# File 'lib/agent/lock/manager.rb', line 118

def break_lock(path)
  scope = Scope.parse(path, tree: tree)
  record = store.find(scope)
  return result(:not_found, []) if record.nil?

  drop(record)
  result(:broken, [record])
end

#check(path) ⇒ Result

Whether this session may write in a scope. A parent's lock alone does not make it :mine, since the child still has to claim its own corner before its siblings can see it there; to the child, that scope is :free to claim.

Parameters:

  • path (String)

Returns:

  • (Result) —

    :held, :mine or :free



87
88
89
90
91
92
93
94
95
# File 'lib/agent/lock/manager.rb', line 87

def check(path)
  scope = Scope.parse(path, tree: tree)
  reap
  blocking, family = conflicts(scope).partition { |record| record.blocks?(identity) }
  return result(:held, blocking) if blocking.any?

  own = family.select { |record| record.held_by?(identity) }
  own.any? ? result(:mine, own) : result(:free, [])
end

#list ⇒ Result

Returns every lock in the store, this tree's siblings included.

Returns:

  • (Result) —

    every lock in the store, this tree's siblings included



98
99
100
101
# File 'lib/agent/lock/manager.rb', line 98

def list
  reap
  result(:listed, store.all)
end

#mine ⇒ Result

Returns:



104
105
106
107
# File 'lib/agent/lock/manager.rb', line 104

def mine
  reap
  result(:listed, store.all.select { |record| record.held_by?(identity) })
end

#note(path, text) ⇒ Result

Write a line into a lock this session holds, so that a machine coming back up has something better than the diff to work out where it was.

Parameters:

  • path (String)
  • text (String)

Returns:



153
154
155
156
157
158
159
160
161
162
# File 'lib/agent/lock/manager.rb', line 153

def note(path, text)
  scope = Scope.parse(path, tree: tree)
  record = store.find(scope)
  return result(:not_found, []) if record.nil?
  return result(:refused, [record]) unless record.held_by?(identity)

  updated = record.note(text)
  store.update(updated)
  result(:noted, [updated])
end

#reap ⇒ Array<Record>

Locks whose holder is provably gone, or, for a holder on another host that cannot be asked, that nobody has touched in so long that nobody can say. A live holder's lock is never among them, however old; see Record#expired?. Reaped before any decision that depends on them, never on a schedule.

A lock with nothing written in it is deleted. One whose holder wrote down what it was doing is orphaned instead: the process is gone, the claim is void, but the notes are the only record of work interrupted halfway, and a reboot is the most likely reason there are any. Whoever comes next sees them, and resume takes the lock and the notes back.

Returns:

  • (Array<Record>) —

    what it cleared out of the way



140
141
142
143
144
145
# File 'lib/agent/lock/manager.rb', line 140

def reap
  store.all.select { |record| record.active? && record.expired?(stale_minutes) }.map do |record|
    record.notes? ? orphan(record) : drop(record)
    record
  end
end

#release(path) ⇒ Result

Parameters:

  • path (String)

Returns:



70
71
72
73
74
75
76
77
78
# File 'lib/agent/lock/manager.rb', line 70

def release(path)
  scope = Scope.parse(path, tree: tree)
  record = store.find(scope)
  return result(:not_found, []) if record.nil?
  return result(:refused, [record]) unless record.held_by?(identity)

  drop(record)
  result(:released, [record])
end

#release_all ⇒ Result

Returns:



110
111
112
113
114
# File 'lib/agent/lock/manager.rb', line 110

def release_all
  held = store.all.select { |record| record.held_by?(identity) }
  held.each { |record| drop(record) }
  result(:released, held)
end

#resume(path) ⇒ Result

Take an interrupted lock back, notes and all.

Inside the store's mutex, because the check and the write are two steps: two sessions resuming one orphan would otherwise both find it interrupted, both write themselves in, and both report :resumed.

Parameters:

  • path (String)

Returns:



172
173
174
175
176
177
178
179
180
181
182
# File 'lib/agent/lock/manager.rb', line 172

def resume(path)
  scope = Scope.parse(path, tree: tree)
  store.synchronize do
    record = store.find(scope)
    next result(:not_found, []) if record.nil? || record.active?

    adopted = record.with(status: Record::ACTIVE, updated_at: Time.now.utc.iso8601, **claim)
    store.update(adopted)
    result(:resumed, [adopted])
  end
end

#stale_minutes ⇒ Integer

Returns how long a lock whose holder cannot be checked is trusted for, and how long a live one goes untouched before it is reported stale.

Returns:

  • (Integer) —

    how long a lock whose holder cannot be checked is trusted for, and how long a live one goes untouched before it is reported stale



41
42
43
# File 'lib/agent/lock/manager.rb', line 41

def stale_minutes
  @stale_minutes ||= Integer(ENV.fetch("AGENT_LOCK_STALE_MINUTES", DEFAULT_STALE_MINUTES))
end