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 +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +1 -1
- data/lib/ask/state/providers/mysql.rb +26 -20
- data/lib/ask/state/providers/postgres.rb +24 -17
- data/lib/ask/state/providers/redis.rb +13 -1
- data/lib/ask/state/providers/sqlite.rb +22 -14
- data/lib/ask/state/providers/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ca18027938f399b03eb670f65b4cd43af7f950b780a4e3808bb86f57dae070d2
|
|
4
|
+
data.tar.gz: cd930f6fc0836cb3a8570e058c5843faecbe82c6391cfe6ecb3ab4c80dd3fdc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
156
|
+
expires_at = now + ttl
|
|
157
157
|
token = SecureRandom.hex(16)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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:
|
|
178
|
+
Lock.new(id: key, token: token, expires_at: expires_at)
|
|
179
179
|
end
|
|
180
180
|
|
|
181
181
|
def release_lock(key, lock)
|
|
182
|
-
|
|
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
|
-
|
|
186
|
+
expires_at = now + ttl
|
|
187
187
|
token = SecureRandom.hex(16)
|
|
188
188
|
|
|
189
|
-
|
|
190
|
-
#
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
135
|
+
now = Time.now
|
|
136
|
+
expires_at = now + ttl
|
|
137
137
|
token = SecureRandom.hex(16)
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 0.12.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: connection_pool
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|