Vix.cpp docs moved to vixcpp.com
Skip to content

WAL ​

wal is the Write-Ahead Log module of Softadastra Engine.

It records operations before higher-level systems rely on them.

The core rule is:

txt
Record first.
Apply and sync after.

WAL gives Softadastra a durable operation history.

It is one of the most important modules for local-first and offline-first reliability.

Why WAL exists ​

Softadastra is designed for environments where failures are normal:

  • process restart
  • network failure
  • transport disconnect
  • peer unavailable
  • sync interruption
  • power loss
  • application crash

A local-first system must not lose accepted work just because the network failed.

The WAL solves this by recording operations locally before sync depends on them.

txt
local operation
  ↓
WAL record
  ↓
store apply
  ↓
sync tracking

The WAL is the durable history of what happened.

What WAL provides ​

The wal module provides:

  • WalConfig
  • WalRecord
  • WalWriter
  • WalReader
  • WalReplayer
  • WalRecordType
  • WalRecordStatus
  • record append
  • record read
  • record stream
  • record replay
  • event append

It allows the engine to:

  • append operations
  • assign sequence numbers
  • attach timestamps
  • flush records
  • read records back
  • stream records
  • replay history
  • recover local state
  • inspect operation history

What WAL does not do ​

wal must not:

  • own current application state
  • decide sync retries
  • connect peers
  • discover nodes
  • resolve conflicts
  • format CLI output
  • own business logic

The rule is:

txt
WAL records history.
Store exposes current state.
Sync tracks propagation.
Transport sends messages.

Include ​

Use the top-level include:

cpp
#include <softadastra/wal/Wal.hpp>

Module location ​

The module lives in:

txt
modules/wal/

Typical structure:

txt
modules/wal/
├── include/
│   └── softadastra/wal/
│       ├── core/
│       ├── encoding/
│       ├── reader/
│       ├── replay/
│       ├── types/
│       ├── writer/
│       └── Wal.hpp
├── src/
├── examples/
├── tests/
├── README.md
├── CMakeLists.txt
└── CHANGELOG.md

The exact structure can evolve, but the responsibility should stay stable:

txt
append, read, stream, and replay durable records

Main concepts ​

The WAL module is built around five concepts:

  • WalConfig
  • WalRecord
  • WalWriter
  • WalReader
  • WalReplayer

The normal flow is:

txt
WalConfig
  ↓
WalWriter
  ↓
append record
  ↓
flush, if needed
  ↓
WalReader / WalReplayer later

WalConfig ​

WalConfig configures how the WAL is used.

Common configuration shapes:

  • durable file-backed WAL
  • memory-only WAL, if supported
  • auto flush behavior, if supported
  • path to WAL file

Example:

cpp
wal::core::WalConfig::durable("example_wal.log")

A durable WAL writes records to a local file.

Durable WAL ​

A durable WAL uses a file path.

Example:

cpp
writer::WalWriter writer{
    core::WalConfig::durable("example_wal.log")};

This means records are written to:

txt
example_wal.log

A durable WAL is useful when operations must survive restart.

WAL path rule ​

A WAL path should be:

  • non-empty
  • writable
  • inside an existing directory
  • unique per local node when used by a node runtime
  • not manually edited by users

Good examples:

txt
data/node-a.wal
data/sdk-persistent-store.wal
data/store_recovery_demo.wal
example_wal.log

Avoid:

  • empty path
  • shared path between unrelated nodes
  • path in unwritable directory

WalRecord ​

WalRecord is one entry in the log.

A record can contain:

  • sequence
  • type
  • status
  • timestamp
  • payload

Conceptual shape:

cpp
WalRecord {
    sequence
    type
    status
    timestamp
    payload
}

Each record is appended in order.

The sequence number gives deterministic ordering.

WalRecordType ​

WalRecordType describes the kind of operation.

Common types include:

  • Put
  • Update
  • Delete
  • Event
  • Checkpoint, if supported
  • Unknown, if needed

The exact enum values depend on the current implementation.

Use the module helper when printing:

cpp
types::to_string(record.type)

WalRecordStatus ​

