Skip to content

Low-Level API

The low-level API is akkaradb::engine::AkkEngine. It is a raw byte-oriented key/value engine: keys and values are passed as std::span<const uint8_t>, and the engine does not know your schema.

Use this layer when you want direct control over key layout, value encoding, durability options, scans, history, and storage-engine behavior.

Choose AkkEngine directly when:

  • You are building a server, JNI bridge, or protocol layer on top of byte buffers.
  • You already have a binary key/value format and do not want typed table encoding.
  • You need to test WAL, MemTable, SST, Blob, VersionLog, or scan behavior directly.
  • You want explicit control over durability and storage components.

For application code built around C++ structs, the high-level AkkaraDB / PackedTable API will usually be easier to read.

#include "akk/engine/AkkEngine.hpp"
#include <array>
#include <cstdint>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace engine = akkaradb::engine;

The public low-level API is C++23-oriented and uses std::span heavily. A small conversion helper keeps examples readable:

std::span<const uint8_t> bytes(std::string_view value) {
return {
reinterpret_cast<const uint8_t*>(value.data()),
value.size(),
};
}
std::string text(std::span<const uint8_t> value) {
return {
reinterpret_cast<const char*>(value.data()),
value.size(),
};
}
std::string text(const std::vector<uint8_t>& value) {
return {
reinterpret_cast<const char*>(value.data()),
value.size(),
};
}

bytes() returns a view. The underlying string or buffer must remain alive until the engine call has consumed it. Passing string literals is fine; passing a span into a destroyed temporary is not.

For an in-memory smoke test, disable the persistent components:

engine::AkkEngineOptions opts;
opts.components.walEnabled = false;
opts.components.blobEnabled = false;
opts.components.manifestEnabled = false;
opts.components.sstEnabled = false;
auto db = engine::AkkEngine::open(std::move(opts));

For a normal embedded database, set paths.dataDir. If individual paths are empty, the engine derives subpaths from dataDir:

OptionDefault when dataDir is set
paths.walDir<dataDir>/wal
paths.blobDir<dataDir>/blobs
paths.sstDir<dataDir>/sstable
paths.manifestPath<dataDir>/manifest.akmf
paths.versionLogPath<dataDir>/history.akvlog
paths.clusterConfigPath<dataDir>/cluster.akcc
paths.nodeIdPath<dataDir>/node.id
engine::AkkEngineOptions opts;
opts.paths.dataDir = "data/akkaradb";
opts.wal.syncMode = engine::wal::WalSyncMode::ASYNC;
opts.blob.thresholdBytes = 32ULL * 1024ULL;
opts.runtime.sstPromoteReads = true;
auto db = engine::AkkEngine::open(std::move(opts));

The default WAL mode is SYNC. ASYNC improves write throughput by flushing in the background. OFF disables WAL writes and should be reserved for temporary data or controlled benchmark scenarios.

For the complete option map, including MemTable, SST, Blob, VersionLog, API server, and cluster-related fields, see Low-Level API Options.

put() writes or replaces a value. remove() writes a tombstone. Reads use std::optional<std::vector<uint8_t>> by default.

db->put(bytes("user:1"), bytes("Alice"));
db->put(bytes("user:2"), bytes("Bob"));
if (auto value = db->get(bytes("user:1"))) {
const std::string name = text(*value);
}
db->put(bytes("user:1"), bytes("Alice Updated"));
const bool exists = db->exists(bytes("user:1"));
const bool missing = !db->exists(bytes("user:404"));
db->remove(bytes("user:2"));

Use getInto() when you want to reuse caller-owned storage on hot paths:

std::vector<uint8_t> out;
if (db->getInto(bytes("user:1"), out)) {
const std::string name = text(out);
}

get() returns std::nullopt for a missing key. getInto() returns false for the same case.

Batch writes use AkkEngine::BatchPutEntry. Entries are still raw byte spans, so the input buffers must stay alive for the duration of the call.

std::array<engine::AkkEngine::BatchPutEntry, 3> entries{{
{bytes("user:1"), bytes("Alice")},
{bytes("user:2"), bytes("Bob")},
{bytes("user:3"), bytes("Carol")},
}};
db->putBatch(entries);

Batch reads accept a span of key spans and return one result per key:

std::array<std::span<const uint8_t>, 3> keys{
bytes("user:1"),
bytes("user:2"),
bytes("user:404"),
};
auto results = db->getBatch(keys);
for (const auto& result : results) {
if (result.found) {
const std::string value = text(result.value);
}
}

