philiprehberger-lock_kit 0.2.3 → 0.4.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: 00027e34019a2945cc1d0cdb7814856d850735edbb603d2bc56bf3bbd1c6dee3
4
- data.tar.gz: 57ce5708b9dc2000720334da4a7b55281331e374942d7b24887d33ef36e622df
3
+ metadata.gz: ea17573729655a3a771771a4491e4ad67313ccdd8c1910964efc84e28cc55cd1
4
+ data.tar.gz: 3d045b798cd8f8b4df88af8127d4fb1ecd30c0ba252a1058fc90095fc4b98b75
5
5
  SHA512:
6
- metadata.gz: 658255f5a49295524d9a8bd72d4e5530f2cd194dac02d1f2c70b679106f0e4919b674c2fe2eba257d7c8af65c185516368a3d4c5b2643394427ce3e81b75a393
7
- data.tar.gz: 71781478e3a427aeeb256b80582a96cb7c58c799b98aa60ccf10a66fd9ac59a632fe4c5fb7374a5c0f814870f9b212f7c99afdc5509696cd15c15de72343655c
6
+ metadata.gz: '08161efb6474fd3cc5b04d6896200df0e6c3e638e7eb89e898e2de4e0efee4ab2ac7decf99eea5f472a78c2914bbb1d5e4665e4d80a4fd7d72efdb3d4c942505'
7
+ data.tar.gz: 1776595c803f9c9285afc8bb6b7fc9f26cf32db45f1067a5f6084d585f741b5c27d6bce6ab2d1824c2db4941991e44bd1c0ac53e71f845e4011ac079b4f5f4af
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-15
11
+
12
+ ### Added
13
+ - `with_retry_lock(path, retries: 3, delay: 0.1, backoff: 2, timeout: nil, auto_cleanup: true, ttl: nil)` module method for lock acquisition with exponential backoff retries
14
+
15
+ ## [0.3.0] - 2026-04-10
16
+
17
+ ### Added
18
+ - `ttl:` option for `with_file_lock`, `with_pid_lock`, `FileLock#acquire`, and `PidLock#acquire` — locks auto-expire after the specified duration
19
+ - `FileLock#expired?` and `PidLock#expired?` instance methods for checking TTL expiration
20
+ - `LockKit.expired?(path)` module-level method for checking lock expiration
21
+ - Stale lock cleanup now considers expired TTL locks as stale
22
+
23
+ ### Fixed
24
+ - Align feature request issue template with guide structure
25
+
10
26
  ## [0.2.3] - 2026-04-09
11
27
 
