Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

Changes since v1.0.2 will be documented here.
### Added

- Added transparent `.gz` and `.zst` reads for persisted file APIs when the
corresponding compression feature is enabled.

## [v1.0.2] - 2026-09-19

Expand Down
9 changes: 5 additions & 4 deletions README-RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,11 @@ int main() {
Файловые backend-ы также поддерживают доступ к уже сохранённым логам через
`LOGIT_LIST_LOG_FILES(index)`, `LOGIT_READ_LOG_FILE(index, path)` и
`LOGIT_READ_LOG_FILES(index, paths)`. Эти helpers читают только то, что уже
успело попасть на диск, не дренируют асинхронные очереди и пока рассматривают
сжатые rotated-файлы как metadata-only записи. Используйте `MemoryLogger` для
почти real-time снимков, а файловые API — для операционного чтения логов за
сегодня или предыдущие дни.
успело попасть на диск, и не дренируют асинхронные очереди. Rotated-файлы `.gz`
и `.zst` распаковываются, когда включена соответствующая feature; иначе read
result возвращает `ok == false`. Используйте `MemoryLogger` для почти real-time
снимков, а файловые API — для операционного чтения логов за сегодня или
предыдущие дни.

### Структурированные и telemetry-бэкенды

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,10 @@ storage should prefer the shared `LOGIT_READ_*` and callback macros above.
File-based backends also expose persisted-file access through
`LOGIT_LIST_LOG_FILES(index)`, `LOGIT_READ_LOG_FILE(index, path)`, and
`LOGIT_READ_LOG_FILES(index, paths)`. These helpers read only what has already
reached disk, do not drain async queues, and currently treat compressed rotated
files as metadata-only entries. Use `MemoryLogger` for near-real-time snapshots
and the file APIs for operational reads of today's or previous days' persisted
logs.
reached disk and do not drain async queues. Rotated `.gz` and `.zst` files are
decompressed when the corresponding feature is enabled; otherwise their read
result has `ok == false`. Use `MemoryLogger` for near-real-time snapshots and
the file APIs for operational reads of today's or previous days' persisted logs.

---

Expand Down Expand Up @@ -1011,8 +1011,8 @@ the rows above document the canonical public families.
| `LOGIT_GET_BUFFERED_STRINGS(index)` | Return buffered formatted messages from a logger that supports snapshots. |
| `LOGIT_GET_BUFFERED_ENTRIES(index)` | Return buffered structured entries from a logger that supports snapshots. |
| `LOGIT_LIST_LOG_FILES(index)` | List persisted log files exposed by a file-based logger. |
| `LOGIT_READ_LOG_FILE(index, path)` | Read one persisted plain-text log file owned by a file-based logger. |
| `LOGIT_READ_LOG_FILES(index, paths)` | Read several persisted plain-text log files and preserve request order. |
| `LOGIT_READ_LOG_FILE(index, path)` | Read one persisted plain or feature-enabled compressed log file owned by a file-based logger. |
| `LOGIT_READ_LOG_FILES(index, paths)` | Read several persisted plain or feature-enabled compressed log files and preserve request order. |
| `LOGIT_WAIT()` | Wait for all asynchronous loggers to finish. |
| `LOGIT_SHUTDOWN()` | Shut down the logging system. |

Expand Down
47 changes: 47 additions & 0 deletions docs/adr/0007-transparent-compressed-file-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# ADR 0007: Transparent compressed-file reads

- Status: Accepted
- Date: 2026-09-19

## Context

`FileLogger` and `UniqueFileLogger` already expose rotated `.gz` and `.zst`
artifacts through `list_log_files()`, but their read APIs previously returned
`ok == false` for every compressed file. Callers therefore had to inspect the
suffix and duplicate LogIt++'s optional-dependency handling.

## Decision

`read_log_file()` and `read_log_files()` transparently decompress `.gz` and
`.zst` file contents through the shared compression helpers. Suffixes remain
case-sensitive and match the names produced by the rotation code.

The operation succeeds only when the corresponding build feature is enabled
and the payload is valid. A disabled feature, unsupported suffix, malformed
payload, or file-read failure returns the existing `LogFileReadResult` contract
with `ok == false` and empty content. The readers do not invoke external
commands and do not drain pending asynchronous writes.

## Consequences

Callers use the same file-read API for plain and compressed artifacts, while
builds without zlib or zstd retain fail-closed behavior. Decompression loads the
compressed and decompressed contents into memory, matching the existing
whole-file result type; callers should avoid using this API for unbounded files.

## Alternatives considered

- Returning compressed bytes would make the result meaning depend on the
suffix and leave dependency handling to every caller.
- Shelling out to gzip or zstd would add platform-specific process and quoting
behavior to a synchronous read API.
- Adding separate compressed-read methods would duplicate the existing file
discovery, ownership, and batch-result contracts.

## References

- `include/logit_cpp/logit/loggers/FileLogger.hpp`
- `include/logit_cpp/logit/loggers/UniqueFileLogger.hpp`
- `tests/file_logger_gzip_compression_test.cpp`
- `tests/file_logger_zstd_compression_test.cpp`

1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ explains **why the current boundary or trade-off exists**.
- [0004 — TimeShield compatibility and dependency reuse](0004-timeshield-compatibility.md)
- [0005 — Benchmark evidence and comparison methodology](0005-benchmark-methodology.md)
- [0006 — Explicit capability for concurrent dispatch](0006-concurrent-dispatch-capability.md)
- [0007 — Transparent compressed-file reads](0007-transparent-compressed-file-reads.md)
7 changes: 4 additions & 3 deletions docs/future-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ Legend:
- [ ] **Binary logging research** — choose a versioned binary record format,
define compatibility/versioning rules, and prototype a reader before adding
a production backend.
- [ ] **Transparent compressed-file reads** — make `read_log_file()` and
`read_log_files()` read `.gz`/`.zst` entries when the corresponding feature is
enabled, with platform-specific tests.
- [x] **Transparent compressed-file reads** — `read_log_file()` and
`read_log_files()` now decompress `.gz`/`.zst` entries when the corresponding
feature is enabled; disabled-feature and malformed-input paths remain
failures and are covered by tests.
- [ ] **Configuration loading** — design a versioned JSON/properties mapping to
the existing backend configuration. Treat file watching/hot reload as a
follow-up, not part of the first configuration API.
Expand Down
5 changes: 3 additions & 2 deletions docs/reference.dox
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,9 @@ storage should prefer the shared `LOGIT_READ_*` and callback macros above.
File-based backends also expose persisted-file access through
`LOGIT_LIST_LOG_FILES(index)`, `LOGIT_READ_LOG_FILE(index, path)`, and
`LOGIT_READ_LOG_FILES(index, paths)`. These helpers return only what has
already reached disk; they do not drain async queues, and compressed rotated
files are listed as metadata-only artifacts in v1. Use `MemoryLogger` for
already reached disk; they do not drain async queues. Rotated `.gz` and `.zst`
files are decompressed when the corresponding feature is enabled; otherwise
their read result has `ok == false`. Use `MemoryLogger` for
near-real-time snapshots and the file APIs for operational reads of persisted
daily logs.

Expand Down
26 changes: 24 additions & 2 deletions include/logit_cpp/logit/detail/CompressionUtils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ inline bool compress_string_gzip(const std::string& input, std::string& output,
/// \param[out] output Decompressed result (valid only on success).
/// \return true on success, false if zlib is unavailable or decompression fails.
inline bool decompress_string_gzip(const std::string& input, std::string& output) {
output.clear();
#if defined(LOGIT_HAS_ZLIB)
z_stream zs;
zs.zalloc = Z_NULL;
Expand All @@ -93,8 +94,6 @@ inline bool decompress_string_gzip(const std::string& input, std::string& output
return false;
}

output.clear();

int ret = Z_OK;
std::size_t offset = 0;
const std::size_t buf_size = 32768;
Expand All @@ -107,6 +106,7 @@ inline bool decompress_string_gzip(const std::string& input, std::string& output
ret = inflate(&zs, Z_NO_FLUSH);
if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR) {
inflateEnd(&zs);
output.clear();
return false;
}

Expand Down Expand Up @@ -159,6 +159,7 @@ inline bool compress_string_zstd(const std::string& input, std::string& output,
/// \param[out] output Decompressed result (valid only on success).
/// \return true on success, false if zstd is unavailable or decompression fails.
inline bool decompress_string_zstd(const std::string& input, std::string& output) {
output.clear();
#if defined(LOGIT_HAS_ZSTD)
std::size_t const d_size = ZSTD_getFrameContentSize(input.data(), input.size());
if (d_size == ZSTD_CONTENTSIZE_ERROR || d_size == ZSTD_CONTENTSIZE_UNKNOWN) {
Expand All @@ -183,6 +184,27 @@ inline bool decompress_string_zstd(const std::string& input, std::string& output
#endif
}

/// \brief Decompress a file payload based on its compressed suffix.
/// \param filename File name ending in `.gz` or `.zst`.
/// \param input Compressed file bytes.
/// \param[out] output Decompressed result (valid only on success).
/// \return true when the suffix is supported and decompression succeeds.
inline bool decompress_string_by_suffix(
const std::string& filename,
const std::string& input,
std::string& output) {
if (filename.size() >= 3 &&
filename.compare(filename.size() - 3, 3, ".gz") == 0) {
return decompress_string_gzip(input, output);
}
if (filename.size() >= 4 &&
filename.compare(filename.size() - 4, 4, ".zst") == 0) {
return decompress_string_zstd(input, output);
}
output.clear();
return false;
}

} // namespace detail
} // namespace logit

Expand Down
2 changes: 1 addition & 1 deletion include/logit_cpp/logit/loggers.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ namespace logit {
#ifndef __EMSCRIPTEN__
#include "detail/CompressionWorker.hpp"
#endif
#include "detail/CompressionUtils.hpp"

#include <algorithm>
#include <atomic>
Expand Down Expand Up @@ -61,7 +62,6 @@ namespace logit {
#endif

#ifdef LOGIT_WITH_MDBX
#include "detail/CompressionUtils.hpp"
#include "detail/MdbxByteIO.hpp"
#include "detail/MdbxKeyUtils.hpp"
#include "detail/MdbxProcessId.hpp"
Expand Down
21 changes: 14 additions & 7 deletions include/logit_cpp/logit/loggers/FileLogger.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,8 @@ namespace logit {
/// \details This API reads only the already persisted file contents and
/// does not wait for pending async writes.
/// \param path Full path returned by `list_log_files()`.
/// \return Read result. Compressed files are listed but unreadable in v1.
/// \return Read result. Rotated `.gz` and `.zst` files are decompressed
/// when the corresponding feature is enabled.
LogFileReadResult read_log_file(const std::string& path) const override {
const std::vector<LogFileInfo> files = list_log_files();
for (size_t i = 0; i < files.size(); ++i) {
Expand Down Expand Up @@ -672,19 +673,25 @@ namespace logit {
LogFileReadResult result;
result.file = info;

if (info.is_compressed) {
result.ok = false;
return result;
}

if (info.is_current) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_file.is_open()) {
m_file.flush();
}
}

result.ok = read_plain_file(info.path, result.content);
std::string file_bytes;
if (!read_plain_file(info.path, file_bytes)) {
result.ok = false;
return result;
}
if (info.is_compressed) {
result.ok = detail::decompress_string_by_suffix(
info.path, file_bytes, result.content);
} else {
result.content = std::move(file_bytes);
result.ok = true;
}
return result;
}

Expand Down
15 changes: 11 additions & 4 deletions include/logit_cpp/logit/loggers/UniqueFileLogger.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -595,12 +595,18 @@ namespace logit {
LogFileReadResult read_log_file_from_info(const LogFileInfo& info) const {
LogFileReadResult result;
result.file = info;
if (info.is_compressed) {
std::string file_bytes;
if (!read_plain_file(info.path, file_bytes)) {
result.ok = false;
return result;
}

result.ok = read_plain_file(info.path, result.content);
if (info.is_compressed) {
result.ok = detail::decompress_string_by_suffix(
info.path, file_bytes, result.content);
} else {
result.content = std::move(file_bytes);
result.ok = true;
}
return result;
}

Expand Down Expand Up @@ -880,7 +886,8 @@ namespace logit {

/// \brief Reads one persisted log file owned by this backend.
/// \param path Full path returned by `list_log_files()`.
/// \return Read result. Compressed files are listed but unreadable in v1.
/// \return Read result. Rotated `.gz` and `.zst` files are decompressed
/// when the corresponding feature is enabled.
LogFileReadResult read_log_file(const std::string& path) const override {
const std::vector<LogFileInfo> files = list_log_files();
for (size_t i = 0; i < files.size(); ++i) {
Expand Down
13 changes: 11 additions & 2 deletions tests/file_logger_gzip_compression_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,30 @@ int main() {
LOGIT_INFO(msg);
LOGIT_WAIT();
std::string current = LOGIT_GET_LAST_FILE_PATH(0);
LOGIT_SHUTDOWN();

std::string rotated = current;
size_t pos = rotated.rfind(".log");
rotated.insert(pos, ".001");
rotated += ".gz";

const logit::LogFileReadResult read = LOGIT_READ_LOG_FILE(0, rotated);
if (!read.ok || read.content.find(msg) == std::string::npos) return 1;
const std::vector<std::string> requested = {rotated};
const std::vector<logit::LogFileReadResult> read_many =
LOGIT_READ_LOG_FILES(0, requested);
if (read_many.size() != 1 || !read_many[0].ok ||
read_many[0].content.find(msg) == std::string::npos) return 1;

gzFile gz = gzopen(rotated.c_str(), "rb");
if (!gz) return 1;
char buf[128];
std::string out;
int n;
while ((n = gzread(gz, buf, sizeof(buf))) > 0) out.append(buf, n);
gzclose(gz);
return out.find(msg) != std::string::npos ? 0 : 1;
const bool ok = out.find(msg) != std::string::npos;
LOGIT_SHUTDOWN();
return ok ? 0 : 1;
}
#else
int main() { return 0; }
Expand Down
13 changes: 11 additions & 2 deletions tests/file_logger_zstd_compression_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ int main() {
LOGIT_INFO(msg);
LOGIT_WAIT();
std::string current = LOGIT_GET_LAST_FILE_PATH(0);
LOGIT_SHUTDOWN();

std::string rotated = current;
size_t pos = rotated.rfind(".log");
rotated.insert(pos, ".001");
rotated += ".zst";

const logit::LogFileReadResult read = LOGIT_READ_LOG_FILE(0, rotated);
if (!read.ok || read.content.find(msg) == std::string::npos) return 1;
const std::vector<std::string> requested = {rotated};
const std::vector<logit::LogFileReadResult> read_many =
LOGIT_READ_LOG_FILES(0, requested);
if (read_many.size() != 1 || !read_many[0].ok ||
read_many[0].content.find(msg) == std::string::npos) return 1;

std::ifstream in(rotated.c_str(), std::ios::binary | std::ios::ate);
if (!in) return 1;
std::streamsize size = in.tellg();
Expand All @@ -43,7 +50,9 @@ int main() {
size_t ret = ZSTD_decompress(decompressed.data(), raw_size, compressed.data(), compressed.size());
if (ZSTD_isError(ret)) return 1;
std::string out(decompressed.data(), ret);
return out.find(msg) != std::string::npos ? 0 : 1;
const bool ok = out.find(msg) != std::string::npos;
LOGIT_SHUTDOWN();
return ok ? 0 : 1;
}
#else
int main() { return 0; }
Expand Down
21 changes: 20 additions & 1 deletion tests/unique_file_logger_file_api_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,17 @@ int main() {
}
compressed_path = plain_files[0].path + ".gz";
std::ofstream gz(compressed_path.c_str(), std::ios_base::binary);
const std::string payload = "unique-compressed-payload";
#if defined(LOGIT_HAS_ZLIB)
std::string compressed;
if (!logit::detail::compress_string_gzip(payload, compressed, 1)) {
return 1;
}
gz.write(compressed.data(), static_cast<std::streamsize>(compressed.size()));
#else
gz << "compressed-placeholder";
#endif
gz.close();
}

const std::vector<logit::LogFileInfo> files = LOGIT_LIST_LOG_FILES(0);
Expand Down Expand Up @@ -108,7 +118,11 @@ int main() {
}

const logit::LogFileReadResult compressed_read = LOGIT_READ_LOG_FILE(0, compressed_path);
#if defined(LOGIT_HAS_ZLIB)
if (!compressed_read.ok || compressed_read.content != "unique-compressed-payload") {
#else
if (compressed_read.ok || !compressed_read.content.empty()) {
#endif
return 1;
}

Expand All @@ -119,7 +133,12 @@ int main() {
return 1;
}
if (!same_file_name(read_many[0].file.path, latest_path) || !read_many[0].ok ||
!same_file_name(read_many[1].file.path, compressed_path) || read_many[1].ok) {
!same_file_name(read_many[1].file.path, compressed_path) ||
#if defined(LOGIT_HAS_ZLIB)
!read_many[1].ok || read_many[1].content != "unique-compressed-payload") {
#else
read_many[1].ok) {
#endif
return 1;
}

Expand Down
Loading