WalRecordStatus describes the status of a record.

Common statuses can include:

  • Pending
  • Committed
  • Applied
  • Failed

The exact enum values depend on the current implementation.

Use:

cpp
types::to_string(record.status)

when printing status.

Payload ​

The payload contains the encoded operation data.

Example:

cpp
core::WalRecord::Payload{1, 2, 3}

The WAL does not need to understand every high-level meaning of the payload.

Higher-level modules can encode and decode their own operation data.

Sequence ​

The sequence number gives every record a stable order.

Example output:

txt
Written record seq=1
Written record seq=2

Recovery depends on deterministic sequence order.

txt
same WAL
  ↓
same sequence order
  ↓
same replay order

Timestamp ​

Each record should carry a timestamp.

The timestamp helps with:

  • debugging
  • recovery inspection
  • sync diagnostics
  • event history
  • runtime observability

The timestamp should come from core time primitives.

WalWriter ​

WalWriter appends records.

It owns write-side behavior:

  • open WAL
  • append record
  • assign sequence
  • write encoded bytes
  • flush when requested
  • return sequence or error

Basic write example ​

cpp
#include <filesystem>
#include <iostream>

#include <softadastra/wal/Wal.hpp>

using namespace softadastra::wal;

int main()
{
    std::cout << "== WAL WRITE EXAMPLE ==\n";

    const std::string path = "example_wal.log";
    std::filesystem::remove(path);

    writer::WalWriter writer{
        core::WalConfig::durable(path)};

    auto first = writer.append(
        types::WalRecordType::Put,
        core::WalRecord::Payload{1, 2, 3});

    if (first.is_err())
    {
        std::cerr << "Failed to write first record: "
                  << first.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Written record seq="
              << first.value()
              << "\n";

    auto second = writer.append(
        types::WalRecordType::Update,
        core::WalRecord::Payload{4, 5});

    if (second.is_err())
    {
        std::cerr << "Failed to write second record: "
                  << second.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Written record seq="
              << second.value()
              << "\n";

    auto flushed = writer.flush();

    if (flushed.is_err())
    {
        std::cerr << "Failed to flush WAL: "
                  << flushed.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "WAL written to: "
              << path
              << "\n";

    return 0;
}

Append flow ​

A WAL append follows this model:

txt
append requested
  ↓
validate record type and payload
  ↓
assign sequence number
  ↓
attach timestamp
  ↓
encode record
  ↓
write bytes
  ↓
return sequence

If auto flush or explicit flush is used:

txt
write bytes
  ↓
flush file
  ↓
return durable result

Flush ​

Flush forces written data to be pushed according to the current backend behavior.

Example:

cpp
auto flushed = writer.flush();

if (flushed.is_err())
{
    std::cerr << flushed.error().message() << "\n";
}

Use flush when durability matters.

The important rule is:

txt
If flush fails, durability is not guaranteed.

WalReader ​

WalReader reads records back.

It owns read-side behavior:

  • open WAL file
  • read encoded records
  • decode records
  • return records
  • report errors

Basic read example ​

cpp
#include <iostream>

#include <softadastra/wal/Wal.hpp>

using namespace softadastra::wal;

int main()
{
    std::cout << "== WAL READ EXAMPLE ==\n";

    const std::string path = "example_wal.log";

    reader::WalReader reader{path};

    auto records = reader.read_all();

    if (records.is_err())
    {
        std::cerr << "Failed to read WAL: "
                  << records.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Total records: "
              << records.value().size()
              << "\n\n";

    for (const auto &record : records.value())
    {
        std::cout << "Sequence:  "
                  << record.sequence
                  << "\n";

        std::cout << "Type:      "
                  << types::to_string(record.type)
                  << "\n";

        std::cout << "Status:    "
                  << types::to_string(record.status)
                  << "\n";

        std::cout << "Timestamp: "
                  << record.timestamp.millis()
                  << "\n";

        std::cout << "Payload:   ";

        for (auto byte : record.payload)
        {
            std::cout << static_cast<int>(byte) << ' ';
        }

        std::cout << "\n----------------------\n";
    }

    return 0;
}

Read flow ​

Reading all records follows this model:

txt
open WAL path
  ↓
read first record
  ↓
decode record
  ↓
validate record
  ↓
append to result list
  ↓
repeat until end

If reading fails:

txt
read error
  ↓
return explicit error

If decoding fails:

txt
decode error
  ↓
return explicit error or valid-prefix recovery result, depending on policy

The failure must be visible.

Streaming records ​

WalReader can also stream records one by one.

This is useful when a WAL may be large.

Stream example ​

cpp
#include <iostream>

#include <softadastra/wal/Wal.hpp>

using namespace softadastra::wal;

int main()
{
    std::cout << "== WAL STREAM EXAMPLE ==\n";

    const std::string path = "example_wal.log";

    reader::WalReader reader{path};

    auto result = reader.for_each(
        [](const core::WalRecord &record)
        {
            std::cout << "[record] seq="
                      << record.sequence
                      << " type="
                      << types::to_string(record.type)
                      << " status="
                      << types::to_string(record.status)
                      << " timestamp="
                      << record.timestamp.millis()
                      << " payload_size="
                      << record.payload.size()
                      << "\n";
        });

    if (result.is_err())
    {
        std::cerr << "Failed to stream WAL: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    return 0;
}

Streaming flow ​

Streaming follows this model:

txt
open WAL
  ↓
read next record
  ↓
decode record
  ↓
call callback
  ↓
repeat

Streaming avoids loading all records into memory at once.

Use streaming for:

  • large WAL files
  • diagnostic tools
  • incremental replay
  • CLI inspection

WalReplayer ​

WalReplayer replays records into another component.

Replay is used to rebuild state or apply history.

Replay to store example ​

cpp
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <map>
#include <string>
#include <vector>

#include <softadastra/wal/Wal.hpp>

using namespace softadastra::wal;

class DemoStore
{
public:
    void apply(const core::WalRecord &record)
    {
        const std::string key =
            "record_" + std::to_string(record.sequence);

        switch (record.type)
        {
        case types::WalRecordType::Put:
        case types::WalRecordType::Update:
            data_[key] = record.payload;
            break;

        case types::WalRecordType::Delete:
            data_.erase(key);
            break;

        default:
            break;
        }
    }

    void print() const
    {
        std::cout << "\n== STORE STATE ==\n";

        if (data_.empty())
        {
            std::cout << "(empty)\n";
            return;
        }

        for (const auto &[key, value] : data_)
        {
            std::cout << key << " => [ ";

            for (auto byte : value)
            {
                std::cout << static_cast<int>(byte) << ' ';
            }

            std::cout << "]\n";
        }
    }

private:
    std::map<std::string, std::vector<std::uint8_t>> data_{};
};

int main()
{
    std::cout << "== WAL REPLAY TO STORE EXAMPLE ==\n";

    const std::string wal_path = "replay_store_example.log";
    std::filesystem::remove(wal_path);

    writer::WalWriter writer{
        core::WalConfig::durable(wal_path)};

    auto first = writer.append(
        types::WalRecordType::Put,
        core::WalRecord::Payload{10, 20, 30});

    auto second = writer.append(
        types::WalRecordType::Update,
        core::WalRecord::Payload{99, 88});

    auto third = writer.append(
        types::WalRecordType::Put,
        core::WalRecord::Payload{7, 8, 9, 10});

    if (first.is_err() || second.is_err() || third.is_err())
    {
        std::cerr << "Failed to write WAL records\n";
        return 1;
    }

    auto flushed = writer.flush();

    if (flushed.is_err())
    {
        std::cerr << "Failed to flush WAL: "
                  << flushed.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "WAL written to: "
              << wal_path
              << "\n";

    DemoStore store;
    replay::WalReplayer replayer{wal_path};

    std::cout << "\n== REPLAYING RECORDS ==\n";

    auto replay_result = replayer.replay(
        [&](const core::WalRecord &record)
        {
            std::cout << "Applying seq="
                      << record.sequence
                      << " type="
                      << types::to_string(record.type)
                      << " payload_size="
                      << record.payload.size()
                      << "\n";

            store.apply(record);
        });

    if (replay_result.is_err())
    {
        std::cerr << "Replay failed: "
                  << replay_result.error().message()
                  << "\n";

        return 1;
    }

    store.print();

    std::filesystem::remove(wal_path);

    return 0;
}

Replay flow ​

Replay follows this model:

txt
open WAL
  ↓
read record in sequence order
  ↓
decode record
  ↓
call apply callback
  ↓
continue until end

Replay is used for:

  • store recovery
  • diagnostics
  • rebuilding state
  • testing deterministic history
  • migration tools later

WAL and filesystem events ​

The WAL can also record filesystem events.

This is useful when Softadastra is used for file synchronization or local filesystem tracking.

File event append example ​

cpp
#include <filesystem>
#include <iostream>
#include <optional>

#include <softadastra/wal/Wal.hpp>

using namespace softadastra;

int main()
{
    std::cout << "== WAL FILE EVENT EXAMPLE ==\n";

    const std::string path = "event_wal.log";
    std::filesystem::remove(path);

    wal::writer::WalWriter writer{
        wal::core::WalConfig::durable(path)};

    auto path_result =
        fs::path::Path::from("docs/file.txt");

    if (path_result.is_err())
    {
        std::cerr << "Invalid path: "
                  << path_result.error().message()
                  << "\n";

        return 1;
    }

    fs::state::FileMetadata metadata{};
    metadata.type = fs::types::FileType::File;
    metadata.size = 128;
    metadata.modified = core::time::Timestamp::now();

    fs::state::FileState state{
        path_result.value(),
        metadata,
        std::nullopt};

    fs::events::FileEvent event{
        fs::types::FileEventType::Created,
        state,
        std::nullopt};

    auto result = writer.append_event(event);

    if (result.is_err())
    {
        std::cerr << "Failed to append event: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Event written with seq="
              << result.value()
              << "\n";

    return 0;
}

WAL and store ​

The store can use WAL-backed configuration.

Relationship:

txt
WAL   -> operation history
Store -> current key-value state

A store operation can be recorded before it is applied.

txt
store put
  ↓
operation encoded
  ↓
WAL append
  ↓
store apply

This allows recovery.

WAL-backed store flow ​

txt
StoreEngine::put(key, value)
  ↓
create Operation::Put
  ↓
encode operation
  ↓
append to WAL
  ↓
apply operation to current store
  ↓
return result

On restart:

txt
open StoreEngine
  ↓
read WAL
  ↓
decode operations
  ↓
replay operations
  ↓
rebuild entries

WAL and sync ​

Sync uses local operation history indirectly.

The WAL makes operations recoverable.

Sync makes operations propagatable.

txt
WAL  -> local durability
Sync -> propagation tracking

A value can be durable locally but not yet synced remotely.

txt
durable locally
  does not mean
acknowledged remotely

WAL and transport ​

Transport does not write WAL directly in the clean model.

Better flow:

txt
store operation
  ↓
WAL append
  ↓
sync tracking
  ↓
transport sends sync message

Transport failure should not affect already recorded WAL history.

WAL and discovery ​

Discovery should not depend on WAL for local store behavior.

Discovery finds peers.

WAL records operations.

They are separate.

A higher-level diagnostic or service may record discovery events later, but generic discovery must not own WAL durability.

WAL and metadata ​

Metadata describes the node.

WAL records history.

They are separate concerns.

A metadata snapshot can include the WAL path for diagnostics later, but metadata should not own WAL writing.

WAL and CLI ​

CLI can expose WAL-backed behavior through commands.

Examples:

  • store put
  • store get
  • sync status
  • status

But WAL must not depend on CLI.

Correct direction:

txt
CLI command
  ↓
store / wal module

Wrong direction:

txt
wal module
  ↓
cli output

WAL should return structured results and errors.

Durability rule ​

The most important WAL rule is:

txt
Do not report durable success if the WAL append failed.

If WAL append fails:

txt
operation requested
  ↓
WAL append fails
  ↓
return error
  ↓
do not pretend operation is durable

This protects local-first correctness.

WAL failure versus transport failure ​

These failures are different.

txt
WAL failure
  -> local durability problem

transport failure
  -> remote delivery problem

If transport fails after WAL succeeds:

txt
local operation remains durable
sync can retry later

If WAL fails before store apply:

txt
operation is not durably accepted
caller must see error

Recovery rule ​

Recovery should be deterministic.

txt
same WAL records
  ↓
same replay order
  ↓
same final state

This is why sequence numbers matter.

Do not replay records in random order.

Valid-prefix recovery ​

If a WAL is partially corrupted, the safest model is:

txt
read valid records
  ↓
stop at invalid record
  ↓
do not apply invalid bytes
  ↓
return clear error or partial recovery result

The engine should never silently apply corrupted records.

The exact recovery policy can evolve, but corruption must be visible.

WAL file ownership ​

A WAL file should normally be owned by one local runtime or node.

Recommended:

txt
one node -> one WAL path

Good:

txt
data/node-a.wal
data/node-b.wal

Avoid two independent nodes writing the same WAL file unless the engine explicitly supports concurrent access.

WAL cleanup ​

Examples may create temporary WAL files.

They can remove them when done:

cpp
std::filesystem::remove(wal_path);

Production runtimes should not delete WAL files unless the operation is intentional.

A WAL contains recovery history.

WAL compaction ​

Future versions may add compaction.

Compaction can reduce log size by replacing old operation history with a snapshot or checkpoint.

Possible flow:

txt
WAL grows
  ↓
store snapshot created
  ↓
checkpoint record written
  ↓
old records compacted

Compaction must preserve recovery correctness.

WAL checkpoints ​

A future checkpoint record can represent a known state.

Conceptual flow:

txt
records 1..1000
  ↓
checkpoint at 1000
  ↓
future recovery starts from checkpoint

This can speed up recovery.

It must be carefully designed.

WAL and snapshots ​

The store can build a snapshot from WAL history.

Flow:

txt
read WAL records
  ↓
decode store operations
  ↓
apply in sequence
  ↓
produce snapshot

This is useful for diagnostics and recovery.

Error handling ​

WAL operations should return explicit errors.

Example:

cpp
auto result = writer.append(
    types::WalRecordType::Put,
    core::WalRecord::Payload{1, 2, 3});

if (result.is_err())
{
    std::cerr << result.error().message() << "\n";
    return 1;
}

Possible errors:

  • invalid path
  • permission denied
  • open failed
  • write failed
  • flush failed
  • read failed
  • decode failed
  • corrupted record
  • unsupported record version
  • invalid payload

Do not hide WAL failures.

Invalid WAL path ​

An empty path should be rejected.

Bad:

txt
""

Good:

txt
data/node-a.wal

The error should explain the problem:

txt
invalid WAL path: path is empty

Directory missing ​

If the WAL path is:

txt
data/node-a.wal

and data/ does not exist, opening or writing can fail.

Fix:

bash
mkdir -p data

The error should be clear:

txt
failed to open WAL path data/node-a.wal: directory does not exist

Permission denied ​

If the process cannot write to the WAL path, return an error.

Example:

txt
failed to append WAL record: permission denied

The caller should not treat the operation as accepted.

Disk full ​

If the disk is full, append or flush can fail.

The engine should return a clear error.

Example:

txt
failed to flush WAL: no space left on device

This is a serious durability problem.

Decode failed ​

If reading a record fails during decode:

txt
read bytes
  ↓
decode failed
  ↓
return error

Do not apply invalid records.

Corrupted record ​

A corrupted record should be visible.

Example:

txt
corrupted WAL record at sequence 42

or:

txt
corrupted WAL record at byte offset 8192

Good diagnostics should include the sequence or file offset when available.

WAL API reference ​

Main areas ​

AreaPurpose
coreWAL config and record primitives
typesRecord types and statuses
writerAppend and flush records
readerRead and stream records
replayReplay records
encodingEncode and decode records

Main types ​

TypePurpose
WalConfigConfigures WAL behavior
WalRecordOne log record
WalWriterAppends records
WalReaderReads records
WalReplayerReplays records
WalRecordTypeType of record
WalRecordStatusStatus of record
cpp
#include <softadastra/wal/Wal.hpp>

Examples ​

Current useful examples include:

  • wal_write.cpp
  • wal_read.cpp
  • wal_stream.cpp
  • wal_replay_to_store.cpp
  • wal_event.cpp

Recommended order:

  1. wal_write.cpp
  2. wal_read.cpp
  3. wal_stream.cpp
  4. wal_replay_to_store.cpp
  5. wal_event.cpp

This order moves from simple append to replay and file event recording.

Run examples ​

From the engine repository:

bash
cd ~/softadastra/softadastra

Build:

bash
vix build

Or with CMake:

bash
cmake --preset dev-ninja
cmake --build --preset build-ninja

Find binaries:

bash
find build-ninja -type f -executable

Run the relevant WAL example binary from the build output.

Testing WAL ​

WAL tests should verify:

  • append one record
  • append multiple records
  • sequence increments
  • flush succeeds
  • read all records
  • stream records
  • replay records
  • invalid path returns error
  • missing directory returns error
  • corrupted record is detected
  • empty WAL reads as empty
  • large payload works
  • delete record works

WAL test flow ​

A good test flow:

txt
create temp WAL path
append records
flush
read records
verify sequence
verify type
verify payload
cleanup temp WAL path

Another:

txt
create temp WAL path
append records
replay records into test store
verify final state
cleanup

Another:

txt
create corrupted WAL file
read WAL
expect explicit error
cleanup

Design rules ​

The wal module should follow these rules:

  1. Append records in deterministic order.
  2. Assign stable sequence numbers.
  3. Use core time for timestamps.
  4. Return explicit errors.
  5. Do not hide write or flush failures.
  6. Do not own store behavior.
  7. Do not own sync retry behavior.
  8. Do not depend on transport or discovery.
  9. Make replay deterministic.
  10. Keep examples small.

Common mistakes ​

Treating WAL as current state ​

Wrong:

txt
WAL is the database state

Better:

txt
WAL is operation history
Store is current state

Ignoring append errors ​

Wrong:

cpp
writer.append(type, payload);
store.apply(operation);

Better:

cpp
auto result = writer.append(type, payload);

if (result.is_err())
{
    return result.error();
}

store.apply(operation);

Applying corrupted records ​

Wrong:

txt
decode failed
  ↓
apply partial payload anyway

Better:

txt
decode failed
  ↓
stop
  ↓
return error

Sharing one WAL between independent nodes ​

Wrong:

txt
node-a and node-b write data/shared.wal

Better:

txt
node-a -> data/node-a.wal
node-b -> data/node-b.wal

Making WAL depend on sync ​

Wrong:

txt
wal module calls SyncEngine

Better:

txt
store or runtime composes WAL and SyncEngine

Making WAL print CLI output ​

Wrong:

txt
WalWriter prints user-facing messages

Better:

txt
WalWriter returns Result
CLI formats output

For durable append:

cpp
writer::WalWriter writer{
    core::WalConfig::durable("data/node-a.wal")};

auto result = writer.append(
    types::WalRecordType::Put,
    payload);

if (result.is_err())
{
    return 1;
}

auto flushed = writer.flush();

if (flushed.is_err())
{
    return 1;
}

For read:

cpp
reader::WalReader reader{"data/node-a.wal"};

auto records = reader.read_all();

if (records.is_err())
{
    return 1;
}

For replay:

cpp
replay::WalReplayer replayer{"data/node-a.wal"};

auto result = replayer.replay(
    [&](const core::WalRecord &record)
    {
        // apply record
    });

if (result.is_err())
{
    return 1;
}

Summary ​

wal is the durability module of Softadastra Engine.

It provides:

  • WalConfig
  • WalRecord
  • WalWriter
  • WalReader
  • WalReplayer
  • append
  • flush
  • read
  • stream
  • replay

The key idea is:

txt
WAL records durable operation history before sync depends on it.

WAL does not own current state, networking, discovery, or conflict resolution.

Next step ​

Continue with store:

Go to Store

Released under the Apache License 2.0.