From fa2a165942a23c16aabcabaf0ee1daa783675832 Mon Sep 17 00:00:00 2001 From: Nanaloveyuki Date: Sat, 9 May 2026 21:24:02 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20file=20sink=20rotation=20and?= =?UTF-8?q?=20retention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++- bitlogger/BitLogger_test.mbt | 39 +++++++++++++ bitlogger/BitLogger_wbtest.mbt | 53 +++++++++++++++++ bitlogger/README.mbt.md | 15 ++++- bitlogger/config.mbt | 32 ++++++++++- bitlogger/file_backend_native.mbt | 32 +++++++++++ bitlogger/file_backend_stub.mbt | 20 +++++++ bitlogger/sinks.mbt | 94 +++++++++++++++++++++++++++++-- docs/README-en.md | 10 +++- docs/changes/0.3.0.md | 5 ++ examples/basic/main.mbt | 5 +- 11 files changed, 299 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 56208e0..eaa5812 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ BitLogger 是一个基于 MoonBit 编写的结构化日志库。 - 🩹 可变换 Record:支持 `with_patch(...)`、`patch_sink(...)` 与脱敏/补字段/message 变换 helper。 - 📮 显式队列:支持 `queued_sink(...)` / `with_queue(...)`、有界积压与溢出策略,作为后续 async sink 的 runtime-safe 基础。 - 🧾 可配置文本格式:支持 `text_formatter(...)`、`format_text(...)`、`text_console_sink(...)`、`formatted_callback_sink(...)` 与模板化 `template` 输出。 -- 💾 Native 文件输出:支持 `file_sink(...)`,但当前仅保证 `native/llvm` backend 可用。 +- 💾 Native 文件输出:支持 `file_sink(...)`,并已提供基础 size rotation / backup retention;当前仅保证 `native/llvm` backend 可用。 - 📦 面向 MoonBit:API 和工程结构围绕 MoonBit 的 package / visibility / toolchain 现实约束设计。 ## 🚀 快速开始 @@ -161,7 +161,10 @@ ignore(logger.flush()) ```moonbit if native_files_supported() { - let logger = Logger::new(file_sink("bitlogger.log"), target="file") + let logger = Logger::new( + file_sink("bitlogger.log", rotation=Some(file_rotation(128, max_backups=2))), + target="file", + ) logger.info("hello", fields=[field("kind", "file")]) ignore(logger.sink.flush()) ignore(logger.sink.close()) @@ -182,7 +185,8 @@ if native_files_supported() { ## 📝 配置说明 - 当前提供 JSON 配置层:`parse_logger_config_text(...)`、`stringify_logger_config(...)`、`build_logger(...)` -- 已支持字段:`min_level`、`target`、`timestamp`、`sink.kind`、`sink.path`、`sink.append`、`sink.auto_flush`、`sink.text_formatter`、`queue` +- 已支持字段:`min_level`、`target`、`timestamp`、`sink.kind`、`sink.path`、`sink.append`、`sink.auto_flush`、`sink.rotation`、`sink.text_formatter`、`queue` +- `sink.rotation` 当前支持 `max_bytes` 与 `max_backups`,提供基础 size-based rotation 和 backup retention - `sink.text_formatter.template` 当前支持固定 token:`{timestamp}`、`{timestamp_ms}`、`{level}`、`{target}`、`{message}`、`{fields}` - 当前可由配置直接组装的 sink 类型:`console`、`json_console`、`text_console`、`file` - `queue` 会作为显式包装层附着在最终 sink 外侧;这仍然是同步 drain 模型,不是 async runtime diff --git a/bitlogger/BitLogger_test.mbt b/bitlogger/BitLogger_test.mbt index 79e332f..3a1d9e8 100644 --- a/bitlogger/BitLogger_test.mbt +++ b/bitlogger/BitLogger_test.mbt @@ -62,6 +62,24 @@ test "logger config parser reads formatter and queue options" { } } +test "logger config parser reads file rotation options" { + let config = parse_logger_config_text( + "{\"sink\":{\"kind\":\"file\",\"path\":\"bitlogger.log\",\"rotation\":{\"max_bytes\":128,\"max_backups\":3}}}", + ) + inspect(match config.sink.kind { + SinkKind::File => "File" + _ => "other" + }, content="File") + inspect(config.sink.path, content="bitlogger.log") + match config.sink.rotation { + Some(rotation) => { + inspect(rotation.max_bytes, content="128") + inspect(rotation.max_backups, content="3") + } + None => inspect(false, content="true") + } +} + test "logger config stringify roundtrips stable fields" { let text = stringify_logger_config( LoggerConfig::new( @@ -91,6 +109,27 @@ test "logger config stringify roundtrips stable fields" { inspect(config.sink.text_formatter.template, content="[{level}] {target} {message}") } +test "logger config stringify roundtrips file rotation fields" { + let text = stringify_logger_config( + LoggerConfig::new( + sink=SinkConfig::new( + kind=SinkKind::File, + path="bitlogger.log", + rotation=Some(file_rotation(256, max_backups=2)), + ), + ), + ) + let config = parse_logger_config_text(text) + inspect(config.sink.path, content="bitlogger.log") + match config.sink.rotation { + Some(rotation) => { + inspect(rotation.max_bytes, content="256") + inspect(rotation.max_backups, content="2") + } + None => inspect(false, content="true") + } +} + test "build logger from config supports queued text console" { let logger = build_logger( LoggerConfig::new( diff --git a/bitlogger/BitLogger_wbtest.mbt b/bitlogger/BitLogger_wbtest.mbt index 9a5fff4..bc9abc2 100644 --- a/bitlogger/BitLogger_wbtest.mbt +++ b/bitlogger/BitLogger_wbtest.mbt @@ -99,6 +99,59 @@ test "file sink availability reflects backend support" { } } +test "file sink rotation config normalizes invalid inputs" { + let rotation = file_rotation(0, max_backups=0) + inspect(rotation.max_bytes, content="1") + inspect(rotation.max_backups, content="1") +} + +test "file sink tracks rotation failures on unavailable backend" { + let sink = file_sink("bitlogger-rotate.log", rotation=Some(file_rotation(1, max_backups=1))) + sink.write(record(Level::Info, "a")) + if sink.is_available() { + inspect(sink.rotation_failures(), content="0") + ignore(sink.close()) + } else { + inspect(sink.rotation_failures(), content="0") + } +} + +test "file sink can rotate on native backend" { + let path = "bitlogger-rotate-native.log" + ignore(remove_file_internal(path)) + ignore(remove_file_internal(path + ".1")) + ignore(remove_file_internal(path + ".2")) + let sink = file_sink( + path, + auto_flush=true, + rotation=Some(file_rotation(20, max_backups=2)), + formatter=fn(rec) { rec.message }, + ) + if sink.is_available() { + sink.write(record(Level::Info, "1234567890")) + sink.write(record(Level::Info, "abcdefghij")) + let current = open_file_handle_internal(path, true) + let rotated = open_file_handle_internal(path + ".1", true) + inspect(current is Some(_), content="true") + inspect(rotated is Some(_), content="true") + match current { + None => () + Some(handle) => ignore(close_file_handle_internal(handle)) + } + match rotated { + None => () + Some(handle) => ignore(close_file_handle_internal(handle)) + } + inspect(sink.rotation_failures(), content="0") + inspect(sink.close(), content="true") + ignore(remove_file_internal(path)) + ignore(remove_file_internal(path + ".1")) + ignore(remove_file_internal(path + ".2")) + } else { + inspect(sink.rotation_failures(), content="0") + } +} + test "json formatter keeps structured shape" { let rec = record( Level::Error, diff --git a/bitlogger/README.mbt.md b/bitlogger/README.mbt.md index 64eec71..7c1090f 100644 --- a/bitlogger/README.mbt.md +++ b/bitlogger/README.mbt.md @@ -42,8 +42,8 @@ BitLogger 是一个基于 MoonBit 的结构化日志库。 - 支持 `parse_logger_config_text(...)`、`stringify_logger_config(...)` 进行最小 JSON 配置读写 - config-driven logger assembly via `build_logger(...)` - 支持 `build_logger(...)` 将配置组装为可直接使用的 logger -- native-only file output via `file_sink(...)` -- 支持 `file_sink(...)`,但当前仅保证 `native/llvm` backend 可用 +- native-only file output via `file_sink(...)`, with basic size rotation and backup retention +- 支持 `file_sink(...)`、基础 size rotation 与 backup retention,但当前仅保证 `native/llvm` backend 可用 ## Example / 示例 @@ -174,10 +174,19 @@ test { ```mbt check test { if native_files_supported() { - let logger = Logger::new(file_sink("bitlogger.log"), target="file") + let logger = Logger::new( + file_sink("bitlogger.log", rotation=Some(file_rotation(128, max_backups=2))), + target="file", + ) logger.info("hello", fields=[field("kind", "file")]) ignore(logger.sink.flush()) ignore(logger.sink.close()) } } ``` + +## File Rotation / 文件轮转 + +- basic rotation is size-based / 当前基础轮转为按文件大小触发 +- `file_rotation(max_bytes, max_backups=...)` controls threshold and retained backups / `file_rotation(max_bytes, max_backups=...)` 控制触发阈值和保留备份数 +- JSON config uses `sink.rotation.max_bytes` and `sink.rotation.max_backups` / JSON 配置使用 `sink.rotation.max_bytes` 与 `sink.rotation.max_backups` diff --git a/bitlogger/config.mbt b/bitlogger/config.mbt index c7991e7..c6f3ed3 100644 --- a/bitlogger/config.mbt +++ b/bitlogger/config.mbt @@ -68,6 +68,7 @@ pub struct SinkConfig { path : String append : Bool auto_flush : Bool + rotation : FileRotation? text_formatter : TextFormatterConfig } @@ -76,6 +77,7 @@ pub fn SinkConfig::new( path~ : String = "", append~ : Bool = true, auto_flush~ : Bool = true, + rotation~ : FileRotation? = None, text_formatter~ : TextFormatterConfig = default_text_formatter_config(), ) -> SinkConfig { { @@ -83,6 +85,7 @@ pub fn SinkConfig::new( path, append, auto_flush, + rotation, text_formatter, } } @@ -360,6 +363,14 @@ fn parse_queue_config(value : @json_parser.JsonValue) -> QueueConfig raise Confi ) } +fn parse_file_rotation_config(value : @json_parser.JsonValue) -> FileRotation raise ConfigError { + let obj = expect_object(value, "sink.rotation") + file_rotation( + get_int(obj, "max_bytes", default=1), + max_backups=get_int(obj, "max_backups", default=1), + ) +} + fn parse_sink_config(value : @json_parser.JsonValue) -> SinkConfig raise ConfigError { let obj = expect_object(value, "sink") let kind = parse_sink_kind(get_string(obj, "kind", default="console")) @@ -379,6 +390,10 @@ fn parse_sink_config(value : @json_parser.JsonValue) -> SinkConfig raise ConfigE path=path, append=get_bool(obj, "append", default=true), auto_flush=get_bool(obj, "auto_flush", default=true), + rotation=match obj.get("rotation") { + None => None + Some(inner) => Some(parse_file_rotation_config(inner)) + }, text_formatter=formatter, ) } @@ -425,14 +440,26 @@ fn text_formatter_config_to_json(config : TextFormatterConfig) -> @json_parser.J }) } -fn sink_config_to_json(config : SinkConfig) -> @json_parser.JsonValue { +fn file_rotation_config_to_json(config : FileRotation) -> @json_parser.JsonValue { @json_parser.JsonValue::Object({ + "max_bytes": @json_parser.JsonValue::Number(config.max_bytes.to_double()), + "max_backups": @json_parser.JsonValue::Number(config.max_backups.to_double()), + }) +} + +fn sink_config_to_json(config : SinkConfig) -> @json_parser.JsonValue { + let obj : Map[String, @json_parser.JsonValue] = { "kind": @json_parser.JsonValue::String(sink_kind_label(config.kind)), "path": @json_parser.JsonValue::String(config.path), "append": @json_parser.JsonValue::Bool(config.append), "auto_flush": @json_parser.JsonValue::Bool(config.auto_flush), "text_formatter": text_formatter_config_to_json(config.text_formatter), - }) + } + match config.rotation { + None => () + Some(rotation) => obj["rotation"] = file_rotation_config_to_json(rotation) + } + @json_parser.JsonValue::Object(obj) } pub fn logger_config_to_json(config : LoggerConfig) -> @json_parser.JsonValue { @@ -470,6 +497,7 @@ fn build_runtime_sink(config : SinkConfig) -> RuntimeSink { config.path, append=config.append, auto_flush=config.auto_flush, + rotation=config.rotation, formatter=fn(rec) { format_text(rec, formatter=config.text_formatter.to_formatter()) }, diff --git a/bitlogger/file_backend_native.mbt b/bitlogger/file_backend_native.mbt index 472536c..c04e528 100644 --- a/bitlogger/file_backend_native.mbt +++ b/bitlogger/file_backend_native.mbt @@ -51,6 +51,16 @@ extern "C" fn file_flush_ffi(handle : NativeFileHandle) -> Int = "fflush" extern "C" fn file_close_ffi(handle : NativeFileHandle) -> Int = "fclose" +extern "C" fn file_seek_ffi(handle : NativeFileHandle, offset : Int, origin : Int) -> Int = "fseek" + +extern "C" fn file_tell_ffi(handle : NativeFileHandle) -> Int = "ftell" + +#borrow(from_path, to_path) +extern "C" fn file_rename_ffi(from_path : Bytes, to_path : Bytes) -> Int = "rename" + +#borrow(path) +extern "C" fn file_remove_ffi(path : Bytes) -> Int = "remove" + pub struct FileHandle { path : String raw : NativeFileHandle @@ -80,6 +90,28 @@ fn close_file_handle_internal(handle : FileHandle) -> Bool { file_close_ffi(handle.raw) == 0 } +fn file_size_internal(handle : FileHandle) -> Int { + ignore(file_seek_ffi(handle.raw, 0, 2)) + let size = file_tell_ffi(handle.raw) + if size < 0 { + 0 + } else { + size + } +} + +fn rename_file_internal(from_path : String, to_path : String) -> Bool { + file_rename_ffi(string_to_c_bytes(from_path), string_to_c_bytes(to_path)) == 0 +} + +fn remove_file_internal(path : String) -> Bool { + file_remove_ffi(string_to_c_bytes(path)) == 0 +} + +fn string_byte_length_internal(content : String) -> Int { + string_to_c_bytes(content).length() - 1 +} + fn native_files_supported_internal() -> Bool { true } diff --git a/bitlogger/file_backend_stub.mbt b/bitlogger/file_backend_stub.mbt index aa623a4..03be947 100644 --- a/bitlogger/file_backend_stub.mbt +++ b/bitlogger/file_backend_stub.mbt @@ -26,6 +26,26 @@ fn close_file_handle_internal(handle : FileHandle) -> Bool { false } +fn file_size_internal(handle : FileHandle) -> Int { + ignore(handle) + 0 +} + +fn rename_file_internal(from_path : String, to_path : String) -> Bool { + ignore(from_path) + ignore(to_path) + false +} + +fn remove_file_internal(path : String) -> Bool { + ignore(path) + false +} + +fn string_byte_length_internal(content : String) -> Int { + content.length() +} + fn native_files_supported_internal() -> Bool { false } diff --git a/bitlogger/sinks.mbt b/bitlogger/sinks.mbt index 7543a15..04a9ed8 100644 --- a/bitlogger/sinks.mbt +++ b/bitlogger/sinks.mbt @@ -45,9 +45,25 @@ pub impl Sink for JsonConsoleSink with write(self, rec) { } pub struct FileSink { + path : String + append : Bool handle : Ref[FileHandle?] formatter : RecordFormatter auto_flush : Bool + rotation : FileRotation? + rotation_failures : Ref[Int] +} + +pub struct FileRotation { + max_bytes : Int + max_backups : Int +} + +pub fn file_rotation(max_bytes : Int, max_backups~ : Int = 1) -> FileRotation { + { + max_bytes: if max_bytes <= 0 { 1 } else { max_bytes }, + max_backups: if max_backups <= 0 { 1 } else { max_backups }, + } } pub fn native_files_supported() -> Bool { @@ -58,14 +74,19 @@ pub fn file_sink( path : String, append~ : Bool = true, auto_flush~ : Bool = true, + rotation~ : FileRotation? = None, formatter~ : RecordFormatter = fn(rec) { format_text(rec) }, ) -> FileSink { { + path, + append, handle: Ref::new(open_file_handle_internal(path, append)), formatter, auto_flush, + rotation, + rotation_failures: Ref::new(0), } } @@ -91,14 +112,79 @@ pub fn FileSink::close(self : FileSink) -> Bool { } } +pub fn FileSink::rotation_failures(self : FileSink) -> Int { + self.rotation_failures.val +} + +fn rotated_file_path(path : String, index : Int) -> String { + "\{path}.\{index}" +} + +fn rotate_file_sink_internal(sink : FileSink, rotation : FileRotation) -> Bool { + let closed = match sink.handle.val { + None => true + Some(handle) => { + let ok = close_file_handle_internal(handle) + sink.handle.val = None + ok + } + } + if !closed { + return false + } + if rotation.max_backups > 0 { + ignore(remove_file_internal(rotated_file_path(sink.path, rotation.max_backups))) + for index = rotation.max_backups - 1; index >= 1; { + let from_path = rotated_file_path(sink.path, index) + let to_path = rotated_file_path(sink.path, index + 1) + ignore(rename_file_internal(from_path, to_path)) + continue index - 1 + } + ignore(rename_file_internal(sink.path, rotated_file_path(sink.path, 1))) + } else { + ignore(remove_file_internal(sink.path)) + } + sink.handle.val = open_file_handle_internal(sink.path, false) + sink.handle.val is Some(_) +} + +fn rotate_if_needed_internal(sink : FileSink, next_line_bytes : Int) -> Bool { + match sink.rotation { + None => true + Some(rotation) => match sink.handle.val { + None => false + Some(handle) => { + let size = file_size_internal(handle) + if size + next_line_bytes <= rotation.max_bytes { + true + } else { + let rotated = rotate_file_sink_internal(sink, rotation) + if !rotated { + sink.rotation_failures.val += 1 + } + rotated + } + } + } + } +} + pub impl Sink for FileSink with write(self, rec) { match self.handle.val { None => () - Some(handle) => { + Some(_) => { let line = "\{(self.formatter)(rec)}\n" - ignore(write_file_handle_internal(handle, line)) - if self.auto_flush { - ignore(flush_file_handle_internal(handle)) + let can_write = rotate_if_needed_internal(self, string_byte_length_internal(line)) + if can_write { + match self.handle.val { + None => () + Some(active) => { + ignore(write_file_handle_internal(active, line)) + if self.auto_flush { + ignore(flush_file_handle_internal(active)) + } + } + } } } } diff --git a/docs/README-en.md b/docs/README-en.md index 22c3a15..8b7e431 100644 --- a/docs/README-en.md +++ b/docs/README-en.md @@ -24,7 +24,7 @@ BitLogger currently provides: - bounded backlog with `QueueOverflowPolicy::DropNewest` and `QueueOverflowPolicy::DropOldest` - configurable text formatting via `text_formatter(...)`, `format_text(...)`, `text_console_sink(...)`, and template-driven `template` output - formatter-based callback integration via `formatted_callback_sink(...)` -- native-only file output via `file_sink(...)` +- native-only file output via `file_sink(...)`, with basic size rotation and backup retention - `native_files_supported()` for backend capability detection - default global logger helpers @@ -151,7 +151,10 @@ Native file sink: ```moonbit if native_files_supported() { - let logger = Logger::new(file_sink("bitlogger.log"), target="file") + let logger = Logger::new( + file_sink("bitlogger.log", rotation=Some(file_rotation(128, max_backups=2))), + target="file", + ) logger.info("hello", fields=[field("kind", "file")]) ignore(logger.sink.flush()) ignore(logger.sink.close()) @@ -171,7 +174,8 @@ if native_files_supported() { ## Config Notes - BitLogger now includes a JSON config layer via `parse_logger_config_text(...)`, `stringify_logger_config(...)`, and `build_logger(...)`. -- Supported keys include `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.text_formatter`, and `queue`. +- Supported keys include `min_level`, `target`, `timestamp`, `sink.kind`, `sink.path`, `sink.append`, `sink.auto_flush`, `sink.rotation`, `sink.text_formatter`, and `queue`. +- `sink.rotation` currently supports `max_bytes` and `max_backups` for basic size-based rotation and backup retention. - `sink.text_formatter.template` currently supports fixed tokens: `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, and `{fields}`. - Config-driven sink assembly currently supports `console`, `json_console`, `text_console`, and `file`. - `queue` remains a synchronous bounded wrapper around the final sink, not an async runtime. diff --git a/docs/changes/0.3.0.md b/docs/changes/0.3.0.md index d39dee0..7902649 100644 --- a/docs/changes/0.3.0.md +++ b/docs/changes/0.3.0.md @@ -27,6 +27,8 @@ version 0.3.0 - feat: use `maria/json_parser` as the first external dependency for practical config loading - feat: add `TextFormatter.template` and `TextFormatterConfig.template` for template-driven text rendering - feat: support formatter tokens `{timestamp}`, `{timestamp_ms}`, `{level}`, `{target}`, `{message}`, and `{fields}` in both runtime and JSON config paths +- feat: add `FileRotation`, `file_rotation(...)`, and size-based file rotation with retained backups for `file_sink(...)` +- feat: support `sink.rotation.max_bytes` and `sink.rotation.max_backups` in JSON logger config ### Test @@ -37,6 +39,7 @@ version 0.3.0 - test: cover partial drain behavior for config-built queued logger - test: cover dropped-count reporting for bounded config-built queue - test: cover template-based formatter rendering and disabled token behavior +- test: cover file rotation config parsing, config roundtrip, and native rotation behavior - test: add async logger lifecycle, config roundtrip, and batching/flush policy test seeds - build: verify `bitlogger_async --target native` and `examples/async_basic --target native` compile successfully @@ -48,6 +51,7 @@ version 0.3.0 - docs: update `examples/async_basic` to use unified JSON-driven async logger config - docs: update root README, English README, and Mooncake README with config usage notes - docs: update formatter examples to demonstrate template-based text rendering +- docs: update file sink examples to demonstrate rotation and backup retention - docs: update root README and English README with async adapter notes and current scope - chore: ignore local `.mooncakes/` cache directory in git @@ -55,5 +59,6 @@ version 0.3.0 - current config scope is still intentionally constrained to stable built-in sink shapes - formatter templates are intentionally limited to simple token replacement and do not yet include conditional blocks or alignment/padding controls +- current file rotation scope is intentionally limited to size-based rename retention and does not yet include time rotation or compression - queue wrapping remains synchronous drain-based delivery, not async runtime scheduling - async logging remains an isolated adapter layer and currently targets `native/llvm` only diff --git a/examples/basic/main.mbt b/examples/basic/main.mbt index 217e8b4..9fa39d6 100644 --- a/examples/basic/main.mbt +++ b/examples/basic/main.mbt @@ -49,7 +49,10 @@ fn main { if @lib.native_files_supported() { let file_logger = @lib.Logger::new( - @lib.file_sink("bitlogger-example.log"), + @lib.file_sink( + "bitlogger-example.log", + rotation=Some(@lib.file_rotation(128, max_backups=2)), + ), min_level=@lib.Level::Info, target="file", )