elips/docs
Internals

Transaction engine

ELIPS transactions are atomic write batches under the single-writer model. Transaction buffers place and erase calls; commit applies them in order, each one WAL-appended before the in-memory mutation. The destructor auto-rolls back if neither commit() nor rollback() ran.

Class shape

cpp
namespace elips {

class TransactionVault {
public:
    RecordID place(const Vector& vector, Payload payload = {},
                   std::optional<RecordID> id = std::nullopt);
    void     erase(const RecordID& id);

private:
    friend class Transaction;
    TransactionVault(Transaction& txn, std::string vault)
        : txn_(&txn), vault_(std::move(vault)) {}

    Transaction* txn_;
    std::string  vault_;
};

class Transaction {
public:
    explicit Transaction(ElipsInstance& db);
    ~Transaction();                          // auto-rolls back if !done_

    TransactionVault vault(std::string name);
    void commit();
    void rollback() noexcept;

private:
    void enqueue_place(std::string vault, const Vector&, Payload, std::optional<RecordID>);
    void enqueue_erase(std::string vault, RecordID);

    ElipsInstance*           db_;
    std::vector<PendingOp>   ops_;
    bool                     done_{false};
};

} // namespace elips

PendingOp queue

cpp
struct PendingOp {
    bool                       is_erase{false};   // true = erase, false = place
    std::string                vault;             // target vault name
    Vector                     vector;            // empty for erase
    Payload                    payload;           // empty for erase
    std::optional<RecordID>    id;                // pre-generated or explicit
};

Inserts capture vector + payload + pre-generated id; erases capture only the target id. The buffer is std::vector<PendingOp> — operations apply in the order they were enqueued.

Eager validation

cpp
void Transaction::enqueue_place(std::string vault, const Vector& vector,
                                Payload payload, std::optional<RecordID> id) {
    if (vector.dimension() != db_->config().dimension()) {
        throw DimensionMismatch{"vector dimension does not match database"};
    }
    if (!all_finite(vector.values())) {
        throw InvalidVector{"vector contains NaN or Inf"};
    }
    ops_.push_back(PendingOp{false, std::move(vault), vector,
                             std::move(payload), std::move(id)});
}

Validating up front means commit() can never fail mid-batch on dimension or finiteness grounds — the only failure modes left are storage IO errors. Without this, atomicity would be impossible to promise: half the batch could end up applied before a bad vector tripped validation.

RecordID pre-generation

cpp
RecordID TransactionVault::place(const Vector& vector, Payload payload,
                                 std::optional<RecordID> id) {
    const RecordID assigned = id.value_or(RecordID::generate());
    txn_->enqueue_place(vault_, vector, std::move(payload), assigned);
    return assigned;          // returned before commit
}

Generating the UUIDv7 at enqueue time lets callers log or cross-reference the id during the transaction, even though the record is not yet visible to readers.

Commit

cpp
void Transaction::commit() {
    for (auto& op : ops_) {
        Vault& vault = db_->vault(op.vault);
        if (op.is_erase) {
            vault.erase(*op.id);                          // WAL + index + store
        } else {
            vault.place(op.vector, op.payload, op.id);    // prepare + WAL + index + store
        }
    }
    ops_.clear();
    done_ = true;
}

Each operation is WAL-appended individually before the in-memory mutation. A crash mid-commit is consistent: WAL replay reapplies every operation that made it to the log; everything past the crash is lost cleanly. Under the single-writer lock no other mutator can interleave.

Rollback & RAII

cpp
void rollback() noexcept { ops_.clear(); done_ = true; }

Transaction::~Transaction() {
    if (!done_) {
        rollback();   // discard buffered ops; none were applied
    }
}

Rollback is trivial — nothing was applied. The destructor calls it on any non-finalised transaction, so exceptions and early returns never leave a half-built batch lying around.

Python context manager

python
with db.begin_transaction() as txn:
    v = txn.vault("data")
    v.place(vector1, {"tag": "a"})
    v.place(vector2, {"tag": "b"})
    # clean exit  → __exit__ calls commit()
    # raised exc  → __exit__ does NOT commit; destructor rolls back

The binding uses a TransactionHolder that keeps the Python Database reference alive for the transaction's lifetime, so the C++ ElipsInstance outlives the Transaction as required.

Isolation level

Atomic batched writes under a single-writer lock — effectively serializable isolation. All transactions are serialised by the database lock. Reads within a transaction do not see uncommitted writes; seek always reads from the committed state.

  • Atomicity — all or none, via rollback. WAL provides durability.
  • Consistency — eager validation rejects bad inputs before they enter the buffer.
  • Isolation — single writer prevents dirty reads and lost updates.
  • Durability — every operation hits the WAL during commit, flushed per Durability.

Lifecycle

entrybegin_transaction()db.begin_transaction()ACTIVEdone_=false · ops_=[]enqueue place/erasecommit()for op : ops_ → WAL+applyROLLED BACKops_.clear() · done_=T~Transaction()auto-rollback if !done_TERMINAL · done_=trueno further ops · safe to destroyRAII: no done_ → rollback ✓
A transaction has exactly three terminal paths: commit, rollback, or destructor-rollback. done_ flips once and never flips back.

Related: Lock manager · Storage & recovery · C++ SDK.