-
Notifications
You must be signed in to change notification settings - Fork 14
feat: implement log rotation with safe stream swapping and backup management #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
fcc2d76
2d6129e
74e7f21
bfb69cc
4b46a72
f86f53d
a52c34b
60f0d7d
54bee53
a6dd459
02a07f3
6342c69
bb274ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,128 @@ | ||||||||||||||
import os | ||||||||||||||
from pathlib import Path | ||||||||||||||
from typing import Optional | ||||||||||||||
from clp_logging.handlers import CLPFileHandler, CLPLogLevelTimeout, EOF_CHAR, FLUSH_FRAME | ||||||||||||||
from datetime import datetime | ||||||||||||||
|
||||||||||||||
class RotatingCLPFileHandler(CLPFileHandler): | ||||||||||||||
""" | ||||||||||||||
Extends `CLPFileHandler` to support file size-based log rotation. | ||||||||||||||
""" | ||||||||||||||
|
||||||||||||||
def __init__( | ||||||||||||||
self, | ||||||||||||||
filename_prefix: str, | ||||||||||||||
log_dir: Path, | ||||||||||||||
max_bytes: int, | ||||||||||||||
backup_count: int = 5, | ||||||||||||||
mode: str = "ab", | ||||||||||||||
enable_compression: bool = True, | ||||||||||||||
timestamp_format: str = "%Y%m%d_%H%M%S", | ||||||||||||||
loglevel_timeout: Optional[CLPLogLevelTimeout] = None, | ||||||||||||||
) -> None: | ||||||||||||||
self.filename_prefix = filename_prefix | ||||||||||||||
self.log_dir = Path(log_dir) | ||||||||||||||
self.max_bytes = max_bytes | ||||||||||||||
self.backup_count = backup_count | ||||||||||||||
self.timestamp_format = timestamp_format | ||||||||||||||
|
||||||||||||||
# Ensure log directory exists | ||||||||||||||
os.makedirs(log_dir, exist_ok=True) | ||||||||||||||
|
||||||||||||||
# Initialize the base filename with a timestamp | ||||||||||||||
self.current_log_file = self._generate_log_filename() | ||||||||||||||
|
||||||||||||||
super().__init__( | ||||||||||||||
fpath=self.current_log_file, | ||||||||||||||
mode=mode, | ||||||||||||||
enable_compression=enable_compression, | ||||||||||||||
timestamp_format=timestamp_format, | ||||||||||||||
loglevel_timeout=loglevel_timeout, | ||||||||||||||
) | ||||||||||||||
|
||||||||||||||
def _generate_log_filename(self) -> Path: | ||||||||||||||
""" | ||||||||||||||
Generate a log filename with the timestamp and prefix. | ||||||||||||||
""" | ||||||||||||||
start_time = datetime.now() | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Consider specifying a timezone for consistent log timestamps. Calling - start_time = datetime.now()
+ from datetime import timezone
+ start_time = datetime.now(timezone.utc) 📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.8.2)47-47: (DTZ005) |
||||||||||||||
timestamp = start_time.strftime(self.timestamp_format) | ||||||||||||||
self.current_log_file = Path(f"{self.log_dir}/{self.filename_prefix}_{timestamp}.clp.zst") | ||||||||||||||
|
||||||||||||||
return self.current_log_file | ||||||||||||||
|
||||||||||||||
def _should_rotate(self) -> bool: | ||||||||||||||
""" | ||||||||||||||
Check if the current file exceeds the maximum size. | ||||||||||||||
""" | ||||||||||||||
try: | ||||||||||||||
return self.current_log_file.stat().st_size >= self.max_bytes | ||||||||||||||
except FileNotFoundError: | ||||||||||||||
return False | ||||||||||||||
|
||||||||||||||
|
||||||||||||||
def _rotate(self) -> None: | ||||||||||||||
""" | ||||||||||||||
Perform log rotation by finalizing the current stream, creating a new log file, | ||||||||||||||
and reinitializing the handler with the new stream. | ||||||||||||||
|
||||||||||||||
This method is responsible for: | ||||||||||||||
1. Flushing and writing any remaining buffered data in the current stream. | ||||||||||||||
2. Writing a logical end-of-file (EOF) marker to signal the end of the log stream. | ||||||||||||||
3. Closing the current stream to release any associated resources. | ||||||||||||||
4. Generating a new log file name based on the current timestamp. | ||||||||||||||
5. Opening a new stream for the new log file and reinitializing the handler to use it. | ||||||||||||||
6. Removing old log files if the number of backups exceeds the specified backup count. | ||||||||||||||
|
||||||||||||||
Thread safety is ensured by acquiring and releasing the handler's lock during the | ||||||||||||||
transition between streams. | ||||||||||||||
""" | ||||||||||||||
try: | ||||||||||||||
self.ostream.flush(FLUSH_FRAME) | ||||||||||||||
self.ostream.write(EOF_CHAR) | ||||||||||||||
finally: | ||||||||||||||
self.ostream.close() | ||||||||||||||
|
||||||||||||||
# Generate a new log filename | ||||||||||||||
new_log_file = self._generate_log_filename() | ||||||||||||||
|
||||||||||||||
# Initialize the new stream | ||||||||||||||
try: | ||||||||||||||
new_stream = open(new_log_file, "ab") | ||||||||||||||
except Exception as e: | ||||||||||||||
raise RuntimeError(f"Failed to open new log file {new_log_file}: {e}") | ||||||||||||||
|
||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Use more specific exception handling and consider raising exceptions with context. Catching a broad try:
- new_stream = open(new_log_file, "ab")
-except Exception as e:
- raise RuntimeError(f"Failed to open new log file {new_log_file}: {e}")
+ new_stream = open(new_log_file, "ab")
+except OSError as err:
+ raise RuntimeError(f"Failed to open new log file {new_log_file}") from err 📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.8.2)90-90: Use a context manager for opening files (SIM115) 91-91: Do not catch blind exception: (BLE001) 92-92: Within an (B904) 92-92: Avoid specifying long messages outside the exception class (TRY003) |
||||||||||||||
self.acquire() | ||||||||||||||
try: | ||||||||||||||
self.stream = new_stream | ||||||||||||||
self.init(new_stream) | ||||||||||||||
finally: | ||||||||||||||
self.release() | ||||||||||||||
|
||||||||||||||
# Remove old backups | ||||||||||||||
self._remove_old_backups() | ||||||||||||||
|
||||||||||||||
def _remove_old_backups(self) -> None: | ||||||||||||||
""" | ||||||||||||||
Remove old log files exceeding the backup count. | ||||||||||||||
""" | ||||||||||||||
if not self.backup_count: | ||||||||||||||
return | ||||||||||||||
|
||||||||||||||
log_files = sorted( | ||||||||||||||
self.log_dir.glob(f"{self.filename_prefix}_*.clp.zst"), | ||||||||||||||
key=os.path.getmtime, | ||||||||||||||
) | ||||||||||||||
if len(log_files) > self.backup_count: | ||||||||||||||
for old_file in log_files[:-self.backup_count]: | ||||||||||||||
try: | ||||||||||||||
old_file.unlink() | ||||||||||||||
except Exception as e: | ||||||||||||||
print(f"Error deleting old log file {old_file}: {e}") | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Catch more targeted exceptions when removing old log files. Catching - except Exception as e:
+ except OSError as e: 📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.8.2)119-119: Do not catch blind exception: (BLE001) |
||||||||||||||
|
||||||||||||||
def _write(self, loglevel: int, msg: str) -> None: | ||||||||||||||
""" | ||||||||||||||
Override `_write` to add rotation logic. | ||||||||||||||
""" | ||||||||||||||
if self._should_rotate(): | ||||||||||||||
self._rotate() | ||||||||||||||
super()._write(loglevel, msg) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick (assertive)
Consider replacing the boolean argument with a more descriptive parameter.
Some style guides discourage using boolean-typed parameters because they make function calls less self-explanatory. For instance, you could replace
enable_compression: bool = True
with an enum or separate methods to increase clarity. However, this is a minor style preference and not strictly required.🧰 Tools
🪛 Ruff (0.8.2)
19-19: Boolean-typed positional argument in function definition
(FBT001)
19-19: Boolean default positional argument in function definition
(FBT002)