Allow to set cache expiry as an absolute timestamp

Sometime it can be useful to set a cache entry expiry
not relative to current time, but as an absolute timestamps,
e.g.:

  - If you want to cache an API token that was provided to
    you with a precise expiry time.
  - If you want to cache something until a precise cutoff
    time, e.g. `expires_at: Time.now.at_end_of_hour`

This leaves the `@created_at` variable in a weird state,
but this is to avoid breaking the binary format.
This commit is contained in:
Jean Boussier 2021-04-03 18:30:32 +02:00
parent 0ff395e1b1
commit 9de17ac4a4
4 changed files with 55 additions and 7 deletions

View File

@ -1,3 +1,11 @@
* Add `expires_at` argument to `ActiveSupport::Cache` `write` and `fetch` to set a cache entry TTL as an absolute time.
```ruby
Rails.cache.write(key, value, expires_at: Time.now.at_end_of_hour)
```
*Jean Boussier*
* Deprecate `ActiveSupport::TimeWithZone.name` so that from Rails 7.1 it will use the default implementation.
*Andrew White*

View File

@ -267,6 +267,14 @@ module ActiveSupport
# cache = ActiveSupport::Cache::MemoryStore.new(expires_in: 5.minutes)
# cache.write(key, value, expires_in: 1.minute) # Set a lower value for one entry
#
# Setting <tt>:expires_at</tt> will set an absolute expiration time on the cache.
# All caches support auto-expiring content after a specified number of
# seconds. This value can only be supplied to the +fetch+ or +write+ method to
# affect just one entry.
#
# cache = ActiveSupport::Cache::MemoryStore.new
# cache.write(key, value, expires_at: Time.now.at_end_of_hour)
#
# Setting <tt>:version</tt> verifies the cache stored under <tt>name</tt>
# is of the same version. nil is returned on mismatches despite contents.
# This feature is used to support recyclable cache keys.
@ -751,7 +759,7 @@ module ActiveSupport
if (race_ttl > 0) && (Time.now.to_f - entry.expires_at <= race_ttl)
# When an entry has a positive :race_condition_ttl defined, put the stale entry back into the cache
# for a brief period while the entry is being recalculated.
entry.expires_at = Time.now + race_ttl
entry.expires_at = Time.now.to_f + race_ttl
write_entry(key, entry, expires_in: race_ttl * 2)
else
delete_entry(key, **options)
@ -801,12 +809,12 @@ module ActiveSupport
DEFAULT_COMPRESS_LIMIT = 1.kilobyte
# Creates a new cache entry for the specified value. Options supported are
# +:compress+, +:compress_threshold+, +:version+ and +:expires_in+.
def initialize(value, compress: true, compress_threshold: DEFAULT_COMPRESS_LIMIT, version: nil, expires_in: nil, **)
# +:compress+, +:compress_threshold+, +:version+, +:expires_at+ and +:expires_in+.
def initialize(value, compress: true, compress_threshold: DEFAULT_COMPRESS_LIMIT, version: nil, expires_in: nil, expires_at: nil, **)
@value = value
@version = version
@created_at = Time.now.to_f
@expires_in = expires_in && expires_in.to_f
@created_at = 0.0
@expires_in = expires_at&.to_f || expires_in && (expires_in.to_f + Time.now.to_f)
compress!(compress_threshold) if compress
end

View File

@ -393,16 +393,42 @@ module CacheStoreBehavior
time = Time.local(2008, 4, 24)
Time.stub(:now, time) do
@cache.write("foo", "bar")
@cache.write("foo", "bar", expires_in: 1.minute)
@cache.write("egg", "spam", expires_in: 2.minute)
assert_equal "bar", @cache.read("foo")
assert_equal "spam", @cache.read("egg")
end
Time.stub(:now, time + 30) do
assert_equal "bar", @cache.read("foo")
assert_equal "spam", @cache.read("egg")
end
Time.stub(:now, time + 61) do
assert_nil @cache.read("foo")
assert_equal "spam", @cache.read("egg")
end
Time.stub(:now, time + 121) do
assert_nil @cache.read("foo")
assert_nil @cache.read("egg")
end
end
def test_expires_at
time = Time.local(2008, 4, 24)
Time.stub(:now, time) do
@cache.write("foo", "bar", expires_at: time + 15.seconds)
assert_equal "bar", @cache.read("foo")
end
Time.stub(:now, time + 10) do
assert_equal "bar", @cache.read("foo")
end
Time.stub(:now, time + 30) do
assert_nil @cache.read("foo")
end
end

View File

@ -9,8 +9,14 @@ class CacheEntryTest < ActiveSupport::TestCase
assert_not entry.expired?, "entry not expired"
entry = ActiveSupport::Cache::Entry.new("value", expires_in: 60)
assert_not entry.expired?, "entry not expired"
Time.stub(:now, Time.now + 61) do
Time.stub(:now, Time.at(entry.expires_at + 1)) do
assert entry.expired?, "entry is expired"
end
end
def test_initialize_with_expires_at
entry = ActiveSupport::Cache::Entry.new("value", expires_in: 60)
clone = ActiveSupport::Cache::Entry.new("value", expires_at: entry.expires_at)
assert_equal entry.expires_at, clone.expires_at
end
end