philiprehberger-lock_kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4323796d385deb9f9a2280dbd05aad5a442738b62fd126f2a9309ded2009d4e7
4
- data.tar.gz: a552375e35331d9bcbeb02d7ffd09a1c27c9edd26114eb76a299cae9ff54f0b9
3
+ metadata.gz: c9a963709d5941abac8fdbbb3c6fe9cf95505b182683a0156d72b31cd1f5bcd7
4
+ data.tar.gz: d75615d7d5009c958771c6c27defb306727fe937832a1320d233c2c890440fc7
5
5
  SHA512:
6
- metadata.gz: cd045a2839a2885aa218b11146c2af5c43ee04de2c13934ba1c4dbb02e587bd9f6d1491a7f0cce7d31c8e01f6646cd54e0a45780623e5af0cc8631b1d5b2002c
7
- data.tar.gz: 5f309ce7a1b7d03c4c86686e23766264e51118b3f8abc13c3c8e7cebd5ac9ef0bac66f07ebc544f06f753b0bf4cd6e33173278341cd71c79a56a1c9c948d473a
6
+ metadata.gz: 2bee02f21982407cee48091a6af645609e9ea19985f11a452eaa700eacc3ca362c0fed2710980a675fc7c67c0d46c33a18fd91bfea75036fb19ce52f30e7738d
7
+ data.tar.gz: 4cfdd43296b2de0c60b78ca6e96336607d2c84cfce5088ee161888680e30309aa53d1d33cc498e2d571f4d416f7c2bbdc1a5a2eeee60ec90881bced4d53f5d53
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-03-28
11
+
12
+ ### Added
13
+ - `LockKit.owner(path)` for inspecting lock holder metadata (PID, hostname, timestamp)
14
+ - `auto_cleanup:` option for automatic stale lock removal
15
+ - `LockKit.with_read_lock` and `LockKit.with_write_lock` for shared/exclusive locking
16
+ - `on_wait:` callback option for monitoring lock contention
17
+ - `LockKit.break!(path, force:)` for manual lock recovery
18
+
19
+ ## [0.1.1] - 2026-03-26
20
+
21
+ ### Added
22
+
23
+ - Add GitHub funding configuration
24
+
10
25
  ## [0.1.0] - 2026-03-26
11
26
 
12
27
  ### Added
