redis-allocator 0.0.1__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/test_allocator.py CHANGED
@@ -6,520 +6,493 @@ This module tests the functionality of:
6
6
  2. RedisAllocator - For distributed resource allocation
7
7
  3. RedisAllocatorObject - For managing allocated resources
8
8
  """
9
- import pytest
10
- from unittest.mock import MagicMock, patch, call
11
- from redis import RedisError
12
- from redis_allocator.allocator import RedisAllocator, RedisThreadHealthCheckPool, RedisAllocatorObject, RedisAllocatableClass, RedisLockPool
13
-
14
-
15
- # Use the _TestObject naming to avoid pytest trying to collect it as a test class
16
- class _TestObject(RedisAllocatableClass):
17
- """Test implementation of RedisAllocatableClass for testing."""
18
-
19
- def __init__(self):
20
- self.config_key = None
21
- self.config_params = None
22
- self.closed = False
23
-
24
- def set_config(self, key, params):
25
- """Set configuration parameters."""
26
- self.config_key = key
27
- self.config_params = params
28
-
29
- def close(self):
30
- """Mark the object as closed."""
31
- self.closed = True
32
-
33
- def name(self):
34
- """Return a name for soft binding."""
35
- return "test_object"
36
-
37
-
38
- @pytest.fixture
39
- def test_object():
40
- """Create a test object implementing RedisAllocatableClass."""
41
- return _TestObject()
42
-
43
-
44
- @pytest.fixture
45
- def allocator(redis_client):
46
- """Create a RedisAllocator instance for testing."""
47
- alloc = RedisAllocator(
48
- redis_client,
49
- 'test',
50
- 'alloc-lock',
51
- shared=False
52
- )
53
- # Set up initial keys
54
- alloc.extend(['key1', 'key2', 'key3'])
55
- return alloc
56
-
57
-
58
- @pytest.fixture
59
- def shared_allocator(redis_client):
60
- """Create a shared RedisAllocator instance for testing."""
61
- alloc = RedisAllocator(
62
- redis_client,
63
- 'test',
64
- 'shared-alloc',
65
- shared=True
66
- )
67
- # Set up initial keys
68
- alloc.extend(['key1', 'key2', 'key3'])
69
- return alloc
70
-
71
-
72
- @pytest.fixture
73
- def health_checker(redis_client):
74
- """Create a RedisThreadHealthCheckPool instance for testing."""
75
- return RedisThreadHealthCheckPool(
76
- redis_client,
77
- 'test',
78
- timeout=60
79
- )
80
-
9
+ import time
10
+ import datetime
11
+ import threading
12
+ from operator import xor
13
+ from pytest_mock import MockFixture
14
+ from redis import Redis
15
+ from freezegun import freeze_time
16
+ from redis_allocator.allocator import (
17
+ RedisAllocator, RedisThreadHealthCheckPool, RedisAllocatorObject,
18
+ )
19
+ from tests.conftest import _TestObject
81
20
 
82
21
  class TestRedisThreadHealthCheckPool:
83
22
  """Tests for the RedisThreadHealthCheckPool class."""
84
23
 
85
- def test_initialization(self, health_checker, redis_client):
86
- """Test that initialization correctly registers the thread and sets up monitoring."""
87
- # Initialization should register the current thread
88
- assert health_checker.current_thread_id is not None
89
- # Initialization calls update and extend, no need to check Redis calls directly
90
- # since we're testing the object's behavior, not implementation details
91
- assert hasattr(health_checker, 'timeout')
92
-
93
- def test_update(self, health_checker, redis_client):
94
- """Test that update refreshes the thread's health status."""
95
- # Override the parent class's update method to verify our object behavior
96
- with patch.object(RedisLockPool, 'update') as mock_update:
97
- # Call update
98
- health_checker.update()
99
-
100
- # Should call the parent's update method with thread ID and timeout
101
- mock_update.assert_called_once_with(health_checker.current_thread_id, timeout=health_checker.timeout)
102
-
103
- def test_finalize(self, health_checker, redis_client):
104
- """Test that finalize cleans up thread resources."""
105
- # Override the parent class's methods to verify our object behavior
106
- with patch.object(RedisLockPool, 'shrink') as mock_shrink:
107
- with patch.object(RedisLockPool, 'unlock') as mock_unlock:
108
- # Call finalize
109
- health_checker.finalize()
110
-
111
- # Should call shrink with thread ID
112
- mock_shrink.assert_called_once_with([health_checker.current_thread_id])
113
- # Should call unlock with thread ID
114
- mock_unlock.assert_called_once_with(health_checker.current_thread_id)
115
-
116
- def test_custom_timeout(self, redis_client):
117
- """Test initialization with a custom timeout value."""
118
- custom_timeout = 120
119
- checker = RedisThreadHealthCheckPool(redis_client, 'test', timeout=custom_timeout)
120
- assert checker.timeout == custom_timeout
121
-
122
- def test_multiple_initialize_calls(self, health_checker):
123
- """Test calling initialize multiple times."""
124
- with patch.object(RedisLockPool, 'update') as mock_update:
125
- with patch.object(RedisLockPool, 'extend') as mock_extend:
126
- # Call initialize again
127
- health_checker.initialize()
128
- health_checker.initialize()
129
-
130
- # Should have called update and extend each time
131
- assert mock_update.call_count == 2
132
- assert mock_extend.call_count == 2
24
+ def test_thread_health_check(self, redis_client: Redis):
25
+ """Test the thread health check mechanism in a multi-thread environment.
26
+
27
+ This test creates actual threads:
28
+ - Some threads are "healthy" (regularly update their status)
29
+ - Some threads are "unhealthy" (stop updating their status)
30
+
31
+ We verify that the health check correctly identifies the healthy vs unhealthy threads.
32
+ """
33
+
34
+ # Set up a health checker with a short timeout to make testing faster
35
+ health_timeout = 60 # 3 seconds timeout for faster testing
36
+
37
+ # Start time for our simulation
38
+ start_time = datetime.datetime(2023, 1, 1, 12, 0, 0)
39
+
40
+ with freeze_time(start_time) as frozen_time:
41
+ checker = RedisThreadHealthCheckPool(redis_client, 'test-health', timeout=health_timeout)
42
+
43
+ # Track thread IDs for verification
44
+ thread_ids = {}
45
+ threads = []
46
+ stop_event = threading.Event()
47
+
48
+ # Thread that keeps updating (healthy)
49
+ def healthy_thread():
50
+ checker.initialize()
51
+ thread_id = str(threading.current_thread().ident)
52
+ thread_ids[threading.current_thread().name] = thread_id
53
+ while not stop_event.is_set():
54
+ checker.update()
55
+ # We don't actually sleep in the threads
56
+ # They'll continue running while we control time externally
57
+ if stop_event.wait(0.01): # Small wait to prevent CPU spinning
58
+ break
59
+
60
+ # Thread that stops updating (becomes unhealthy)
61
+ def unhealthy_thread():
62
+ checker.initialize()
63
+ thread_id = str(threading.current_thread().ident)
64
+ thread_ids[threading.current_thread().name] = thread_id
65
+ # Only update once, then stop (simulating a dead thread)
66
+
67
+ # Create and start threads
68
+ for i in range(3):
69
+ t = threading.Thread(target=healthy_thread, name=f"healthy-{i}", daemon=True)
70
+ t.start()
71
+ threads.append(t)
72
+
73
+ for i in range(2):
74
+ t = threading.Thread(target=unhealthy_thread, name=f"unhealthy-{i}", daemon=True)
75
+ t.start()
76
+ threads.append(t)
77
+
78
+ # Wait for all threads to register
79
+ # Instead of time.sleep(1), advance time using freeze_time
80
+ frozen_time.tick(1.0)
81
+
82
+ # Get initial thread status - all should be healthy initially
83
+ registered_threads = list(checker.keys())
84
+ for thread_name, thread_id in thread_ids.items():
85
+ assert thread_id in registered_threads, f"Thread {thread_name} should be registered"
86
+ assert checker.is_locked(thread_id), f"Thread {thread_id} should be locked/healthy"
87
+
88
+ # Wait for unhealthy threads to expire
89
+ # Advance time past the health_timeout
90
+ frozen_time.tick(health_timeout + 1)
91
+ time.sleep(0.1)
92
+ # Now verify healthy vs unhealthy status
93
+ for thread_name, thread_id in thread_ids.items():
94
+ if thread_name.startswith("healthy"):
95
+ assert checker.is_locked(thread_id), f"Thread {thread_name} should still be locked/healthy"
96
+ else:
97
+ assert not checker.is_locked(thread_id), f"Thread {thread_name} should be unlocked/unhealthy"
98
+
99
+ # Clean up
100
+ stop_event.set()
101
+
102
+ def test_thread_recovery(self, redis_client: Redis):
103
+ """Test that a thread can recover after being marked as unhealthy.
104
+
105
+ This simulates a scenario where a thread stops updating for a while (becomes unhealthy),
106
+ but then recovers and starts updating again (becomes healthy).
107
+ """
108
+
109
+ # Start time for our simulation
110
+ start_time = datetime.datetime(2023, 1, 1, 12, 0, 0)
111
+
112
+ with freeze_time(start_time) as frozen_time:
113
+ # Set up a health checker with a short timeout
114
+ health_timeout = 2 # 2 seconds timeout for faster testing
115
+ checker = RedisThreadHealthCheckPool(redis_client, 'test-recovery', timeout=health_timeout)
116
+
117
+ # Control variables
118
+ pause_updates = threading.Event()
119
+ resume_updates = threading.Event()
120
+ stop_thread = threading.Event()
121
+ thread_id = None
122
+
123
+ # Thread function that will pause and resume updates
124
+ def recovery_thread():
125
+ nonlocal thread_id
126
+ checker.initialize()
127
+ thread_id = str(threading.current_thread().ident)
128
+
129
+ # Update until told to pause
130
+ while not pause_updates.is_set() and not stop_thread.is_set():
131
+ checker.update()
132
+ stop_thread.wait(0.01) # Small wait to prevent CPU spinning
133
+
134
+ # Wait until told to resume
135
+ resume_updates.wait()
136
+
137
+ if not stop_thread.is_set():
138
+ # Recover by re-initializing
139
+ checker.initialize()
140
+
141
+ # Continue updating
142
+ while not stop_thread.is_set():
143
+ checker.update()
144
+ stop_thread.wait(0.01)
145
+
146
+ # Start the thread
147
+ thread = threading.Thread(target=recovery_thread)
148
+ thread.daemon = True
149
+ thread.start()
150
+
151
+ # Wait for thread to initialize
152
+ frozen_time.tick(0.5)
153
+
154
+ # Verify thread is initially healthy
155
+ assert thread_id is not None, "Thread ID should be set"
156
+ assert checker.is_locked(thread_id), "Thread should be healthy after initialization"
157
+
158
+ # Pause updates to let the thread become unhealthy
159
+ pause_updates.set()
160
+
161
+ # Wait for thread to become unhealthy by advancing time
162
+ frozen_time.tick(health_timeout + 1)
163
+
164
+ # Verify thread is now unhealthy
165
+ assert not checker.is_locked(thread_id), "Thread should be unhealthy after timeout"
166
+
167
+ # Tell thread to resume updates
168
+ resume_updates.set()
169
+
170
+ # Wait for thread to recover
171
+ frozen_time.tick(1.0)
172
+ time.sleep(0.1)
173
+
174
+ # Verify thread is healthy again
175
+ assert checker.is_locked(thread_id), "Thread should be healthy after recovery"
176
+
177
+ # Clean up
178
+ stop_thread.set()
179
+ thread.join(timeout=1)
133
180
 
