Skip to content

Commit 1b17601

Browse files
committed
keep TTL during migration
both cli and lazy migration test
1 parent 6ee4852 commit 1b17601

File tree

3 files changed

+83
-14
lines changed

3 files changed

+83
-14
lines changed

agent_memory_server/cli.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,33 +188,39 @@ async def run_migration():
188188
for batch_start in range(0, len(string_keys), batch_size):
189189
batch_keys = string_keys[batch_start : batch_start + batch_size]
190190

191-
# First, read all string data in a pipeline
191+
# Read all string data and TTLs in a pipeline
192192
read_pipe = redis.pipeline()
193193
for key in batch_keys:
194194
read_pipe.get(key)
195-
string_data_list = await read_pipe.execute()
195+
read_pipe.ttl(key)
196+
results = await read_pipe.execute()
197+
198+
# Parse results (alternating: data, ttl, data, ttl, ...)
199+
migrations = [] # List of (key, data, ttl) tuples
200+
for i, key in enumerate(batch_keys):
201+
string_data = results[i * 2]
202+
ttl = results[i * 2 + 1]
196203

197-
# Parse and prepare migration
198-
migrations = [] # List of (key, data) tuples
199-
for key, string_data in zip(batch_keys, string_data_list, strict=False):
200204
if string_data is None:
201205
continue
202206

203207
try:
204208
if isinstance(string_data, bytes):
205209
string_data = string_data.decode("utf-8")
206210
data = json_module.loads(string_data)
207-
migrations.append((key, data))
211+
migrations.append((key, data, ttl))
208212
except Exception as e:
209213
errors += 1
210214
logger.error(f"Failed to parse key {key}: {e}")
211215

212-
# Execute migrations in a pipeline (delete + json.set)
216+
# Execute migrations in a pipeline (delete + json.set + expire if needed)
213217
if migrations:
214218
write_pipe = redis.pipeline()
215-
for key, data in migrations:
219+
for key, data, ttl in migrations:
216220
write_pipe.delete(key)
217221
write_pipe.json().set(key, "$", data)
222+
if ttl > 0:
223+
write_pipe.expire(key, ttl)
218224

219225
try:
220226
await write_pipe.execute()
@@ -224,10 +230,12 @@ async def run_migration():
224230
logger.warning(
225231
f"Batch migration failed, retrying individually: {e}"
226232
)
227-
for key, data in migrations:
233+
for key, data, ttl in migrations:
228234
try:
229235
await redis.delete(key)
230236
await redis.json().set(key, "$", data)
237+
if ttl > 0:
238+
await redis.expire(key, ttl)
231239
migrated += 1
232240
except Exception as e2:
233241
errors += 1

agent_memory_server/working_memory.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,13 +181,17 @@ async def _migrate_string_to_json(
181181
logger.info(f"Migrating working memory key {key} from string to JSON format")
182182

183183
# Atomically migrate the key from string to JSON using a Lua script
184-
# The script: if key is string, get value, delete, set as JSON; else do nothing
184+
# The script: get TTL, get value, delete, set as JSON, restore TTL if > 0
185185
lua_script = """
186186
local key = KEYS[1]
187187
if redis.call('TYPE', key).ok == 'string' then
188+
local ttl = redis.call('TTL', key)
188189
local val = redis.call('GET', key)
189190
redis.call('DEL', key)
190191
redis.call('JSON.SET', key, '$', ARGV[1])
192+
if ttl > 0 then
193+
redis.call('EXPIRE', key, ttl)
194+
end
191195
return val
192196
else
193197
return nil
@@ -196,10 +200,6 @@ async def _migrate_string_to_json(
196200
# Pass the JSON string as ARGV[1]
197201
await redis_client.eval(lua_script, 1, key, json.dumps(data))
198202

199-
# Preserve TTL if it was set
200-
# Note: TTL is lost during migration since we deleted the key
201-
# The next set_working_memory call will restore it if configured
202-
203203
logger.info(f"Successfully migrated working memory key {key} to JSON format")
204204

205205
# Decrement the counter (O(1) instead of re-scanning all keys)

tests/test_working_memory.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,67 @@ async def test_backward_compatibility_string_to_json_migration(
435435
assert retrieved_again is not None
436436
assert retrieved_again.session_id == session_id
437437

438+
@pytest.mark.asyncio
439+
async def test_migration_preserves_ttl(self, async_redis_client):
440+
"""Test that TTL is preserved when migrating from string to JSON format."""
441+
import json
442+
443+
from agent_memory_server.working_memory import reset_migration_status
444+
445+
# Reset migration status to ensure lazy migration is active
446+
reset_migration_status()
447+
448+
session_id = "test-ttl-migration-session"
449+
namespace = "test-namespace"
450+
ttl_seconds = 3600 # 1 hour
451+
452+
# Create old-format data with TTL
453+
old_format_data = {
454+
"messages": [],
455+
"memories": [],
456+
"session_id": session_id,
457+
"namespace": namespace,
458+
"context": None,
459+
"user_id": None,
460+
"tokens": 0,
461+
"ttl_seconds": ttl_seconds,
462+
"data": {},
463+
"long_term_memory_strategy": {"strategy": "discrete"},
464+
"last_accessed": 1704067200,
465+
"created_at": 1704067200,
466+
"updated_at": 1704067200,
467+
}
468+
469+
# Store as old string format with TTL
470+
key = Keys.working_memory_key(session_id=session_id, namespace=namespace)
471+
await async_redis_client.set(key, json.dumps(old_format_data), ex=ttl_seconds)
472+
473+
# Verify TTL is set
474+
ttl_before = await async_redis_client.ttl(key)
475+
assert ttl_before > 0
476+
assert ttl_before <= ttl_seconds
477+
478+
# Trigger migration by reading
479+
retrieved_mem = await get_working_memory(
480+
session_id=session_id,
481+
namespace=namespace,
482+
redis_client=async_redis_client,
483+
)
484+
assert retrieved_mem is not None
485+
486+
# Verify key was migrated to JSON
487+
key_type = await async_redis_client.type(key)
488+
if isinstance(key_type, bytes):
489+
key_type = key_type.decode("utf-8")
490+
assert key_type == "ReJSON-RL"
491+
492+
# Verify TTL was preserved
493+
ttl_after = await async_redis_client.ttl(key)
494+
assert ttl_after > 0
495+
# TTL should be close to original (within a few seconds of test execution)
496+
assert ttl_after <= ttl_seconds
497+
assert ttl_after >= ttl_before - 5 # Allow 5 seconds for test execution
498+
438499
@pytest.mark.asyncio
439500
async def test_check_and_set_migration_status_with_no_keys(
440501
self, async_redis_client

0 commit comments

Comments
 (0)