Skip to content

Commit 7b0c595

Browse files
authored
Merge pull request #15 from LegionIO/fleet/ws-00g-cache-set-nx
Fleet WS-00G: add Legion::Cache.set_nx
2 parents d035368 + c7d1ced commit 7b0c595

8 files changed

Lines changed: 185 additions & 2 deletions

File tree

lib/legion/cache.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,19 @@ def set(key, value, ttl: nil, async: true, phi: false)
190190
end
191191
end
192192

193+
def set_nx(key, value, ttl: nil)
194+
effective_ttl = resolve_ttl(ttl)
195+
return Legion::Cache::Memory.set_nx(key, value, ttl: effective_ttl) if using_memory?
196+
return Legion::Cache::Local.set_nx(key, value, ttl: effective_ttl) if using_local?
197+
return Legion::Cache::Local.set_nx(key, value, ttl: effective_ttl) if failback_to_local?
198+
199+
configure_shared_adapter!
200+
super
201+
rescue StandardError => e
202+
handle_exception(e, level: :warn, handled: true, operation: :cache_set_nx, key: key)
203+
false
204+
end
205+
193206
def set_sync(key, value, ttl: nil, **)
194207
return Legion::Cache::Memory.set_sync(key, value, ttl: ttl) if using_memory?
195208
return Legion::Cache::Local.set_sync(key, value, ttl: ttl) if using_local?

lib/legion/cache/memcached.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ def fetch(key, ttl: nil, &)
7676
nil
7777
end
7878

79+
def set_nx(key, value, ttl: nil)
80+
effective_ttl = ttl || default_ttl
81+
result = client.with { |conn| conn.add(key, value, effective_ttl) == true }
82+
log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" }
83+
result
84+
rescue StandardError => e
85+
handle_exception(e, level: :error, handled: false, operation: :memcached_set_nx, key: key, ttl: effective_ttl)
86+
raise
87+
end
88+
7989
def set(key, value, ttl: nil, **)
8090
set_sync(key, value, ttl: ttl, **)
8191
end

lib/legion/cache/memory.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,25 @@ def set(key, value, ttl: nil, async: true, phi: false) # rubocop:disable Lint/Un
5555
set_sync(key, value, ttl: ttl, phi: phi)
5656
end
5757

58+
def set_nx(key, value, ttl: nil)
59+
@mutex.synchronize do
60+
expire_if_needed(key)
61+
return false if @store.key?(key)
62+
63+
@store[key] = value
64+
if ttl&.positive?
65+
@expiry[key] = Time.now + ttl
66+
else
67+
@expiry.delete(key)
68+
end
69+
log.debug { "[cache:memory] SET_NX #{key} ttl=#{ttl.inspect} result=true" }
70+
true
71+
end
72+
rescue StandardError => e
73+
handle_exception(e, level: :warn, handled: true, operation: :memory_set_nx)
74+
false
75+
end
76+
5877
def set_sync(key, value, ttl: nil, phi: false)
5978
ttl = enforce_phi_ttl(ttl, phi: phi) if phi
6079
@mutex.synchronize do

lib/legion/cache/redis.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ def fetch(key, ttl: nil)
100100
result
101101
end
102102

103+
def set_nx(key, value, ttl: nil)
104+
effective_ttl = ttl || default_ttl
105+
serialized = serialize_value(value)
106+
result = client.with { |conn| conn.set(key, serialized, nx: true, ex: effective_ttl) == 'OK' }
107+
log.debug { "[cache] SET_NX #{key} ttl=#{effective_ttl.inspect} result=#{result}" }
108+
result
109+
rescue StandardError => e
110+
handle_exception(e, level: :error, handled: false, operation: :redis_set_nx, key: key, ttl: effective_ttl)
111+
raise
112+
end
113+
103114
def set(key, value, ttl: nil, **)
104115
set_sync(key, value, ttl: ttl, **)
105116
end

lib/legion/cache/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
module Legion
44
module Cache
5-
VERSION = '1.4.1'
5+
VERSION = '1.4.2'
66
end
77
end

spec/legion/cache/helper_spec.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,11 @@ def cache_default_ttl
459459

460460
it 'serializes hash as JSON via cache set (merge)' do
461461
allow(Legion::Cache).to receive(:get).with('microsoft_teams:h').and_return(nil)
462-
expect(Legion::Cache).to receive(:set).with('microsoft_teams:h', '{"f":"v"}', ttl: 3600, async: false)
462+
expect(Legion::Cache).to receive(:set) do |key, json, **opts|
463+
expect(key).to eq('microsoft_teams:h')
464+
expect(opts).to eq(ttl: 3600, async: false)
465+
expect(Legion::JSON.load(json)).to eq(f: 'v')
466+
end
463467
subject.cache_hset(':h', { 'f' => 'v' })
464468
end
465469

