ask-state-providers 0.2.0
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 +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +21 -0
- data/README.md +220 -0
- data/lib/ask/state/providers/mysql.rb +256 -0
- data/lib/ask/state/providers/postgres.rb +275 -0
- data/lib/ask/state/providers/redis.rb +148 -0
- data/lib/ask/state/providers/sqlite.rb +261 -0
- data/lib/ask/state/providers/version.rb +9 -0
- data/lib/ask-state-providers.rb +15 -0
- metadata +111 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 18cfafbe7783b55543313b0708ed885275c8bd954a63cf152251dbfb465fde49
|
|
4
|
+
data.tar.gz: 60c56b1e649b86c893e8af07b446c3a022afb4d4b56d0419d74465b402ca36be
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 38f53d279574b58eddf256975467c8bb30047060e5e994093f38ced15feb61c7e02e34c9023b7ec02a669e1b94142ca9a26cdd9d27938c5f5d51aee5d61e0715
|
|
7
|
+
data.tar.gz: be37ac572c9081ab10bf85f878a6285fd04b3522c731ae0bc28d472c855584e567d791b21953f6e013df1cbf5ebe3c48146ba91aded39c4aa08e2e2c80456f1e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] — 2026-07-23
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **BREAKING:** Removed `SessionPersistence` wrapper class — no longer needed. Session accepts `State::Adapter` directly via `state:` keyword.
|
|
10
|
+
- **BREAKING:** `Session.load` now calls `adapter.get(id)` instead of `adapter.load(id)`. Any custom adapter must respond to `get`/`set`/`delete`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Shared contract test suite** — `AdapterContract` module with 26 tests that every backend must pass. Runs against SQLite, Redis (via fakeredis), Postgres (when `DATABASE_URL` is set), and MySQL (when `MYSQL_URL` is set).
|
|
15
|
+
- **Per-turn persistence** — Session now persists after every LLM turn, not just at the end of `run()`. Mid-session crashes no longer lose progress.
|
|
16
|
+
- **`Session.load` restores `@messages`** — Previously `session.messages` returned `nil` after loading. Now it's populated from the restored chat messages.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **README** — Updated `persistence:` example to `state:`.
|
|
21
|
+
|
|
22
|
+
## [0.1.0] — 2026-07-23
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`Ask::State::Providers::SQLite`** — Persistent key-value store backed by SQLite with WAL mode. Supports all `Adapter` primitives: key-value (get/set/delete/set_if_not_exists/clear, TTL), distributed locking (acquire/release with token safety), message queues (FIFO enqueue/dequeue/depth), and ordered lists (append/range/remove with max-length trimming). 58 tests.
|
|
27
|
+
|
|
28
|
+
- **`Ask::State::Providers::Redis`** — Distributed state store backed by Redis with `ask:state:` key namespace. Uses `SET NX EX` for atomic locking, `RPUSH`/`LPOP` for queues, `LTRIM` for bounded lists, and Lua `EVAL` for safe lock release. 40 tests (tested with fakeredis).
|
|
29
|
+
|
|
30
|
+
- **`Ask::State::Providers::PostgreSQL`** — State store backed by PostgreSQL with connection pooling, `ON CONFLICT` handling, and `RETURNING` clauses. Tests skip gracefully when `DATABASE_URL` is not set.
|
|
31
|
+
|
|
32
|
+
- **`Ask::State::Providers::MySQL`** — State store backed by MySQL/MariaDB with prepared statements and `ON DUPLICATE KEY UPDATE`. Tests skip gracefully when `MYSQL_URL` is not set.
|
|
33
|
+
|
|
34
|
+
- **Autoloading** — All providers use Ruby `autoload` so only the backends you need are loaded.
|
|
35
|
+
|
|
36
|
+
- **CI** — GitHub Actions workflow testing Ruby 3.2, 3.3, and 3.4.
|
|
37
|
+
|
|
38
|
+
- **Documentation** — Full README with quick start, backend comparison table, and API reference.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# ask-state-providers
|
|
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.
|
|
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.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your `Gemfile`:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "ask-state-providers"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then add the database driver for the backend you want to use:
|
|
29
|
+
|
|
30
|
+
```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"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "ask-state-providers"
|
|
48
|
+
|
|
49
|
+
# Pick your backend:
|
|
50
|
+
store = Ask::State::Providers::SQLite.new(path: "my_app.db")
|
|
51
|
+
# store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
|
|
52
|
+
# store = Ask::State::Providers::Postgres.new(url: ENV["DATABASE_URL"])
|
|
53
|
+
# store = Ask::State::Providers::MySQL.new(url: ENV["MYSQL_URL"])
|
|
54
|
+
|
|
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
|
|
62
|
+
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
|
|
69
|
+
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
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Backends
|
|
78
|
+
|
|
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.
|
|
117
|
+
|
|
118
|
+
### MySQL (`Ask::State::Providers::MySQL`)
|
|
119
|
+
|
|
120
|
+
Best for teams already running MySQL or MariaDB.
|
|
121
|
+
|
|
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) |
|
|
127
|
+
|
|
128
|
+
Uses prepared statements, `ON DUPLICATE KEY UPDATE`, and `SELECT ... LIMIT 1` for safe dequeue.
|
|
129
|
+
|
|
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
|
+
```
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# Install dependencies
|
|
195
|
+
bundle install
|
|
196
|
+
|
|
197
|
+
# Run tests (SQLite tests run everywhere, Redis needs fakeredis,
|
|
198
|
+
# Postgres/MySQL need DATABASE_URL/MYSQL_URL env vars)
|
|
199
|
+
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
|
+
```
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
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
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module State
|
|
9
|
+
module Providers
|
|
10
|
+
# A persistent key-value store backed by MySQL.
|
|
11
|
+
#
|
|
12
|
+
# Implements the full {Ask::State::Adapter} contract:
|
|
13
|
+
# key-value storage, distributed locking, message queues, and ordered
|
|
14
|
+
# lists. Uses the +mysql2+ gem for database access.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = Ask::State::Providers::MySQL.new(
|
|
18
|
+
# url: ENV["MYSQL_URL"]
|
|
19
|
+
# )
|
|
20
|
+
# store.set("key", { hello: "world" })
|
|
21
|
+
# store.get("key") # => { "hello" => "world" }
|
|
22
|
+
#
|
|
23
|
+
class MySQL < ::Ask::State::Adapter
|
|
24
|
+
MIGRATIONS = <<~SQL
|
|
25
|
+
CREATE TABLE IF NOT EXISTS state_store (
|
|
26
|
+
`key` VARCHAR(255) PRIMARY KEY NOT NULL,
|
|
27
|
+
`value` TEXT NOT NULL,
|
|
28
|
+
expires_at DATETIME(3) NULL
|
|
29
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS locks (
|
|
32
|
+
`key` VARCHAR(255) PRIMARY KEY NOT NULL,
|
|
33
|
+
token VARCHAR(64) NOT NULL,
|
|
34
|
+
expires_at DATETIME(3) NOT NULL
|
|
35
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS queues (
|
|
38
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
39
|
+
queue_name VARCHAR(255) NOT NULL,
|
|
40
|
+
`value` TEXT NOT NULL,
|
|
41
|
+
enqueued_at DATETIME(3) NOT NULL,
|
|
42
|
+
INDEX idx_queues_queue_name (queue_name, id)
|
|
43
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS lists (
|
|
46
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
47
|
+
list_key VARCHAR(255) NOT NULL,
|
|
48
|
+
`value` TEXT NOT NULL,
|
|
49
|
+
INDEX idx_lists_list_key (list_key, id)
|
|
50
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
51
|
+
SQL
|
|
52
|
+
|
|
53
|
+
# Creates a new MySQL-backed state store.
|
|
54
|
+
#
|
|
55
|
+
# @param url [String] MySQL connection URL
|
|
56
|
+
def initialize(url: ENV.fetch("MYSQL_URL", "mysql2://root@localhost:3306/ask_state"))
|
|
57
|
+
require "mysql2"
|
|
58
|
+
|
|
59
|
+
uri = URI.parse(url)
|
|
60
|
+
@client = Mysql2::Client.new(
|
|
61
|
+
host: uri.host || "localhost",
|
|
62
|
+
port: uri.port || 3306,
|
|
63
|
+
username: uri.user || "root",
|
|
64
|
+
password: uri.password,
|
|
65
|
+
database: uri.path.sub("/", ""),
|
|
66
|
+
charset: "utf8mb4"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
migrate
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -- key-value --
|
|
73
|
+
|
|
74
|
+
def get(key)
|
|
75
|
+
row = @client.prepare(<<~SQL).execute(key, Time.now.utc.strftime("%Y-%m-%d %H:%M:%S.%3N")).first
|
|
76
|
+
SELECT `value` FROM state_store
|
|
77
|
+
WHERE `key` = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
78
|
+
SQL
|
|
79
|
+
row ? JSON.parse(row["value"]) : nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def set(key, value, ttl: nil)
|
|
83
|
+
stmt = @client.prepare(<<~SQL)
|
|
84
|
+
INSERT INTO state_store (`key`, `value`, expires_at)
|
|
85
|
+
VALUES (?, ?, ?)
|
|
86
|
+
ON DUPLICATE KEY UPDATE
|
|
87
|
+
`value` = VALUES(`value`),
|
|
88
|
+
expires_at = VALUES(expires_at)
|
|
89
|
+
SQL
|
|
90
|
+
stmt.execute(key, JSON.generate(value), ttl ? (Time.now.utc + ttl).strftime("%Y-%m-%d %H:%M:%S.%3N") : nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delete(key)
|
|
94
|
+
@client.prepare("DELETE FROM state_store WHERE `key` = ?").execute(key)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def set_if_not_exists(key, value, ttl: nil)
|
|
98
|
+
now = Time.now.utc
|
|
99
|
+
expires = ttl ? (now + ttl).strftime("%Y-%m-%d %H:%M:%S.%3N") : nil
|
|
100
|
+
now_f = now.strftime("%Y-%m-%d %H:%M:%S.%3N")
|
|
101
|
+
|
|
102
|
+
stmt = @client.prepare(<<~SQL)
|
|
103
|
+
INSERT INTO state_store (`key`, `value`, expires_at)
|
|
104
|
+
SELECT ?, ?, ?
|
|
105
|
+
WHERE NOT EXISTS (
|
|
106
|
+
SELECT 1 FROM state_store
|
|
107
|
+
WHERE `key` = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
108
|
+
)
|
|
109
|
+
SQL
|
|
110
|
+
# MySQL's INSERT...SELECT won't fail on DUPLICATE KEY if WHERE is false
|
|
111
|
+
stmt.execute(key, JSON.generate(value), expires, key, now_f)
|
|
112
|
+
@client.affected_rows > 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def clear
|
|
116
|
+
@client.query("DELETE FROM state_store")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# -- distributed locking --
|
|
120
|
+
|
|
121
|
+
def acquire_lock(key, ttl: 10)
|
|
122
|
+
now = Time.now.utc
|
|
123
|
+
expires_at_time = now + ttl
|
|
124
|
+
token = SecureRandom.hex(16)
|
|
125
|
+
now_f = now.strftime("%Y-%m-%d %H:%M:%S.%3N")
|
|
126
|
+
|
|
127
|
+
# Clean up expired lock
|
|
128
|
+
@client.prepare("DELETE FROM locks WHERE `key` = ? AND expires_at <= ?").execute(key, now_f)
|
|
129
|
+
|
|
130
|
+
result = @client.prepare(<<~SQL).execute(key, expires_at_time.strftime("%Y-%m-%d %H:%M:%S.%3N"), token)
|
|
131
|
+
INSERT INTO locks (`key`, expires_at, token)
|
|
132
|
+
SELECT ?, ?, ?
|
|
133
|
+
WHERE NOT EXISTS (
|
|
134
|
+
SELECT 1 FROM locks WHERE `key` = ?
|
|
135
|
+
)
|
|
136
|
+
SQL
|
|
137
|
+
result = @client.prepare(<<~SQL).execute(key)
|
|
138
|
+
SELECT COUNT(*) AS cnt FROM locks WHERE `key` = ?
|
|
139
|
+
SQL
|
|
140
|
+
|
|
141
|
+
# Check if we got the lock
|
|
142
|
+
row = result.first
|
|
143
|
+
return nil unless row && row["cnt"].to_i > 0
|
|
144
|
+
|
|
145
|
+
Lock.new(id: key, token: token, expires_at: expires_at_time)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def release_lock(key, lock)
|
|
149
|
+
@client.prepare("DELETE FROM locks WHERE `key` = ? AND token = ?").execute(key, lock.token)
|
|
150
|
+
@client.affected_rows > 0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# -- message queues --
|
|
154
|
+
|
|
155
|
+
def enqueue(queue, value)
|
|
156
|
+
stmt = @client.prepare(<<~SQL)
|
|
157
|
+
INSERT INTO queues (queue_name, `value`, enqueued_at)
|
|
158
|
+
VALUES (?, ?, ?)
|
|
159
|
+
SQL
|
|
160
|
+
stmt.execute(queue, JSON.generate(value), Time.now.utc.strftime("%Y-%m-%d %H:%M:%S.%3N"))
|
|
161
|
+
id = @client.last_id
|
|
162
|
+
|
|
163
|
+
QueueEntry.new(id: id.to_s, value: value, enqueued_at: Time.now)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dequeue(queue)
|
|
167
|
+
row = @client.prepare(<<~SQL).execute(queue).first
|
|
168
|
+
SELECT id, `value`, enqueued_at FROM queues
|
|
169
|
+
WHERE queue_name = ?
|
|
170
|
+
ORDER BY id ASC
|
|
171
|
+
LIMIT 1
|
|
172
|
+
SQL
|
|
173
|
+
return nil unless row
|
|
174
|
+
|
|
175
|
+
@client.prepare("DELETE FROM queues WHERE id = ?").execute(row["id"])
|
|
176
|
+
|
|
177
|
+
QueueEntry.new(
|
|
178
|
+
id: row["id"].to_s,
|
|
179
|
+
value: JSON.parse(row["value"]),
|
|
180
|
+
enqueued_at: Time.parse(row["enqueued_at"])
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def queue_depth(queue)
|
|
185
|
+
result = @client.prepare("SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = ?").execute(queue)
|
|
186
|
+
row = result.first
|
|
187
|
+
row["cnt"]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# -- ordered lists --
|
|
191
|
+
|
|
192
|
+
def list_append(key, value, max_length: nil)
|
|
193
|
+
stmt = @client.prepare("INSERT INTO lists (list_key, `value`) VALUES (?, ?)")
|
|
194
|
+
stmt.execute(key, JSON.generate(value))
|
|
195
|
+
|
|
196
|
+
return unless max_length
|
|
197
|
+
|
|
198
|
+
@client.prepare(<<~SQL).execute(key, key, max_length)
|
|
199
|
+
DELETE FROM lists WHERE id <= (
|
|
200
|
+
SELECT COALESCE(MIN(id), 0) FROM (
|
|
201
|
+
SELECT id FROM lists
|
|
202
|
+
WHERE list_key = ?
|
|
203
|
+
ORDER BY id DESC
|
|
204
|
+
LIMIT ?
|
|
205
|
+
) sub
|
|
206
|
+
)
|
|
207
|
+
SQL
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def list_range(key, start = 0, stop = -1)
|
|
211
|
+
rows = if stop == -1
|
|
212
|
+
@client.prepare(<<~SQL).execute(key, start)
|
|
213
|
+
SELECT `value` FROM lists
|
|
214
|
+
WHERE list_key = ?
|
|
215
|
+
ORDER BY id ASC
|
|
216
|
+
LIMIT 18446744073709551615 OFFSET ?
|
|
217
|
+
SQL
|
|
218
|
+
else
|
|
219
|
+
limit = stop - start + 1
|
|
220
|
+
@client.prepare(<<~SQL).execute(key, limit, start)
|
|
221
|
+
SELECT `value` FROM lists
|
|
222
|
+
WHERE list_key = ?
|
|
223
|
+
ORDER BY id ASC
|
|
224
|
+
LIMIT ? OFFSET ?
|
|
225
|
+
SQL
|
|
226
|
+
end
|
|
227
|
+
rows.map { |r| JSON.parse(r["value"]) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def list_remove(key, value)
|
|
231
|
+
serialized = JSON.generate(value)
|
|
232
|
+
@client.prepare("DELETE FROM lists WHERE list_key = ? AND `value` = ?").execute(key, serialized)
|
|
233
|
+
@client.affected_rows
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# -- lifecycle --
|
|
237
|
+
|
|
238
|
+
def close
|
|
239
|
+
@client&.close
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def migrate
|
|
245
|
+
MIGRATIONS.split(";").each do |sql|
|
|
246
|
+
sql = sql.strip
|
|
247
|
+
@client.query(sql) unless sql.empty?
|
|
248
|
+
end
|
|
249
|
+
rescue => e
|
|
250
|
+
# Tables may already exist with different charset — that's fine
|
|
251
|
+
raise unless e.message.include?("already exists")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module State
|
|
9
|
+
module Providers
|
|
10
|
+
# A persistent key-value store backed by PostgreSQL.
|
|
11
|
+
#
|
|
12
|
+
# Implements the full {Ask::State::Adapter} contract:
|
|
13
|
+
# key-value storage, distributed locking, message queues, and ordered
|
|
14
|
+
# lists. Uses the +pg+ gem for database access.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = Ask::State::Providers::Postgres.new(
|
|
18
|
+
# url: ENV["DATABASE_URL"]
|
|
19
|
+
# )
|
|
20
|
+
# store.set("key", { hello: "world" })
|
|
21
|
+
# store.get("key") # => { "hello" => "world" }
|
|
22
|
+
#
|
|
23
|
+
class Postgres < ::Ask::State::Adapter
|
|
24
|
+
MIGRATIONS = <<~SQL
|
|
25
|
+
CREATE TABLE IF NOT EXISTS state_store (
|
|
26
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
27
|
+
value TEXT NOT NULL,
|
|
28
|
+
expires_at TIMESTAMPTZ
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS locks (
|
|
32
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
33
|
+
token TEXT NOT NULL,
|
|
34
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS queues (
|
|
38
|
+
id BIGSERIAL PRIMARY KEY,
|
|
39
|
+
queue_name TEXT NOT NULL,
|
|
40
|
+
value TEXT NOT NULL,
|
|
41
|
+
enqueued_at TIMESTAMPTZ NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_queues_queue_name
|
|
44
|
+
ON queues (queue_name, id);
|
|
45
|
+
|
|
46
|
+
CREATE TABLE IF NOT EXISTS lists (
|
|
47
|
+
id BIGSERIAL PRIMARY KEY,
|
|
48
|
+
list_key TEXT NOT NULL,
|
|
49
|
+
value TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_lists_list_key
|
|
52
|
+
ON lists (list_key, id);
|
|
53
|
+
SQL
|
|
54
|
+
|
|
55
|
+
# Creates a new PostgreSQL-backed state store.
|
|
56
|
+
#
|
|
57
|
+
# @param url [String] Postgres connection URL
|
|
58
|
+
# @param pool_size [Integer] connection pool size (default 5)
|
|
59
|
+
def initialize(url: ENV.fetch("DATABASE_URL", "postgres://localhost:5432/ask_state"),
|
|
60
|
+
pool_size: 5)
|
|
61
|
+
require "pg"
|
|
62
|
+
|
|
63
|
+
@pool = ConnectionPool.new(size: pool_size) do
|
|
64
|
+
::PG.connect(url)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
migrate
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# -- key-value --
|
|
71
|
+
|
|
72
|
+
def get(key)
|
|
73
|
+
@pool.with do |conn|
|
|
74
|
+
row = conn.exec_params(<<~SQL, [key, Time.now.utc])
|
|
75
|
+
SELECT value FROM state_store
|
|
76
|
+
WHERE key = $1 AND (expires_at IS NULL OR expires_at > $2)
|
|
77
|
+
SQL
|
|
78
|
+
row.ntuples > 0 ? JSON.parse(row[0]["value"]) : nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def set(key, value, ttl: nil)
|
|
83
|
+
@pool.with do |conn|
|
|
84
|
+
conn.exec_params(<<~SQL, [key, JSON.generate(value), ttl ? Time.now.utc + ttl : nil])
|
|
85
|
+
INSERT INTO state_store (key, value, expires_at)
|
|
86
|
+
VALUES ($1, $2, $3)
|
|
87
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
88
|
+
value = EXCLUDED.value,
|
|
89
|
+
expires_at = EXCLUDED.expires_at
|
|
90
|
+
SQL
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def delete(key)
|
|
95
|
+
@pool.with do |conn|
|
|
96
|
+
conn.exec_params("DELETE FROM state_store WHERE key = $1", [key])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def set_if_not_exists(key, value, ttl: nil)
|
|
101
|
+
@pool.with do |conn|
|
|
102
|
+
now = Time.now.utc
|
|
103
|
+
expires = ttl ? now + ttl : nil
|
|
104
|
+
|
|
105
|
+
result = conn.exec_params(<<~SQL, [key, JSON.generate(value), expires, now])
|
|
106
|
+
INSERT INTO state_store (key, value, expires_at)
|
|
107
|
+
SELECT $1, $2, $3
|
|
108
|
+
WHERE NOT EXISTS (
|
|
109
|
+
SELECT 1 FROM state_store
|
|
110
|
+
WHERE key = $1 AND (expires_at IS NULL OR expires_at > $4)
|
|
111
|
+
)
|
|
112
|
+
ON CONFLICT (key) DO NOTHING
|
|
113
|
+
SQL
|
|
114
|
+
result.cmd_tuples > 0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def clear
|
|
119
|
+
@pool.with { |conn| conn.exec("DELETE FROM state_store") }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -- distributed locking --
|
|
123
|
+
|
|
124
|
+
def acquire_lock(key, ttl: 10)
|
|
125
|
+
now = Time.now.utc
|
|
126
|
+
expires_at_time = now + ttl
|
|
127
|
+
token = SecureRandom.hex(16)
|
|
128
|
+
|
|
129
|
+
acquired = @pool.with do |conn|
|
|
130
|
+
# First clean up expired locks
|
|
131
|
+
conn.exec_params("DELETE FROM locks WHERE key = $1 AND expires_at <= $2",
|
|
132
|
+
[key, now])
|
|
133
|
+
|
|
134
|
+
result = conn.exec_params(<<~SQL, [key, expires_at_time, token, now])
|
|
135
|
+
INSERT INTO locks (key, expires_at, token)
|
|
136
|
+
SELECT $1, $2, $3
|
|
137
|
+
WHERE NOT EXISTS (
|
|
138
|
+
SELECT 1 FROM locks
|
|
139
|
+
WHERE key = $1
|
|
140
|
+
)
|
|
141
|
+
SQL
|
|
142
|
+
result.cmd_tuples > 0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
acquired ? Lock.new(id: key, token: token, expires_at: expires_at_time) : nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def release_lock(key, lock)
|
|
149
|
+
@pool.with do |conn|
|
|
150
|
+
result = conn.exec_params(
|
|
151
|
+
"DELETE FROM locks WHERE key = $1 AND token = $2",
|
|
152
|
+
[key, lock.token]
|
|
153
|
+
)
|
|
154
|
+
result.cmd_tuples > 0
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# -- message queues --
|
|
159
|
+
|
|
160
|
+
def enqueue(queue, value)
|
|
161
|
+
id = SecureRandom.uuid
|
|
162
|
+
|
|
163
|
+
@pool.with do |conn|
|
|
164
|
+
conn.exec_params(<<~SQL, [queue, JSON.generate(value), Time.now.utc])
|
|
165
|
+
INSERT INTO queues (queue_name, value, enqueued_at)
|
|
166
|
+
VALUES ($1, $2, $3)
|
|
167
|
+
SQL
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
QueueEntry.new(id: id, value: value, enqueued_at: Time.now)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def dequeue(queue)
|
|
174
|
+
@pool.with do |conn|
|
|
175
|
+
result = conn.exec_params(<<~SQL, [queue])
|
|
176
|
+
DELETE FROM queues
|
|
177
|
+
WHERE id = (
|
|
178
|
+
SELECT id FROM queues
|
|
179
|
+
WHERE queue_name = $1
|
|
180
|
+
ORDER BY id ASC
|
|
181
|
+
LIMIT 1
|
|
182
|
+
)
|
|
183
|
+
RETURNING value, enqueued_at
|
|
184
|
+
SQL
|
|
185
|
+
return nil if result.ntuples == 0
|
|
186
|
+
|
|
187
|
+
QueueEntry.new(
|
|
188
|
+
id: SecureRandom.uuid,
|
|
189
|
+
value: JSON.parse(result[0]["value"]),
|
|
190
|
+
enqueued_at: Time.parse(result[0]["enqueued_at"])
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def queue_depth(queue)
|
|
196
|
+
@pool.with do |conn|
|
|
197
|
+
result = conn.exec_params(
|
|
198
|
+
"SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = $1", [queue]
|
|
199
|
+
)
|
|
200
|
+
result[0]["cnt"].to_i
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# -- ordered lists --
|
|
205
|
+
|
|
206
|
+
def list_append(key, value, max_length: nil)
|
|
207
|
+
@pool.with do |conn|
|
|
208
|
+
conn.exec_params(<<~SQL, [key, JSON.generate(value)])
|
|
209
|
+
INSERT INTO lists (list_key, value)
|
|
210
|
+
VALUES ($1, $2)
|
|
211
|
+
SQL
|
|
212
|
+
|
|
213
|
+
return unless max_length
|
|
214
|
+
|
|
215
|
+
conn.exec_params(<<~SQL, [key, key, max_length])
|
|
216
|
+
DELETE FROM lists WHERE id <= (
|
|
217
|
+
SELECT COALESCE(MIN(id), 0) FROM (
|
|
218
|
+
SELECT id FROM lists
|
|
219
|
+
WHERE list_key = $1
|
|
220
|
+
ORDER BY id DESC
|
|
221
|
+
LIMIT $3
|
|
222
|
+
) sub
|
|
223
|
+
)
|
|
224
|
+
SQL
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def list_range(key, start = 0, stop = -1)
|
|
229
|
+
@pool.with do |conn|
|
|
230
|
+
rows = if stop == -1
|
|
231
|
+
conn.exec_params(<<~SQL, [key, start])
|
|
232
|
+
SELECT value FROM lists
|
|
233
|
+
WHERE list_key = $1
|
|
234
|
+
ORDER BY id ASC
|
|
235
|
+
OFFSET $2
|
|
236
|
+
SQL
|
|
237
|
+
else
|
|
238
|
+
limit = stop - start + 1
|
|
239
|
+
conn.exec_params(<<~SQL, [key, limit, start])
|
|
240
|
+
SELECT value FROM lists
|
|
241
|
+
WHERE list_key = $1
|
|
242
|
+
ORDER BY id ASC
|
|
243
|
+
LIMIT $2 OFFSET $3
|
|
244
|
+
SQL
|
|
245
|
+
end
|
|
246
|
+
rows.map { |r| JSON.parse(r["value"]) }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def list_remove(key, value)
|
|
251
|
+
serialized = JSON.generate(value)
|
|
252
|
+
@pool.with do |conn|
|
|
253
|
+
result = conn.exec_params(
|
|
254
|
+
"DELETE FROM lists WHERE list_key = $1 AND value = $2",
|
|
255
|
+
[key, serialized]
|
|
256
|
+
)
|
|
257
|
+
result.cmd_tuples
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# -- lifecycle --
|
|
262
|
+
|
|
263
|
+
def close
|
|
264
|
+
@pool&.shutdown { |c| c.close }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def migrate
|
|
270
|
+
@pool.with { |conn| conn.exec(MIGRATIONS) }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module State
|
|
8
|
+
module Providers
|
|
9
|
+
# A distributed key-value store backed by Redis.
|
|
10
|
+
#
|
|
11
|
+
# Implements the full {Ask::State::Adapter} contract:
|
|
12
|
+
# key-value storage, distributed locking, message queues, and ordered
|
|
13
|
+
# lists. Best suited for multi-process or multi-host deployments where
|
|
14
|
+
# shared state is required.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = Ask::State::Providers::Redis.new(url: ENV["REDIS_URL"])
|
|
18
|
+
# store.set("key", { hello: "world" })
|
|
19
|
+
# store.get("key") # => { "hello" => "world" }
|
|
20
|
+
#
|
|
21
|
+
class Redis < ::Ask::State::Adapter
|
|
22
|
+
NAMESPACE = "ask:state"
|
|
23
|
+
|
|
24
|
+
# Creates a new Redis-backed state store.
|
|
25
|
+
#
|
|
26
|
+
# @param url [String] Redis URL (redis://...)
|
|
27
|
+
def initialize(url: ENV.fetch("REDIS_URL", "redis://localhost:6379"))
|
|
28
|
+
require "redis"
|
|
29
|
+
|
|
30
|
+
@redis = ::Redis.new(url: url)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -- key-value --
|
|
34
|
+
|
|
35
|
+
def get(key)
|
|
36
|
+
val = @redis.call("GET", prefixed(key))
|
|
37
|
+
val ? JSON.parse(val) : nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def set(key, value, ttl: nil)
|
|
41
|
+
args = [prefixed(key), JSON.generate(value)]
|
|
42
|
+
args.concat(["EX", ttl.to_i]) if ttl
|
|
43
|
+
@redis.call("SET", *args)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def delete(key)
|
|
47
|
+
@redis.call("DEL", prefixed(key))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def set_if_not_exists(key, value, ttl: nil)
|
|
51
|
+
args = [prefixed(key), JSON.generate(value)]
|
|
52
|
+
args.concat(["EX", ttl.to_i]) if ttl
|
|
53
|
+
@redis.call("SET", *args, "NX") == "OK"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear
|
|
57
|
+
keys = @redis.call("KEYS", "#{NAMESPACE}:*")
|
|
58
|
+
@redis.call("DEL", *keys) if keys.any?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# -- distributed locking --
|
|
62
|
+
|
|
63
|
+
def acquire_lock(key, ttl: 10)
|
|
64
|
+
prefixed_key = prefixed("lock:#{key}")
|
|
65
|
+
token = SecureRandom.hex(16)
|
|
66
|
+
expires_at = Time.now + ttl
|
|
67
|
+
|
|
68
|
+
acquired = @redis.call("SET", prefixed_key, token, "NX", "EX", ttl) == "OK"
|
|
69
|
+
acquired ? Lock.new(id: key, token: token, expires_at: expires_at) : nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def release_lock(key, lock)
|
|
73
|
+
prefixed_key = prefixed("lock:#{key}")
|
|
74
|
+
|
|
75
|
+
# Atomic release: only delete if token matches (Lua script)
|
|
76
|
+
script = <<~LUA
|
|
77
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
78
|
+
return redis.call("DEL", KEYS[1])
|
|
79
|
+
else
|
|
80
|
+
return 0
|
|
81
|
+
end
|
|
82
|
+
LUA
|
|
83
|
+
|
|
84
|
+
@redis.call("EVAL", script, [1], [prefixed_key, lock.token]) == 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# -- message queues --
|
|
88
|
+
|
|
89
|
+
def enqueue(queue, value)
|
|
90
|
+
id = SecureRandom.uuid
|
|
91
|
+
entry = { id: id, value: value, enqueued_at: Time.now.iso8601 }
|
|
92
|
+
|
|
93
|
+
@redis.call("RPUSH", prefixed("queue:#{queue}"), JSON.generate(entry))
|
|
94
|
+
|
|
95
|
+
QueueEntry.new(id: id, value: value, enqueued_at: Time.now)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def dequeue(queue)
|
|
99
|
+
val = @redis.call("LPOP", prefixed("queue:#{queue}"))
|
|
100
|
+
return nil unless val
|
|
101
|
+
|
|
102
|
+
entry = JSON.parse(val)
|
|
103
|
+
QueueEntry.new(
|
|
104
|
+
id: entry["id"],
|
|
105
|
+
value: entry["value"],
|
|
106
|
+
enqueued_at: Time.parse(entry["enqueued_at"])
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def queue_depth(queue)
|
|
111
|
+
@redis.call("LLEN", prefixed("queue:#{queue}"))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# -- ordered lists --
|
|
115
|
+
|
|
116
|
+
def list_append(key, value, max_length: nil)
|
|
117
|
+
@redis.call("RPUSH", prefixed("list:#{key}"), JSON.generate(value))
|
|
118
|
+
return unless max_length
|
|
119
|
+
|
|
120
|
+
# LTRIM keeps only the specified range (negative indices, so -max_length means keep newest max_length)
|
|
121
|
+
@redis.call("LTRIM", prefixed("list:#{key}"), -max_length, -1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def list_range(key, start = 0, stop = -1)
|
|
125
|
+
items = @redis.call("LRANGE", prefixed("list:#{key}"), start, stop)
|
|
126
|
+
items.map { |item| JSON.parse(item) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def list_remove(key, value)
|
|
130
|
+
serialized = JSON.generate(value)
|
|
131
|
+
@redis.call("LREM", prefixed("list:#{key}"), 0, serialized)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# -- lifecycle --
|
|
135
|
+
|
|
136
|
+
def close
|
|
137
|
+
@redis&.close
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def prefixed(key)
|
|
143
|
+
"#{NAMESPACE}:#{key}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module State
|
|
9
|
+
module Providers
|
|
10
|
+
# A persistent key-value store backed by SQLite.
|
|
11
|
+
#
|
|
12
|
+
# Implements the full {Ask::State::Adapter} contract:
|
|
13
|
+
# key-value storage, distributed locking, message queues, and ordered
|
|
14
|
+
# lists. Uses the +sqlite3+ gem.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# store = Ask::State::Providers::SQLite.new(path: "sessions.db")
|
|
18
|
+
# store.set("key", { hello: "world" })
|
|
19
|
+
# store.get("key") # => { "hello" => "world" }
|
|
20
|
+
#
|
|
21
|
+
class SQLite < ::Ask::State::Adapter
|
|
22
|
+
# Creates or opens the database at +path+.
|
|
23
|
+
def initialize(path: "sessions.db", **pragmas)
|
|
24
|
+
require "sqlite3"
|
|
25
|
+
|
|
26
|
+
@db = SQLite3::Database.new(path)
|
|
27
|
+
@db.results_as_hash = true
|
|
28
|
+
@db.busy_timeout = 5000
|
|
29
|
+
|
|
30
|
+
defaults = {
|
|
31
|
+
journal_mode: "WAL",
|
|
32
|
+
synchronous: "NORMAL",
|
|
33
|
+
foreign_keys: "ON",
|
|
34
|
+
cache_size: -64_000
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
defaults.merge(pragmas).each do |key, value|
|
|
38
|
+
@db.execute("PRAGMA #{key} = #{value}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
migrate
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# -- key-value --
|
|
45
|
+
|
|
46
|
+
def get(key)
|
|
47
|
+
row = @db.get_first_row(<<~SQL, [key, Time.now.to_f])
|
|
48
|
+
SELECT value FROM state_store
|
|
49
|
+
WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)
|
|
50
|
+
SQL
|
|
51
|
+
row ? JSON.parse(row["value"]) : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set(key, value, ttl: nil)
|
|
55
|
+
@db.execute(<<~SQL, [key, JSON.generate(value), ttl ? Time.now.to_f + ttl : nil])
|
|
56
|
+
INSERT OR REPLACE INTO state_store (key, value, expires_at)
|
|
57
|
+
VALUES (?, ?, ?)
|
|
58
|
+
SQL
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delete(key)
|
|
62
|
+
@db.execute("DELETE FROM state_store WHERE key = ?", [key])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def set_if_not_exists(key, value, ttl: nil)
|
|
66
|
+
now = Time.now.to_f
|
|
67
|
+
expires = ttl ? now + ttl : nil
|
|
68
|
+
|
|
69
|
+
@db.transaction do
|
|
70
|
+
row = @db.get_first_row(
|
|
71
|
+
"SELECT 1 FROM state_store WHERE key = ? AND (expires_at IS NULL OR expires_at > ?)",
|
|
72
|
+
[key, now]
|
|
73
|
+
)
|
|
74
|
+
next false if row
|
|
75
|
+
|
|
76
|
+
@db.execute("DELETE FROM state_store WHERE key = ?", [key])
|
|
77
|
+
@db.execute(<<~SQL, [key, JSON.generate(value), expires])
|
|
78
|
+
INSERT INTO state_store (key, value, expires_at)
|
|
79
|
+
VALUES (?, ?, ?)
|
|
80
|
+
SQL
|
|
81
|
+
next true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def clear
|
|
86
|
+
@db.execute("DELETE FROM state_store")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# -- distributed locking --
|
|
90
|
+
|
|
91
|
+
def acquire_lock(key, ttl: 10)
|
|
92
|
+
now = Time.now.to_f
|
|
93
|
+
expires_at_time = Time.now + ttl
|
|
94
|
+
token = SecureRandom.hex(16)
|
|
95
|
+
|
|
96
|
+
acquired = @db.transaction do
|
|
97
|
+
row = @db.get_first_row(
|
|
98
|
+
"SELECT 1 FROM locks WHERE key = ? AND expires_at > ?",
|
|
99
|
+
[key, now]
|
|
100
|
+
)
|
|
101
|
+
next false if row
|
|
102
|
+
|
|
103
|
+
@db.execute("DELETE FROM locks WHERE key = ?", [key])
|
|
104
|
+
@db.execute(<<~SQL, [key, expires_at_time.to_f, token])
|
|
105
|
+
INSERT INTO locks (key, expires_at, token)
|
|
106
|
+
VALUES (?, ?, ?)
|
|
107
|
+
SQL
|
|
108
|
+
next true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
acquired ? Lock.new(id: key, token: token, expires_at: expires_at_time) : nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def release_lock(key, lock)
|
|
115
|
+
@db.execute(
|
|
116
|
+
"DELETE FROM locks WHERE key = ? AND token = ?",
|
|
117
|
+
[key, lock.token]
|
|
118
|
+
)
|
|
119
|
+
@db.changes > 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# -- message queues --
|
|
123
|
+
|
|
124
|
+
def enqueue(queue, value)
|
|
125
|
+
@db.execute(<<~SQL, [queue, JSON.generate(value), Time.now.iso8601])
|
|
126
|
+
INSERT INTO queues (queue_name, value, enqueued_at)
|
|
127
|
+
VALUES (?, ?, ?)
|
|
128
|
+
SQL
|
|
129
|
+
id = @db.last_insert_row_id
|
|
130
|
+
QueueEntry.new(id: id.to_s, value: value, enqueued_at: Time.now)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def dequeue(queue)
|
|
134
|
+
row = @db.get_first_row(<<~SQL, [queue])
|
|
135
|
+
DELETE FROM queues
|
|
136
|
+
WHERE id = (
|
|
137
|
+
SELECT id FROM queues
|
|
138
|
+
WHERE queue_name = ?
|
|
139
|
+
ORDER BY id ASC
|
|
140
|
+
LIMIT 1
|
|
141
|
+
)
|
|
142
|
+
RETURNING id, value, enqueued_at
|
|
143
|
+
SQL
|
|
144
|
+
return nil unless row
|
|
145
|
+
|
|
146
|
+
QueueEntry.new(
|
|
147
|
+
id: row["id"].to_s,
|
|
148
|
+
value: JSON.parse(row["value"]),
|
|
149
|
+
enqueued_at: Time.parse(row["enqueued_at"])
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def queue_depth(queue)
|
|
154
|
+
row = @db.get_first_row(
|
|
155
|
+
"SELECT COUNT(*) AS cnt FROM queues WHERE queue_name = ?", [queue]
|
|
156
|
+
)
|
|
157
|
+
row["cnt"]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# -- ordered lists --
|
|
161
|
+
|
|
162
|
+
def list_append(key, value, max_length: nil)
|
|
163
|
+
serialized = JSON.generate(value)
|
|
164
|
+
|
|
165
|
+
@db.execute(<<~SQL, [key, serialized])
|
|
166
|
+
INSERT INTO lists (list_key, value)
|
|
167
|
+
VALUES (?, ?)
|
|
168
|
+
SQL
|
|
169
|
+
|
|
170
|
+
return unless max_length
|
|
171
|
+
|
|
172
|
+
# Keep only the newest max_length items by deleting those with the
|
|
173
|
+
# smallest id values beyond the threshold
|
|
174
|
+
row = @db.get_first_row(<<~SQL, [key, max_length])
|
|
175
|
+
SELECT MIN(id) AS cutoff FROM (
|
|
176
|
+
SELECT id FROM lists
|
|
177
|
+
WHERE list_key = ?
|
|
178
|
+
ORDER BY id DESC
|
|
179
|
+
LIMIT ?
|
|
180
|
+
)
|
|
181
|
+
SQL
|
|
182
|
+
return unless row && row["cutoff"]
|
|
183
|
+
|
|
184
|
+
@db.execute(<<~SQL, [key, row["cutoff"]])
|
|
185
|
+
DELETE FROM lists WHERE list_key = ? AND id < ?
|
|
186
|
+
SQL
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def list_range(key, start = 0, stop = -1)
|
|
190
|
+
if stop == -1
|
|
191
|
+
rows = @db.execute(<<~SQL, [key, start])
|
|
192
|
+
SELECT value FROM lists
|
|
193
|
+
WHERE list_key = ?
|
|
194
|
+
ORDER BY id ASC
|
|
195
|
+
LIMIT -1 OFFSET ?
|
|
196
|
+
SQL
|
|
197
|
+
else
|
|
198
|
+
limit = stop - start + 1
|
|
199
|
+
rows = @db.execute(<<~SQL, [key, limit, start])
|
|
200
|
+
SELECT value FROM lists
|
|
201
|
+
WHERE list_key = ?
|
|
202
|
+
ORDER BY id ASC
|
|
203
|
+
LIMIT ? OFFSET ?
|
|
204
|
+
SQL
|
|
205
|
+
end
|
|
206
|
+
rows.map { |r| JSON.parse(r["value"]) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def list_remove(key, value)
|
|
210
|
+
serialized = JSON.generate(value)
|
|
211
|
+
@db.execute(
|
|
212
|
+
"DELETE FROM lists WHERE list_key = ? AND value = ?",
|
|
213
|
+
[key, serialized]
|
|
214
|
+
)
|
|
215
|
+
@db.changes
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# -- lifecycle --
|
|
219
|
+
|
|
220
|
+
def close
|
|
221
|
+
@db.close
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def migrate
|
|
227
|
+
@db.execute_batch(<<~SQL)
|
|
228
|
+
CREATE TABLE IF NOT EXISTS state_store (
|
|
229
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
230
|
+
value TEXT NOT NULL,
|
|
231
|
+
expires_at REAL
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
CREATE TABLE IF NOT EXISTS locks (
|
|
235
|
+
key TEXT PRIMARY KEY NOT NULL,
|
|
236
|
+
token TEXT NOT NULL,
|
|
237
|
+
expires_at REAL NOT NULL
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
CREATE TABLE IF NOT EXISTS queues (
|
|
241
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
242
|
+
queue_name TEXT NOT NULL,
|
|
243
|
+
value TEXT NOT NULL,
|
|
244
|
+
enqueued_at TEXT NOT NULL
|
|
245
|
+
);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS idx_queues_queue_name
|
|
247
|
+
ON queues (queue_name, id);
|
|
248
|
+
|
|
249
|
+
CREATE TABLE IF NOT EXISTS lists (
|
|
250
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
251
|
+
list_key TEXT NOT NULL,
|
|
252
|
+
value TEXT NOT NULL
|
|
253
|
+
);
|
|
254
|
+
CREATE INDEX IF NOT EXISTS idx_lists_list_key
|
|
255
|
+
ON lists (list_key, id);
|
|
256
|
+
SQL
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ask"
|
|
4
|
+
require "ask/state"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module State
|
|
8
|
+
module Providers
|
|
9
|
+
autoload :SQLite, "ask/state/providers/sqlite"
|
|
10
|
+
autoload :Redis, "ask/state/providers/redis"
|
|
11
|
+
autoload :Postgres, "ask/state/providers/postgres"
|
|
12
|
+
autoload :MySQL, "ask/state/providers/mysql"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-state-providers
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ask-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.25'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.25'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mocha
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.1'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
description: Provides Ask::State adapter implementations for SQLite, Redis, PostgreSQL,
|
|
69
|
+
and MySQL. Each backend implements the Ask::State::Adapter contract (get/set/delete,
|
|
70
|
+
distributed locking, message queues, and ordered lists) so agents, sessions, and
|
|
71
|
+
workflows pick the right persistence layer with one line of code.
|
|
72
|
+
email:
|
|
73
|
+
- kaka@myrrlabs.com
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- CHANGELOG.md
|
|
79
|
+
- LICENSE
|
|
80
|
+
- README.md
|
|
81
|
+
- lib/ask-state-providers.rb
|
|
82
|
+
- lib/ask/state/providers/mysql.rb
|
|
83
|
+
- lib/ask/state/providers/postgres.rb
|
|
84
|
+
- lib/ask/state/providers/redis.rb
|
|
85
|
+
- lib/ask/state/providers/sqlite.rb
|
|
86
|
+
- lib/ask/state/providers/version.rb
|
|
87
|
+
homepage: https://github.com/ask-rb/ask-state-providers
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
homepage_uri: https://github.com/ask-rb/ask-state-providers
|
|
92
|
+
source_code_uri: https://github.com/ask-rb/ask-state-providers
|
|
93
|
+
changelog_uri: https://github.com/ask-rb/ask-state-providers/blob/master/CHANGELOG.md
|
|
94
|
+
rdoc_options: []
|
|
95
|
+
require_paths:
|
|
96
|
+
- lib
|
|
97
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - ">="
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '3.2'
|
|
102
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '0'
|
|
107
|
+
requirements: []
|
|
108
|
+
rubygems_version: 4.0.3
|
|
109
|
+
specification_version: 4
|
|
110
|
+
summary: Pluggable state backends for the ask-rb ecosystem
|
|
111
|
+
test_files: []
|