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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00027e34019a2945cc1d0cdb7814856d850735edbb603d2bc56bf3bbd1c6dee3
4
- data.tar.gz: 57ce5708b9dc2000720334da4a7b55281331e374942d7b24887d33ef36e622df
3
+ metadata.gz: f82a00dac3cb9f7457b0d6e98d97a14a86502b1d2b2fe688b5bef0c07dcdbda1
4
+ data.tar.gz: 88e1f0ac7f71c7585e7730b3984a9d4646c63ec7324f23731d2c088806257a3a
5
5
  SHA512:
6
- metadata.gz: 658255f5a49295524d9a8bd72d4e5530f2cd194dac02d1f2c70b679106f0e4919b674c2fe2eba257d7c8af65c185516368a3d4c5b2643394427ce3e81b75a393
7
- data.tar.gz: 71781478e3a427aeeb256b80582a96cb7c58c799b98aa60ccf10a66fd9ac59a632fe4c5fb7374a5c0f814870f9b212f7c99afdc5509696cd15c15de72343655c
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
  [![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,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, and wait callback |
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
- 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.3.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
@@ -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.2.3
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-09 00:00:00.000000000 Z
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 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: []