Offline-first Sync
This page is a practical, beginner-friendly guide for the Vix.cpp offline-first sync engine.
Goal: You can enqueue operations locally, stay offline, crash/restart, then safely converge when the network comes back.
You will learn:
- What the SyncEngine does
- How Outbox makes writes durable before any network attempt
- How RetryPolicy schedules retries
- How to test your setup quickly (copy-paste + run)
- How to simulate failures (offline, retryable errors, permanent errors, crash)
0) Mental model (what happens at runtime)
When you call outbox.enqueue(op):
- The operation is persisted to disk (OutboxStore).
- The engine can try to deliver it later (now or when online).
When the engine ticks:
- It asks Outbox for ready operations.
- It claims them (InFlight).
- It sends them through a transport (HTTP, WS, P2P, edge).
- It marks them Done or Failed (and schedules retry).
Key offline-first rule:
- No network attempt happens unless the operation was persisted first.
Headers
Engine:
#include <vix/sync/engine/SyncEngine.hpp>
#include <vix/sync/engine/SyncWorker.hpp>Outbox:
#include <vix/sync/outbox/Outbox.hpp>
#include <vix/sync/outbox/OutboxStore.hpp>
#include <vix/sync/outbox/FileOutboxStore.hpp>Network:
#include <vix/net/NetworkProbe.hpp>How to run these examples
You have two common ways:
Option A) Use Vix CLI (fastest)
If you already use vix run:
vix run main.cppThis compiles and runs the file.
Option B) Build with CMake
If your project is a normal CMake project, create a small target and link Vix. Then:
cmake -S . -B build
cmake --build build -j
./build/sync_exampleNotes for beginners:
- These examples write files under
./.vix_test_*. You can delete that folder anytime. - They use
assert(...)to keep checks minimal.
Helper: now_ms() (used by all examples)
The sync engine uses millisecond timestamps.
#include <chrono>
#include <cstdint>
static std::int64_t now_ms()
{
using namespace std::chrono;
return duration_cast<milliseconds>(
steady_clock::now().time_since_epoch()
).count();
}Example 1: Minimal smoke test (enqueue + tick + Done)
This is the smallest complete setup: file-backed outbox + always-online probe + transport + engine.
What you should see:
- The engine processes at least 1 operation.
- The operation ends as
Done.
#include <cassert>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <vix/net/NetworkProbe.hpp>
#include <vix/sync/Operation.hpp>
#include <vix/sync/engine/SyncEngine.hpp>
#include <vix/sync/outbox/FileOutboxStore.hpp>
#include <vix/sync/outbox/Outbox.hpp>
// Copy from Appendix at the end of this file
#include "fake_http_transport.hpp"
static std::int64_t now_ms()
{
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static void reset_dir(const std::filesystem::path& dir)
{
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir, ec);
}
static void run_example_1()
{
using namespace vix::sync;
using namespace vix::sync::engine;
using namespace vix::sync::outbox;
const std::filesystem::path dir = "./.vix_test_smoke";
reset_dir(dir);
auto store = std::make_shared<FileOutboxStore>(FileOutboxStore::Config{
.file_path = dir / "outbox.json",
.pretty_json = true,
.fsync_on_write = false
});
auto outbox = std::make_shared<Outbox>(
Outbox::Config{ .owner = "example-1" },
store
);
auto probe = std::make_shared<vix::net::NetworkProbe>(
vix::net::NetworkProbe::Config{},
[] { return true; } // always online
);
auto transport = std::make_shared<FakeHttpTransport>();
transport->setDefault({ .ok = true });
SyncEngine engine(
SyncEngine::Config{ .worker_count = 1, .batch_limit = 10 },
outbox, probe, transport
);
Operation op;
op.kind = "http.post";
op.target = "/api/messages";
op.payload = R"({"text":"hello offline"})";
const auto id = outbox->enqueue(op, now_ms());
const auto processed = engine.tick(now_ms());
assert(processed >= 1);
auto saved = store->get(id);
assert(saved.has_value());
assert(saved->status == OperationStatus::Done);
std::cout << "[example-1] OK: operation is Done\n";
}
int main()
{
run_example_1();
return 0;
}Beginner tip:
- Open
./.vix_test_smoke/outbox.jsonto see the durable state.
Example 2: Retryable failure (fails first, succeeds later)
This simulates a flaky network or a temporary server error.
What you should see:
- First tick: operation becomes Failed and a retry is scheduled.
- Later tick: operation becomes Done.
#include <cassert>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <vix/net/NetworkProbe.hpp>
#include <vix/sync/Operation.hpp>
#include <vix/sync/engine/SyncEngine.hpp>
#include <vix/sync/outbox/FileOutboxStore.hpp>
#include <vix/sync/outbox/Outbox.hpp>
#include "fake_http_transport.hpp"
static std::int64_t now_ms()
{
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static void reset_dir(const std::filesystem::path& dir)
{
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir, ec);
}
static void run_example_2()
{
using namespace vix::sync;
using namespace vix::sync::engine;
using namespace vix::sync::outbox;
const std::filesystem::path dir = "./.vix_test_retry";
reset_dir(dir);
auto store = std::make_shared<FileOutboxStore>(FileOutboxStore::Config{
.file_path = dir / "outbox.json",
.pretty_json = true,
.fsync_on_write = false
});
auto outbox = std::make_shared<Outbox>(
Outbox::Config{ .owner = "example-2" },
store
);
auto probe = std::make_shared<vix::net::NetworkProbe>(
vix::net::NetworkProbe::Config{},
[] { return true; }
);
auto transport = std::make_shared<FakeHttpTransport>();
// First attempt fails (retryable), then succeed.
transport->setRuleForTarget("/api/messages", FakeHttpTransport::Rule{
.ok = false,
.retryable = true,
.error = "temporary error (retryable)"
});
SyncEngine engine(
SyncEngine::Config{ .worker_count = 1, .batch_limit = 10 },
outbox, probe, transport
);
Operation op;
op.kind = "http.post";
op.target = "/api/messages";
op.payload = R"({"text":"retry me"})";
const auto t0 = now_ms();
const auto id = outbox->enqueue(op, t0);
// Tick 1: should fail (retryable)
engine.tick(t0);
{
auto saved = store->get(id);
assert(saved.has_value());
assert(saved->status == OperationStatus::Failed || saved->status == OperationStatus::Pending);
}
// Switch transport to success for next attempt
transport->setRuleForTarget("/api/messages", FakeHttpTransport::Rule{ .ok = true });
// Tick 2: run in the future so retry window can pass.
const auto t1 = t0 + 10'000;
engine.tick(t1);
auto final = store->get(id);
assert(final.has_value());
assert(final->status == OperationStatus::Done);
std::cout << "[example-2] OK: retryable failure eventually becomes Done\n";
}
int main()
{
run_example_2();
return 0;
}Beginner tip:
- Retry scheduling is computed using RetryPolicy.
- You can tune delays and max attempts in
Outbox::Config{ .retry = ... }.
Example 3: Permanent failure (do not retry)
This simulates a permanent error such as invalid request data.
What you should see:
- The operation becomes PermanentFailed.
- The transport is not called again for that operation.
#include <cassert>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <vix/net/NetworkProbe.hpp>
#include <vix/sync/Operation.hpp>
#include <vix/sync/engine/SyncEngine.hpp>
#include <vix/sync/outbox/FileOutboxStore.hpp>
#include <vix/sync/outbox/Outbox.hpp>
#include "fake_http_transport.hpp"
static std::int64_t now_ms()
{
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static void reset_dir(const std::filesystem::path& dir)
{
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir, ec);
}
static void run_example_3()
{
using namespace vix::sync;
using namespace vix::sync::engine;
using namespace vix::sync::outbox;
const std::filesystem::path dir = "./.vix_test_perm";
reset_dir(dir);
auto store = std::make_shared<FileOutboxStore>(FileOutboxStore::Config{
.file_path = dir / "outbox.json",
.pretty_json = true,
.fsync_on_write = false
});
auto outbox = std::make_shared<Outbox>(
Outbox::Config{ .owner = "example-3" },
store
);
auto probe = std::make_shared<vix::net::NetworkProbe>(
vix::net::NetworkProbe::Config{},
[] { return true; }
);
auto transport = std::make_shared<FakeHttpTransport>();
transport->setRuleForTarget("/api/messages", FakeHttpTransport::Rule{
.ok = false,
.retryable = false,
.error = "bad request (permanent)"
});
SyncEngine engine(
SyncEngine::Config{ .worker_count = 1, .batch_limit = 10 },
outbox, probe, transport
);
Operation op;
op.kind = "http.post";
op.target = "/api/messages";
op.payload = R"({"text":"invalid data"})";
const auto t0 = now_ms();
const auto id = outbox->enqueue(op, t0);
engine.tick(t0);
{
auto saved = store->get(id);
assert(saved.has_value());
assert(saved->status == OperationStatus::PermanentFailed);
}
const auto calls_after_first = transport->callCount();
engine.tick(t0 + 10'000);
assert(transport->callCount() == calls_after_first);
std::cout << "[example-3] OK: permanent failure is not retried\n";
}
int main()
{
run_example_3();
return 0;
}Example 4: Crash simulation (InFlight timeout requeue)
This simulates a crash after claiming an operation but before completing it.
What you should see:
- The operation is InFlight.
- After inflight_timeout_ms, the engine requeues it.
- Next tick delivers it and marks Done.
#include <cassert>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <vix/net/NetworkProbe.hpp>
#include <vix/sync/Operation.hpp>
#include <vix/sync/engine/SyncEngine.hpp>
#include <vix/sync/outbox/FileOutboxStore.hpp>
#include <vix/sync/outbox/Outbox.hpp>
#include "fake_http_transport.hpp"
static std::int64_t now_ms()
{
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static void reset_dir(const std::filesystem::path& dir)
{
std::error_code ec;
std::filesystem::remove_all(dir, ec);
std::filesystem::create_directories(dir, ec);
}
static void run_example_4()
{
using namespace vix::sync;
using namespace vix::sync::engine;
using namespace vix::sync::outbox;
const std::filesystem::path dir = "./.vix_test_inflight";
reset_dir(dir);
auto store = std::make_shared<FileOutboxStore>(FileOutboxStore::Config{
.file_path = dir / "outbox.json",
.pretty_json = true,
.fsync_on_write = false
});
auto outbox = std::make_shared<Outbox>(
Outbox::Config{ .owner = "example-4" },
store
);
auto probe = std::make_shared<vix::net::NetworkProbe>(
vix::net::NetworkProbe::Config{},
[] { return true; }
);
auto transport = std::make_shared<FakeHttpTransport>();
transport->setDefault({ .ok = true });
SyncEngine::Config cfg;
cfg.worker_count = 1;
cfg.batch_limit = 10;
cfg.idle_sleep_ms = 0;
cfg.offline_sleep_ms = 0;
cfg.inflight_timeout_ms = 50;
SyncEngine engine(cfg, outbox, probe, transport);
Operation op;
op.kind = "http.post";
op.target = "/api/messages";
op.payload = R"({"text":"recover me"})";
const auto t0 = now_ms();
const auto id = outbox->enqueue(op, t0);
// Simulate crash: claim without completing.
const bool claimed = outbox->claim(id, t0);
assert(claimed);
{
auto saved = store->get(id);
assert(saved.has_value());
assert(saved->status == OperationStatus::InFlight);
}
// Engine sees inflight timeout and requeues
const auto t1 = t0 + 60;
engine.tick(t1);
// Next tick should deliver
engine.tick(t1 + 1);
auto final = store->get(id);
assert(final.has_value());
assert(final->status == OperationStatus::Done);
std::cout << "[example-4] OK: inflight timeout requeues and completes\n";
}
int main()
{
run_example_4();
return 0;
}Beginner debugging checklist
If an operation does not become Done:
- Open your outbox file (example:
./.vix_test_smoke/outbox.json). - Check status, attempt, last_error, and next_retry_at_ms.
- Confirm your NetworkProbe returns true (online).
- Confirm your transport returns { ok=true }.
- Confirm your engine tick uses a now_ms that is increasing.
Appendix: Minimal Fake Transport (copy-paste)
Save this as fake_http_transport.hpp next to your main.cpp.
#ifndef VIX_FAKE_HTTP_TRANSPORT_HPP
#define VIX_FAKE_HTTP_TRANSPORT_HPP
#include <string>
#include <unordered_map>
#include <vix/sync/engine/SyncWorker.hpp>
namespace vix::sync::engine
{
class FakeHttpTransport final : public ISyncTransport
{
public:
struct Rule
{
bool ok{true};
bool retryable{true};
std::string error{"simulated failure"};
};
void setDefault(Rule r) { def_ = std::move(r); }
void setRuleForKind(std::string kind, Rule r)
{
by_kind_[std::move(kind)] = std::move(r);
}
void setRuleForTarget(std::string target, Rule r)
{
by_target_[std::move(target)] = std::move(r);
}
std::size_t callCount() const noexcept { return calls_; }
SendResult send(const vix::sync::Operation& op) override
{
++calls_;
if (auto it = by_target_.find(op.target); it != by_target_.end())
return toResult(it->second);
if (auto it = by_kind_.find(op.kind); it != by_kind_.end())
return toResult(it->second);
return toResult(def_);
}
private:
static SendResult toResult(const Rule& r)
{
SendResult res;
res.ok = r.ok;
res.retryable = r.retryable;
res.error = r.ok ? "" : r.error;
return res;
}
private:
Rule def_{};
std::unordered_map<std::string, Rule> by_kind_;
std::unordered_map<std::string, Rule> by_target_;
std::size_t calls_{0};
};
} // namespace vix::sync::engine
#endif