typed_cache 0.1.1 → 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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/README.md +116 -19
- data/examples.md +106 -50
- data/lib/typed_cache/backends/memory.rb +9 -6
- data/lib/typed_cache/backends.rb +6 -8
- data/lib/typed_cache/cache_builder.rb +72 -19
- data/lib/typed_cache/cache_key.rb +2 -0
- data/lib/typed_cache/cache_ref.rb +4 -0
- data/lib/typed_cache/clock.rb +31 -14
- data/lib/typed_cache/decorator.rb +17 -0
- data/lib/typed_cache/{store → decorators}/instrumented.rb +19 -23
- data/lib/typed_cache/decorators.rb +7 -3
- data/lib/typed_cache/errors.rb +9 -1
- data/lib/typed_cache/instrumenter.rb +43 -0
- data/lib/typed_cache/instrumenters/active_support.rb +28 -0
- data/lib/typed_cache/instrumenters/mixins/namespaced_singleton.rb +52 -0
- data/lib/typed_cache/instrumenters/mixins.rb +8 -0
- data/lib/typed_cache/instrumenters/monitor.rb +27 -0
- data/lib/typed_cache/instrumenters/null.rb +26 -0
- data/lib/typed_cache/instrumenters.rb +38 -0
- data/lib/typed_cache/registry.rb +15 -0
- data/lib/typed_cache/store.rb +3 -0
- data/lib/typed_cache/version.rb +1 -1
- data/lib/typed_cache.rb +30 -14
- data/sig/generated/typed_cache/backends.rbs +2 -0
- data/sig/generated/typed_cache/cache_builder.rbs +13 -2
- data/sig/generated/typed_cache/clock.rbs +19 -9
- data/sig/generated/typed_cache/decorator.rbs +8 -0
- data/sig/generated/typed_cache/decorators/instrumented.rbs +35 -0
- data/sig/generated/typed_cache/decorators.rbs +2 -0
- data/sig/generated/typed_cache/errors.rbs +2 -0
- data/sig/generated/typed_cache/instrumenter.rbs +31 -0
- data/sig/generated/typed_cache/instrumenters/active_support.rbs +20 -0
- data/sig/generated/typed_cache/instrumenters/mixins/namespaced_singleton.rbs +33 -0
- data/sig/generated/typed_cache/instrumenters/mixins.rbs +8 -0
- data/sig/generated/typed_cache/instrumenters/monitor.rbs +19 -0
- data/sig/generated/typed_cache/instrumenters/null.rbs +21 -0
- data/sig/generated/typed_cache/instrumenters.rbs +24 -0
- data/sig/generated/typed_cache/registry.rbs +8 -0
- data/sig/generated/typed_cache/store/instrumented.rbs +2 -6
- data/sig/generated/typed_cache/store.rbs +3 -0
- data/sig/generated/typed_cache.rbs +6 -6
- data/typed_cache.gemspec +4 -3
- data.tar.gz.sig +0 -0
- metadata +25 -27
- metadata.gz.sig +0 -0
- data/lib/typed_cache/instrumentation.rb +0 -112
- data/sig/generated/typed_cache/instrumentation.rbs +0 -30
- data/sig/handwritten/gems/zeitwerk/2.7/zeitwerk.rbs +0 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2cd862f92c635a4ee2fa15fa26186e56226d9dca6a132ad7de2f96fc9935ee31
|
4
|
+
data.tar.gz: a862c34b4e86e838e4a30bfda8149357581782d4e2c64dd6a37882724930955d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2df948e5a9e9808c13547e940547484ad9cff0ce75085f3ee512a8ef866459dd791c7d1c85147b65dbb6ccbacea973cc412b44d3be12f7cb564c94afee452f2a
|
7
|
+
data.tar.gz: 57e1c99cb2c11a82e1b9add65357a8abacdc0260ea1ca66c6cd3616ac13db73439b24c25643eed5425a1a23fb62984bf2feb4ce854c1a54f1bcbd790248e0f2d
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data/README.md
CHANGED
@@ -1,5 +1,11 @@
|
|
1
1
|
# TypedCache
|
2
2
|
|
3
|
+

