Skip to content

Commit 9a8fcf8

Browse files
authored
Merge pull request #45 from github/fletchto99/kv-increment
kv: Add increment functionality
2 parents d6688dd + f146ac3 commit 9a8fcf8

File tree

6 files changed

+308
-1
lines changed

6 files changed

+308
-1
lines changed

.travis.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ env:
1313
- RAILS_VERSION=5.1.5
1414
- RAILS_VERSION=5.0.6
1515
- RAILS_VERSION=4.2.10
16+
services:
17+
- mysql

github-ds.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@ Gem::Specification.new do |spec|
3939
spec.add_development_dependency "activesupport"
4040
spec.add_development_dependency "mysql2"
4141
spec.add_development_dependency "mocha", "~> 1.2.1"
42+
spec.add_development_dependency "minitest-focus", "~> 1.1.2"
43+
spec.add_development_dependency "pry", "~> 0.12.2"
4244
end

lib/github/ds/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
module GitHub
22
module DS
3-
VERSION = "0.2.11"
3+
VERSION = "0.3.0"
44
end
55
end

lib/github/kv.rb

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class KV
4848
KeyLengthError = Class.new(StandardError)
4949
ValueLengthError = Class.new(StandardError)
5050
UnavailableError = Class.new(StandardError)
51+
InvalidValueError = Class.new(StandardError)
5152

5253
class MissingConnectionError < StandardError; end
5354

@@ -252,6 +253,102 @@ def setnx(key, value, expires: nil)
252253
}
253254
end
254255

256+
# increment :: String, Integer, expires: Time? -> Integer
257+
#
258+
# Increment the key's value by an amount.
259+
#
260+
# key - The key to increment.
261+
# amount - The amount to increment the key's value by.
262+
# The user can increment by both positive and
263+
# negative values
264+
# expires - When the key should expire.
265+
# touch_on_insert - Only when expires is specified. When true
266+
# the expires value is only touched upon
267+
# inserts. Otherwise the record is always
268+
# touched.
269+
#
270+
# Returns the key's value after incrementing.
271+
def increment(key, amount: 1, expires: nil, touch_on_insert: false)
272+
validate_key(key)
273+
validate_amount(amount) if amount
274+
validate_expires(expires) if expires
275+
validate_touch(touch_on_insert, expires)
276+
277+
expires ||= GitHub::SQL::NULL
278+
279+
# This query uses a few MySQL "hacks" to ensure that the incrementing
280+
# is done atomically and the value is returned. The first trick is done
281+
# using the `LAST_INSERT_ID` function. This allows us to manually set
282+
# the LAST_INSERT_ID returned by the query. Here we are able to set it
283+
# to the new value when an increment takes place, essentially allowing us
284+
# to do: `UPDATE...;SELECT value from key_value where key=:key` in a
285+
# single step.
286+
#
287+
# However the `LAST_INSERT_ID` trick is only used when the value is
288+
# updated. Upon a fresh insert we know the amount is going to be set
289+
# to the amount specified.
290+
#
291+
# Lastly we only do these tricks when the value at the key is an integer.
292+
# If the value is not an integer the update ensures the values remain the
293+
# same and we raise an error.
294+
encapsulate_error {
295+
sql = GitHub::SQL.run(<<-SQL, key: key, amount: amount, now: now, expires: expires, touch: !touch_on_insert, connection: connection)
296+
INSERT INTO key_values (`key`, `value`, `created_at`, `updated_at`, `expires_at`)
297+
VALUES(:key, :amount, :now, :now, :expires)
298+
ON DUPLICATE KEY UPDATE
299+
`value`=IF(
300+
concat('',`value`*1) = `value`,
301+
LAST_INSERT_ID(IF(
302+
`expires_at` IS NULL OR `expires_at`>=:now,
303+
`value`+:amount,
304+
:amount
305+
)),
306+
`value`
307+
),
308+
`updated_at`=IF(
309+
concat('',`value`*1) = `value`,
310+
:now,
311+
`updated_at`
312+
),
313+
`expires_at`=IF(
314+
concat('',`value`*1) = `value`,
315+
IF(
316+
:touch,
317+
:expires,
318+
`expires_at`
319+
),
320+
`expires_at`
321+
)
322+
SQL
323+
324+
# The ordering of these statements is extremely important if we are to
325+
# support incrementing a negative amount. The checks occur in this order:
326+
# 1. Check if an update with new values occurred? If so return the result
327+
# This could potentially result in `sql.last_insert_id` with a value
328+
# of 0, thus it must be before the second check.
329+
# 2. Check if an update took place but nothing changed (I.E. no new value
330+
# was set)
331+
# 3. Check if an insert took place.
332+
#
333+
# See https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html for
334+
# more information (NOTE: CLIENT_FOUND_ROWS is set)
335+
if sql.affected_rows == 2
336+
# An update took place in which data changed. We use a hack to set
337+
# the last insert ID to be the new value.
338+
sql.last_insert_id
339+
elsif sql.affected_rows == 0 || (sql.affected_rows == 1 && sql.last_insert_id == 0)
340+
# No insert took place nor did any update occur. This means that
341+
# the value was not an integer thus not incremented.
342+
raise InvalidValueError
343+
elsif sql.affected_rows == 1
344+
# If the number of affected_rows is 1 then a new value was inserted
345+
# thus we can just return the amount given to us since that is the
346+
# value at the key
347+
amount
348+
end
349+
}
350+
end
351+
255352
# del :: String -> nil
256353
#
257354
# Deletes the specified key. Returns nil. Raises on error.
@@ -311,6 +408,28 @@ def ttl(key)
311408
}
312409
end
313410