data/README.md CHANGED
@@ -2,10 +2,14 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-lock-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-lock-kit/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-lock_kit.svg)](https://rubygems.org/gems/philiprehberger-lock_kit)
5
+ [![GitHub release](https://img.shields.io/github/v/release/philiprehberger/rb-lock-kit)](https://github.com/philiprehberger/rb-lock-kit/releases)
6
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-lock-kit)](https://github.com/philiprehberger/rb-lock-kit/commits/main)
5
7
  [![License](https://img.shields.io/github/license/philiprehberger/rb-lock-kit)](LICENSE)
8
+ [![Bug Reports](https://img.shields.io/github/issues/philiprehberger/rb-lock-kit/bug)](https://github.com/philiprehberger/rb-lock-kit/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
9
+ [![Feature Requests](https://img.shields.io/github/issues/philiprehberger/rb-lock-kit/enhancement)](https://github.com/philiprehberger/rb-lock-kit/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
6
10
  [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
7
11
 
8
- File-based and PID locking for process coordination
12
+ File-based and PID locking for process coordination with stale lock detection, read-write locks, and lock owner identification.
9
13
 
10
14
  ## Requirements
11
15
 
@@ -16,7 +20,7 @@ File-based and PID locking for process coordination
16
20
  Add to your Gemfile:
17
21
 
18
22
  ```ruby
19
- gem "philiprehberger-lock_kit"
23
+ gem 'philiprehberger-lock_kit'
20
24
  ```
21
25
 
22
26
  Or install directly:
@@ -28,9 +32,9 @@ gem install philiprehberger-lock_kit
28
32
  ## Usage
29
33
 
30
34
  ```ruby
31
- require "philiprehberger/lock_kit"
35
+ require 'philiprehberger/lock_kit'
32
36
 
33
- Philiprehberger::LockKit.with_file_lock("/tmp/my.lock") do
37
+ Philiprehberger::LockKit.with_file_lock('/tmp/my.lock') do
34
38
  # exclusive work here
35
39
  end
36
40
  ```
@@ -38,7 +42,7 @@ end
38
42
  ### File Locking with Timeout
39
43
 
40
44
  ```ruby
41
- Philiprehberger::LockKit.with_file_lock("/tmp/my.lock", timeout: 5) do
45
+ Philiprehberger::LockKit.with_file_lock('/tmp/my.lock', timeout: 5) do
42
46
  # waits up to 5 seconds for the lock
43
47
  end
44
48
  ```
@@ -46,15 +50,67 @@ end
46
50
  ### PID File Locking
47
51
 
48
52
  ```ruby
49
- Philiprehberger::LockKit.with_pid_lock("my_worker") do
53
+ Philiprehberger::LockKit.with_pid_lock('my_worker') do
50
54
  # only one process with this name can run at a time
51
55
  end
52
56
  ```
53
57
 
58
+ ### Read-Write Locks
59
+
60
+ ```ruby
61
+ # Multiple readers can hold the lock concurrently
62
+ Philiprehberger::LockKit.with_read_lock('/tmp/data.lock', timeout: 5) do
63
+ # read shared data
64
+ end
65
+
66
+ # Write lock is exclusive — no readers or other writers allowed
67
+ Philiprehberger::LockKit.with_write_lock('/tmp/data.lock', timeout: 5) do
68
+ # write shared data
69
+ end
70
+ ```
71
+
72
+ ### Automatic Stale Lock Cleanup
73
+
74
+ ```ruby
75
+ Philiprehberger::LockKit.with_file_lock('/tmp/my.lock', auto_cleanup: true) do
76
+ # automatically removes stale locks from dead processes
77
+ end
78
+
79
+ Philiprehberger::LockKit.with_pid_lock('my_worker', auto_cleanup: true) do
80
+ # same for PID locks
81
+ end
82
+ ```
83
+
84
+ ### Lock Owner Identification
85
+
86
+ ```ruby
87
+ info = Philiprehberger::LockKit.owner('/tmp/my.lock')
88
+ # => { pid: 12345, hostname: 'web-01', acquired_at: 2026-03-28 12:00:00 +0000 }
89
+ ```
90
+
91
+ ### Lock Waiting with Callbacks
92
+
93
+ ```ruby
94
+ Philiprehberger::LockKit.with_file_lock('/tmp/my.lock', timeout: 10, on_wait: ->(elapsed) { puts "Waiting #{elapsed}s..." }) do
95
+ # callback fires every 0.5s while waiting
96
+ end
97
+ ```
98
+
99
+ ### Force Break Lock
100
+
101
+ ```ruby
102
+ # Break stale locks only (raises if held by a live process)
103
+ Philiprehberger::LockKit.break!('/tmp/my.lock')
104
+ # => { broken: true, previous_owner: { pid: 12345, hostname: 'web-01', acquired_at: ... } }
105
+
106
+ # Force break any lock regardless of status
107
+ Philiprehberger::LockKit.break!('/tmp/my.lock', force: true)
108
+ ```
109
+
54
110
  ### Manual File Lock
55
111
 
56
112
  ```ruby
57
- lock = Philiprehberger::LockKit::FileLock.new("/tmp/my.lock")
113
+ lock = Philiprehberger::LockKit::FileLock.new('/tmp/my.lock')
58
114
  lock.acquire(timeout: 10)
59
115
  # ... do work ...
60
116
  lock.release
@@ -63,7 +119,7 @@ lock.release
63
119
  ### Manual PID Lock
64
120
 
65
121
  ```ruby
66
- lock = Philiprehberger::LockKit::PidLock.new("my_worker", dir: "/var/run")
122
+ lock = Philiprehberger::LockKit::PidLock.new('my_worker', dir: '/var/run')
67
123
  lock.acquire
68
124
  # ... do work ...
69
125
  lock.release
@@ -72,8 +128,8 @@ lock.release
72
128
  ### Checking Lock Status
73
129
 
74
130
  ```ruby
75
- Philiprehberger::LockKit.locked?("/tmp/my.lock") # => true/false
76
- Philiprehberger::LockKit.stale?("/tmp/my_worker.pid") # => true/false
131
+ Philiprehberger::LockKit.locked?('/tmp/my.lock') # => true/false
132
+ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
77
133
  ```
78
134
 
79
135
  ## API
@@ -82,29 +138,46 @@ Philiprehberger::LockKit.stale?("/tmp/my_worker.pid") # => true/false
82
138
 
83
139
  | Method | Description |
84
140
  |--------|-------------|
85
- | `.with_file_lock(path, timeout: nil) { }` | Execute block with exclusive file lock |
86
- | `.with_pid_lock(name, dir: Dir.tmpdir) { }` | Execute block with PID file lock |
141
+ | `.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil) { }` | Execute block with exclusive file lock |
142
+ | `.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false) { }` | Execute block with PID file lock |
143
+ | `.with_read_lock(path, timeout: nil) { }` | Execute block with shared read lock |
144
+ | `.with_write_lock(path, timeout: nil) { }` | Execute block with exclusive write lock |
87
145
  | `.locked?(path)` | Check if a file is currently locked |
88
146
  | `.stale?(pid_file)` | Check if a PID file references a dead process |
147
+ | `.owner(path)` | Get lock owner metadata (pid, hostname, acquired_at) |
148
+ | `.break!(path, force: false)` | Break a lock (stale only by default, any with force) |
89
149
 
90
150
  ### `LockKit::FileLock`
91
151
 
92
152
  | Method | Description |
93
153
  |--------|-------------|
94
154
  | `.new(path)` | Create a file lock instance |
95
- | `#acquire(timeout: nil)` | Acquire exclusive lock, optional timeout in seconds |
155
+ | `#acquire(timeout: nil, auto_cleanup: false, on_wait: nil)` | Acquire exclusive lock with optional timeout, cleanup, and wait callback |
96
156
  | `#release` | Release the lock and close the file handle |
97
157
  | `#locked?` | Check if the file is currently locked |
158
+ | `#owner` | Get lock owner metadata |
98
159
 
99
160
  ### `LockKit::PidLock`
100
161
 
101
162
  | Method | Description |
102
163
  |--------|-------------|
103
164
  | `.new(name, dir: Dir.tmpdir)` | Create a PID lock instance |
104
- | `#acquire` | Acquire PID lock, raises if held by a living process |
165
+ | `#acquire(auto_cleanup: false)` | Acquire PID lock, raises if held by a living process |
105
166
  | `#release` | Release lock and remove PID file |
106
167
  | `#locked?` | Check if lock is held by a living process |
107
168
  | `#stale?` | Check if PID file references a dead process |
169
+ | `#owner` | Get lock owner metadata |
170
+
171
+ ### `LockKit::ReadWriteLock`
172
+
173
+ | Method | Description |
174
+ |--------|-------------|
175
+ | `.new(path)` | Create a read-write lock instance |
176
+ | `#acquire_read(timeout: nil)` | Acquire shared read lock |
177
+ | `#release_read` | Release the read lock |
178
+ | `#acquire_write(timeout: nil)` | Acquire exclusive write lock |
179
+ | `#release_write` | Release the write lock |
180
+ | `#reader_count` | Get current number of active readers |
108
181
 
109
182
  ## Development
110
183
 
@@ -114,6 +187,13 @@ bundle exec rspec
114
187
  bundle exec rubocop
115
188
  ```
116
189
 
190
+ ## Support
191
+
192
+ If you find this package useful, consider giving it a star on GitHub — it helps motivate continued maintenance and development.
193
+
194
+ [![LinkedIn](https://img.shields.io/badge/Philip%20Rehberger-LinkedIn-0A66C2?logo=linkedin)](https://www.linkedin.com/in/philiprehberger)
195
+ [![More packages](https://img.shields.io/badge/more-open%20source%20packages-blue)](https://philiprehberger.com/open-source-packages)
196
+
117
197
  ## License
118
198
 
119
199
  [MIT](LICENSE)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'socket'
5
+
3
6
  module Philiprehberger
4
7
  module LockKit
5
8
  # Exclusive file lock using flock(2)
@@ -10,15 +13,22 @@ module Philiprehberger
10
13
  # @param path [String] path to the lock file
11
14
  def initialize(path)
12
15
  @path = path
16
+ @meta_path = "#{path}.meta"
13
17
  @file = nil
14
18
  end
15
19
 
16
20
  # Acquire an exclusive lock on the file
17
21
  #
18
22
  # @param timeout [Numeric, nil] seconds to wait before raising; nil means non-blocking single attempt
23
+ # @param auto_cleanup [Boolean] when true, check for stale locks and remove them
24
+ # @param on_wait [Proc, nil] callback invoked every 0.5s while waiting; receives elapsed seconds
19
25
  # @return [true] when the lock is acquired
20
26
  # @raise [LockKit::Error] if the lock cannot be acquired
21
- def acquire(timeout: nil)
27
+ def acquire(timeout: nil, auto_cleanup: false, on_wait: nil)
28
+ if auto_cleanup
29
+ cleanup_stale_lock
30
+ end
31
+
22
32
  @file = File.open(@path, File::CREAT | File::RDWR)
23
33
 
24
34
  if timeout.nil?
@@ -28,18 +38,29 @@ module Philiprehberger
28
38
  end
29
39
  else
30
40
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
41
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ last_callback = start_time
31
43
 
32
44
  until @file.flock(File::LOCK_EX | File::LOCK_NB)
33
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
+ remaining = deadline - now
47
+
34
48
  if remaining <= 0
35
49
  close_file
36
50
  raise Error, "Timeout acquiring lock on #{@path} after #{timeout}s"
37
51
  end
38
52
 
53
+ if on_wait && (now - last_callback) >= 0.5
54
+ elapsed = (now - start_time).round(1)
55
+ on_wait.call(elapsed)
56
+ last_callback = now
57
+ end
58
+
39
59
  sleep [0.05, remaining].min
40
60
  end
41
61
  end
42
62
 
63
+ write_metadata
43
64
  true
44
65
  end
45
66
 
@@ -49,6 +70,7 @@ module Philiprehberger
49
70
  def release
50
71
  return unless @file
51
72
 
73
+ remove_metadata
52
74
  @file.flock(File::LOCK_UN)
53
75
  close_file
54
76
  end
@@ -74,8 +96,71 @@ module Philiprehberger
74
96
  end
75
97
  end
76
98
 
99
+ # Read lock owner metadata
100
+ #
101
+ # @return [Hash, nil] hash with :pid, :hostname, :acquired_at keys or nil
102
+ def owner
103
+ return nil unless File.exist?(@meta_path)
104
+
105
+ content = File.read(@meta_path).strip
106
+ return nil if content.empty?
107
+
108
+ data = JSON.parse(content)
109
+ {
110
+ pid: data['pid'],
111
+ hostname: data['hostname'],
112
+ acquired_at: data['acquired_at'] ? Time.parse(data['acquired_at']) : nil
113
+ }
114
+ rescue JSON::ParserError, Errno::ENOENT
115
+ nil
116
+ end
117
+
77
118
  private
78
119
 
120
+ def write_metadata
121
+ metadata = {
122
+ 'pid' => Process.pid,
123
+ 'hostname' => Socket.gethostname,
124
+ 'acquired_at' => Time.now.iso8601
125
+ }
126
+ File.write(@meta_path, JSON.generate(metadata))
127
+ end
128
+
129
+ def remove_metadata
130
+ FileUtils.rm_f(@meta_path)
131
+ rescue Errno::ENOENT
132
+ nil
133
+ end
134
+
135
+ def cleanup_stale_lock
136
+ return unless File.exist?(@path)
137
+
138
+ meta_path = "#{@path}.meta"
139
+ return unless File.exist?(meta_path)
140
+
141
+ content = File.read(meta_path).strip
142
+ return if content.empty?
143
+
144
+ data = JSON.parse(content)
145
+ pid = data['pid']
146
+ return unless pid
147
+
148
+ unless process_alive?(pid)
149
+ FileUtils.rm_f(meta_path)
150
+ end
151
+ rescue JSON::ParserError, Errno::ENOENT
152
+ nil
153
+ end
154
+
155
+ def process_alive?(pid)
156
+ Process.kill(0, pid)
157
+ true
158
+ rescue Errno::ESRCH
159
+ false
160
+ rescue Errno::EPERM
161
+ true
162
+ end
163
+
79
164
  def close_file
80
165
  @file&.close
81
166
  @file = nil
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'socket'
3
5
  require 'tmpdir'
4
6
 
5
7
  module Philiprehberger
6
8
  module LockKit
7
9
  # PID-file based lock with stale process detection
8
10
  #
9
- # Writes the current process ID to a file. Other processes can check the
10
- # file to determine if the lock holder is still alive, enabling automatic
11
- # recovery from crashed processes.
11
+ # Writes the current process ID, hostname, and timestamp to a file in JSON
12
+ # format. Other processes can check the file to determine if the lock holder
13
+ # is still alive, enabling automatic recovery from crashed processes.
12
14
  class PidLock
13
15
  # @param name [String] lock name (used as the PID file basename)
14
16
  # @param dir [String] directory for the PID file (defaults to system tmpdir)
@@ -21,13 +23,15 @@ module Philiprehberger
21
23
 
22
24
  # Acquire the PID lock
23
25
  #
24
- # Creates a PID file containing the current process ID. If a PID file
25
- # already exists, checks whether the owning process is still alive. Stale
26
- # PID files from dead processes are automatically cleaned up.
26
+ # Creates a PID file containing metadata (PID, hostname, timestamp) in
27
+ # JSON format. If a PID file already exists, checks whether the owning
28
+ # process is still alive. Stale PID files from dead processes are
29
+ # automatically cleaned up.
27
30
  #
31
+ # @param auto_cleanup [Boolean] when true, automatically remove stale locks
28
32
  # @return [true] when the lock is acquired
29
33
  # @raise [LockKit::Error] if the lock is held by a living process
30
- def acquire
34
+ def acquire(auto_cleanup: false)
31
35
  if File.exist?(@pid_path)
32
36
  existing_pid = read_pid
33
37
 
@@ -39,7 +43,12 @@ module Philiprehberger
39
43
  FileUtils.rm_f(@pid_path)
40
44
  end
41
45
 
42
- File.write(@pid_path, Process.pid.to_s)
46
+ metadata = {
47
+ 'pid' => Process.pid,
48
+ 'hostname' => Socket.gethostname,
49
+ 'acquired_at' => Time.now.iso8601
50
+ }
51
+ File.write(@pid_path, JSON.generate(metadata))
43
52
  @acquired = true
44
53
  true
45
54
  end
@@ -81,6 +90,17 @@ module Philiprehberger
81
90
  !process_alive?(pid)
82
91
  end
83
92
 
93
+ # Read lock owner metadata from the PID file
94
+ #
95
+ # @return [Hash, nil] hash with :pid, :hostname, :acquired_at keys or nil
96
+ def owner
97
+ return nil unless File.exist?(@pid_path)
98
+
99
+ read_metadata
100
+ rescue Errno::ENOENT
101
+ nil
102
+ end
103
+
84
104
  private
85
105
 
86
106
  # @return [Integer, nil]
@@ -88,11 +108,37 @@ module Philiprehberger
88
108
  content = File.read(@pid_path).strip
89
109
  return nil if content.empty?
90
110
 
111
+ # Try JSON format first
112
+ data = JSON.parse(content)
113
+ data.is_a?(Hash) ? data['pid'] : Integer(data)
114
+ rescue JSON::ParserError
115
+ # Fall back to plain PID format for backwards compatibility
91
116
  Integer(content)
92
117
  rescue ArgumentError, Errno::ENOENT
93
118
  nil
94
119
  end
95
120
 
121
+ # @return [Hash, nil] parsed metadata with symbolized keys
122
+ def read_metadata
123
+ content = File.read(@pid_path).strip
124
+ return nil if content.empty?
125
+
126
+ data = JSON.parse(content)
127
+ {
128
+ pid: data['pid'],
129
+ hostname: data['hostname'],
130
+ acquired_at: data['acquired_at'] ? Time.parse(data['acquired_at']) : nil
131
+ }
132
+ rescue JSON::ParserError
133
+ # Plain PID format — limited metadata
134
+ pid = Integer(content, exception: false)
135
+ return nil unless pid
136
+
137
+ { pid: pid, hostname: nil, acquired_at: nil }
138
+ rescue Errno::ENOENT
139
+ nil
140
+ end
141
+
96
142
  # @param pid [Integer]
97
143
  # @return [Boolean]
98
144
  def process_alive?(pid)
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module LockKit
5
+ # Read-write lock supporting shared reads and exclusive writes
6
+ #
7
+ # Read locks are shared — multiple readers can hold the lock concurrently.
8
+ # Write locks are exclusive — no readers or other writers are allowed.
9
+ # Uses a `.readers` counter file alongside the lock file to track state.
10
+ class ReadWriteLock
11
+ # @param path [String] base path for the lock files
12
+ def initialize(path)
13
+ @path = path
14
+ @write_lock_path = "#{path}.write"
15
+ @readers_path = "#{path}.readers"
16
+ @write_file = nil
17
+ end
18
+
19
+ # Acquire a shared read lock
20
+ #
21
+ # @param timeout [Numeric, nil] seconds to wait before raising
22
+ # @return [true] when the lock is acquired
23
+ # @raise [LockKit::Error] if a write lock is held and timeout expires
24
+ def acquire_read(timeout: nil)
25
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
26
+
27
+ loop do
28
+ # Check if a write lock is held
29
+ unless write_locked?
30
+ increment_readers
31
+ # Double-check no writer snuck in
32
+ unless write_locked?
33
+ return true
34
+ end
35
+
36
+ decrement_readers
37
+ end
38
+
39
+ raise Error, "Could not acquire read lock on #{@path}" unless deadline
40
+
41
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ if remaining <= 0
43
+ raise Error, "Timeout acquiring read lock on #{@path} after #{timeout}s"
44
+ end
45
+
46
+ sleep [0.05, remaining].min
47
+ end
48
+ end
49
+
50
+ # Release the shared read lock
51
+ #
52
+ # @return [void]
53
+ def release_read
54
+ decrement_readers
55
+ end
56
+
57
+ # Acquire an exclusive write lock
58
+ #
59
+ # @param timeout [Numeric, nil] seconds to wait before raising
60
+ # @return [true] when the lock is acquired
61
+ # @raise [LockKit::Error] if the lock cannot be acquired
62
+ def acquire_write(timeout: nil)
63
+ @write_file = File.open(@write_lock_path, File::CREAT | File::RDWR)
64
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
65
+
66
+ # First, acquire the write file lock
67
+ loop do
68
+ break if @write_file.flock(File::LOCK_EX | File::LOCK_NB)
69
+
70
+ if deadline
71
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ if remaining <= 0
73
+ close_write_file
74
+ raise Error, "Timeout acquiring write lock on #{@path} after #{timeout}s"
75
+ end
76
+
77
+ sleep [0.05, remaining].min
78
+ else
79
+ close_write_file
80
+ raise Error, "Could not acquire write lock on #{@path}"
81
+ end
82
+ end
83
+
84
+ # Then, wait for all readers to finish
85
+ loop do
86
+ break if reader_count.zero?
87
+
88
+ if deadline
89
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ if remaining <= 0
91
+ @write_file.flock(File::LOCK_UN)
92
+ close_write_file
93
+ raise Error, "Timeout acquiring write lock on #{@path} after #{timeout}s — readers still active"
94
+ end
95
+
96
+ sleep [0.05, remaining].min
97
+ else
98
+ @write_file.flock(File::LOCK_UN)
99
+ close_write_file
100
+ raise Error, "Could not acquire write lock on #{@path} — readers still active"
101
+ end
102
+ end
103
+
104
+ true
105
+ end
106
+
107
+ # Release the exclusive write lock
108
+ #
109
+ # @return [void]
110
+ def release_write
111
+ return unless @write_file
112
+
113
+ @write_file.flock(File::LOCK_UN)
114
+ close_write_file
115
+ end
116
+
117
+ # Return the current number of active readers
118
+ #
119
+ # @return [Integer]
120
+ def reader_count
121
+ return 0 unless File.exist?(@readers_path)
122
+
123
+ count = File.read(@readers_path).strip.to_i
124
+ count.negative? ? 0 : count
125
+ rescue Errno::ENOENT
126
+ 0
127
+ end
128
+
129
+ private
130
+
131
+ def write_locked?
132
+ return false unless File.exist?(@write_lock_path)
133
+
134
+ f = File.open(@write_lock_path, File::CREAT | File::RDWR)
135
+ got_lock = f.flock(File::LOCK_EX | File::LOCK_NB)
136
+ if got_lock
137
+ f.flock(File::LOCK_UN)
138
+ f.close
139
+ false
140
+ else
141
+ f.close
142
+ true
143
+ end
144
+ end
145
+
146
+ def increment_readers
147
+ update_reader_count(1)
148
+ end
149
+
150
+ def decrement_readers
151
+ update_reader_count(-1)
152
+ end
153
+
154
+ def update_reader_count(delta)
155
+ lock_path = "#{@readers_path}.lock"
156
+ File.open(lock_path, File::CREAT | File::RDWR) do |f|
157
+ f.flock(File::LOCK_EX)
158
+ current = begin
159
+ File.read(@readers_path).strip.to_i
160
+ rescue Errno::ENOENT
161
+ 0
162
+ end
163
+ new_count = [current + delta, 0].max
164
+ File.write(@readers_path, new_count.to_s)
165
+ f.flock(File::LOCK_UN)
166
+ end
167
+ end
168
+
169
+ def close_write_file
170
+ @write_file&.close
171
+ @write_file = nil
172
+ end
173
+ end
174
+ end
175
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module LockKit
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  require_relative 'lock_kit/version'
4
6
  require_relative 'lock_kit/file_lock'
5
7
  require_relative 'lock_kit/pid_lock'
8
+ require_relative 'lock_kit/read_write_lock'
6
9
 
7
10
  module Philiprehberger
8
11
  module LockKit
@@ -12,12 +15,14 @@ module Philiprehberger
12
15
  #
13
16
  # @param path [String] path to the lock file
14
17
  # @param timeout [Numeric, nil] seconds to wait before raising
18
+ # @param auto_cleanup [Boolean] automatically remove stale locks before acquiring
19
+ # @param on_wait [Proc, nil] callback invoked every 0.5s while waiting; receives elapsed seconds
15
20
  # @yield block to execute while the lock is held
16
21
  # @return [Object] the return value of the block
17
22
  # @raise [Error] if the lock cannot be acquired
18
- def self.with_file_lock(path, timeout: nil, &block)
23
+ def self.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, &block)
19
24
  lock = FileLock.new(path)
20
- lock.acquire(timeout: timeout)
25
+ lock.acquire(timeout: timeout, auto_cleanup: auto_cleanup, on_wait: on_wait)
21
26
  begin
22
27
  block.call
23
28
  ensure
@@ -29,12 +34,13 @@ module Philiprehberger
29
34
  #
30
35
  # @param name [String] lock name
31
36
  # @param dir [String] directory for the PID file
37
+ # @param auto_cleanup [Boolean] automatically remove stale locks before acquiring
32
38
  # @yield block to execute while the lock is held
33
39
  # @return [Object] the return value of the block
34
40
  # @raise [Error] if the lock is held by another process
35
- def self.with_pid_lock(name, dir: Dir.tmpdir, &block)
41
+ def self.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, &block)
36
42
  lock = PidLock.new(name, dir: dir)
37
- lock.acquire
43
+ lock.acquire(auto_cleanup: auto_cleanup)
38
44
  begin
39
45
  block.call
40
46
  ensure
@@ -42,6 +48,45 @@ module Philiprehberger
42
48
  end
43
49
  end
44
50
 
51
+ # Execute a block while holding a shared read lock
52
+ #
53
+ # Multiple readers can hold the lock concurrently. Blocks if a write lock
54
+ # is held.
55
+ #
56
+ # @param path [String] base path for the lock files
57
+ # @param timeout [Numeric, nil] seconds to wait before raising
58
+ # @yield block to execute while the read lock is held
59
+ # @return [Object] the return value of the block
60
+ # @raise [Error] if the lock cannot be acquired
61
+ def self.with_read_lock(path, timeout: nil, &block)
62
+ lock = ReadWriteLock.new(path)
63
+ lock.acquire_read(timeout: timeout)
64
+ begin
65
+ block.call
66
+ ensure
67
+ lock.release_read
68
+ end
69
+ end
70
+
71
+ # Execute a block while holding an exclusive write lock
72
+ #
73
+ # No readers or other writers are allowed while the write lock is held.
74
+ #
75
+ # @param path [String] base path for the lock files
76
+ # @param timeout [Numeric, nil] seconds to wait before raising
77
+ # @yield block to execute while the write lock is held
78
+ # @return [Object] the return value of the block
79
+ # @raise [Error] if the lock cannot be acquired
80
+ def self.with_write_lock(path, timeout: nil, &block)
81
+ lock = ReadWriteLock.new(path)
82
+ lock.acquire_write(timeout: timeout)
83
+ begin
84
+ block.call
85
+ ensure
86
+ lock.release_write
87
+ end
88
+ end
89
+
45
90
  # Check if a file is currently locked by another process
46
91
  #
47
92
  # @param path [String] path to the lock file
@@ -60,7 +105,14 @@ module Philiprehberger
60
105
  content = File.read(pid_file).strip
61
106
  return true if content.empty?
62
107
 
63
- pid = Integer(content)
108
+ # Try JSON format first
109
+ pid = begin
110
+ data = JSON.parse(content)
111
+ data.is_a?(Hash) ? data['pid'] : Integer(data)
112
+ rescue JSON::ParserError
113
+ Integer(content)
114
+ end
115
+
64
116
  Process.kill(0, pid)
65
117
  false
66
118
  rescue ArgumentError
@@ -70,5 +122,85 @@ module Philiprehberger
70
122
  rescue Errno::EPERM
71
123
  false
72
124
  end
125
+
126
+ # Get lock owner metadata
127
+ #
128
+ # @param path [String] path to the lock file or PID file
129
+ # @return [Hash, nil] hash with :pid, :hostname, :acquired_at keys or nil if not locked
130
+ def self.owner(path)
131
+ # Check for file lock metadata
132
+ meta_path = "#{path}.meta"
133
+ if File.exist?(meta_path)
134
+ content = File.read(meta_path).strip
135
+ unless content.empty?
136
+ data = JSON.parse(content)
137
+ return {
138
+ pid: data['pid'],
139
+ hostname: data['hostname'],
140
+ acquired_at: data['acquired_at'] ? Time.parse(data['acquired_at']) : nil
141
+ }
142
+ end
143
+ end
144
+
145
+ # Check for PID lock (JSON format)
146
+ if File.exist?(path)
147
+ content = File.read(path).strip
148
+ unless content.empty?
149
+ data = JSON.parse(content)
150
+ return {
151
+ pid: data['pid'],
152
+ hostname: data['hostname'],
153
+ acquired_at: data['acquired_at'] ? Time.parse(data['acquired_at']) : nil
154
+ }
155
+ end
156
+ end
157
+
158
+ nil
159
+ rescue JSON::ParserError, Errno::ENOENT
160
+ nil
161
+ end
162
+
163
+ # Force break a lock
164
+ #
165
+ # @param path [String] path to the lock file or PID file
166
+ # @param force [Boolean] when true, break any lock regardless of PID status
167
+ # @return [Hash] result with :broken and :previous_owner or :reason keys
168
+ # @raise [Error] if the lock is held by a live process and force is false
169
+ def self.break!(path, force: false)
170
+ meta_path = "#{path}.meta"
171
+
172
+ # Determine the previous owner
173
+ previous_owner = owner(path)
174
+
175
+ unless previous_owner || File.exist?(path)
176
+ return { broken: false, reason: 'not locked' }
177
+ end
178
+
179
+ # Check if the lock holder is alive
180
+ if previous_owner && previous_owner[:pid]
181
+ alive = begin
182
+ Process.kill(0, previous_owner[:pid])
183
+ true
184
+ rescue Errno::ESRCH
185
+ false
186
+ rescue Errno::EPERM
187
+ true
188
+ end
189
+
190
+ if alive && !force
191
+ raise Error, "Lock on #{path} is held by living process #{previous_owner[:pid]}"
192
+ end
193
+ end
194
+
195
+ # Break the lock
196
+ FileUtils.rm_f(path)
197
+ FileUtils.rm_f(meta_path)
198
+
199
+ if previous_owner
200
+ { broken: true, previous_owner: previous_owner }
201
+ else
202
+ { broken: true, previous_owner: nil }
203
+ end
204
+ end
73
205
  end
74
206
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-lock_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-03-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: File locks using flock and PID file locks with stale detection for coordinating
14
14
  between processes. Timeout support and automatic cleanup.
@@ -24,6 +24,7 @@ files:
24
24
  - lib/philiprehberger/lock_kit.rb
25
25
  - lib/philiprehberger/lock_kit/file_lock.rb
26
26
  - lib/philiprehberger/lock_kit/pid_lock.rb
27
+ - lib/philiprehberger/lock_kit/read_write_lock.rb
27
28
  - lib/philiprehberger/lock_kit/version.rb
28
29
  homepage: https://github.com/philiprehberger/rb-lock-kit
29
30
  licenses: