Skip to content

Commit

Permalink
Add file deletion options for rotated files
Browse files Browse the repository at this point in the history
  • Loading branch information
lomby92 authored and Bungeefan committed May 2, 2024
1 parent 3e9f87c commit e55c71c
Show file tree
Hide file tree
Showing 3 changed files with 146 additions and 0 deletions.
53 changes: 53 additions & 0 deletions lib/src/outputs/advanced_file_output.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -58,6 +67,8 @@ class AdvancedFileOutput extends LogOutput {
int maxFileSizeKB = 1024,
String latestFileName = 'latest.log',
String Function(DateTime timestamp)? fileNameFormatter,
int? maxRotatedFilesCount,
Comparator<File>? fileSorter,
}) : _path = path,
_overrideExisting = overrideExisting,
_encoding = encoding,
Expand All @@ -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.
Expand All @@ -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<File> _fileSorter;

final File _file;
IOSink? _sink;
Expand All @@ -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<void> init() async {
if (_rotatingFilesMode) {
Expand Down Expand Up @@ -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) {
Expand All @@ -181,6 +205,35 @@ class AdvancedFileOutput extends LogOutput {
_sink = null; // Explicitly set null until assigned again
}

Future<void> _deleteRotatedFiles() async {
// If maxRotatedFilesCount is not set, keep all files
if (_maxRotatedFilesCount == null) return;

final dir = Directory(_path);
final files = dir
.listSync()
.whereType<File>()
// 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<void> destroy() async {
_bufferFlushTimer?.cancel();
Expand Down
12 changes: 12 additions & 0 deletions lib/src/outputs/advanced_file_output_stub.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';

import '../log_level.dart';
import '../log_output.dart';
Expand Down Expand Up @@ -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,
Expand All @@ -52,6 +62,8 @@ class AdvancedFileOutput extends LogOutput {
int maxFileSizeKB = 1024,
String latestFileName = 'latest.log',
String Function(DateTime timestamp)? fileNameFormatter,
int? maxRotatedFilesCount,
Comparator<File>? fileSorter,
}) {
throw UnsupportedError("Not supported on this platform.");
}
Expand Down
81 changes: 81 additions & 0 deletions test/outputs/advanced_file_output_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<File>()
.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<File>()
.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();
Expand Down

0 comments on commit e55c71c

Please sign in to comment.