philiprehberger-lock_kit 0.2.3 → 0.3.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 +11 -0
- data/README.md +27 -7
- data/lib/philiprehberger/lock_kit/file_lock.rb +34 -1
- data/lib/philiprehberger/lock_kit/pid_lock.rb +44 -8
- data/lib/philiprehberger/lock_kit/version.rb +1 -1
- data/lib/philiprehberger/lock_kit.rb +33 -4
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f82a00dac3cb9f7457b0d6e98d97a14a86502b1d2b2fe688b5bef0c07dcdbda1
|
|
4
|
+
data.tar.gz: 88e1f0ac7f71c7585e7730b3984a9d4646c63ec7324f23731d2c088806257a3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba54abec814f3c18e17f0942f6954b3d108f59ea302ab8ae82efa21afaf118d911b9cc1c6ad1b089ad15b89faacb1c586372cf0b85ac5fe9a2843c4d89ce5f19
|
|
7
|
+
data.tar.gz: 45248d0994c40fb782df7ce7185992c7a440267ad7fbc5bd43a27d2b0dc18b10172885b4b4c86aa9ddad550993a1ca661a43f979b7338d289fa0feffe128c5a9
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-04-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `ttl:` option for `with_file_lock`, `with_pid_lock`, `FileLock#acquire`, and `PidLock#acquire` — locks auto-expire after the specified duration
|
|
14
|
+
- `FileLock#expired?` and `PidLock#expired?` instance methods for checking TTL expiration
|
|
15
|
+
- `LockKit.expired?(path)` module-level method for checking lock expiration
|
|
16
|
+
- Stale lock cleanup now considers expired TTL locks as stale
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Align feature request issue template with guide structure
|
|
20
|
+
|
|
10
21
|
## [0.2.3] - 2026-04-09
|
|
11
22
|
|
|
12
23
|
### Fixed
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-lock_kit)
|
|
5
5
|
[](https://github.com/philiprehberger/rb-lock-kit/commits/main)
|
|
6
6
|
|
|
7
|
-
File-based and PID locking for process coordination with stale
|
|
7
|
+
File-based and PID locking for process coordination with TTL expiration, stale detection, read-write locks, and lock owner identification
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -64,6 +64,23 @@ Philiprehberger::LockKit.with_write_lock('/tmp/data.lock', timeout: 5) do
|
|
|
64
64
|
end
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
+
### Lock with TTL (Time-to-Live)
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# Lock expires after 30 seconds — treated as stale once elapsed
|
|
71
|
+
Philiprehberger::LockKit.with_file_lock('/tmp/my.lock', ttl: 30) do
|
|
72
|
+
# work that should not hold the lock indefinitely
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# PID locks also support TTL
|
|
76
|
+
Philiprehberger::LockKit.with_pid_lock('my_worker', ttl: 60) do
|
|
77
|
+
# expires after 60 seconds
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if a lock has expired
|
|
81
|
+
Philiprehberger::LockKit.expired?('/tmp/my.lock') # => true/false
|
|
82
|
+
```
|
|
83
|
+
|
|
67
84
|
### Automatic Stale Lock Cleanup
|
|
68
85
|
|
|
69
86
|
```ruby
|
|
@@ -133,12 +150,13 @@ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
|
|
|
133
150
|
|
|
134
151
|
| Method | Description |
|
|
135
152
|
|--------|-------------|
|
|
136
|
-
| `.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil) { }` | Execute block with exclusive file lock |
|
|
137
|
-
| `.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false) { }` | Execute block with PID file lock |
|
|
153
|
+
| `.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil) { }` | Execute block with exclusive file lock |
|
|
154
|
+
| `.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, ttl: nil) { }` | Execute block with PID file lock |
|
|
138
155
|
| `.with_read_lock(path, timeout: nil) { }` | Execute block with shared read lock |
|
|
139
156
|
| `.with_write_lock(path, timeout: nil) { }` | Execute block with exclusive write lock |
|
|
140
157
|
| `.locked?(path)` | Check if a file is currently locked |
|
|
141
158
|
| `.stale?(pid_file)` | Check if a PID file references a dead process |
|
|
159
|
+
| `.expired?(path)` | Check if a lock has expired based on its TTL |
|
|
142
160
|
| `.owner(path)` | Get lock owner metadata (pid, hostname, acquired_at) |
|
|
143
161
|
| `.break!(path, force: false)` | Break a lock (stale only by default, any with force) |
|
|
144
162
|
|
|
@@ -147,9 +165,10 @@ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
|
|
|
147
165
|
| Method | Description |
|
|
148
166
|
|--------|-------------|
|
|
149
167
|
| `.new(path)` | Create a file lock instance |
|
|
150
|
-
| `#acquire(timeout: nil, auto_cleanup: false, on_wait: nil)` | Acquire exclusive lock with optional timeout, cleanup,
|
|
168
|
+
| `#acquire(timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil)` | Acquire exclusive lock with optional timeout, cleanup, wait callback, and TTL |
|
|
151
169
|
| `#release` | Release the lock and close the file handle |
|
|
152
|
-
| `#locked?` | Check if the file is currently locked |
|
|
170
|
+
| `#locked?` | Check if the file is currently locked (false if expired) |
|
|
171
|
+
| `#expired?` | Check if the lock has expired based on its TTL |
|
|
153
172
|
| `#owner` | Get lock owner metadata |
|
|
154
173
|
|
|
155
174
|
### `LockKit::PidLock`
|
|
@@ -157,9 +176,10 @@ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
|
|
|
157
176
|
| Method | Description |
|
|
158
177
|
|--------|-------------|
|
|
159
178
|
| `.new(name, dir: Dir.tmpdir)` | Create a PID lock instance |
|
|
160
|
-
| `#acquire(auto_cleanup: false)` | Acquire PID lock, raises if held by a living process |
|
|
179
|
+
| `#acquire(auto_cleanup: false, ttl: nil)` | Acquire PID lock with optional TTL, raises if held by a living process |
|
|
161
180
|
| `#release` | Release lock and remove PID file |
|
|
162
|
-
| `#locked?` | Check if lock is held by a living process |
|
|
181
|
+
| `#locked?` | Check if lock is held by a living process (false if expired) |
|
|
182
|
+
| `#expired?` | Check if the lock has expired based on its TTL |
|
|
163
183
|
| `#stale?` | Check if PID file references a dead process |
|
|
164
184
|
| `#owner` | Get lock owner metadata |
|
|
165
185
|
|
|
@@ -22,9 +22,12 @@ module Philiprehberger
|
|
|
22
22
|
# @param timeout [Numeric, nil] seconds to wait before raising; nil means non-blocking single attempt
|
|
23
23
|
# @param auto_cleanup [Boolean] when true, check for stale locks and remove them
|
|
24
24
|
# @param on_wait [Proc, nil] callback invoked every 0.5s while waiting; receives elapsed seconds
|
|
25
|
+
# @param ttl [Numeric, nil] time-to-live in seconds; lock expires after this duration
|
|
25
26
|
# @return [true] when the lock is acquired
|
|
26
27
|
# @raise [LockKit::Error] if the lock cannot be acquired
|
|
27
|
-
def acquire(timeout: nil, auto_cleanup: false, on_wait: nil)
|
|
28
|
+
def acquire(timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil)
|
|
29
|
+
@ttl = ttl
|
|
30
|
+
|
|
28
31
|
if auto_cleanup
|
|
29
32
|
cleanup_stale_lock
|
|
30
33
|
end
|
|
@@ -79,10 +82,12 @@ module Philiprehberger
|
|
|
79
82
|
#
|
|
80
83
|
# Opens the file, attempts a non-blocking exclusive lock, and immediately
|
|
81
84
|
# releases it. Returns true if the lock attempt fails (file is locked).
|
|
85
|
+
# Returns false if the lock has expired (TTL elapsed).
|
|
82
86
|
#
|
|
83
87
|
# @return [Boolean]
|
|
84
88
|
def locked?
|
|
85
89
|
return false unless File.exist?(@path)
|
|
90
|
+
return false if expired?
|
|
86
91
|
|
|
87
92
|
f = File.open(@path, File::CREAT | File::RDWR)
|
|
88
93
|
got_lock = f.flock(File::LOCK_EX | File::LOCK_NB)
|
|
@@ -96,6 +101,24 @@ module Philiprehberger
|
|
|
96
101
|
end
|
|
97
102
|
end
|
|
98
103
|
|
|
104
|
+
# Check whether the lock has expired based on its TTL
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true if the lock metadata contains an expires_at time that has passed
|
|
107
|
+
def expired?
|
|
108
|
+
return false unless File.exist?(@meta_path)
|
|
109
|
+
|
|
110
|
+
content = File.read(@meta_path).strip
|
|
111
|
+
return false if content.empty?
|
|
112
|
+
|
|
113
|
+
data = JSON.parse(content)
|
|
114
|
+
expires_at = data['expires_at']
|
|
115
|
+
return false unless expires_at
|
|
116
|
+
|
|
117
|
+
Time.parse(expires_at) <= Time.now
|
|
118
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
99
122
|
# Read lock owner metadata
|
|
100
123
|
#
|
|
101
124
|
# @return [Hash, nil] hash with :pid, :hostname, :acquired_at keys or nil
|
|
@@ -123,6 +146,7 @@ module Philiprehberger
|
|
|
123
146
|
'hostname' => Socket.gethostname,
|
|
124
147
|
'acquired_at' => Time.now.iso8601
|
|
125
148
|
}
|
|
149
|
+
metadata['expires_at'] = (Time.now + @ttl).iso8601 if @ttl
|
|
126
150
|
File.write(@meta_path, JSON.generate(metadata))
|
|
127
151
|
end
|
|
128
152
|
|
|
@@ -142,6 +166,15 @@ module Philiprehberger
|
|
|
142
166
|
return if content.empty?
|
|
143
167
|
|
|
144
168
|
data = JSON.parse(content)
|
|
169
|
+
|
|
170
|
+
# Check TTL expiration
|
|
171
|
+
expires_at = data['expires_at']
|
|
172
|
+
if expires_at && Time.parse(expires_at) <= Time.now
|
|
173
|
+
FileUtils.rm_f(meta_path)
|
|
174
|
+
return
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check process liveness
|
|
145
178
|
pid = data['pid']
|
|
146
179
|
return unless pid
|
|
147
180
|
|
|
@@ -29,18 +29,26 @@ module Philiprehberger
|
|
|
29
29
|
# automatically cleaned up.
|
|
30
30
|
#
|
|
31
31
|
# @param auto_cleanup [Boolean] when true, automatically remove stale locks
|
|
32
|
+
# @param ttl [Numeric, nil] time-to-live in seconds; lock expires after this duration
|
|
32
33
|
# @return [true] when the lock is acquired
|
|
33
34
|
# @raise [LockKit::Error] if the lock is held by a living process
|
|
34
|
-
def acquire(auto_cleanup: false)
|
|
35
|
-
|
|
36
|
-
existing_pid = read_pid
|
|
35
|
+
def acquire(auto_cleanup: false, ttl: nil)
|
|
36
|
+
@ttl = ttl
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
if File.exist?(@pid_path)
|
|
39
|
+
# Check TTL expiration first
|
|
40
|
+
if lock_expired?
|
|
41
|
+
FileUtils.rm_f(@pid_path)
|
|
42
|
+
else
|
|
43
|
+
existing_pid = read_pid
|
|
44
|
+
|
|
45
|
+
if existing_pid && process_alive?(existing_pid)
|
|
46
|
+
raise Error, "Lock '#{@name}' is held by process #{existing_pid}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stale PID file — remove it
|
|
50
|
+
FileUtils.rm_f(@pid_path)
|
|
40
51
|
end
|
|
41
|
-
|
|
42
|
-
# Stale PID file — remove it
|
|
43
|
-
FileUtils.rm_f(@pid_path)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
metadata = {
|
|
@@ -48,6 +56,7 @@ module Philiprehberger
|
|
|
48
56
|
'hostname' => Socket.gethostname,
|
|
49
57
|
'acquired_at' => Time.now.iso8601
|
|
50
58
|
}
|
|
59
|
+
metadata['expires_at'] = (Time.now + ttl).iso8601 if ttl
|
|
51
60
|
File.write(@pid_path, JSON.generate(metadata))
|
|
52
61
|
@acquired = true
|
|
53
62
|
true
|
|
@@ -68,9 +77,12 @@ module Philiprehberger
|
|
|
68
77
|
|
|
69
78
|
# Check whether the lock is currently held by a living process
|
|
70
79
|
#
|
|
80
|
+
# Returns false if the lock has expired (TTL elapsed).
|
|
81
|
+
#
|
|
71
82
|
# @return [Boolean]
|
|
72
83
|
def locked?
|
|
73
84
|
return false unless File.exist?(@pid_path)
|
|
85
|
+
return false if expired?
|
|
74
86
|
|
|
75
87
|
pid = read_pid
|
|
76
88
|
return false unless pid
|
|
@@ -90,6 +102,13 @@ module Philiprehberger
|
|
|
90
102
|
!process_alive?(pid)
|
|
91
103
|
end
|
|
92
104
|
|
|
105
|
+
# Check whether the lock has expired based on its TTL
|
|
106
|
+
#
|
|
107
|
+
# @return [Boolean] true if the lock metadata contains an expires_at time that has passed
|
|
108
|
+
def expired?
|
|
109
|
+
lock_expired?
|
|
110
|
+
end
|
|
111
|
+
|
|
93
112
|
# Read lock owner metadata from the PID file
|
|
94
113
|
#
|
|
95
114
|
# @return [Hash, nil] hash with :pid, :hostname, :acquired_at keys or nil
|
|
@@ -103,6 +122,23 @@ module Philiprehberger
|
|
|
103
122
|
|
|
104
123
|
private
|
|
105
124
|
|
|
125
|
+
def lock_expired?
|
|
126
|
+
return false unless File.exist?(@pid_path)
|
|
127
|
+
|
|
128
|
+
content = File.read(@pid_path).strip
|
|
129
|
+
return false if content.empty?
|
|
130
|
+
|
|
131
|
+
data = JSON.parse(content)
|
|
132
|
+
return false unless data.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
expires_at = data['expires_at']
|
|
135
|
+
return false unless expires_at
|
|
136
|
+
|
|
137
|
+
Time.parse(expires_at) <= Time.now
|
|
138
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
106
142
|
# @return [Integer, nil]
|
|
107
143
|
def read_pid
|
|
108
144
|
content = File.read(@pid_path).strip
|
|
@@ -17,12 +17,13 @@ module Philiprehberger
|
|
|
17
17
|
# @param timeout [Numeric, nil] seconds to wait before raising
|
|
18
18
|
# @param auto_cleanup [Boolean] automatically remove stale locks before acquiring
|
|
19
19
|
# @param on_wait [Proc, nil] callback invoked every 0.5s while waiting; receives elapsed seconds
|
|
20
|
+
# @param ttl [Numeric, nil] time-to-live in seconds; lock expires after this duration
|
|
20
21
|
# @yield block to execute while the lock is held
|
|
21
22
|
# @return [Object] the return value of the block
|
|
22
23
|
# @raise [Error] if the lock cannot be acquired
|
|
23
|
-
def self.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, &block)
|
|
24
|
+
def self.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil, &block)
|
|
24
25
|
lock = FileLock.new(path)
|
|
25
|
-
lock.acquire(timeout: timeout, auto_cleanup: auto_cleanup, on_wait: on_wait)
|
|
26
|
+
lock.acquire(timeout: timeout, auto_cleanup: auto_cleanup, on_wait: on_wait, ttl: ttl)
|
|
26
27
|
begin
|
|
27
28
|
block.call
|
|
28
29
|
ensure
|
|
@@ -35,12 +36,13 @@ module Philiprehberger
|
|
|
35
36
|
# @param name [String] lock name
|
|
36
37
|
# @param dir [String] directory for the PID file
|
|
37
38
|
# @param auto_cleanup [Boolean] automatically remove stale locks before acquiring
|
|
39
|
+
# @param ttl [Numeric, nil] time-to-live in seconds; lock expires after this duration
|
|
38
40
|
# @yield block to execute while the lock is held
|
|
39
41
|
# @return [Object] the return value of the block
|
|
40
42
|
# @raise [Error] if the lock is held by another process
|
|
41
|
-
def self.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, &block)
|
|
43
|
+
def self.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, ttl: nil, &block)
|
|
42
44
|
lock = PidLock.new(name, dir: dir)
|
|
43
|
-
lock.acquire(auto_cleanup: auto_cleanup)
|
|
45
|
+
lock.acquire(auto_cleanup: auto_cleanup, ttl: ttl)
|
|
44
46
|
begin
|
|
45
47
|
block.call
|
|
46
48
|
ensure
|
|
@@ -123,6 +125,33 @@ module Philiprehberger
|
|
|
123
125
|
false
|
|
124
126
|
end
|
|
125
127
|
|
|
128
|
+
# Check if a lock has expired based on its TTL
|
|
129
|
+
#
|
|
130
|
+
# @param path [String] path to the lock file or PID file
|
|
131
|
+
# @return [Boolean] true if the lock metadata contains an expires_at time that has passed
|
|
132
|
+
def self.expired?(path)
|
|
133
|
+
# Check file lock metadata
|
|
134
|
+
meta_path = "#{path}.meta"
|
|
135
|
+
target = if File.exist?(meta_path)
|
|
136
|
+
meta_path
|
|
137
|
+
elsif File.exist?(path)
|
|
138
|
+
path
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return false unless target
|
|
142
|
+
|
|
143
|
+
content = File.read(target).strip
|
|
144
|
+
return false if content.empty?
|
|
145
|
+
|
|
146
|
+
data = JSON.parse(content)
|
|
147
|
+
expires_at = data['expires_at']
|
|
148
|
+
return false unless expires_at
|
|
149
|
+
|
|
150
|
+
Time.parse(expires_at) <= Time.now
|
|
151
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
126
155
|
# Get lock owner metadata
|
|
127
156
|
#
|
|
128
157
|
# @param path [String] path to the lock file or PID file
|
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.3.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-04-
|
|
11
|
+
date: 2026-04-11 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.
|
|
@@ -53,6 +53,6 @@ requirements: []
|
|
|
53
53
|
rubygems_version: 3.5.22
|
|
54
54
|
signing_key:
|
|
55
55
|
specification_version: 4
|
|
56
|
-
summary: File-based and PID locking for process coordination with
|
|
57
|
-
read-write locks, and lock owner identification
|
|
56
|
+
summary: File-based and PID locking for process coordination with TTL expiration,
|
|
57
|
+
stale detection, read-write locks, and lock owner identification
|
|
58
58
|
test_files: []
|