bipolar_cache 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14dba1b50e859c7210b98cc2fb557341169d4e97ca5e1e90cf82ed5eeb1a617f
4
- data.tar.gz: 2a26dd62e0ee0cb66e8bc2d4821831850a56d4a212b6574dbde3cb8043dc817a
3
+ metadata.gz: 3bb404064cea70958b9164dce8ca19a2896ed8cceb9472af9e893a7d32a9ea83
4
+ data.tar.gz: 4cd2da217bb8347ea474949fc6a21d6accdb125e438f1091dbb746aa4c8ca4a3
5
5
  SHA512:
6
- metadata.gz: e10eb1702dcdea29b43c149a6cc364ec8ec999ab45c1334fa931087af7700324ffad07a782e38fa72cad5fbc6d2231a98615bf454da73ce447fd5b9621cc6573
7
- data.tar.gz: 21114e271dfec2fde22731c7ed81b24cd12fcc7286dbd5c4debda8be36f5a67ca795e99ce33629807cc3720b3f70b93fe54a203abbe0652cfd00dc74daa06a30
6
+ metadata.gz: 6c9ac5ae9a879b6e399a83f5a8ff8cdbdb4e7180917b52f40c60b9f38e2309c90f96e98cfe909e8e56527c40c94b76a0ffb3b09a2380aa4ab8f5213031069c5e
7
+ data.tar.gz: 5a1ee634187ad333b3d5d728d4e5cd957569f5806c4ffb1c666dbb066ec3884d484225ad36d856eb6fcc657bcd70ebf0fdcaba00ac9464662cd76c4070c3a7f3
data/.rubocop.yml CHANGED
@@ -6,9 +6,19 @@ AllCops:
6
6
  Metrics/AbcSize:
7
7
  Enabled: false
8
8
 
9
+ Metrics/CyclomaticComplexity:
10
+ Enabled: false
11
+
12
+ Metrics/PerceivedComplexity:
13
+ Enabled: false
14
+
9
15
  Metrics/MethodLength:
10
16
  Enabled: false
11
17
 
18
+ Metrics/ClassLength:
19
+ Exclude:
20
+ - "test/**/*"
21
+
12
22
  Style/Documentation:
13
23
  Enabled: false
14
24
 
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 - 2026-10-14
4
+
5
+ ### Breaking changes
6
+
7
+ - `BipolarCache.read!` raises `ArgumentError` for missing, unknown or non-callable
8
+ keywords. Before, a missing `actual:` surfaced as a `NoMethodError` that `rescue:` swallowed,
9
+ and misspelled keywords were silently ignored.
10
+ - `BipolarCache.read!`: when only the cache write fails, the error goes to `rescue:` (or is
11
+ re-raised without one) but the value that was read is still returned instead of the `rescue:` result.
12
+ - Sequel plugin: procs passed to `bipolar_count_cache` run in the scope of the model instance
13
+ (`self` used to be the class), so they can call instance methods and associations directly.
14
+ - Sequel plugin: for `Sequel::Model` instances the default cache write is a bare dataset `UPDATE`
15
+ of the cache column. It no longer runs validations and hooks, saves other unsaved columns or
16
+ inserts an unsaved record. `increment!`/`decrement!` use `coalesce(col, 0) + n` in SQL, so
17
+ concurrent increments are not lost and a `NULL` column is handled. Your own `update:` is
18
+ still called with the new value.
19
+ - Sequel plugin: invalid options (wrong type, `chance` below 0, above 100 or `NaN`) raise
20
+ `ArgumentError` when `bipolar_count_cache` is declared instead of on first use.
21
+ - Sequel plugin: the `bcsp_*` helper methods are private. `fetch_or_build_procs` is gone: procs are
22
+ built per call instead of being memoized on the instance.
23
+
24
+ ### Changed
25
+
26
+ - `BipolarCache.read!`: `if:` is optional (default: caching enabled) and every
27
+ callable may be any object responding to `call`, not only a `Proc`.
28
+ - Sequel plugin: `if:`, `chance:` and `rescue:` accept a Symbol/String naming an instance method,
29
+ like `actual:`, `cached:` and `update:` already did. `if: :method` used to be ignored,
30
+ which left caching switched on.
31
+
32
+ ### Fixed
33
+
34
+ - Sequel plugin: two `bipolar_count_cache` declarations with the same association name no longer
35
+ share one set of procs.
36
+ - Sequel plugin: a `dup`/`clone` no longer reads and writes the original record's cache.
37
+ - Sequel plugin: reading a count on a frozen model no longer raises `FrozenError`.
38
+ - Sequel plugin: `refresh!` goes through `rescue:` like `increment!`/`decrement!`, and those two
39
+ no longer replace the original error with a `NoMethodError`.
40
+ - Sequel plugin: a model whose count was read can be `Marshal.dump`ed again. The memoized procs
41
+ lived in an instance variable and made `Marshal.dump` raise `TypeError`.
42
+ - Sequel plugin: with the default `chance`, a `NULL` cache column is a miss. It used to be a hit 10% of
43
+ the time and `comments_count` returned `nil`.
44
+ - Sequel plugin: the default cache write clears the cache column from `changed_columns`, so a later
45
+ `save`/`save_changes` no longer writes a stale value over a newer one.
46
+
47
+ ### Added
48
+
49
+ - Type signatures in `sig/bipolar_cache.rbs`.
50
+ - Tests against real `Sequel::Model` instances, both with Sequel's mock adapter and with an
51
+ in-memory SQLite database; `sequel` and `sqlite3` are development dependencies.
52
+ - Ruby 4.0 in the CI matrix.
data/README.md CHANGED
@@ -1,26 +1,167 @@
1
1
  # BipolarCache
