diff --git a/README.md b/README.md index 66fcbf7..cc236c3 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,18 @@ limiter.poke # => SET limiter 0 NX + INCRBY limiter 1 false == limiter.exceeded? # => GET "limiter" ``` +Lists, unique lists, sets, ordered sets, and hashes support expiration: + +```ruby +set = Kredis.set "myset", expires_in: 1.second +set.add "hello", "world" # => SADD myset "hello" "world" +true == set.include?("hello") # => SISMEMBER myset "hello +sleep 2 +[] == set.members # => SMEMBERS myset +``` + +To support lower versions of redis, which does not has `nx` option on `EXPIRE` command, multiple commands are used to achieve the same effect. + ### Models You can use all these structures in models: @@ -189,6 +201,7 @@ class Person < ApplicationRecord kredis_unique_list :skills, limit: 2 kredis_enum :morning, values: %w[ bright blue black ], default: "bright" kredis_counter :steps, expires_in: 1.hour + kredis_set :favorite_colors, expires_in: 1.day private def generate_names_key diff --git a/lib/kredis.rb b/lib/kredis.rb index 28f0baf..03f3db7 100644 --- a/lib/kredis.rb +++ b/lib/kredis.rb @@ -11,6 +11,7 @@ require "kredis/namespace" require "kredis/type_casting" require "kredis/default_values" +require "kredis/expiration" require "kredis/types" require "kredis/attributes" diff --git a/lib/kredis/attributes.rb b/lib/kredis/attributes.rb index cd00319..8f75db4 100644 --- a/lib/kredis/attributes.rb +++ b/lib/kredis/attributes.rb @@ -44,20 +44,20 @@ def kredis_json(name, key: nil, default: nil, config: :shared, after_change: nil kredis_connection_with __method__, name, key, default: default, config: config, after_change: after_change, expires_in: expires_in end - def kredis_list(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) - kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change + def kredis_list(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change, expires_in: expires_in end - def kredis_unique_list(name, limit: nil, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) - kredis_connection_with __method__, name, key, default: default, limit: limit, typed: typed, config: config, after_change: after_change + def kredis_unique_list(name, limit: nil, key: nil, default: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + kredis_connection_with __method__, name, key, default: default, limit: limit, typed: typed, config: config, after_change: after_change, expires_in: expires_in end - def kredis_set(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) - kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change + def kredis_set(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change, expires_in: expires_in end - def kredis_ordered_set(name, limit: nil, default: nil, key: nil, typed: :string, config: :shared, after_change: nil) - kredis_connection_with __method__, name, key, default: default, limit: limit, typed: typed, config: config, after_change: after_change + def kredis_ordered_set(name, limit: nil, default: nil, key: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + kredis_connection_with __method__, name, key, default: default, limit: limit, typed: typed, config: config, after_change: after_change, expires_in: expires_in end def kredis_slot(name, key: nil, config: :shared, after_change: nil) @@ -76,8 +76,8 @@ def kredis_limiter(name, limit:, key: nil, config: :shared, after_change: nil, e kredis_connection_with __method__, name, key, limit: limit, config: config, after_change: after_change, expires_in: expires_in end - def kredis_hash(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil) - kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change + def kredis_hash(name, key: nil, default: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + kredis_connection_with __method__, name, key, default: default, typed: typed, config: config, after_change: after_change, expires_in: expires_in end def kredis_boolean(name, key: nil, default: nil, config: :shared, after_change: nil, expires_in: nil) diff --git a/lib/kredis/expiration.rb b/lib/kredis/expiration.rb new file mode 100644 index 0000000..3c98bda --- /dev/null +++ b/lib/kredis/expiration.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Kredis::Expiration + extend ActiveSupport::Concern + + included do + proxying :ttl, :expire + attr_accessor :expires_in + end + + private + def with_expiration(suppress: false, &block) + result = block.call + if !suppress && expires_in && ttl < 0 + expire expires_in.to_i + end + result + end +end diff --git a/lib/kredis/types.rb b/lib/kredis/types.rb index 91c8db6..1c8e855 100644 --- a/lib/kredis/types.rb +++ b/lib/kredis/types.rb @@ -57,24 +57,24 @@ def enum(key, values:, default:, config: :shared, after_change: nil) type_from(Enum, config, key, after_change: after_change, values: values, default: default) end - def hash(key, typed: :string, default: nil, config: :shared, after_change: nil) - type_from(Hash, config, key, after_change: after_change, default: default, typed: typed) + def hash(key, typed: :string, default: nil, config: :shared, after_change: nil, expires_in: nil) + type_from(Hash, config, key, after_change: after_change, default: default, typed: typed, expires_in: expires_in) end - def list(key, default: nil, typed: :string, config: :shared, after_change: nil) - type_from(List, config, key, after_change: after_change, default: default, typed: typed) + def list(key, default: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + type_from(List, config, key, after_change: after_change, default: default, typed: typed, expires_in: expires_in) end - def unique_list(key, default: nil, typed: :string, limit: nil, config: :shared, after_change: nil) - type_from(UniqueList, config, key, after_change: after_change, default: default, typed: typed, limit: limit) + def unique_list(key, default: nil, typed: :string, limit: nil, config: :shared, after_change: nil, expires_in: nil) + type_from(UniqueList, config, key, after_change: after_change, default: default, typed: typed, limit: limit, expires_in: expires_in) end - def set(key, default: nil, typed: :string, config: :shared, after_change: nil) - type_from(Set, config, key, after_change: after_change, default: default, typed: typed) + def set(key, default: nil, typed: :string, config: :shared, after_change: nil, expires_in: nil) + type_from(Set, config, key, after_change: after_change, default: default, typed: typed, expires_in: expires_in) end - def ordered_set(key, default: nil, typed: :string, limit: nil, config: :shared, after_change: nil) - type_from(OrderedSet, config, key, after_change: after_change, default: default, typed: typed, limit: limit) + def ordered_set(key, default: nil, typed: :string, limit: nil, config: :shared, after_change: nil, expires_in: nil) + type_from(OrderedSet, config, key, after_change: after_change, default: default, typed: typed, limit: limit, expires_in: expires_in) end def slot(key, config: :shared, after_change: nil) diff --git a/lib/kredis/types/hash.rb b/lib/kredis/types/hash.rb index b441a19..01d22ed 100644 --- a/lib/kredis/types/hash.rb +++ b/lib/kredis/types/hash.rb @@ -4,6 +4,7 @@ class Kredis::Types::Hash < Kredis::Types::Proxying prepend Kredis::DefaultValues + include Kredis::Expiration proxying :hget, :hset, :hmget, :hdel, :hgetall, :hkeys, :hvals, :del, :exists? @@ -18,7 +19,9 @@ def []=(key, value) end def update(**entries) - hset entries.transform_values { |val| type_to_string(val, typed) }.compact if entries.flatten.any? + with_expiration do + hset entries.transform_values { |val| type_to_string(val, typed) }.compact if entries.flatten.any? + end end def values_at(*keys) diff --git a/lib/kredis/types/list.rb b/lib/kredis/types/list.rb index 0c1e06f..77013de 100644 --- a/lib/kredis/types/list.rb +++ b/lib/kredis/types/list.rb @@ -2,6 +2,7 @@ class Kredis::Types::List < Kredis::Types::Proxying prepend Kredis::DefaultValues + include Kredis::Expiration proxying :lrange, :lrem, :lpush, :ltrim, :rpush, :exists?, :del @@ -16,12 +17,20 @@ def remove(*elements) types_to_strings(elements, typed).each { |element| lrem 0, element } end - def prepend(*elements) - lpush types_to_strings(elements, typed) if elements.flatten.any? + def prepend(*elements, suppress_expiration: false) + return if elements.flatten.empty? + + with_expiration(suppress: suppress_expiration) do + lpush types_to_strings(elements, typed) + end end - def append(*elements) - rpush types_to_strings(elements, typed) if elements.flatten.any? + def append(*elements, suppress_expiration: false) + return if elements.flatten.empty? + + with_expiration(suppress: suppress_expiration) do + rpush types_to_strings(elements, typed) + end end alias << append diff --git a/lib/kredis/types/ordered_set.rb b/lib/kredis/types/ordered_set.rb index 49e0a78..392432f 100644 --- a/lib/kredis/types/ordered_set.rb +++ b/lib/kredis/types/ordered_set.rb @@ -2,6 +2,7 @@ class Kredis::Types::OrderedSet < Kredis::Types::Proxying prepend Kredis::DefaultValues + include Kredis::Expiration proxying :multi, :zrange, :zrem, :zadd, :zremrangebyrank, :zcard, :exists?, :del, :zscore @@ -53,9 +54,11 @@ def insert(elements, prepending: false) [ score, element ] end - multi do - zadd(elements_with_scores) - trim(from_beginning: prepending) + with_expiration do + multi do + zadd(elements_with_scores) + trim(from_beginning: prepending) + end end end diff --git a/lib/kredis/types/set.rb b/lib/kredis/types/set.rb index 4a157b5..328957b 100644 --- a/lib/kredis/types/set.rb +++ b/lib/kredis/types/set.rb @@ -2,6 +2,7 @@ class Kredis::Types::Set < Kredis::Types::Proxying prepend Kredis::DefaultValues + include Kredis::Expiration proxying :smembers, :sadd, :srem, :multi, :del, :sismember, :scard, :spop, :exists?, :srandmember @@ -13,7 +14,11 @@ def members alias to_a members def add(*members) - sadd types_to_strings(members, typed) if members.flatten.any? + return unless members.flatten.any? + + with_expiration do + sadd types_to_strings(members, typed) + end end alias << add diff --git a/lib/kredis/types/unique_list.rb b/lib/kredis/types/unique_list.rb index bd73088..d18934a 100644 --- a/lib/kredis/types/unique_list.rb +++ b/lib/kredis/types/unique_list.rb @@ -3,6 +3,7 @@ # You'd normally call this a set, but Redis already has another data type for that class Kredis::Types::UniqueList < Kredis::Types::List proxying :multi, :ltrim, :exists? + include Kredis::Expiration attr_accessor :typed, :limit @@ -10,10 +11,12 @@ def prepend(elements) elements = Array(elements).uniq return if elements.empty? - multi do - remove elements - super - ltrim 0, (limit - 1) if limit + with_expiration do + multi do + remove elements + super(elements, suppress_expiration: true) + ltrim 0, (limit - 1) if limit + end end end @@ -21,10 +24,12 @@ def append(elements) elements = Array(elements).uniq return if elements.empty? - multi do - remove elements - super - ltrim(-limit, -1) if limit + with_expiration do + multi do + remove elements + super(elements, suppress_expiration: true) + ltrim(-limit, -1) if limit + end end end alias << append diff --git a/test/attributes_test.rb b/test/attributes_test.rb index 6fa0b4e..306ec66 100644 --- a/test/attributes_test.rb +++ b/test/attributes_test.rb @@ -13,9 +13,12 @@ class Person kredis_list :names_with_custom_key_via_lambda, key: ->(p) { "person:#{p.id}:names_customized" } kredis_list :names_with_custom_key_via_method, key: :generate_key kredis_list :names_with_default_via_lambda, default: ->(p) { [ "Random", p.name ] } + kredis_list :names_with_ttl, expires_in: 1.second kredis_unique_list :skills, limit: 2 kredis_unique_list :skills_with_default_via_lambda, default: ->(p) { [ "Random", "Random", p.name ] } + kredis_unique_list :skills_with_ttl, expires_in: 1.second kredis_ordered_set :reading_list, limit: 2 + kredis_ordered_set :reading_list_with_ttl, expires_in: 1.second kredis_flag :special kredis_flag :temporary_special, expires_in: 1.second kredis_string :address @@ -34,6 +37,7 @@ class Person kredis_slots :meetings, available: 3 kredis_set :vacations kredis_set :vacations_with_default_via_lambda, default: ->(p) { JSON.parse(p.vacation_destinations).map { |location| location["city"] } } + kredis_set :vacations_with_ttl, expires_in: 1.second kredis_json :settings kredis_json :settings_with_default_via_lambda, default: ->(p) { JSON.parse(p.anthropometry).merge(eye_color: p.eye_color) } kredis_counter :amount @@ -42,6 +46,7 @@ class Person kredis_string :temporary_password, expires_in: 1.second kredis_hash :high_scores, typed: :integer kredis_hash :high_scores_with_default_via_lambda, typed: :integer, default: ->(p) { { high_score: JSON.parse(p.scores).max } } + kredis_hash :high_scores_with_ttl, typed: :integer, expires_in: 1.second kredis_boolean :onboarded kredis_boolean :adult_with_default_via_lambda, default: ->(p) { Date.today.year - p.birthdate.year >= 18 } kredis_limiter :update_limit, limit: 3, expires_in: 1.second @@ -148,6 +153,14 @@ class AttributesTest < ActiveSupport::TestCase assert_equal %w[ Random Jason ], Kredis.redis.lrange("people:8:names_with_default_via_lambda", 0, -1) end + test "list with ttl" do + @person.names_with_ttl.append(%w[ david kasper ]) + assert_equal %w[ david kasper ], @person.names_with_ttl.elements + + sleep 1.1 + assert_equal [], @person.names_with_ttl.elements + end + test "unique list" do @person.skills.prepend(%w[ trolling photography ]) @person.skills.prepend("racing") @@ -160,11 +173,27 @@ class AttributesTest < ActiveSupport::TestCase assert_equal %w[ Random Jason ], Kredis.redis.lrange("people:8:skills_with_default_via_lambda", 0, -1) end + test "unique list with ttl" do + @person.skills_with_ttl.prepend(%w[ trolling photography ]) + assert_equal %w[ trolling photography ].to_set, @person.skills_with_ttl.elements.to_set + + sleep 1.1 + assert_equal [], @person.skills_with_ttl.elements + end + test "ordered set" do @person.reading_list.prepend(%w[ rework shapeup remote ]) assert_equal %w[ remote shapeup ], @person.reading_list.elements end + test "ordered set with ttl" do + @person.reading_list_with_ttl.prepend(%w[ rework ]) + assert_equal %w[ rework ], @person.reading_list_with_ttl.elements + + sleep 1.1 + assert_equal [], @person.reading_list_with_ttl.elements + end + test "flag" do assert_not @person.special? @@ -324,6 +353,14 @@ class AttributesTest < ActiveSupport::TestCase assert_equal [ "Paris" ], Kredis.redis.smembers("people:8:vacations_with_default_via_lambda") end + test "set with ttl" do + @person.vacations_with_ttl.add "paris" + assert_equal [ "paris" ], @person.vacations_with_ttl.members + + sleep 1.1 + assert_equal [], @person.vacations_with_ttl.members + end + test "json" do @person.settings.value = { "color" => "red", "count" => 2 } assert_equal({ "color" => "red", "count" => 2 }, @person.settings.value) @@ -368,6 +405,14 @@ class AttributesTest < ActiveSupport::TestCase assert_equal({ "high_score" => 28 }, @person.high_scores_with_default_via_lambda.to_h) end + test "hash with ttl" do + @person.high_scores_with_ttl.update(the_lost_viking: 99) + assert_equal({ "the_lost_viking" => 99 }, @person.high_scores_with_ttl.to_h) + + sleep 1.1 + assert_equal({}, @person.high_scores_with_ttl.to_h) + end + test "boolean" do @person.onboarded.value = true assert @person.onboarded.value diff --git a/test/types/hash_test.rb b/test/types/hash_test.rb index ef1f0de..6b2fd81 100644 --- a/test/types/hash_test.rb +++ b/test/types/hash_test.rb @@ -130,4 +130,14 @@ class HashTest < ActiveSupport::TestCase assert_nil @hash["key"] assert_equal "value2", @hash["key2"] end + + test "support ttl" do + @hash = Kredis.hash "myhash", expires_in: 1.second + @hash[:key] = :value + @hash.update("key2" => "value2", "key3" => "value3") + assert_equal "value", @hash[:key] + + sleep 1.1 + assert_equal [], @hash.keys + end end diff --git a/test/types/list_test.rb b/test/types/list_test.rb index 7507548..d9c5301 100644 --- a/test/types/list_test.rb +++ b/test/types/list_test.rb @@ -76,6 +76,33 @@ class ListTest < ActiveSupport::TestCase assert_equal %w[ 2 3 ], @list.elements end + test "append with expiring list" do + @list = Kredis.list "mylist", expires_in: 1.second + @list.append(%w[1 2]) + + sleep 0.2.seconds + @list.append(3) + + sleep 0.3.seconds + assert_equal %w[ 1 2 3 ], @list.elements + + sleep 0.6.seconds + assert_equal [], @list.elements + end + + test "prepend with expiring list" do + @list = Kredis.list "mylist", expires_in: 1.second + @list.prepend(%w[1 2]) + + sleep 0.2.seconds + @list.prepend(3) + + sleep 0.3.seconds + assert_equal %w[ 3 2 1 ], @list.elements + + sleep 0.6.seconds + assert_equal [], @list.elements + end test "default" do @list = Kredis.list "mylist", default: %w[ 1 2 3 ] diff --git a/test/types/ordered_set_test.rb b/test/types/ordered_set_test.rb index 3738dc9..fd9cdf9 100644 --- a/test/types/ordered_set_test.rb +++ b/test/types/ordered_set_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "active_support/core_ext/integer" class OrderedSetTest < ActiveSupport::TestCase setup { @set = Kredis.ordered_set "ordered-set", limit: 5 } @@ -31,6 +32,16 @@ class OrderedSetTest < ActiveSupport::TestCase assert_equal thousand_elements, @set.elements end + test "append with expiry" do + @set = Kredis.ordered_set "ordered-set", limit: 5, expires_in: 1.second + + @set.append(%w[ 1 2 3 ]) + assert_equal %w[ 1 2 3 ], @set.elements + + sleep 1.1 + assert @set.elements.empty? + end + test "prepend" do @set.prepend(%w[ 1 2 3 ]) @set.prepend(%w[ 1 2 3 4 ]) diff --git a/test/types/set_test.rb b/test/types/set_test.rb index a062aa5..8c085de 100644 --- a/test/types/set_test.rb +++ b/test/types/set_test.rb @@ -2,6 +2,7 @@ require "test_helper" require "active_support/core_ext/object/inclusion" +require "active_support/core_ext/integer" class SetTest < ActiveSupport::TestCase setup { @set = Kredis.set "myset" } @@ -101,7 +102,6 @@ class SetTest < ActiveSupport::TestCase assert_equal [ 1.5, 2.7 ], @set.sample(2).sort end - test "default" do @set = Kredis.set "mylist", default: %w[ 1 2 3 ] assert_equal %w[ 1 2 3 ], @set.members @@ -128,6 +128,18 @@ class SetTest < ActiveSupport::TestCase assert_equal [ 1, 2, 3, 5, 6, 7 ], @set.members end + test "add with expiration" do + @set = Kredis.set "mylist", typed: :integer, expires_in: 1.second + @set.add(%w[ 1 2 3 ]) + + sleep 0.7.seconds + @set.add(%w[ 4 5 ]) + assert_equal [ 1, 2, 3, 4, 5 ], @set.members + + sleep 0.5.seconds + assert_equal [], @set.members + end + test "remove with default" do @set = Kredis.set "mylist", default: -> () { %w[ 1 2 3 4 ] } @set.remove(%w[ 2 3 ]) diff --git a/test/types/unique_list_test.rb b/test/types/unique_list_test.rb index 8ac91ed..4883175 100644 --- a/test/types/unique_list_test.rb +++ b/test/types/unique_list_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "active_support/core_ext/integer" class UniqueListTest < ActiveSupport::TestCase setup { @list = Kredis.unique_list "myuniquelist", limit: 5 } @@ -14,6 +15,16 @@ class UniqueListTest < ActiveSupport::TestCase assert_equal %w[ 1 2 3 4 5 ], @list.elements end + test "append with expiration" do + @list = Kredis.unique_list "xs", limit: 5, expires_in: 1.second + + @list.append(%w[ 1 2 3 ]) + assert_equal %w[ 1 2 3 ], @list.elements + + sleep 1.1 + assert @list.elements.empty? + end + test "prepend" do @list.prepend(%w[ 1 2 3 ]) @list.prepend(%w[ 1 2 3 4 ])