Skip to content

Fix memory leak in fakeredis Lua script execution#300

Merged
chrisguidry merged 3 commits intomainfrom
fakeredis-memory-leak
Jan 26, 2026
Merged

Fix memory leak in fakeredis Lua script execution#300
chrisguidry merged 3 commits intomainfrom
fakeredis-memory-leak

Conversation

@chrisguidry
Copy link
Owner

@chrisguidry chrisguidry commented Jan 26, 2026

Summary

  • Restructure Lua callback caching in the fakeredis monkeypatch to avoid creating new functools.partial objects on every eval() call
  • Add collectgarbage() after each script execution to clean up Lua tables
  • Fix flaky logging test that was broken by newer fakeredis DEBUG logging

Problem

The memory:// backend was leaking memory when executing Lua scripts. Every eval() created new partial objects for callbacks that were held by the Lua runtime, preventing garbage collection.

Related: PrefectHQ/prefect#18605

Testing

Created a test harness running a perpetual task at ~10 evals/second:

  • Before fix: Memory grew several MB per minute
  • After fix: RSS stays flat at ~200KB above baseline after 1000+ evals

The same fix has been submitted upstream: cunla/fakeredis-py#452

🤖 Generated with Claude Code

The fakeredis `memory://` backend was leaking memory when executing Lua
scripts. Every `eval()` call created new `functools.partial` objects for
the Lua callbacks (redis.call, redis.pcall, redis.log, cjson.encode,
cjson.decode), and these were held by the Lua runtime, preventing GC.

This restructures the monkeypatch to:
- Create callback wrappers once at runtime init that look up the current
  socket dynamically
- Cache static partials for log/cjson functions on the server
- Only update KEYS/ARGV per call via a lightweight Lua function
- Call `collectgarbage()` after each script to clean up Lua tables

Testing methodology: Created a harness running a perpetual task at 10
evals/second. Before the fix, memory grew several MB per minute. After
the fix, RSS stays flat at ~200KB above baseline even after 1000+ evals.

The same fix has been submitted upstream: cunla/fakeredis-py#452

Also adds objgraph to dev dependencies for memory debugging.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@github-actions
Copy link

github-actions bot commented Jan 26, 2026

📚 Documentation has been built for this PR!

You can download the documentation directly here:
https://github.com/chrisguidry/docket/actions/runs/21343148421/artifacts/5251609881

@codecov-commenter
Copy link

codecov-commenter commented Jan 26, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.64%. Comparing base (0062982) to head (0c87f23).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #300   +/-   ##
=======================================
  Coverage   99.64%   99.64%           
=======================================
  Files          98       98           
  Lines        9766     9767    +1     
  Branches      468      468           
=======================================
+ Hits         9731     9732    +1     
  Misses         20       20           
  Partials       15       15           
Flag Coverage Δ
python-3.10 99.64% <100.00%> (+<0.01%) ⬆️
python-3.11 98.35% <100.00%> (+<0.01%) ⬆️
python-3.12 99.64% <100.00%> (+<0.01%) ⬆️
python-3.13 99.64% <100.00%> (+<0.01%) ⬆️
python-3.14 99.63% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/docket/_redis.py 100.00% <ø> (ø)
tests/fundamentals/test_logging.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69e4791ea6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +513 to 516
# Only update KEYS and ARGV per call (callbacks are already set)
self._server._lua_set_keys_argv(
lua_runtime.table_from(keys_and_args[:numkeys]),
lua_runtime.table_from(keys_and_args[numkeys:]),

Choose a reason for hiding this comment

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

P2 Badge Reinitialize redis/cjson per eval to avoid leaked state

Because set_globals_init (which assigns redis, cjson, and module globals) now runs only when _lua_runtime is first created, subsequent EVAL calls only update KEYS/ARGV. Any script that mutates those globals (e.g., redis.call = nil, cjson = {}, or overwriting module globals) will leave the Lua environment corrupted for later scripts, whereas the previous implementation reset these globals on every call. This can cause later scripts to fail or behave incorrectly; consider re-running the globals setup each eval while reusing cached callbacks.

Useful? React with 👍 / 👎.

chrisguidry and others added 2 commits January 25, 2026 20:13
This was added during memory leak debugging but isn't needed as a
permanent dev dependency.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
The test was checking caplog.text for absence of unlogged values, but
fakeredis DEBUG logs contain raw pickle data that includes those values.
Filter to only docket-originated logs for the assertion.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@chrisguidry
Copy link
Owner Author

Thanks for the review! This is a fair observation about the changed semantics.

The old code did reset redis, cjson, and module globals on every eval() call, and now we only set them up once at runtime initialization.

However, I think this is fine because:

  1. Scripts that mutate these globals are buggy - No legitimate Lua script should do redis.call = nil or cjson = {}. That's self-sabotage.

  2. Real Redis has the same shared state - The Lua environment in actual Redis is also shared between script executions. We're now actually more consistent with real Redis behavior, not less.

  3. The _check_for_lua_globals check exists - fakeredis already validates that scripts don't create unexpected new globals after execution.

The memory leak was causing real problems (MB/minute growth under heavy Lua usage), and the fix is worth the theoretical edge case of scripts that corrupt their own globals.

@chrisguidry chrisguidry merged commit c62a121 into main Jan 26, 2026
72 of 73 checks passed
@chrisguidry chrisguidry deleted the fakeredis-memory-leak branch January 26, 2026 01:35
chrisguidry added a commit to PrefectHQ/prefect that referenced this pull request Jan 26, 2026
pydocket 0.17.2 includes a fix for a memory leak in fakeredis's Lua
script execution that was causing unbounded memory growth when using
the `memory://` URL for the Docket broker. Each `eval()` call was
creating `functools.partial` objects that got held by the Lua runtime
and never collected.

Related to #18605

See also:
- chrisguidry/docket#300
- cunla/fakeredis-py#452

Co-Authored-By: Claude Opus 4.5 <[email protected]>
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.

2 participants