134
181
 
135
182
  class TestRedisAllocatorObject:
136
183
  """Tests for the RedisAllocatorObject class."""
137
-
138
- def test_initialization(self, allocator, test_object):
184
+
185
+ def test_initialization(self, redis_allocator: RedisAllocator, test_object: _TestObject):
139
186
  """Test that initialization correctly sets up the object."""
140
- # Create a test params dict
141
187
  params = {"param1": "value1", "param2": "value2"}
142
-
143
- # Create a RedisAllocatorObject
144
- obj = RedisAllocatorObject(allocator, "test_key", test_object, params)
145
-
146
- # Verify properties
147
- assert obj._allocator == allocator
188
+ obj = RedisAllocatorObject(redis_allocator, "test_key", test_object, params)
189
+
190
+ assert obj.allocator == redis_allocator
148
191
  assert obj.key == "test_key"
149
192
  assert obj.obj == test_object
150
193
  assert obj.params == params
151
-
152
- # Verify set_config was called on the wrapped object
153
194
  assert test_object.config_key == "test_key"
154
195
  assert test_object.config_params == params
155
-
156
- def test_initialization_with_defaults(self, allocator):
157
- """Test initialization with default None values."""
158
- # Create a RedisAllocatorObject with default None values
159
- obj = RedisAllocatorObject(allocator, "test_key")
160
-
161
- # Verify properties
162
- assert obj._allocator == allocator
163
- assert obj.key == "test_key"
164
- assert obj.obj is None
165
- assert obj.params is None
166
-
167
- def test_update(self, allocator, test_object):
168
- """Test the update method (renamed from lock)."""
169
- # Create a RedisAllocatorObject
170
- obj = RedisAllocatorObject(allocator, "test_key", test_object, {})
171
-
172
- # Reset mock
173
- allocator.update = MagicMock()
174
-
175
- # Call update with positive timeout
196
+
197
+ def test_update(self, redis_allocator: RedisAllocator, test_object: _TestObject, mocker: MockFixture):
198
+ """Test the update method."""
199
+ obj = RedisAllocatorObject(redis_allocator, "test_key", test_object, {})
200
+ mock_alloc_update = mocker.patch.object(redis_allocator, 'update')
201
+ mock_alloc_free = mocker.patch.object(redis_allocator, 'free')
176
202
  obj.update(60)
