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:
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.
local operation
↓
WAL record
↓
store apply
↓
sync trackingThe WAL is the durable history of what happened.
What WAL provides
The wal module provides:
WalConfigWalRecordWalWriterWalReaderWalReplayerWalRecordTypeWalRecordStatus- 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:
WAL records history.
Store exposes current state.
Sync tracks propagation.
Transport sends messages.Include
Use the top-level include:
#include <softadastra/wal/Wal.hpp>Module location
The module lives in:
modules/wal/Typical structure:
modules/wal/
├── include/
│ └── softadastra/wal/
│ ├── core/
│ ├── encoding/
│ ├── reader/
│ ├── replay/
│ ├── types/
│ ├── writer/
│ └── Wal.hpp
├── src/
├── examples/
├── tests/
├── README.md
├── CMakeLists.txt
└── CHANGELOG.mdThe exact structure can evolve, but the responsibility should stay stable:
append, read, stream, and replay durable recordsMain concepts
The WAL module is built around five concepts:
WalConfigWalRecordWalWriterWalReaderWalReplayer
The normal flow is:
WalConfig
↓
WalWriter
↓
append record
↓
flush, if needed
↓
WalReader / WalReplayer laterWalConfig
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:
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:
writer::WalWriter writer{
core::WalConfig::durable("example_wal.log")};This means records are written to:
example_wal.logA 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:
data/node-a.wal
data/sdk-persistent-store.wal
data/store_recovery_demo.wal
example_wal.logAvoid:
- 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:
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:
PutUpdateDeleteEventCheckpoint, if supportedUnknown, if needed
The exact enum values depend on the current implementation.
Use the module helper when printing:
types::to_string(record.type)WalRecordStatus
WalRecordStatus describes the status of a record.
Common statuses can include:
PendingCommittedAppliedFailed
The exact enum values depend on the current implementation.
Use:
types::to_string(record.status)when printing status.
Payload
The payload contains the encoded operation data.
Example:
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:
Written record seq=1
Written record seq=2Recovery depends on deterministic sequence order.
same WAL
↓
same sequence order
↓
same replay orderTimestamp
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
#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:
append requested
↓
validate record type and payload
↓
assign sequence number
↓
attach timestamp
↓
encode record
↓
write bytes
↓
return sequenceIf auto flush or explicit flush is used:
write bytes
↓
flush file
↓
return durable resultFlush
Flush forces written data to be pushed according to the current backend behavior.
Example:
auto flushed = writer.flush();
if (flushed.is_err())
{
std::cerr << flushed.error().message() << "\n";
}Use flush when durability matters.
The important rule is:
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
#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:
open WAL path
↓
read first record
↓
decode record
↓
validate record
↓
append to result list
↓
repeat until endIf reading fails:
read error
↓
return explicit errorIf decoding fails:
decode error
↓
return explicit error or valid-prefix recovery result, depending on policyThe 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
#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:
open WAL
↓
read next record
↓
decode record
↓
call callback
↓
repeatStreaming 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
#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:
open WAL
↓
read record in sequence order
↓
decode record
↓
call apply callback
↓
continue until endReplay 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
#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:
WAL -> operation history
Store -> current key-value stateA store operation can be recorded before it is applied.
store put
↓
operation encoded
↓
WAL append
↓
store applyThis allows recovery.
WAL-backed store flow
StoreEngine::put(key, value)
↓
create Operation::Put
↓
encode operation
↓
append to WAL
↓
apply operation to current store
↓
return resultOn restart:
open StoreEngine
↓
read WAL
↓
decode operations
↓
replay operations
↓
rebuild entriesWAL and sync
Sync uses local operation history indirectly.
The WAL makes operations recoverable.
Sync makes operations propagatable.
WAL -> local durability
Sync -> propagation trackingA value can be durable locally but not yet synced remotely.
durable locally
does not mean
acknowledged remotelyWAL and transport
Transport does not write WAL directly in the clean model.
Better flow:
store operation
↓
WAL append
↓
sync tracking
↓
transport sends sync messageTransport 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 putstore getsync statusstatus
But WAL must not depend on CLI.
Correct direction:
CLI command
↓
store / wal moduleWrong direction:
wal module
↓
cli outputWAL should return structured results and errors.
Durability rule
The most important WAL rule is:
Do not report durable success if the WAL append failed.If WAL append fails:
operation requested
↓
WAL append fails
↓
return error
↓
do not pretend operation is durableThis protects local-first correctness.
WAL failure versus transport failure
These failures are different.
WAL failure
-> local durability problem
transport failure
-> remote delivery problemIf transport fails after WAL succeeds:
local operation remains durable
sync can retry laterIf WAL fails before store apply:
operation is not durably accepted
caller must see errorRecovery rule
Recovery should be deterministic.
same WAL records
↓
same replay order
↓
same final stateThis 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:
read valid records
↓
stop at invalid record
↓
do not apply invalid bytes
↓
return clear error or partial recovery resultThe 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:
one node -> one WAL pathGood:
data/node-a.wal
data/node-b.walAvoid 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:
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:
WAL grows
↓
store snapshot created
↓
checkpoint record written
↓
old records compactedCompaction must preserve recovery correctness.
WAL checkpoints
A future checkpoint record can represent a known state.
Conceptual flow:
records 1..1000
↓
checkpoint at 1000
↓
future recovery starts from checkpointThis can speed up recovery.
It must be carefully designed.
WAL and snapshots
The store can build a snapshot from WAL history.
Flow:
read WAL records
↓
decode store operations
↓
apply in sequence
↓
produce snapshotThis is useful for diagnostics and recovery.
Error handling
WAL operations should return explicit errors.
Example:
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:
""Good:
data/node-a.walThe error should explain the problem:
invalid WAL path: path is emptyDirectory missing
If the WAL path is:
data/node-a.waland data/ does not exist, opening or writing can fail.
Fix:
mkdir -p dataThe error should be clear:
failed to open WAL path data/node-a.wal: directory does not existPermission denied
If the process cannot write to the WAL path, return an error.
Example:
failed to append WAL record: permission deniedThe 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:
failed to flush WAL: no space left on deviceThis is a serious durability problem.
Decode failed
If reading a record fails during decode:
read bytes
↓
decode failed
↓
return errorDo not apply invalid records.
Corrupted record
A corrupted record should be visible.
Example:
corrupted WAL record at sequence 42or:
corrupted WAL record at byte offset 8192Good diagnostics should include the sequence or file offset when available.
WAL API reference
Main areas
| Area | Purpose |
|---|---|
core | WAL config and record primitives |
types | Record types and statuses |
writer | Append and flush records |
reader | Read and stream records |
replay | Replay records |
encoding | Encode and decode records |
Main types
| Type | Purpose |
|---|---|
WalConfig | Configures WAL behavior |
WalRecord | One log record |
WalWriter | Appends records |
WalReader | Reads records |
WalReplayer | Replays records |
WalRecordType | Type of record |
WalRecordStatus | Status of record |
Recommended public include
#include <softadastra/wal/Wal.hpp>Examples
Current useful examples include:
wal_write.cppwal_read.cppwal_stream.cppwal_replay_to_store.cppwal_event.cpp
Recommended order:
wal_write.cppwal_read.cppwal_stream.cppwal_replay_to_store.cppwal_event.cpp
This order moves from simple append to replay and file event recording.
Run examples
From the engine repository:
cd ~/softadastra/softadastraBuild:
vix buildOr with CMake:
cmake --preset dev-ninja
cmake --build --preset build-ninjaFind binaries:
find build-ninja -type f -executableRun 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:
create temp WAL path
append records
flush
read records
verify sequence
verify type
verify payload
cleanup temp WAL pathAnother:
create temp WAL path
append records
replay records into test store
verify final state
cleanupAnother:
create corrupted WAL file
read WAL
expect explicit error
cleanupDesign rules
The wal module should follow these rules:
- Append records in deterministic order.
- Assign stable sequence numbers.
- Use core time for timestamps.
- Return explicit errors.
- Do not hide write or flush failures.
- Do not own store behavior.
- Do not own sync retry behavior.
- Do not depend on transport or discovery.
- Make replay deterministic.
- Keep examples small.
Common mistakes
Treating WAL as current state
Wrong:
WAL is the database stateBetter:
WAL is operation history
Store is current stateIgnoring append errors
Wrong:
writer.append(type, payload);
store.apply(operation);Better:
auto result = writer.append(type, payload);
if (result.is_err())
{
return result.error();
}
store.apply(operation);Applying corrupted records
Wrong:
decode failed
↓
apply partial payload anywayBetter:
decode failed
↓
stop
↓
return errorSharing one WAL between independent nodes
Wrong:
node-a and node-b write data/shared.walBetter:
node-a -> data/node-a.wal
node-b -> data/node-b.walMaking WAL depend on sync
Wrong:
wal module calls SyncEngineBetter:
store or runtime composes WAL and SyncEngineMaking WAL print CLI output
Wrong:
WalWriter prints user-facing messagesBetter:
WalWriter returns Result
CLI formats outputRecommended usage pattern
For durable append:
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:
reader::WalReader reader{"data/node-a.wal"};
auto records = reader.read_all();
if (records.is_err())
{
return 1;
}For replay:
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:
WalConfigWalRecordWalWriterWalReaderWalReplayer- append
- flush
- read
- stream
- replay
The key idea is:
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: