low_card_tables 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +59 -0
- data/Gemfile +17 -0
- data/LICENSE +21 -0
- data/README.md +75 -0
- data/Rakefile +6 -0
- data/lib/low_card_tables.rb +72 -0
- data/lib/low_card_tables/active_record/base.rb +55 -0
- data/lib/low_card_tables/active_record/migrations.rb +223 -0
- data/lib/low_card_tables/active_record/relation.rb +35 -0
- data/lib/low_card_tables/active_record/scoping.rb +87 -0
- data/lib/low_card_tables/errors.rb +74 -0
- data/lib/low_card_tables/has_low_card_table/base.rb +114 -0
- data/lib/low_card_tables/has_low_card_table/low_card_association.rb +273 -0
- data/lib/low_card_tables/has_low_card_table/low_card_associations_manager.rb +143 -0
- data/lib/low_card_tables/has_low_card_table/low_card_dynamic_method_manager.rb +224 -0
- data/lib/low_card_tables/has_low_card_table/low_card_objects_manager.rb +80 -0
- data/lib/low_card_tables/low_card_table/base.rb +184 -0
- data/lib/low_card_tables/low_card_table/cache.rb +214 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/exponential_cache_expiration_policy.rb +151 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/fixed_cache_expiration_policy.rb +23 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/has_cache_expiration.rb +100 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/no_caching_expiration_policy.rb +13 -0
- data/lib/low_card_tables/low_card_table/cache_expiration/unlimited_cache_expiration_policy.rb +13 -0
- data/lib/low_card_tables/low_card_table/row_collapser.rb +175 -0
- data/lib/low_card_tables/low_card_table/row_manager.rb +681 -0
- data/lib/low_card_tables/low_card_table/table_unique_index.rb +134 -0
- data/lib/low_card_tables/version.rb +4 -0
- data/lib/low_card_tables/version_support.rb +52 -0
- data/low_card_tables.gemspec +69 -0
- data/spec/low_card_tables/helpers/database_helper.rb +148 -0
- data/spec/low_card_tables/helpers/query_spy_helper.rb +47 -0
- data/spec/low_card_tables/helpers/system_helpers.rb +63 -0
- data/spec/low_card_tables/system/basic_system_spec.rb +254 -0
- data/spec/low_card_tables/system/bulk_system_spec.rb +334 -0
- data/spec/low_card_tables/system/caching_system_spec.rb +531 -0
- data/spec/low_card_tables/system/migrations_system_spec.rb +747 -0
- data/spec/low_card_tables/system/options_system_spec.rb +581 -0
- data/spec/low_card_tables/system/queries_system_spec.rb +142 -0
- data/spec/low_card_tables/system/validations_system_spec.rb +88 -0
- data/spec/low_card_tables/unit/active_record/base_spec.rb +53 -0
- data/spec/low_card_tables/unit/active_record/migrations_spec.rb +207 -0
- data/spec/low_card_tables/unit/active_record/relation_spec.rb +47 -0
- data/spec/low_card_tables/unit/active_record/scoping_spec.rb +101 -0
- data/spec/low_card_tables/unit/has_low_card_table/base_spec.rb +79 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_association_spec.rb +287 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_associations_manager_spec.rb +190 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_dynamic_method_manager_spec.rb +234 -0
- data/spec/low_card_tables/unit/has_low_card_table/low_card_objects_manager_spec.rb +70 -0
- data/spec/low_card_tables/unit/low_card_table/base_spec.rb +207 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/exponential_cache_expiration_policy_spec.rb +128 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/fixed_cache_expiration_policy_spec.rb +25 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/has_cache_expiration_policy_spec.rb +100 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/no_caching_expiration_policy_spec.rb +14 -0
- data/spec/low_card_tables/unit/low_card_table/cache_expiration/unlimited_cache_expiration_policy_spec.rb +14 -0
- data/spec/low_card_tables/unit/low_card_table/cache_spec.rb +282 -0
- data/spec/low_card_tables/unit/low_card_table/row_collapser_spec.rb +109 -0
- data/spec/low_card_tables/unit/low_card_table/row_manager_spec.rb +918 -0
- data/spec/low_card_tables/unit/low_card_table/table_unique_index_spec.rb +117 -0
- metadata +206 -0
@@ -0,0 +1,214 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module LowCardTable
|
3
|
+
# This class is responsible for caching all the rows for a given low-card table, and then returning various subsets
|
4
|
+
# of those rows on demand.
|
5
|
+
#
|
6
|
+
# This class is actually pretty simple, and it's largely for one big reason: our cache is very simple -- we cache
|
7
|
+
# the entire contents of the table, in memory, and the only way we update it is to throw out the entire cache and
|
8
|
+
# create a brand-new one. As such, from the inside, this class has no mechanisms whatsoever for updating the cache
|
9
|
+
# (as this object is thrown away and a whole new Cache object created when the cache is invalidated) nor are there
|
10
|
+
# any methods for worrying about what should be in cache, doing a LRU, or anything like that.
|
11
|
+
class Cache
|
12
|
+
# Creates a new Cache for the given +model_class+, which must be an ActiveRecord::Base subclass that has declared
|
13
|
+
# +is_low_card_table+.
|
14
|
+
#
|
15
|
+
# +options+ can contain:
|
16
|
+
#
|
17
|
+
# [:max_row_count] By default, the cache will raise a fatal error if trying to cache a low-card table that contains
|
18
|
+
# more than DEFAULT_MAX_ROW_COUNT (5,000) rows. This is provided so that we detect early on if you
|
19
|
+
# start storing data via the low-card system that is not, in fact, of low cardinality. However,
|
20
|
+
# if you really are using a low-card table properly and just happen to have more than 5,000
|
21
|
+
# distinct combinations of values, you can increase this limit via this option.
|
22
|
+
def initialize(model_class, options = { })
|
23
|
+
unless model_class.respond_to?(:is_low_card_table?) && model_class.is_low_card_table?
|
24
|
+
raise ArgumentError, "You must supply a class that is a model class for a low-card table, not #{model_class.inspect}."
|
25
|
+
end
|
26
|
+
|
27
|
+
@model_class = model_class
|
28
|
+
@options = options
|
29
|
+
|
30
|
+
fill!
|
31
|
+
end
|
32
|
+
|
33
|
+
# At what time was this cache loaded? This is used (in conjunction with a cache-expiration policy object) to
|
34
|
+
# determine if the cache is stale or not.
|
35
|
+
def loaded_at
|
36
|
+
@rows_read_at
|
37
|
+
end
|
38
|
+
|
39
|
+
# This behaves identically to #rows_matching, except that, everywhere a low-card model object would be returned,
|
40
|
+
# a simple integer ID is returned instead.
|
41
|
+
def ids_matching(hash_or_hashes = nil, &block)
|
42
|
+
matching = rows_matching(hash_or_hashes, &block)
|
43
|
+
|
44
|
+
out = case matching
|
45
|
+
when Array then matching.map(&:id)
|
46
|
+
when Hash then
|
47
|
+
h = { }
|
48
|
+
matching.each { |k,v| h[k] = v.map(&:id) }
|
49
|
+
h
|
50
|
+
when nil then nil
|
51
|
+
else raise "Unknown return value from #rows_matching; this should never happen: #{matching.inspect}"
|
52
|
+
end
|
53
|
+
|
54
|
+
out
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns an Array of all models of the low-card table in the cache. The order is undefined.
|
58
|
+
def all_rows
|
59
|
+
@rows_by_id.values
|
60
|
+
end
|
61
|
+
|
62
|
+
# Given a single numeric ID of a low-card row, returns that low-card model object.
|
63
|
+
#
|
64
|
+
# Given an Array of one or more IDs of low-card rows, returns a Hash mapping each of those IDs to the
|
65
|
+
# corresponding low-card model object.
|
66
|
+
#
|
67
|
+
# Raises LowCardTables::Errors::LowCardIdNotFoundError if any of the supplied IDs are not found in the cache.
|
68
|
+
# (It is up to calling code -- in our case, the RowManager -- to flush the cache and try again, if desired.)
|
69
|
+
def rows_for_ids(id_or_ids)
|
70
|
+
ids = Array(id_or_ids)
|
71
|
+
|
72
|
+
missing_ids = [ ]
|
73
|
+
out = { }
|
74
|
+
|
75
|
+
ids.each do |id|
|
76
|
+
r = @rows_by_id[id]
|
77
|
+
if r
|
78
|
+
out[id] = r
|
79
|
+
else
|
80
|
+
missing_ids << id
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
unless missing_ids.length == 0
|
85
|
+
raise LowCardTables::Errors::LowCardIdNotFoundError.new("Can't find IDs for low-card table #{@model_class.table_name}: #{missing_ids.join(", ")}", missing_ids)
|
86
|
+
end
|
87
|
+
|
88
|
+
if id_or_ids.kind_of?(Array)
|
89
|
+
out
|
90
|
+
else
|
91
|
+
out[id_or_ids]
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Returns a subset of rows in the cache that match a specified set of conditions. (This can be all rows or no
|
96
|
+
# rows, depending on the conditions, or any set in between.)
|
97
|
+
#
|
98
|
+
# You can specify conditions in the following ways:
|
99
|
+
#
|
100
|
+
# * A single Hash, passed as an argument. The return value will be an Array that contains zero or more instances
|
101
|
+
# of the low-card model class. Only instances that have columns matching the specified Hash will be returned.
|
102
|
+
# * An array of one or more Hashes, passed as an argument. The return value will be a Hash; as keys, it will have
|
103
|
+
# exactly the Hashes you passed in. The value for each key will be an array of zero or more instances of the
|
104
|
+
# low-card model class, each of which matches the corresponding key.
|
105
|
+
# * A block, passed to the method as a normal Ruby block. The return value will be an Array that contains zero
|
106
|
+
# or more instances of the low-card model class. The block will be invoked with every instance of the low-card
|
107
|
+
# model class, and only those instances where the block returns a value that evaluates to true will be included.
|
108
|
+
#
|
109
|
+
# In the form where you pass in an array of one or more Hashes, it is possible for the same low-card row to show
|
110
|
+
# up in multiple values in the returned Hash, if it matches more than one of the supplied Hashes.
|
111
|
+
#
|
112
|
+
# Note that all matching is done via LowCardTables::LowCardTable::Base#_low_card_row_matches_any_hash?. See that
|
113
|
+
# method for more documentation -- by overriding it, you can change the behavior of this method.
|
114
|
+
def rows_matching(hash_or_hashes = nil, &block)
|
115
|
+
hashes = hash_or_hashes || [ ]
|
116
|
+
hashes = [ hashes ] unless hashes.kind_of?(Array)
|
117
|
+
hashes.each { |h| raise ArgumentError, "You must supply Hashes, not: #{h.inspect}" unless h.kind_of?(Hash) }
|
118
|
+
|
119
|
+
if block && hashes.length > 0
|
120
|
+
raise ArgumentError, "You can supply either one or more Hashes to match against OR a block, but not both. Hashes: #{hashes.inspect}; block: #{block.inspect}"
|
121
|
+
elsif (! block) && hashes.length == 0
|
122
|
+
raise ArgumentError, "You must supply either one or more Hashes to match against or a block; you supplied neither."
|
123
|
+
end
|
124
|
+
|
125
|
+
|
126
|
+
if hashes.length > 0
|
127
|
+
out = { }
|
128
|
+
|
129
|
+
hashes.each { |h| out[h] = [ ] }
|
130
|
+
|
131
|
+
@rows_by_id.each do |id,r|
|
132
|
+
hashes.each do |h|
|
133
|
+
out[h] << r if r._low_card_row_matches_any_hash?([ h.with_indifferent_access ])
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
if hash_or_hashes.kind_of?(Array)
|
138
|
+
out
|
139
|
+
else
|
140
|
+
out[hash_or_hashes]
|
141
|
+
end
|
142
|
+
else
|
143
|
+
@rows_by_id.values.select { |r| r._low_card_row_matches_block?(block) }
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
private
|
148
|
+
# Fills the cache. This can only ever be called once.
|
149
|
+
def fill!
|
150
|
+
# Explode if we don't have a unique index on the underlying table -- because then we have no way of
|
151
|
+
# guaranteeing that we won't create duplicate rows. (I am of the opinion that you'll never *truly* be certain
|
152
|
+
# that your data obeys rules unless they're in the database, as constraints; application code can be as
|
153
|
+
# careful as it wants to be, but, somehow, things always slip by unless you define constraints at the
|
154
|
+
# database layer.)
|
155
|
+
@model_class.low_card_ensure_has_unique_index!
|
156
|
+
|
157
|
+
raise "Cannot fill: we already have values!" if @rows_by_id
|
158
|
+
|
159
|
+
# We grab the time before we have fired off the query; this makes sure we don't create a race condition where
|
160
|
+
# we think the cache is just a tiny bit newer than it actually is.
|
161
|
+
read_rows_time = current_time
|
162
|
+
|
163
|
+
# We ask for one more than the number of rows we are willing to accept here; this is so that if we have
|
164
|
+
# too many rows, we can detect it, but we still won't do something crazy like try to load one million
|
165
|
+
# rows into memory.
|
166
|
+
raw_rows = @model_class.order("#{@model_class.primary_key} ASC").limit(max_row_count + 1).to_a
|
167
|
+
raise_too_many_rows_error if raw_rows.length > max_row_count
|
168
|
+
|
169
|
+
# Yes, we should never have duplicate IDs here -- it should be a primary key. But if it isn't for some reason,
|
170
|
+
# we want to explode right away, rather than failing in strange, unpredicatable, and awful ways later.
|
171
|
+
out = { }
|
172
|
+
raw_rows.each do |raw_row|
|
173
|
+
id = raw_row.id
|
174
|
+
raise_duplicate_id_error(id, out[id], raw_row) if out[id]
|
175
|
+
out[id] = raw_row
|
176
|
+
end
|
177
|
+
|
178
|
+
@rows_by_id = out
|
179
|
+
@rows_read_at = read_rows_time
|
180
|
+
end
|
181
|
+
|
182
|
+
# This is broken out into a separate method merely for ease of testing.
|
183
|
+
def current_time
|
184
|
+
Time.now
|
185
|
+
end
|
186
|
+
|
187
|
+
DEFAULT_MAX_ROW_COUNT = 5_000
|
188
|
+
|
189
|
+
def max_row_count
|
190
|
+
@options[:max_row_count] || DEFAULT_MAX_ROW_COUNT
|
191
|
+
end
|
192
|
+
|
193
|
+
def raise_too_many_rows_error
|
194
|
+
raise LowCardTables::Errors::LowCardTooManyRowsError, %{We tried to read in all the rows for low-card table '#{@model_class.table_name}', but there were
|
195
|
+
more rows that we are willing to handle -- there are at least #{max_row_count + 1}.
|
196
|
+
Most likely, something has gone horribly wrong with your low-card table (such as you
|
197
|
+
starting to store data that is not, in fact, low-cardinality at all). Alternatively,
|
198
|
+
perhaps you need to declare :max_row_count => (some larger value) in your
|
199
|
+
is_low_card_table declaration.}
|
200
|
+
end
|
201
|
+
|
202
|
+
def raise_duplicate_id_error(id, row_one, row_two)
|
203
|
+
raise %{Duplicate ID in low-card table for class #{@model_class}!
|
204
|
+
We have at least two rows with ID #{id.inspect}:
|
205
|
+
|
206
|
+
#{row_one}
|
207
|
+
|
208
|
+
and
|
209
|
+
|
210
|
+
#{row_two}}
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
data/lib/low_card_tables/low_card_table/cache_expiration/exponential_cache_expiration_policy.rb
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'active_support/time'
|
2
|
+
|
3
|
+
module LowCardTables
|
4
|
+
module LowCardTable
|
5
|
+
module CacheExpiration
|
6
|
+
# If you don't understand the low-card cache and why cache-expiration policies are interesting and important,
|
7
|
+
# please read the Github Wiki page at https://github.com/ageweke/low_card_tables/wiki/Caching for more
|
8
|
+
# background first.
|
9
|
+
#
|
10
|
+
# An ExponentialCacheExpirationPolicy is by far the most sophisticated cache-expiration policy allowed. It breaks
|
11
|
+
# apart the cache-expiration time into three separate regions. In order, they are:
|
12
|
+
#
|
13
|
+
# * <b>Zero Floor</b>, an intitial period of time (which can be zero in length) during which the cache expires
|
14
|
+
# immediately -- effectively meaning there is no cache;
|
15
|
+
# * <b>Exponential Increase</b>, where the cache-expiration time starts at a given minimum, and increases by
|
16
|
+
# some geometric factor at each expiration thereafter;
|
17
|
+
# * <b>Maximum</b>, where the cache-expiration time tops out at a particular value.
|
18
|
+
#
|
19
|
+
# The idea is that, in any system with a significant amount of production traffic, the stable state has basically
|
20
|
+
# no new low-cardinality values being created at all -- any combination that can be created, will have already
|
21
|
+
# been created. (This is true for a very large number of systems; but, of course, it all depends on _your_
|
22
|
+
# particular system. It's possible you have a very "long-tailed" set of low-card values.)
|
23
|
+
#
|
24
|
+
# As such, it's safe to cache low-card tables for very long periods of time in the steady state. However, after
|
25
|
+
# a deploy that introduces code that can create never-before-seen combinations of low-card values, there will be
|
26
|
+
# new values created relatively rapidly, with the creation rate tapering off over time until we reach a steady
|
27
|
+
# state where no new values are created at all. This fits exactly the model of the
|
28
|
+
# ExponentialCacheExpirationPolicy.
|
29
|
+
#
|
30
|
+
# A word about measured timings:
|
31
|
+
#
|
32
|
+
# Measured timings are completely deterministic and do not depend on when the cache is actually accessed. That is,
|
33
|
+
# one way of implementing this class would be to only check and advance what period we're in when someone calls
|
34
|
+
# #stale? on it. However, this would mean that thinking about how the class works is very difficult: what time
|
35
|
+
# period we're in depends on how often someone has asked us whether we're stale or not.
|
36
|
+
#
|
37
|
+
# Instead, the start time of this object (that is, the time when the +:zero_floor+ begins) is passed in to the
|
38
|
+
# constructor, as +:start_time+. All time periods involved start from this point and are measured back-to-back --
|
39
|
+
# that is, the first exponential time period begins immediately upon completion of the +:zero_floor+ period, the
|
40
|
+
# next one immediately after that, and so on. (No, this doesn't happen in real time; there's no thread waiting
|
41
|
+
# around just to update this object. Rather, when needed, we determine which period we're in on-demand.)
|
42
|
+
class ExponentialCacheExpirationPolicy
|
43
|
+
# Creates a new instance. +options+ must be a Hash that can must contain:
|
44
|
+
#
|
45
|
+
# * +:start_time+: The time at which this caching policy should start -- _i.e._, the start time for the
|
46
|
+
# zero floor. This must be a Time object.
|
47
|
+
#
|
48
|
+
# ...and can contain any of the following:
|
49
|
+
#
|
50
|
+
# * +:zero_floor_time+: The amount of time at the start that the cache will not cache anything; default is
|
51
|
+
# three minutes.
|
52
|
+
# * +:min_time+: Once the zero floor has completed, the initial period during which the cache will be valid;
|
53
|
+
# default is ten seconds.
|
54
|
+
# * +:exponent+: Once the initial +:min_time+ period has passed, subsequent periods will each geometrically
|
55
|
+
# increase by this exponent. (For example, if :+min_time+ is 3.0, and +:exponent+ is 1.5, then the first
|
56
|
+
# period will be 3.0 seconds; the second will be 3.0 * 1.5 = 4.5 seconds; the third will be 4.5 * 1.5 = 6.75
|
57
|
+
# seconds; and so on.) Default is 2.0, meaning the validity time doubles.
|
58
|
+
# * +:max_time+: Once the cache validity period reaches +:max_time+ seconds, it is pinned at this value, and
|
59
|
+
# will not increase further. Default is one hour.
|
60
|
+
def initialize(options)
|
61
|
+
options.assert_valid_keys(:zero_floor_time, :min_time, :exponent, :max_time, :start_time)
|
62
|
+
|
63
|
+
@start_time = options[:start_time]
|
64
|
+
raise ArgumentError, "start_time cannot be #{@start_time.inspect}" unless @start_time && @start_time.kind_of?(::Time)
|
65
|
+
|
66
|
+
@zero_floor = options[:zero_floor_time] || 3.minutes
|
67
|
+
@min_time = options[:min_time] || 10.seconds
|
68
|
+
@exponent = options[:exponent] || 2.0
|
69
|
+
@max_time = options[:max_time] || 1.hour
|
70
|
+
|
71
|
+
raise ArgumentError, "zero_floor_time cannot be #{@zero_floor.inspect}" unless @zero_floor.kind_of?(Numeric) && @zero_floor >= 0.0
|
72
|
+
raise ArgumentError, "min_time cannot be #{@min_time.inspect}" unless @min_time.kind_of?(Numeric) && @min_time > 1.0
|
73
|
+
raise ArgumentError, "exponent cannoot be #{@exponent.inspect}" unless @exponent.kind_of?(Numeric) && @exponent > 1.0
|
74
|
+
raise ArgumentError, "max_time cannot be #{@max_time.inspect}" unless @max_time.kind_of?(Numeric) && @max_time > 0.0 && @max_time > @min_time
|
75
|
+
|
76
|
+
# @segment_start_time is the time at which the current segment started.
|
77
|
+
# @segment_end_time is the time at which the current segment ends.
|
78
|
+
# @segment_expiration_time is the time at which the current cache will expire (absolute time, not relative);
|
79
|
+
# typically this will be the same as @segment_end_time, but it's different (zero) during the initial
|
80
|
+
# @min_time period.
|
81
|
+
@segment_start_time = @start_time
|
82
|
+
|
83
|
+
if @zero_floor > 0
|
84
|
+
@segment_end_time = @segment_start_time + @zero_floor
|
85
|
+
@segment_expiration_time = 0
|
86
|
+
else
|
87
|
+
@segment_end_time = @segment_start_time + @min_time
|
88
|
+
@segment_expiration_time = @min_time
|
89
|
+
end
|
90
|
+
|
91
|
+
# Let's just make sure the clock doesn't run backwards, shall we?
|
92
|
+
@last_seen_time = @start_time
|
93
|
+
end
|
94
|
+
|
95
|
+
attr_reader :zero_floor, :min_time, :exponent, :max_time
|
96
|
+
|
97
|
+
# Called by LowCardTables::LowCardTable::Cache; this indicates whether the cache is stale or not. In order to
|
98
|
+
# make testing easier, the time at which the cache was read (+cache_time+) and the current time (+current_time+)
|
99
|
+
# are passed in, rather than implied using +Time.now+.
|
100
|
+
#
|
101
|
+
# It is an error to call this method with a +current_time+ that is before a previously-seen +current_time+.
|
102
|
+
# (In other words, no, the clock can't run backwards.)
|
103
|
+
def stale?(cache_time, current_time)
|
104
|
+
if current_time < @last_seen_time
|
105
|
+
raise ArgumentError, "Our clock is running backwards?!? We have previously seen a time of #{@last_seen_time.to_f}, and now it's #{current_time.to_f}?"
|
106
|
+
elsif current_time > @last_seen_time
|
107
|
+
@last_seen_time = current_time
|
108
|
+
end
|
109
|
+
|
110
|
+
next_segment! until within_current_segment?(current_time)
|
111
|
+
|
112
|
+
out = if cache_time < @segment_start_time
|
113
|
+
true
|
114
|
+
else
|
115
|
+
(current_time - cache_time) >= @segment_expiration_time
|
116
|
+
end
|
117
|
+
|
118
|
+
out
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
attr_reader :start_time
|
123
|
+
|
124
|
+
# Are we within the current segment, according to @segment_start_time and @segment_end_time?
|
125
|
+
def within_current_segment?(cache_time)
|
126
|
+
cache_time < @segment_end_time
|
127
|
+
end
|
128
|
+
|
129
|
+
# Advances @segment_start_time, @segment_end_time, and @segment_expiration_time to the next segment.
|
130
|
+
def next_segment!
|
131
|
+
if @segment_expiration_time == 0
|
132
|
+
# We're in the initial @min_time segment -- advance to the first exponential.
|
133
|
+
@segment_start_time += @zero_floor
|
134
|
+
@segment_end_time = @segment_start_time + @min_time
|
135
|
+
@segment_expiration_time = @min_time
|
136
|
+
elsif @segment_expiration_time >= @max_time
|
137
|
+
# We're in the final @max_time -- simply create another period of that duration.
|
138
|
+
@segment_start_time = @segment_end_time
|
139
|
+
@segment_end_time = @segment_start_time + @max_time
|
140
|
+
@segment_expiration_time = @max_time
|
141
|
+
else
|
142
|
+
# We're in the exponential-increase period -- move to the next, longer, period.
|
143
|
+
@segment_start_time = @segment_end_time
|
144
|
+
@segment_expiration_time = [ @segment_expiration_time * @exponent, @max_time ].min
|
145
|
+
@segment_end_time = @segment_start_time + @segment_expiration_time
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module LowCardTables
|
2
|
+
module LowCardTable
|
3
|
+
module CacheExpiration
|
4
|
+
# A FixedCacheExpirationPolicy is a very simple kind of cache-expiration policy: the cache expires a certain
|
5
|
+
# amount of time after it is filled, every time.
|
6
|
+
class FixedCacheExpirationPolicy
|
7
|
+
def initialize(expiration_time)
|
8
|
+
unless expiration_time && expiration_time.kind_of?(Numeric) && expiration_time >= 0.0
|
9
|
+
raise ArgumentError, "Expiration time must be a nonnegative number, not: #{expiration_time.inspect}"
|
10
|
+
end
|
11
|
+
|
12
|
+
@expiration_time = expiration_time
|
13
|
+
end
|
14
|
+
|
15
|
+
def stale?(cache_time, current_time)
|
16
|
+
(current_time - cache_time) >= @expiration_time
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :expiration_time
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'exponential_cache_expiration_policy')
|
2
|
+
require File.join(File.dirname(__FILE__), 'fixed_cache_expiration_policy')
|
3
|
+
require File.join(File.dirname(__FILE__), 'unlimited_cache_expiration_policy')
|
4
|
+
require File.join(File.dirname(__FILE__), 'no_caching_expiration_policy')
|
5
|
+
|
6
|
+
module LowCardTables
|
7
|
+
module LowCardTable
|
8
|
+
module CacheExpiration
|
9
|
+
# This is a module that gets mixed in to LowCardTables::LowCardTable::Base. It provides the class it gets mixed
|
10
|
+
# into with a #low_card_cache_expiration method that can be called with various values (a number, zero, +:unlimited+,
|
11
|
+
# or +:exponential+ with options) to set the cache-expiration policy, or called with no arguments to return the
|
12
|
+
# cache-expiration policy as one of those same values.
|
13
|
+
#
|
14
|
+
# The value is stored on a class-by-class basis, and is inherited. This provides the behavior we want: if you set
|
15
|
+
# a policy on LowCardTables::LowCardTable::Base, it will be applied to all low-card tables; if you set a policy
|
16
|
+
# on an individual table, it will apply to only that table and will override any policy applied to the base.
|
17
|
+
module HasCacheExpiration
|
18
|
+
extend ActiveSupport::Concern
|
19
|
+
|
20
|
+
module ClassMethods
|
21
|
+
# Sets the cache-expiration policy of this class. You can pass:
|
22
|
+
#
|
23
|
+
# * A positive number -- sets the cache-expiration policy to be that many seconds.
|
24
|
+
# * Zero -- turns off caching entirely.
|
25
|
+
# * +:unlimited+ -- makes the cache last forever.
|
26
|
+
# * :+exponential+, optionally with an options hash as the second argument -- sets the cache-expiration policy
|
27
|
+
# to be exponential; see ExponentialCacheExpirationPolicy for more details.
|
28
|
+
#
|
29
|
+
# If you pass no arguments at all, you're returned the current cache-expiration policy.
|
30
|
+
#
|
31
|
+
# If you do not call this method at all, the cache-expiration policy will be undefined. You should always either
|
32
|
+
# call this method, or set up an inheritance chain using #low_card_cache_policy_inherits_from, to a class
|
33
|
+
# that does have it defined.
|
34
|
+
def low_card_cache_expiration(type_or_number = nil, options = { })
|
35
|
+
if type_or_number.nil?
|
36
|
+
@_low_card_cache_expiration_return_value || low_card_cache_expiration_inherited
|
37
|
+
else
|
38
|
+
if type_or_number == 0
|
39
|
+
@_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::NoCachingExpirationPolicy.new
|
40
|
+
@_low_card_cache_expiration_return_value = 0
|
41
|
+
elsif type_or_number.kind_of?(Numeric)
|
42
|
+
@_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::FixedCacheExpirationPolicy.new(type_or_number)
|
43
|
+
@_low_card_cache_expiration_return_value = type_or_number
|
44
|
+
elsif type_or_number == :unlimited
|
45
|
+
@_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::UnlimitedCacheExpirationPolicy.new
|
46
|
+
@_low_card_cache_expiration_return_value = :unlimited
|
47
|
+
elsif type_or_number == :exponential
|
48
|
+
@_low_card_cache_expiration_policy_object = LowCardTables::LowCardTable::CacheExpiration::ExponentialCacheExpirationPolicy.new(options.merge(:start_time => low_card_current_time))
|
49
|
+
if options.size > 0
|
50
|
+
@_low_card_cache_expiration_return_value = [ :exponential, options ]
|
51
|
+
else
|
52
|
+
@_low_card_cache_expiration_return_value = :exponential
|
53
|
+
end
|
54
|
+
else
|
55
|
+
raise ArgumentError, "Invalid cache expiration time argumnet '#{type_or_number.inspect}'; you must pass a number, :unlimited, or :exponential."
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# For +low_card_tables+ internal use only. Returns the current cache-expiration policy object -- e.g., an
|
61
|
+
# instance of LowCardTables::LowCardTable::CacheExpiration::FixedCacheExpirationPolicy.
|
62
|
+
def low_card_cache_expiration_policy_object
|
63
|
+
@_low_card_cache_expiration_policy_object || low_card_cache_expiration_policy_object_inherited
|
64
|
+
end
|
65
|
+
|
66
|
+
# Declares that this class should inherit its cache-expiration policy from the specified other class, which
|
67
|
+
# must be a class that has had this module included in it (directly or indirectly) too. In other words, if
|
68
|
+
# you don't set a policy on this class, but have specified that it inherits its policy from the given other
|
69
|
+
# class, then it will use whatever policy the other class has set.
|
70
|
+
#
|
71
|
+
# We use this because low-card tables' classes all inherit directly from ::ActiveRecord::Base; they don't
|
72
|
+
# inherit from some parent "it's a low-card table" class. (This is because inheritance in ActiveRecord is
|
73
|
+
# used to denote data in common via STI, not what we're trying to do here.) So we use this to set up the
|
74
|
+
# default policy directly on the ::LowCardTables root module, and 'inherit' it into individual low-card tables
|
75
|
+
# (via the +included+ block on LowCardTables::LowCardTable::Base) this way.
|
76
|
+
def low_card_cache_policy_inherits_from(other_class)
|
77
|
+
@_low_card_cache_policy_inherits_from = other_class
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
# We break this into a separate method simply for ease of testing -- some of our specs override it.
|
82
|
+
def low_card_current_time
|
83
|
+
Time.now
|
84
|
+
end
|
85
|
+
|
86
|
+
# Returns the proper return value for #low_card_cache_expiration from the 'inherited' class, if there is one.
|
87
|
+
def low_card_cache_expiration_inherited
|
88
|
+
@_low_card_cache_policy_inherits_from.low_card_cache_expiration if @_low_card_cache_policy_inherits_from
|
89
|
+
end
|
90
|
+
|
91
|
+
# Returns the proper return value for #low_card_cache_expiration_policy_object from the 'inherited' class,
|
92
|
+
# if there is one.
|
93
|
+
def low_card_cache_expiration_policy_object_inherited
|
94
|
+
@_low_card_cache_policy_inherits_from.low_card_cache_expiration_policy_object if @_low_card_cache_policy_inherits_from
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|