redis-objects-preloadable 0.1.0 → 0.1.2
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 +9 -0
- data/README.md +22 -0
- data/lib/redis/objects/preloadable/model_extension.rb +22 -2
- data/lib/redis/objects/preloadable/preload_context.rb +21 -0
- data/lib/redis/objects/preloadable/relation_extension.rb +29 -0
- data/lib/redis/objects/preloadable/type_patches/counter.rb +3 -0
- data/lib/redis/objects/preloadable/type_patches/hash_key.rb +3 -0
- data/lib/redis/objects/preloadable/type_patches/list.rb +3 -0
- data/lib/redis/objects/preloadable/type_patches/set.rb +3 -0
- data/lib/redis/objects/preloadable/type_patches/sorted_set.rb +3 -0
- data/lib/redis/objects/preloadable/type_patches/value.rb +3 -0
- data/lib/redis/objects/preloadable/version.rb +1 -1
- data/lib/redis/objects/preloadable.rb +47 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a44062b18cd9ef7c2fed29f9bf72f7438f319bb9383727f7c69dcf013d6c98fd
|
|
4
|
+
data.tar.gz: 6d9bbfafa458a31596ac7cf721852e5ea05e50ea2fe9ecb2d30c640e0e46b6e6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8197221aca5a63029b05b0a1a7fa4a54bf8a9ac3338f1467a979016647e534fa61c80ec26b3ac2b903ef88afd1866d51619e369e70da54dded7a573b50bd9996
|
|
7
|
+
data.tar.gz: 9dae232377119b913f983a42e0fa999f1ccf5ee48261d231b86b4599837de0c2485937ed867f7d13c1d6e6bdcb3bb8beb3440a5df49e89aa938ae3caed991031
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.1.2] - 2026-02-22
|
|
4
|
+
|
|
5
|
+
- Fix `RelationExtension#reset` to clear `@redis_preload_context_attached`, so `redis_preload` context is correctly re-attached on every `load` after `reset` (matches AR `.includes` behavior)
|
|
6
|
+
- Document that `record.reload` does not clear preloaded Redis values (by design)
|
|
7
|
+
|
|
8
|
+
## [0.1.1] - 2026-02-22
|
|
9
|
+
|
|
10
|
+
- Fix Rails 8.1 / Ruby 3.4 compatibility: forward all arguments in `ModelExtension.all` override to avoid `ArgumentError` when `ActiveRecord::Persistence#_find_record` calls `all(all_queries: ...)` ([#fix](https://github.com/kyohah/redis-objects-preloadable/issues))
|
|
11
|
+
|
|
3
12
|
## [0.1.0] - 2026-02-20
|
|
4
13
|
|
|
5
14
|
- Initial release
|
data/README.md
CHANGED
|
@@ -105,6 +105,28 @@ Preloading is lazy. The `redis_preload` scope attaches metadata to the relation,
|
|
|
105
105
|
|
|
106
106
|
The type patches are applied via `prepend` on `Redis::Counter`, `Redis::Value`, `Redis::List`, `Redis::Set`, `Redis::SortedSet`, and `Redis::HashKey`.
|
|
107
107
|
|
|
108
|
+
## Limitations
|
|
109
|
+
|
|
110
|
+
### `record.reload` does not clear preloaded values
|
|
111
|
+
|
|
112
|
+
`reload` refreshes DB columns only. Preloaded Redis values remain cached on the redis-objects instances and are **not** updated.
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
widget = Widget.redis_preload(:view_count).first
|
|
116
|
+
widget.view_count.value # => 5 (preloaded)
|
|
117
|
+
|
|
118
|
+
# Another process increments the counter in Redis...
|
|
119
|
+
|
|
120
|
+
widget.reload
|
|
121
|
+
widget.view_count.value # => 5 (still the preloaded value — NOT refreshed)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If you need a fresh Redis value after `reload`, fetch the record again without preloading:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
Widget.find(widget.id).view_count.value # => hits Redis directly
|
|
128
|
+
```
|
|
129
|
+
|
|
108
130
|
## Backward Compatibility: `read_redis_counter`
|
|
109
131
|
|
|
110
132
|
If your models use the `read_redis_counter` helper (from the original Concern-based approach), it continues to work. With transparent preloading, you can remove explicit `read_redis_counter` calls and access `counter.value` directly.
|
|
@@ -3,17 +3,37 @@
|
|
|
3
3
|
class Redis
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
|
+
# ActiveSupport::Concern that integrates Preloadable into ActiveRecord models.
|
|
7
|
+
#
|
|
8
|
+
# Included automatically when a model does +include Redis::Objects::Preloadable+.
|
|
9
|
+
# Overrides +.all+ to extend relations with {RelationExtension} and provides
|
|
10
|
+
# the backward-compatible +read_redis_counter+ helper.
|
|
11
|
+
#
|
|
6
12
|
module ModelExtension
|
|
7
13
|
extend ActiveSupport::Concern
|
|
8
14
|
|
|
9
15
|
class_methods do
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
# @api private
|
|
17
|
+
def all(...)
|
|
18
|
+
super(...).extending(Redis::Objects::Preloadable::RelationExtension)
|
|
12
19
|
end
|
|
13
20
|
end
|
|
14
21
|
|
|
15
22
|
private
|
|
16
23
|
|
|
24
|
+
# Backward-compatible helper for reading a counter with SQL fallback.
|
|
25
|
+
#
|
|
26
|
+
# If the counter has a preloaded value, it is returned directly.
|
|
27
|
+
# Otherwise, checks Redis and falls back to the block (SQL query).
|
|
28
|
+
#
|
|
29
|
+
# With transparent preloading, this method is no longer necessary.
|
|
30
|
+
# You can access +counter.value+ directly instead.
|
|
31
|
+
#
|
|
32
|
+
# @param _name [Symbol] the counter attribute name (unused in transparent mode)
|
|
33
|
+
# @param counter [Redis::Counter] the counter instance
|
|
34
|
+
# @yield SQL fallback block that returns the count
|
|
35
|
+
# @return [Integer] the counter value
|
|
36
|
+
#
|
|
17
37
|
def read_redis_counter(_name, counter)
|
|
18
38
|
if counter.instance_variable_defined?(:@preloaded_value)
|
|
19
39
|
raw = counter.instance_variable_get(:@preloaded_value)
|
|
@@ -3,15 +3,36 @@
|
|
|
3
3
|
class Redis
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
|
+
# Redis::Objects types that support MGET (single-value keys).
|
|
6
7
|
MGET_TYPES = %i[counter value].freeze
|
|
7
8
|
|
|
9
|
+
# Holds a batch of records and attribute names, and resolves them
|
|
10
|
+
# in a single Redis round-trip on first access.
|
|
11
|
+
#
|
|
12
|
+
# PreloadContext is created by {RelationExtension#load} or
|
|
13
|
+
# {Preloadable.preload} and attached to each redis-objects instance.
|
|
14
|
+
# Resolution is lazy and idempotent.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# context = PreloadContext.new(records, [:view_count, :tag_ids])
|
|
18
|
+
# context.resolve! # fetches all values in one batch
|
|
19
|
+
# context.resolve! # no-op on subsequent calls
|
|
20
|
+
#
|
|
8
21
|
class PreloadContext
|
|
22
|
+
# @param records [Array<ActiveRecord::Base>] records to preload
|
|
23
|
+
# @param names [Array<Symbol>] redis-objects attribute names
|
|
9
24
|
def initialize(records, names)
|
|
10
25
|
@records = records
|
|
11
26
|
@names = names
|
|
12
27
|
@resolved = false
|
|
13
28
|
end
|
|
14
29
|
|
|
30
|
+
# Resolve all preloaded attributes in a single batch.
|
|
31
|
+
#
|
|
32
|
+
# Uses MGET for counter/value types and pipelined commands for
|
|
33
|
+
# list/set/sorted_set/hash_key types. This method is idempotent.
|
|
34
|
+
#
|
|
35
|
+
# @return [void]
|
|
15
36
|
def resolve!
|
|
16
37
|
return if @resolved
|
|
17
38
|
|
|
@@ -3,15 +3,44 @@
|
|
|
3
3
|
class Redis
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
|
+
# Extends ActiveRecord::Relation with +redis_preload+ scope.
|
|
7
|
+
#
|
|
8
|
+
# This module is automatically mixed into relations via
|
|
9
|
+
# {ModelExtension::ClassMethods#all}.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# Widget.where(active: true).redis_preload(:view_count, :tag_ids).each do |w|
|
|
13
|
+
# w.view_count.value # preloaded
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
6
16
|
module RelationExtension
|
|
17
|
+
# Declare redis-objects attributes to batch-preload when the relation loads.
|
|
18
|
+
#
|
|
19
|
+
# Can be chained with other ActiveRecord scopes. Preloading is lazy:
|
|
20
|
+
# no Redis calls until the first attribute access.
|
|
21
|
+
#
|
|
22
|
+
# @param names [Array<Symbol>] redis-objects attribute names
|
|
23
|
+
# @return [ActiveRecord::Relation] a new relation with preload metadata
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# Widget.order(:id).redis_preload(:view_count).limit(100)
|
|
27
|
+
#
|
|
7
28
|
def redis_preload(*names)
|
|
8
29
|
spawn.tap { |r| r.instance_variable_set(:@redis_preload_names, names) }
|
|
9
30
|
end
|
|
10
31
|
|
|
32
|
+
# @return [Array<Symbol>] the redis-objects attribute names to preload
|
|
11
33
|
def redis_preload_names
|
|
12
34
|
@redis_preload_names || []
|
|
13
35
|
end
|
|
14
36
|
|
|
37
|
+
# @api private
|
|
38
|
+
def reset
|
|
39
|
+
@redis_preload_context_attached = false
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @api private
|
|
15
44
|
def load
|
|
16
45
|
result = super
|
|
17
46
|
|
|
@@ -4,7 +4,10 @@ class Redis
|
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
6
|
module TypePatches
|
|
7
|
+
# Prepended onto Redis::Counter to support preloaded values.
|
|
8
|
+
# Fetched via MGET. Returns +.to_i+ (0 for nil/missing keys).
|
|
7
9
|
module Counter
|
|
10
|
+
# @api private
|
|
8
11
|
def preload!(raw_value)
|
|
9
12
|
@preloaded_value = raw_value
|
|
10
13
|
end
|
|
@@ -4,7 +4,10 @@ class Redis
|
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
6
|
module TypePatches
|
|
7
|
+
# Prepended onto Redis::HashKey to support preloaded values.
|
|
8
|
+
# Fetched via HGETALL in a pipeline.
|
|
7
9
|
module HashKey
|
|
10
|
+
# @api private
|
|
8
11
|
def preload!(raw_value)
|
|
9
12
|
@preloaded_value = raw_value || {}
|
|
10
13
|
end
|
|
@@ -4,7 +4,10 @@ class Redis
|
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
6
|
module TypePatches
|
|
7
|
+
# Prepended onto Redis::List to support preloaded values.
|
|
8
|
+
# Fetched via LRANGE 0 -1 in a pipeline.
|
|
7
9
|
module List
|
|
10
|
+
# @api private
|
|
8
11
|
def preload!(raw_value)
|
|
9
12
|
@preloaded_value = raw_value || []
|
|
10
13
|
end
|
|
@@ -4,7 +4,10 @@ class Redis
|
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
6
|
module TypePatches
|
|
7
|
+
# Prepended onto Redis::Set to support preloaded values.
|
|
8
|
+
# Fetched via SMEMBERS in a pipeline.
|
|
7
9
|
module Set
|
|
10
|
+
# @api private
|
|
8
11
|
def preload!(raw_value)
|
|
9
12
|
@preloaded_value = raw_value || []
|
|
10
13
|
end
|
|
@@ -4,7 +4,10 @@ class Redis
|
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
6
|
module TypePatches
|
|
7
|
+
# Prepended onto Redis::SortedSet to support preloaded values.
|
|
8
|
+
# Fetched via ZRANGE 0 -1 WITHSCORES in a pipeline.
|
|
7
9
|
module SortedSet
|
|
10
|
+
# @api private
|
|
8
11
|
def preload!(raw_value)
|
|
9
12
|
@preloaded_value = raw_value || []
|
|
10
13
|
end
|
|
@@ -4,7 +4,10 @@ class Redis
|
|
|
4
4
|
module Objects
|
|
5
5
|
module Preloadable
|
|
6
6
|
module TypePatches
|
|
7
|
+
# Prepended onto Redis::Value to support preloaded values.
|
|
8
|
+
# Fetched via MGET. Returns raw string or nil.
|
|
7
9
|
module Value
|
|
10
|
+
# @api private
|
|
8
11
|
def preload!(raw_value)
|
|
9
12
|
@preloaded_value = raw_value
|
|
10
13
|
end
|
|
@@ -21,6 +21,36 @@ Redis::Set.prepend(Redis::Objects::Preloadable::TypePatches::Set)
|
|
|
21
21
|
Redis::SortedSet.prepend(Redis::Objects::Preloadable::TypePatches::SortedSet)
|
|
22
22
|
Redis::HashKey.prepend(Redis::Objects::Preloadable::TypePatches::HashKey)
|
|
23
23
|
|
|
24
|
+
# Redis::Objects::Preloadable eliminates N+1 Redis calls for redis-objects
|
|
25
|
+
# attributes on ActiveRecord models.
|
|
26
|
+
#
|
|
27
|
+
# It provides two APIs:
|
|
28
|
+
#
|
|
29
|
+
# 1. +redis_preload+ scope on ActiveRecord relations
|
|
30
|
+
# 2. +Redis::Objects::Preloadable.preload+ for arbitrary record arrays
|
|
31
|
+
#
|
|
32
|
+
# == Basic usage
|
|
33
|
+
#
|
|
34
|
+
# class Pack < ApplicationRecord
|
|
35
|
+
# include Redis::Objects
|
|
36
|
+
# include Redis::Objects::Preloadable
|
|
37
|
+
#
|
|
38
|
+
# counter :cache_total_count
|
|
39
|
+
# list :recent_item_ids
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# # Scope-based (top-level queries)
|
|
43
|
+
# Pack.redis_preload(:cache_total_count, :recent_item_ids).limit(100).each do |pack|
|
|
44
|
+
# pack.cache_total_count.value # preloaded via MGET
|
|
45
|
+
# pack.recent_item_ids.values # preloaded via pipeline
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# == Association-loaded records
|
|
49
|
+
#
|
|
50
|
+
# users = User.includes(:articles).load
|
|
51
|
+
# articles = users.flat_map(&:articles)
|
|
52
|
+
# Redis::Objects::Preloadable.preload(articles, :view_count)
|
|
53
|
+
#
|
|
24
54
|
class Redis
|
|
25
55
|
module Objects
|
|
26
56
|
module Preloadable
|
|
@@ -28,6 +58,23 @@ class Redis
|
|
|
28
58
|
base.include(ModelExtension)
|
|
29
59
|
end
|
|
30
60
|
|
|
61
|
+
# Batch-preload Redis::Objects attributes on an array of records.
|
|
62
|
+
#
|
|
63
|
+
# Use this for records loaded outside of +redis_preload+ scope,
|
|
64
|
+
# such as association-loaded records via +includes+.
|
|
65
|
+
#
|
|
66
|
+
# Preloading is lazy: no Redis calls are made until the first
|
|
67
|
+
# attribute access on any of the records.
|
|
68
|
+
#
|
|
69
|
+
# @param records [Array<ActiveRecord::Base>, ActiveRecord::Relation] records to preload
|
|
70
|
+
# @param names [Array<Symbol>] redis-objects attribute names to preload
|
|
71
|
+
# @return [Array<ActiveRecord::Base>] the same records array
|
|
72
|
+
#
|
|
73
|
+
# @example Preload on association-loaded records
|
|
74
|
+
# users = User.includes(:articles).load
|
|
75
|
+
# articles = users.flat_map(&:articles)
|
|
76
|
+
# Redis::Objects::Preloadable.preload(articles, :view_count, :cached_summary)
|
|
77
|
+
#
|
|
31
78
|
def self.preload(records, *names)
|
|
32
79
|
records = records.to_a
|
|
33
80
|
return records if records.empty? || names.empty?
|