177
-
178
- # Verify update was called
179
- allocator.update.assert_called_once_with("test_key", timeout=60)
180
-
181
- def test_update_with_zero_timeout(self, allocator, test_object):
182
- """Test update with zero timeout, which should free the object."""
183
- # Create a RedisAllocatorObject
184
- obj = RedisAllocatorObject(allocator, "test_key", test_object, {})
185
-
186
- # Reset mocks
187
- allocator.update = MagicMock()
188
- allocator.free = MagicMock()
189
-
190
- # Call update with zero timeout
203
+ mock_alloc_update.assert_called_once_with("test_key", timeout=60)
204
+ mock_alloc_free.assert_not_called()
191
205
  obj.update(0)
192
-
193
- # Verify free was called instead of update
194
- allocator.update.assert_not_called()
195
- allocator.free.assert_called_once_with(obj)
196
-
197
- def test_close(self, allocator, test_object):
206
+ mock_alloc_update.assert_called_once()
207
+ mock_alloc_free.assert_called_once()
208
+
209
+ def test_close(self, redis_allocator: RedisAllocator, test_object: _TestObject):
198
210
  """Test the close method."""
199
- # Create a RedisAllocatorObject
200
- obj = RedisAllocatorObject(allocator, "test_key", test_object, {})
201
-
202
- # Call close
211
+ obj = RedisAllocatorObject(redis_allocator, "test_key", test_object, {})
212
+ obj.open()
213
+ assert not test_object.closed
203
214
  obj.close()