411+
# mttl :: [String] -> Result<[Time | nil]>
412+
#
413+
# Returns the expires_at time for the specified key or nil.
414+
#
415+
# Example:
416+
#
417+
# kv.mttl(["foo", "octocat"])
418+
# # => #<Result value: [2018-04-23 11:34:54 +0200, nil]>
419+
#
420+
def mttl(keys)
421+
validate_key_array(keys)
422+
423+
Result.new {
424+
kvs = GitHub::SQL.results(<<-SQL, :keys => keys, :now => now, :connection => connection).to_h
425+
SELECT `key`, expires_at FROM key_values
426+
WHERE `key` in :keys AND (expires_at IS NULL OR expires_at > :now)
427+
SQL
428+
429+
keys.map { |key| kvs[key] }
430+
}
431+
end
432+
314433
private
315434
def now
316435
use_local_time ? Time.now : GitHub::SQL::NOW
@@ -369,6 +488,19 @@ def validate_value_length(value)
369488
end
370489
end
371490

491+
def validate_amount(amount)
492+
raise ArgumentError.new("The amount specified must be an integer") unless amount.is_a? Integer
493+
raise ArgumentError.new("The amount specified cannot be 0") if amount == 0
494+
end
495+
496+
def validate_touch(touch, expires)
497+
raise ArgumentError.new("touch_on_insert must be a boolean value") unless [true, false].include?(touch)
498+
499+
if touch && expires.nil?
500+
raise ArgumentError.new("Please specify an expires value if you wish to touch on insert")
501+
end
502+
end
503+
372504
def validate_expires(expires)
373505
unless expires.respond_to?(:to_time)
374506
raise TypeError, "expires must be a time of some sort (Time, DateTime, ActiveSupport::TimeWithZone, etc.), but was #{expires.class}"

test/github/kv_test.rb

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,159 @@ def test_set_failure
5454
end
5555
end
5656

