ask-state-providers 0.4.7 → 0.4.8

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: 4a565b9fd91fef16617d528d07fdde7dae5473875bf9f5c3dc652242bd61533a
4
- data.tar.gz: 1b7ead2e8be6cb4ec13f3526360c1de9879ac2294b39c2006432be697ec163ba
3
+ metadata.gz: ca18027938f399b03eb670f65b4cd43af7f950b780a4e3808bb86f57dae070d2
4
+ data.tar.gz: cd930f6fc0836cb3a8570e058c5843faecbe82c6391cfe6ecb3ab4c80dd3fdc4
5
5
  SHA512:
6
- metadata.gz: 9fa6bafae89e29ab87a23de46ab7f2cbe9fa672c101a9b3d2493631d0983856f55f0759817e7aa2298746c407ff365ef45b7fba149f43e337ccc63de5cf495fb
7
- data.tar.gz: e4650a39f613ccaf7c2b5a35c63503bcb1d52aa77e04acf6be87624edf6b8626eaa05c5a3b0dc3bf4113b459f7b1d38f494fe1a46b0747573cc38b78e0ec674d
6
+ metadata.gz: d07655542453ce69b1095b7d4771290e1ce1ada5884aa94bf35cff45ba3af71c9f704ee126b586d5073335fe9d81a710fc9fe4d7c573ce9835eaac49974f88ce
7
+ data.tar.gz: 200b2e8a1a0f181589a13db0f43ea34b7b7f13cfd5ff5b410475bf9d85f57d54a6368b5337bc89bf1c2e5b7543a60354d63fa75255e6b46f4a3e50a39f80b67a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.4.8] — 2026-09-22
8
+
9
+ ### Changed
10
+
11
+ - **Minimum `ask-core` raised to `>= 0.12.0`** (was `>= 0.11.4`) — aligns
12
+ the provider stack with the decision-vocabulary release of `ask-core`.
13
+
14
+ ### Fixed
15
+
16
+ - **Provider lock contract hardened (token-safe release, TTL/expiry, cross-backend parity)**:
17
+
18
+ - **MySQL `acquire_lock` no longer hands a second contender a lock.**
19
+ Success was decided by `COUNT(*) > 0` on the key, so while an owner
20
+ held the lock, the next `acquire_lock` also returned a `Lock` —
21
+ exclusion was broken. It now uses one conditional upsert
22
+ (`ON DUPLICATE KEY UPDATE` that only takes over expired rows) and
23
+ reports success from `affected_rows`.
24
+ - **SQLite `acquire_lock` is atomic across processes.** The old
25
+ SELECT-then-DELETE-then-INSERT could delete a lock another process
26
+ had just granted. Replaced with a single
27
+ `INSERT … ON CONFLICT DO UPDATE … WHERE expires_at <= now
28
+ RETURNING token` that only takes over expired rows.
29
+ - **Postgres `acquire_lock` no longer races into `UniqueViolation`.**
30
+ DELETE-then-`INSERT … WHERE NOT EXISTS` let concurrent callers
31
+ collide on the primary key and raise instead of returning `nil`.
32
+ Now one `ON CONFLICT DO UPDATE … WHERE` upsert, verified by
33
+ `RETURNING token`.
34
+ - **Redis `acquire_lock` accepts non-positive TTLs.**
35
+ `SET … EX 0/-1` raised `invalid expire time`; it now returns an
36
+ already-expired lock without writing (and refuses when a live lock
37
+ is held), matching the SQL backends. Uses `PX` so whole-second and
38
+ sub-second TTLs share one path.
39
+ - **SQLite/Postgres/MySQL `release_lock` respects expiry.** They
40
+ deleted by key+token alone and returned `true` for an
41
+ expired-but-unreaped lock; the `Adapter` contract (and Memory/Redis)
42
+ return `false` once expired. Release now requires a matching token
43
+ **and** an unexpired lock.
44
+
45
+ ### Tests
46
+
47
+ - **Shared `AdapterContract` lock coverage expanded** — five new
48
+ contract tests run against Memory, SQLite, Redis, Postgres, and MySQL:
49
+ release-after-expiry returns `false`, double release fails, a stale
50
+ owner cannot release the lock's replacement, `ttl: 0` grants no
51
+ exclusion, and `ttl:` lands in `expires_at`. Redis's
52
+ `test_lock_expired_can_be_acquired` skip is removed, and
53
+ `Ask::State::Memory` now runs the full shared contract
54
+ (`memory_contract_test.rb`).
55
+
5
56
  ## [0.4.1] — 2026-08-12
6
57
 
7
58
  ### Fixed
data/README.md CHANGED
@@ -56,7 +56,7 @@ store.delete("user:1")
56
56
  Every backend implements `Ask::State::Adapter`:
57
57
 
58
58
  - Key-value: `get`, `set(key, value, ttl:)`, `delete`, `set_if_not_exists`, `keys(pattern:)`, `clear`
59
- - Distributed locking: `acquire_lock(key, ttl:)`, `release_lock(key, lock)`
59
+ - Distributed locking: `acquire_lock(key, ttl:)`, `release_lock(key, lock)` — token-safe on every backend: `release_lock` returns `true` only while the caller still owns an unexpired lock; a stale owner (wrong token, lock timed out, or lock taken over) gets `false` and cannot free the new owner's lock.
60
60
  - Message queues: `enqueue(queue, value)`, `dequeue(queue)`
61
61
  - Ordered lists: `list_append(key, value, max_length:)`, `list_range(key, start, stop)`, `list_remove(key, value)`
62
62
 
@@ -153,33 +153,39 @@ module Ask
153
153
 
154
154
  def acquire_lock(key, ttl: 10)
155
155
  now = Time.now.utc
156
- expires_at_time = now + ttl
156
+ expires_at = now + ttl
157
157
  token = SecureRandom.hex(16)
158
- now_f = now.strftime("%Y-%m-%d %H:%M:%S.%3N")
159
-
160
- # Clean up expired lock
161
- @client.prepare("DELETE FROM locks WHERE `key` = ? AND expires_at <= ?").execute(key, now_f)
162
-
163
- result = @client.prepare(<<~SQL).execute(key, expires_at_time.strftime("%Y-%m-%d %H:%M:%S.%3N"), token)
164
- INSERT INTO locks (`key`, expires_at, token)
165
- SELECT ?, ?, ?
166
- WHERE NOT EXISTS (
167
- SELECT 1 FROM locks WHERE `key` = ?
168
- )
169
- SQL
170
- result = @client.prepare(<<~SQL).execute(key)
171
- SELECT COUNT(*) AS cnt FROM locks WHERE `key` = ?
158
+ now_s = now.strftime("%Y-%m-%d %H:%M:%S.%3N")
159
+ expires_s = expires_at.strftime("%Y-%m-%d %H:%M:%S.%3N")
160
+
161
+ # One atomic upsert: insert when free; overwrite the row only
162
+ # when it has expired (IF keeps a live lock's token), so a
163
+ # contender can never displace an active owner. affected_rows
164
+ # is 1 for insert, 2 for takeover, 0 when a live lock won —
165
+ # the old COUNT(*) check could not tell "held by someone"
166
+ # from "held by me" and handed a second Lock to a contender.
167
+ stmt = @client.prepare(<<~SQL)
168
+ INSERT INTO locks (`key`, token, expires_at)
169
+ VALUES (?, ?, ?)
170
+ ON DUPLICATE KEY UPDATE
171
+ token = IF(expires_at <= ?, VALUES(token), token),
172
+ expires_at = IF(expires_at <= ?, VALUES(expires_at), expires_at)
172
173
  SQL
174
+ stmt.execute(key, token, expires_s, now_s, now_s)
173
175
 
