diff --git a/README.md b/README.md index dc78085..e0f2aff 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ and emit; their records flow into the handlers `log_setup` wired. ## 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. -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 @@ -248,6 +248,12 @@ setup_logging(name="run", queue=True) 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 `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 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 diff --git a/pyproject.toml b/pyproject.toml index 6b9d56e..1b7f8d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] 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" requires-python = ">=3.10" dependencies = [] diff --git a/src/log_setup/__init__.py b/src/log_setup/__init__.py index bdd73c0..9d74e04 100644 --- a/src/log_setup/__init__.py +++ b/src/log_setup/__init__.py @@ -13,4 +13,4 @@ from .setup import setup_logging __all__ = ["setup_logging"] -__version__ = "0.6.1" +__version__ = "0.6.2" diff --git a/src/log_setup/rotation.py b/src/log_setup/rotation.py index 7dcd2a6..87c6903 100644 --- a/src/log_setup/rotation.py +++ b/src/log_setup/rotation.py @@ -143,16 +143,20 @@ def make_rotator( compress: bool, log_dir: Optional[str] = None, prune_stem: Optional[str] = None, backup_count: int = 0, keep_uncompressed: Optional[int] = None, keep_compressed: Optional[int] = None, + rotate_mode: Optional[str] = None, ) -> Callable[[str, str], None]: """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 `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 - here is what bounds retention for daily/size. FOOTGUN: `backup_count <= 0` means - "keep no rolled history", but `prune()` itself no-ops at `<= 0` (its own sentinel for - "don't touch history") - so a zero-retention roll is deleted by the rotator directly - right after landing, rather than relying on prune to do it. + here is what bounds retention for daily/size. FOOTGUN: for `rotate_mode="size"`, + `backup_count <= 0` means "keep no rolled history", but `prune()` itself no-ops at + `<= 0` (its own sentinel for "don't touch history") - so a zero-retention roll is + 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 file PLAIN and re-tier `log_dir` - newest `keep_uncompressed` stay uncompressed, next @@ -176,10 +180,11 @@ def make_rotator( else: _move(source, dest) if backup_count <= 0: - try: - os.remove(dest) - except OSError: - pass + if rotate_mode == "size": + try: + os.remove(dest) + except OSError: + pass elif log_dir is not None and prune_stem is not None: prune(log_dir, prune_stem, backup_count) return rotator @@ -359,7 +364,7 @@ def attach_rolling( handler, log_dir: str, compress: bool, prune_stem: Optional[str] = None, backup_count: int = 0, keep_uncompressed: Optional[int] = None, keep_compressed: Optional[int] = None, - tiered: bool = False, + tiered: bool = False, rotate_mode: Optional[str] = None, ) -> Tuple[Callable, Callable]: """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 (the handler's own retention can't see the redirected files). pass `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( os.path.basename(prune_stem or ""), log_dir, compress, plain=tiered, ) rotator = make_rotator( compress, log_dir, prune_stem, backup_count, keep_uncompressed, keep_compressed, + rotate_mode=rotate_mode, ) handler.namer = namer handler.rotator = rotator diff --git a/src/log_setup/setup.py b/src/log_setup/setup.py index 3a64f23..c30a1ab 100644 --- a/src/log_setup/setup.py +++ b/src/log_setup/setup.py @@ -149,7 +149,7 @@ def _file_handler( attach_rolling( handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count, keep_uncompressed=keep_uncompressed, keep_compressed=keep_compressed, - tiered=tiered, + tiered=tiered, rotate_mode=rotate, ) elif rotate == "daily": handler = logging.handlers.TimedRotatingFileHandler( @@ -158,7 +158,7 @@ def _file_handler( attach_rolling( handler, log_dir, compress, prune_stem=history_stem, backup_count=backup_count, keep_uncompressed=keep_uncompressed, keep_compressed=keep_compressed, - tiered=tiered, + tiered=tiered, rotate_mode=rotate, ) else: if rotate == "on_start":