Skip to content

Conversation

@fcenedes
Copy link
Collaborator

@fcenedes fcenedes commented Dec 4, 2025

Poor attempt to fix #69

Changes

  • Removing json_dump (as we rely on pydantic models anyway
  • Removing ISO date time handler (managed by pydantic)
  • Storing working memory as json natively instead of String (Hash has been discarded because the schema is nested)

Poort attempt to fix redis#69
@jit-ci
Copy link

jit-ci bot commented Dec 4, 2025

Hi, I’m Jit, a friendly security platform designed to help developers build secure applications from day zero with an MVS (Minimal viable security) mindset.

In case there are security findings, they will be communicated to you as a comment inside the PR.

Hope you’ll enjoy using Jit.

Questions? Comments? Want to learn more? Get in touch with us.

@fcenedes
Copy link
Collaborator Author

fcenedes commented Dec 5, 2025

For backward compatibility, adding a hybrid strategy :

Lazy migration when an old string type is met -> migrated to JSON -> recheck existing type string, if none, it activates a fast path. On startup, check if at least one string type exists without blocking a long startup in case of many keys to convert

- Cli for migration (very large number of keys)
- Benchmark
- Early exit
- edge case bench (1M json key - 1 string key)
@fcenedes
Copy link
Collaborator Author

fcenedes commented Dec 5, 2025

Edge case 1M json key - 1 string key in the end

  • added pipeline
  • early exit
  • cli for migration...
Metric Before (sequential) After (pipelined) Improvement
Worst-case scan (1M keys) 93.99s 4.09s 23x faster

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates working memory storage from Redis STRING format (JSON serialized as strings) to native Redis JSON format using the RedisJSON module. The migration includes backward compatibility through lazy migration and a CLI command for bulk migration.

Key Changes:

  • Removed custom JSON datetime handler in favor of pydantic's built-in serialization
  • Implemented lazy migration with early-exit optimization for startup performance
  • Added CLI command migrate-working-memory for bulk migration of existing keys

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
agent_memory_server/working_memory.py Core migration logic with global state tracking, lazy migration on read, and native JSON storage for writes
agent_memory_server/cli.py New CLI command for bulk migration with dry-run support and progress reporting
agent_memory_server/main.py Application startup now checks migration status to enable/disable lazy migration
tests/test_working_memory_strategies.py Updated mocks to use redis.json() API instead of string get/set
tests/test_working_memory.py Added comprehensive migration tests including backward compatibility scenarios
tests/benchmarks/test_migration_benchmark.py New benchmark suite to measure migration performance at scale
.gitignore Added pattern to ignore augment markdown files

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 179 to 194
click.echo("\n--dry-run specified, no changes made.")
return

# Migrate keys in batches using pipeline
click.echo(f"\nMigrating {len(string_keys)} keys...")
migrate_start = time.time()
migrated = 0
errors = 0

# Process in batches
for batch_start in range(0, len(string_keys), batch_size):
batch_keys = string_keys[batch_start : batch_start + batch_size]

# First, read all string data in a pipeline
read_pipe = redis.pipeline()
for key in batch_keys:
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration command does not preserve TTL when migrating keys from string to JSON format. Before deleting the old key (line 193), get and preserve its TTL:

# Get TTL before deleting
ttl = await redis.ttl(key)
await redis.delete(key)
await redis.json().set(key, "$", data)
# Restore TTL if it was set (ttl > 0)
if ttl > 0:
    await redis.expire(key, ttl)

This ensures keys with expiration don't become permanent after migration.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines 22 to 27
# Flag to track if all string keys have been migrated to JSON
# When True, we skip the type() check and go straight to json().get()
_string_keys_migrated: bool = False

def json_datetime_handler(obj):
"""JSON serializer for datetime objects."""
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
# Counter for remaining string keys (avoids re-scanning after each migration)
_remaining_string_keys: int = 0
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global variables _string_keys_migrated and _remaining_string_keys are not thread-safe and won't be shared across multiple uvicorn workers. In a multi-worker deployment, each worker process will have its own copy of these variables, causing:

  1. Redundant migration checks on startup (each worker scans independently)
  2. Race conditions when decrementing the counter during lazy migration
  3. Inconsistent migration state across workers

Consider using Redis itself to store the migration status (e.g., a Redis key like "migration:working_memory:complete") or use proper inter-process synchronization mechanisms.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 92 to 111
_string_keys_migrated = True
_remaining_string_keys = 0
return True


def _decrement_string_key_count() -> None:
"""
Decrement the string key counter after a successful migration.
Note: When _remaining_string_keys is -1, we don't know the exact count
(early exit mode). In this case, lazy migration stays enabled until
the migration script is run.
"""
global _string_keys_migrated, _remaining_string_keys

# If we don't know the count (-1), we can't track completion
# The migration script will set the flag when done
if _remaining_string_keys == -1:
return

Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function _decrement_string_key_count() modifies global state (_remaining_string_keys, _string_keys_migrated) without any synchronization mechanism. In a concurrent environment, multiple async tasks could call this function simultaneously, leading to:

  1. Lost decrements (two tasks read the same value, decrement, and write back)
  2. Race conditions when checking if migration is complete

Consider using an asyncio.Lock or storing the counter in Redis with atomic operations (e.g., DECR command).

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually a good idea...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 164 to 170
# Delete the old string key and set as JSON
await redis_client.delete(key)
await redis_client.json().set(key, "$", data)

# Preserve TTL if it was set
# Note: TTL is lost during migration since we deleted the key
# The next set_working_memory call will restore it if configured
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TTL is lost during migration from string to JSON format. The comment acknowledges this (line 164-165 in original file), but this means that working memory entries with TTL will have their expiration removed during migration, potentially causing them to persist indefinitely until explicitly deleted.

Consider preserving the TTL during migration by:

  1. Getting the TTL before deleting the old key: ttl = await redis_client.ttl(key)
  2. After setting the JSON key, restore the TTL if it was set: if ttl > 0: await redis_client.expire(key, ttl)
Suggested change
# Delete the old string key and set as JSON
await redis_client.delete(key)
await redis_client.json().set(key, "$", data)
# Preserve TTL if it was set
# Note: TTL is lost during migration since we deleted the key
# The next set_working_memory call will restore it if configured
# Preserve TTL if it was set
ttl = await redis_client.ttl(key)
# Delete the old string key and set as JSON
await redis_client.delete(key)
await redis_client.json().set(key, "$", data)
# Restore TTL if it was set (ttl > 0)
if ttl > 0:
await redis_client.expire(key, ttl)

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


# Scan for working_memory:* keys
cursor = 0
json_keys_found = 0
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable string_keys_found is not used.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, i disagree ;-)

@fcenedes
Copy link
Collaborator Author

fcenedes commented Dec 5, 2025

Ok one last touch for those who go with the cli to migrate and no longer need any backward compatibilty logic :

introducing yet another env variable

abrookins and others added 5 commits December 4, 2025 17:16
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@abrookins
Copy link
Collaborator

@fcenedes I'm having trouble pushing a fix to your branch, but basically, you want to run the pre-commit formatter:

uv run pre-commit run --all-files

@abrookins
Copy link
Collaborator

Feel free to add a noqa comment to skip the ruff complaint about no elif after returns

@fcenedes
Copy link
Collaborator Author

done

both cli and lazy migration
test
well... light refactor isn't it ? ;-)
@abrookins
Copy link
Collaborator

Confirmed that tests pass locally (API keys issue with PRs).

Copy link
Collaborator

@abrookins abrookins left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this, @fcenedes! 💯 LGTM

@abrookins abrookins merged commit 17378d0 into redis:main Dec 10, 2025
12 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save working memory as a hash or JSON object instead of string

2 participants