C++ · Quickstart

Quickstart

#include <smashtable/shared.hpp>#include <smashtable/row_search.hpp> 1.0.0-rc.1 · c4d34add

include(FetchContent)
FetchContent_Declare(
    smashtable
    GIT_REPOSITORY https://github.com/ashvardanian/SmashTable
    GIT_TAG main
)
FetchContent_MakeAvailable(smashtable)
target_link_libraries(your_target PRIVATE smashtable::header)

For a system-wide install, standard CMake package discovery works:

sudo cmake --install build_release
find_package(smashtable REQUIRED)
target_link_libraries(your_target PRIVATE smashtable::header)

The library throws nowhere, and will not let you make it. Every API returns status_t, reads included, and a read delivers its answer through noexcept callbacks rather than through the return. A read is fallible from serializable_k up, where it has to write down what it read before the commit can validate it. A key whose copy or comparison can throw fails to compile rather than being quietly accepted. All containers take custom allocators and can be pre-allocated.

The same shape as the Python example, with no threads in sight:

#include <smashtable/basic_avl_tree.hpp>
#include <smashtable/monotonic_store.hpp>

namespace st = ashvardanian::smashtable;

using id_t = std::uint64_t;
using by_id_t = st::monotonic_avl_map<id_t, record_t>;      // id   → record
using by_name_t = st::monotonic_avl_map<name_t, id_t>;      // name → id

auto by_id = *by_id_t::make();
auto by_name = *by_name_t::make();

auto group = *st::make_transaction_group(by_id, by_name);
auto [ids, names] = group.participants();

_ = ids.upsert({id, record});
_ = names.upsert({record.name, id});
_ = ids.watch(id);                  // fail rather than clobber a concurrent write

_ = group.stage();                  // both reserve, or neither does
_ = group.commit();                 // both become visible

The body, the stage and the commit always run in that order, so commit_with takes the first and runs the other two, resetting every participant if anything refuses:

st::status_t const landed = group.commit_with([&](auto &ids, auto &names) noexcept {
    st::status_t const filed = ids.upsert({id, record});
    return failed(filed) ? filed : names.upsert({record.name, id});
});

Staging the two by hand would leave the first one staged when the second refuses, which is the bug a group exists to remove. Participants are visited in order of the store’s address rather than of the argument list, so two groups naming the same stores in opposite orders cannot deadlock on each other. A refused stage() rolls the staged prefix back rather than resetting it, so the writes survive and the group can be retried.

Basic containers — basic_avl_tree, basic_wb_tree — carry STL-style bidirectional iterators. Transactional containers do not, because keeping an iterator valid across concurrent updates costs more than it returns:

  • find(), lower_bound() and upper_bound() return status_t and take two noexcept callbacks, for the found and missing cases.
  • equal_range() and sample_one() return status_t and take one callback, invoked per element in the answer.
  • range(from, until, callback) walks [from, until) smallest first, and range(from, until, callback, st::descending) walks (until, from] greatest first, taking st::unbounded for either end.
  • sample_reservoir() also returns status_t, but scatters into a random-access output iterator rather than a callback, taking the bounds, a generator, a running seen count and the reservoir capacity.
  • find_copy(), lower_bound_copy() and upper_bound_copy() return an expected<value_t> for callers that cannot use a callback.

API

Types

status_tWhether an operation succeeded, and why it did not.
shared.hpp:255 ↗
enum status_t

The library's only result code.

Values follow errno where one fits, so a failure can be handed to strerror or mapped onto a platform error without a translation table. success_k is zero, which is why the type is scoped and truth-testing goes through succeeded: an implicit conversion would read every success as false. Prefer checking an expected, which already answers this question.

The three conflicts have no errno between them: EBUSY and EAGAIN, the closest, are about a resource being held, while these are about what another transaction published. A retry loop reads them to decide whether retrying can help, which is what conflicted answers.

Statuses fall into five categories. Resources: out_of_memory_heap_k and input_output_k. Input: invalid_argument_k. State: the three conflicts, operation_not_permitted_k, operation_would_block_k, capacity_exhausted_k, key_already_exists_k and key_not_found_k. Interruptions: none, as no verb is cancelled or deferred. Defects: consistency_k and unknown_k. Any verb that allocates, binds a device or touches a file may also return a Resources status. A verb's own block lists only what its contract adds.

Values

success_k0The operation did what it was asked, and any result it carries is live.
unknown_k-1A failure the library cannot name more precisely, which no caller should branch on.
consistency_k-2An invariant of the container itself did not hold.
write_conflict_k-3A key this transaction wrote was published over since its snapshot.
read_conflict_k-4A key this transaction read was published over since its snapshot.
phantom_conflict_k-5A window this transaction read gained or lost a member since its snapshot.
out_of_memory_heap_kENOMEMThe heap refused the allocation the operation needed.
invalid_argument_kEINVALAn argument was outside what the call accepts, decided before anything was written.
operation_not_permitted_kEPERMThe object is not in a state that admits the call, such as committing what was never staged.
operation_would_block_kEWOULDBLOCKA bounded retry gave up rather than block.
capacity_exhausted_kENOSPCA probe sequence with no slot left, which a rehash mends rather than more memory.
input_output_kEIOA device or file refused a read or write, which no retry in memory mends.
key_already_exists_kEEXISTA strict insert found the key already taken.
key_not_found_kENOENTAn update or erase found no such key.
status_tWhether an operation succeeded, and why it did not.
shared.hpp:302 ↗
enum status_t