|
4
|
+

|
5
|
+

|
6
|
+
|
7
|
+

|
8
|
+
|
3
9
|
TypedCache is a lightweight, type-safe façade around your favourite Ruby cache
|
4
10
|
stores. It adds three things on top of the raw back-end implementation:
|
5
11
|
|
@@ -35,26 +41,33 @@ If there are issues with unsigned gems, use `MediumSecurity` instead.
|
|
35
41
|
```ruby
|
36
42
|
require "typed_cache"
|
37
43
|
|
38
|
-
# Build
|
44
|
+
# 1. Build a store
|
39
45
|
store = TypedCache.builder
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
46
|
+
.with_backend(:memory, shared: true)
|
47
|
+
.with_instrumentation(:rails) # e.g. using ActiveSupport
|
48
|
+
.build
|
49
|
+
.value # unwrap Either for brevity
|
50
|
+
|
51
|
+
# 2. Get a reference to a key
|
52
|
+
user_ref = store.ref("users:123") # => CacheRef
|
53
|
+
|
54
|
+
# 3. Fetch and compute if absent
|
55
|
+
user_snapshot = user_ref.fetch do
|
56
|
+
puts "Cache miss! Computing..."
|
57
|
+
{ id: 123, name: "Jane" }
|
58
|
+
end.value # => Snapshot
|
59
|
+
|
60
|
+
puts "Found: #{user_snapshot.value} (from_cache?=#{user_snapshot.from_cache?})"
|
48
61
|
```
|
49
62
|
|
50
63
|
## Builder API
|
51
64
|
|
52
|
-
| Step
|
53
|
-
|
|
54
|
-
| `with_backend(:name, **opts)`
|
55
|
-
| `with_decorator(:key)`
|
56
|
-
| `with_instrumentation`
|
57
|
-
| `build`
|
65
|
+
| Step | Purpose |
|
66
|
+
| ------------------------------- | -------------------------------------------------------------- |
|
67
|
+
| `with_backend(:name, **opts)` | Mandatory. Configure the concrete **Backend** and its options. |
|
68
|
+
| `with_decorator(:key)` | Optional. Add a decorator by registry key. |
|
69
|
+
| `with_instrumentation(:source)` | Optional. Add instrumentation, e.g. `:rails` or `:dry`. |
|
70
|
+
| `build` | Returns `Either[Error, Store]`. |
|
58
71
|
|
59
72
|
### Back-ends vs Decorators
|
60
73
|
|
@@ -112,16 +125,100 @@ result.fold(
|
|
112
125
|
)
|
113
126
|
```
|
114
127
|
|
128
|
+
## The `CacheRef` API
|
129
|
+
|
130
|
+
While you can call `get`, `set`, and `fetch` directly on the `store`, the more powerful way to work with TypedCache is via the `CacheRef` object. It provides a rich, monadic API for a single cache key.
|
131
|
+
|
132
|
+
You get a `CacheRef` by calling `store.ref(key)`:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
user_ref = store.ref("users:123") # => #<TypedCache::CacheRef ...>
|
136
|
+
```
|
137
|
+
|
138
|
+
Now you can operate on it:
|
139
|
+
|
140
|
+
```ruby
|
141
|
+
# Fetch a value, computing it if it's missing
|
142
|
+
snapshot_either = user_ref.fetch do
|
143
|
+
{ id: 123, name: "Jane Doe" }
|
144
|
+
end
|
145
|
+
|
146
|
+
# The result is always an Either[Error, Snapshot]
|
147
|
+
snapshot_either.fold(
|
148
|
+
->(err) { warn "Something went wrong: #{err.message}" },
|
149
|
+
->(snapshot) { puts "Got value: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
|
150
|
+
)
|
151
|
+
|
152
|
+
# You can also map over values
|
153
|
+
name_either = user_ref.map { |user| user[:name] }
|
154
|
+
puts "User name is: #{name_either.value.value}" # unwrap Either, then Snapshot
|
155
|
+
```
|
156
|
+
|
157
|
+
The `CacheRef` API encourages a functional style and makes composing cache operations safe and predictable.
|
158
|
+
|
115
159
|
## Instrumentation
|
116
160
|
|
117
|
-
|
118
|
-
`TypedCache.config.instrumentation.enabled = true`:
|
161
|
+
TypedCache can publish events about cache operations using different instrumenters. To enable it, use the `with_instrumentation` method on the builder, specifying an instrumentation backend:
|
119
162
|
|
163
|
+
```ruby
|
164
|
+
# For ActiveSupport::Notifications (e.g. in Rails)
|
165
|
+
store = TypedCache.builder
|
166
|
+
.with_backend(:memory)
|
167
|
+
.with_instrumentation(:rails)
|
168
|
+
.build.value
|
169
|
+
|
170
|
+
# For Dry::Monitor
|
171
|
+
store = TypedCache.builder
|
172
|
+
.with_backend(:memory)
|
173
|
+
.with_instrumentation(:dry)
|
174
|
+
.build.value
|
120
175
|
```
|
121
|
-
|
176
|
+
|
177
|
+
Events are published to a topic like `typed_cache.<operation>` (e.g., `typed_cache.get`). The topic namespace can be configured.
|
178
|
+
|
179
|
+
Payload keys include: `:namespace, :key, :operation, :duration`, and `cache_hit`.
|
180
|
+
|
181
|
+
You can subscribe to these events like so:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
# Example for ActiveSupport
|
185
|
+
ActiveSupport::Notifications.subscribe("typed_cache.get") do |name, start, finish, id, payload|
|
186
|
+
|
187
|
+
# Or you can subscribe via the store object itself
|
188
|
+
instrumenter = store.instrumenter
|
189
|
+
instrumenter.subscribe("get") do |event|
|
190
|
+
payload = event.payload
|
191
|
+
puts "Cache GET for key #{payload[:key]} took #{payload[:duration]}ms. Hit? #{payload[:cache_hit]}"
|
192
|
+
end
|
122
193
|
```
|
123
194
|
|
124
|
-
|
195
|
+
If you call `with_instrumentation` with no arguments, it uses a `Null` instrumenter, which has no overhead.
|
196
|
+
|
197
|
+
### Custom Instrumenters
|
198
|
+
|
199
|
+
Just like with back-ends and decorators, you can write and register your own instrumenters. An instrumenter must implement an `instrument` and a `subscribe` method.
|
200
|
+
|
201
|
+
```ruby
|
202
|
+
class MyCustomInstrumenter
|
203
|
+
include TypedCache::Instrumenter
|
204
|
+
|
205
|
+
def instrument(operation, key, **payload, &block)
|
206
|
+
# ... your logic ...
|
207
|
+
end
|
208
|
+
|
209
|
+
def subscribe(event_name, **filters, &block)
|
210
|
+
# ... your logic ...
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# Register it
|
215
|
+
TypedCache::Instrumenters.register(:custom, MyCustomInstrumenter)
|
216
|
+
|
217
|
+
# Use it
|
218
|
+
store = TypedCache.builder
|
219
|
+
.with_instrumentation(:custom)
|
220
|
+
# ...
|
221
|
+
```
|
125
222
|
|
126
223
|
## Further examples
|
127
224
|
|
data/examples.md
CHANGED
@@ -7,16 +7,19 @@ This document provides practical examples of using TypedCache in real applicatio
|
|
7
7
|
The simplest way to create a type-safe cache:
|
8
8
|
|
9
9
|
```ruby
|
10
|
-
|
11
|
-
|
12
|
-
cache_result = TypedCache.builder
|
10
|
+
# The builder can be pre-configured and reused
|
11
|
+
builder = TypedCache.builder
|
13
12
|
.with_backend(:memory, shared: true)
|
14
|
-
.with_instrumentation
|
15
|
-
|
13
|
+
.with_instrumentation(:rails)
|
14
|
+
|
15
|
+
# The store is built with a namespace
|
16
|
+
users_store = builder.build(TypedCache::Namespace.at("users")).value
|
17
|
+
|
18
|
+
# Get a reference to a key
|
19
|
+
user_ref = users_store.ref("123")
|
16
20
|
|
17
|
-
|
18
|
-
|
19
|
-
store.set(key, { id: 123, name: "Jane" })
|
21
|
+
# Set a value
|
22
|
+
user_ref.set({ id: 123, name: "Jane" })
|
20
23
|
```
|
21
24
|
|
22
25
|
## Rails Integration
|
@@ -24,13 +27,17 @@ store.set(key, { id: 123, name: "Jane" })
|
|
24
27
|
Using TypedCache with `Rails.cache` and ActiveSupport notifications:
|
25
28
|
|
26
29
|
```ruby
|
27
|
-
|
30
|
+
# Use Rails.cache as the backend
|
31
|
+
builder = TypedCache.builder
|
28
32
|
.with_backend(:active_support, Rails.cache)
|
29
|
-
.with_instrumentation
|
30
|
-
|
33
|
+
.with_instrumentation(:rails)
|
34
|
+
|
35
|
+
# Build a store for a specific part of your app
|
36
|
+
header_store = builder.build(TypedCache::Namespace.at("views:header")).value
|
31
37
|
|
32
|
-
|
33
|
-
|
38
|
+
# Use a ref to fetch/render
|
39
|
+
header_ref = header_store.ref("main")
|
40
|
+
header_html = header_ref.fetch { render_header }.value.value
|
34
41
|
```
|
35
42
|
|
36
43
|
## Pattern Matching
|
@@ -58,35 +65,72 @@ Reuse a preconfigured builder for several namespaces:
|
|
58
65
|
base_builder = TypedCache.builder
|
59
66
|
.with_backend(:memory, shared: true)
|
60
67
|
|
61
|
-
users_store
|
62
|
-
posts_store
|
68
|
+
users_store = base_builder.build(TypedCache::Namespace.at("users")).value
|
69
|
+
posts_store = base_builder.build(TypedCache::Namespace.at("posts")).value
|
63
70
|
comments_store = base_builder.build(TypedCache::Namespace.at("comments")).value
|
64
71
|
```
|
65
72
|
|
66
|
-
##
|
73
|
+
## CacheRef API
|
67
74
|
|
68
|
-
|
75
|
+
The `CacheRef` is the most powerful way to interact with a cache key.
|
69
76
|
|
70
77
|
```ruby
|
71
|
-
#
|
72
|
-
|
78
|
+
ref = store.ref("some-key") # => CacheRef
|
79
|
+
```
|
80
|
+
|
81
|
+
### Get a value
|
82
|
+
|
83
|
+
The `get` method returns an `Either[Error, Snapshot]`.
|
73
84
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
->(
|
85
|
+
```ruby
|
86
|
+
result = ref.get
|
87
|
+
result.fold(
|
88
|
+
->(error) { puts "Cache miss or error: #{error.message}" },
|
89
|
+
->(snapshot) { puts "Found: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
|
78
90
|
)
|
91
|
+
```
|
92
|
+
|
93
|
+
### Fetch (get or compute)
|
94
|
+
|
95
|
+
The `fetch` method is the most common operation. It gets a value from the cache, but if it's missing, it runs the block, stores the result, and returns it.
|
96
|
+
|
97
|
+
```ruby
|
98
|
+
user = ref.fetch { expensive_user_lookup(123) }.value.value
|
99
|
+
```
|
100
|
+
|
101
|
+
### Mapping values
|
102
|
+
|
103
|
+
You can transform the value inside the cache reference without breaking the monadic chain.
|
104
|
+
|
105
|
+
```ruby
|
106
|
+
# user_ref holds { id: 1, name: "John" }
|
107
|
+
name_ref = user_ref.map { |user| user[:name] }
|
108
|
+
|
109
|
+
name_snapshot = name_ref.get.value # => Snapshot(value: "John", ...)
|
110
|
+
```
|
111
|
+
|
112
|
+
### Chaining operations with `bind`
|
79
113
|
|
80
|
-
|
81
|
-
user = cache.peek.value_or({ id: 0, name: "Anonymous" })
|
114
|
+
For more complex logic, you can use `bind` (or `flat_map`) to chain operations that return an `Either`.
|
82
115
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
116
|
+
```ruby
|
117
|
+
user_ref.bind do |user|
|
118
|
+
if user.active?
|
119
|
+
posts_ref.set(user.posts)
|
120
|
+
else
|
121
|
+
TypedCache::Either.left(StandardError.new("User is not active"))
|
122
|
+
end
|
87
123
|
end
|
88
124
|
```
|
89
125
|
|
126
|
+
### Getting the value or a default
|
127
|
+
|
128
|
+
If you just want the value and don't care about the `Snapshot` metadata, you can use `value_or`.
|
129
|
+
|
130
|
+
```ruby
|
131
|
+
user_name = user_ref.map { |u| u[:name] }.value_or("Anonymous")
|
132
|
+
```
|
133
|
+
|
90
134
|
## Custom Backend
|
91
135
|
|
92
136
|
Registering and using a custom cache backend:
|
@@ -126,35 +170,47 @@ cache = TypedCache.builder
|
|
126
170
|
.build.value
|
127
171
|
```
|
128
172
|
|
129
|
-
##
|
173
|
+
## Custom Instrumenter
|
174
|
+
|
175
|
+
You can create and register your own instrumenter to integrate with any monitoring or logging system. An instrumenter needs to implement `instrument` and `subscribe` methods.
|
176
|
+
|
177
|
+
Here's an example of a simple logging instrumenter:
|
130
178
|
|
131
179
|
```ruby
|
132
|
-
|
133
|
-
|
134
|
-
|
180
|
+
class LoggingInstrumenter
|
181
|
+
include TypedCache::Instrumenter
|
182
|
+
include TypedCache::Instrumenters::Mixins::NamespacedSingleton
|
183
|
+
|
184
|
+
def instrument(operation, key, **payload, &block)
|
185
|
+
puts "[CACHE] Starting: #{operation} for key #{key}"
|
186
|
+
result = block.call
|
187
|
+
puts "[CACHE] Finished: #{operation} for key #{key}"
|
188
|
+
result
|
189
|
+
end
|
190
|
+
|
191
|
+
def subscribe(operation, **_filters, &block)
|
192
|
+
# For simplicity, this example doesn't implement a full subscription model,
|
193
|
+
# but in a real-world scenario, you would store and manage callbacks here.
|
194
|
+
puts "[CACHE] Subscribed to '#{operation}'"
|
195
|
+
end
|
135
196
|
end
|
136
197
|
|
137
|
-
|
198
|
+
# Register the new instrumenter
|
199
|
+
TypedCache::Instrumenters.register(:logger, LoggingInstrumenter)
|
200
|
+
|
201
|
+
# Use it in the builder
|
202
|
+
logging_cache = TypedCache.builder
|
138
203
|
.with_backend(:memory)
|
139
|
-
.with_instrumentation
|
204
|
+
.with_instrumentation(:logger)
|
140
205
|
.build.value
|
141
206
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
## Configuration Snippet
|
207
|
+
# Subscribe to an event
|
208
|
+
logging_cache.instrumenter.subscribe("get")
|
146
209
|
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
```
|
152
|
-
|
153
|
-
You can register additional decorators or back-ends as needed:
|
154
|
-
|
155
|
-
```ruby
|
156
|
-
TypedCache::Decorators.register(:logger, MyLoggerDecorator)
|
157
|
-
TypedCache::Backends.register(:redis, MyRedisBackend)
|
210
|
+
# Operations will now be logged
|
211
|
+
logging_cache.ref("test").set("hello")
|
212
|
+
# => [CACHE] Starting: set for key test
|
213
|
+
# => [CACHE] Finished: set for key test
|
158
214
|
```
|
159
215
|
|
160
216
|
## Thread Safety
|
@@ -1,7 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'singleton'
|
4
|
-
|
4
|
+
|
5
|
+
require 'concurrent/map'
|
5
6
|
|
6
7
|
module TypedCache
|
7
8
|
class MemoryStoreRegistry
|
@@ -42,14 +43,16 @@ module TypedCache
|
|
42
43
|
# @rbs! attr_accessor expires_at: Time
|
43
44
|
# @rbs! attr_reader value: V
|
44
45
|
|
45
|
-
|
46
|
-
|
47
|
-
|
46
|
+
class << self
|
47
|
+
# @rbs (value: V, expires_in: Integer) -> Entry[V]
|
48
|
+
def expiring(value:, expires_in:)
|
49
|
+
new(value: value, expires_at: Clock.moment + expires_in)
|
50
|
+
end
|
48
51
|
end
|
49
52
|
|
50
53
|
# @rbs () -> bool
|
51
54
|
def expired?
|
52
|
-
Clock.
|
55
|
+
Clock.now >= expires_at
|
53
56
|
end
|
54
57
|
end
|
55
58
|
private_constant :Entry
|
@@ -84,7 +87,7 @@ module TypedCache
|
|
84
87
|
#: (cache_key, V) -> either[Error, Snapshot[V]]
|
85
88
|
def set(key, value)
|
86
89
|
key = namespaced_key(key)
|
87
|
-
expires_at = Clock.
|
90
|
+
expires_at = Clock.now + @ttl
|
88
91
|
entry = Entry.new(value: value, expires_at: expires_at)
|
89
92
|
backing_store[key] = entry
|
90
93
|
Either.right(Snapshot.new(value, source: :cache))
|
data/lib/typed_cache/backends.rb
CHANGED
@@ -1,25 +1,23 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
4
|
-
require 'dry/types'
|
5
|
-
|
6
|
-
require_relative 'backends/memory'
|
7
|
-
require_relative 'backends/active_support'
|
3
|
+
require 'typed_cache/registry'
|
8
4
|
|
9
5
|
module TypedCache
|
10
6
|
module Backends
|
7
|
+
autoload :Memory, 'typed_cache/backends/memory'
|
8
|
+
autoload :ActiveSupport, 'typed_cache/backends/active_support'
|
9
|
+
|
10
|
+
# @api private
|
11
11
|
# Backend registry using composition
|
12
12
|
REGISTRY = Registry.new('backend', {
|
13
13
|
memory: Memory,
|
14
|
-
active_support: ActiveSupport,
|
15
14
|
}).freeze
|
16
15
|
|
17
|
-
private_constant :REGISTRY
|
18
|
-
|
19
16
|
class << self
|
20
17
|
extend Forwardable
|
21
18
|
delegate [:resolve, :register, :available, :registered?] => :registry
|
22
19
|
|
20
|
+
# @api private
|
23
21
|
#: -> Registry
|
24
22
|
def registry = REGISTRY
|
25
23
|
|
@@ -1,8 +1,32 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'typed_cache/namespace'
|
4
|
+
require 'typed_cache/cache_key'
|
5
|
+
require 'typed_cache/errors'
|
6
|
+
|
7
|
+
require 'typed_cache/store'
|
8
|
+
require 'typed_cache/backends'
|
9
|
+
require 'typed_cache/decorators'
|
10
|
+
require 'typed_cache/instrumenters'
|
11
|
+
|
12
|
+
require 'dry/struct'
|
13
|
+
require 'dry/types'
|
14
|
+
|
3
15
|
module TypedCache
|
4
16
|
class CacheBuilder
|
5
17
|
# @rbs! type config = TypedCache::typed_cache_config
|
18
|
+
# @rbs! type instrumenter_source = :default | :dry | :rails | Instrumenter
|
19
|
+
|
20
|
+
class BackendConfig < Dry::Struct
|
21
|
+
attribute :name, Dry.Types::Symbol
|
22
|
+
attribute :args, Dry.Types::Array.of(Dry.Types::Any)
|
23
|
+
attribute :options, Dry.Types::Hash.map(Dry.Types::Symbol, Dry.Types::Any)
|
24
|
+
end
|
25
|
+
|
26
|
+
class DecoratorConfig < Dry::Struct
|
27
|
+
attribute :name, Dry.Types::Symbol
|
28
|
+
attribute :options, Dry.Types::Hash.map(Dry.Types::Symbol, Dry.Types::Any)
|
29
|
+
end
|
6
30
|
|
7
31
|
# @rbs (config, Registry[backend[untyped]], Registry[decorator[untyped]]) -> void
|
8
32
|
def initialize(config, backend_registry = Backends, decorator_registry = Decorators)
|
@@ -10,15 +34,13 @@ module TypedCache
|
|
10
34
|
@backend_registry = backend_registry
|
11
35
|
@decorator_registry = decorator_registry
|
12
36
|
|
13
|
-
@
|
14
|
-
@
|
15
|
-
@backend_options = {}
|
16
|
-
@decorators = []
|
37
|
+
@backend_config = nil
|
38
|
+
@decorator_configs = []
|
17
39
|
end
|
18
40
|
|
19
41
|
# Builds the cache - the only method that can fail
|
20
42
|
# @rbs (?Namespace) -> either[Error, Store[V]]
|
21
|
-
def build(namespace = Namespace.at(@config.
|
43
|
+
def build(namespace = Namespace.at(@config.instrumentation.namespace))
|
22
44
|
validate_and_build(namespace)
|
23
45
|
end
|
24
46
|
|
@@ -26,22 +48,22 @@ module TypedCache
|
|
26
48
|
# Invalid configurations are caught during build()
|
27
49
|
# @rbs (Symbol, *untyped, **untyped) -> self
|
28
50
|
def with_backend(name, *args, **options)
|
29
|
-
@
|
30
|
-
@backend_args = args
|
31
|
-
@backend_options = options
|
51
|
+
@backend_config = BackendConfig.new(name:, args:, options:)
|
32
52
|
self
|
33
53
|
end
|
34
54
|
|
35
55
|
# Adds an arbitrary decorator by registry key
|
36
56
|
# @rbs (Symbol) -> self
|
37
|
-
def with_decorator(name)
|
38
|
-
@
|
57
|
+
def with_decorator(name, **options)
|
58
|
+
@decorator_configs << DecoratorConfig.new(name:, options:)
|
39
59
|
self
|
40
60
|
end
|
41
61
|
|
42
|
-
#
|
43
|
-
|
44
|
-
|
62
|
+
# Adds instrumentation using the specified strategy.
|
63
|
+
# @rbs (instrumenter_source) -> either[Error, self]
|
64
|
+
def with_instrumentation(source = :default)
|
65
|
+
@instrumenter_source = source
|
66
|
+
self
|
45
67
|
end
|
46
68
|
|
47
69
|
private
|
@@ -49,29 +71,60 @@ module TypedCache
|
|
49
71
|
# @rbs (Namespace) -> either[Error, Store[V]]
|
50
72
|
def validate_and_build(namespace)
|
51
73
|
create_store(namespace).bind do |store|
|
52
|
-
apply_decorators(store)
|
74
|
+
apply_decorators(store).bind do |decorated_store|
|
75
|
+
apply_instrumentation(decorated_store)
|
76
|
+
end
|
53
77
|
end
|
54
78
|
end
|
55
79
|
|
56
80
|
# @rbs (Namespace) -> either[Error, Store[V]]
|
57
81
|
def create_store(namespace)
|
58
|
-
return Either.left(ArgumentError.new('Backend not configured')) unless @
|
82
|
+
return Either.left(ArgumentError.new('Backend not configured')) unless @backend_config
|
59
83
|
|
60
84
|
# Prepend namespace to the arguments for the backend constructor
|
61
|
-
@backend_registry.resolve(@
|
85
|
+
@backend_registry.resolve(@backend_config.name, namespace, *@backend_config.args, **@backend_config.options)
|
62
86
|
end
|
63
87
|
|
64
88
|
# @rbs (Store[V]) -> either[Error, Store[V]]
|
65
89
|
def apply_decorators(store)
|
66
|
-
return Either.right(store) if @
|
90
|
+
return Either.right(store) if @decorator_configs.empty?
|
67
91
|
|
68
|
-
@
|
92
|
+
names = @decorator_configs.map(&:name)
|
93
|
+
|
94
|
+
name_counts = names.tally
|
95
|
+
duplicates = name_counts.keys.select { |name| name_counts[name] > 1 }
|
96
|
+
return Either.left(ArgumentError.new("Duplicate decorator: #{duplicates.join(", ")}")) if duplicates.any?
|
97
|
+
|
98
|
+
@decorator_configs.reduce(Either.right(store)) do |result, decorator_config|
|
69
99
|
result.bind do |current_store|
|
70
|
-
@decorator_registry.resolve(
|
100
|
+
@decorator_registry.resolve(decorator_config.name, current_store, **decorator_config.options)
|
71
101
|
end
|
72
102
|
end
|
73
103
|
rescue => e
|
74
104
|
Either.left(StoreError.new(:decorator_application, 'decorator', "Failed to apply decorator: #{e.message}", e))
|
75
105
|
end
|
106
|
+
|
107
|
+
def apply_instrumentation(store)
|
108
|
+
return Either.right(store) unless @instrumenter_source
|
109
|
+
|
110
|
+
instrumenter =
|
111
|
+
case @instrumenter_source
|
112
|
+
when Symbol
|
113
|
+
Instrumenters.resolve(@instrumenter_source, namespace: @config.default_namespace)
|
114
|
+
when Instrumenter
|
115
|
+
Either.right(@instrumenter_source)
|
116
|
+
else
|
117
|
+
Either.left(TypedCache::TypeError.new(
|
118
|
+
':default | :dry | :rails | Instrumenter',
|
119
|
+
@instrumenter_source.class.name,
|
120
|
+
@instrumenter_source,
|
121
|
+
"Invalid instrumenter source: #{@instrumenter_source.inspect}",
|
122
|
+
))
|
123
|
+
end
|
124
|
+
|
125
|
+
instrumenter.bind do |i|
|
126
|
+
@decorator_registry.resolve(:instrumented, store, instrumenter: i)
|
127
|
+
end
|
128
|
+
end
|
76
129
|
end
|
77
130
|
end
|
@@ -1,5 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative 'cache_key'
|
4
|
+
require_relative 'store'
|
5
|
+
require_relative 'snapshot'
|
6
|
+
|
3
7
|
module TypedCache
|
4
8
|
# A monadic wrapper for cached values that provides safe access with rich error context.
|
5
9
|
# All operations return Either[Error, Snapshot[V]] to provide detailed information about
|