From 9de17ac4a47cd3a6252dc0e9672805fb49ea5d03 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 3 Apr 2021 18:30:32 +0200 Subject: [PATCH] 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. --- activesupport/CHANGELOG.md | 8 ++++++ activesupport/lib/active_support/cache.rb | 18 ++++++++---- .../cache/behaviors/cache_store_behavior.rb | 28 ++++++++++++++++++- activesupport/test/cache/cache_entry_test.rb | 8 +++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 15fa176922..24d7cb7f59 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -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* diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb index 712b79799b..9669319d16 100644 --- a/activesupport/lib/active_support/cache.rb +++ b/activesupport/lib/active_support/cache.rb @@ -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 :expires_at 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 :version verifies the cache stored under name # 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 diff --git a/activesupport/test/cache/behaviors/cache_store_behavior.rb b/activesupport/test/cache/behaviors/cache_store_behavior.rb index 429d5e106f..dadb11c828 100644 --- a/activesupport/test/cache/behaviors/cache_store_behavior.rb +++ b/activesupport/test/cache/behaviors/cache_store_behavior.rb @@ -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 diff --git a/activesupport/test/cache/cache_entry_test.rb b/activesupport/test/cache/cache_entry_test.rb index b7497afd8e..6c3b69b3b8 100644 --- a/activesupport/test/cache/cache_entry_test.rb +++ b/activesupport/test/cache/cache_entry_test.rb @@ -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