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 +4 -4
- data/.rubocop.yml +10 -0
- data/CHANGELOG.md +52 -0
- data/README.md +149 -8
- data/lib/bipolar_cache/sequel/plugin_alpha.rb +173 -92
- data/lib/bipolar_cache/version.rb +1 -1
- data/lib/bipolar_cache.rb +52 -15
- data/sig/bipolar_cache.rbs +47 -1
- metadata +8 -10
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/.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
|
-
|
|
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
|
-
|
|
34
|
+
Add to your Gemfile:
|
|
8
35
|
|
|
9
|
-
|
|
36
|
+
```ruby
|
|
37
|
+
gem "bipolar_cache"
|
|
38
|
+
```
|
|
10
39
|
|
|
11
|
-
|
|
40
|
+
Or install directly:
|
|
12
41
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(**
|
|
17
|
+
bcsp_cache(**bcsp_build_procs(**opts))
|
|
13
18
|
end
|
|
14
19
|
|
|
15
20
|
define_method "#{method_name}_refresh!" do
|
|
16
|
-
procs =
|
|
17
|
-
|
|
18
|
-
procs
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# @param
|
|
21
|
-
# @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
|
|
22
30
|
# @return [Object]
|
|
31
|
+
# @raise [ArgumentError] on missing, unknown or non-callable arguments
|
|
23
32
|
def self.read!(**procs)
|
|
24
|
-
|
|
33
|
+
procs = normalize!(procs)
|
|
34
|
+
rescuer = procs[:rescue]
|
|
25
35
|
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
72
|
+
|
|
73
|
+
{ if: ALWAYS }.merge(procs)
|
|
38
74
|
end
|
|
75
|
+
private_class_method :normalize!
|
|
39
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,16 +1,15 @@
|
|
|
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
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies: []
|
|
13
|
-
description:
|
|
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:
|
|
50
|
-
signing_key:
|
|
48
|
+
rubygems_version: 4.0.20
|
|
51
49
|
specification_version: 4
|
|
52
|
-
summary: BipolarCache.
|
|
50
|
+
summary: BipolarCache. Probabilistic caching toolkit.
|
|
53
51
|
test_files: []
|