The kept log: every process rotates postulo.log on its own, so one rotation becomes several and history is lost #379
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#379
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading the logging configuration, the image's process layout and the standard library'sRotatingFileHandler.Checked again on 2026-10-01 against
mainat0a5cad587: still present.Every Postulo process configures its own
logging.handlers.RotatingFileHandleron the samepostulo.log: the three gunicorn workers in the web container, and the scheduler and worker containers when their profiles are on, all on the one/app/datavolume. The handler decides to rotate from the position of its own open file and renames the files without asking anybody else, which the Python logging cookbook states is unsupported across processes. One real rotation therefore turns into one rotation per process, the full generations are pushed off the end, and records land in files that are already rotated. Separately, the reader sorts rotations by name, so from ten backups onpostulo.log.10is read beforepostulo.log.2.Where:
config/settings/base.py:783-795(thefilehandler,logging.handlers.RotatingFileHandler, configured identically in every process);core/logs.py:201-207(files():sorted(path.parent.glob(f"{path.name}.*"), key=lambda p: p.name));docker/Dockerfile(--workers 3) anddocker/compose.yml(theschedulerandworkerservices mount the samepostulo-datavolume).What happens: with the defaults (
POSTULO_LOG_MAX_BYTES5 MB,POSTULO_LOG_BACKUPS3) and three workers A, B and C:postulo.logpast 5 MB, so A renames.2→.3,.1→.2,postulo.log→.1, and opens a newpostulo.log..1. Their next record goes there, and the one after it finds their position near 5 MB, so each rotates in turn: the oldest two generations are deleted and the few lines A has written into the new file become.1, then.2.After one real rotation the kept log holds one full generation instead of three, plus fragments, and Server settings → Logs and
/logsread records out of order across files. With the scheduler and worker running the cascade is five long. Not reproduced here, because on Windows the rename fails instead; the sequence follows fromshouldRolloverusingself.stream.tell()anddoRolloverrenaming unconditionally.Why it matters: the log is where an operator goes after something failed.
POSTULO_LOG_MAX_BYTES×POSTULO_LOG_BACKUPSpromises about 20 MB of history and delivers a fraction of it, straight after each rotation, and the page shows yesterday's error in the wrong place or not at all.Fix: have one writer per file. Either give each process its own file (
postulo-web-<pid>.logis awkward with gunicorn's recycling, sopostulo-web.logthrough aWatchedFileHandler,postulo-scheduler.log,postulo-worker.log) and havefiles()merge them by time; or keep one file written byWatchedFileHandlerin every process and rotate it from a single place (the scheduler, or the web container's entrypoint). Sort rotations by their numeric suffix infiles().Done when
tests/test_logs.pycreatespostulo.log.1topostulo.log.11and assertsfiles()returns them in numeric order.tests/test_logs.pyasserts that no process is configured to size-rotate a file another process writes: either thefilehandler inLOGGINGnames a file per process role, or it is aWatchedFileHandlerand rotation happens in one place.tests/test_logs.pywrites through two handlers configured as two processes would be, past the size limit, and asserts that every record written is still readable in time order.Landed as
258ae5ed8.Fixed, and by neither of the two designs suggested above. Here is why.
A file per process role (
postulo-web.log,postulo-scheduler.log,postulo-worker.log) still leaves gunicorn's three workers writing one file, so the web file needs exactly the coordination the single file needs, and the reader gains a merge by time across files. The entrypoint'smanage.pyruns (migrate,plugins sync,fetch_geonames,check --deploy) are further writers the issue did not list, each of which would need a role too.One file, rotated from a single place needs a place that is always running. The scheduler is an optional Compose profile, so on a default install nothing would ever rotate; the web container's entrypoint runs once, at start.
What was done instead: every process still writes
postulo.log, throughpostulo.core.logs.SharedRotatingFileHandler, which differs from the standard handler in two ways.flockonpostulo.log.lock, and looks again under the lock. A process that arrives behind another's rotation finds the path already changed and does nothing.The lock is never waited for: a log call must not block on another process. If the lock is taken a rotation is under way, and the record is written to the stream in hand, which at worst is the file that has just become
.1. It is kept, a few milliseconds out of place.Where there is no
fcntl(Windows, a developer's machine, one process) or the lock file cannot be created, the handler behaves as the standard one.On the "Done when" list:
files()returns.1to.11in numeric order, and reads only all-digit suffixes, so the lock file is not mistaken for a rotation.WatchedFileHandlerwith rotation in one place. The test asserts instead that the configured handler is the shared one. That meets what the item is for, not its wording..2; a held lock is respected; and four real processes writing the same file lose nothing and push out no half-empty rotation.Those four need a platform where an open file can be renamed, so they are skipped on Windows, where this was written. They ran and passed in CI on
17739fa1a(run 635, Python 3.12, 3.13 and 3.14).