Skip to content

Commit a0062d1

Browse files
authored
Improved version of UniqueList: OrderedSet (#76)
* Back UniqueList with a Redis Sorted Set A sorted set is optimal for this type of functionality because we can use fewer, more-performant Redis calls. * Wrap unique list insert and trim in a transaction * Fallback / migration mechanism for existing users of UniqueLists In order to ease the transition from UniqueList being backed by a Redis list, we can fallback to the legacy implementation for read operations. For write operations we first migrate to a sorted set, then retry. * Update the Readme with sorted set backed unique list commands * Test remaining methods with migration fallbacks * Be more specific about which errors to raise * Improve the fallback / migration system * Improve unique list scoring to avoid collisions Use the Redis server time as a basis. Then add the current process time. Then add to that the index of the inserted elements as a microsecond component. * Update Readme with realistic Unique List scores * Implement OrderedSet Like a UniqueList, but backed by a Redis sorted set rather than a list * Resolve pipeline deprecation warnings * Document OrderedSet * Remove UniqueListLegacy * Revert fallback options * Revert change to type_from * Enforce limit is not negative * Revert config proxying * Fix bug in score ge neration
1 parent 0961d68 commit a0062d1

File tree

4 files changed

+183
-0
lines changed

4 files changed

+183
-0
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ unique_list << "5" # => LREM myuniquelist 0, "5" + R
6262
unique_list.remove(3) # => LREM myuniquelist 0, "3"
6363
[ "4", "2", "1", "5" ] == unique_list.elements # => LRANGE myuniquelist 0, -1
6464

65+
ordered_set = Kredis.ordered_set "myorderedset"
66+
ordered_set.append(%w[ 2 3 4 ]) # => ZADD myorderedset 1646131025.4953232 2 1646131025.495326 3 1646131025.4953272 4
67+
ordered_set.prepend(%w[ 1 2 3 4 ]) # => ZADD myorderedset -1646131025.4957051 1 -1646131025.495707 2 -1646131025.4957082 3 -1646131025.4957092 4
68+
ordered_set.append([])
69+
ordered_set << "5" # => ZADD myorderedset 1646131025.4960442 5
70+
ordered_set.remove(3) # => ZREM myorderedset 3
71+
[ "4", "2", "1", "5" ] == ordered_set.elements # => ZRANGE myorderedset 0 -1
72+
6573
set = Kredis.set "myset", typed: :datetime
6674
set.add(DateTime.tomorrow, DateTime.yesterday) # => SADD myset "2021-02-03 00:00:00 +0100" "2021-02-01 00:00:00 +0100"
6775
set << DateTime.tomorrow # => SADD myset "2021-02-03 00:00:00 +0100"

lib/kredis/types.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def set(key, typed: :string, config: :shared, after_change: nil)
7171
type_from(Set, config, key, after_change: after_change, typed: typed)
7272
end
7373

74+
def ordered_set(key, typed: :string, limit: nil, config: :shared, after_change: nil)
75+
type_from(OrderedSet, config, key, after_change: after_change, typed: typed, limit: limit)
76+
end
77+
7478
def slot(key, config: :shared, after_change: nil)
7579
type_from(Slots, config, key, after_change: after_change, available: 1)
7680
end
@@ -99,4 +103,5 @@ def type_from(type_klass, config, key, after_change: nil, **options)
99103
require "kredis/types/list"
100104
require "kredis/types/unique_list"
101105
require "kredis/types/set"
106+
require "kredis/types/ordered_set"
102107
require "kredis/types/slots"

lib/kredis/types/ordered_set.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
class Kredis::Types::OrderedSet < Kredis::Types::Proxying
2+
proxying :multi, :zrange, :zrem, :zadd, :zremrangebyrank, :zcard, :exists?, :del
3+
4+
attr_accessor :typed
5+
attr_reader :limit
6+
7+
def elements
8+
strings_to_types(zrange(0, -1) || [], typed)
9+
end
10+
alias to_a elements
11+
12+
def remove(*elements)
13+
zrem(types_to_strings(elements, typed))
14+
end
15+
16+
def prepend(elements)
17+
insert(elements, prepending: true)
18+
end
19+
20+
def append(elements)
21+
insert(elements)
22+
end
23+
alias << append
24+
25+
def limit=(limit)
26+
raise "Limit must be greater than 0" if limit && limit <= 0
27+
28+
@limit = limit
29+
end
30+
31+
private
32+
def insert(elements, prepending: false)
33+
elements = Array(elements)
34+
return if elements.empty?
35+
36+
elements_with_scores = types_to_strings(elements, typed).map.with_index do |element, index|
37+
score = generate_base_score(negative: prepending) + (index / 100000)
38+
39+
[ score , element ]
40+
end
41+
42+
multi do |pipeline|
43+
pipeline.zadd(elements_with_scores)
44+
trim(from_beginning: prepending, pipeline: pipeline)
45+
end
46+
end
47+
48+
def generate_base_score(negative:)
49+
current_time = process_start_time + process_uptime
50+
51+
negative ? -current_time : current_time
52+
end
53+
54+
def process_start_time
55+
@process_start_time ||= redis.time.join(".").to_f - process_uptime
56+
end
57+
58+
def process_uptime
59+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
60+
end
61+
62+
def trim(from_beginning:, pipeline:)
63+
return unless limit
64+
65+
if from_beginning
66+
pipeline.zremrangebyrank(limit, -1)
67+
else
68+
pipeline.zremrangebyrank(0, -(limit + 1))
69+
end
70+
end
71+
end

test/types/ordered_set_test.rb

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
require "test_helper"
2+
3+
class OrderedSetTest < ActiveSupport::TestCase
4+
setup { @set = Kredis.ordered_set "ordered-set", limit: 5 }
5+
6+
test "append" do
7+
@set.append(%w[ 1 2 3 ])
8+
@set.append(%w[ 1 2 3 4 ])
9+
assert_equal %w[ 1 2 3 4 ], @set.elements
10+
11+
@set << "5"
12+
assert_equal %w[ 1 2 3 4 5 ], @set.elements
13+
end
14+
15+
test "appending the same element re-appends it" do
16+
@set.append(%w[ 1 2 3 ])
17+
@set.append(%w[ 2 ])
18+
assert_equal %w[ 1 3 2 ], @set.elements
19+
end
20+
21+
test "mass append maintains ordering" do
22+
@set = Kredis.ordered_set "ordered-set" # no limit
23+
24+
thousand_elements = 1000.times.map { [*"A".."Z"].sample(10).join }
25+
@set.append(thousand_elements)
26+
assert_equal thousand_elements, @set.elements
27+
28+
thousand_elements.each { |element| @set.append(element) }
29+
assert_equal thousand_elements, @set.elements
30+
end
31+
32+
test "prepend" do
33+
@set.prepend(%w[ 1 2 3 ])
34+
@set.prepend(%w[ 1 2 3 4 ])
35+
assert_equal %w[ 4 3 2 1 ], @set.elements
36+
end
37+
38+
test "append nothing" do
39+
@set.append(%w[ 1 2 3 ])
40+
@set.append([])
41+
assert_equal %w[ 1 2 3 ], @set.elements
42+
end
43+
44+
test "prepend nothing" do
45+
@set.prepend(%w[ 1 2 3 ])
46+
@set.prepend([])
47+
assert_equal %w[ 3 2 1 ], @set.elements
48+
end
49+
50+
test "typed as integers" do
51+
@set = Kredis.ordered_set "mylist", typed: :integer
52+
53+
@set.append [ 1, 2 ]
54+
@set << 2
55+
assert_equal [ 1, 2 ], @set.elements
56+
57+
@set.remove(2)
58+
assert_equal [ 1 ], @set.elements
59+
60+
@set.append [ "1-a", 2 ]
61+
62+
assert_equal [ 1, 2 ], @set.elements
63+
end
64+
65+
test "exists?" do
66+
assert_not @set.exists?
67+
68+
@set.append [ 1, 2 ]
69+
assert @set.exists?
70+
end
71+
72+
test "appending over limit" do
73+
@set.append(%w[ 1 2 3 4 5 ])
74+
@set.append(%w[ 6 7 8 ])
75+
assert_equal %w[ 4 5 6 7 8 ], @set.elements
76+
end
77+
78+
test "prepending over limit" do
79+
@set.prepend(%w[ 1 2 3 4 5 ])
80+
@set.prepend(%w[ 6 7 8 ])
81+
assert_equal %w[ 8 7 6 5 4 ], @set.elements
82+
end
83+
84+
test "appending array with duplicates" do
85+
@set.append(%w[ 1 1 1 ])
86+
assert_equal %w[ 1 ], @set.elements
87+
end
88+
89+
test "prepending array with duplicates" do
90+
@set.prepend(%w[ 1 1 1 ])
91+
assert_equal %w[ 1 ], @set.elements
92+
end
93+
94+
test "limit can't be 0 or less" do
95+
assert_raises do
96+
Kredis.ordered_set "ordered-set", limit: -1
97+
end
98+
end
99+
end

0 commit comments

Comments
 (0)