ask-state-providers 0.4.0 → 0.4.1
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 +3 -0
- data/lib/ask/state/providers/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 128f88b945a38f01707b8bac7bb1d78e85ffff581c392894346cdace4aad4f64
|
|
4
|
+
data.tar.gz: 0fae335db04415df0ebb3482afc5f132f83fe6822a988787f4cc10828d979197
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d266562afb53d4e22cb30b340e5e973cc88962386baa295f430a0dcd53873562b8e0f0029215dfbbe241e3a62d11187e09b9eadc546e89517cffb70e9b28cf61
|
|
7
|
+
data.tar.gz: 37a5a82527b15ab1837f8ebf5c918af68de9e504b70f773b615bfa7d2adbe1700007b0df482428aa8fe709cb95ebd6215aa8b014133c54e842e28e5e8df7d797
|
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)
|
|
@@ -68,6 +68,9 @@ module Ask
|
|
|
68
68
|
def delete(key)
|
|
69
69
|
@mutex.synchronize do
|
|
70
70
|
@db.execute("DELETE FROM state_store WHERE key = ?", [key])
|
|
71
|
+
# delete removes everything under the key, including ordered
|
|
72
|
+
# lists (consumers store event feeds as lists).
|
|
73
|
+
@db.execute("DELETE FROM lists WHERE list_key = ?", [key])
|
|
71
74
|
end
|
|
72
75
|
end
|
|
73
76
|
|