From e55c71c5f36318df4006fd027eb3deb35d6b412b Mon Sep 17 00:00:00 2001 From: Giacomo Lombardi Date: Tue, 23 Apr 2024 11:32:37 +0200 Subject: [PATCH] Add file deletion options for rotated files --- lib/src/outputs/advanced_file_output.dart | 53 ++++++++++++ .../outputs/advanced_file_output_stub.dart | 12 +++ test/outputs/advanced_file_output_test.dart | 81 +++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/lib/src/outputs/advanced_file_output.dart b/lib/src/outputs/advanced_file_output.dart index 8b562e82..e70b15d6 100644 --- a/lib/src/outputs/advanced_file_output.dart +++ b/lib/src/outputs/advanced_file_output.dart @@ -48,6 +48,15 @@ class AdvancedFileOutput extends LogOutput { /// /// [path] is either treated as directory for rotating or as target file name, /// depending on [maxFileSizeKB]. + /// + /// [maxRotatedFilesCount] controls the number of rotated files to keep. By default + /// is null, which means no limit. + /// If set to a positive number, the output will keep the last + /// [maxRotatedFilesCount] files. The deletion step will be executed by sorting + /// files following the [fileSorter] ascending strategy and keeping the last files. + /// The [latestFileName] will not be counted. The default [fileSorter] strategy is + /// sorting by last modified date, beware that could be not reliable in some + /// platforms and/or filesystems. AdvancedFileOutput({ required String path, bool overrideExisting = false, @@ -58,6 +67,8 @@ class AdvancedFileOutput extends LogOutput { int maxFileSizeKB = 1024, String latestFileName = 'latest.log', String Function(DateTime timestamp)? fileNameFormatter, + int? maxRotatedFilesCount, + Comparator? fileSorter, }) : _path = path, _overrideExisting = overrideExisting, _encoding = encoding, @@ -73,6 +84,8 @@ class AdvancedFileOutput extends LogOutput { // ignore: deprecated_member_use_from_same_package Level.wtf, ], + _maxRotatedFilesCount = maxRotatedFilesCount, + _fileSorter = fileSorter ?? _defaultFileSorter, _file = maxFileSizeKB > 0 ? File('$path/$latestFileName') : File(path); /// Logs directory path by default, particular log file path if [_maxFileSizeKB] is 0. @@ -86,6 +99,8 @@ class AdvancedFileOutput extends LogOutput { final int _maxFileSizeKB; final int _maxBufferSize; final String Function(DateTime timestamp) _fileNameFormatter; + final int? _maxRotatedFilesCount; + final Comparator _fileSorter; final File _file; IOSink? _sink; @@ -106,6 +121,14 @@ class AdvancedFileOutput extends LogOutput { '-${t.millisecond.toDigits(3)}.log'; } + /// Sort files by their last modified date. + /// This behaviour is inspired by the Log4j PathSorter. + /// + /// This method fulfills the requirements of the [Comparator] interface. + static int _defaultFileSorter(File a, File b) { + return a.lastModifiedSync().compareTo(b.lastModifiedSync()); + } + @override Future init() async { if (_rotatingFilesMode) { @@ -157,6 +180,7 @@ class AdvancedFileOutput extends LogOutput { // Rotate the log file await _closeSink(); await _file.rename('$_path/${_fileNameFormatter(DateTime.now())}'); + await _deleteRotatedFiles(); await _openSink(); } } catch (e, s) { @@ -181,6 +205,35 @@ class AdvancedFileOutput extends LogOutput { _sink = null; // Explicitly set null until assigned again } + Future _deleteRotatedFiles() async { + // If maxRotatedFilesCount is not set, keep all files + if (_maxRotatedFilesCount == null) return; + + final dir = Directory(_path); + final files = dir + .listSync() + .whereType() + // Filter out the latest file + .where((f) => f.path != _file.path) + .toList(); + + // If the number of files is less than the limit, don't delete anything + if (files.length <= _maxRotatedFilesCount!) return; + + files.sort(_fileSorter); + + final filesToDelete = + files.sublist(0, files.length - _maxRotatedFilesCount!); + for (final file in filesToDelete) { + try { + await file.delete(); + } catch (e, s) { + print('Failed to delete file: $e'); + print(s); + } + } + } + @override Future destroy() async { _bufferFlushTimer?.cancel(); diff --git a/lib/src/outputs/advanced_file_output_stub.dart b/lib/src/outputs/advanced_file_output_stub.dart index 68483274..03f3c682 100644 --- a/lib/src/outputs/advanced_file_output_stub.dart +++ b/lib/src/outputs/advanced_file_output_stub.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import '../log_level.dart'; import '../log_output.dart'; @@ -42,6 +43,15 @@ class AdvancedFileOutput extends LogOutput { /// /// [path] is either treated as directory for rotating or as target file name, /// depending on [maxFileSizeKB]. + /// + /// [maxRotatedFilesCount] controls the number of rotated files to keep. By default + /// is null, which means no limit. + /// If set to a positive number, the output will keep the last + /// [maxRotatedFilesCount] files. The deletion step will be executed by sorting + /// files following the [fileSorter] ascending strategy and keeping the last files. + /// The [latestFileName] will not be counted. The default [fileSorter] strategy is + /// sorting by last modified date, beware that could be not reliable in some + /// platforms and/or filesystems. AdvancedFileOutput({ required String path, bool overrideExisting = false, @@ -52,6 +62,8 @@ class AdvancedFileOutput extends LogOutput { int maxFileSizeKB = 1024, String latestFileName = 'latest.log', String Function(DateTime timestamp)? fileNameFormatter, + int? maxRotatedFilesCount, + Comparator? fileSorter, }) { throw UnsupportedError("Not supported on this platform."); } diff --git a/test/outputs/advanced_file_output_test.dart b/test/outputs/advanced_file_output_test.dart index ced7f29e..3a75cdb9 100644 --- a/test/outputs/advanced_file_output_test.dart +++ b/test/outputs/advanced_file_output_test.dart @@ -108,6 +108,87 @@ void main() { ); }); + test('Rolling files with rotated files deletion', () async { + var output = AdvancedFileOutput( + path: dir.path, + maxFileSizeKB: 1, + maxRotatedFilesCount: 1, + ); + + await output.init(); + final event0 = OutputEvent(LogEvent(Level.fatal, ""), ["1" * 1500]); + output.output(event0); + await output.destroy(); + + // Start again to roll files on init without waiting for timer tick + await output.init(); + final event1 = OutputEvent(LogEvent(Level.fatal, ""), ["2" * 1500]); + output.output(event1); + await output.destroy(); + + // And again for another roll + await output.init(); + final event2 = OutputEvent(LogEvent(Level.fatal, ""), ["3" * 1500]); + output.output(event2); + await output.destroy(); + + final files = dir.listSync(); + + // Expect only 2 files: the "latest" that is the current log file + // and only one rotated file. The first created file should be deleted. + expect(files, hasLength(2)); + final latestFile = File('${dir.path}/latest.log'); + final rotatedFile = dir + .listSync() + .whereType() + .firstWhere((file) => file.path != latestFile.path); + expect(await latestFile.readAsString(), contains("3")); + expect(await rotatedFile.readAsString(), contains("2")); + }); + + test('Rolling files with custom file sorter', () async { + var output = AdvancedFileOutput( + path: dir.path, + maxFileSizeKB: 1, + maxRotatedFilesCount: 1, + // Define a custom file sorter that sorts files by their length + // (strange behavior for testing purposes) from the longest to + // the shortest: the longest file should be deleted first. + fileSorter: (a, b) => b.lengthSync().compareTo(a.lengthSync()), + ); + + await output.init(); + final event0 = OutputEvent(LogEvent(Level.fatal, ""), ["1" * 1500]); + output.output(event0); + await output.destroy(); + + // Start again to roll files on init without waiting for timer tick + await output.init(); + // Create a second file with a greater length (it should be deleted first) + final event1 = OutputEvent(LogEvent(Level.fatal, ""), ["2" * 3000]); + output.output(event1); + await output.destroy(); + + // And again for another roll + await output.init(); + final event2 = OutputEvent(LogEvent(Level.fatal, ""), ["3" * 1500]); + output.output(event2); + await output.destroy(); + + final files = dir.listSync(); + + // Expect only 2 files: the "latest" that is the current log file + // and only one rotated file (the shortest one). + expect(files, hasLength(2)); + final latestFile = File('${dir.path}/latest.log'); + final rotatedFile = dir + .listSync() + .whereType() + .firstWhere((file) => file.path != latestFile.path); + expect(await latestFile.readAsString(), contains("3")); + expect(await rotatedFile.readAsString(), contains("1")); + }); + test('Flush temporary buffer on destroy', () async { var output = AdvancedFileOutput(path: dir.path); await output.init();