bipolar_cache 0.4.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 +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +37 -5
- data/lib/bipolar_cache/sequel/plugin_alpha.rb +170 -104
- data/lib/bipolar_cache/version.rb +1 -1
- data/lib/bipolar_cache.rb +51 -15
- data/sig/bipolar_cache.rbs +47 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3bb404064cea70958b9164dce8ca19a2896ed8cceb9472af9e893a7d32a9ea83
|
|
4
|
+
data.tar.gz: 4cd2da217bb8347ea474949fc6a21d6accdb125e438f1091dbb746aa4c8ca4a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6c9ac5ae9a879b6e399a83f5a8ff8cdbdb4e7180917b52f40c60b9f38e2309c90f96e98cfe909e8e56527c40c94b76a0ffb3b09a2380aa4ab8f5213031069c5e
|
|
7
|
+
data.tar.gz: 5a1ee634187ad333b3d5d728d4e5cd957569f5806c4ffb1c666dbb066ec3884d484225ad36d856eb6fcc657bcd70ebf0fdcaba00ac9464662cd76c4070c3a7f3
|
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
|
@@ -47,7 +47,7 @@ $ gem install bipolar_cache
|
|
|
47
47
|
|
|
48
48
|
### Core API
|
|
49
49
|
|
|
50
|
-
`BipolarCache.read!` accepts six keyword arguments, all callables (procs
|
|
50
|
+
`BipolarCache.read!` accepts six keyword arguments, all callables (procs, lambdas, or anything that responds to `call`):
|
|
51
51
|
|
|
52
52
|
```ruby
|
|
53
53
|
result = BipolarCache.read!(
|
|
@@ -65,10 +65,19 @@ result = BipolarCache.read!(
|
|
|
65
65
|
| `actual` | yes | Callable that returns the real value (e.g., a database query) |
|
|
66
66
|
| `cached` | yes | Callable that returns the cached value |
|
|
67
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` |
|
|
68
|
+
| `if` | no | Callable that returns true/false. When false, always returns the actual value. Defaults to always enabled. |
|
|
69
69
|
| `update` | yes | Callable that receives the actual value and persists it to the cache |
|
|
70
70
|
| `rescue` | no | Callable that receives a `StandardError`. Without it, errors re-raise. |
|
|
71
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
|
+
|
|
72
81
|
### Sequel Plugin (alpha)
|
|
73
82
|
|
|
74
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`):
|
|
@@ -91,8 +100,8 @@ This generates four instance methods:
|
|
|
91
100
|
```ruby
|
|
92
101
|
user.comments_count # probabilistic: returns cached or actual count
|
|
93
102
|
user.comments_count_refresh! # force: computes actual and updates cache
|
|
94
|
-
user.comments_count_increment! # bump cache by 1 (or by: n)
|
|
95
|
-
user.comments_count_decrement! # drop cache by 1 (or by: n)
|
|
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
|
|
96
105
|
```
|
|
97
106
|
|
|
98
107
|
#### Options
|
|
@@ -103,11 +112,34 @@ bipolar_count_cache :comments,
|
|
|
103
112
|
chance: 75, # fixed probability as percentage (default: adaptive)
|
|
104
113
|
actual: -> { comments_dataset.where(visible: true).count }, # custom actual proc
|
|
105
114
|
cached: :my_cache_column, # custom cache column/method name
|
|
106
|
-
update: ->(v) {
|
|
115
|
+
update: ->(v) { update(my_cache_column: v) }, # custom update proc
|
|
107
116
|
rescue: ->(e) { log(e); nil }, # custom error handler
|
|
108
117
|
if: -> { !new? } # disable caching for unsaved records
|
|
109
118
|
```
|
|
110
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
|
+
|
|
111
143
|
#### Chance values
|
|
112
144
|
|
|
113
145
|
The `chance` option controls cache-hit probability:
|
|
@@ -3,156 +3,222 @@
|
|
|
3
3
|
require_relative "../../bipolar_cache"
|
|
4
4
|
|
|
5
5
|
module BipolarCache
|
|
6
|
+
# NOTE: inside this namespace a bare +Sequel+ is BipolarCache::Sequel;
|
|
7
|
+
# reach the gem with +::Sequel+.
|
|
6
8
|
module Sequel
|
|
7
9
|
module PluginAlpha
|
|
8
10
|
module ClassMethods
|
|
9
11
|
def bipolar_count_cache(name, **opts)
|
|
12
|
+
Options.validate!(opts)
|
|
10
13
|
opts = opts.merge({ name: name })
|
|
11
14
|
method_name = opts[:method] || "#{name}_count"
|
|
12
15
|
|
|
13
16
|
define_method method_name do
|
|
14
|
-
bcsp_cache(**
|
|
17
|
+
bcsp_cache(**bcsp_build_procs(**opts))
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
define_method "#{method_name}_refresh!" do
|
|
18
|
-
procs =
|
|
19
|
-
|
|
20
|
-
procs
|
|
21
|
-
|
|
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
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
define_method "#{method_name}_increment!" do |by: 1|
|
|
25
|
-
|
|
26
|
-
new_value = by + (procs[:cached].call || 0)
|
|
27
|
-
procs[:update].call(new_value)
|
|
28
|
-
new_value
|
|
29
|
-
rescue StandardError => e
|
|
30
|
-
raise unless procs[:rescue].is_a?(Proc)
|
|
31
|
-
|
|
32
|
-
procs[:rescue].call(e)
|
|
31
|
+
bcsp_adjust_cache!(opts, by)
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
define_method "#{method_name}_decrement!" do |by: 1|
|
|
36
|
-
|
|
37
|
-
new_value = (-by) + (procs[:cached].call || 0)
|
|
38
|
-
procs[:update].call(new_value)
|
|
39
|
-
new_value
|
|
40
|
-
rescue StandardError => e
|
|
41
|
-
raise unless procs[:rescue].is_a?(Proc)
|
|
42
|
-
|
|
43
|
-
procs[:rescue].call(e)
|
|
35
|
+
bcsp_adjust_cache!(opts, -by)
|
|
44
36
|
end
|
|
45
37
|
end
|
|
46
38
|
end
|
|
47
39
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
54
68
|
end
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
70
84
|
end
|
|
71
85
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
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?
|
|
75
106
|
|
|
76
|
-
def bcsp_proc_actual_from(**opts)
|
|
77
|
-
case opts[:actual]
|
|
78
|
-
when Proc
|
|
79
|
-
opts[:actual]
|
|
80
|
-
when String, Symbol
|
|
81
|
-
-> { send(opts[:actual]) }
|
|
82
|
-
else
|
|
83
107
|
-> { send("#{opts[:name]}_dataset").count }
|
|
84
108
|
end
|
|
85
|
-
end
|
|
86
109
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
opts[:cached]
|
|
91
|
-
when String, Symbol
|
|
92
|
-
-> { send(opts[:cached]) }
|
|
93
|
-
else
|
|
110
|
+
def bcsp_proc_cached_from(**opts)
|
|
111
|
+
return bcsp_bind(:cached, opts[:cached]) unless opts[:cached].nil?
|
|
112
|
+
|
|
94
113
|
-> { send("#{opts[:name]}_count_cache") }
|
|
95
114
|
end
|
|
96
|
-
end
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
case opts[:update]
|
|
106
|
-
when Proc
|
|
107
|
-
opts[:update]
|
|
108
|
-
when String, Symbol
|
|
109
|
-
->(value) { send(opts[:update], value) }
|
|
110
|
-
else
|
|
111
|
-
->(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) }
|
|
112
121
|
end
|
|
113
|
-
end
|
|
114
122
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
else # default
|
|
130
|
-
->(value) { (value || 0) < 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
|
|
131
137
|
end
|
|
132
|
-
end
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
else # default
|
|
139
|
+
def bcsp_proc_rescue_from(**opts)
|
|
140
|
+
return bcsp_bind(:rescue, opts[:rescue]) unless opts[:rescue].nil?
|
|
141
|
+
|
|
138
142
|
lambda { |e|
|
|
139
143
|
warn "[BipolarCache] #{e.class}: #{e.message}"
|
|
140
144
|
0
|
|
141
145
|
}
|
|
142
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
|
|
143
166
|
end
|
|
144
167
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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)
|
|
184
|
+
end
|
|
185
|
+
|
|
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
|
|
154
201
|
end
|
|
155
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
|
|
156
222
|
end
|
|
157
223
|
end
|
|
158
224
|
end
|
data/lib/bipolar_cache.rb
CHANGED
|
@@ -5,6 +5,11 @@ require_relative "bipolar_cache/version"
|
|
|
5
5
|
module BipolarCache
|
|
6
6
|
class Error < StandardError; end
|
|
7
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
|
+
|
|
8
13
|
##
|
|
9
14
|
# It may read actual value,
|
|
10
15
|
# it may read cached value,
|
|
@@ -12,29 +17,60 @@ module BipolarCache
|
|
|
12
17
|
# It depends on chance.
|
|
13
18
|
# Call it at your peril.
|
|
14
19
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
# @param
|
|
20
|
-
# @param
|
|
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
|
|
21
30
|
# @return [Object]
|
|
31
|
+
# @raise [ArgumentError] on missing, unknown or non-callable arguments
|
|
22
32
|
def self.read!(**procs)
|
|
23
|
-
|
|
33
|
+
procs = normalize!(procs)
|
|
34
|
+
rescuer = procs[:rescue]
|
|
24
35
|
|
|
25
|
-
|
|
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
|
|
26
41
|
|
|
27
|
-
if procs[:chance].call(cached_value) > rand
|
|
28
|
-
cached_value
|
|
29
|
-
else
|
|
30
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
|
|
31
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
|
|
62
|
+
|
|
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?
|
|
32
68
|
|
|
33
|
-
|
|
69
|
+
procs.each do |key, callable|
|
|
70
|
+
raise ArgumentError, "#{key} must respond to #call, got #{callable.inspect}" unless callable.respond_to?(:call)
|
|
34
71
|
end
|
|
35
|
-
rescue StandardError => e
|
|
36
|
-
raise unless procs[:rescue].is_a?(Proc)
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
{ if: ALWAYS }.merge(procs)
|
|
39
74
|
end
|
|
75
|
+
private_class_method :normalize!
|
|
40
76
|
end
|
data/sig/bipolar_cache.rbs
CHANGED
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
module BipolarCache
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bipolar_cache
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andriy Tyurnikov
|
|
@@ -18,6 +18,7 @@ extensions: []
|
|
|
18
18
|
extra_rdoc_files: []
|
|
19
19
|
files:
|
|
20
20
|
- ".rubocop.yml"
|
|
21
|
+
- CHANGELOG.md
|
|
21
22
|
- README.md
|
|
22
23
|
- Rakefile
|
|
23
24
|
- lib/bipolar_cache.rb
|
|
@@ -44,7 +45,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
44
45
|
- !ruby/object:Gem::Version
|
|
45
46
|
version: '0'
|
|
46
47
|
requirements: []
|
|
47
|
-
rubygems_version: 4.0.
|
|
48
|
+
rubygems_version: 4.0.20
|
|
48
49
|
specification_version: 4
|
|
49
50
|
summary: BipolarCache. Probabilistic caching toolkit.
|
|
50
51
|
test_files: []
|