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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4af42039dcfbb213799ab09a162acb60c1e78f1b09cb94e52d9dd2bece7b18c
4
- data.tar.gz: b0deb44a8f91e0c6cc36dcabf279d822b08386ef49854225b78dc4955cc723b0
3
+ metadata.gz: 15f0b0f326dfb8c8edb6de15f540d9586336431aa187277e7a708600de665daa
4
+ data.tar.gz: 109159d685d50ee6070575abb4e8b58e8422b24c20fab8a65c1a4143af53a258
5
5
  SHA512:
6
- metadata.gz: 53bf5a675035408f401a354f0ee3c778ac1c23dcd9115d54b192e65ddcd3df7b64a6ff0bf69aa9cc9b879cf7b485ed59ce2a79da15d8cee9b07688b03a66d5b1
7
- data.tar.gz: '09c163faa51ecfb38db57c93fcd33752213e8cfe4367706167cabf3e54130b88b7b6dfebd9e9ee14571b30ac839418fac3fafa06daccf9ae5729739d4716f7e1'
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
- Pluggable state backends for the [ask-rb](https://github.com/ask-rb) ecosystem. Provides `Ask::State::Adapter` implementations for **SQLite**, **Redis**, **PostgreSQL**, and **MySQL** — one interface, four databases, zero coupling to your infrastructure.
3
+ [![Gem Version](https://badge.fury.io/rb/ask-state-providers.svg)](https://badge.fury.io/rb/ask-state-providers)
4
4
 
5
- ```ruby
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
- Then add the database driver for the backend you want to use:
13
+ Add the driver gem for the backend you use:
29
14
 
30
15
  ```ruby
31
- # For SQLite (ships with Ruby's standard library — no extra gem needed on most systems)
32
- gem "sqlite3"
33
-
34
- # For Redis
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
- # Pick your backend:
50
- store = Ask::State::Providers::SQLite.new(path: "my_app.db")
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
- # Key-value storage
56
- store.set("user:1", { name: "Alice", role: "admin" })
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
- task = store.dequeue("tasks")
71
-
72
- # Ordered lists
73
- store.list_append("recent_events", event, max_length: 100)
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
- ### SQLite (`Ask::State::Providers::SQLite`)
80
-
81
- Best for single-process, single-user applications CLI tools, local development, personal agents.
82
-
83
- | Feature | Detail |
84
- |---------|--------|
85
- | **Driver** | [`sqlite3`](https://github.com/sparklemotion/sqlite3-ruby) |
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
- ### MySQL (`Ask::State::Providers::MySQL`)
54
+ ## Adapter contract
119
55
 
120
- Best for teams already running MySQL or MariaDB.
56
+ Every backend implements `Ask::State::Adapter`:
121
57
 
122
- | Feature | Detail |
123
- |---------|--------|
124
- | **Driver** | [`mysql2`](https://github.com/brianmario/mysql2) |
125
- | **Configuration** | `MySQL.new(url:)` |
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
- Uses prepared statements, `ON DUPLICATE KEY UPDATE`, and `SELECT ... LIMIT 1` for safe dequeue.
63
+ ## Full documentation
129
64
 
130
- ## API Reference
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
- ```bash
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 — see [LICENSE](LICENSE).
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
@@ -41,7 +41,13 @@ module Ask
41
41
  end
42
42
 
43
43
  def delete(key)
44
- @mutex.synchronize { @data.delete(key) }
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
- @redis.call("DEL", prefixed(key))
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
- @db = SQLite3::Database.new(path)
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 = @db.get_first_row(<<~SQL, [key, Time.now.to_f])
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
- @db.execute(<<~SQL, [key, JSON.generate(value), ttl ? Time.now.to_f + ttl : nil])
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
- @db.execute("DELETE FROM state_store WHERE key = ?", [key])
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 = @db.get_first_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
- @db.execute("DELETE FROM state_store WHERE key = ?", [key])
87
- @db.execute(<<~SQL, [key, JSON.generate(value), expires])
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
- @db.execute("DELETE FROM state_store")
98
- @db.execute("DELETE FROM locks")
99
- @db.execute("DELETE FROM queues")
100
- @db.execute("DELETE FROM lists")
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 = @db.get_first_row(<<~SQL, [key, Time.now.to_f])
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
- @db.execute(sql, params).map { |r| r["key"] }
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 = @db.get_first_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
- @db.execute("DELETE FROM locks WHERE key = ?", [key])
148
- @db.execute(<<~SQL, [key, expires_at_time.to_f, token])
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
- @db.execute(
157
+ db.execute(
160
158
  "DELETE FROM locks WHERE key = ? AND token = ?",
161
159
  [key, lock.token]
162
160
  )
163
- @db.changes > 0
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
- @db.execute(<<~SQL, [queue, JSON.generate(value), Time.now.iso8601])
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 = @db.last_insert_row_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 = @db.get_first_row(<<~SQL, [queue])
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 = @db.get_first_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
- @db.execute(<<~SQL, [key, serialized])
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 = @db.get_first_row(<<~SQL, [key, max_length])
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
- @db.execute(<<~SQL, [key, row["cutoff"]])
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
- @db.execute(<<~SQL, [key, start])
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
- @db.execute(<<~SQL, [key, limit, start])
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
- @db.execute(
263
+ db.execute(
266
264
  "DELETE FROM lists WHERE list_key = ? AND value = ?",
267
265
  [key, serialized]
268
266
  )
269
- @db.changes
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.close
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
- @db.execute_batch(<<~SQL)
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,
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module State
5
5
  module Providers
6
- VERSION = "0.4.0"
6
+ VERSION = "0.4.2"
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.0
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.3
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: []