pennycress 0.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/lib/pennycress/cache.rb +80 -0
- data/lib/pennycress/caching.rb +18 -0
- data/lib/pennycress/configuration.rb +98 -0
- data/lib/pennycress/constraints.rb +15 -0
- data/lib/pennycress/errors.rb +6 -0
- data/lib/pennycress/input.rb +44 -0
- data/lib/pennycress/integration.rb +100 -0
- data/lib/pennycress/model_reference.rb +114 -0
- data/lib/pennycress/models.rb +32 -0
- data/lib/pennycress/output.rb +35 -0
- data/lib/pennycress/output_reference.rb +43 -0
- data/lib/pennycress/railtie.rb +37 -0
- data/lib/pennycress/registry.rb +50 -0
- data/lib/pennycress/schema.rb +88 -0
- data/lib/pennycress/value.rb +265 -0
- data/lib/pennycress/value_config.rb +49 -0
- data/lib/pennycress/version.rb +5 -0
- data/lib/pennycress/warm_seed_job.rb +19 -0
- data/lib/pennycress/warming.rb +30 -0
- data/lib/pennycress/watched_model.rb +68 -0
- data/lib/pennycress.rb +30 -0
- data/lib/tasks/pennycress.rake +8 -0
- metadata +77 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 481d0ac59ec75712e97bd7ddf0bd6a3dc712e616056d544ca015bfd84fd439f1
|
|
4
|
+
data.tar.gz: 102c76801dbfb8b2ae6cc11a83acee5405ef8f8277d8d51037f225999dcec2e4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c2e852b9413643f112c51425b593e550fac26130865b526c96982621b185d031586c14e4a5f42bf92442ae25c0dcd0a8628209d15df5afd9a4f8ab2a6278c529
|
|
7
|
+
data.tar.gz: 2a7243f6ed832c3ff8434db21e4723070529007ce26c6445b7be86d3f5f9b22b748e377cc72ac676dc9118765ba17e491c6435dd05676c4dde5b49e14da4f932
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Justin Locsei
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/logo.svg" width="600" alt="Pennycress">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<br>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
Pennycress is a Rails gem that allows you to describe expensive, model-based computations as Ruby classes. You define a list of input models, a way to transform those models into a cached output value, and any model changes that should invalidate it. Values can also provide a plan for warming the cache, enabling distributed, batch-focused precomputation. By capturing the full lifecycle of a cached value in Ruby, Pennycress offers a lightweight alternative to materialized views that lets you fix hot read paths without recreating them in SQL.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
[](https://github.com/justinlocsei/pennycress/actions/workflows/verify.yml)
|
|
14
|
+
[](https://rubygems.org/gems/pennycress)
|
|
15
|
+
[](https://github.com/justinlocsei/pennycress/blob/main/LICENSE)
|
|
16
|
+
|
|
17
|
+
<!-- <toc> -->
|
|
18
|
+
- [Installation](#installation)
|
|
19
|
+
- [Quick Start](#quick-start)
|
|
20
|
+
- [Rails Integration](#rails-integration)
|
|
21
|
+
- [How Values Work](#how-values-work)
|
|
22
|
+
- [Inputs](#inputs)
|
|
23
|
+
- [Outputs](#outputs)
|
|
24
|
+
- [Fetching a Value](#fetching-a-value)
|
|
25
|
+
- [Fetching Multiple Values](#fetching-multiple-values)
|
|
26
|
+
- [Cache Invalidation](#cache-invalidation)
|
|
27
|
+
- [Producing Invalidation Inputs](#producing-invalidation-inputs)
|
|
28
|
+
- [Specifying Invalidation Triggers](#specifying-invalidation-triggers)
|
|
29
|
+
- [Handling Destroyed Records](#handling-destroyed-records)
|
|
30
|
+
- [Cache Warming](#cache-warming)
|
|
31
|
+
- [Warming the Cache](#warming-the-cache)
|
|
32
|
+
- [Batch Computation](#batch-computation)
|
|
33
|
+
- [Configuring Pennycress](#configuring-pennycress)
|
|
34
|
+
- [Why the Name?](#why-the-name)
|
|
35
|
+
<!-- </toc> -->
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Add Pennycress to your application's `Gemfile`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
gem "pennycress"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then install it:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
bundle install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
Create the following file at `app/values/account_order_total.rb`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class AccountOrderTotal < Pennycress::Value
|
|
57
|
+
# Derive the order total from the Account model
|
|
58
|
+
input :account
|
|
59
|
+
|
|
60
|
+
# Compute and cache a numeric order total
|
|
61
|
+
output Integer
|
|
62
|
+
|
|
63
|
+
# Evict the cached value for a changed account
|
|
64
|
+
watch :account do |account|
|
|
65
|
+
[{ account: account }]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Evict the cached value for the account associated with a changed order
|
|
69
|
+
watch :order do |order|
|
|
70
|
+
[{ account: order.account_id }]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Run a cache-warming job for every active organization
|
|
74
|
+
seeds { Organization.active.ids }
|
|
75
|
+
|
|
76
|
+
# Use a model method to calculate the order total
|
|
77
|
+
#
|
|
78
|
+
# In this example, we're assuming that this is an expensive call that triggers
|
|
79
|
+
# lots of reads. Maybe it's legacy code that hasn't been touched in ten years.
|
|
80
|
+
def compute(account:)
|
|
81
|
+
account.calculate_completed_order_total
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Warm the cache for each account in an organization
|
|
85
|
+
#
|
|
86
|
+
# This runs in the context of a background job. Each value emitted by `seeds`
|
|
87
|
+
# enqueues one background job, so allowing multiple warming jobs to run at
|
|
88
|
+
# once can rapidly populate cached values.
|
|
89
|
+
def seed_to_inputs(organization_id)
|
|
90
|
+
Organization
|
|
91
|
+
.find(organization_id)
|
|
92
|
+
.active_accounts
|
|
93
|
+
.map { |account| { account: account } }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Any application code that needs the order total for an account can request it by passing an `Account` instance:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
AccountOrderTotal.fetch(account: account)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Rails Integration
|
|
105
|
+
|
|
106
|
+
Any class that subclasses `Pennycress::Value` and lives under `app/values` is loaded automatically when your Rails app boots or reloads. Values can be placed in nested directories, allowing for paths like `app/values/billing/account_order_total.rb`.
|
|
107
|
+
|
|
108
|
+
All discovered values have [invalidation handlers](#cache-invalidation) registered and are made available for [cache warming](#cache-warming). To use a different location for your value definitions, see [Configuring Pennycress](#configuring-pennycress) for details.
|
|
109
|
+
|
|
110
|
+
## How Values Work
|
|
111
|
+
|
|
112
|
+
A Pennycress `Value` describes the transformation of a set of model inputs into an output with a stable cache key. The smallest useful value specifies its input, output, and a `#compute` method that can delegate to the application's existing logic:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class AccountOrderTotal < Pennycress::Value
|
|
116
|
+
input :account
|
|
117
|
+
output Integer
|
|
118
|
+
|
|
119
|
+
def compute(account:)
|
|
120
|
+
account.calculate_completed_order_total
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The `input` and `output` declarations act as both documentation and validation. If a caller provides an invalid account input, or `#compute` returns a non-numeric value, `Pennycress::ValidationError` is raised.
|
|
126
|
+
|
|
127
|
+
### Inputs
|
|
128
|
+
|
|
129
|
+
The `input` declaration accepts one or more model identifiers:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
input :account
|
|
133
|
+
input :account, :organization
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A model identifier follows Rails naming conventions, with `:account` resolving to `Account` and `:uploaded_file` mapping to `UploadedFile`. The resolved class must be a database-backed model that inherits from `ActiveRecord::Base`.
|
|
137
|
+
|
|
138
|
+
### Outputs
|
|
139
|
+
|
|
140
|
+
The `output` declaration describes the type of data a value produces from its model inputs, which can be either a Ruby class or a hash with multiple typed fields:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
output Integer
|
|
144
|
+
output count: Integer, labels: Array
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A hash output validates the type of each key's value and raises an error when unknown or missing keys are detected. Pennycress performs shallow validation of each field against its declared type, so deep validation of an array's items or child hashes should be handled by the caller of `.fetch`.
|
|
148
|
+
|
|
149
|
+
### Fetching a Value
|
|
150
|
+
|
|
151
|
+
Application code requests a computed value by calling `.fetch`. This validates the model inputs, computes the value in the case of a cache miss, and verifies the type of the resulting output.
|
|
152
|
+
|
|
153
|
+
When requesting a computed value, callers can provide either a persisted model instance or its primary key:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
AccountOrderTotal.fetch(account: account)
|
|
157
|
+
AccountOrderTotal.fetch(account: 1)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Pennycress uses the primary key when constructing the cache key, so these forms are equivalent. On a cache hit, passing a primary key does not load the model from the database. Composite primary keys are supported by passing their key values as an array.
|
|
161
|
+
|
|
162
|
+
### Fetching Multiple Values
|
|
163
|
+
|
|
164
|
+
When application code needs a computed value for several models, it should use `.fetch_many`:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
totals = AccountOrderTotal.fetch_many([
|
|
168
|
+
{ account: alfa },
|
|
169
|
+
{ account: bravo },
|
|
170
|
+
{ account: charlie }
|
|
171
|
+
])
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The returned values match the order of the inputs, with the second item in `totals` containing the value computed for account `bravo`. Cached outputs are reused, and only misses are computed.
|
|
175
|
+
|
|
176
|
+
By default, Pennycress calls `#compute` once for each miss. Values can override `#compute_many` to replace those calls with a more efficient batch calculation, which is explored in depth in [Batch Computation](#batch-computation).
|
|
177
|
+
|
|
178
|
+
## Cache Invalidation
|
|
179
|
+
|
|
180
|
+
Pennycress enables fine-grained invalidation in response to committed changes to Active Record models. A value declares invalidation rules using `watch`, which specifies a model and logic to run when an instance of that model changes:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
class AccountOrderTotal < Pennycress::Value
|
|
184
|
+
watch :account do |account|
|
|
185
|
+
[{ account: account }]
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The symbol passed to `watch` resolves to an Active Record class using Rails naming conventions. When a matching model is created, updated, or destroyed, Pennycress calls the block with the changed record and evicts the cached output for every input returned by the block. With an input's value removed from the cache, the next call to `.fetch` will calculate it with the latest model data.
|
|
191
|
+
|
|
192
|
+
### Producing Invalidation Inputs
|
|
193
|
+
|
|
194
|
+
A watch block returns an enumerable of inputs for the value. It may return one input, several inputs, or an empty array when a change does not affect the value. Inputs follow the same rules used for the values passed to `.fetch`, supporting either model instances or IDs:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
watch :account { |account| [{ account: account }] }
|
|
198
|
+
watch :order { |order| [{ account: order.account_id }] }
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Inputs can be any valid `Enumerable`, rather than just a concrete `Array`. If your list of inputs may be large, you can use a lazy or custom `Enumerator` to reduce memory usage during invalidation.
|
|
202
|
+
|
|
203
|
+
### Specifying Invalidation Triggers
|
|
204
|
+
|
|
205
|
+
By default, a watch responds to `:create`, `:update`, and `:destroy` commits. If you would like to only respond to a subset of those events, use `on:` to narrow the actions that trigger invalidation:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
watch :order, on: %i[create update] do |order|
|
|
209
|
+
[{ account: order.account_id }]
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
If a value should only be invalidated when specific fields change, the watch block can inspect Active Record's saved-change information and return an empty list of inputs for unrelated changes:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
watch :account, on: [:update] do |account|
|
|
217
|
+
account.saved_change_to_billing_status? ? [{ account: account }] : []
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Handling Destroyed Records
|
|
222
|
+
|
|
223
|
+
Destroy invalidations run after the destroy transaction is committed. The record's in-memory attributes, including its primary and foreign keys, are generally still available, but the row will no longer exists in the database.
|
|
224
|
+
|
|
225
|
+
These conditions can result in errors when trying to fetch associated records. For watches that respond to a `:destroy` commit, derive inputs from attributes retained on the destroyed instance:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
watch :order, on: %i[create update destroy] do |order|
|
|
229
|
+
[{ account: order.account_id }]
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
If a watch cannot handle destroyed records using the same logic as created or updated ones, you can define separate invalidation handlers for each action:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
watch :order, on: %i[create update] do |order|
|
|
237
|
+
[{ account: order.account.id }]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
watch :order, on: [:destroy] do |order|
|
|
241
|
+
[{ account: order.account_id }]
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Cache Warming
|
|
246
|
+
|
|
247
|
+
Pennycress values can define seeds that divide the work of cache warming into discrete background jobs. Each seed returned from the `seeds` block creates an Active Job that passes its seed to `.seed_to_inputs`, and the resulting inputs are fetched through the normal cache pipeline.
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
class AccountOrderTotal < Pennycress::Value
|
|
251
|
+
input :account
|
|
252
|
+
output Integer
|
|
253
|
+
|
|
254
|
+
seeds { Organization.active.ids }
|
|
255
|
+
|
|
256
|
+
def seed_to_inputs(organization_id)
|
|
257
|
+
Organization
|
|
258
|
+
.find(organization_id)
|
|
259
|
+
.active_accounts
|
|
260
|
+
.map { |account| { account: account } }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def compute(account:)
|
|
264
|
+
account.calculate_completed_order_total
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Seeds can be any value that can be safely serialized in the context of an Active Job. When possible, prefer primitive values like numbers and strings.
|
|
270
|
+
|
|
271
|
+
Seeding is a performance optimization to eagerly populate the cache for values that are known to put pressure on the database. A value can define as narrow or wide a set of inputs for warming as are appropriate to the computed value.
|
|
272
|
+
|
|
273
|
+
### Warming the Cache
|
|
274
|
+
|
|
275
|
+
All registered `Value` classes that define seeds can be warmed via the `pennycress:warm_cache` Rake task, making it a natural fit for post-deployment tasks. If you prefer to start cache warming through Ruby code, you can call `Pennycress.warm_cache`.
|
|
276
|
+
|
|
277
|
+
Both operations will block while they enqueue Active Jobs for each warming seed. This should be a fast operation, since the computation of each value is performed in the background jobs, and the only synchronous work is the calculation of seeds.
|
|
278
|
+
|
|
279
|
+
Warming jobs will be added to the default queue without any further configuration. If you wish to use a named queue to limit concurrent jobs, you can use the `warming_queue` option detailed in [Configuring Pennycress](#configuring-pennycress).
|
|
280
|
+
|
|
281
|
+
## Batch Computation
|
|
282
|
+
|
|
283
|
+
The default `#compute_many` implementation calls `#compute` for each input that resulted in a cache miss. If a value can be calculated more efficiently in bulk, define a custom `compute_many` implementation:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
class AccountOrderTotal < Pennycress::Value
|
|
287
|
+
input :account
|
|
288
|
+
output Integer
|
|
289
|
+
|
|
290
|
+
def compute(account:)
|
|
291
|
+
account.calculate_completed_order_total
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def compute_many(inputs)
|
|
295
|
+
ids = inputs.map do |input|
|
|
296
|
+
input[:account].id
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
by_id = Account.calculate_completed_order_totals_for_ids(ids)
|
|
300
|
+
|
|
301
|
+
ids.map do |id|
|
|
302
|
+
by_id.fetch(id)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
This example uses a theoretical method that efficiently calculates order totals for multiple accounts and exposes the totals in a hash keyed by account ID. By fetching these values once and mapping them to the ordered inputs, `compute_many` avoids N+1 issues when calculating multiple account totals.
|
|
309
|
+
|
|
310
|
+
## Configuring Pennycress
|
|
311
|
+
|
|
312
|
+
While Pennycress comes with sane defaults, you can customize its integration with your Rails app using an initializer. The available settings and their default values are shown below:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
# config/initializers/pennycress.rb
|
|
316
|
+
Pennycress.configure do |config|
|
|
317
|
+
# The ActiveSupport::Cache::Store instance used for all cache operations
|
|
318
|
+
config.cache = Rails.cache
|
|
319
|
+
|
|
320
|
+
# The prefix used for the cache key of all Pennycress values
|
|
321
|
+
config.cache_namespace = "pennycress"
|
|
322
|
+
|
|
323
|
+
# The directories in which values can be defined
|
|
324
|
+
config.directories = ["app/values"]
|
|
325
|
+
|
|
326
|
+
# The queue to which warming jobs are added
|
|
327
|
+
config.warming_queue = :default
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Why the Name?
|
|
332
|
+
|
|
333
|
+
In [floristry](https://www.instagram.com/justinlocsei/), pennycress is a filler flower with lots of tiny leaves. Fine, granular foliage feels appropriate for a library that offers fine-grained cache invalidation.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pennycress
|
|
4
|
+
# A cache is a thin wrapper around an ActiveSupport cache store that uses
|
|
5
|
+
# specialized output references for all cache operations.
|
|
6
|
+
class Cache
|
|
7
|
+
WRITE_OPTIONS = { skip_nil: true }.freeze
|
|
8
|
+
private_constant :WRITE_OPTIONS
|
|
9
|
+
|
|
10
|
+
# Creates a cache
|
|
11
|
+
#
|
|
12
|
+
# @param store [ActiveSupport::Cache::Store] the backing cache store
|
|
13
|
+
def initialize(store)
|
|
14
|
+
@store = store
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Evicts a cached value
|
|
18
|
+
#
|
|
19
|
+
# @param reference [OutputReference]
|
|
20
|
+
# @return [void]
|
|
21
|
+
def evict(reference)
|
|
22
|
+
@store.delete(reference.cache_key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Evicts cached values
|
|
26
|
+
#
|
|
27
|
+
# @param references [Array<OutputReference>]
|
|
28
|
+
# @return [void]
|
|
29
|
+
def evict_many(references)
|
|
30
|
+
@store.delete_multi(references.map(&:cache_key))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Reads a cached value, computing and storing it when absent
|
|
34
|
+
#
|
|
35
|
+
# @param reference [OutputReference]
|
|
36
|
+
# @yieldparam reference [OutputReference] a reference that was not cached
|
|
37
|
+
# @yieldreturn [Object] the value to cache in the case of a cache miss
|
|
38
|
+
# @return [Object] the cached or computed value
|
|
39
|
+
def fetch(reference, &)
|
|
40
|
+
@store.fetch(reference.cache_key, **WRITE_OPTIONS, &)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Reads a set of cached values, computing and storing misses
|
|
44
|
+
#
|
|
45
|
+
# @param references [Array<OutputReference>]
|
|
46
|
+
# @yieldparam reference [OutputReference] a reference that was not cached
|
|
47
|
+
# @yieldreturn [Object] the value to cache for the reference
|
|
48
|
+
# @return [Array<Object>] cached or computed values in reference order
|
|
49
|
+
def fetch_multi(references)
|
|
50
|
+
keys = references.map(&:cache_key)
|
|
51
|
+
refs_by_key = keys.zip(references).to_h
|
|
52
|
+
|
|
53
|
+
cached = @store.fetch_multi(*keys, **WRITE_OPTIONS) do |key|
|
|
54
|
+
yield refs_by_key.fetch(key)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
keys.map { |key| cached.fetch(key) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Writes a value to the cache
|
|
61
|
+
#
|
|
62
|
+
# @param reference [OutputReference]
|
|
63
|
+
# @param value [Object] the value to cache
|
|
64
|
+
# @return [void]
|
|
65
|
+
def write(reference, value)
|
|
66
|
+
@store.write(reference.cache_key, value, **WRITE_OPTIONS)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Writes multiple values to the cache
|
|
70
|
+
#
|
|
71
|
+
# @param entries [Hash{OutputReference => Object}]
|
|
72
|
+
# @return [void]
|
|
73
|
+
def write_multi(entries)
|
|
74
|
+
@store.write_multi(
|
|
75
|
+
entries.transform_keys(&:cache_key),
|
|
76
|
+
**WRITE_OPTIONS
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pennycress
|
|
4
|
+
# This module defines helpers for building cache keys.
|
|
5
|
+
module Caching
|
|
6
|
+
# Builds a cache key from segments
|
|
7
|
+
#
|
|
8
|
+
# @param segments [Array<Object>]
|
|
9
|
+
# @return [String]
|
|
10
|
+
def self.key(*segments)
|
|
11
|
+
segments
|
|
12
|
+
.compact
|
|
13
|
+
.map(&:to_s)
|
|
14
|
+
.reject(&:empty?)
|
|
15
|
+
.join("/")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/cache/null_store"
|
|
4
|
+
|
|
5
|
+
module Pennycress
|
|
6
|
+
# The configuration stores all global settings for Pennycress.
|
|
7
|
+
class Configuration
|
|
8
|
+
class << self
|
|
9
|
+
# Builds a new configuration
|
|
10
|
+
#
|
|
11
|
+
# @yieldparam config [Configuration] the configuration to customize
|
|
12
|
+
# @return [Configuration] the built configuration
|
|
13
|
+
def build
|
|
14
|
+
config = new
|
|
15
|
+
yield config
|
|
16
|
+
config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Configuration] the current configuration
|
|
20
|
+
def current
|
|
21
|
+
@current ||= new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Expands relative paths against a root
|
|
25
|
+
#
|
|
26
|
+
# @param root [Pathname, String] the root for relative paths
|
|
27
|
+
# @param paths [Array<String>] paths to expand
|
|
28
|
+
# @return [Array<String>] absolute paths
|
|
29
|
+
def expand_paths(root, paths)
|
|
30
|
+
paths.map do |path|
|
|
31
|
+
File.absolute_path?(path) ? path : File.join(root, path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Builds a modified copy of the current configuration
|
|
36
|
+
#
|
|
37
|
+
# @yieldparam config [Configuration] a copy of the current configuration
|
|
38
|
+
# @return [Configuration] the modified configuration
|
|
39
|
+
def modify
|
|
40
|
+
config = current.dup
|
|
41
|
+
yield config
|
|
42
|
+
config
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Runs a block with a temporary configuration
|
|
46
|
+
#
|
|
47
|
+
# @param config [Configuration] the configuration to use
|
|
48
|
+
# @yield run a block in which the given configuration is the current one
|
|
49
|
+
# @return [Object] the block's return value
|
|
50
|
+
def override(config)
|
|
51
|
+
previous = @current
|
|
52
|
+
@current = config
|
|
53
|
+
|
|
54
|
+
yield
|
|
55
|
+
ensure
|
|
56
|
+
@current = previous
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @!attribute [w] cache
|
|
61
|
+
# @param value [ActiveSupport::Cache::Store]
|
|
62
|
+
attr_writer :cache
|
|
63
|
+
|
|
64
|
+
# @!attribute [rw] cache_namespace
|
|
65
|
+
# @return [String] a global prefix for cache keys
|
|
66
|
+
attr_accessor :cache_namespace
|
|
67
|
+
|
|
68
|
+
# @!attribute [rw] directories
|
|
69
|
+
# @return [Array<String>] absolute paths to directories containing value classes
|
|
70
|
+
attr_accessor :directories
|
|
71
|
+
|
|
72
|
+
# @!attribute [rw] warming_queue
|
|
73
|
+
# @return [Symbol] the queue for the warming job
|
|
74
|
+
attr_accessor :warming_queue
|
|
75
|
+
|
|
76
|
+
# Creates a configuration
|
|
77
|
+
def initialize
|
|
78
|
+
@cache_namespace = "pennycress"
|
|
79
|
+
@directories = []
|
|
80
|
+
@warming_queue = :default
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [ActiveSupport::Cache::Store] the cache store to use
|
|
84
|
+
def cache
|
|
85
|
+
@cache ||= ActiveSupport::Cache::NullStore.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
protected
|
|
89
|
+
|
|
90
|
+
# @param original [Configuration] the configuration to copy
|
|
91
|
+
def initialize_copy(original)
|
|
92
|
+
@cache = original.instance_variable_get(:@cache)
|
|
93
|
+
@cache_namespace = original.cache_namespace.dup
|
|
94
|
+
@directories = original.directories.dup
|
|
95
|
+
@warming_queue = original.warming_queue
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pennycress
|
|
4
|
+
# This module provides a collection of helpers for enforcing constraints on
|
|
5
|
+
# user-facing classes.
|
|
6
|
+
module Constraints
|
|
7
|
+
# Raises an error stating that the named method is required
|
|
8
|
+
#
|
|
9
|
+
# @param name [Symbol] the name of the method
|
|
10
|
+
# @raise [NotImplementedError]
|
|
11
|
+
def require_method(name)
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement ##{name}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model_reference"
|
|
4
|
+
require_relative "models"
|
|
5
|
+
require_relative "schema"
|
|
6
|
+
|
|
7
|
+
module Pennycress
|
|
8
|
+
# An input describes a value's model inputs.
|
|
9
|
+
class Input
|
|
10
|
+
# @return [Array<Symbol>] the IDs of the models used by the value
|
|
11
|
+
attr_reader :model_ids
|
|
12
|
+
|
|
13
|
+
# Creates an input schema for a value
|
|
14
|
+
#
|
|
15
|
+
# @param model_ids [Array<Symbol>] model IDs (e.g., `:uploaded_file, :user`)
|
|
16
|
+
# @raise [ArgumentError] if model IDs are missing or invalid
|
|
17
|
+
def initialize(*model_ids)
|
|
18
|
+
raise ArgumentError, "model IDs are required" if model_ids.empty?
|
|
19
|
+
raise ArgumentError, "model IDs must be symbols" unless model_ids.all?(Symbol)
|
|
20
|
+
|
|
21
|
+
@model_ids = model_ids.uniq.sort
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validates an input against the schema
|
|
25
|
+
#
|
|
26
|
+
# @param input [Object] user-provided input
|
|
27
|
+
# @return [Hash] an input that conforms to the schema
|
|
28
|
+
# @raise [ValidationError] if the input is invalid
|
|
29
|
+
def validate(input)
|
|
30
|
+
Schema.validate_shape(schema, input) do |key, value|
|
|
31
|
+
ModelReference.from(schema[key], value, label: key.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @return [Hash{Symbol => Class}] a mapping of IDs to model classes
|
|
38
|
+
def schema
|
|
39
|
+
@schema ||= model_ids.to_h do |id|
|
|
40
|
+
[id, Pennycress::Models.resolve(id)]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|