Skip to content

Commit 3a872c1

Browse files
committed
feat(DEVC-1286): update migration logic
1 parent 03ada1e commit 3a872c1

7 files changed

Lines changed: 232 additions & 264 deletions

File tree

src/corva/cache_adapter.py

Lines changed: 56 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,33 @@
99
import redis
1010
import semver
1111

12+
from version import VERSION
13+
1214

1315
class CacheRepositoryProtocol(Protocol):
1416
def set(
1517
self,
1618
key: str,
1719
value: str,
1820
ttl: int,
19-
) -> None:
20-
...
21+
) -> None: ...
2122

22-
def set_many(self, data: Sequence[Tuple[str, str, int]]) -> None:
23-
...
23+
def set_many(self, data: Sequence[Tuple[str, str, int]]) -> None: ...
2424

25-
def get(self, key: str) -> Optional[str]:
26-
...
25+
def get(self, key: str) -> Optional[str]: ...
2726

28-
def get_many(self, keys: Sequence[str]) -> Dict[str, Optional[str]]:
29-
...
27+
def get_many(self, keys: Sequence[str]) -> Dict[str, Optional[str]]: ...
3028

31-
def get_all(self) -> Dict[str, str]:
32-
...
29+
def get_all(self) -> Dict[str, str]: ...
3330

34-
def delete(self, key: str) -> None:
35-
...
31+
def delete(self, key: str) -> None: ...
3632

37-
def delete_many(self, keys: Sequence[str]) -> None:
38-
...
33+
def delete_many(self, keys: Sequence[str]) -> None: ...
3934

40-
def delete_all(self) -> None:
41-
...
35+
def delete_all(self) -> None: ...
4236

4337

4438
class RedisRepository:
45-
4639
def __init__(self, hash_name: str, client: redis.Redis):
4740
self.hash_name = hash_name
4841
self.client = client
@@ -85,45 +78,49 @@ def delete_all(self) -> None:
8578

8679
class HashMigrator:
8780
MINIMUM_ALLOWED_REDIS_SERVER = semver.Version(major=7, minor=4, patch=0)
81+
NEW_HASH_PREFIX = "/new"
8882

8983
def __init__(self, hash_name: str, client: redis.Redis):
9084
self.hash_name = hash_name
91-
self.zset_name = f'{hash_name}.EXPIREAT'
85+
self.zset_name = f"{hash_name}.EXPIREAT"
9286
self.client = client
9387

94-
def _proper_redis_server_version(self) -> bool:
88+
def check_redis_server_version(self) -> None:
9589
# Require Redis 7.4+ for per-field TTL commands
96-
redis_version_str = self.client.info(section="server").get("redis_version")
97-
98-
if not redis_version_str:
99-
return False
100-
90+
redis_version_str = self.client.info(section="server")["redis_version"]
10191
server_version = semver.Version.parse(version=redis_version_str)
10292

10393
if server_version < self.MINIMUM_ALLOWED_REDIS_SERVER:
104-
return False
105-
106-
return True
94+
raise RuntimeError(
95+
f"Redis server version {server_version} "
96+
f"less then {self.MINIMUM_ALLOWED_REDIS_SERVER} -> "
97+
f"incompatible with used python SDK version `{VERSION}`")
10798

