Module: Philiprehberger::LockKit

Defined in:
lib/philiprehberger/lock_kit.rb,
lib/philiprehberger/lock_kit/version.rb,
lib/philiprehberger/lock_kit/pid_lock.rb,
lib/philiprehberger/lock_kit/file_lock.rb,
lib/philiprehberger/lock_kit/read_write_lock.rb

Defined Under Namespace

Classes: Error, FileLock, PidLock, ReadWriteLock

Constant Summary collapse

VERSION =
'0.4.0'

Class Method Summary collapse

Class Method Details

.break!(path, force: false) ⇒ Hash

Force break a lock

Parameters:

  • path (String)

    path to the lock file or PID file

  • force (Boolean) (defaults to: false)

    when true, break any lock regardless of PID status

Returns:

  • (Hash)

    result with :broken and :previous_owner or :reason keys

Raises:

  • (Error)

    if the lock is held by a live process and force is false



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/philiprehberger/lock_kit.rb', line 227

def self.break!(path, force: false)
  meta_path = "#{path}.meta"

  # Determine the previous owner
  previous_owner = owner(path)

  unless previous_owner || File.exist?(path)
    return { broken: false, reason: 'not locked' }
  end

  # Check if the lock holder is alive
  if previous_owner && previous_owner[:pid]
    alive = begin
      Process.kill(0, previous_owner[:pid])
      true
    rescue Errno::ESRCH
      false
    rescue Errno::EPERM
      true
    end

    if alive && !force
      raise Error, "Lock on #{path} is held by living process #{previous_owner[:pid]}"
    end
  end

  # Break the lock
  FileUtils.rm_f(path)
  FileUtils.rm_f(meta_path)

  if previous_owner
    { broken: true, previous_owner: previous_owner }
  else
    { broken: true, previous_owner: nil }
  end
end

.expired?(path) ⇒ Boolean

Check if a lock has expired based on its TTL

Parameters:

  • path (String)

    path to the lock file or PID file

Returns:

  • (Boolean)

    true if the lock metadata contains an expires_at time that has passed



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/philiprehberger/lock_kit.rb', line 161

def self.expired?(path)
  # Check file lock metadata
  meta_path = "#{path}.meta"
  target = if File.exist?(meta_path)
             meta_path
           elsif File.exist?(path)
             path
           end

  return false unless target

  content = File.read(target).strip
  return false if content.empty?

  data = JSON.parse(content)
  expires_at = data['expires_at']
  return false unless expires_at

  Time.parse(expires_at) <= Time.now
rescue JSON::ParserError, Errno::ENOENT
  false
end

.locked?(path) ⇒ Boolean

Check if a file is currently locked by another process

Parameters:

  • path (String)

    path to the lock file

Returns:

  • (Boolean)


125
126
127
# File 'lib/philiprehberger/lock_kit.rb', line 125

def self.locked?(path)
  FileLock.new(path).locked?
end

.owner(path) ⇒ Hash?

Get lock owner metadata

Parameters:

  • path (String)

    path to the lock file or PID file

Returns:

  • (Hash, nil)

    hash with :pid, :hostname, :acquired_at keys or nil if not locked



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# File 'lib/philiprehberger/lock_kit.rb', line 188

def self.owner(path)
  # Check for file lock metadata
  meta_path = "#{path}.meta"
  if File.exist?(meta_path)
    content = File.read(meta_path).strip
    unless content.empty?
      data = JSON.parse(content)
      return {
        pid: data['pid'],
        hostname: data['hostname'],
        acquired_at: data['acquired_at'] ? Time.parse(data['acquired_at']) : nil
      }
    end
  end

  # Check for PID lock (JSON format)
  if File.exist?(path)
    content = File.read(path).strip
    unless content.empty?
      data = JSON.parse(content)
      return {
        pid: data['pid'],
        hostname: data['hostname'],
        acquired_at: data['acquired_at'] ? Time.parse(data['acquired_at']) : nil
      }
    end
  end

  nil
rescue JSON::ParserError, Errno::ENOENT
  nil
end

.stale?(pid_file) ⇒ Boolean

Check if a PID file references a dead process

Parameters:

  • pid_file (String)

    path to the PID file

Returns:

  • (Boolean)


133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/philiprehberger/lock_kit.rb', line 133

def self.stale?(pid_file)
  return false unless File.exist?(pid_file)

  content = File.read(pid_file).strip
  return true if content.empty?

  # Try JSON format first
  pid = begin
    data = JSON.parse(content)
    data.is_a?(Hash) ? data['pid'] : Integer(data)
  rescue JSON::ParserError
    Integer(content)
  end

  Process.kill(0, pid)
  false
rescue ArgumentError
  true
rescue Errno::ESRCH
  true
rescue Errno::EPERM
  false
end

.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil) { ... } ⇒ Object

Execute a block while holding an exclusive file lock