spec/legion/cache/set_nx_spec.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
require 'legion/cache/redis'
5+
require 'legion/cache/memcached'
6+
require 'legion/cache/memory'
7+
8+
RSpec.describe 'Legion::Cache set_nx' do
9+
describe Legion::Cache::Memory do
10+
before { described_class.reset! }
11+
12+
describe '.set_nx' do
13+
it 'returns true and stores value when key does not exist' do
14+
result = described_class.set_nx('nx-key', 'value', ttl: 60)
15+
expect(result).to be true
16+
expect(described_class.get('nx-key')).to eq('value')
17+
end
18+
19+
it 'returns false and does not overwrite when key already exists' do
20+
described_class.set('nx-key', 'original', ttl: 60)
21+
result = described_class.set_nx('nx-key', 'overwrite', ttl: 60)
22+
expect(result).to be false
23+
expect(described_class.get('nx-key')).to eq('original')
24+
end
25+
26+
it 'returns true after an expired key has been purged' do
27+
described_class.set('nx-expire', 'old', ttl: 0.05)
28+
sleep 0.07
29+
result = described_class.set_nx('nx-expire', 'new', ttl: 60)
30+
expect(result).to be true
31+
expect(described_class.get('nx-expire')).to eq('new')
32+
end
33+
34+
it 'is atomic under concurrent access' do
35+
winners = []
36+
mutex = Mutex.new
37+
threads = 10.times.map do |i|
38+
Thread.new do
39+
won = described_class.set_nx('race-key', "value-#{i}", ttl: 60)
40+
mutex.synchronize { winners << i } if won
41+
end
42+
end
43+
threads.each(&:join)
44+
expect(winners.size).to eq(1)
45+
end
46+
end
47+
end
48+
49+
describe Legion::Cache::Redis do
50+
let(:cache) { described_class.dup }
51+
let(:pool) { instance_double(ConnectionPool) }
52+
let(:redis) { instance_double(Redis) }
53+
54+
before do
55+
cache.instance_variable_set(:@client, pool)
56+
cache.instance_variable_set(:@connected, true)
57+
allow(pool).to receive(:with).and_yield(redis)
58+
end
59+
60+
describe '#set_nx' do
61+
it 'returns true when Redis SET NX succeeds (returns "OK")' do
62+
allow(redis).to receive(:set).with('nx-key', anything, nx: true, ex: 60).and_return('OK')
63+
expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be true
64+
end
65+
66+
it 'returns false when Redis SET NX fails (key exists, returns nil)' do
67+
allow(redis).to receive(:set).with('nx-key', anything, nx: true, ex: 60).and_return(nil)
68+
expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be false
69+
end
70+
71+
it 'passes nx: true and ex: ttl to Redis SET' do
72+
expect(redis).to receive(:set).with('nx-key', anything, nx: true, ex: 120).and_return('OK')
73+
cache.set_nx('nx-key', 'value', ttl: 120)
74+
end
75+
76+
it 'serializes the value before storing' do
77+
captured = nil
78+
allow(redis).to receive(:set) do |_key, val, **_opts|
79+
captured = val
80+
'OK'
81+
end
82+
cache.set_nx('nx-key', { data: 42 }, ttl: 60)
83+
expect(captured).to be_a(String)
84+
end
85+
end
86+
end
87+
88+
describe Legion::Cache::Memcached do
89+
let(:cache) { described_class.dup }
90+
let(:pool) { instance_double(ConnectionPool) }
91+
let(:dalli) { instance_double(Dalli::Client) }
92+
93+
before do
94+
cache.instance_variable_set(:@client, pool)
95+
cache.instance_variable_set(:@connected, true)
96+
allow(pool).to receive(:with).and_yield(dalli)
97+
end
98+
99+
describe '#set_nx' do
100+
it 'returns true when Dalli#add succeeds (key did not exist)' do
101+
allow(dalli).to receive(:add).with('nx-key', 'value', 60).and_return(true)
102+
expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be true
103+
end
104+
105+
it 'returns false when Dalli#add fails (key already exists, returns nil/false)' do
106+
allow(dalli).to receive(:add).with('nx-key', 'value', 60).and_return(nil)
107+
expect(cache.set_nx('nx-key', 'value', ttl: 60)).to be false
108+
end
109+
110+
it 'passes the ttl positionally to Dalli#add' do
111+
expect(dalli).to receive(:add).with('nx-key', 'value', 90).and_return(true)
112+
cache.set_nx('nx-key', 'value', ttl: 90)
113+
end
114+
end
115+
end
116+
end

spec/legion/cache_interface_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,14 @@
8484
it 'responds to enabled?' do
8585
expect(Legion::Cache).to respond_to(:enabled?)
8686
end
87+
88+
it 'has set_nx method' do
89+
expect(Legion::Cache.method(:set_nx)).to be_a(Method)
90+
end
91+
92+
it 'set_nx accepts keyword ttl' do
93+
params = Legion::Cache.method(:set_nx).parameters
94+
names = params.map(&:last)
95+
expect(names).to include(:ttl)
96+
end
8797
end

0 commit comments

Comments
 (0)