10899
def run(self) -> bool:
109-
"""Migrate from old Lua+ZSET per-field TTL to Redis built-in per-field TTL.
100+
"""Prepare parallel new-style cache while keeping legacy structures.
110101
111-
Safe to call multiple times. Behavior:
112-
- If the legacy ZSET ("<hash>.EXPIREAT") does not exist → return False.
113-
- Requires Redis server version >= 7.4.0 (built-in per-field hash TTLs).
102+
Behavior (idempotent):
103+
- If legacy ZSET ("<hash>.EXPIREAT") does not exist → return False.
104+
- Requires Redis server >= 7.4.0 for per-field hash TTL commands.
105+
- Creates a new hash key with prefix NEW_HASH_PREFIX + hash_name.
114106
- For each zset member (field → absolute ms deadline):
115-
• Past deadline → HDEL the field.
116-
• Future deadline → set per-field TTL via HPEXPIRE (milliseconds).
117-
- After processing completes: PERSIST the hash and DELETE the legacy ZSET.
107+
• Past deadline → do not copy field to new hash
108+
• Future deadline → copy current value from legacy hash to new hash and
109+
set per-field TTL via HPEXPIRE (milliseconds) on the new hash.
110+
- Legacy hash and legacy ZSET are preserved intact to allow rollback.
118111
119-
Returns True if migration was attempted on a supported server (i.e., legacy
120-
ZSET existed); otherwise False.
112+
Returns True if the new-style cache was created during this run
121113
"""
114+
self.check_redis_server_version()
115+
122116
# Legacy structure must exist; otherwise nothing to do
123117
if not self.client.exists(self.zset_name):
124118
return False
125119

126-
if not self._proper_redis_server_version():
120+
new_hash_name = self.NEW_HASH_PREFIX + self.hash_name
121+
122+
# If new hash already exists, consider migration already done
123+
if self.client.exists(new_hash_name):
127124
return False
128125

129126
from corva import Logger
@@ -132,27 +129,35 @@ def run(self) -> bool:
132129
sec, micro = self.client.time()
133130
now_ms = int(sec) * 1000 + int(micro) // 1000
134131

135-
# Queue all operations in a single pipeline and execute once
132+
# Create pipeline for batched ops on the NEW hash
136133
pipe = self.client.pipeline()
137134

135+
# Ensure the new hash key exists
136+
# Copy fields from old hash into the new one based on ZSET deadlines
138137
for field, score in self.client.zscan_iter(self.zset_name):
139138
# score is the absolute deadline in ms
140139
deadline_ms = int(float(score))
141-
ttl_ms = deadline_ms - now_ms
142-
if ttl_ms <= 0:
143-
pipe.hdel(self.hash_name, field)
144-
else:
145-
pipe.execute_command(
146-
"HPEXPIRE", self.hash_name, ttl_ms, "FIELDS", 1, field
147-
)
140+
remaining_ttl_ms = deadline_ms - now_ms
141+
if remaining_ttl_ms <= 0:
142+
continue
143+
144+
value = self.client.hget(self.hash_name, field)
145+
if value is None:
146+
# No value to copy (may have been removed already)
147+
continue
148+
149+
# Write to the new hash and apply per-field TTL there
150+
pipe.hset(new_hash_name, field, value)
151+
pipe.execute_command(
152+
"HPEXPIRE", new_hash_name, remaining_ttl_ms, "FIELDS", 1, field
153+
)
148154

149155
# Execute queued field operations (no-op if nothing queued)
150156
pipe.execute()
151157

152-
# Remove key-level TTL and legacy ZSET now that fields are migrated
153-
self.client.persist(self.hash_name)
154-
self.client.delete(self.zset_name)
155-
156-
# Migration was attempted because legacy ZSET existed
157-
Logger.info(f"Migration success: hash_name = '{self.hash_name}'")
158+
# Do NOT modify/persist legacy structures; keep them for rollback
159+
Logger.info(
160+
f"Migration prepared parallel cache: legacy='{self.hash_name}', "
161+
f"new='{new_hash_name}'"
162+
)
158163
return True

src/corva/service/cache_sdk.py

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,24 @@
1717

1818

1919
class UserCacheSdkProtocol(Protocol):
20-
def set(self, key: str, value: str, ttl: int = ...) -> None:
21-
...
20+
def set(self, key: str, value: str, ttl: int = ...) -> None: ...
2221

2322
def set_many(
2423
self, data: Sequence[Union[Tuple[str, str], Tuple[str, str, int]]]
25-
) -> None:
26-
...
24+
) -> None: ...
2725

28-
def get(self, key: str) -> Optional[str]:
29-
...
26+
def get(self, key: str) -> Optional[str]: ...
3027

31-
def get_many(self, keys: Sequence[str]) -> Dict[str, Optional[str]]:
32-
...
28+
def get_many(self, keys: Sequence[str]) -> Dict[str, Optional[str]]: ...
3329

34-
def get_all(self) -> Dict[str, str]:
35-
...
30+
def get_all(self) -> Dict[str, str]: ...
3631

3732
# TODO: remove asterisk in v2 - it was added for backward compatibility
38-
def delete(self, *, key: str) -> None:
39-
...
33+
def delete(self, *, key: str) -> None: ...
4034

41-
def delete_many(self, keys: Sequence[str]) -> None:
42-
...
35+
def delete_many(self, keys: Sequence[str]) -> None: ...
4336

44-
def delete_all(self) -> None:
45-
...
37+
def delete_all(self) -> None: ...
4638

4739

4840
class UserRedisSdk:
@@ -70,17 +62,15 @@ def __init__(
7062
elif redis_client is None:
7163
redis_client = redis.Redis.from_url(url=redis_dsn, decode_responses=True)
7264

65+
migrator = HashMigrator(hash_name, redis_client)
66+
migrator.run()
67+
hash_name = HashMigrator.NEW_HASH_PREFIX + hash_name
68+
7369
self.cache_repo = cache_adapter.RedisRepository(
7470
hash_name=hash_name,
7571
client=cast(redis.Redis, redis_client),
7672
)
7773

78-
try:
79-
migrator = HashMigrator(hash_name, redis_client)
80-
migrator.run()
81-
except Exception:
82-
pass
83-
8474
def set(self, key: str, value: str, ttl: int = SIXTY_DAYS) -> None:
8575
self.cache_repo.set(key=key, value=value, ttl=ttl)
8676

@@ -112,9 +102,3 @@ def delete_many(self, keys: Sequence[str]) -> None:
112102

113103
def delete_all(self) -> None:
114104
self.cache_repo.delete_all()
115-
116-
117-
118-
119-
120-

src/plugin.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ def pytest_load_initial_conftests(args, early_config, parser):
3636
'CACHE_URL': 'redis://localhost:6379',
3737
'APP_KEY': f'{provider}.test-app-name',
3838
'PROVIDER': provider,
39-
'FAKEREDIS_LUA_VERSION': "5.2",
4039
**os.environ, # override env values if provided by user
4140
}
4241
os.environ.update(env)

tests/conftest.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
from typing import cast
1+
from typing import Iterable, cast
22

33
import pytest
44
from fakeredis import FakeRedis
55
from redis import Redis
66

7+
from corva import cache_adapter
78
from corva.configuration import SETTINGS
89
from corva.testing import TestClient
910
from corva.validate_app_init import read_manifest
1011

12+
from .utils.patch_fakeredis import info # noqa
1113

12-
@pytest.fixture(scope='function', autouse=True)
13-
def clean_real_redis():
14+
15+
@pytest.fixture(scope="function")
16+
def redis_adapter(redis_client: Redis) -> Iterable[cache_adapter.RedisRepository]:
17+
redis_adapter = cache_adapter.RedisRepository(
18+
hash_name="test_hash_name", client=redis_client
19+
)
20+
yield redis_adapter
21+
22+
23+
@pytest.fixture(scope="function")
24+
def redis_client():
1425
redis_client = Redis.from_url(url=SETTINGS.CACHE_URL, decode_responses=True)
1526

1627
redis_client.flushall()
@@ -20,24 +31,25 @@ def clean_real_redis():
2031
redis_client.flushall()
2132

2233

23-
@pytest.fixture(scope='function', autouse=True)
24-
def clean_fake_redis():
25-
redis_client = cast(FakeRedis, FakeRedis.from_url(url=SETTINGS.CACHE_URL))
26-
27-
redis_client.flushall()
28-
34+
@pytest.fixture(scope="function", autouse=True)
35+
def clean_redis_clients():
36+
redis_clients = (
37+
Redis.from_url(url=SETTINGS.CACHE_URL),
38+
cast(FakeRedis, FakeRedis.from_url(url=SETTINGS.CACHE_URL)),
39+
)
40+
[client.flushall() for client in redis_clients]
2941
yield
3042

31-
redis_client.flushall()
43+
[client.flushall() for client in redis_clients]
3244

3345

34-
@pytest.fixture(scope='function', autouse=True)
46+
@pytest.fixture(scope="function", autouse=True)
3547
def clean_read_manifest_lru_cache():
3648
read_manifest.cache_clear()
3749
yield
3850
read_manifest.cache_clear()
3951

4052

41-
@pytest.fixture(scope='function')
53+
@pytest.fixture(scope="function")
4254
def context():
4355
return TestClient._context

0 commit comments

Comments
 (0)