Parameters:

  • path (String)

    path to the lock file

  • timeout (Numeric, nil) (defaults to: nil)

    seconds to wait before raising

  • auto_cleanup (Boolean) (defaults to: false)

    automatically remove stale locks before acquiring

  • on_wait (Proc, nil) (defaults to: nil)

    callback invoked every 0.5s while waiting; receives elapsed seconds

  • ttl (Numeric, nil) (defaults to: nil)

    time-to-live in seconds; lock expires after this duration

Yields:

  • block to execute while the lock is held

Returns:

  • (Object)

    the return value of the block

Raises:

  • (Error)

    if the lock cannot be acquired



24
25
26
27
28
29
30
31
32
# File 'lib/philiprehberger/lock_kit.rb', line 24

def self.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil, &block)
  lock = FileLock.new(path)
  lock.acquire(timeout: timeout, auto_cleanup: auto_cleanup, on_wait: on_wait, ttl: ttl)
  begin
    block.call
  ensure
    lock.release
  end
end

.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, ttl: nil) { ... } ⇒ Object

Execute a block while holding a PID file lock

Parameters:

  • name (String)

    lock name

  • dir (String) (defaults to: Dir.tmpdir)

    directory for the PID file

  • auto_cleanup (Boolean) (defaults to: false)

    automatically remove stale locks before acquiring

  • ttl (Numeric, nil) (defaults to: nil)

    time-to-live in seconds; lock expires after this duration

Yields:

  • block to execute while the lock is held

Returns:

  • (Object)

    the return value of the block

Raises:

  • (Error)

    if the lock is held by another process



43
44
45
46
47
48
49
50
51
# File 'lib/philiprehberger/lock_kit.rb', line 43

def self.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, ttl: nil, &block)
  lock = PidLock.new(name, dir: dir)
  lock.acquire(auto_cleanup: auto_cleanup, ttl: ttl)
  begin
    block.call
  ensure
    lock.release
  end
end

.with_read_lock(path, timeout: nil) { ... } ⇒ Object

Execute a block while holding a shared read lock

Multiple readers can hold the lock concurrently. Blocks if a write lock is held.

Parameters:

  • path (String)

    base path for the lock files

  • timeout (Numeric, nil) (defaults to: nil)

    seconds to wait before raising

Yields:

  • block to execute while the read lock is held

Returns:

  • (Object)

    the return value of the block

Raises:

  • (Error)

    if the lock cannot be acquired



63
64
65
66
67
68
69
70
71
# File 'lib/philiprehberger/lock_kit.rb', line 63

def self.with_read_lock(path, timeout: nil, &block)
  lock = ReadWriteLock.new(path)
  lock.acquire_read(timeout: timeout)
  begin
    block.call
  ensure
    lock.release_read
  end
end

.with_retry_lock(path, retries: 3, delay: 0.1, backoff: 2, timeout: nil, auto_cleanup: true, ttl: nil) { ... } ⇒ Object

Execute a block with a file lock, retrying with exponential backoff on failure

Parameters:

  • path (String)

    path to the lock file

  • retries (Integer) (defaults to: 3)

    maximum number of attempts

  • delay (Numeric) (defaults to: 0.1)

    initial delay in seconds between retries

  • backoff (Numeric) (defaults to: 2)

    multiplier applied to delay after each failed attempt

  • timeout (Numeric, nil) (defaults to: nil)

    seconds to wait per acquire attempt

  • auto_cleanup (Boolean) (defaults to: true)

    automatically remove stale locks before acquiring

  • ttl (Numeric, nil) (defaults to: nil)

    time-to-live in seconds; lock expires after this duration

Yields:

  • block to execute while the lock is held

Returns:

  • (Object)

    the return value of the block

Raises:

  • (Error)

    if all retry attempts are exhausted



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/philiprehberger/lock_kit.rb', line 104

def self.with_retry_lock(path, retries: 3, delay: 0.1, backoff: 2, timeout: nil, auto_cleanup: true, ttl: nil, &block)
  attempts = 0
  current_delay = delay

  loop do
    attempts += 1
    begin
      return with_file_lock(path, timeout: timeout, auto_cleanup: auto_cleanup, ttl: ttl, &block)
    rescue Error
      raise if attempts >= retries

      sleep current_delay
      current_delay *= backoff
    end
  end
end

.with_write_lock(path, timeout: nil) { ... } ⇒ Object

Execute a block while holding an exclusive write lock

No readers or other writers are allowed while the write lock is held.

Parameters:

  • path (String)

    base path for the lock files

  • timeout (Numeric, nil) (defaults to: nil)

    seconds to wait before raising

Yields:

  • block to execute while the write lock is held

Returns:

  • (Object)

    the return value of the block

Raises:

  • (Error)

    if the lock cannot be acquired



82
83
84
85
86
87
88
89
90
# File 'lib/philiprehberger/lock_kit.rb', line 82

def self.with_write_lock(path, timeout: nil, &block)
  lock = ReadWriteLock.new(path)
  lock.acquire_write(timeout: timeout)
  begin
    block.call
  ensure
    lock.release_write
  end
end