174
- # Check if we got the lock
175
- row = result.first
176
- return nil unless row && row["cnt"].to_i > 0
176
+ return nil unless @client.affected_rows > 0
177
177
 
178
- Lock.new(id: key, token: token, expires_at: expires_at_time)
178
+ Lock.new(id: key, token: token, expires_at: expires_at)
179
179
  end
180
180
 
181
181
  def release_lock(key, lock)
182
- @client.prepare("DELETE FROM locks WHERE `key` = ? AND token = ?").execute(key, lock.token)
182
+ # Token match plus unexpired: an owner whose lock has already
183
+ # timed out must not "release" a row nobody considers held
184
+ # (the Adapter contract returns false once expired).
185
+ stmt = @client.prepare(
186
+ "DELETE FROM locks WHERE `key` = ? AND token = ? AND expires_at > ?"
187
+ )
188
+ stmt.execute(key, lock.token, Time.now.utc.strftime("%Y-%m-%d %H:%M:%S.%3N"))
183
189
  @client.affected_rows > 0
184
190
  end
185
191
 
@@ -183,33 +183,40 @@ module Ask
183
183
 
184
184
  def acquire_lock(key, ttl: 10)
185
185
  now = Time.now.utc
186
- expires_at_time = now + ttl
186
+ expires_at = now + ttl
187
187
  token = SecureRandom.hex(16)
188
188
 
189
- acquired = with_connection do |conn|
190
- # First clean up expired locks
191
- conn.exec_params("DELETE FROM locks WHERE key = $1 AND expires_at <= $2",
192
- [key, now])
193
-
194
- result = conn.exec_params(<<~SQL, [key, expires_at_time, token])
195
- INSERT INTO locks (key, expires_at, token)
196
- SELECT $1::text, $2::timestamptz, $3::text
197
- WHERE NOT EXISTS (
198
- SELECT 1 FROM locks
199
- WHERE key = $1
200
- )
189
+ row = with_connection do |conn|
190
+ # One atomic upsert: insert when free, take over only an
191
+ # expired row, leave a live lock's token untouched. This
192
+ # replaces DELETE-then-INSERT...WHERE NOT EXISTS, which let
193
+ # concurrent racers collide on the primary key and raise
194
+ # UniqueViolation instead of returning nil. A row comes back
195
+ # only when we won.
196
+ conn.exec_params(<<~SQL, [key, token, expires_at, now])
197
+ INSERT INTO locks (key, token, expires_at)
198
+ VALUES ($1::text, $2::text, $3::timestamptz)
199
+ ON CONFLICT (key) DO UPDATE SET
200
+ token = EXCLUDED.token,
201
+ expires_at = EXCLUDED.expires_at
202
+ WHERE locks.expires_at <= $4::timestamptz
203
+ RETURNING token
201
204
  SQL
202
- result.cmd_tuples > 0
203
205
  end
204
206
 
205
- acquired ? Lock.new(id: key, token: token, expires_at: expires_at_time) : nil
207
+ return nil unless row.ntuples > 0 && row[0]["token"] == token
208
+
209
+ Lock.new(id: key, token: token, expires_at: expires_at)
206
210
  end
207
211
 
208
212
  def release_lock(key, lock)
209
213
  with_connection do |conn|
214
+ # Token match plus unexpired: an owner whose lock has already
215
+ # timed out must not "release" a row nobody considers held
216
+ # (the Adapter contract returns false once expired).
210
217
  result = conn.exec_params(
211
- "DELETE FROM locks WHERE key = $1 AND token = $2",
212
- [key, lock.token]
218
+ "DELETE FROM locks WHERE key = $1 AND token = $2 AND expires_at > $3",
219
+ [key, lock.token, Time.now.utc]
213
220
  )
214
221
  result.cmd_tuples > 0
215
222
  end
@@ -78,8 +78,20 @@ module Ask
78
78
  prefixed_key = prefixed("lock:#{key}")
