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
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 elipsPendingOp queue
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
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
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
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
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
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 backThe 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
Related: Lock manager · Storage & recovery · C++ SDK.