elips/docs
Internals

Transaction engine

ELIPS transactions are atomic write batches under the single-writer model. Transaction buffers add and remove 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 TransactionCollection {
public:
    RecordID add(const Vector& vector, Payload payload = {},
                 std::optional<RecordID> id = std::nullopt);
    void     remove(const RecordID& id);

private:
    friend class Transaction;
    TransactionCollection(Transaction& txn, std::string collection)
        : txn_(&txn), collection_(std::move(collection)) {}

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

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

    TransactionCollection collection(std::string name);
    void commit();
    void rollback() noexcept;

private:
    void enqueue_add(std::string collection, const Vector&, Payload, std::optional<RecordID>);
    void enqueue_remove(std::string collection, RecordID);

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

} // namespace elips

PendingOp queue

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

Inserts capture vector + payload + pre-generated id; removes 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_add(std::string collection, 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(collection), 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 TransactionCollection::add(const Vector& vector, Payload payload,
                                    std::optional<RecordID> id) {
    const RecordID assigned = id.value_or(RecordID::generate());
    txn_->enqueue_add(collection_, 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_) {
        Collection& collection = db_->collection(op.collection);
        if (op.is_remove) {
            collection.remove(*op.id);                        // WAL + index + store
        } else {
            collection.add(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:
    c = txn.collection("data")
    c.add(vector1, {"tag": "a"})
    c.add(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++ Database 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; search 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 add/deletecommit()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.