The engine orders keys lexicographically by raw bytes. That means key design is part of your API contract.

A practical layout is:

<domain>:<stable-id>

For example:

user:0000000000000001
user:0000000000000002
order:0000000000000001

If you encode numeric IDs as text, pad them to a fixed width. Without padding, "user:10" sorts before "user:2".

For binary numeric keys, encode integers in sortable big-endian order:

std::vector<uint8_t> userKey(uint64_t id) {
std::vector<uint8_t> key{'u', 's', 'e', 'r', ':'};
for (int shift = 56; shift >= 0; shift -= 8) {
key.push_back(static_cast<uint8_t>((id >> shift) & 0xff));
}
return key;
}

This keeps bytewise range scans aligned with numeric order.

scan() reads a half-open range: [startKey, endKey). The returned iterator views depend on a caller-owned BufferArena, so the arena must outlive the scan.

akkaradb::core::BufferArena arena;
auto rows = db->scan(arena, bytes("user:"), bytes("user;"));
for (auto it = rows.begin(); !(it == rows.end()); ++it) {
const auto& row = *it;
const std::string key = text(row.key);
const std::string value = text(row.value);
}

The "user:" to "user;" range works because ';' is the next ASCII byte after ':'. For arbitrary binary prefixes, compute the next prefix:

std::optional<std::vector<uint8_t>> nextPrefix(std::span<const uint8_t> prefix) {
std::vector<uint8_t> end{prefix.begin(), prefix.end()};
for (auto it = end.rbegin(); it != end.rend(); ++it) {
if (*it != 0xff) {
++(*it);
end.erase(it.base(), end.end());
return end;
}
}
return std::nullopt;
}

Then scan either [prefix, nextPrefix(prefix)) or [prefix, unbounded) when no larger prefix exists:

auto prefix = bytes("user:");
auto end = nextPrefix(prefix);
auto rows = end
? db->scan(arena, prefix, *end)
: db->scan(arena, prefix);

scan() and getIntoArena() return non-owning views. They become invalid when the arena is reset, cleared, or destroyed.

akkaradb::core::BufferArena arena;
std::span<const uint8_t> value;
if (db->getIntoArena(bytes("user:1"), arena, value)) {
std::vector<uint8_t> owned{value.begin(), value.end()};
arena.reset();
// owned is still valid; value is not.
}

Use std::vector<uint8_t> when values need to cross request, thread, or API boundaries. Use arena views for local read pipelines where the lifetime is obvious.

The engine can be closed explicitly:

db->forceFlush();
db->forceSync();
db->close();

forceFlush() moves MemTable data toward SST storage. forceSync() synchronizes durable state such as WAL and version history. close() is idempotent and also follows runtime.forceFlushOnClose and runtime.forceSyncOnClose.

For tests and benchmark fixtures, closing explicitly makes resource lifetime clearer. In production code, treat forceSync() as the boundary for writes that must be durable before the next operation continues.

History is disabled by default. Enable components.versionLogEnabled before opening the engine:

engine::AkkEngineOptions opts;
opts.paths.dataDir = "data/history";
opts.components.versionLogEnabled = true;
opts.vlog.syncMode = engine::vlog::VLogSyncMode::ASYNC;
auto db = engine::AkkEngine::open(std::move(opts));

After that, each write for a key is recorded in the version log:

db->put(bytes("profile:1"), bytes("v1"));
db->put(bytes("profile:1"), bytes("v2"));
auto history = db->history(bytes("profile:1"));
if (!history.empty()) {
const uint64_t firstSeq = history.front().seq;
auto oldValue = db->getAt(bytes("profile:1"), firstSeq);
db->rollbackKey(bytes("profile:1"), firstSeq);
}

rollbackKey() affects one key. rollbackTo() rolls the whole engine back to a sequence and has a much wider blast radius. Use it only when the operational semantics are explicit.

When the version log is disabled, history() returns an empty vector and point-in-time reads cannot recover previous values.

Large values can be moved to Blob storage by setting blob.thresholdBytes. Values at or above the threshold are stored as blobs while the key still behaves like a normal key/value entry.

engine::AkkEngineOptions opts;
opts.paths.dataDir = "data/blob";
opts.blob.thresholdBytes = 8ULL * 1024ULL;
auto db = engine::AkkEngine::open(std::move(opts));
db->put(bytes("file:1"), bytes("large payload"));