204
-
205
- # Verify close was called on the wrapped object
206
215
  assert test_object.closed
207
-
208
- def test_close_with_none_object(self, allocator):
209
- """Test the close method with None object."""
210
- # Create a RedisAllocatorObject with None object
211
- obj = RedisAllocatorObject(allocator, "test_key")
212
-
213
- # Call close should not raise any exception
214
- obj.close()
215
-
216
- def test_del(self, allocator, test_object):
216
+ obj.close() # Should not raise
217
+
218
+ def test_del(self, redis_allocator: RedisAllocator, test_object: _TestObject, mocker: MockFixture):
217
219
  """Test the __del__ method."""
218
- # Create a RedisAllocatorObject
219
- obj = RedisAllocatorObject(allocator, "test_key", test_object, {})
220
-
221
- # Patch close method to verify it gets called
222
- obj.close = MagicMock()
223
-
224
- # Simulate __del__ being called
220
+ obj = RedisAllocatorObject(redis_allocator, "test_key", test_object, {})
221
+ obj.close = mocker.MagicMock()
225
222
  obj.__del__()
226
-
227
- # Verify close was called
228
223
  obj.close.assert_called_once()
229
224
 
230
225
 
231
226
  class TestRedisAllocator:
232
227
  """Tests for the RedisAllocator class."""
233
-
234
- def test_initialization(self, redis_client):
235
- """Test the initialization of RedisAllocator."""
236
- allocator = RedisAllocator(redis_client, 'test', 'alloc-lock')
237
-
238
- # Should have an empty WeakValueDictionary for objects
239
- assert len(allocator.objects) == 0
240
- # Should be initialized with default values
241
- assert allocator.shared is False
242
- # Should have default soft_bind_timeout
243
- assert allocator.soft_bind_timeout == 3600
244
-
245
- def test_initialization_with_custom_values(self, redis_client):
246
- """Test initialization with custom values."""
247
- eps = 1e-8
248
- allocator = RedisAllocator(
249
- redis_client,
250
- 'custom_prefix',
251
- suffix='custom_suffix',
252
- eps=eps,
253
- shared=True
228
+
229
+ def get_redis_pool_state(self, redis_allocator: RedisAllocator, redis_client: Redis):
230
+ """Get the current state of the Redis pool."""
231
+ head_key = redis_client.get(redis_allocator._pool_pointer_str(True))
232
+ tail_key = redis_client.get(redis_allocator._pool_pointer_str(False))
233
+ pool_state = redis_client.hgetall(redis_allocator._pool_str())
234
+ locked_status = dict(redis_allocator.items_locked_status())
235
+ return {
236
+ "pool_state": pool_state,
237
+ "head_key": head_key,
238
+ "tail_key": tail_key,
239
+ "locked_status": locked_status
240
+ }
241
+
242
+ def time_to_expire(self, redis_client: Redis, timeout: int):
243
+ if timeout > 0:
244
+ return int(redis_client.time()[0]) + timeout
245
+ return -1
246
+
247
+ def generate_pool_state(self, redis_client: Redis, free_keys: list[str | tuple[str, int]], allocated_keys: list[str | tuple[str, int]]):
248
+ free_keys = [keys if isinstance(keys, tuple) else (keys, -1) for keys in free_keys]
249
+ allocated_keys = [keys if isinstance(keys, tuple) else (keys, -1) for keys in allocated_keys]
250
+
251
+ state = {
252
+ "pool_state": {},
253
+ "head_key": free_keys[0][0] if len(free_keys) > 0 else '',
254
+ "tail_key": free_keys[-1][0] if len(free_keys) > 0 else '',
255
+ "locked_status": {},
256
+ }
257
+ for prev_key, current_key, next_key in zip([('', -1)] + free_keys[:-1], free_keys, free_keys[1:] + [('', -1)]):
258
+ state["pool_state"][current_key[0]] = f'{prev_key[0]}||{next_key[0]}||{self.time_to_expire(redis_client, current_key[1])}'
259
+ state["locked_status"][current_key[0]] = False
260
+ for key in allocated_keys:
261
+ state["pool_state"][key[0]] = f'#ALLOCATED||#ALLOCATED||{self.time_to_expire(redis_client, key[1])}'
262
+ state["locked_status"][key[0]] = True
263
+ return state
264
+
265
+ def test_initialization(self, redis_allocator: RedisAllocator, redis_client: Redis):
266
+ """Test the initialization of Redisredis_allocator."""
267
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
268
+ redis_client,
269
+ ['key1', 'key2', 'key3'],
270
+ []
254
271
  )