57+
def test_increment_failure
58+
ActiveRecord::Base.connection.stubs(:insert).raises(Errno::ECONNRESET)
59+
60+
assert_raises GitHub::KV::UnavailableError do
61+
@kv.increment("foo")
62+
end
63+
end
64+
65+
def test_increment_default_value
66+
result = @kv.increment("foo")
67+
68+
assert_equal 1, result
69+
assert_nil @kv.ttl("foo").value!
70+
end
71+
72+
def test_increment_large_value
73+
result = @kv.increment("foo", amount: 10000)
74+
75+
assert_equal 10000, result
76+
end
77+
78+
def test_increment_negative
79+
result = @kv.increment("foo", amount: -1)
80+
81+
assert_equal -1, result
82+
end
83+
84+
def test_increment_negative_to_0
85+
@kv.set("foo", "1")
86+
result = @kv.increment("foo", amount: -1)
87+
88+
assert_equal 0, result
89+
end
90+
91+
def test_increment_multiple
92+
@kv.increment("foo")
93+
result = @kv.increment("foo")
94+
95+
assert_equal 2, result
96+
end
97+
98+
def test_increment_multiple_different_values
99+
@kv.increment("foo")
100+
result = @kv.increment("foo", amount: 2)
101+
102+
assert_equal 3, result
103+
end
104+
105+
def test_increment_existing
106+
@kv.set("foo", "1")
107+
108+
result = @kv.increment("foo")
109+
110+
assert_equal 2, result
111+
end
112+
113+
def test_increment_overwrites_expired_value
114+
@kv.set("foo", "100", expires: 1.hour.ago)
115+
expires = 1.hour.from_now.utc
116+
result = @kv.increment("foo", expires: expires)
117+
118+
assert_equal expires.to_i, @kv.ttl("foo").value!.to_i
119+
assert_equal 1, result
120+
end
121+
122+
def test_increment_overwrites_expired_value_from_incrementing
123+
@kv.increment("foo", expires: 1.hour.ago)
124+
expires = 1.hour.from_now.utc
125+
result = @kv.increment("foo", expires: expires)
126+
127+
assert_equal 1, result
128+
assert_equal expires.to_i, @kv.ttl("foo").value!.to_i
129+
end
130+
131+
def test_increment_overwrites_expired_with_nil
132+
@kv.increment("foo", expires: 1.hour.ago)
133+
result = @kv.increment("foo")
134+
135+
assert_equal 1, result
136+
assert_nil @kv.ttl("foo").value!
137+
end
138+
139+
def test_increment_sets_expires
140+
expires = 1.hour.from_now.utc
141+
@kv.increment("foo", expires: expires)
142+
143+
assert_equal expires.to_i, @kv.ttl("foo").value!.to_i
144+
end
145+
146+
def test_increment_sets_expires_only_on_insert
147+
expires = 1.hour.from_now.utc
148+
result = @kv.increment("foo", expires: expires, touch_on_insert: true)
149+
assert_equal 1, result
150+
151+
result = @kv.increment("foo", expires: 3.hours.from_now.utc, touch_on_insert: true)
152+
assert_equal 2, result
153+
154+
assert_equal expires.to_i, @kv.ttl("foo").value!.to_i
155+
end
156+
157+
def test_increment_sets_expires_only_on_insert_for_existing
158+
expires = 1.hour.from_now.utc
159+
@kv.set("foo", "100", expires: expires)
160+
161+
result = @kv.increment("foo", expires: 3.hours.from_now.utc, touch_on_insert: true)
162+
assert_equal 101, result
163+
164+
assert_equal expires.to_i, @kv.ttl("foo").value!.to_i
165+
end
166+
167+
def test_increment_updates_expires
168+
expires = 2.hours.from_now.utc
169+
170+
@kv.set("foo", "100", expires: 1.hour.from_now)
171+
result = @kv.increment("foo", expires: expires)
172+
173+
assert_equal 101, result
174+
assert_equal expires.to_i, @kv.ttl("foo").value!.to_i
175+
end
176+
177+
def test_increment_non_integer_key_value
178+
@kv.set("foo", "bar")
179+
180+
assert_raises GitHub::KV::InvalidValueError do
181+
@kv.increment("foo")
182+
end
183+
assert_equal "bar", @kv.get("foo").value!
184+
end
185+
186+
def test_increment_only_accepts_integer_amounts
187+
assert_raises ArgumentError do
188+
@kv.increment("foo", amount: "bar")
189+
end
190+
end
191+
192+
def test_increment_only_accepts_integer_amounts
193+
assert_raises ArgumentError do
194+
@kv.increment("foo", amount: 0)
195+
end
196+
end
197+
198+
def test_increment_with_touch_expects_expires
199+
assert_raises ArgumentError do
200+
@kv.increment("foo", touch_on_insert: true)
201+
end
202+
end
203+
204+
def test_increment_with_touch_must_be_boolean
205+
assert_raises ArgumentError do
206+
@kv.increment("foo", touch_on_insert: "blahblah")
207+
end
208+
end
209+
57210
def test_exists
58211
assert_equal false, @kv.exists("foo").value!
59212

@@ -187,6 +340,18 @@ def test_ttl_for_key_that_exists_but_is_expired
187340
assert_nil @kv.ttl("foo-ttl").value!
188341
end
189342

343+
def test_mttl
344+
assert_equal [nil, nil], @kv.mttl(["foo-ttl", "bar-ttl"]).value!
345+
346+
# the Time.at dance is necessary because MySQL does not support sub-second
347+
# precision in DATETIME values
348+
expires = Time.at(1.hour.from_now.to_i).utc
349+
@kv.set("foo-ttl", "bar", expires: expires)
350+
351+
assert_equal [expires, nil], @kv.mttl(["foo-ttl", "bar-ttl"]).value!
352+
assert_equal [nil, expires], @kv.mttl(["bar-ttl", "foo-ttl"]).value!
353+
end
354+
190355
def test_type_checks_key
191356
assert_raises TypeError do
192357
@kv.get(0)
@@ -240,6 +405,10 @@ def test_timecop
240405
# mset/mget
241406
@kv.mset({"foo" => "baz"}, expires: 1.day.from_now.utc)
242407
assert_equal ["baz"], @kv.mget(["foo"]).value!
408+
409+
# increment
410+
@kv.increment("foo-increment", expires: 1.day.from_now.utc)
411+
assert_equal 1, @kv.get("foo-increment").value!.to_i
243412
end
244413
end
245414
end

test/test_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
require "timecop"
99
require "minitest/autorun"
1010
require "mocha/mini_test"
11+
require "minitest/focus"
12+
require "pry"
1113

1214
ActiveRecord::Base.configurations = {
1315
"without_database" => {

0 commit comments

Comments
 (0)