ask-state-providers 0.4.0 → 0.4.2
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 +10 -0
- data/README.md +33 -177
- data/lib/ask/state/memory.rb +7 -1
- data/lib/ask/state/providers/mysql.rb +3 -0
- data/lib/ask/state/providers/postgres.rb +3 -0
- data/lib/ask/state/providers/redis.rb +4 -1
- data/lib/ask/state/providers/sqlite.rb +65 -40
- data/lib/ask/state/providers/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15f0b0f326dfb8c8edb6de15f540d9586336431aa187277e7a708600de665daa
|
|
4
|
+
data.tar.gz: 109159d685d50ee6070575abb4e8b58e8422b24c20fab8a65c1a4143af53a258
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4fc50fe29074ef8f25079cc572c037e4b8f665bc746c579117f3726934c076027541663747eca6c3984d411e3ae712ef0d1e0ded76869f9d2cc2cddcc4b71379
|
|
7
|
+
data.tar.gz: cc5cbd6989e1a04ca8e6d69fc09e37673dd4bd83479bfd97e3ad2360622c1eb887ca386806d8b4b461b78fcf57e08e29a73ab7db3f1d7fec2cc99591ec381a19
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.1] — 2026-08-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`delete(key)` now removes everything under the key** — including
|
|
10
|
+
ordered lists (and queues in the Memory backend). Consumers store event
|
|
11
|
+
feeds as lists (ask-workflow's project store, the app-server session
|
|
12
|
+
store); previously the feed survived deletion. New shared contract test
|
|
13
|
+
`test_delete_removes_list_entries` runs against every provider.
|
|
14
|
+
|
|
5
15
|
## [0.3.0] — 2026-07-28
|
|
6
16
|
|
|
7
17
|
### Added
|
data/README.md
CHANGED
|
@@ -1,44 +1,22 @@
|
|
|
1
1
|
# ask-state-providers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/ask-state-providers)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
# Local dev — zero config
|
|
7
|
-
store = Ask::State::Providers::SQLite.new
|
|
8
|
-
|
|
9
|
-
# In production with Rails
|
|
10
|
-
store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
|
|
11
|
-
|
|
12
|
-
# With your existing database
|
|
13
|
-
store = Ask::State::Providers::Postgres.new(url: ENV["DATABASE_URL"])
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## Why?
|
|
17
|
-
|
|
18
|
-
ask-rb agents and sessions need to persist state — conversations, tool results, locks, task queues. Each deployment has different infrastructure: a CLI tool needs SQLite, a Rails app already has Postgres, a distributed system needs Redis. Instead of baking one backend into ask-core, this gem provides them all as drop-in adapters behind the same `Ask::State::Adapter` contract.
|
|
5
|
+
Pluggable state backends for the ask-rb ecosystem. One `Ask::State::Adapter` contract, five backends: in-memory Memory, SQLite, Redis, Postgres, and MySQL. `Ask::State::Memory`, the in-process default, lives in this gem since 0.3.0.
|
|
19
6
|
|
|
20
7
|
## Installation
|
|
21
8
|
|
|
22
|
-
Add this line to your `Gemfile`:
|
|
23
|
-
|
|
24
9
|
```ruby
|
|
25
10
|
gem "ask-state-providers"
|
|
26
11
|
```
|
|
27
12
|
|
|
28
|
-
|
|
13
|
+
Add the driver gem for the backend you use:
|
|
29
14
|
|
|
30
15
|
```ruby
|
|
31
|
-
#
|
|
32
|
-
gem "
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
gem "redis"
|
|
36
|
-
|
|
37
|
-
# For PostgreSQL
|
|
38
|
-
gem "pg"
|
|
39
|
-
|
|
40
|
-
# For MySQL
|
|
41
|
-
gem "mysql2"
|
|
16
|
+
gem "sqlite3" # SQLite
|
|
17
|
+
gem "redis" # Redis
|
|
18
|
+
gem "pg" # PostgreSQL
|
|
19
|
+
gem "mysql2" # MySQL
|
|
42
20
|
```
|
|
43
21
|
|
|
44
22
|
## Quick Start
|
|
@@ -46,175 +24,53 @@ gem "mysql2"
|
|
|
46
24
|
```ruby
|
|
47
25
|
require "ask-state-providers"
|
|
48
26
|
|
|
49
|
-
#
|
|
50
|
-
store = Ask::State::Providers::SQLite.new(path: "
|
|
27
|
+
store = Ask::State::Memory.new # in-process, no persistence
|
|
28
|
+
# store = Ask::State::Providers::SQLite.new(path: "sessions.db")
|
|
51
29
|
# store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
|
|
52
30
|
# store = Ask::State::Providers::Postgres.new(url: ENV["DATABASE_URL"])
|
|
53
31
|
# store = Ask::State::Providers::MySQL.new(url: ENV["MYSQL_URL"])
|
|
54
32
|
|
|
55
|
-
|
|
56
|
-
store.
|
|
57
|
-
store.get("user:1") # => {"name" => "Alice", "role" => "admin"}
|
|
58
|
-
store.set("temp", "expires", ttl: 3600) # auto-expires in 1 hour
|
|
59
|
-
store.delete("user:1")
|
|
60
|
-
|
|
61
|
-
# Conditional create
|
|
33
|
+
store.set("user:1", { name: "Alice", role: "admin" }, ttl: 3600)
|
|
34
|
+
store.get("user:1") # => { "name" => "Alice", "role" => "admin" }
|
|
62
35
|
store.set_if_not_exists("lock:deploy", "in_progress")
|
|
63
|
-
|
|
64
|
-
# Distributed locking
|
|
65
|
-
lock = store.acquire_lock("deploy-prod", ttl: 60)
|
|
66
|
-
store.release_lock("deploy-prod", lock) if lock
|
|
67
|
-
|
|
68
|
-
# Message queues
|
|
36
|
+
store.acquire_lock("deploy-prod", ttl: 60)
|
|
69
37
|
store.enqueue("tasks", { action: "send_email" })
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
store.
|
|
74
|
-
store.list_range("recent_events", 0, 9) # first 10
|
|
38
|
+
store.dequeue("tasks")
|
|
39
|
+
store.list_append("recent_events", "event-1", max_length: 100)
|
|
40
|
+
store.list_range("recent_events", 0, 9)
|
|
41
|
+
store.delete("user:1")
|
|
75
42
|
```
|
|
76
43
|
|
|
77
44
|
## Backends
|
|
78
45
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
|
84
|
-
|
|
85
|
-
|
|
|
86
|
-
| **Configuration** | `SQLite.new(path:)` |
|
|
87
|
-
| **Storage** | Single file on disk |
|
|
88
|
-
| **Concurrency** | WAL mode with 5-second busy timeout |
|
|
89
|
-
| **Tables** | `state_store`, `locks`, `queues`, `lists` (auto-created) |
|
|
90
|
-
|
|
91
|
-
Uses `INSERT OR REPLACE` for key-value, `INSERT ... WHERE NOT EXISTS` for conditional writes, and `DELETE ... RETURNING` for safe queue dequeue.
|
|
92
|
-
|
|
93
|
-
### Redis (`Ask::State::Providers::Redis`)
|
|
94
|
-
|
|
95
|
-
Best for distributed, multi-process, or multi-host deployments.
|
|
96
|
-
|
|
97
|
-
| Feature | Detail |
|
|
98
|
-
|---------|--------|
|
|
99
|
-
| **Driver** | [`redis`](https://github.com/redis-rb/redis-rb) |
|
|
100
|
-
| **Configuration** | `Redis.new(url:)` |
|
|
101
|
-
| **Storage** | In-memory with optional persistence |
|
|
102
|
-
| **Key prefix** | `ask:state:` (all keys are namespaced) |
|
|
103
|
-
|
|
104
|
-
Leverages Redis-native primitives: `SET NX EX` for atomic locking with auto-expire, `RPUSH`/`LPOP` for FIFO queues, `LTRIM` for bounded lists, Lua `EVAL` for safe lock release.
|
|
105
|
-
|
|
106
|
-
### PostgreSQL (`Ask::State::Providers::Postgres`)
|
|
107
|
-
|
|
108
|
-
Best for Rails apps and deployments already running Postgres.
|
|
109
|
-
|
|
110
|
-
| Feature | Detail |
|
|
111
|
-
|---------|--------|
|
|
112
|
-
| **Driver** | [`pg`](https://github.com/ged/ruby-pg) |
|
|
113
|
-
| **Configuration** | `Postgres.new(url:)` |
|
|
114
|
-
| **Connection pool** | Built-in via `connection_pool` (default pool size: 5) |
|
|
115
|
-
|
|
116
|
-
Uses `ON CONFLICT`, `RETURNING`, and `INSERT ... WHERE NOT EXISTS` for safe concurrent access.
|
|
46
|
+
| Backend | Constructor | Notes |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `Ask::State::Memory` | `Memory.new` | In-memory, thread-safe, lost on process exit |
|
|
49
|
+
| `Ask::State::Providers::SQLite` | `SQLite.new(path: "sessions.db")` | Single file, WAL mode, tables auto-created |
|
|
50
|
+
| `Ask::State::Providers::Redis` | `Redis.new(url:)` | Keys namespaced under `ask:state:` |
|
|
51
|
+
| `Ask::State::Providers::Postgres` | `Postgres.new(url:, pool_size: 5)` | Built-in connection pool |
|
|
52
|
+
| `Ask::State::Providers::MySQL` | `MySQL.new(url:)` | `utf8mb4` character set |
|
|
117
53
|
|
|
118
|
-
|
|
54
|
+
## Adapter contract
|
|
119
55
|
|
|
120
|
-
|
|
56
|
+
Every backend implements `Ask::State::Adapter`:
|
|
121
57
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
| **Character set** | `utf8mb4` (full Unicode including emoji) |
|
|
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)`
|
|
60
|
+
- Message queues: `enqueue(queue, value)`, `dequeue(queue)`
|
|
61
|
+
- Ordered lists: `list_append(key, value, max_length:)`, `list_range(key, start, stop)`, `list_remove(key, value)`
|
|
127
62
|
|
|
128
|
-
|
|
63
|
+
## Full documentation
|
|
129
64
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
All backends implement `Ask::State::Adapter`:
|
|
133
|
-
|
|
134
|
-
### Key-Value
|
|
135
|
-
|
|
136
|
-
| Method | Description |
|
|
137
|
-
|--------|-------------|
|
|
138
|
-
| `get(key)` | Retrieve a value, or `nil` if missing or expired |
|
|
139
|
-
| `set(key, value, ttl:)` | Store a value (JSON-serializable). `ttl` in seconds |
|
|
140
|
-
| `delete(key)` | Remove a key |
|
|
141
|
-
| `set_if_not_exists(key, value, ttl:)` | Create only if key doesn't exist (or is expired). Returns `true`/`false` |
|
|
142
|
-
| `clear` | Remove all keys |
|
|
143
|
-
|
|
144
|
-
### Distributed Locking
|
|
145
|
-
|
|
146
|
-
| Method | Description |
|
|
147
|
-
|--------|-------------|
|
|
148
|
-
| `acquire_lock(key, ttl:)` | Acquire a lock. Returns `Lock` or `nil` |
|
|
149
|
-
| `release_lock(key, lock)` | Release a lock (only the owner can). Returns `true`/`false` |
|
|
150
|
-
|
|
151
|
-
### Message Queues
|
|
152
|
-
|
|
153
|
-
| Method | Description |
|
|
154
|
-
|--------|-------------|
|
|
155
|
-
| `enqueue(queue, value)` | Push to the back of a queue. Returns `QueueEntry` |
|
|
156
|
-
| `dequeue(queue)` | Pop from the front of a queue. Returns `QueueEntry` or `nil` |
|
|
157
|
-
| `queue_depth(queue)` | Number of items in the queue |
|
|
158
|
-
|
|
159
|
-
### Ordered Lists
|
|
160
|
-
|
|
161
|
-
| Method | Description |
|
|
162
|
-
|--------|-------------|
|
|
163
|
-
| `list_append(key, value, max_length:)` | Append to list. Trims to `max_length` (keeps newest) |
|
|
164
|
-
| `list_range(key, start, stop)` | Slice of the list. `stop = -1` means all |
|
|
165
|
-
| `list_remove(key, value)` | Remove all occurrences. Returns count removed |
|
|
166
|
-
|
|
167
|
-
### Lifecycle
|
|
168
|
-
|
|
169
|
-
| Method | Description |
|
|
170
|
-
|--------|-------------|
|
|
171
|
-
| `close` | Close the connection(s) |
|
|
172
|
-
|
|
173
|
-
## Using with ask-agent Sessions
|
|
174
|
-
|
|
175
|
-
```ruby
|
|
176
|
-
require "ask-state-providers"
|
|
177
|
-
require "ask-agent"
|
|
178
|
-
|
|
179
|
-
store = Ask::State::Providers::SQLite.new
|
|
180
|
-
|
|
181
|
-
session = Ask::Agent::Session.new(
|
|
182
|
-
"triage",
|
|
183
|
-
model: "gpt-4o",
|
|
184
|
-
state: store
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
session.run("What happened last time we saw this error?")
|
|
188
|
-
# Every turn is persisted — survive restarts, searchable, auditable
|
|
189
|
-
```
|
|
65
|
+
The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [ask-state-providers in depth](https://ask-rb.github.io/ask-docs/reference/api#ask-state-providers) covers the adapter contract and backends. API reference: https://ask-rb.github.io/ask-docs/reference/api.
|
|
190
66
|
|
|
191
67
|
## Development
|
|
192
68
|
|
|
193
|
-
```
|
|
194
|
-
# Install dependencies
|
|
69
|
+
```
|
|
195
70
|
bundle install
|
|
196
|
-
|
|
197
|
-
# Run tests (SQLite tests run everywhere, Redis needs fakeredis,
|
|
198
|
-
# Postgres/MySQL need DATABASE_URL/MYSQL_URL env vars)
|
|
199
71
|
bundle exec rake test
|
|
200
|
-
|
|
201
|
-
# Run tests with verbose output
|
|
202
|
-
bundle exec ruby -Itest test/ask/state/providers/sqlite_test.rb
|
|
203
|
-
bundle exec ruby -Itest test/ask/state/providers/redis_test.rb
|
|
204
|
-
|
|
205
|
-
# Test Postgres locally
|
|
206
|
-
DATABASE_URL="postgres://localhost:5432/ask_state_test" bundle exec rake test
|
|
207
|
-
|
|
208
|
-
# Test MySQL locally
|
|
209
|
-
MYSQL_URL="mysql2://root@localhost:3306/ask_state_test" bundle exec rake test
|
|
210
72
|
```
|
|
211
73
|
|
|
212
74
|
## License
|
|
213
75
|
|
|
214
|
-
MIT
|
|
215
|
-
|
|
216
|
-
## Links
|
|
217
|
-
|
|
218
|
-
- **Source:** https://github.com/ask-rb/ask-state-providers
|
|
219
|
-
- **Issues:** https://github.com/ask-rb/ask-state-providers/issues
|
|
220
|
-
- **Docs:** https://github.com/ask-rb/ask-docs
|
|
76
|
+
MIT
|
data/lib/ask/state/memory.rb
CHANGED
|
@@ -41,7 +41,13 @@ module Ask
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def delete(key)
|
|
44
|
-
@mutex.synchronize
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@data.delete(key)
|
|
46
|
+
# delete removes everything under the key, including ordered
|
|
47
|
+
# lists (consumers store event feeds and queues as lists).
|
|
48
|
+
@lists.delete(key)
|
|
49
|
+
@queues.delete(key)
|
|
50
|
+
end
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def set_if_not_exists(key, value, ttl: nil)
|
|
@@ -92,6 +92,9 @@ module Ask
|
|
|
92
92
|
|
|
93
93
|
def delete(key)
|
|
94
94
|
@client.prepare("DELETE FROM state_store WHERE `key` = ?").execute(key)
|
|
95
|
+
# delete removes everything under the key, including ordered
|
|
96
|
+
# lists (consumers store event feeds as lists).
|
|
97
|
+
@client.prepare("DELETE FROM lists WHERE list_key = ?").execute(key)
|
|
95
98
|
end
|
|
96
99
|
|
|
97
100
|
def set_if_not_exists(key, value, ttl: nil)
|
|
@@ -94,6 +94,9 @@ module Ask
|
|
|
94
94
|
def delete(key)
|
|
95
95
|
@pool.with do |conn|
|
|
96
96
|
conn.exec_params("DELETE FROM state_store WHERE key = $1", [key])
|
|
97
|
+
# delete removes everything under the key, including ordered
|
|
98
|
+
# lists (consumers store event feeds as lists).
|
|
99
|
+
conn.exec_params("DELETE FROM lists WHERE list_key = $1", [key])
|
|
97
100
|
end
|
|
98
101
|
end
|
|
99
102
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "securerandom"
|
|
5
|
+
require "time"
|
|
5
6
|
|
|
6
7
|
module Ask
|
|
7
8
|
module State
|
|
@@ -44,7 +45,9 @@ module Ask
|
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def delete(key)
|
|
47
|
-
|
|
48
|
+
# delete removes everything under the key, including ordered
|
|
49
|
+
# lists (consumers store event feeds as lists).
|
|
50
|
+
@redis.call("DEL", prefixed(key), prefixed("list:#{key}"))
|
|
48
51
|
end
|
|
49
52
|
|
|
50
53
|
def set_if_not_exists(key, value, ttl: nil)
|
|
@@ -25,22 +25,17 @@ module Ask
|
|
|
25
25
|
def initialize(path: "sessions.db", **pragmas)
|
|
26
26
|
require "sqlite3"
|
|
27
27
|
|
|
28
|
+
@path = path
|
|
29
|
+
@pid = Process.pid
|
|
28
30
|
@mutex = Mutex.new
|
|
29
|
-
@
|
|
30
|
-
@db.results_as_hash = true
|
|
31
|
-
@db.busy_timeout = 5000
|
|
32
|
-
|
|
33
|
-
defaults = {
|
|
31
|
+
@pragmas = {
|
|
34
32
|
journal_mode: "WAL",
|
|
35
33
|
synchronous: "NORMAL",
|
|
36
34
|
foreign_keys: "ON",
|
|
37
35
|
cache_size: -64_000
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
defaults.merge(pragmas).each do |key, value|
|
|
41
|
-
@db.execute("PRAGMA #{key} = #{value}")
|
|
42
|
-
end
|
|
36
|
+
}.merge(pragmas)
|
|
43
37
|
|
|
38
|
+
connect
|
|
44
39
|
migrate
|
|
45
40
|
end
|
|
46
41
|
|
|
@@ -48,7 +43,7 @@ module Ask
|
|
|
48
43
|
|
|
49
44
|
def get(key)
|
|
50
45
|
@mutex.synchronize do
|
|
51
|
-
row =
|
|
46
|
+
row = db.get_first_row(<<~SQL, [key, Time.now.to_f])
|
|
52
47
|
SELECT value FROM state_store
|
|
53
48
|
WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
54
49
|
SQL
|
|
@@ -58,7 +53,7 @@ module Ask
|
|
|
58
53
|
|
|
59
54
|
def set(key, value, ttl: nil)
|
|
60
55
|
@mutex.synchronize do
|
|
61
|
-
|
|
56
|
+
db.execute(<<~SQL, [key, JSON.generate(value), ttl ? Time.now.to_f + ttl : nil])
|
|
62
57
|
INSERT OR REPLACE INTO state_store (key, value, expires_at)
|
|
63
58
|
VALUES (?, ?, ?)
|
|
64
59
|
SQL
|
|
@@ -67,7 +62,10 @@ module Ask
|
|
|
67
62
|
|
|
68
63
|
def delete(key)
|
|
69
64
|
@mutex.synchronize do
|
|
70
|
-
|
|
65
|
+
db.execute("DELETE FROM state_store WHERE key = ?", [key])
|
|
66
|
+
# delete removes everything under the key, including ordered
|
|
67
|
+
# lists (consumers store event feeds as lists).
|
|
68
|
+
db.execute("DELETE FROM lists WHERE list_key = ?", [key])
|
|
71
69
|
end
|
|
72
70
|
end
|
|
73
71
|
|
|
@@ -76,15 +74,15 @@ module Ask
|
|
|
76
74
|
now = Time.now.to_f
|
|
77
75
|
expires = ttl ? now + ttl : nil
|
|
78
76
|
|
|
79
|
-
row =
|
|
77
|
+
row = db.get_first_row(
|
|
80
78
|
"SELECT 1 FROM state_store WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)",
|
|
81
79
|
[key, now]
|
|
82
80
|
)
|
|
83
81
|
return false if row
|
|
84
82
|
|
|
85
83
|
# Key doesn't exist or is expired — delete any leftovers, then insert
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
db.execute("DELETE FROM state_store WHERE key = ?", [key])
|
|
85
|
+
db.execute(<<~SQL, [key, JSON.generate(value), expires])
|
|
88
86
|
INSERT INTO state_store (key, value, expires_at)
|
|
89
87
|
VALUES (?, ?, ?)
|
|
90
88
|
SQL
|
|
@@ -94,16 +92,16 @@ module Ask
|
|
|
94
92
|
|
|
95
93
|
def clear
|
|
96
94
|
@mutex.synchronize do
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
db.execute("DELETE FROM state_store")
|
|
96
|
+
db.execute("DELETE FROM locks")
|
|
97
|
+
db.execute("DELETE FROM queues")
|
|
98
|
+
db.execute("DELETE FROM lists")
|
|
101
99
|
end
|
|
102
100
|
end
|
|
103
101
|
|
|
104
102
|
def exists?(key)
|
|
105
103
|
@mutex.synchronize do
|
|
106
|
-
row =
|
|
104
|
+
row = db.get_first_row(<<~SQL, [key, Time.now.to_f])
|
|
107
105
|
SELECT 1 FROM state_store
|
|
108
106
|
WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
109
107
|
SQL
|
|
@@ -126,7 +124,7 @@ module Ask
|
|
|
126
124
|
WHERE (expires_at IS NULL OR expires_at > ?)
|
|
127
125
|
SQL
|
|
128
126
|
end
|
|
129
|
-
|
|
127
|
+
db.execute(sql, params).map { |r| r["key"] }
|
|
130
128
|
end
|
|
131
129
|
end
|
|
132
130
|
|
|
@@ -138,14 +136,14 @@ module Ask
|
|
|
138
136
|
expires_at_time = Time.now + ttl
|
|
139
137
|
token = SecureRandom.hex(16)
|
|
140
138
|
|
|
141
|
-
row =
|
|
139
|
+
row = db.get_first_row(
|
|
142
140
|
"SELECT 1 FROM locks WHERE key = ? AND expires_at > ?",
|
|
143
141
|
[key, now]
|
|
144
142
|
)
|
|
145
143
|
return nil if row
|
|
146
144
|
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
db.execute("DELETE FROM locks WHERE key = ?", [key])
|
|
146
|
+
db.execute(<<~SQL, [key, expires_at_time.to_f, token])
|
|
149
147
|
INSERT INTO locks (key, expires_at, token)
|
|
150
148
|
VALUES (?, ?, ?)
|
|
151
149
|
SQL
|
|
@@ -156,11 +154,11 @@ module Ask
|
|
|
156
154
|
|
|
157
155
|
def release_lock(key, lock)
|
|
158
156
|
@mutex.synchronize do
|
|
159
|
-
|
|
157
|
+
db.execute(
|
|
160
158
|
"DELETE FROM locks WHERE key = ? AND token = ?",
|
|
161
159
|
[key, lock.token]
|
|
162
160
|
)
|
|
163
|
-
|
|
161
|
+
db.changes > 0
|
|
164
162
|
end
|
|
165
163
|
end
|
|
166
164
|
|
|
@@ -168,18 +166,18 @@ module Ask
|
|
|
168
166
|
|
|
169
167
|
def enqueue(queue, value)
|
|
170
168
|
@mutex.synchronize do
|
|
171
|
-
|
|
169
|
+
db.execute(<<~SQL, [queue, JSON.generate(value), Time.now.iso8601])
|
|
172
170
|
INSERT INTO queues (queue_name, value, enqueued_at)
|
|
173
171
|
VALUES (?, ?, ?)
|
|
174
172
|
SQL
|
|
175
|
-
id =
|
|
173
|
+
id = db.last_insert_row_id
|
|
176
174
|
QueueEntry.new(id: id.to_s, value: value, enqueued_at: Time.now)
|
|
177
175
|
end
|
|
178
176
|
end
|
|
179
177
|
|
|
180
178
|
def dequeue(queue)
|
|
181
179
|
@mutex.synchronize do
|
|
182
|
-
row =
|
|
180
|
+
row = db.get_first_row(<<~SQL, [queue])
|
|
183
181
|
DELETE FROM queues
|
|
184
182
|
WHERE id = (
|
|
185
183
|
SELECT id FROM queues
|
|
@@ -201,7 +199,7 @@ module Ask
|
|
|
201
199
|
|
|
202
200
|
def queue_depth(queue)
|
|
203
201
|
@mutex.synchronize do
|
|
204
|
-
row =
|
|
202
|
+
row = db.get_first_row(
|
|
205
203
|
"SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = ?", [queue]
|
|
206
204
|
)
|
|
207
205
|
row["cnt"]
|
|
@@ -214,14 +212,14 @@ module Ask
|
|
|
214
212
|
@mutex.synchronize do
|
|
215
213
|
serialized = JSON.generate(value)
|
|
216
214
|
|
|
217
|
-
|
|
215
|
+
db.execute(<<~SQL, [key, serialized])
|
|
218
216
|
INSERT INTO lists (list_key, value)
|
|
219
217
|
VALUES (?, ?)
|
|
220
218
|
SQL
|
|
221
219
|
|
|
222
220
|
return unless max_length
|
|
223
221
|
|
|
224
|
-
row =
|
|
222
|
+
row = db.get_first_row(<<~SQL, [key, max_length])
|
|
225
223
|
SELECT MIN(id) AS cutoff FROM (
|
|
226
224
|
SELECT id FROM lists
|
|
227
225
|
WHERE list_key = ?
|
|
@@ -231,7 +229,7 @@ module Ask
|
|
|
231
229
|
SQL
|
|
232
230
|
return unless row && row["cutoff"]
|
|
233
231
|
|
|
234
|
-
|
|
232
|
+
db.execute(<<~SQL, [key, row["cutoff"]])
|
|
235
233
|
DELETE FROM lists WHERE list_key = ? AND id < ?
|
|
236
234
|
SQL
|
|
237
235
|
end
|
|
@@ -240,7 +238,7 @@ module Ask
|
|
|
240
238
|
def list_range(key, start = 0, stop = -1)
|
|
241
239
|
@mutex.synchronize do
|
|
242
240
|
rows = if stop == -1
|
|
243
|
-
|
|
241
|
+
db.execute(<<~SQL, [key, start])
|
|
244
242
|
SELECT value FROM lists
|
|
245
243
|
WHERE list_key = ?
|
|
246
244
|
ORDER BY id ASC
|
|
@@ -248,7 +246,7 @@ module Ask
|
|
|
248
246
|
SQL
|
|
249
247
|
else
|
|
250
248
|
limit = stop - start + 1
|
|
251
|
-
|
|
249
|
+
db.execute(<<~SQL, [key, limit, start])
|
|
252
250
|
SELECT value FROM lists
|
|
253
251
|
WHERE list_key = ?
|
|
254
252
|
ORDER BY id ASC
|
|
@@ -262,11 +260,11 @@ module Ask
|
|
|
262
260
|
def list_remove(key, value)
|
|
263
261
|
@mutex.synchronize do
|
|
264
262
|
serialized = JSON.generate(value)
|
|
265
|
-
|
|
263
|
+
db.execute(
|
|
266
264
|
"DELETE FROM lists WHERE list_key = ? AND value = ?",
|
|
267
265
|
[key, serialized]
|
|
268
266
|
)
|
|
269
|
-
|
|
267
|
+
db.changes
|
|
270
268
|
end
|
|
271
269
|
end
|
|
272
270
|
|
|
@@ -282,14 +280,41 @@ module Ask
|
|
|
282
280
|
|
|
283
281
|
def close
|
|
284
282
|
@mutex.synchronize do
|
|
285
|
-
@db
|
|
283
|
+
@db&.close unless @db&.closed?
|
|
286
284
|
end
|
|
287
285
|
end
|
|
288
286
|
|
|
289
287
|
private
|
|
290
288
|
|
|
289
|
+
# Open (or reopen) the database connection. Reconnect is the
|
|
290
|
+
# fork story: when a process forks (SolidQueue workers, Puma
|
|
291
|
+
# cluster), the child inherits the parent's connection, which
|
|
292
|
+
# sqlite3's fork safety closes — so every operation goes through
|
|
293
|
+
# #db, which reopens the connection and gives the child a fresh
|
|
294
|
+
# mutex when the process id changed.
|
|
295
|
+
def connect
|
|
296
|
+
@db = SQLite3::Database.new(@path)
|
|
297
|
+
@db.results_as_hash = true
|
|
298
|
+
@db.busy_timeout = 5000
|
|
299
|
+
@pragmas.each do |key, value|
|
|
300
|
+
@db.execute("PRAGMA #{key} = #{value}")
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The live connection, reopened if a fork closed it. Call this
|
|
305
|
+
# inside the mutex synchronize (connect swaps the mutex when the
|
|
306
|
+
# process changed, so the old mutex is the one we're holding).
|
|
307
|
+
def db
|
|
308
|
+
if @pid != Process.pid || @db.nil? || @db.closed?
|
|
309
|
+
@pid = Process.pid
|
|
310
|
+
@mutex = Mutex.new
|
|
311
|
+
connect
|
|
312
|
+
end
|
|
313
|
+
@db
|
|
314
|
+
end
|
|
315
|
+
|
|
291
316
|
def migrate
|
|
292
|
-
|
|
317
|
+
db.execute_batch(<<~SQL)
|
|
293
318
|
CREATE TABLE IF NOT EXISTS state_store (
|
|
294
319
|
key TEXT PRIMARY KEY NOT NULL,
|
|
295
320
|
value TEXT NOT NULL,
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -106,7 +106,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
106
106
|
- !ruby/object:Gem::Version
|
|
107
107
|
version: '0'
|
|
108
108
|
requirements: []
|
|
109
|
-
rubygems_version: 4.0.
|
|
109
|
+
rubygems_version: 4.0.18
|
|
110
110
|
specification_version: 4
|
|
111
111
|
summary: Pluggable state backends for the ask-rb ecosystem
|
|
112
112
|
test_files: []
|