79
79
  token = SecureRandom.hex(16)
80
80
  expires_at = Time.now + ttl
81
+ ttl_ms = (ttl.to_f * 1000).ceil
82
+
83
+ # Redis rejects non-positive expire times, so SET … EX 0/-1
84
+ # raised instead of behaving like the SQL backends. A ttl <= 0
85
+ # lock is already expired on arrival: report success only when
86
+ # the key is free and write nothing, so the next acquire gets
87
+ # through the way an expired SQL row would. PX (not EX) keeps
88
+ # whole-second and sub-second TTLs on the same code path.
89
+ acquired = if ttl_ms <= 0
90
+ @redis.call("EXISTS", prefixed_key) == 0
91
+ else
92
+ @redis.call("SET", prefixed_key, token, "NX", "PX", ttl_ms) == "OK"
93
+ end
81
94
 
82
- acquired = @redis.call("SET", prefixed_key, token, "NX", "EX", ttl) == "OK"
83
95
  acquired ? Lock.new(id: key, token: token, expires_at: expires_at) : nil
84
96
  end
85
97
 
@@ -132,31 +132,39 @@ module Ask
132
132
 
133
133
  def acquire_lock(key, ttl: 10)
134
134
  @mutex.synchronize do
135
- now = Time.now.to_f
136
- expires_at_time = Time.now + ttl
135
+ now = Time.now
136
+ expires_at = now + ttl
137
137
  token = SecureRandom.hex(16)
138
138
 
139
- row = db.get_first_row(
140
- "SELECT 1 FROM locks WHERE key = ? AND expires_at > ?",
141
- [key, now]
142
- )
143
- return nil if row
144
-
145
- db.execute("DELETE FROM locks WHERE key = ?", [key])
146
- db.execute(<<~SQL, [key, expires_at_time.to_f, token])
147
- INSERT INTO locks (key, expires_at, token)
139
+ # One atomic upsert: insert when the key is free, overwrite
140
+ # only when the held lock is expired, never touch a live one.
141
+ # The old SELECT-then-DELETE-then-INSERT let a second process
142
+ # delete a lock that had just been granted to someone else.
143
+ # A row comes back only when we won.
144
+ row = db.get_first_row(<<~SQL, [key, token, expires_at.to_f, now.to_f])
145
+ INSERT INTO locks (key, token, expires_at)
148
146
  VALUES (?, ?, ?)
147
+ ON CONFLICT(key) DO UPDATE SET
148
+ token = excluded.token,
149
+ expires_at = excluded.expires_at
150
+ WHERE locks.expires_at <= ?
151
+ RETURNING token
149
152
  SQL
150
153
 
151
- Lock.new(id: key, token: token, expires_at: expires_at_time)
154
+ return nil unless row && row["token"] == token
155
+
156
+ Lock.new(id: key, token: token, expires_at: expires_at)
152
157
  end
153
158
  end
154
159
 
155
160
  def release_lock(key, lock)
156
161
  @mutex.synchronize do
162
+ # Token match plus unexpired: an owner whose lock has already
163
+ # timed out must not "release" a row nobody considers held
164
+ # (the Adapter contract returns false once expired).
157
165
  db.execute(
158
- "DELETE FROM locks WHERE key = ? AND token = ?",
159
- [key, lock.token]
166
+ "DELETE FROM locks WHERE key = ? AND token = ? AND expires_at > ?",
167
+ [key, lock.token, Time.now.to_f]
160
168
  )
161
169
  db.changes > 0
162
170
  end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module State
5
5
  module Providers
6
- VERSION = "0.4.7"
6
+ VERSION = "0.4.8"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-state-providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.7
4
+ version: 0.4.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.11.4
18
+ version: 0.12.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.11.4
25
+ version: 0.12.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: connection_pool
28
28
  requirement: !ruby/object:Gem::Requirement