255
-
256
- # Should have custom values
257
- assert allocator.prefix == 'custom_prefix'
258
- assert allocator.suffix == 'custom_suffix'
259
- assert allocator.eps == eps
260
- assert allocator.shared is True
261
-
262
- def test_object_key_non_shared(self, allocator, test_object):
263
- """Test the object_key method in non-shared mode."""
264
- # In non-shared mode, should return the key as is
265
- allocator.shared = False
266
- result = allocator.object_key("test_key", test_object)
267
- assert result == "test_key"
268
-
269
- def test_object_key_shared(self, allocator, test_object):
270
- """Test the object_key method in shared mode."""
271
- # In shared mode, should return key:obj
272
- allocator.shared = True
273
- result = allocator.object_key("test_key", test_object)
274
- assert result == f"test_key:{test_object}"
275
-
276
- def test_object_key_with_none(self, allocator):
277
- """Test the object_key method with None object."""
278
- # With None object, should still work
279
- allocator.shared = True
280
- result = allocator.object_key("test_key", None)
281
- assert result == "test_key:None"
282
-
283
- allocator.shared = False
284
- result = allocator.object_key("test_key", None)
285
- assert result == "test_key"
286
-
287
- def test_extend(self, allocator, redis_client):
288
- """Test the extend method."""
289
- # Clear any existing data
290
- redis_client.flushall()
291
-
292
- # Call extend
293
- allocator.extend(["key4", "key5"])
294
-
295
- # Verify keys were added
296
- assert "key4" in allocator
297
- assert "key5" in allocator
298
-
299
- def test_extend_empty(self, allocator, redis_client):
300
- """Test extend with empty keys."""
301
- # Clear any existing data
302
- redis_client.flushall()
303
-
304
- # Call extend with empty list
305
- allocator.extend([])
306
- allocator.extend(None)
307
-
308
- # No keys should be added
309
- assert len(list(allocator.keys())) == 0
310
-
311
- def test_shrink(self, allocator, redis_client):
312
- """Test the shrink method."""
313
- # Clear any existing data
314
- redis_client.flushall()
315
-
316
- # Add some keys first
317
- allocator.extend(["key1", "key2", "key3"])
318
-
319
- # Call shrink
320
- allocator.shrink(["key1", "key2"])
321
-
322
- # Verify keys were removed
323
- assert "key1" not in allocator
324
- assert "key2" not in allocator
325
- assert "key3" in allocator
326
-
327
- def test_shrink_empty(self, allocator, redis_client):
328
- """Test shrink with empty keys."""
329
- # Clear any existing data
330
- redis_client.flushall()
331
-
332
- # Add some keys first
333
- allocator.extend(["key1", "key2"])
334
-
335
- # Call shrink with empty list
336
- allocator.shrink([])
337
- allocator.shrink(None)
338
-
339
- # Keys should remain unchanged
340
- assert "key1" in allocator
341
- assert "key2" in allocator
342
-
343
- def test_assign(self, allocator, redis_client):
344
- """Test the assign method."""
345
- # Clear any existing data
346
- redis_client.flushall()
347
-
348
- # Add some initial keys
349
- allocator.extend(["key1", "key2"])
350
-
351
- # Call assign with new keys
352
- allocator.assign(["key3", "key4"])
353
-
354
- # Verify old keys are gone and new keys are present
355
- assert "key1" not in allocator
356
- assert "key2" not in allocator
357
- assert "key3" in allocator
358
- assert "key4" in allocator
359
-
360
- # Call assign with None
361
- allocator.assign(None)
362
-
363
- # All keys should be gone
364
- assert len(list(allocator.keys())) == 0
365
-
366
- def test_assign_empty(self, allocator, redis_client):
367
- """Test assign with empty keys."""
368
- # Clear any existing data
369
- redis_client.flushall()
370
-
371
- # Add some initial keys
372
- allocator.extend(["key1", "key2"])
373
-
374
- # Call assign with empty list
375
- allocator.assign([])
376
-
377
- # All keys should be gone
378
- assert len(list(allocator.keys())) == 0
379
-
380
- def test_clear(self, allocator, redis_client):
381
- """Test the clear method."""
382
- # Clear any existing data
383
- redis_client.flushall()
384
-
385
- # Add some keys
386
- allocator.extend(["key1", "key2"])
387
-
388
- # Call clear
389
- allocator.clear()
390
-
391
- # All keys should be gone
392
- assert len(list(allocator.keys())) == 0
393
-
394
- def test_redis_error_in_clear(self, allocator, redis_client):
395
- """Test handling Redis errors in clear."""
396
- # Clear any existing data
397
- redis_client.flushall()
398
-
399
- # Add some keys
400
- allocator.extend(["key1", "key2"])
401
-
402
- # Mock Redis error
403
- redis_client.delete = lambda *args: (_ for _ in ()).throw(RedisError("Test error"))
404
-
405
- # Call clear should raise RedisError
406
- with pytest.raises(RedisError):
407
- allocator.clear()
408
-
409
- def test_keys(self, allocator, redis_client):
410
- """Test the keys method."""
411
- # Clear any existing data
412
- redis_client.flushall()
413
-
414
- # Add some keys
415
- allocator.extend(["key1", "key2", "key3"])
416
-
417
- # Get keys
418
- result = list(allocator.keys())
419
-
420
- # Verify we got all keys
421
- assert set(result) == {"key1", "key2", "key3"}
422
-
423
- def test_redis_error_in_keys(self, allocator, redis_client):
424
- """Test handling Redis errors in keys."""
425
- # Clear any existing data
426
- redis_client.flushall()
427
-
428
- # Add some keys
429
- allocator.extend(["key1", "key2"])
430
-
431
- # Mock Redis error
432
- redis_client.hkeys = lambda *args: (_ for _ in ()).throw(RedisError("Test error"))
433
-
434
- # Getting keys should raise RedisError
435
- with pytest.raises(RedisError):
436
- list(allocator.keys())
437
-
438
- def test_contains(self, allocator, redis_client):
439
- """Test the __contains__ method."""
440
- # Clear any existing data
441
- redis_client.flushall()
442
-
443
- # Add some keys
444
- allocator.extend(["key1", "key2"])
445
-
446
- # Check containment
447
- assert "key1" in allocator
448
- assert "key2" in allocator
449
- assert "key3" not in allocator
450
-
451
- def test_redis_error_in_contains(self, allocator, redis_client):
452
- """Test handling Redis errors in __contains__."""
453
- # Clear any existing data
454
- redis_client.flushall()
455
-
456
- # Add some keys
457
- allocator.extend(["key1", "key2"])
458
-
459
- # Mock Redis error
460
- redis_client.hexists = lambda *args: (_ for _ in ()).throw(RedisError("Test error"))
461
-
462
- # Checking containment should raise RedisError
463
- with pytest.raises(RedisError):
464
- "key1" in allocator
465
-
466
- def test_update_soft_bind(self, allocator, redis_client):
467
- """Test the update_soft_bind method."""
468
- # Set up mock
469
- allocator.update = MagicMock()
470
-
471
- # Call update_soft_bind
472
- allocator.update_soft_bind("test_name", "test_key")
473
-
474
- # Verify update was called with the right parameters
475
- allocator.update.assert_called_once_with(
476
- allocator._soft_bind_name("test_name"),
477
- "test_key",
478
- timeout=allocator.soft_bind_timeout
272
+
273
+ def test_allocation_and_freeing(self, redis_allocator: RedisAllocator, redis_client: Redis):
274
+ """Test basic allocation/freeing using Lua scripts and verify Redis state."""
275
+ shared = redis_allocator.shared
276
+ test_keys = ["key1", "key2", "key3"]
277
+ redis_allocator.extend(test_keys)
278
+ self.test_initialization(redis_allocator, redis_client)
279
+
280
+ for key in test_keys:
281
+ assert key in redis_allocator
282
+ assert not redis_allocator.is_locked(key)
283
+ assert not redis_client.exists(redis_allocator._key_str(key))
284
+
285
+ allocated_key = redis_allocator.malloc_key(timeout=30)
286
+ assert allocated_key == "key1"
287
+ assert xor(shared, redis_allocator.is_locked(allocated_key))
288
+ assert xor(shared, redis_client.exists(redis_allocator._key_str(allocated_key)))
289
+ assert redis_client.ttl(redis_allocator._key_str(allocated_key)) <= 30
290
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
291
+ redis_client,
292
+ (['key2', 'key3', 'key1'] if shared else ['key2', 'key3']),
293
+ ([] if shared else ['key1'])
294
+ )
295
+
296
+ allocated_key = redis_allocator.malloc_key(timeout=30)
297
+ assert allocated_key == "key2"
298
+ assert xor(shared, redis_allocator.is_locked(allocated_key))
299
+ assert xor(shared, redis_client.exists(redis_allocator._key_str(allocated_key)))
300
+ assert redis_client.ttl(redis_allocator._key_str(allocated_key)) <= 30
301
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
302
+ redis_client,
303
+ (['key3', 'key1', 'key2'] if shared else ['key3']),
304
+ ([] if shared else ['key1', 'key2'])
305
+ )
306
+
307
+ redis_allocator.free_keys("key1")
308
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
309
+ redis_client,
310
+ (['key3', 'key1', 'key2'] if shared else ['key3', 'key1']),
311
+ ([] if shared else ['key2'])
479
312
  )