2
2
 
3
- Probabalistic caching toolkit useful for caching of database counters, and other operations.
3
+ Probabilistic caching toolkit for Ruby. Instead of always hitting the database or always returning a cached value, BipolarCache flips a coin — sometimes you get the cache, sometimes you get the real thing, and when the real thing differs, the cache gets updated.
4
+
5
+ Useful for caching database counters and other operations where occasional staleness is acceptable and you want to gradually reduce database load without explicit cache invalidation.
6
+
7
+ ## How it works
8
+
9
+ ```
10
+ BipolarCache.read! → should we cache? (if)
11
+ │
12
+ no ────┘──── yes
13
+ │ │
14
+ return actual read cached value
15
+ │
16
+ chance(cached) > rand?
17
+ │
18
+ yes ──┘── no
19
+ │ │
20
+ return compute actual
21
+ cached │
22
+ cached != actual?
23
+ │
24
+ yes ──┘── no
25
+ │ │
26
+ update return
27
+ cache actual
28
+ return
29
+ actual
30
+ ```
4
31
 
5
32
  ## Installation
6
33
 
7
- Install the gem and add to the application's Gemfile by executing:
34
+ Add to your Gemfile:
8
35
 
9
- $ bundle add bipolar_cache
36
+ ```ruby
37
+ gem "bipolar_cache"
38
+ ```
10
39
 
11
- If bundler is not being used to manage dependencies, install the gem by executing:
40
+ Or install directly:
12
41
 
13
- $ gem install bipolar_cache
42
+ ```
43
+ $ gem install bipolar_cache
44
+ ```
14
45
 
15
46
  ## Usage
16
47
 
48
+ ### Core API
17
49
 
50
+ `BipolarCache.read!` accepts six keyword arguments, all callables (procs, lambdas, or anything that responds to `call`):
18
51
 
19
- ## Development
52
+ ```ruby
53
+ result = BipolarCache.read!(
54
+ actual: -> { Post.where(user_id: user.id).count }, # the real value (expensive)
55
+ cached: -> { user.posts_count_cache }, # the cached value (cheap)
56
+ chance: ->(cached_value) { cached_value < 10 ? 0.1 : 0.9 }, # probability of cache hit
57
+ if: -> { user.caching_enabled? }, # enable/disable caching
58
+ update: ->(value) { user.update(posts_count_cache: value) }, # write new value to cache
59
+ rescue: ->(error) { Rails.logger.error(error); 0 } # handle errors
60
+ )
61
+ ```
62
+
63
+ | Parameter | Required | Description |
64
+ |-----------|----------|-------------|
65
+ | `actual` | yes | Callable that returns the real value (e.g., a database query) |
66
+ | `cached` | yes | Callable that returns the cached value |
67
+ | `chance` | yes | Callable that receives the cached value and returns a float 0.0-1.0. Higher = more likely to use cache. |
68
+ | `if` | no | Callable that returns true/false. When false, always returns the actual value. Defaults to always enabled. |
69
+ | `update` | yes | Callable that receives the actual value and persists it to the cache |
70
+ | `rescue` | no | Callable that receives a `StandardError`. Without it, errors re-raise. |
71
+
72
+ Missing, unknown (e.g. a misspelled `chnace:`) or non-callable arguments raise `ArgumentError`, even when `rescue` is given.
73
+
74
+ #### Errors
75
+
76
+ - An error while reading (`if`, `cached`, `chance` or `actual`) goes to `rescue`, and its return value becomes the result.
77
+ - An error while writing the cache (`update`) is also passed to `rescue`, but the result is still the actual value
78
+ that was just read. A failed cache write never replaces a good answer.
79
+ - Without `rescue`, both kinds of error are re-raised.
80
+
81
+ ### Sequel Plugin (alpha)
82
+
83
+ For [Sequel](https://github.com/jeremyevans/sequel) models, the plugin generates all the procs automatically. Your model needs a `_count_cache` column (e.g., `comments_count_cache`):
84
+
85
+ ```ruby
86
+ require "bipolar_cache/sequel/plugin_alpha"
87
+
88
+ class User < Sequel::Model
89
+ include BipolarCache::Sequel::PluginAlpha
90
+
91
+ # Assumes:
92
+ # - association: user.comments_dataset
93
+ # - cache column: user.comments_count_cache
94
+ bipolar_count_cache :comments
95
+ end
96
+ ```
97
+
98
+ This generates four instance methods:
20
99
 
21
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
100
+ ```ruby
101
+ user.comments_count # probabilistic: returns cached or actual count
102
+ user.comments_count_refresh! # force: computes actual and updates cache
103
+ user.comments_count_increment! # bump cache by 1 (or by: n), returns the new value
104
+ user.comments_count_decrement! # drop cache by 1 (or by: n), returns the new value
105
+ ```
106
+
107
+ #### Options
108
+
109
+ ```ruby
110
+ bipolar_count_cache :comments,
111
+ method: "comment_total", # custom method name (default: "{name}_count")
112
+ chance: 75, # fixed probability as percentage (default: adaptive)
113
+ actual: -> { comments_dataset.where(visible: true).count }, # custom actual proc
114
+ cached: :my_cache_column, # custom cache column/method name
115
+ update: ->(v) { update(my_cache_column: v) }, # custom update proc
116
+ rescue: ->(e) { log(e); nil }, # custom error handler
117
+ if: -> { !new? } # disable caching for unsaved records
118
+ ```
119
+
120
+ Procs run in the scope of the model instance, so they can call its methods and associations directly.
121
+ `actual`, `cached`, `update`, `if`, `chance` and `rescue` also accept a Symbol (or String) naming an
122
+ instance method, which receives the same arguments the proc would:
123
+
124
+ ```ruby
125
+ bipolar_count_cache :comments, if: :caching_enabled?, chance: :hit_chance, rescue: :report_error
126
+ ```
127
+
128
+ Options are checked when `bipolar_count_cache` is declared: a value of the wrong type, or a numeric `chance`
129
+ outside 0-100 (or `NaN`), raises `ArgumentError` at class-load time. A method name that the model doesn't
130
+ respond to raises `ArgumentError` the first time the cache is used.
131
+
132
+ #### How the cache is written
133
+
134
+ For a `Sequel::Model` the default write is a plain `UPDATE` of the cache column. It does not run validations or
135
+ hooks, does not save other unsaved changes on the record, and does nothing in the database for a record that
136
+ isn't saved yet (the new value only lives in memory). `increment!` and `decrement!` are done in SQL
137
+ (`cache = coalesce(cache, 0) + n`), so concurrent bumps don't overwrite each other and a `NULL` column counts as 0.
138
+
139
+ Passing your own `update:` replaces all of this: it is called with the new value, whatever it does.
140
+
141
+ By default errors are logged with `warn` and the method returns `0`; pass `rescue:` to change that.
142
+
143
+ #### Chance values
144
+
145
+ The `chance` option controls cache-hit probability:
146
+
147
+ | Value | Interpretation | Example |
148
+ |-------|---------------|---------|
149
+ | `0.0` - `1.0` (Float) | Direct probability | `chance: 0.9` = 90% cache hit |
150
+ | `1` (Integer) | Direct probability (1.0 = 100%) | `chance: 1` = always cache |
151
+ | `2` - `100` (Integer) | Percentage, divided by 100 | `chance: 75` = 75% cache hit |
152
+ | `0` | Never use cache | `chance: 0` = always compute actual |
153
+ | Proc | Dynamic, receives cached value | `chance: ->(v) { v > 100 ? 0.95 : 0.5 }` |
154
+ | _(omitted)_ | Adaptive default | < 10: 10% cache hit, >= 10: 90% cache hit |
155
+
156
+ ## Development
22
157
 
23
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
158
+ ```
159
+ bin/setup # install dependencies
160
+ rake test # run tests
161
+ rake rubocop # lint
162
+ rake # run both
163
+ bin/console # interactive prompt
164
+ ```
24
165
 
25
166
  ## Contributing
26
167
 
@@ -1,143 +1,224 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../bipolar_cache"
4
+
3
5
  module BipolarCache
6
+ # NOTE: inside this namespace a bare +Sequel+ is BipolarCache::Sequel;
7
+ # reach the gem with +::Sequel+.
4
8
  module Sequel
5
9
  module PluginAlpha
6
10
  module ClassMethods
7
11
  def bipolar_count_cache(name, **opts)
12
+ Options.validate!(opts)
8
13
  opts = opts.merge({ name: name })
9
14
  method_name = opts[:method] || "#{name}_count"
10
15
 
11
16
  define_method method_name do
12
- bcsp_cache(**fetch_or_build_procs(**opts))
17
+ bcsp_cache(**bcsp_build_procs(**opts))
13
18
  end
14
19
 
15
20
  define_method "#{method_name}_refresh!" do
16
- procs = fetch_or_build_procs(**opts)
17
- actual_value = procs[:actual].call
18
- procs[:update].call(actual_value) if actual_value != procs[:cached].call
19
- actual_value
21
+ procs = bcsp_build_procs(**opts)
22
+
23
+ bcsp_guarded(procs) do
24
+ actual_value = procs[:actual].call
25
+ procs[:update].call(actual_value) if actual_value != procs[:cached].call
26
+ actual_value
27
+ end
20
28
  end
21
29
 
22
30
  define_method "#{method_name}_increment!" do |by: 1|
23
- procs = fetch_or_build_procs(**opts)
24
-
25
- procs[:update].call(by + procs[:cached].call)
31
+ bcsp_adjust_cache!(opts, by)
26
32
  end
27
33
 
28
34
  define_method "#{method_name}_decrement!" do |by: 1|
29
- procs = fetch_or_build_procs(**opts)
30
-
31
- procs[:update].call((-by) + procs[:cached].call)
35
+ bcsp_adjust_cache!(opts, -by)
32
36
  end
33
37
  end
34
38
  end
35
39
 
36
- def self.included(base)
37
- base.extend ClassMethods
38
- end
40
+ module Options
41
+ class << self
42
+ # Checks everything about the options that can be checked without an
43
+ # instance, so a bad declaration fails when the class is loaded.
44
+ def validate!(opts)
45
+ %i[actual cached update if rescue chance].each { |key| validate_option!(key, opts[key]) }
46
+ end
39
47
 
40
- def bcsp_cache(**procs)
41
- BipolarCache.read!(**procs)
48
+ # Turns a numeric chance into a probability: 0..1 is taken as is,
49
+ # anything above 1 is a percentage.
50
+ def chance_probability(chance)
51
+ raise ArgumentError, "chance must be a number, got NaN" if chance.is_a?(Float) && chance.nan?
52
+ raise ArgumentError, "chance must be >= 0, got #{chance}" if chance.negative?
53
+ raise ArgumentError, "chance must be <= 100, got #{chance}" if chance > 100
54
+
55
+ chance > 1 ? chance / 100.0 : chance
56
+ end
57
+
58
+ private
59
+
60
+ def validate_option!(key, value)
61
+ return if value.nil? || value.is_a?(Proc) || value.is_a?(Symbol) || value.is_a?(String)
62
+ return if key == :if && [true, false].include?(value)
63
+ return chance_probability(value) if key == :chance && (value.is_a?(Integer) || value.is_a?(Float))
64
+
65
+ raise ArgumentError, "#{key} has an unsupported value: #{value.inspect}"
66
+ end
67
+ end
42
68
  end
43
69
 
44
- def fetch_or_build_procs(**opts)
45
- # create cached store for instance procs, if none
46
- instance_variable_set(:@bcsp_proc_store, {}) unless instance_variable_defined?(:@bcsp_proc_store)
47
-
48
- if instance_variable_get(:@bcsp_proc_store)[opts[:name]].nil?
49
- instance_variable_get(:@bcsp_proc_store)[opts[:name]] =
50
- {
51
- actual: bcsp_proc_actual_from(**opts),
52
- cached: bcsp_proc_cached_from(**opts),
53
- update: bcsp_proc_update_from(**opts),
54
- chance: bcsp_proc_chance_from(**opts),
55
- rescue: bcsp_proc_rescue_from(**opts),
56
- if: bcsp_proc_if_from(**opts)
57
- }
70
+ # Turns the declaration options into the callables BipolarCache.read!
71
+ # expects, all bound to the instance they are built on.
72
+ module Builders
73
+ private
74
+
75
+ def bcsp_build_procs(**opts)
76
+ {
77
+ actual: bcsp_proc_actual_from(**opts),
78
+ cached: bcsp_proc_cached_from(**opts),
79
+ update: bcsp_proc_update_from(**opts),
80
+ chance: bcsp_proc_chance_from(**opts),
81
+ rescue: bcsp_proc_rescue_from(**opts),
82
+ if: bcsp_proc_if_from(**opts)
83
+ }.freeze
58
84
  end
59
85
 
60
- # stored procs
61
- instance_variable_get(:@bcsp_proc_store)[opts[:name]]
62
- end
86
+ # Procs run in the scope of the instance; Symbols and Strings name a
87
+ # method of the instance that receives the call arguments.
88
+ def bcsp_bind(key, option)
89
+ case option
90
+ when Proc
91
+ ->(*args) { instance_exec(*args, &option) }
92
+ when Symbol, String
93
+ unless respond_to?(option, true)
94
+ raise ArgumentError, "#{key}: #{option.inspect} is not a method of #{self.class} " \
95
+ "(expected a Proc or the name of an instance method)"
96
+ end
97
+
98
+ ->(*args) { send(option, *args) }
99
+ else
100
+ raise ArgumentError, "#{key} must be a Proc, Symbol or String, got #{option.inspect}"
101
+ end
102
+ end
103
+
104
+ def bcsp_proc_actual_from(**opts)
105
+ return bcsp_bind(:actual, opts[:actual]) unless opts[:actual].nil?
63
106
 
64
- def bcsp_proc_actual_from(**opts)
65
- case opts[:actual]
66
- when Proc
67
- opts[:actual]
68
- when String, Symbol
69
- -> { send(opts[:actual]) }
70
- else
71
107
  -> { send("#{opts[:name]}_dataset").count }
72
108
  end
73
- end
74
109
 
75
- def bcsp_proc_cached_from(**opts)
76
- case opts[:cached]
77
- when Proc
78
- opts[:cached]
79
- when String, Symbol
80
- -> { send(opts[:cached]) }
81
- else
110
+ def bcsp_proc_cached_from(**opts)
111
+ return bcsp_bind(:cached, opts[:cached]) unless opts[:cached].nil?
112
+
82
113
  -> { send("#{opts[:name]}_count_cache") }
83
114
  end
84
- end
85
115
 
86
- def bcsp_proc_update_from(**opts)
87
- cache_name = if opts[:cached].is_a?(String) || opts[:cached].is_a?(Symbol)
88
- opts[:cached]
89
- else
90
- "#{opts[:name]}_count_cache"
91
- end
92
-
93
- case opts[:update]
94
- when Proc
95
- opts[:update]
96
- when String, Symbol
97
- -> { send(opts[:update]) }
98
- else
99
- ->(value) { update({ cache_name => value }) }
116
+ def bcsp_proc_update_from(**opts)
117
+ return bcsp_bind(:update, opts[:update]) unless opts[:update].nil?
118
+
119
+ column = bcsp_cache_column(opts)
120
+ ->(value) { bcsp_write_cache(column, value) }
100
121
  end
101
- end
102
122
 
103
- def bcsp_proc_chance_from(**opts)
104
- case opts[:chance]
105
- when Proc
106
- opts[:chance]
107
- when Integer, Float
108
- normalised_chance = if opts[:chance] > 1
109
- opts[:chance] / 100
110
- else
111
- opts[:chance]
112
- end
113
- ->(_value) { normalised_chance }
114
- else # default
115
- ->(value) { value < 10 ? 0.1 : 0.9 }
123
+ def bcsp_proc_chance_from(**opts)
124
+ case opts[:chance]
125
+ when nil # default
126
+ lambda { |value|
127
+ next 0 if value.nil? # a missing cache is never a hit: it would hand back nil
128
+
129
+ value < 10 ? 0.1 : 0.9
130
+ }
131
+ when Integer, Float
132
+ probability = Options.chance_probability(opts[:chance])
133
+ ->(_value) { probability }
134
+ else
135
+ bcsp_bind(:chance, opts[:chance])
136
+ end
116
137
  end
117
- end
118
138
 
119
- def bcsp_proc_rescue_from(**opts)
120
- if opts[:rescue].is_a? Proc
121
- opts[:rescue]
122
- else # default
139
+ def bcsp_proc_rescue_from(**opts)
140
+ return bcsp_bind(:rescue, opts[:rescue]) unless opts[:rescue].nil?
141
+
123
142
  lambda { |e|
124
- e.inspect
143
+ warn "[BipolarCache] #{e.class}: #{e.message}"
125
144
  0
126
145
  }
127
146
  end
147
+
148
+ def bcsp_proc_if_from(**opts)
149
+ case opts[:if]
150
+ when nil # default
151
+ -> { true }
152
+ when true, false
153
+ value = opts[:if]
154
+ -> { value }
155
+ else
156
+ bcsp_bind(:if, opts[:if])
157
+ end
158
+ end
159
+
160
+ def bcsp_cache_column(opts)
161
+ case opts[:cached]
162
+ when String, Symbol then opts[:cached].to_sym
163
+ else :"#{opts[:name]}_count_cache"
164
+ end
165
+ end
166
+ end
167
+
168
+ include Builders
169
+
170
+ def self.included(base)
171
+ base.extend ClassMethods
172
+ end
173
+
174
+ private
175
+
176
+ def bcsp_cache(**procs)
177
+ BipolarCache.read!(**procs)
178
+ end
179
+
180
+ def bcsp_guarded(procs)
181
+ yield
182
+ rescue StandardError => e
183
+ procs[:rescue].call(e)
128
184
  end
129
185
 
130
- def bcsp_proc_if_from(**opts)
131
- case opts[:if]
132
- when Proc
133
- opts[:if]
134
- when TrueClass, FalseClass
135
- value = opts[:if]
136
- -> { value }
137
- else # default
138
- -> { true }
186
+ def bcsp_adjust_cache!(opts, delta)
187
+ procs = bcsp_build_procs(**opts)
188
+
189
+ bcsp_guarded(procs) do
190
+ new_value = (procs[:cached].call || 0) + delta
191
+
192
+ if opts[:update].nil? && bcsp_sequel_model?
193
+ column = bcsp_cache_column(opts)
194
+ relative = ::Sequel.function(:coalesce, ::Sequel[column], 0) + delta
195
+ bcsp_write_cache(column, new_value, relative)
196
+ else
197
+ procs[:update].call(new_value)
198
+ end
199
+
200
+ new_value
139
201
  end
140
202
  end
203
+
204
+ def bcsp_sequel_model?
205
+ defined?(::Sequel::Model) && is_a?(::Sequel::Model)
206
+ end
207
+
208
+ # Default cache writer. A Sequel model gets a bare UPDATE of the cache
209
+ # column: no validations, hooks or other unsaved columns are involved,
210
+ # and +expression+ lets the database do the arithmetic. Anything else
211
+ # is asked to #update itself.
212
+ def bcsp_write_cache(column, value, expression = value)
213
+ return update({ column => value }) unless bcsp_sequel_model?
214
+
215
+ this.update(column => expression) unless new?
216
+ return if frozen?
217
+
218
+ values[column] = value
219
+ # the value is now in the database; a later save must not write it again
220
+ changed_columns.delete(column)
221
+ end
141
222
  end
142
223
  end
143
224
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BipolarCache
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/bipolar_cache.rb CHANGED
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "bipolar_cache/version"
4
- require_relative "bipolar_cache/sequel/plugin_alpha"
5
4
 
6
5
  module BipolarCache
7
6
  class Error < StandardError; end
8
7
 
8
+ REQUIRED_KEYS = %i[actual cached chance update].freeze
9
+ OPTIONAL_KEYS = %i[if rescue].freeze
10
+ ALWAYS = -> { true }
11
+ private_constant :REQUIRED_KEYS, :OPTIONAL_KEYS, :ALWAYS
12
+
9
13
  ##
10
14
  # It may read actual value,
11
15
  # it may read cached value,
@@ -13,27 +17,60 @@ module BipolarCache
13
17
  # It depends on chance.
14
18
  # Call it at your peril.
15
19
  #
16
- # @param actual [Proc] callable to perform an operation (cacheable)
17
- # @param cached [Proc] callable to read cached value
18
- # @param chance [Proc(Object)] callable to compute chance of cache hit
19
- # @param if [Proc] callable to enable/disable caching
20
- # @param rescue [Proc(StandardError)] callable to be invoked on exception
21
- # @param update [Proc(Object)] callable to update cached value
20
+ # Errors raised while reading are passed to +rescue+ (re-raised without it).
21
+ # An error raised while writing the cache is reported to +rescue+ the same
22
+ # way, but the value that was read is still returned.
23
+ #
24
+ # @param actual [#call] callable to perform an operation (cacheable)
25
+ # @param cached [#call] callable to read cached value
26
+ # @param chance [#call(Object)] callable to compute chance of cache hit
27
+ # @param if [#call] callable to enable/disable caching (default: enabled)
28
+ # @param rescue [#call(StandardError)] callable to be invoked on exception
29
+ # @param update [#call(Object)] callable to update cached value
22
30
  # @return [Object]
31
+ # @raise [ArgumentError] on missing, unknown or non-callable arguments
23
32
  def self.read!(**procs)
24
- return procs[:actual].call unless procs[:if].call
33
+ procs = normalize!(procs)
34
+ rescuer = procs[:rescue]
25
35
 
26
- cached_value = procs[:cached].call
36
+ begin
37
+ return procs[:actual].call unless procs[:if].call
38
+
39
+ cached_value = procs[:cached].call
40
+ return cached_value if procs[:chance].call(cached_value) > rand
27
41
 
28
- if procs[:chance].call(cached_value) > rand
29
- cached_value
30
- else
31
42
  actual_value = procs[:actual].call
43
+ rescue StandardError => e
44
+ raise unless rescuer
45
+
46
+ return rescuer.call(e)
47
+ end
48
+
49
+ begin
32
50
  procs[:update].call(actual_value) if cached_value != actual_value
51
+ rescue StandardError => e
52
+ raise unless rescuer
53
+
54
+ rescuer.call(e)
55
+ end
56
+
57
+ actual_value
58
+ end
59
+
60
+ def self.normalize!(procs)
61
+ procs = procs.compact
33
62
 
34
- actual_value
63
+ unknown = procs.keys - REQUIRED_KEYS - OPTIONAL_KEYS
64
+ raise ArgumentError, "unknown keywords: #{unknown.map(&:inspect).join(", ")}" unless unknown.empty?
65
+
66
+ missing = REQUIRED_KEYS - procs.keys
67
+ raise ArgumentError, "missing keywords: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
68
+
69
+ procs.each do |key, callable|
70
+ raise ArgumentError, "#{key} must respond to #call, got #{callable.inspect}" unless callable.respond_to?(:call)
35
71
  end
36
- rescue StandardError => e
37
- procs[:rescue].call(e) if procs[:rescue].is_a?(Proc)
72
+
73
+ { if: ALWAYS }.merge(procs)
38
74
  end
75
+ private_class_method :normalize!
39
76
  end
@@ -1,4 +1,50 @@
1
1
  module BipolarCache
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ interface _Call
8
+ def call: () -> untyped
9
+ end
10
+
11
+ interface _CallWithValue
12
+ def call: (untyped value) -> untyped
13
+ end
14
+
15
+ interface _CallWithError
16
+ def call: (StandardError error) -> untyped
17
+ end
18
+
19
+ def self.read!: (
20
+ actual: _Call,
21
+ cached: _Call,
22
+ chance: _CallWithValue,
23
+ update: _CallWithValue,
24
+ ?if: _Call?,
25
+ ?rescue: _CallWithError?
26
+ ) -> untyped
27
+
28
+ module Sequel
29
+ module PluginAlpha
30
+ # A Proc runs in the scope of the model instance; a Symbol or String
31
+ # names an instance method that receives the call arguments.
32
+ type callable = Proc | Symbol | String
33
+
34
+ def self.included: (Module base) -> void
35
+
36
+ module ClassMethods
37
+ def bipolar_count_cache: (
38
+ Symbol | String name,
39
+ ?method: Symbol | String,
40
+ ?actual: callable?,
41
+ ?cached: callable?,
42
+ ?update: callable?,
43
+ ?chance: callable | Integer | Float | nil,
44
+ ?if: callable | bool | nil,
45
+ ?rescue: callable?
46
+ ) -> void
47
+ end
48
+ end
49
+ end
4
50
  end
metadata CHANGED
@@ -1,16 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bipolar_cache
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andriy Tyurnikov
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-04-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies: []
13
- description: Probabalistic caching toolkit useful for caching of database counters,
12
+ description: Probabilistic caching toolkit useful for caching of database counters,
14
13
  and other operations.
15
14
  email:
16
15
  - Andriy.Tyurnikov@gmail.com
@@ -19,6 +18,7 @@ extensions: []
19
18
  extra_rdoc_files: []
20
19
  files:
21
20
  - ".rubocop.yml"
21
+ - CHANGELOG.md
22
22
  - README.md
23
23
  - Rakefile
24
24
  - lib/bipolar_cache.rb
@@ -29,9 +29,8 @@ homepage: https://github.com/rubakas/bipolar_cache
29
29
  licenses: []
30
30
  metadata:
31
31
  homepage_uri: https://github.com/rubakas/bipolar_cache
32
- source_code_uri: https://github.com/rubakas/bipolar_cache
33
- changelog_uri: https://github.com/rubakas/bipolar_cache
34
- post_install_message:
32
+ source_code_uri: https://github.com/rubakas/bipolar_cache/tree/main
33
+ changelog_uri: https://github.com/rubakas/bipolar_cache/blob/main/CHANGELOG.md
35
34
  rdoc_options: []
36
35
  require_paths:
37
36
  - lib
@@ -46,8 +45,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
45
  - !ruby/object:Gem::Version
47
46
  version: '0'
48
47
  requirements: []
49
- rubygems_version: 3.5.6
50
- signing_key:
48
+ rubygems_version: 4.0.20
51
49
  specification_version: 4
52
- summary: BipolarCache. Probabalistic caching toolkit.
50
+ summary: BipolarCache. Probabilistic caching toolkit.
53
51
  test_files: []