runBlobGc() can reclaim unreferenced blobs:

db->runBlobGc();

Do not run blob GC while version history is enabled. Old versions may still reference previous blob values, and the engine rejects this combination.

stats() returns a snapshot of engine counters and subsystem state.

auto stats = db->stats();
const auto puts = stats.putsTotal;
const auto gets = stats.getsTotal;
const auto memtableBytes = stats.memtable.approxBytes;
if (stats.wal.enabled) {
const auto walBytes = stats.wal.bytesWritten;
}
if (stats.sst.enabled) {
const auto fileCount = stats.sst.fileCount;
}

Useful fields include:

FieldMeaning
currentSeqCurrent engine sequence number
putsTotal, removesTotal, getsTotalTop-level operation counters
getsMemtableHit, getsSstHit, getsMissRead-path hit/miss counters
memtable.approxBytesApproximate in-memory MemTable bytes
wal.bytesWritten, wal.syncsExecutedWAL write/sync counters
sst.fileCount, sst.levelsSST storage shape
blob.bytesOnDisk, blob.gcCyclesBlob storage and GC state
vlog.indexedEntries, vlog.rollbackEntriesVersion history state

For the full metrics map and interpretation notes, see Low-Level API Stats.

OperationAPIBehavior
OpenAkkEngine::open(options)Creates or recovers an engine instance
Writeput(key, value)Stores or replaces a key/value pair
Write with hintputHinted(key, value, fp64, miniKey)Hot path when the caller already computed key fingerprints
Batch writeputBatch(entries)Writes multiple key/value pairs
Deleteremove(key)Writes a tombstone
Delete with hintremoveHinted(key, fp64, miniKey)Deletes with precomputed key fingerprints
Readget(key)Returns std::optional<std::vector<uint8_t>>
Batch readgetBatch(keys)Returns one BatchGetResult per key
Read into vectorgetInto(key, out)Reuses caller-owned storage
Read into arenagetIntoArena(key, arena, out)Returns a view tied to arena lifetime
Existsexists(key)Checks current-key existence
Countcount(start, end)Counts keys in a half-open range
Scanscan(arena, start, end)Iterates current key/value rows
Historyhistory(key)Returns per-key version entries when VersionLog is enabled
Point-in-time readgetAt(key, seq)Reads the value visible at a sequence
Rollback keyrollbackKey(key, seq)Rolls one key back
Rollback enginerollbackTo(seq)Rolls all keys back
Statsstats()Returns subsystem counters
FlushforceFlush()Flushes MemTable state toward SST
SyncforceSync()Synchronizes durable state
Blob GCrunBlobGc()Reclaims unreferenced blobs
Closeclose()Closes the engine; safe to call more than once

Expected absence is represented by return values:

  • get() and getAt() return std::nullopt.
  • getInto() and getIntoArena() return false.
  • Empty scans and histories produce empty iterators or vectors.

Invalid use and storage failures are exceptions. Common examples are invalid configuration, I/O failures, corrupt persisted data, CRC mismatches, closed-engine access, and unsafe operations such as blob GC while version history is enabled.

#include "akk/engine/AkkEngine.hpp"
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace engine = akkaradb::engine;
std::span<const uint8_t> bytes(std::string_view value) {
return {
reinterpret_cast<const uint8_t*>(value.data()),
value.size(),
};
}
std::string text(const std::vector<uint8_t>& value) {
return {
reinterpret_cast<const char*>(value.data()),
value.size(),
};
}
int main() {
engine::AkkEngineOptions opts;
opts.paths.dataDir = "data";
opts.wal.syncMode = engine::wal::WalSyncMode::ASYNC;
opts.components.versionLogEnabled = true;
opts.blob.thresholdBytes = 32ULL * 1024ULL;
auto db = engine::AkkEngine::open(std::move(opts));
db->put(bytes("user:1"), bytes("Alice"));
db->put(bytes("user:2"), bytes("Bob"));
if (auto value = db->get(bytes("user:1"))) {
const std::string name = text(*value);
}
akkaradb::core::BufferArena arena;
auto rows = db->scan(arena, bytes("user:"), bytes("user;"));
for (auto it = rows.begin(); !(it == rows.end()); ++it) {
const auto& row = *it;
const auto key = row.key;
const auto value = row.value;
}
auto history = db->history(bytes("user:1"));
if (!history.empty()) {
auto oldValue = db->getAt(bytes("user:1"), history.front().seq);
}
db->forceSync();
db->close();
}