480
-
481
- def test_unbind_soft_bind(self, allocator, redis_client):
482
- """Test the unbind_soft_bind method."""
483
- # Set up mock
484
- allocator.unlock = MagicMock()
485
-
486
- # Call unbind_soft_bind
487
- allocator.unbind_soft_bind("test_name")
488
-
489
- # Verify unlock was called with the right parameter
490
- allocator.unlock.assert_called_once_with(allocator._soft_bind_name("test_name"))
491
-
492
- def test_soft_bind_with_empty_name(self, allocator):
493
- """Test soft bind methods with empty name."""
494
- # Set up mocks
495
- allocator.update = MagicMock()
496
- allocator.unlock = MagicMock()
497
-
498
- # Call methods with empty name
499
- allocator.update_soft_bind("", "test_key")
500
- allocator.unbind_soft_bind("")
501
-
502
- # Should still call the underlying methods with empty string
503
- allocator.update.assert_called_once()
504
- allocator.unlock.assert_called_once()
505
-
506
- # The soft bind name should be generated even with empty string
507
- assert allocator._soft_bind_name("") != ""
508
-
509
- def test_shared_vs_non_shared_allocation(self, allocator, shared_allocator):
510
- """Test difference between shared and non-shared allocation."""
511
- # Set up mocks for both allocators
512
- allocator._malloc_script = MagicMock(return_value="key1")
513
- shared_allocator._malloc_script = MagicMock(return_value="key1")
514
-
515
- # Check the malloc_lua_script property for both allocators
516
- non_shared_script = allocator._malloc_lua_script
517
- shared_script = shared_allocator._malloc_lua_script
518
-
519
- # The scripts should be different, with shared=0 in non-shared and shared=1 in shared
520
- assert "local shared = 0" in non_shared_script
521
- assert "local shared = 1" in shared_script
522
-
523
- # Both can allocate the same key, but behavior should differ
524
- assert allocator.malloc_key() == "key1"
525
- assert shared_allocator.malloc_key() == "key1"
313
+
314
+ {
315
+ "locked_status": {
316
+ 'key1': False,
317
+ 'key2': not shared,
318
+ 'key3': False
319
+ },
320
+ "pool_state": {
321
+ 'key1': f'key3||{("key2" if shared else "")}||-1',
322
+ 'key2': ('key1||||-1' if shared else '#ALLOCATED||#ALLOCATED||-1'),
323
+ 'key3': '||key1||-1'
324
+ },
325
+ "head_key": 'key3',
326
+ "tail_key": ('key2' if shared else 'key1')
327
+ }
328
+
329
+ redis_allocator.free_keys("key2")
330
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
331
+ redis_client,
332
+ ['key3', 'key1', 'key2'],
333
+ []
334
+ )
335
+
336
+ def test_allocation_with_object(self, redis_allocator: RedisAllocator, redis_client: Redis,
337
+ test_object: _TestObject):
338
+ """Test allocation with object wrapper using Lua scripts and verify Redis state."""
339
+ shared = redis_allocator.shared
340
+ params = {"param1": "value1"}
341
+ # Determine potential bind key (though it shouldn't be created)
342
+
343
+ bind_key = redis_allocator._soft_bind_name(test_object.name) if test_object.name else None
344
+
345
+ # --- Allocation ---
346
+ alloc_obj = redis_allocator.malloc(timeout=30, obj=test_object, params=params)
347
+
348
+ # Verify object and Redis state after allocation
349
+ assert alloc_obj is not None
350
+ assert isinstance(alloc_obj, RedisAllocatorObject)
351
+ assert alloc_obj.key == "key1"
352
+ assert alloc_obj.obj == test_object
353
+ assert alloc_obj.params == params
354
+ assert test_object.config_key == alloc_obj.key
355
+ assert test_object.config_params == params
356
+ assert xor(shared, redis_client.exists(redis_allocator._key_str(alloc_obj.key)))
357
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
358
+ redis_client,
359
+ (['key2', 'key3', 'key1'] if shared else ['key2', 'key3']),
360
+ ([] if shared else ['key1'])
361
+ )
362
+
363
+ if bind_key is not None:
364
+ assert redis_client.exists(bind_key)
365
+ redis_allocator.free(alloc_obj)
366
+
367
+ # Verify state after freeing
368
+ assert not redis_allocator.is_locked(alloc_obj.key)
369
+ assert redis_client.exists(redis_allocator._key_str(alloc_obj.key)) == 0
370
+ assert alloc_obj.key in redis_allocator
371
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
372
+ redis_client,
373
+ ['key2', 'key3', 'key1'],
374
+ []
375
+ )
376
+
377
+ def test_gc_functionality(self, allocator_with_policy: RedisAllocator, redis_client: Redis):
378
+ """Test GC scenarios by interacting directly with Redis via Lua scripts."""
379
+ shared = allocator_with_policy.shared
380
+ with freeze_time("2024-01-01") as time:
381
+ alloc_key1 = allocator_with_policy.malloc_key(timeout=1)
382
+ assert alloc_key1 == "key1"
383
+ alloc_key2 = allocator_with_policy.malloc_key(timeout=1)
384
+ assert alloc_key2 == "key2"
385
+ alloc_key3 = allocator_with_policy.malloc_key(timeout=1)
386
+ assert alloc_key3 == "key3"
387
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
388
+ redis_client,
389
+ (['key1', 'key2', 'key3'] if shared else []),
390
+ ([] if shared else ['key1', 'key2', 'key3'])
391
+ )
392
+ alloc_key4 = allocator_with_policy.malloc_key(timeout=1)
393
+ if shared:
394
+ assert alloc_key4 == "key1"
395
+ else:
396
+ assert alloc_key4 is None
397
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
398
+ redis_client,
399
+ (['key2', 'key3', 'key1'] if shared else []),
400
+ ([] if shared else ['key1', 'key2', 'key3'])
401
+ )
402
+ time.tick(1.1)
403
+ allocator_with_policy.gc()
404
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
405
+ redis_client,
406
+ (['key2', 'key3', 'key1'] if shared else ['key1', 'key2', 'key3']),
407
+ []
408
+ )
409
+ allocator_with_policy.extend(["key2"], timeout=2)
410
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
411
+ redis_client,
412
+ ([('key2', 2), 'key3', 'key1'] if shared else ['key1', ('key2', 2), 'key3']),
413
+ [])
414
+ alloc_key5 = allocator_with_policy.malloc_key(timeout=1)
415
+ assert alloc_key5 == ("key2" if shared else "key1")
416
+ time.tick(3)
417
+ allocator_with_policy.gc()
418
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
419
+ redis_client,
420
+ ['key3', 'key1'],
421
+ [])
422
+ assert allocator_with_policy.policy.updater.index == 0
423
+ assert len(allocator_with_policy) == 2
424
+ obj = allocator_with_policy.malloc()
425
+ assert len(allocator_with_policy) == 3
426
+ assert obj.key == "key3"
427
+ assert allocator_with_policy.policy.updater.index == 1
428
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
429
+ redis_client,
430
+ [('key1', 300), ('key2', 300), 'key3'] if shared else [('key1', 300), ('key2', 300)],
431
+ [] if shared else ['key3']
432
+ )
433
+ time.tick(600)
434
+ allocator_with_policy.gc()
435
+ allocator_with_policy.gc() # some times should be called twice to remove the expired items
436
+ allocator_with_policy.policy.refresh_pool(allocator_with_policy)
437
+ assert len(allocator_with_policy) == 4
438
+ allocator_with_policy.gc()
439
+ assert self.get_redis_pool_state(allocator_with_policy, redis_client) == self.generate_pool_state(
440
+ redis_client,
441
+ ["key3", ("key4", 300), ("key5", 300), ("key6", 300)],
442
+ []
443
+ )
444
+
445
+
446
+
447
+ def test_soft_binding(self, redis_allocator: RedisAllocator, redis_client: Redis, test_object: _TestObject):
448
+ """Test soft binding mechanism with direct Redis interaction."""
449
+ shared = redis_allocator.shared
450
+ with freeze_time("2024-01-01") as time:
451
+ # Extend the pool
452
+ allocation1 = redis_allocator.malloc(timeout=30, obj=test_object, cache_timeout=10)
453
+ assert allocation1.key == "key1"
454
+ assert xor(shared, redis_allocator.is_locked("key1"))
455
+ bind_str = redis_allocator._soft_bind_name(test_object.name) if test_object.name else None
456
+ if bind_str is not None:
457
+ assert redis_client.get(bind_str) == "key1"
458
+ assert 1 < redis_client.ttl(bind_str) <= 10
459
+ redis_allocator.free(allocation1)
460
+
461
+ assert not redis_allocator.is_locked("key1")
462
+ allocation2 = redis_allocator.malloc(timeout=30, obj=test_object, cache_timeout=10)
463
+ assert allocation2.key == ("key1" if bind_str else "key2")
464
+ if shared:
465
+ if bind_str:
466
+ state = ['key2', 'key3', 'key1'], []
467
+ else:
468
+ state = ['key3', 'key1', 'key2'], []
469
+ else:
470
+ if bind_str:
471
+ state = ['key2', 'key3'], ['key1']
472
+ else:
473
+ state = ['key3', 'key1'], ['key2']
474
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
475
+ redis_client,
476
+ *state
477
+ )
478
+ time.tick(31)
479
+ redis_allocator.gc()
480
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(
481
+ redis_client,
482
+ (['key2', 'key3', 'key1'] if bind_str else ['key3', 'key1', 'key2']),
483
+ []
484
+ )
485
+ allocation3 = redis_allocator.malloc_key(timeout=30, name="unbind", cache_timeout=10)
486
+ assert allocation3 == ("key2" if bind_str else "key3")
487
+ assert xor(shared, redis_allocator.is_locked(allocation3))
488
+ if bind_str:
489
+ if shared:
490
+ state = ['key3', 'key1', 'key2'], []
491
+ else:
492
+ state = ['key3', 'key1'], ['key2']
493
+ else:
494
+ if shared:
495
+ state = ['key1', 'key2', 'key3'], []
496
+ else:
497
+ state = ['key1', 'key2'], ['key3']
498
+ assert self.get_redis_pool_state(redis_allocator, redis_client) == self.generate_pool_state(redis_client, *state)