File: test_thundering_herd.rb

package info (click to toggle)
ruby-dalli 5.0.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 992 kB
  • sloc: ruby: 9,447; sh: 19; makefile: 4
file content (385 lines) | stat: -rw-r--r-- 12,186 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# frozen_string_literal: true

require_relative '../helper'

describe 'thundering herd protection' do
  # Thundering herd features require memcached 1.6+ (meta protocol)
  describe 'using the meta protocol' do
    before do
      skip 'Thundering herd features require memcached 1.6+' unless MemcachedManager.supported_protocols.include?(:meta)
    end

    describe 'get_with_recache' do
      it 'returns value and recache status for existing key' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          dc.set('existing_key', 'existing_value', 300)

          # Get an existing key - should not have any recache flags
          server = dc.send(:ring).server_for_key('existing_key')
          result = server.request(:meta_get, 'existing_key')

          assert_equal 'existing_value', result[:value]
          assert_predicate result[:cas], :positive?
          refute result[:won_recache]
          refute result[:stale]
          refute result[:lost_recache]
        end
      end

      it 'returns nil value for non-existent key without vivify' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          server = dc.send(:ring).server_for_key('nonexistent_key')
          result = server.request(:meta_get, 'nonexistent_key')

          assert_nil result[:value]
          refute result[:won_recache]
          refute result[:stale]
          refute result[:lost_recache]
        end
      end

      it 'vivifies on miss with N flag and returns won_recache' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          server = dc.send(:ring).server_for_key('vivify_key')
          # Request with vivify_ttl - on miss, creates stub and returns W flag
          result = server.request(:meta_get, 'vivify_key', { vivify_ttl: 30 })

          # The first client should win the recache race on a miss
          assert result[:won_recache], 'First client should win recache on miss with N flag'
          # When vivifying, memcached creates an empty stub value
          # The value will be empty string (marshalled) since the key was just created
        end
      end

      it 'returns stale and lost_recache for second client during vivify' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          server = dc.send(:ring).server_for_key('race_key')

          # First client vivifies the key
          result1 = server.request(:meta_get, 'race_key', { vivify_ttl: 30 })

          assert result1[:won_recache], 'First client should win'

          # Second client should see stale and lost the race
          server.request(:meta_get, 'race_key', { vivify_ttl: 30 })
          # NOTE: On the second request, the stub already exists
          # Behavior depends on memcached version and exact timing
          # The key point is that only one client wins
        end
      end
    end

    describe 'delete_stale' do
      it 'marks item as stale instead of deleting' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          dc.set('stale_key', 'stale_value', 300)

          # Verify the key exists
          assert_equal 'stale_value', dc.get('stale_key')

          # Mark it as stale
          server = dc.send(:ring).server_for_key('stale_key')
          result = server.request(:delete_stale, 'stale_key')

          assert result, 'delete_stale should return true on success'

          # After marking stale, the key should still be accessible
          # but will have the X flag when fetched with N/R flags
        end
      end
    end

    describe 'request formatter' do
      it 'formats meta_get with N flag' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          vivify_ttl: 30
        )

        assert_includes req, 'N30'
      end

      it 'formats meta_get with R flag' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          recache_ttl: 60
        )

        assert_includes req, 'R60'
      end

      it 'formats meta_get with both N and R flags' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          vivify_ttl: 30,
          recache_ttl: 60
        )

        assert_includes req, 'N30'
        assert_includes req, 'R60'
      end

      it 'formats meta_delete with I (stale) flag' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_delete(
          key: 'test_key',
          stale: true
        )

        assert_includes req, ' I'
      end

      it 'formats meta_get with h (hit status) flag' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          return_hit_status: true
        )

        assert_includes req, ' h'
      end

      it 'formats meta_get with l (last access) flag' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          return_last_access: true
        )

        assert_includes req, ' l'
      end

      it 'formats meta_get with u (skip LRU bump) flag' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          skip_lru_bump: true
        )

        assert_includes req, ' u'
      end

      it 'formats meta_get with all metadata flags combined' do
        req = Dalli::Protocol::Meta::RequestFormatter.meta_get(
          key: 'test_key',
          vivify_ttl: 30,
          recache_ttl: 60,
          return_hit_status: true,
          return_last_access: true,
          skip_lru_bump: true
        )

        assert_includes req, 'N30'
        assert_includes req, 'R60'
        assert_includes req, ' h'
        assert_includes req, ' l'
        assert_includes req, ' u'
      end
    end

    describe 'get_with_metadata' do
      it 'returns value and metadata for existing key' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          dc.set('metadata_key', 'metadata_value', 300)

          result = dc.get_with_metadata('metadata_key')

          assert_equal 'metadata_value', result[:value]
          assert_predicate result[:cas], :positive?
          refute result[:won_recache]
          refute result[:stale]
          refute result[:lost_recache]
        end
      end

      it 'returns nil value for non-existent key' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          result = dc.get_with_metadata('nonexistent_key')

          assert_nil result[:value]
          refute result[:won_recache]
        end
      end

      it 'supports vivify_ttl option for thundering herd protection' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          # First request should win the recache race
          result = dc.get_with_metadata('vivify_key', vivify_ttl: 30)

          assert result[:won_recache], 'First client should win recache on miss with vivify_ttl'
        end
      end

      it 'supports recache_ttl option for early recache' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          # Set with a very short TTL
          dc.set('recache_key', 'recache_value', 5)

          # Request with recache_ttl higher than remaining TTL should win recache
          result = dc.get_with_metadata('recache_key', recache_ttl: 10)

          # Either we win recache (TTL was below threshold) or the value is returned
          assert_equal 'recache_value', result[:value]
        end
      end

      it 'returns hit status when requested' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          dc.set('hit_key', 'hit_value', 300)

          # First access - should show not previously hit
          result1 = dc.get_with_metadata('hit_key', return_hit_status: true)

          assert_equal 'hit_value', result1[:value]
          refute_nil result1[:hit_before]
          refute result1[:hit_before], 'First access should show not previously hit'

          # Second access - should show previously hit
          result2 = dc.get_with_metadata('hit_key', return_hit_status: true)

          assert result2[:hit_before], 'Second access should show previously hit'
        end
      end

      it 'returns last access time when requested' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          dc.set('access_key', 'access_value', 300)

          # First get to establish access time
          dc.get('access_key')

          # Small delay to ensure measurable time difference
          sleep 1

          # Get with last access time
          result = dc.get_with_metadata('access_key', return_last_access: true)

          assert_equal 'access_value', result[:value]
          refute_nil result[:last_access]
          assert_predicate result[:last_access], :positive?, 'Last access time should be positive'
        end
      end

      it 'supports skip_lru_bump option' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          dc.set('lru_key', 'lru_value', 300)

          # Get with skip_lru_bump - should not update hit status
          result = dc.get_with_metadata('lru_key', skip_lru_bump: true, return_hit_status: true)

          assert_equal 'lru_value', result[:value]
          # With skip_lru_bump, the hit status should remain as first access
          refute result[:hit_before], 'skip_lru_bump should not update hit status'
        end
      end

      it 'does not include optional metadata fields when not requested' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          dc.set('simple_key', 'simple_value', 300)

          result = dc.get_with_metadata('simple_key')

          assert_equal 'simple_value', result[:value]
          refute result.key?(:hit_before), 'Should not include hit_before when not requested'
          refute result.key?(:last_access), 'Should not include last_access when not requested'
        end
      end
    end

    describe 'fetch_with_lock' do
      it 'regenerates value on cache miss' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          call_count = 0
          value = dc.fetch_with_lock('new_key', ttl: 300, lock_ttl: 30) do
            call_count += 1
            'generated_value'
          end

          assert_equal 'generated_value', value
          assert_equal 1, call_count

          # Value should now be cached
          cached_value = dc.get('new_key')

          assert_equal 'generated_value', cached_value
        end
      end

      it 'returns cached value without calling block' do
        memcached_persistent(:meta) do |dc|
          dc.flush
          dc.set('existing_key', 'existing_value', 300)

          call_count = 0
          value = dc.fetch_with_lock('existing_key', ttl: 300, lock_ttl: 30) do
            call_count += 1
            'should_not_be_called'
          end

          assert_equal 'existing_value', value
          assert_equal 0, call_count
        end
      end

      it 'requires a block' do
        memcached_persistent(:meta) do |dc|
          assert_raises(ArgumentError) do
            dc.fetch_with_lock('key', ttl: 300, lock_ttl: 30)
          end
        end
      end

      it 'works with complex values' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          complex_value = { items: [1, 2, 3], metadata: { count: 3 } }
          value = dc.fetch_with_lock('complex_key', ttl: 300, lock_ttl: 30) do
            complex_value
          end

          assert_equal complex_value, value
          assert_equal complex_value, dc.get('complex_key')
        end
      end

      it 'only allows one client to regenerate' do
        memcached_persistent(:meta) do |dc|
          dc.flush

          # First fetch should win and regenerate
          result1 = dc.fetch_with_lock('race_key', ttl: 300, lock_ttl: 30) do
            'first_value'
          end

          assert_equal 'first_value', result1

          # Second fetch should get the cached value
          result2 = dc.fetch_with_lock('race_key', ttl: 300, lock_ttl: 30) do
            'second_value_should_not_be_used'
          end

          assert_equal 'first_value', result2
        end
      end
    end
  end
end