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
100 changes: 100 additions & 0 deletions docs/spec/core/file_io_ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# `morph::core::FileIoOps` — design

`morph::core::FileIoOps` (`include/morph/core/file_io_ops.hpp`) is an
injectable strategy for the raw file-I/O primitives `morph::journal::
FileActionLog` and `morph::offline::FileOfflineQueue` both call:
`fwrite`, `fflush`, `fsync`/`_commit`, `fopen`, an ifstream-open probe, and
`std::filesystem::resize_file`. Every member is a `std::function` defaulting
to the real syscall/stdlib call it stands in for.

## Contents

- [Why it exists](#why-it-exists)
- [Shape](#shape)
- [Usage](#usage)
- [Thread safety](#thread-safety)
- [Cross-references](#cross-references)

## Why it exists

`FileActionLog`/`FileOfflineQueue` both have several branch arms that only
run when a real OS-level file-I/O call fails partway through an
otherwise-successful operation — disk full, a file descriptor closed
underneath, a permission change racing an exact window between two library
calls. None of those are reachable from a portable unit test without a way
to fail one specific call on demand (see `LASTRADA-Software/morph#97`,
which requested exactly this for `FileActionLog`; `FileOfflineQueue` has the
identical gap).

`FileIoOps` is that seam. A test constructs one, overrides the one member it
wants to fail (optionally gated behind a `std::shared_ptr<bool>` or a call
counter so it only fails on a specific call, not every call for the rest of
the object's lifetime), and passes it to the class under test's constructor.
Every other member stays at its real default, so the rest of the class's I/O
behaves normally around the one injected failure.

## Shape

A plain aggregate of six `std::function` members, each mirroring one
underlying call:

| Member | Mirrors | Signature |
|---|---|---|
| `fwrite` | `std::fwrite` | `size_t(const void*, size_t, FILE*)` |
| `fflush` | `std::fflush` | `int(FILE*)` |
| `fsync` | POSIX `fsync` / Windows `_commit` | `int(FILE*)` |
| `fopen` | `std::fopen` | `FILE*(const std::string&, const char*)` |
| `canOpenForRead` | `std::ifstream{path}`'s own open check | `bool(const std::filesystem::path&)` |
| `resizeFile` | `std::filesystem::resize_file` | `void(const std::filesystem::path&, uintmax_t, std::error_code&)` |

`canOpenForRead` exists because a fault-injection test has no way to make a
*real* `std::ifstream` construction fail without actually breaking the
filesystem — so `repairTornTail()`'s "can I read this path right now" check
is factored out as its own predicate, consulted before the real
`std::ifstream` is constructed, rather than trying to intercept the stream
construction itself.

## Usage

Both classes take an optional second constructor parameter:

```cpp
explicit FileActionLog(std::filesystem::path path, morph::core::FileIoOps ioOps = {});
explicit FileOfflineQueue(std::filesystem::path path, morph::core::FileIoOps ioOps = {});
```

A normal caller never passes one — the default-constructed `FileIoOps` is
byte-for-byte what both classes called directly before this seam existed, so
this is not a behavior change for any existing caller. Example, forcing a
short write:

```cpp
morph::core::FileIoOps ioOps;
ioOps.fwrite = [](const void* buffer, std::size_t size, std::FILE* file) {
return size - 1; // always one byte short
};
FileActionLog log{path, ioOps};
REQUIRE_THROWS_AS(log.append(entry), std::runtime_error);
```

To fail only a *specific* call (e.g. "the reopen after rotate()'s rename, not
the constructor's own open"), capture a shared counter or flag and check it
inside the lambda — see `tests/test_action_log_phase2.cpp`'s and
`tests/test_file_offline_queue.cpp`'s own fault-injection test cases for the
established idiom.

## Thread safety

`FileIoOps` itself is a plain value type with no shared state — copying or
moving one has ordinary value semantics. Whether the *callbacks* themselves
are safe to call from multiple threads concurrently is up to whatever a test
installs; the real default callbacks are exactly the real syscalls, which
already have their own well-defined thread-safety.

## Cross-references

- [`docs/spec/journal/journal.md`](../journal/journal.md) — `FileActionLog`'s
own design, including the branches this seam closes.
- [`docs/spec/offline/offline.md`](../offline/offline.md) — `FileOfflineQueue`'s
own design, including the identical class of branch this seam closes.
- `LASTRADA-Software/morph#97` — the issue that requested this seam.
9 changes: 7 additions & 2 deletions docs/spec/journal/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,12 @@ entry is one `toJson`-encoded line. `flush()` flushes the C stdio buffer and
then issues a real `fsync` (POSIX `fsync` / Windows `_commit`), so a crash
immediately after `flush()` returns cannot lose data.

Open (creating if necessary) via `FileActionLog(std::filesystem::path)`.
Open (creating if necessary) via `FileActionLog(std::filesystem::path, morph::core::FileIoOps = {})`.
The second parameter is a test-only fault-injection seam (`morph/core/
file_io_ops.hpp`) — the raw `fwrite`/`fflush`/`fsync`/`fopen`/file-open/
`resize_file` calls this class makes, as an injectable strategy defaulting to
the real syscalls, letting a test force the failure branches that otherwise
need a real OS-level I/O error to reach. A normal caller never passes one.
Throws `std::runtime_error` if the file cannot be opened. Closes the file in
the destructor. Copy and move are deleted.

Expand Down Expand Up @@ -702,7 +707,7 @@ All symbols live in `namespace morph::journal`.
|---|---|---|
| `IActionLog` | abstract struct | `virtual ~IActionLog() = default`; `append(LogEntry)`, `flush()`, `entries(entityKey)`. |
| `InMemoryActionLog` | class | `: IActionLog`. Thread-safe `std::vector`-backed. `flush()` no-op. |
| `FileActionLog` | class | `: IActionLog`. Newline-delimited JSON, fsync on `flush()`. `explicit FileActionLog(std::filesystem::path)`. `void rotate(const std::filesystem::path& sealedPath)` seals the active file and reopens a fresh one — see [Rotation and retention](#rotation-and-retention). Copy/move deleted. |
| `FileActionLog` | class | `: IActionLog`. Newline-delimited JSON, fsync on `flush()`. `explicit FileActionLog(std::filesystem::path, morph::core::FileIoOps = {})` — the `FileIoOps` is a test-only fault-injection seam, see above. `void rotate(const std::filesystem::path& sealedPath)` seals the active file and reopens a fresh one — see [Rotation and retention](#rotation-and-retention). Copy/move deleted. |
| `SessionLog` | class | `: IActionLog`. Full-fidelity in-memory log + `undoLast()` + `checkpoint()`. |

### Process-wide default
Expand Down
7 changes: 7 additions & 0 deletions docs/spec/offline/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ queue depths; `SqliteOfflineQueue` is the index-backed alternative for
high-volume keyed enqueues. Not safe for multiple processes to open the same
path concurrently.

The constructor takes an optional second `morph::core::FileIoOps` parameter
(`FileOfflineQueue(std::filesystem::path, morph::core::FileIoOps = {})`) — the
same test-only fault-injection seam `FileActionLog` uses (see
`docs/spec/journal/journal.md`): the raw `fwrite`/`fflush`/`fsync`/`fopen`
calls this class makes, as an injectable strategy defaulting to the real
syscalls. A normal caller never passes one.

### `SqliteOfflineQueue`

Reference SQLite-backed `IOfflineQueue` (`sqlite_offline_queue.hpp`), built
Expand Down
99 changes: 99 additions & 0 deletions include/morph/core/file_io_ops.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: Apache-2.0

#pragma once
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <system_error>

#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif

namespace morph::core {

/// @brief The raw file-I/O primitives `morph::journal::FileActionLog` and
/// `morph::offline::FileOfflineQueue` both call, as an injectable
/// strategy. Every member defaults to the real syscall/stdlib call it
/// stands in for, so a default-constructed `FileIoOps` is byte-for-byte
/// what both classes called directly before this seam existed — no
/// behavior change for a normal caller.
///
/// @par Why this exists
/// Both classes have several branch arms that only run when a real OS-level
/// file-I/O call fails partway through an otherwise-successful operation
/// (disk full, fd closed underneath, a permission change racing an exact
/// window). None of those are reachable from a portable unit test without
/// this seam — see `LASTRADA-Software/morph#97`, which requested exactly
/// this for `FileActionLog`; `FileOfflineQueue` has the identical gap. A
/// test constructs a `FileIoOps` whose relevant member fails on demand (or
/// on the Nth call, or forever) and passes it to the class under test;
/// every other member stays at its real default, so the rest of the class's
/// I/O behaves normally around the one injected failure.
///
/// @par Thread safety
/// `FileIoOps` itself is a plain value type with no shared state — copying
/// or moving one has ordinary value semantics. Whether the *callbacks*
/// themselves are safe to call from multiple threads concurrently is up to
/// whatever a test installs; the real default callbacks are exactly the
/// real syscalls, which already have their own well-defined thread-safety.
struct FileIoOps {
/// @brief Writes @p size bytes from @p buffer to @p file. Mirrors `std::fwrite`.
/// @return The number of bytes actually written; short of @p size on failure.
std::function<std::size_t(const void* buffer, std::size_t size, std::FILE* file)> fwrite =
[](const void* buffer, std::size_t size, std::FILE* file) {
return std::fwrite(buffer, 1, size, file);
};

/// @brief Flushes @p file's stdio buffer. Mirrors `std::fflush`.
/// @return `0` on success, nonzero on failure.
std::function<int(std::FILE* file)> fflush = [](std::FILE* file) { return std::fflush(file); };

/// @brief Commits @p file's contents to durable storage. POSIX `fsync` /
/// Windows `_commit`, resolved from @p file via `fileno`/`_fileno`.
/// @return `0` on success, nonzero on failure.
std::function<int(std::FILE* file)> fsync = [](std::FILE* file) {
#ifdef _WIN32
return _commit(_fileno(file));
#else
return ::fsync(fileno(file));
#endif
};

/// @brief Opens @p path in mode @p mode. Mirrors `std::fopen`.
/// @return The open file, or `nullptr` on failure.
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory) — mirrors std::fopen's own raw-owning-pointer return
std::function<std::FILE*(const std::string& path, const char* mode)> fopen =
[](const std::string& path, const char* mode) {
// NOLINTNEXTLINE(cert-err33-c) — callers check the returned handle themselves
return std::fopen(path.c_str(), mode);
};

/// @brief Reports whether @p path can be opened for reading right now.
/// Stands in for `std::ifstream{path}`'s own open-succeeded check
/// (`repairTornTail()`'s `if (!input)`) — a fault-injection test has
/// no way to make a *real* `std::ifstream` construction fail
/// without actually breaking the filesystem, so this predicate is
/// consulted first; the real default performs the real open
/// `std::ifstream` itself would.
/// @return `true` if @p path is currently readable.
std::function<bool(const std::filesystem::path& path)> canOpenForRead =
[](const std::filesystem::path& path) { return static_cast<bool>(std::ifstream{path}); };

/// @brief Truncates/extends @p path to @p newSize bytes. Mirrors
/// `std::filesystem::resize_file`.
/// @param path Path to resize.
/// @param newSize Target size, in bytes.
/// @param errorCode Set on failure, cleared on success — same contract as
/// `std::filesystem::resize_file`'s own `error_code` overload.
std::function<void(const std::filesystem::path& path, std::uintmax_t newSize, std::error_code& errorCode)>
resizeFile = [](const std::filesystem::path& path, std::uintmax_t newSize, std::error_code& errorCode) {
std::filesystem::resize_file(path, newSize, errorCode);
};
};

} // namespace morph::core
Loading
Loading