fix: rotate-mode-aware zero-retention delete in make_rotator (logsetup-1)

v0.5.1's rotate="size" fix added an unconditional os.remove(dest) at
backup_count<=0 in make_rotator to bound the live file, but the rotator is
shared with rotate="daily" and had no mode awareness - every midnight roll
under daily with backup_count=0 was deleting the just-rolled log instead of
leaving it unpruned. make_rotator/attach_rolling now take rotate_mode and
gate the delete-on-land branch to "size" only, matching the documented
contract that only size always bounds the live file. Bump 0.6.1 -> 0.6.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-03 16:15:03 -04:00
parent 4d1acc3a47
commit e78a384f1a
5 changed files with 30 additions and 16 deletions
+8 -2
View File
@@ -13,12 +13,12 @@ and emit; their records flow into the handlers `log_setup` wired.
## Install ## Install
``` ```
log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v0.6.1 log_setup @ git+ssh://git@git.rethinkstudios.io/rethink-public/log_setup.git@v0.6.2
``` ```
No dependencies — stdlib only. No dependencies — stdlib only.
Drop the `@v0.6.1` suffix from the line above to install the latest unpinned. Drop the `@v0.6.2` suffix from the line above to install the latest unpinned.
## Quick start ## Quick start
@@ -248,6 +248,12 @@ setup_logging(name="run", queue=True)
documented (keeps that many rolled files). This does not change `"daily"`/`"on_start"`, documented (keeps that many rolled files). This does not change `"daily"`/`"on_start"`,
where `backup_count=0` still means "roll, but don't prune the rolled files" (unbounded where `backup_count=0` still means "roll, but don't prune the rolled files" (unbounded
`log_dir` growth) — that is a separate, pre-existing knob, not this fix's scope. `log_dir` growth) — that is a separate, pre-existing knob, not this fix's scope.
- **`"daily"` regression fixed (v0.6.2).** v0.5.1's `rotate="size"` fix above shared its
rotator with `"daily"`, so a `rotate="daily", backup_count=0` roll was incorrectly
deleted at every midnight rollover instead of just landing unpruned. The rotator is now
rotate-mode aware: the zero-retention delete only ever fires for `"size"`, matching the
contract in the bullet above — `"daily"`/`"on_start"` with `backup_count=0` were always
meant to roll without pruning and now do again.
- **Gzip writes are crash-safe (v0.5.1+).** `_gzip_file` now writes to a `.tmp` sibling and - **Gzip writes are crash-safe (v0.5.1+).** `_gzip_file` now writes to a `.tmp` sibling and
atomically `os.replace`s it onto the final `.gz` path, so a crash/OOM/power-loss mid-write atomically `os.replace`s it onto the final `.gz` path, so a crash/OOM/power-loss mid-write
can never leave a truncated `.gz` at the path retention logic trusts. Tiered retention's can never leave a truncated `.gz` at the path retention logic trusts. Tiered retention's
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "log_setup" name = "log_setup"
version = "0.6.1" version = "0.6.2"
description = "stdlib app-entry-point logging setup: live run.log, rotation, gzip, retention, consistent format" description = "stdlib app-entry-point logging setup: live run.log, rotation, gzip, retention, consistent format"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = []
+1 -1
View File
@@ -13,4 +13,4 @@ from .setup import setup_logging
__all__ = ["setup_logging"] __all__ = ["setup_logging"]
__version__ = "0.6.1" __version__ = "0.6.2"
+14 -6
View File
@@ -143,16 +143,20 @@ def make_rotator(
compress: bool, log_dir: Optional[str] = None, compress: bool, log_dir: Optional[str] = None,
prune_stem: Optional[str] = None, backup_count: int = 0, prune_stem: Optional[str] = None, backup_count: int = 0,
keep_uncompressed: Optional[int] = None, keep_compressed: Optional[int] = None, keep_uncompressed: Optional[int] = None, keep_compressed: Optional[int] = None,
rotate_mode: Optional[str] = None,
) -> Callable[[str, str], None]: ) -> Callable[[str, str], None]:
"""rotator: move (or gzip) the source live file to the destination rolled path """rotator: move (or gzip) the source live file to the destination rolled path
legacy mode (default): gzip on roll when `compress`, then prune `log_dir` to legacy mode (default): gzip on roll when `compress`, then prune `log_dir` to
`backup_count` newest rolled files - the stdlib handler's own retention only scans `backup_count` newest rolled files - the stdlib handler's own retention only scans
the live file's directory, so it never sees files redirected into `log_dir`; pruning the live file's directory, so it never sees files redirected into `log_dir`; pruning
here is what bounds retention for daily/size. FOOTGUN: `backup_count <= 0` means here is what bounds retention for daily/size. FOOTGUN: for `rotate_mode="size"`,
"keep no rolled history", but `prune()` itself no-ops at `<= 0` (its own sentinel for `backup_count <= 0` means "keep no rolled history", but `prune()` itself no-ops at
"don't touch history") - so a zero-retention roll is deleted by the rotator directly `<= 0` (its own sentinel for "don't touch history") - so a zero-retention roll is
right after landing, rather than relying on prune to do it. deleted by the rotator directly right after landing, rather than relying on prune to
do it. `rotate_mode` gates this delete-on-land branch to `"size"` only - `"daily"`
(and any other non-size mode) with `backup_count <= 0` still rolls without pruning,
matching the documented contract that only `size` always bounds the live file.
tiered mode (when `keep_uncompressed`/`keep_compressed` are given): land the rolled tiered mode (when `keep_uncompressed`/`keep_compressed` are given): land the rolled
file PLAIN and re-tier `log_dir` - newest `keep_uncompressed` stay uncompressed, next file PLAIN and re-tier `log_dir` - newest `keep_uncompressed` stay uncompressed, next
@@ -176,6 +180,7 @@ def make_rotator(
else: else:
_move(source, dest) _move(source, dest)
if backup_count <= 0: if backup_count <= 0:
if rotate_mode == "size":
try: try:
os.remove(dest) os.remove(dest)
except OSError: except OSError:
@@ -359,7 +364,7 @@ def attach_rolling(
handler, log_dir: str, compress: bool, handler, log_dir: str, compress: bool,
prune_stem: Optional[str] = None, backup_count: int = 0, prune_stem: Optional[str] = None, backup_count: int = 0,
keep_uncompressed: Optional[int] = None, keep_compressed: Optional[int] = None, keep_uncompressed: Optional[int] = None, keep_compressed: Optional[int] = None,
tiered: bool = False, tiered: bool = False, rotate_mode: Optional[str] = None,
) -> Tuple[Callable, Callable]: ) -> Tuple[Callable, Callable]:
"""wire the custom namer + rotator onto a rotating handler; return them """wire the custom namer + rotator onto a rotating handler; return them
@@ -372,13 +377,16 @@ def attach_rolling(
pass `prune_stem`/`backup_count` so the rotator prunes `log_dir` after each roll pass `prune_stem`/`backup_count` so the rotator prunes `log_dir` after each roll
(the handler's own retention can't see the redirected files). pass (the handler's own retention can't see the redirected files). pass
`keep_uncompressed`/`keep_compressed` for tiered retention instead (see `keep_uncompressed`/`keep_compressed` for tiered retention instead (see
make_rotator); `tiered=True` lands rolls plain (retier compresses). make_rotator); `tiered=True` lands rolls plain (retier compresses). `rotate_mode`
("size"/"daily") is forwarded to `make_rotator` so the zero-retention delete-on-land
branch only ever fires for `"size"`.
""" """
namer = make_history_namer( namer = make_history_namer(
os.path.basename(prune_stem or ""), log_dir, compress, plain=tiered, os.path.basename(prune_stem or ""), log_dir, compress, plain=tiered,
) )
rotator = make_rotator( rotator = make_rotator(
compress, log_dir, prune_stem, backup_count, keep_uncompressed, keep_compressed, compress, log_dir, prune_stem, backup_count, keep_uncompressed, keep_compressed,
rotate_mode=rotate_mode,
) )
handler.namer = namer handler.namer = namer
handler.rotator = rotator handler.rotator = rotator
+2 -2
View File
@@ -149,7 +149,7 @@ def _file_handler(
attach_rolling( attach_rolling(
handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count, handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count,
keep_uncompressed=keep_uncompressed, keep_compressed=keep_compressed, keep_uncompressed=keep_uncompressed, keep_compressed=keep_compressed,
tiered=tiered, tiered=tiered, rotate_mode=rotate,
) )
elif rotate == "daily": elif rotate == "daily":
handler = logging.handlers.TimedRotatingFileHandler( handler = logging.handlers.TimedRotatingFileHandler(
@@ -158,7 +158,7 @@ def _file_handler(
attach_rolling( attach_rolling(
handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count, handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count,
keep_uncompressed=keep_uncompressed, keep_compressed=keep_compressed, keep_uncompressed=keep_uncompressed, keep_compressed=keep_compressed,
tiered=tiered, tiered=tiered, rotate_mode=rotate,
) )
else: else:
if rotate == "on_start": if rotate == "on_start":