philiprehberger-lock_kit 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +94 -14
- data/lib/philiprehberger/lock_kit/file_lock.rb +87 -2
- data/lib/philiprehberger/lock_kit/pid_lock.rb +54 -8
- data/lib/philiprehberger/lock_kit/read_write_lock.rb +175 -0
- data/lib/philiprehberger/lock_kit/version.rb +1 -1
- data/lib/philiprehberger/lock_kit.rb +137 -5
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9a963709d5941abac8fdbbb3c6fe9cf95505b182683a0156d72b31cd1f5bcd7
|
|
4
|
+
data.tar.gz: d75615d7d5009c958771c6c27defb306727fe937832a1320d233c2c890440fc7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bee02f21982407cee48091a6af645609e9ea19985f11a452eaa700eacc3ca362c0fed2710980a675fc7c67c0d46c33a18fd91bfea75036fb19ce52f30e7738d
|
|
7
|
+
data.tar.gz: 4cfdd43296b2de0c60b78ca6e96336607d2c84cfce5088ee161888680e30309aa53d1d33cc498e2d571f4d416f7c2bbdc1a5a2eeee60ec90881bced4d53f5d53
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ 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
|
+
|
|
10
19
|
## [0.1.1] - 2026-03-26
|
|
11
20
|
|
|
12
21
|
### Added
|
data/README.md
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/philiprehberger/rb-lock-kit/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-lock_kit)
|
|
5
|
+
[](https://github.com/philiprehberger/rb-lock-kit/releases)
|
|
6
|
+
[](https://github.com/philiprehberger/rb-lock-kit/commits/main)
|
|
5
7
|
[](LICENSE)
|
|
8
|
+
[](https://github.com/philiprehberger/rb-lock-kit/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
|
|
9
|
+
[](https://github.com/philiprehberger/rb-lock-kit/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
|
|
6
10
|
[](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
|
|
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
|
|
35
|
+
require 'philiprehberger/lock_kit'
|
|
32
36
|
|
|
33
|
-
Philiprehberger::LockKit.with_file_lock(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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?(
|
|
76
|
-
Philiprehberger::LockKit.stale?(
|
|
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
|
|
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
|
+
[](https://www.linkedin.com/in/philiprehberger)
|
|
195
|
+
[](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
|
-
|
|
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
|
|
10
|
-
# file to determine if the lock holder
|
|
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
|
|
25
|
-
# already exists, checks whether the owning
|
|
26
|
-
# PID files from dead processes are
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
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-
|
|
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:
|