Skip to content

Make debug.log rotation multiprocess-safe via concurrent-log-handler - #1442

Merged
jonfroehlich merged 3 commits into
masterfrom
1439-gunicorn-log-rotation-race
Jul 29, 2026
Merged

Make debug.log rotation multiprocess-safe via concurrent-log-handler#1442
jonfroehlich merged 3 commits into
masterfrom
1439-gunicorn-log-rotation-race

Conversation

@jonfroehlich

Copy link
Copy Markdown
Member

Fixes #1439.

Problem

Gunicorn runs 3 workers on -test and prod (docker-entrypoint.sh), each building its own RotatingFileHandler over the same media/debug.log. The stdlib handler is not multiprocess-safe: workers raced on rollover, renaming each other's freshly created files, so backups truncated far below maxBytes (11 KB files against a 5 MB threshold) and log records were silently lost. Full evidence and mechanism in #1439.

Fix (option 1 from the issue)

  • requirements.txt — add concurrent-log-handler==0.9.29 (sole transitive dep: portalocker). Purpose-built drop-in that takes a cross-process file lock around every write and rollover.
  • makeabilitylab/settings.py_file_log_handler now returns concurrent_log_handler.ConcurrentRotatingFileHandler with the same maxBytes/backupCount. Its lock file is redirected to /tmp via lock_file_directory — the default drops it next to the log, i.e. inside the web-served media root. /tmp is container-local, which is correct: all 3 workers share one container. The settings.py hardcodes the log file to /code/media/debug.log (breaks on any host without that path) #1283 NullHandler degrade path is untouched.
  • website/tests/test_logging_config.py — pins the new handler class; asserts the lock dir stays outside MEDIA_ROOT; and builds the real handler via logging.config.dictConfig, writes a record, and verifies no lock file lands beside the log — guarding the class path and constructor kwargs (a typo'd kwarg would crash django.setup() on every server).
  • Version bump to 2.31.2; comment touch-ups in settings_test.py and CLAUDE.md.

Testing

Full suite (726 tests) run in a one-off container against the running local stack with the new package installed: OK (8 pre-existing skips). The dictConfig test exercises the real 0.9.29 package, confirming lock_file_directory is a valid kwarg.

After deploy to -test, verify by checking that debug.log.N backups only ever appear at ~5 MB and that /version.json still reports log_to_file: true.

🤖 Generated with Claude Code

jonfroehlich and others added 3 commits July 28, 2026 17:45
…1439)

Gunicorn runs 3 workers on -test and prod, each opening the same
media/debug.log through LOGGING['handlers']['file']. The stdlib
RotatingFileHandler is not multiprocess-safe: when one worker rotated,
the others kept writing to the renamed inode and then rotated the
nearly-empty new file themselves, so backups truncated far below
maxBytes and log records were silently lost (see #1439 for the prod
evidence).

Swap the handler class for concurrent_log_handler.
ConcurrentRotatingFileHandler (new pinned dependency), which takes a
cross-process file lock around every write and rollover. Its lock file
is redirected to /tmp rather than its default location next to the log,
because LOG_FILE lives inside the web-served media root; /tmp is shared
by all workers since they run in one container. The #1283 NullHandler
degrade path is unchanged.

Tests pin the new handler class, assert the lock dir stays out of
MEDIA_ROOT, and build the real handler via dictConfig to guard the
class path and kwargs (a typo'd kwarg would crash django.setup() on
every server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five follow-ups from the review of PR #1442, all in the same area:

1. The handler class was a hard dependency: dictConfig raises ValueError on
   an unimportable class, aborting django.setup() with none of the #1283
   degrade signals. That state is reachable without a broken deploy -- the
   container bind-mounts the checkout over /code while site-packages come
   from the last image build, so a branch switch (or the gap between the
   deploy webhook's git pull and its build) can run this settings.py against
   an older image. Probe the import once and fall back to the stdlib
   RotatingFileHandler: racy on rollover, but logging keeps working.

2. Nothing validated the lock directory. gettempdir() itself can raise, and
   a lock file that can't be opened fails inside emit() -> handleError
   (stderr, unread on the servers) while /version.json still says healthy.
   Resolve and writability-check the dir up front; degrade if unusable.

3. The lock path was a fixed name in a world-writable sticky dir
   (/tmp/.__debug.lock, opened "r+"). A root-created one -- the devcontainer
   connects as root -- locks the apache workers out permanently, since /tmp's
   sticky bit stops them unlinking it. Namespace the dir by uid, so a lock is
   only ever shared with same-uid processes (the deployed case).

4. The instantiation test's dictConfig closed every handler Django had
   configured, for the rest of the test process. Re-apply settings.LOGGING.

5. django.db.backends emitted a record per SQL query on -test (DEBUG=True) --
   the bulk of the volume behind the rapid rollovers, now also the biggest
   payer of the per-record cross-process lock, and raw SQL in a publicly
   downloadable file. Pinned to INFO.

Degrading quietly is the thing #1283 exists to prevent, so the resulting
handler is reported as log_rotation on /version.json (derived from the built
LOGGING dict so it can't drift) and printed as a startup warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…otation-race

# Conflicts:
#	CLAUDE.md
#	makeabilitylab/settings.py
@jonfroehlich
jonfroehlich merged commit 6ecd33b into master Jul 29, 2026
3 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.

Gunicorn workers race on debug.log rotation, causing log loss

1 participant