better_auth-redis-storage 0.6.2 → 0.7.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +52 -5
- data/lib/better_auth/redis_storage/version.rb +1 -1
- data/lib/better_auth/redis_storage.rb +93 -26
- 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: bf3169d1223b56ee0f482b4eb81c7d748f1014bbd52c43a98e83bea301155820
|
|
4
|
+
data.tar.gz: 6e00528c060b9cd991dfbb8e204bd736c81b931140d168b1a40b1ed9e4a33ba8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe7311656877d8aa018c6644d795bf1dbc0111640458f7de9fe8ab17991d72c26bb81624271e0267f4be8a540482e729e98f77413714c9f5507aca51eef3b403
|
|
7
|
+
data.tar.gz: ccbacf369690383a7798049b09fcc4c6e1b04e3fdb9fed42f97908ad677cd7de6243cdc3875dd7b2573bfb5440ce302489431e57c030cbf3bbd8b191632a366d
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0 - 2026-05-05
|
|
6
|
+
|
|
7
|
+
- Validate `scan_count` as either `nil` or a positive `Integer`.
|
|
8
|
+
- Reject `nil` logical keys before prefixing Redis keys.
|
|
9
|
+
- Coerce positive finite non-Integer `Numeric` TTL values for `SETEX`.
|
|
10
|
+
- Fall back to plain `SET` for positive sub-second numeric TTLs that would truncate to `0`.
|
|
11
|
+
- Delete `clear` matches in chunks to avoid oversized Redis `DEL` commands.
|
|
12
|
+
- Stream `clear` deletion by `SCAN` page when `scan_count:` is configured.
|
|
13
|
+
- Add `atomic_clear:` opt-in generation-scoped keys so `clear` is logically atomic under concurrent writers.
|
|
14
|
+
- Run the real Redis integration suite explicitly in CI and release verification.
|
|
15
|
+
- Document Redis operational caveats for empty prefixes, key ordering, TTLs, and clusters.
|
|
16
|
+
|
|
5
17
|
## 0.2.0 - 2026-04-29
|
|
6
18
|
|
|
7
19
|
- Add `BetterAuth.redis_storage` and `BetterAuth::RedisStorage.redisStorage` builders for upstream-shaped Redis storage configuration.
|
data/README.md
CHANGED
|
@@ -47,19 +47,27 @@ storage = BetterAuth::RedisStorage.redisStorage(client: redis)
|
|
|
47
47
|
storage = BetterAuth::RedisStorage.new(
|
|
48
48
|
client: redis,
|
|
49
49
|
key_prefix: "better-auth:",
|
|
50
|
-
scan_count: nil
|
|
50
|
+
scan_count: nil,
|
|
51
|
+
atomic_clear: false
|
|
51
52
|
)
|
|
52
53
|
```
|
|
53
54
|
|
|
54
55
|
`client` must respond to `get`, `set`, `setex`, `del`, and `keys`. It should
|
|
55
|
-
also respond to `scan` when `scan_count:` is configured
|
|
56
|
-
interfaces exposed by the `redis`
|
|
56
|
+
also respond to `scan` when `scan_count:` is configured, and to `incr` when
|
|
57
|
+
`atomic_clear:` is enabled. This matches the interfaces exposed by the `redis`
|
|
58
|
+
and `redis-namespace` gems.
|
|
57
59
|
|
|
58
60
|
`key_prefix` defaults to `"better-auth:"`. Passing `nil` falls back to the
|
|
59
61
|
default. Any other value, including the empty string, is honored verbatim.
|
|
60
62
|
Redis databases are not isolation boundaries for shared clients; applications
|
|
61
63
|
sharing a Redis instance should use distinct prefixes.
|
|
62
64
|
|
|
65
|
+
> **Warning:** Passing `key_prefix: ""` puts Better Auth keys at the root of
|
|
66
|
+
> the selected Redis logical namespace. `list_keys` and `clear` then match `*`,
|
|
67
|
+
> so collisions across apps or tenants are possible and `clear` deletes every
|
|
68
|
+
> key in that Redis database. Use an application-specific prefix unless the
|
|
69
|
+
> Redis database is fully dedicated to Better Auth.
|
|
70
|
+
|
|
63
71
|
`scan_count` is a Ruby-only opt-in for large Redis databases. By default the gem
|
|
64
72
|
uses `KEYS "#{key_prefix}*"` to match upstream exactly. Set `scan_count:` to a
|
|
65
73
|
positive count such as `100`, `500`, or `1000` to use `SCAN` instead:
|
|
@@ -68,6 +76,23 @@ positive count such as `100`, `500`, or `1000` to use `SCAN` instead:
|
|
|
68
76
|
storage = BetterAuth::RedisStorage.new(client: redis, scan_count: 500)
|
|
69
77
|
```
|
|
70
78
|
|
|
79
|
+
`atomic_clear` is a Ruby-only opt-in for applications that need `clear` to be
|
|
80
|
+
logically atomic under concurrent writers:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
storage = BetterAuth::RedisStorage.new(
|
|
84
|
+
client: redis,
|
|
85
|
+
scan_count: 500,
|
|
86
|
+
atomic_clear: true
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
When enabled, data keys are stored under a generation prefix such as
|
|
91
|
+
`better-auth:v1:<key>`. Calling `clear` atomically increments the generation key
|
|
92
|
+
so new reads and writes immediately move to the next generation. The previous
|
|
93
|
+
generation is then deleted best-effort, but correctness does not depend on that
|
|
94
|
+
physical cleanup finishing immediately.
|
|
95
|
+
|
|
71
96
|
## Behavior
|
|
72
97
|
|
|
73
98
|
The storage object implements the Better Auth secondary storage contract:
|
|
@@ -82,13 +107,18 @@ storage.clear
|
|
|
82
107
|
|
|
83
108
|
`listKeys` is available as a camelCase alias for upstream parity.
|
|
84
109
|
|
|
110
|
+
`list_keys` returns every matching logical key but Redis does not guarantee key
|
|
111
|
+
order for `KEYS` or `SCAN`. Sort the returned array in application code when a
|
|
112
|
+
stable order matters.
|
|
113
|
+
|
|
85
114
|
TTL handling for `set(key, value, ttl)`:
|
|
86
115
|
|
|
87
116
|
| TTL value | Redis command |
|
|
88
117
|
| --- | --- |
|
|
89
|
-
| `nil`, non-numeric strings, `0`, negative numbers | `set(prefixed_key, value)` |
|
|
118
|
+
| `nil`, non-numeric strings, `0`, negative numbers, non-finite numbers | `set(prefixed_key, value)` |
|
|
90
119
|
| Positive `Integer` | `setex(prefixed_key, ttl, value)` |
|
|
91
|
-
| Positive `Float` | `setex(prefixed_key, ttl.to_i, value)` |
|
|
120
|
+
| Positive finite `Float` or other `Numeric` values `>= 1` | `setex(prefixed_key, ttl.to_i, value)` |
|
|
121
|
+
| Positive finite `Float` or other `Numeric` values `< 1` | `set(prefixed_key, value)` |
|
|
92
122
|
| Positive numeric `String` | `setex(prefixed_key, ttl.to_i, value)` |
|
|
93
123
|
|
|
94
124
|
`set`, `delete`, and `clear` return `nil`, mirroring upstream's `Promise<void>`
|
|
@@ -98,6 +128,23 @@ contract in Ruby form. Tests and applications should assert stored values via
|
|
|
98
128
|
`clear` intentionally differs from upstream when there are no matching keys:
|
|
99
129
|
upstream calls `del(...keys)` even when `keys` is empty, while this Ruby gem
|
|
100
130
|
skips `del` to avoid Redis `ERR wrong number of arguments for 'del'`.
|
|
131
|
+
When keys do exist, `clear` deletes them in batches of
|
|
132
|
+
`BetterAuth::RedisStorage::DELETE_CHUNK_SIZE` keys per `del` call to avoid very
|
|
133
|
+
large Redis argument lists.
|
|
134
|
+
|
|
135
|
+
With `atomic_clear: true`, `clear` increments a generation key with Redis
|
|
136
|
+
`INCR`, making old generation keys immediately invisible to `get`, `set`,
|
|
137
|
+
`delete`, `list_keys`, and Better Auth itself. Cleanup of the old generation is
|
|
138
|
+
best-effort and uses `SCAN` when `scan_count:` is configured.
|
|
139
|
+
|
|
140
|
+
Redis Cluster users should treat `list_keys` and `clear` as operationally
|
|
141
|
+
constrained helpers. This adapter does not scan every cluster node, and
|
|
142
|
+
multi-key `del` calls require keys to live in a compatible hash slot. Prefer a
|
|
143
|
+
single-slot prefix strategy such as Redis hash tags when using these helpers in
|
|
144
|
+
clustered deployments. `atomic_clear: true` improves the logical `clear`
|
|
145
|
+
contract because correctness uses a single `INCR` generation key, but physical
|
|
146
|
+
cleanup of old generations remains subject to the connected client's scan
|
|
147
|
+
coverage.
|
|
101
148
|
|
|
102
149
|
## Better Auth Usage
|
|
103
150
|
|
|
@@ -4,28 +4,33 @@ require "better_auth"
|
|
|
4
4
|
require_relative "redis_storage/version"
|
|
5
5
|
|
|
6
6
|
module BetterAuth
|
|
7
|
-
def self.redis_storage(client:, key_prefix: RedisStorage::DEFAULT_KEY_PREFIX, scan_count: nil)
|
|
8
|
-
RedisStorage.new(client: client, key_prefix: key_prefix, scan_count: scan_count)
|
|
7
|
+
def self.redis_storage(client:, key_prefix: RedisStorage::DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
|
|
8
|
+
RedisStorage.new(client: client, key_prefix: key_prefix, scan_count: scan_count, atomic_clear: atomic_clear)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
class RedisStorage
|
|
12
12
|
DEFAULT_KEY_PREFIX = "better-auth:"
|
|
13
13
|
SCAN_DEFAULT_COUNT = 100
|
|
14
|
+
DELETE_CHUNK_SIZE = 500
|
|
14
15
|
|
|
15
|
-
attr_reader :client, :key_prefix, :scan_count
|
|
16
|
+
attr_reader :client, :key_prefix, :scan_count, :atomic_clear
|
|
16
17
|
|
|
17
|
-
def self.build(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil)
|
|
18
|
-
new(client: client, key_prefix: key_prefix, scan_count: scan_count)
|
|
18
|
+
def self.build(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
|
|
19
|
+
new(client: client, key_prefix: key_prefix, scan_count: scan_count, atomic_clear: atomic_clear)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def self.redisStorage(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil)
|
|
22
|
-
new(client: client, key_prefix: key_prefix, scan_count: scan_count)
|
|
22
|
+
def self.redisStorage(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
|
|
23
|
+
new(client: client, key_prefix: key_prefix, scan_count: scan_count, atomic_clear: atomic_clear)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def initialize(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil)
|
|
26
|
+
def initialize(client:, key_prefix: DEFAULT_KEY_PREFIX, scan_count: nil, atomic_clear: false)
|
|
26
27
|
@client = client
|
|
27
28
|
@key_prefix = key_prefix.nil? ? DEFAULT_KEY_PREFIX : key_prefix.to_s
|
|
29
|
+
if !scan_count.nil? && !(scan_count.is_a?(Integer) && scan_count.positive?)
|
|
30
|
+
raise ArgumentError, "scan_count must be nil or a positive Integer; got #{scan_count.inspect}"
|
|
31
|
+
end
|
|
28
32
|
@scan_count = scan_count
|
|
33
|
+
@atomic_clear = !!atomic_clear
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
def get(key)
|
|
@@ -49,14 +54,16 @@ module BetterAuth
|
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
def list_keys
|
|
52
|
-
|
|
57
|
+
prefix = storage_prefix
|
|
58
|
+
storage_keys(prefix).map { |key| unprefix_key(key, prefix) }
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def clear
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
if atomic_clear
|
|
63
|
+
clear_current_generation
|
|
64
|
+
else
|
|
65
|
+
delete_matching_keys(storage_prefix)
|
|
66
|
+
end
|
|
60
67
|
nil
|
|
61
68
|
end
|
|
62
69
|
|
|
@@ -65,28 +72,81 @@ module BetterAuth
|
|
|
65
72
|
private
|
|
66
73
|
|
|
67
74
|
def prefix_key(key)
|
|
68
|
-
"
|
|
75
|
+
raise ArgumentError, "secondary storage key must not be nil" if key.nil?
|
|
76
|
+
|
|
77
|
+
"#{storage_prefix}#{key}"
|
|
69
78
|
end
|
|
70
79
|
|
|
71
|
-
def unprefix_key(key)
|
|
72
|
-
key.sub(/\A#{Regexp.escape(
|
|
80
|
+
def unprefix_key(key, prefix = storage_prefix)
|
|
81
|
+
key.sub(/\A#{Regexp.escape(prefix)}/, "")
|
|
73
82
|
end
|
|
74
83
|
|
|
75
|
-
def
|
|
76
|
-
return
|
|
84
|
+
def storage_prefix(generation = current_generation)
|
|
85
|
+
return key_prefix unless atomic_clear
|
|
77
86
|
|
|
78
|
-
|
|
87
|
+
"#{key_prefix}v#{generation}:"
|
|
79
88
|
end
|
|
80
89
|
|
|
81
|
-
def
|
|
82
|
-
|
|
90
|
+
def generation_key
|
|
91
|
+
"#{key_prefix}__generation__"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def current_generation
|
|
95
|
+
return nil unless atomic_clear
|
|
96
|
+
|
|
97
|
+
generation = client.get(generation_key).to_i
|
|
98
|
+
generation.positive? ? generation : 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def clear_current_generation
|
|
102
|
+
generation = current_generation
|
|
103
|
+
bump_generation(generation)
|
|
104
|
+
delete_matching_keys(storage_prefix(generation), single_key: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def bump_generation(previous_generation)
|
|
108
|
+
generation = client.incr(generation_key).to_i
|
|
109
|
+
generation = client.incr(generation_key).to_i if generation <= previous_generation.to_i
|
|
110
|
+
generation
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def storage_keys(prefix = storage_prefix)
|
|
114
|
+
return scan_keys(prefix) if scan_count
|
|
115
|
+
|
|
116
|
+
client.keys("#{prefix}*")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def scan_keys(prefix = storage_prefix)
|
|
83
120
|
matches = []
|
|
121
|
+
each_scan_batch(prefix) { |keys| matches.concat(keys) }
|
|
122
|
+
matches
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def each_scan_batch(prefix = storage_prefix)
|
|
126
|
+
cursor = "0"
|
|
84
127
|
loop do
|
|
85
|
-
cursor, keys = client.scan(cursor, match: "#{
|
|
86
|
-
|
|
128
|
+
cursor, keys = client.scan(cursor, match: "#{prefix}*", count: scan_count)
|
|
129
|
+
yield keys
|
|
87
130
|
break if cursor.to_s == "0"
|
|
88
131
|
end
|
|
89
|
-
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def delete_matching_keys(prefix, single_key: false)
|
|
135
|
+
if scan_count
|
|
136
|
+
each_scan_batch(prefix) { |keys| delete_keys(keys, single_key: single_key) }
|
|
137
|
+
else
|
|
138
|
+
delete_keys(storage_keys(prefix), single_key: single_key)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def delete_keys(keys, single_key: false)
|
|
143
|
+
# Upstream calls del(...keys) unconditionally; Ruby keeps this guard to
|
|
144
|
+
# avoid Redis ERR wrong number of arguments when no prefixed keys exist.
|
|
145
|
+
if single_key
|
|
146
|
+
keys.each { |key| client.del(key) }
|
|
147
|
+
else
|
|
148
|
+
keys.each_slice(DELETE_CHUNK_SIZE) { |chunk| client.del(*chunk) }
|
|
149
|
+
end
|
|
90
150
|
end
|
|
91
151
|
|
|
92
152
|
def coerce_ttl(ttl)
|
|
@@ -96,12 +156,19 @@ module BetterAuth
|
|
|
96
156
|
when Integer
|
|
97
157
|
ttl
|
|
98
158
|
when Float
|
|
99
|
-
ttl.
|
|
159
|
+
ttl.finite? ? ttl : nil
|
|
100
160
|
when String
|
|
101
161
|
Integer(ttl, exception: false)
|
|
162
|
+
when Numeric
|
|
163
|
+
ttl.to_f
|
|
102
164
|
end
|
|
103
165
|
|
|
104
|
-
|
|
166
|
+
return nil unless numeric.is_a?(Numeric)
|
|
167
|
+
return nil unless numeric.respond_to?(:positive?) && numeric.positive?
|
|
168
|
+
return nil if numeric.respond_to?(:finite?) && !numeric.finite?
|
|
169
|
+
|
|
170
|
+
seconds = numeric.is_a?(Integer) ? numeric : numeric.to_i
|
|
171
|
+
seconds.positive? ? seconds : nil
|
|
105
172
|
end
|
|
106
173
|
end
|
|
107
174
|
end
|