Skip to content

Commit 6ecd33b

Browse files
authored
Merge pull request #1442 from makeabilitylab/1439-gunicorn-log-rotation-race
Make debug.log rotation multiprocess-safe via concurrent-log-handler
2 parents f7cc8fa + 0cf16c9 commit 6ecd33b

7 files changed

Lines changed: 288 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ the existing viewset/serializer pattern and keep `v1` fields additive-only
130130
- **Prod/test `config.ini` has only a `[Django]` section — no `[Postgres]` section.** Per `settings.py`, a missing `[Postgres]` section means Django uses the fallback `DATABASES` default (`HOST='db'`) — i.e. the dockerized `db` service of the active compose file. A `[Postgres]` section, if added, would override it. So the DB is the in-stack `db` container in **every** environment (no external Postgres); on the servers that's the `db` service in `docker-compose.yml`.
131131
- `DEBUG` resolution order: `DJANGO_ENV=PROD` forces False → `config.ini [Django] DEBUG``DJANGO_ENV=DEBUG` forces True → default False.
132132
- `TIME_ZONE = 'America/Los_Angeles'`. `ML_WEBSITE_VERSION` in settings is shown in the admin header and used in release tagging.
133-
- **Logging (#1283):** `debug.log` lives at `LOG_DIR/debug.log`, where `LOG_DIR` is `$ML_LOG_DIR` or `<BASE_DIR>/media` (`/code/media` in the container). Keep it inside `MEDIA_ROOT` — that's the tree bind-mounted to the shared CSE filesystem, so it's what makes the log readable over SSH at all. `ML_LOG_DIR` is unset everywhere today; it exists for non-`/code` hosts. `MEDIA_ROOT` is web-served, so never log anything sensitive. If the dir isn't writable the file handler degrades to a `NullHandler` rather than crashing `django.setup()`, and since there's no console on the servers that state surfaces via `/version.json` (`log_to_file`) and a superuser-only callout on the admin dashboard.
133+
- **Logging (#1283):** `debug.log` lives at `LOG_DIR/debug.log`, where `LOG_DIR` is `$ML_LOG_DIR` or `<BASE_DIR>/media` (`/code/media` in the container). Keep it inside `MEDIA_ROOT` — that's the tree bind-mounted to the shared CSE filesystem, so it's what makes the log readable over SSH at all. `ML_LOG_DIR` is unset everywhere today; it exists for non-`/code` hosts. `MEDIA_ROOT` is web-served, so never log anything sensitive. If the dir isn't writable the file handler degrades to a `NullHandler` rather than crashing `django.setup()`, and since there's no console on the servers that state surfaces via `/version.json` (`log_to_file`) and a superuser-only callout on the admin dashboard. Rotation uses `concurrent-log-handler` (#1439) because Gunicorn's 3 workers share one file — the stdlib `RotatingFileHandler` races on rollover across processes. Its lock file goes in a per-uid temp dir (`/tmp/makelab-log-locks-<uid>`), never the web-served media root and never shared across users. If the package isn't importable (the bind-mounted checkout can be ahead of the image's site-packages) or no lock dir is usable, the handler degrades to the stdlib `RotatingFileHandler` instead of crashing `django.setup()`; `/version.json` reports which one is live as `log_rotation`. `django.db.backends` is pinned to INFO so per-query SQL doesn't dominate the log (or the lock).
134134

135135
### Container startup side effects (`docker-entrypoint.sh`)
136136

makeabilitylab/settings.py

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"""
1414

1515
import os
16-
from configparser import ConfigParser
16+
import tempfile # for the log-rotation lock-file dir, see _file_log_handler
17+
from configparser import ConfigParser
1718
import datetime # for DATE_MAKEABILITYLAB_FORMED global
1819

1920
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -86,8 +87,8 @@
8687
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
8788

8889
# Makeability Lab Global Variables, including Makeability Lab version
89-
ML_WEBSITE_VERSION = "2.32.0" # Keep this updated with each release and also change the short description below
90-
ML_WEBSITE_VERSION_DESCRIPTION = "Positions can now be titled \"Research Software Engineer\", and the affiliation fields are relabeled \"Institution or organization\" and \"Department or unit\" — collaborators come from nonprofits and companies, not just universities (#1437). This release also carries the 2.31.1 log-path fix (#1283)."
90+
ML_WEBSITE_VERSION = "2.32.1" # Keep this updated with each release and also change the short description below
91+
ML_WEBSITE_VERSION_DESCRIPTION = "debug.log rotation is now multiprocess-safe (concurrent-log-handler): Gunicorn's three workers previously raced on rollover and silently lost log records (#1439)."
9192
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
9293
MAX_BANNERS = 7 # Maximum number of banners on a page
9394

@@ -122,6 +123,21 @@
122123
# degraded state is surfaced two web-reachable ways instead: the 'log_to_file' field
123124
# on /version.json (website/views/version.py) and a warning callout on the admin
124125
# dashboard (website/templates/admin/index.html).
126+
# Probed once, at import, so a missing package degrades the handler instead of
127+
# killing startup (see _file_log_handler). This matters because the container
128+
# bind-mounts the repo over /code while site-packages come from whenever the
129+
# image was last built: a branch switch or the window between the deploy
130+
# webhook's `git pull` and `docker compose build` can leave new settings.py
131+
# running against an older image. dictConfig raises ValueError ("Unable to
132+
# configure handler 'file'") on an unimportable class, and that aborts
133+
# django.setup() — no NullHandler degrade, no /version.json, no admin callout.
134+
try:
135+
import concurrent_log_handler # noqa: F401 (imported only to probe availability)
136+
_HAS_CONCURRENT_LOG_HANDLER = True
137+
except ImportError:
138+
_HAS_CONCURRENT_LOG_HANDLER = False
139+
140+
125141
def _ensure_log_dir_writable(log_dir):
126142
"""Create ``log_dir`` if needed and return True if it looks writable.
127143
@@ -135,7 +151,7 @@ def _ensure_log_dir_writable(log_dir):
135151
136152
1. This checks the *directory*, not the eventual log file. A dir that is
137153
writable but already holds a root-owned, read-only ``debug.log`` would
138-
still let RotatingFileHandler raise on open. That doesn't match the real
154+
still let the file handler raise on open. That doesn't match the real
139155
deploy model, where media/ is owned by the app's own user.
140156
2. ``os.access(dir, os.W_OK)`` returns True for root regardless of the
141157
directory mode, so a mode-555 dir wouldn't be caught when running as root.
@@ -151,27 +167,82 @@ def _ensure_log_dir_writable(log_dir):
151167
return False
152168

153169

170+
def _lock_file_directory():
171+
"""Return a writable directory for the rotation lock file, or None.
172+
173+
``ConcurrentRotatingFileHandler`` coordinates processes through a lock file.
174+
Two things about *where* that file goes matter here:
175+
176+
1. By default it lands next to the log — i.e. inside the web-served media
177+
root (LOG_FILE lives there so the file is reachable over SSH;
178+
see the LOG_DIR note below). Lock files must not be in the public tree.
179+
``test_lock_file_stays_out_of_media_root`` pins this.
180+
2. The lock name is derived from the log's basename alone, so every process
181+
sharing one temp dir shares ``/tmp/.__debug.lock`` — and that file is
182+
opened ``"r+"``. If a root process creates it first (the devcontainer
183+
connects as root; see CLAUDE.md), the ``apache`` workers get a
184+
PermissionError on every write and, thanks to /tmp's sticky bit, can't
185+
even unlink it to recover — file logging would die silently. So the
186+
directory is namespaced by uid: processes only ever share a lock with
187+
same-uid processes, which is exactly the case that needs the locking
188+
(all Gunicorn workers run as ``apache`` in one container).
189+
190+
Returns None if the directory can't be created or written — including the
191+
pathological case where ``gettempdir()`` itself finds nowhere usable and
192+
raises. The caller then falls back to the stdlib handler, because the
193+
concurrent handler surfaces lock failures through ``handleError`` (stderr,
194+
which nothing reads on the servers) while ``/version.json`` would still
195+
report healthy — the exact blind spot #1283 exists to close.
196+
"""
197+
try:
198+
lock_dir = os.path.join(tempfile.gettempdir(),
199+
f'makelab-log-locks-{os.getuid()}')
200+
except (OSError, AttributeError):
201+
# No usable temp dir, or no getuid() (non-POSIX host).
202+
return None
203+
return lock_dir if _ensure_log_dir_writable(lock_dir) else None
204+
205+
154206
def _file_log_handler(log_file, level, enabled):
155207
"""Return the ``LOGGING['handlers']['file']`` config dict.
156208
157-
When ``enabled`` is False (the log dir isn't writable) this returns a
158-
NullHandler instead, which keeps every logger's ``'file'`` handler reference
159-
valid while never touching disk — so startup degrades instead of dying.
160-
161-
Split out of the ``LOGGING`` literal so both branches are directly testable;
209+
Three outcomes, in descending order of goodness:
210+
211+
* ``ConcurrentRotatingFileHandler`` (issue #1439) — the intended one. On
212+
-test and prod, Gunicorn runs 3 worker processes (docker-entrypoint.sh)
213+
that each open the *same* log file, and the stdlib ``RotatingFileHandler``
214+
is not multiprocess-safe: workers raced on rollover, renaming each other's
215+
freshly created files and silently dropping records. The concurrent
216+
handler takes a cross-process lock around every write and rollover.
217+
* The stdlib ``RotatingFileHandler`` — if the package isn't importable or
218+
no lock directory is usable. Racy on rollover (i.e. #1439 is back), but
219+
logging keeps working, which beats aborting ``django.setup()``.
220+
* ``NullHandler`` — ``enabled`` is False, meaning the log dir isn't
221+
writable. Keeps every logger's ``'file'`` handler reference valid while
222+
never touching disk (issue #1283).
223+
224+
Only the first is multiprocess-safe, so which one we got is reported as
225+
``log_rotation`` on ``/version.json`` (there is no console on the servers).
226+
227+
Split out of the ``LOGGING`` literal so the branches are directly testable;
162228
``LOGGING`` is evaluated once at import, so a test can't re-derive it.
163229
See ``website/tests/test_logging_config.py``.
164230
"""
165231
if not enabled:
166232
return {'class': 'logging.NullHandler'}
167-
return {
233+
handler = {
168234
'level': level,
169235
'class': 'logging.handlers.RotatingFileHandler',
170236
'filename': log_file,
171237
'maxBytes': 1024*1024*5, # 5 MB
172238
'backupCount': 6,
173239
'formatter': 'verbose', # can switch between verbose and simple
174240
}
241+
lock_dir = _lock_file_directory() if _HAS_CONCURRENT_LOG_HANDLER else None
242+
if lock_dir is not None:
243+
handler['class'] = 'concurrent_log_handler.ConcurrentRotatingFileHandler'
244+
handler['lock_file_directory'] = lock_dir
245+
return handler
175246

176247

177248
# NOTE: this default must stay in sync with MEDIA_ROOT (defined further down as
@@ -247,6 +318,16 @@ def _file_log_handler(log_file, level, enabled):
247318
'django.utils.autoreload': {
248319
'level': 'INFO', # Change to 'INFO' or 'WARNING'
249320
},
321+
# Django logs every SQL query here at DEBUG, but only when DEBUG is on.
322+
# That is on for local dev *and* on -test, where it was the bulk of the
323+
# log volume behind #1439's rapid rollovers — and every record now takes
324+
# a cross-process lock, so the noisiest logger is also the one paying
325+
# the most for it. It also puts raw SQL in a file that is publicly
326+
# downloadable (see the LOG_DIR note). Pinned to INFO, which silences
327+
# query logging; set it back to DEBUG locally if you need to see them.
328+
'django.db.backends': {
329+
'level': 'INFO',
330+
},
250331
# This logger captures information about incoming HTTP requests, including details
251332
# about the request method, URL, and any exceptions that occur during request
252333
# processing. It’s useful for getting a high-level overview of the requests
@@ -269,6 +350,21 @@ def _file_log_handler(log_file, level, enabled):
269350
},
270351
}
271352

353+
# Which rotation handler we actually ended up with — derived from the built
354+
# config rather than tracked separately so the two can't drift. Uppercase on
355+
# purpose: like LOG_TO_FILE, it's read through django.conf.settings, here by
356+
# /version.json ('log_rotation'). Only 'ConcurrentRotatingFileHandler' is
357+
# multiprocess-safe; 'RotatingFileHandler' means we degraded and #1439's
358+
# rollover race is live again, which is otherwise invisible on the servers.
359+
LOG_ROTATION = LOGGING['handlers']['file']['class'].rsplit('.', 1)[-1]
360+
361+
if LOG_TO_FILE and LOG_ROTATION != 'ConcurrentRotatingFileHandler':
362+
# Secondary signal only, same as the LOG_TO_FILE warning above: the channel
363+
# that works remotely is /version.json ('log_rotation').
364+
print(f"WARNING: falling back to {LOG_ROTATION} — log rotation is NOT "
365+
f"multiprocess-safe (concurrent-log-handler importable: "
366+
f"{_HAS_CONCURRENT_LOG_HANDLER}). Check /version.json 'log_rotation'.")
367+
272368
# Application definition
273369
INSTALLED_APPS = [
274370
'website.apps.WebsiteConfig',

makeabilitylab/settings_test.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@
3434
"DATABASE_PORT", DATABASES["default"].get("PORT", "5432") # noqa: F405
3535
)
3636

37-
# The base settings wire a RotatingFileHandler to /code/media/debug.log (a
37+
# The base settings wire a rotating file handler to /code/media/debug.log (a
3838
# container path). Django evaluates LOGGING at startup, so on any host without
3939
# that directory — a CI runner, a fresh checkout — django.setup() crashes
4040
# before a single test runs. Swap just the 'file' handler for a no-op; this
4141
# keeps every logger's handler reference valid while never touching disk.
4242
LOGGING["handlers"]["file"] = {"class": "logging.NullHandler"} # noqa: F405
43+
# Keep the derived setting honest — it names the handler class actually wired
44+
# up, and /version.json reports it (#1439).
45+
LOG_ROTATION = "NullHandler"
4346

4447
# Speed up the auth tests (Data Health suite creates real superuser rows).
4548
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

requirements.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ django-prose-editor[sanitize]==0.26.0
133133
# See: https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/gunicorn/
134134
gunicorn==23.0.0
135135

136+
# concurrent-log-handler - multiprocess-safe rotating file log handler, used by
137+
# LOGGING['handlers']['file'] in settings.py. Gunicorn's 3 workers each open the
138+
# same media/debug.log, and the stdlib RotatingFileHandler is not
139+
# multiprocess-safe: workers raced on rollover and silently lost log records
140+
# (issue #1439). This handler takes a cross-process file lock (via its
141+
# portalocker dependency) around every write and rollover.
142+
concurrent-log-handler==0.9.29
143+
136144
# -----------------------------------------------------------------------------
137145
# Security & Networking
138146
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)