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.
- redis_allocator/__init__.py +5 -1
- redis_allocator/_version.py +1 -0
- redis_allocator/allocator.py +819 -280
- redis_allocator/lock.py +66 -17
- redis_allocator/task_queue.py +81 -57
- redis_allocator-0.3.2.dist-info/METADATA +529 -0
- redis_allocator-0.3.2.dist-info/RECORD +15 -0
- {redis_allocator-0.0.1.dist-info → redis_allocator-0.3.2.dist-info}/licenses/LICENSE +21 -21
- tests/conftest.py +150 -46
- tests/test_allocator.py +461 -488
- tests/test_lock.py +675 -338
- tests/test_task_queue.py +136 -136
- redis_allocator-0.0.1.dist-info/METADATA +0 -229
- redis_allocator-0.0.1.dist-info/RECORD +0 -14
- {redis_allocator-0.0.1.dist-info → redis_allocator-0.3.2.dist-info}/WHEEL +0 -0
- {redis_allocator-0.0.1.dist-info → redis_allocator-0.3.2.dist-info}/top_level.txt +0 -0
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
|
10
|
-
|
11
|
-
|
12
|
-
from
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
86
|
-
"""Test
|
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
|
-
|
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,
|
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
|
-
|
144
|
-
obj
|
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
|
157
|
-
"""Test
|
158
|
-
|
159
|
-
|
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
|
-
|
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
|
-
|
194
|
-
|
195
|
-
|
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
|
-
|
200
|
-
obj
|
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
|
-
|
209
|
-
|
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
|
-
|
219
|
-
obj =
|
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
|
235
|
-
"""
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
redis_client
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
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
|
-
|
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
|
-
assert
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
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
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
#
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
assert
|
521
|
-
assert
|
522
|
-
|
523
|
-
|
524
|
-
assert
|
525
|
-
|
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)
|