12
28
  ### Fixed
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-lock_kit.svg)](https://rubygems.org/gems/philiprehberger-lock_kit)
5
5
  [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-lock-kit)](https://github.com/philiprehberger/rb-lock-kit/commits/main)
6
6
 
7
- File-based and PID locking for process coordination with stale lock detection, read-write locks, and lock owner identification
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,37 @@ Philiprehberger::LockKit.with_write_lock('/tmp/data.lock', timeout: 5) do
64
64
  end
65
65
  ```
66
66
 
67
+ ### Retry Lock
68
+
69
+ ```ruby
70
+ # Retries lock acquisition with exponential backoff
71
+ Philiprehberger::LockKit.with_retry_lock('/tmp/my.lock', retries: 5, delay: 0.2, backoff: 2) do
72
+ # acquired after retrying if initially contended
73
+ end
74
+
75
+ # With timeout per attempt, TTL, and custom cleanup
76
+ Philiprehberger::LockKit.with_retry_lock('/tmp/my.lock', retries: 3, delay: 0.1, backoff: 2, timeout: 5, ttl: 30) do
77
+ # each attempt waits up to 5s; lock expires after 30s
78
+ end
79
+ ```
80
+
81
+ ### Lock with TTL (Time-to-Live)
82
+
83
+ ```ruby
84
+ # Lock expires after 30 seconds — treated as stale once elapsed
85
+ Philiprehberger::LockKit.with_file_lock('/tmp/my.lock', ttl: 30) do
86
+ # work that should not hold the lock indefinitely
87
+ end
88
+
89
+ # PID locks also support TTL
90
+ Philiprehberger::LockKit.with_pid_lock('my_worker', ttl: 60) do
91
+ # expires after 60 seconds
92
+ end
93
+
94
+ # Check if a lock has expired
95
+ Philiprehberger::LockKit.expired?('/tmp/my.lock') # => true/false
96
+ ```
97
+
67
98
  ### Automatic Stale Lock Cleanup
68
99
 
69
100
  ```ruby
@@ -133,12 +164,14 @@ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
133
164
 
134
165
  | Method | Description |
135
166
  |--------|-------------|
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 |
167
+ | `.with_file_lock(path, timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil) { }` | Execute block with exclusive file lock |
168
+ | `.with_retry_lock(path, retries: 3, delay: 0.1, backoff: 2, timeout: nil, auto_cleanup: true, ttl: nil) { }` | Execute block with file lock, retrying with exponential backoff |
169
+ | `.with_pid_lock(name, dir: Dir.tmpdir, auto_cleanup: false, ttl: nil) { }` | Execute block with PID file lock |
138
170
  | `.with_read_lock(path, timeout: nil) { }` | Execute block with shared read lock |
139
171
  | `.with_write_lock(path, timeout: nil) { }` | Execute block with exclusive write lock |
140
172
  | `.locked?(path)` | Check if a file is currently locked |
141
173
  | `.stale?(pid_file)` | Check if a PID file references a dead process |
174
+ | `.expired?(path)` | Check if a lock has expired based on its TTL |
142
175
  | `.owner(path)` | Get lock owner metadata (pid, hostname, acquired_at) |
143
176
  | `.break!(path, force: false)` | Break a lock (stale only by default, any with force) |
144
177
 
@@ -147,9 +180,10 @@ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
147
180
  | Method | Description |
148
181
  |--------|-------------|
149
182
  | `.new(path)` | Create a file lock instance |
150
- | `#acquire(timeout: nil, auto_cleanup: false, on_wait: nil)` | Acquire exclusive lock with optional timeout, cleanup, and wait callback |
183
+ | `#acquire(timeout: nil, auto_cleanup: false, on_wait: nil, ttl: nil)` | Acquire exclusive lock with optional timeout, cleanup, wait callback, and TTL |
151
184
  | `#release` | Release the lock and close the file handle |
152
- | `#locked?` | Check if the file is currently locked |
185
+ | `#locked?` | Check if the file is currently locked (false if expired) |
186
+ | `#expired?` | Check if the lock has expired based on its TTL |
153
187
  | `#owner` | Get lock owner metadata |
154
188
 
155
189
  ### `LockKit::PidLock`
@@ -157,9 +191,10 @@ Philiprehberger::LockKit.stale?('/tmp/my_worker.pid') # => true/false
157
191
  | Method | Description |
158
192
  |--------|-------------|
159
193
  | `.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 |
194
+ | `#acquire(auto_cleanup: false, ttl: nil)` | Acquire PID lock with optional TTL, raises if held by a living process |
161
195
  | `#release` | Release lock and remove PID file |
162
- | `#locked?` | Check if lock is held by a living process |
196
+ | `#locked?` | Check if lock is held by a living process (false if expired) |
197
+ | `#expired?` | Check if the lock has expired based on its TTL |
163
198
  | `#stale?` | Check if PID file references a dead process |
164
199
  | `#owner` | Get lock owner metadata |
165
200
 
@@ -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
- if File.exist?(@pid_path)
36
- existing_pid = read_pid
35
+ def acquire(auto_cleanup: false, ttl: nil)
36
+ @ttl = ttl
37
37
 
38
- if existing_pid && process_alive?(existing_pid)
39
- raise Error, "Lock '#{@name}' is held by process #{existing_pid}"
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module LockKit
5
- VERSION = '0.2.3'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -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
@@ -87,6 +89,35 @@ module Philiprehberger
87
89
  end
88
90
  end
89
91
 
92
+ # Execute a block with a file lock, retrying with exponential backoff on failure
93
+ #
94
+ # @param path [String] path to the lock file
95
+ # @param retries [Integer] maximum number of attempts
96
+ # @param delay [Numeric] initial delay in seconds between retries
97
+ # @param backoff [Numeric] multiplier applied to delay after each failed attempt
98
+ # @param timeout [Numeric, nil] seconds to wait per acquire attempt
99
+ # @param auto_cleanup [Boolean] automatically remove stale locks before acquiring
100
+ # @param ttl [Numeric, nil] time-to-live in seconds; lock expires after this duration
101
+ # @yield block to execute while the lock is held
102
+ # @return [Object] the return value of the block
103
+ # @raise [Error] if all retry attempts are exhausted
104
+ def self.with_retry_lock(path, retries: 3, delay: 0.1, backoff: 2, timeout: nil, auto_cleanup: true, ttl: nil, &block)
105
+ attempts = 0
106
+ current_delay = delay
107
+
108
+ loop do
109
+ attempts += 1
110
+ begin
111
+ return with_file_lock(path, timeout: timeout, auto_cleanup: auto_cleanup, ttl: ttl, &block)
112
+ rescue Error
113
+ raise if attempts >= retries
114
+
115
+ sleep current_delay
116
+ current_delay *= backoff
117
+ end
118
+ end
119
+ end
120
+
90
121
  # Check if a file is currently locked by another process
91
122
  #
92
123
  # @param path [String] path to the lock file
@@ -123,6 +154,33 @@ module Philiprehberger
123
154
  false
124
155
  end
125
156
 
157
+ # Check if a lock has expired based on its TTL
158
+ #
159
+ # @param path [String] path to the lock file or PID file
160
+ # @return [Boolean] true if the lock metadata contains an expires_at time that has passed
161
+ def self.expired?(path)
162
+ # Check file lock metadata
163
+ meta_path = "#{path}.meta"
164
+ target = if File.exist?(meta_path)
165
+ meta_path
166
+ elsif File.exist?(path)
167
+ path
168
+ end
169
+
170
+ return false unless target
171
+
172
+ content = File.read(target).strip
173
+ return false if content.empty?
174
+
175
+ data = JSON.parse(content)
176
+ expires_at = data['expires_at']
177
+ return false unless expires_at
178
+
179
+ Time.parse(expires_at) <= Time.now
180
+ rescue JSON::ParserError, Errno::ENOENT
181
+ false
182
+ end
183
+
126
184
  # Get lock owner metadata
127
185
  #
128
186
  # @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.2.3
4
+ version: 0.4.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-09 00:00:00.000000000 Z
11
+ date: 2026-04-16 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 stale lock detection,
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: []