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.
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cache"
4
+ require_relative "caching"
5
+ require_relative "configuration"
6
+ require_relative "constraints"
7
+ require_relative "input"
8
+ require_relative "output"
9
+ require_relative "output_reference"
10
+ require_relative "registry"
11
+ require_relative "value_config"
12
+ require_relative "watched_model"
13
+
14
+ module Pennycress
15
+ # A value describes a computation performed for each distinct input. After
16
+ # the initial value is computed, it is cached and reused until an invalidation
17
+ # condition is met.
18
+ class Value
19
+ include Constraints
20
+
21
+ class << self
22
+ # @param value [Class] the value being defined
23
+ # @return [void]
24
+ def inherited(value)
25
+ super
26
+ Registry.current.register(value)
27
+ end
28
+
29
+ # @return [ValueConfig] the value's configuration
30
+ def config
31
+ @config ||= ValueConfig.new
32
+ end
33
+
34
+ # Evicts cached outputs for the given inputs
35
+ #
36
+ # @param inputs [Enumerable<Hash>] a list of keyword arguments for inputs
37
+ # @return [void]
38
+ # @raise [ValidationError] if any inputs are invalid
39
+ def evict_many(inputs)
40
+ evict = inputs
41
+ .map { |input| reference(input) }
42
+ .to_a
43
+
44
+ cache.evict_many(evict)
45
+ end
46
+
47
+ # Computes an output value for the given input
48
+ #
49
+ # @param inspect_reference [Proc, nil] expose the output reference used for retrieval
50
+ # @param input [Hash] keyword arguments that should conform to the input schema
51
+ # @return [Object] the output value
52
+ # @raise [ValidationError] if the input is invalid
53
+ def fetch(inspect_reference: nil, **input)
54
+ ref = reference(input)
55
+ inspect_reference&.call(ref)
56
+
57
+ cache.fetch(ref) do
58
+ new.send(:fetch, **ref.input)
59
+ end
60
+ end
61
+
62
+ # Computes an output value for each input in an enumerable
63
+ #
64
+ # @param inputs [Enumerable<Hash>] a list of keyword arguments for inputs
65
+ # @return [Array<Object>] output values
66
+ # @raise [ValidationError] if any inputs or outputs are invalid
67
+ def fetch_many(inputs)
68
+ new.send(:fetch_many, cache, inputs).to_a
69
+ end
70
+
71
+ # Defines the value's input schema
72
+ #
73
+ # @param model_ids [Array<Symbol>] model IDs (e.g., `:uploaded_file, :user`)
74
+ # @return [void]
75
+ def input(*model_ids)
76
+ config.input = Input.new(*model_ids)
77
+ end
78
+
79
+ # Invalidate cached outputs for a watched model
80
+ #
81
+ # @param watch [WatchedModel] the watch that produced the change
82
+ # @param model [ActiveRecord::Base] the changed model instance
83
+ # @return [void]
84
+ def invalidate_model(watch, model)
85
+ evict_many(watch.inputs_for(model))
86
+ end
87
+
88
+ # Defines the value's output schema
89
+ #
90
+ # @param schema [Class, Hash{Symbol => Class}] a scalar type or shape hash
91
+ # @return [void]
92
+ # @raise [ArgumentError] if the schema is invalid
93
+ def output(schema)
94
+ config.output = Output.new(schema)
95
+ end
96
+
97
+ # Clears memoized cache bindings
98
+ #
99
+ # @return [void]
100
+ def reset_cache
101
+ @cache = nil
102
+ end
103
+
104
+ # Defines or returns the value's seeds
105
+ #
106
+ # @yieldreturn [Enumerable<Object>] a list of seeds to warm
107
+ # @return [Enumerable<Object>] the defined seeds, when called without a block
108
+ def seeds(&block)
109
+ if block
110
+ config.seeds = block
111
+ else
112
+ class_eval(&config.seeds)
113
+ end
114
+ end
115
+
116
+ # Registers a watch on a model
117
+ #
118
+ # @param id [Symbol] the model ID to watch (e.g., `:discussion`)
119
+ # @param on [Array<Symbol>] commit actions that trigger invalidation
120
+ # @yieldparam model [ActiveRecord::Base] the changed model instance
121
+ # @yieldreturn [Enumerable<Hash>] inputs to refresh
122
+ # @return [void]
123
+ def watch(id, on: WatchedModel::ACTIONS, &inputs)
124
+ config.watches << WatchedModel.new(id, on: on, &inputs)
125
+ end
126
+
127
+ # Warms the cache for each seed
128
+ #
129
+ # @return [void]
130
+ def warm
131
+ value = new
132
+ seeds.each { |seed| value.warm_seed(seed) }
133
+ end
134
+
135
+ private
136
+
137
+ # @return [Cache] the value's cache
138
+ def cache
139
+ @cache ||= Cache.new(Configuration.current.cache)
140
+ end
141
+
142
+ # @return [String] the value's cache namespace
143
+ def cache_namespace
144
+ @cache_namespace ||= if name
145
+ name.split("::").map(&:downcase).join("/")
146
+ else
147
+ ""
148
+ end
149
+ end
150
+
151
+ # Builds an output reference for the given input
152
+ #
153
+ # @param input [Hash] keyword arguments that should conform to the input schema
154
+ # @return [OutputReference] a reference to the cached output
155
+ # @raise [ValidationError] if the input is invalid
156
+ def reference(input)
157
+ validated = config.input.validate(input)
158
+
159
+ namespace = Caching.key(
160
+ Configuration.current.cache_namespace,
161
+ cache_namespace
162
+ )
163
+
164
+ OutputReference.new(
165
+ input: validated,
166
+ namespace: namespace.empty? ? nil : namespace
167
+ )
168
+ end
169
+ end
170
+
171
+ # Computes an output value for a valid input
172
+ #
173
+ # @return [Object]
174
+ # @api value
175
+ def compute(*)
176
+ require_method(:compute)
177
+ end
178
+
179
+ # Computes output values for each input in an enumerable
180
+ #
181
+ # @param inputs [Enumerable<Hash>] a list of valid inputs
182
+ # @return [Enumerable<Object>] output values
183
+ # @api value
184
+ def compute_many(inputs)
185
+ inputs.map do |input|
186
+ compute(**input)
187
+ end
188
+ end
189
+
190
+ # Converts a seed to a list of inputs
191
+ #
192
+ # @return [Enumerable<Hash>]
193
+ # @api value
194
+ def seed_to_inputs(*)
195
+ require_method(:seed_to_inputs)
196
+ end
197
+
198
+ # Warms the cache for a seed
199
+ #
200
+ # @param seed [Object]
201
+ # @return [void]
202
+ def warm_seed(seed)
203
+ self.class.send(:fetch_many, seed_to_inputs(seed)).each { nil }
204
+ end
205
+
206
+ private
207
+
208
+ # Computes an output value for a valid input
209
+ #
210
+ # @param input [Hash] a valid input
211
+ # @return [Object] the output value
212
+ def fetch(**input)
213
+ output.validate(compute(**input))
214
+ end
215
+
216
+ # Computes an output value for each input in an enumerable
217
+ #
218
+ # @param cache [Cache]
219
+ # @param inputs [Enumerable<Hash>] inputs to validate and compute
220
+ # @return [Array<Object>] output values
221
+ def fetch_many(cache, inputs)
222
+ refs = inputs
223
+ .map { |input| self.class.send(:reference, input) }
224
+ .to_a
225
+
226
+ misses = []
227
+
228
+ values = cache.fetch_multi(refs) do |ref|
229
+ misses << ref
230
+ nil
231
+ end
232
+
233
+ return values if misses.empty?
234
+
235
+ inputs = misses.map(&:input)
236
+ results = compute_many(inputs).to_a
237
+
238
+ if results.length != inputs.length
239
+ raise ValidationError,
240
+ "compute_many must return one output per input: got #{results.length}, expected #{inputs.length}"
241
+ end
242
+
243
+ computed = results.map do |result|
244
+ output.validate(result)
245
+ end
246
+
247
+ cache.write_multi(misses.zip(computed).to_h)
248
+
249
+ index = 0
250
+
251
+ values.map do |value|
252
+ if value.nil?
253
+ computed[index].tap { index += 1 }
254
+ else
255
+ value
256
+ end
257
+ end
258
+ end
259
+
260
+ # @return [Output] the value's output schema
261
+ def output
262
+ @output ||= self.class.config.output
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "input"
5
+ require_relative "output"
6
+ require_relative "watched_model"
7
+
8
+ module Pennycress
9
+ # A value config holds data describing how a value behaves.
10
+ class ValueConfig
11
+ # @!attribute [w] input
12
+ # @param value [Input]
13
+ attr_writer :input
14
+
15
+ # @!attribute [w] output
16
+ # @param value [Output]
17
+ attr_writer :output
18
+
19
+ # @!attribute [w] seeds
20
+ # @param value [Proc]
21
+ attr_writer :seeds
22
+
23
+ # @return [Input] the schema for the value's input
24
+ # @raise [ValidationError] if a schema is incomplete
25
+ def input
26
+ raise ValidationError, "input is not defined" if @input.nil?
27
+
28
+ @input
29
+ end
30
+
31
+ # @return [Output] the schema for the value's output
32
+ # @raise [ValidationError] if a schema is not defined
33
+ def output
34
+ raise ValidationError, "output is not defined" if @output.nil?
35
+
36
+ @output
37
+ end
38
+
39
+ # @return [Proc] a block that defines the list of seeds
40
+ def seeds
41
+ @seeds ||= proc { [] }
42
+ end
43
+
44
+ # @return [Array<WatchedModel>] watches registered for the value
45
+ def watches
46
+ @watches ||= []
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennycress
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ require_relative "configuration"
6
+
7
+ module Pennycress
8
+ # This job warms cached outputs for one value seed.
9
+ class WarmSeedJob < ActiveJob::Base
10
+ queue_as { Configuration.current.warming_queue }
11
+
12
+ # @param value_class_name [String] the value class to warm
13
+ # @param seed [Object] the seed to warm
14
+ # @return [void]
15
+ def perform(value_class_name, seed)
16
+ value_class_name.constantize.new.warm_seed(seed)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+ require_relative "warm_seed_job"
5
+
6
+ module Pennycress
7
+ # This module orchestrates cache warming for registered value classes.
8
+ module Warming
9
+ # Warms cached outputs for each registered value seed
10
+ #
11
+ # @param async [Boolean] whether to warm the cache via jobs or synchronous operations
12
+ # @return [void]
13
+ # @raise [ArgumentError] if a value class has no name
14
+ def self.warm_cache(async: true)
15
+ method = async ? :perform_later : :perform_now
16
+
17
+ Registry.current.values.each do |value_class|
18
+ value_class.seeds.each do |seed|
19
+ name = value_class.name
20
+
21
+ unless name
22
+ raise ArgumentError, "value class must have a name"
23
+ end
24
+
25
+ WarmSeedJob.send(method, name, seed)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "models"
4
+
5
+ module Pennycress
6
+ # A watched model describes a model observed by a value. When the model
7
+ # changes, a value has the chance to selectively invalidate itself by deriving
8
+ # inputs from the changed model.
9
+ class WatchedModel
10
+ # Commit actions a watch can respond to
11
+ ACTIONS = %i[create destroy update].freeze
12
+
13
+ # @return [Symbol] the ID of the watched model
14
+ attr_reader :id
15
+
16
+ # @return [Proc] a block that maps a model to inputs
17
+ attr_reader :inputs
18
+
19
+ # @return [Array<Symbol>] commit actions that trigger invalidation
20
+ attr_reader :on
21
+
22
+ # Creates a watched model
23
+ #
24
+ # @param id [Symbol] the ID of the model to watch
25
+ # @param on [Array<Symbol>] commit actions that trigger invalidation
26
+ # @yieldparam model [ActiveRecord::Base] the changed model instance
27
+ # @yieldreturn [Enumerable<Hash>] inputs to refresh
28
+ # @raise [ArgumentError] if the commit actions are invalid
29
+ def initialize(id, on: ACTIONS, &inputs)
30
+ @id = id
31
+ @inputs = inputs
32
+ @on = validate_actions(on)
33
+ end
34
+
35
+ # Produces invalidation inputs for a given model
36
+ #
37
+ # @param model [ActiveRecord::Base] a changed model instance
38
+ # @return [Enumerable<Hash>] invalidation inputs
39
+ def inputs_for(model)
40
+ inputs.call(model)
41
+ end
42
+
43
+ # @return [Class] the watched ActiveRecord model class
44
+ def model_class
45
+ @model_class ||= Models.resolve(id)
46
+ end
47
+
48
+ private
49
+
50
+ # Validates commit actions
51
+ #
52
+ # @param actions [Array<Symbol>]
53
+ # @return [Array<Symbol>] valid commit actions
54
+ def validate_actions(actions)
55
+ if actions.empty?
56
+ raise ArgumentError, "commit actions cannot be empty"
57
+ end
58
+
59
+ unknown = actions - ACTIONS
60
+
61
+ unless unknown.empty?
62
+ raise ArgumentError, "unsupported commit actions: #{unknown.map(&:inspect).join(', ')}"
63
+ end
64
+
65
+ actions.uniq.sort
66
+ end
67
+ end
68
+ end
data/lib/pennycress.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pennycress/configuration"
4
+ require_relative "pennycress/value"
5
+ require_relative "pennycress/version"
6
+ require_relative "pennycress/warming"
7
+
8
+ require_relative "pennycress/railtie" if defined?(Rails::Railtie)
9
+
10
+ # Pennycress allows Rails apps to precompute and cache expensive logic, with
11
+ # fine-grained invalidation and support for targeted cache warming.
12
+ module Pennycress
13
+ class << self
14
+ # Configures Pennycress
15
+ #
16
+ # @yieldparam config [Configuration] the current configuration
17
+ # @return [void]
18
+ def configure
19
+ yield Configuration.current
20
+ end
21
+
22
+ # Warms cached outputs for each registered value seed
23
+ #
24
+ # @param async [Boolean] whether to warm the cache via jobs or synchronous operations
25
+ # @return [void]
26
+ def warm_cache(async: true)
27
+ Warming.warm_cache(async: async)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :pennycress do
4
+ desc "Warm the cache for all values that define seeds"
5
+ task warm_cache: :environment do
6
+ Pennycress.warm_cache
7
+ end
8
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pennycress
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Justin Locsei
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: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ description: Pennycress allows Rails apps to precompute and cache expensive logic,
27
+ with fine-grained invalidation and support for targeted cache warming.
28
+ executables: []
29
+ extensions: []
30
+ extra_rdoc_files: []
31
+ files:
32
+ - LICENSE
33
+ - README.md
34
+ - lib/pennycress.rb
35
+ - lib/pennycress/cache.rb
36
+ - lib/pennycress/caching.rb
37
+ - lib/pennycress/configuration.rb
38
+ - lib/pennycress/constraints.rb
39
+ - lib/pennycress/errors.rb
40
+ - lib/pennycress/input.rb
41
+ - lib/pennycress/integration.rb
42
+ - lib/pennycress/model_reference.rb
43
+ - lib/pennycress/models.rb
44
+ - lib/pennycress/output.rb
45
+ - lib/pennycress/output_reference.rb
46
+ - lib/pennycress/railtie.rb
47
+ - lib/pennycress/registry.rb
48
+ - lib/pennycress/schema.rb
49
+ - lib/pennycress/value.rb
50
+ - lib/pennycress/value_config.rb
51
+ - lib/pennycress/version.rb
52
+ - lib/pennycress/warm_seed_job.rb
53
+ - lib/pennycress/warming.rb
54
+ - lib/pennycress/watched_model.rb
55
+ - lib/tasks/pennycress.rake
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.3.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.20
75
+ specification_version: 4
76
+ summary: Memoized domain values for Rails
77
+ test_files: []