The library's only result code.

Values follow errno where one fits, so a failure can be handed to strerror or mapped onto a platform error without a translation table. success_k is zero, which is why the type is scoped and truth-testing goes through succeeded: an implicit conversion would read every success as false. Prefer checking an expected, which already answers this question.

The three conflicts have no errno between them: EBUSY and EAGAIN, the closest, are about a resource being held, while these are about what another transaction published. A retry loop reads them to decide whether retrying can help, which is what conflicted answers.

Statuses fall into five categories. Resources: out_of_memory_heap_k and input_output_k. Input: invalid_argument_k. State: the three conflicts, operation_not_permitted_k, operation_would_block_k, capacity_exhausted_k, key_already_exists_k and key_not_found_k. Interruptions: none, as no verb is cancelled or deferred. Defects: consistency_k and unknown_k. Any verb that allocates, binds a device or touches a file may also return a Resources status. A verb's own block lists only what its contract adds.

ClassexpectedA value that may not be there, and the reason when it is not - the library's only result.7
shared.hpp:900 ↗
template <typename value_type_>
class expected

Storage is a union, so the value exists only on success and value_type_ needs no default constructor. That is what lets one type cover both what std::optional carried and what a status carried, including the move-only transaction types that have no empty state to sit in. A default-constructed expected reports failure, since there is nothing in it.

Decomposes as auto [value, status], for value types that can be default-constructed to hand something back on the failure path; see get.

expectedA reason with nothing behind it; a success has no value to report, so it reads as unknown_k.
shared.hpp:915 ↗
constexpr expected() noexcept;
constexpr expected(status_t status) noexcept;

A reason with nothing behind it; a success has no value to report, so it reads as unknown_k.

expected(value_t && value, status_t status = success_k) noexcept;

A value, kept only while status says there is one.

requires (std::is_nothrow_copy_constructible_v<value_t>)
expected(value_t const & value, status_t status = success_k) noexcept;
expected(expected && other) noexcept;
expected(expected const &)=delete;

A failure carries its reason and nothing else, so the argument is left to its owner.

operator=
shared.hpp:935 ↗
expected & operator=(expected && other) noexcept;
expected & operator=(expected const &)=delete;
~expected
shared.hpp:942 ↗
~expected() noexcept;
operator boolWhether the value is there, which is the only safe precondition for reading it.
shared.hpp:950 ↗
constexpr operator bool() const noexcept;
failedWhether there is no value, which is the protocol every result in the library answers.
shared.hpp:954 ↗
bool failed() const noexcept;
statusWhy there is no value, or success when there is one.
shared.hpp:957 ↗
status_t status() const noexcept;
getTuple protocol behind auto [value, status], returning the value by value.
shared.hpp:976 ↗
template <std::size_t index_>
requires (std::is_nothrow_default_constructible_v<value_t>)
auto get() &&noexcept;
template <std::size_t index_>
requires (std::is_nothrow_default_constructible_v<value_t> && std::is_nothrow_copy_constructible_v<value_t>)
auto get() const &noexcept;

A reference would bind into storage that was never constructed when the status is a failure, which reads as garbage and trips no sanitizer, so the failure path default-constructs instead. Offered only where that is possible: a transaction cannot be decomposed, and has nothing worth binding when it fails anyway, so callers check it and dereference.

Functions

succeededWhether status reports success, which no implicit conversion can be trusted to say.
shared.hpp:305 ↗
bool succeeded(status_t status) noexcept;
bool succeeded(bool is_satisfied) noexcept;

A plain condition is already its own verdict, so generic code can ask either shape.

template <typename result_type_>
requires requires(result_type_ const &result) { result.failed(); }
bool succeeded(result_type_ const & result) noexcept;

The same question of a richer result - one that reports its own success, like the node handles the trees hand back - so generic code can ask it without knowing which it holds.

Without this, a caller holding a bool has to spell the question differently from one holding a status_t, and anything asking it of both - a check, a gate, a fold - needs a branch on which it was handed.

failedWhether status reports a failure, and so carries a reason.
shared.hpp:308 ↗
bool failed(status_t status) noexcept;
bool failed(bool is_satisfied) noexcept;

The same of a plain condition, so succeeded and failed stay a pair for every shape.

first_failureKeeps the first refusal of a walk that reports more than once, so the reason travels.
shared.hpp:328 ↗
status_t first_failure(status_t recorded, status_t next) noexcept;

The discipline a read walk wants: the first thing that went wrong is what the caller asked about, and nothing after it can be trusted anyway.

last_failureKeeps the last refusal of a walk that reports more than once.
shared.hpp:339 ↗
status_t last_failure(status_t recorded, status_t next) noexcept;

The discipline a write walk wants, where stopping at the first refusal would leave the steps after it untouched: the walk runs to the end regardless, so the reason it carries out is the last one it met.

name_ofThe enumerator's own spelling, for a message a person reads rather than decodes.
shared.hpp:342 ↗
char const * name_of(status_t status) noexcept;
name_ofThe enumerator's own name without its suffix, for a benchmark or a log.
row_search.hpp:63 ↗
char const * name_of(row_kit_t kit) noexcept;
TypesMember types2
expected::value_tvalue_type_
expected::value_typevalue_t