Prechádzať zdrojové kódy

feat(storage): WriteTxn + ReadTxn + LmdbDbi primitives

Adds the LMDB transaction + sub-database wrappers that sit on top of
LmdbEnv. Together with the env wrapper from Task 1.2 this completes the
Stage 1 substrate scaffolding for the v2.0 storage engine. Production
binary still uses v1.x storage; the new code has full unit coverage but
no live caller yet.

- service/src/storage/lmdb_txn.hpp/.cpp: WriteTxn (single-writer per
  env, explicit commit / abort, dtor aborts if not finalised) and
  ReadTxn (MVCC snapshot, dtor aborts). Move-only.
- service/src/storage/lmdb_dbi.hpp/.cpp: LmdbDbi wraps a named sub-db
  (MDB_dbi held as plain unsigned int — typedef-compat with lmdb.h).
  put / get / del / count primitives. MDB_NOTFOUND is normal, not an
  error. Returned string_view aliases the LMDB mmap and lives for the
  duration of the txn.
- service/src/storage/lmdb_env.cpp: open with MDB_NOTLS. Without this
  flag LMDB assigns one reader-locktable slot per thread per env,
  which (a) makes two concurrent ReadTxns from the same thread fail
  with MDB_BAD_RSLOT — incompatible with RAII per-txn ownership — and
  (b) leaks slots when threads exit. v2.0's gRPC server uses a thread
  pool so per-thread slot affinity is wrong. MDB_NOTLS gives the txn
  itself ownership of its slot for its lifetime.
- tests/test_lmdb_env.cpp: 11 additional tests for commit roundtrip,
  abort discards, MVCC reader-during-write, sub-db isolation, del on
  missing, count, double-commit-throws, dtor-aborts, commit-after-
  abort-throws, move construction, and string_view-into-mmap lifetime.
- service/CMakeLists.txt + tests/CMakeLists.txt: add lmdb_txn.cpp /
  lmdb_dbi.cpp to the service target and test target source lists.
fszontagh 2 mesiacov pred
rodič
commit
aa4c606f49

+ 2 - 0
service/CMakeLists.txt

@@ -60,6 +60,8 @@ set(DATABASE_SERVICE_SOURCES
     src/config/collection_config_manager.cpp
     src/config/config_loader.cpp
     src/storage/lmdb_env.cpp
+    src/storage/lmdb_txn.cpp
+    src/storage/lmdb_dbi.cpp
 )
 
 # Create executable

+ 104 - 0
service/src/storage/lmdb_dbi.cpp

@@ -0,0 +1,104 @@
+// v2.0 storage engine — sub-database wrapper implementation.
+
+#include "storage/lmdb_dbi.hpp"
+
+#include <lmdb.h>
+
+#include <stdexcept>
+#include <string>
+
+#include "storage/lmdb_txn.hpp"
+
+namespace smartbotic::db::storage {
+
+namespace {
+
+[[noreturn]] void throw_mdb(int rc, const char* where) {
+    std::string msg = "LMDB ";
+    msg += where;
+    msg += ": ";
+    msg += mdb_strerror(rc);
+    throw std::runtime_error(msg);
+}
+
+inline void mdb_check(int rc, const char* where) {
+    if (rc != MDB_SUCCESS) throw_mdb(rc, where);
+}
+
+// mdb_dbi_open requires a null-terminated name. std::string_view is not
+// guaranteed null-terminated, so copy into a small std::string before use.
+inline std::string to_cstr(std::string_view name) {
+    return std::string(name);
+}
+
+inline MDB_val to_val(std::string_view sv) {
+    return MDB_val{sv.size(), const_cast<char*>(sv.data())};
+}
+
+inline std::string_view to_sv(const MDB_val& v) {
+    return std::string_view(static_cast<const char*>(v.mv_data), v.mv_size);
+}
+
+}  // namespace
+
+LmdbDbi::LmdbDbi(WriteTxn& txn, std::string_view name) {
+    MDB_dbi raw_dbi = 0;
+    std::string n = to_cstr(name);
+    mdb_check(mdb_dbi_open(txn.raw(), n.c_str(), MDB_CREATE, &raw_dbi),
+              "dbi_open (write/create)");
+    dbi_ = raw_dbi;
+}
+
+LmdbDbi::LmdbDbi(ReadTxn& txn, std::string_view name) {
+    MDB_dbi raw_dbi = 0;
+    std::string n = to_cstr(name);
+    mdb_check(mdb_dbi_open(txn.raw(), n.c_str(), 0, &raw_dbi),
+              "dbi_open (read)");
+    dbi_ = raw_dbi;
+}
+
+void LmdbDbi::put(WriteTxn& txn, std::string_view key, std::string_view value) {
+    MDB_val k = to_val(key);
+    MDB_val v = to_val(value);
+    mdb_check(mdb_put(txn.raw(), dbi_, &k, &v, 0), "put");
+}
+
+std::optional<std::string_view> LmdbDbi::get(WriteTxn& txn, std::string_view key) {
+    MDB_val k = to_val(key);
+    MDB_val v{0, nullptr};
+    int rc = mdb_get(txn.raw(), dbi_, &k, &v);
+    if (rc == MDB_NOTFOUND) return std::nullopt;
+    if (rc != MDB_SUCCESS) throw_mdb(rc, "get (write)");
+    return to_sv(v);
+}
+
+std::optional<std::string_view> LmdbDbi::get(ReadTxn& txn, std::string_view key) {
+    MDB_val k = to_val(key);
+    MDB_val v{0, nullptr};
+    int rc = mdb_get(txn.raw(), dbi_, &k, &v);
+    if (rc == MDB_NOTFOUND) return std::nullopt;
+    if (rc != MDB_SUCCESS) throw_mdb(rc, "get (read)");
+    return to_sv(v);
+}
+
+bool LmdbDbi::del(WriteTxn& txn, std::string_view key) {
+    MDB_val k = to_val(key);
+    int rc = mdb_del(txn.raw(), dbi_, &k, nullptr);
+    if (rc == MDB_NOTFOUND) return false;
+    if (rc != MDB_SUCCESS) throw_mdb(rc, "del");
+    return true;
+}
+
+uint64_t LmdbDbi::count(ReadTxn& txn) const {
+    MDB_stat st{};
+    mdb_check(mdb_stat(txn.raw(), dbi_, &st), "stat (read)");
+    return st.ms_entries;
+}
+
+uint64_t LmdbDbi::count(WriteTxn& txn) const {
+    MDB_stat st{};
+    mdb_check(mdb_stat(txn.raw(), dbi_, &st), "stat (write)");
+    return st.ms_entries;
+}
+
+}  // namespace smartbotic::db::storage

+ 50 - 0
service/src/storage/lmdb_dbi.hpp

@@ -0,0 +1,50 @@
+// v2.0 storage engine — named sub-database wrapper.
+//
+// Wraps a single LMDB sub-database (MDB_dbi). Sub-dbs are opened within a
+// txn; the dbi handle persists past the txn end and is owned by the env.
+// Never mdb_dbi_close() — LMDB best-practice on long-lived envs.
+//
+// Note: MDB_dbi is `typedef unsigned int MDB_dbi` in <lmdb.h>. We hold it
+// as a plain `unsigned int` here to avoid pulling lmdb.h into the header.
+
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string_view>
+
+namespace smartbotic::db::storage {
+
+class WriteTxn;
+class ReadTxn;
+
+class LmdbDbi {
+public:
+    // Open (and create-if-missing) a named sub-db within a write txn.
+    LmdbDbi(WriteTxn& txn, std::string_view name);
+
+    // Open an existing sub-db (no create) within a read txn.
+    LmdbDbi(ReadTxn& txn, std::string_view name);
+
+    // Put / get / del under the given txn.
+    // Strings returned by get() reference the LMDB mmap and are valid for
+    // the duration of the txn — copy if you need to outlive the txn.
+    void put(WriteTxn& txn, std::string_view key, std::string_view value);
+    std::optional<std::string_view> get(WriteTxn& txn, std::string_view key);
+    std::optional<std::string_view> get(ReadTxn& txn, std::string_view key);
+
+    // Returns true if a row was deleted, false if the key was absent.
+    // Absent is NOT an error.
+    bool del(WriteTxn& txn, std::string_view key);
+
+    // Entry count via mdb_stat.
+    uint64_t count(ReadTxn& txn) const;
+    uint64_t count(WriteTxn& txn) const;
+
+    unsigned int handle() const noexcept { return dbi_; }
+
+private:
+    unsigned int dbi_ = 0;  // MDB_dbi (typedef'd to unsigned int)
+};
+
+}  // namespace smartbotic::db::storage

+ 10 - 1
service/src/storage/lmdb_env.cpp

@@ -45,7 +45,16 @@ LmdbEnv::LmdbEnv(const LmdbEnvOpts& opts) : opts_(opts) {
         mdb_check(mdb_env_set_maxdbs(env_, opts_.max_dbs), "env_set_maxdbs");
         mdb_check(mdb_env_set_maxreaders(env_, opts_.max_readers), "env_set_maxreaders");
 
-        unsigned int flags = 0;
+        // MDB_NOTLS: don't use thread-local storage for reader locktable
+        // slots. The default behavior assigns one slot per thread per env,
+        // which (a) prevents multiple concurrent read txns from the same
+        // thread (LMDB returns MDB_BAD_RSLOT on the second begin), and
+        // (b) leaks slots when threads exit. v2.0's gRPC server handles
+        // requests on a thread pool where any thread may serve any RPC,
+        // so per-thread slot affinity is wrong. With MDB_NOTLS the txn
+        // itself owns its slot for its lifetime — which matches the RAII
+        // contract of ReadTxn.
+        unsigned int flags = MDB_NOTLS;
         if (opts_.no_sync) flags |= MDB_NOSYNC;
 
         mdb_check(mdb_env_open(env_, opts_.path.c_str(), flags, 0644), "env_open");

+ 90 - 0
service/src/storage/lmdb_txn.cpp

@@ -0,0 +1,90 @@
+// v2.0 storage engine — transaction RAII wrapper implementations.
+
+#include "storage/lmdb_txn.hpp"
+
+#include <lmdb.h>
+
+#include <stdexcept>
+#include <string>
+
+#include "storage/lmdb_env.hpp"
+
+namespace smartbotic::db::storage {
+
+namespace {
+
+[[noreturn]] void throw_mdb(int rc, const char* where) {
+    std::string msg = "LMDB ";
+    msg += where;
+    msg += ": ";
+    msg += mdb_strerror(rc);
+    throw std::runtime_error(msg);
+}
+
+inline void mdb_check(int rc, const char* where) {
+    if (rc != MDB_SUCCESS) throw_mdb(rc, where);
+}
+
+}  // namespace
+
+// -----------------------------------------------------------------------
+// WriteTxn
+// -----------------------------------------------------------------------
+
+WriteTxn::WriteTxn(LmdbEnv& env) {
+    mdb_check(mdb_txn_begin(env.raw(), nullptr, 0, &txn_), "txn_begin (write)");
+}
+
+WriteTxn::~WriteTxn() {
+    if (txn_ && !finalised_) {
+        mdb_txn_abort(txn_);
+    }
+}
+
+WriteTxn::WriteTxn(WriteTxn&& other) noexcept
+    : txn_(other.txn_), finalised_(other.finalised_) {
+    other.txn_ = nullptr;
+    other.finalised_ = true;  // moved-from is a no-op on drop
+}
+
+void WriteTxn::commit() {
+    if (finalised_) {
+        throw std::runtime_error(
+            "WriteTxn::commit: txn already finalised (committed or aborted)");
+    }
+    int rc = mdb_txn_commit(txn_);
+    finalised_ = true;
+    if (rc != MDB_SUCCESS) {
+        // mdb_txn_commit always disposes the handle internally on both
+        // success and failure paths, so don't abort here.
+        throw_mdb(rc, "txn_commit");
+    }
+}
+
+void WriteTxn::abort() noexcept {
+    if (txn_ && !finalised_) {
+        mdb_txn_abort(txn_);
+        finalised_ = true;
+    }
+}
+
+// -----------------------------------------------------------------------
+// ReadTxn
+// -----------------------------------------------------------------------
+
+ReadTxn::ReadTxn(LmdbEnv& env) {
+    mdb_check(mdb_txn_begin(env.raw(), nullptr, MDB_RDONLY, &txn_),
+              "txn_begin (read)");
+}
+
+ReadTxn::~ReadTxn() {
+    if (txn_) {
+        mdb_txn_abort(txn_);
+    }
+}
+
+ReadTxn::ReadTxn(ReadTxn&& other) noexcept : txn_(other.txn_) {
+    other.txn_ = nullptr;
+}
+
+}  // namespace smartbotic::db::storage

+ 60 - 0
service/src/storage/lmdb_txn.hpp

@@ -0,0 +1,60 @@
+// v2.0 storage engine — transaction RAII wrappers.
+//
+// LMDB semantics consumed here:
+//   - mdb_txn_begin / mdb_txn_commit / mdb_txn_abort.
+//   - Write txns serialise globally (single writer per env).
+//   - Read txns are concurrent and see a snapshot from txn_begin time.
+//   - Read txns cannot be "committed" meaningfully; they're aborted on drop.
+//
+// Both types are move-only. Headers do not pull in <lmdb.h>; the .cpp does.
+
+#pragma once
+
+// Forward declarations — keep <lmdb.h> out of headers.
+struct MDB_txn;
+
+namespace smartbotic::db::storage {
+
+class LmdbEnv;
+
+// Write transaction. Single writer per env (LMDB serialises globally).
+// Must be explicitly committed via commit() to persist; otherwise the
+// destructor aborts. abort() may be called explicitly; subsequent
+// commit() throws.
+class WriteTxn {
+public:
+    explicit WriteTxn(LmdbEnv& env);
+    ~WriteTxn();
+    WriteTxn(WriteTxn&& other) noexcept;
+    WriteTxn& operator=(WriteTxn&&) = delete;  // disallow re-assignment after construct
+    WriteTxn(const WriteTxn&) = delete;
+    WriteTxn& operator=(const WriteTxn&) = delete;
+
+    void commit();          // explicit commit; calling twice throws.
+    void abort() noexcept;  // explicit abort; commit() after abort() throws.
+
+    MDB_txn* raw() noexcept { return txn_; }
+
+private:
+    MDB_txn* txn_ = nullptr;
+    bool finalised_ = false;  // set by commit() or abort()
+};
+
+// Read transaction. Concurrent with other readers and a single writer.
+// Sees a snapshot of the env from txn_begin time. Destructor aborts.
+class ReadTxn {
+public:
+    explicit ReadTxn(LmdbEnv& env);
+    ~ReadTxn();
+    ReadTxn(ReadTxn&& other) noexcept;
+    ReadTxn& operator=(ReadTxn&&) = delete;
+    ReadTxn(const ReadTxn&) = delete;
+    ReadTxn& operator=(const ReadTxn&) = delete;
+
+    MDB_txn* raw() noexcept { return txn_; }
+
+private:
+    MDB_txn* txn_ = nullptr;
+};
+
+}  // namespace smartbotic::db::storage

+ 5 - 3
tests/CMakeLists.txt

@@ -326,12 +326,14 @@ endif()
 
 target_link_libraries(bench_doc_memory PRIVATE ${yyjson_LIBRARIES})
 
-# LMDB environment unit test (v2.0 storage Phase C Stage 1, Task 1.2).
-# Compiles storage/lmdb_env.cpp directly; will pick up lmdb_txn.cpp and
-# lmdb_dbi.cpp in Task 1.3.
+# LMDB environment unit test (v2.0 storage Phase C Stage 1, Tasks 1.2 / 1.3).
+# Compiles the storage/ sources directly — no dependency on the service
+# executable.
 add_executable(test_lmdb_env
     test_lmdb_env.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_env.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_txn.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/storage/lmdb_dbi.cpp
 )
 
 target_include_directories(test_lmdb_env PRIVATE

+ 321 - 3
tests/test_lmdb_env.cpp

@@ -1,24 +1,30 @@
-// v2.0 storage engine — unit tests for LmdbEnv (Task 1.2).
-//
-// Task 1.3 will extend this file with WriteTxn / ReadTxn / LmdbDbi coverage.
+// v2.0 storage engine — unit tests for LmdbEnv (Task 1.2) plus WriteTxn /
+// ReadTxn / LmdbDbi primitives (Task 1.3).
 
 #include <atomic>
 #include <cassert>
 #include <cstdlib>
 #include <filesystem>
 #include <iostream>
+#include <optional>
 #include <stdexcept>
 #include <string>
+#include <string_view>
 #include <unistd.h>
 
 #include <lmdb.h>
 
+#include "storage/lmdb_dbi.hpp"
 #include "storage/lmdb_env.hpp"
+#include "storage/lmdb_txn.hpp"
 
 namespace fs = std::filesystem;
 
+using smartbotic::db::storage::LmdbDbi;
 using smartbotic::db::storage::LmdbEnv;
 using smartbotic::db::storage::LmdbEnvOpts;
+using smartbotic::db::storage::ReadTxn;
+using smartbotic::db::storage::WriteTxn;
 
 namespace {
 
@@ -249,15 +255,327 @@ void test_move_constructor_transfers_ownership() {
     check(b.path() == td.path, "moved-to env retains path");
 }
 
+// =========================================================================
+// Task 1.3 — WriteTxn / ReadTxn / LmdbDbi tests
+// =========================================================================
+
+void test_write_txn_commit_roundtrip() {
+    TmpDir td("commit-roundtrip");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "alpha", "one");
+        dbi.put(wtxn, "beta", "two");
+        wtxn.commit();
+    }
+
+    {
+        ReadTxn rtxn(env);
+        LmdbDbi dbi(rtxn, "_test");
+        auto a = dbi.get(rtxn, "alpha");
+        check(a.has_value() && std::string(*a) == "one", "alpha = one");
+        auto b = dbi.get(rtxn, "beta");
+        check(b.has_value() && std::string(*b) == "two", "beta = two");
+        auto c = dbi.get(rtxn, "gamma");
+        check(!c.has_value(), "gamma absent -> nullopt");
+    }
+}
+
+void test_write_txn_abort_discards() {
+    TmpDir td("abort-discards");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    // First commit creates the sub-db so the post-abort read txn can open it.
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "seed", "ok");
+        wtxn.commit();
+    }
+
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "alpha", "one");
+        wtxn.abort();
+    }
+
+    {
+        ReadTxn rtxn(env);
+        LmdbDbi dbi(rtxn, "_test");
+        auto a = dbi.get(rtxn, "alpha");
+        check(!a.has_value(), "aborted put leaves no trace");
+        auto s = dbi.get(rtxn, "seed");
+        check(s.has_value(), "seed survived (sanity check that the sub-db is intact)");
+    }
+}
+
+void test_read_during_write() {
+    TmpDir td("read-during-write");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    // Seed: k=v1.
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "k", "v1");
+        wtxn.commit();
+    }
+
+    // Open a long-lived read txn on the v1 snapshot.
+    ReadTxn rtxn(env);
+    LmdbDbi rdbi(rtxn, "_test");
+
+    // Update k -> v2 in a separate, committed write txn.
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi wdbi(wtxn, "_test");
+        wdbi.put(wtxn, "k", "v2");
+        wtxn.commit();
+    }
+
+    // The pre-existing read txn still sees v1 (LMDB MVCC snapshot).
+    auto got = rdbi.get(rtxn, "k");
+    check(got.has_value() && std::string(*got) == "v1",
+          "long-lived read txn sees pre-write snapshot value");
+
+    // A fresh read txn sees v2.
+    {
+        ReadTxn r2(env);
+        LmdbDbi d2(r2, "_test");
+        auto g2 = d2.get(r2, "k");
+        check(g2.has_value() && std::string(*g2) == "v2",
+              "fresh read txn sees post-write value");
+    }
+}
+
+void test_multiple_subdbs_isolated() {
+    TmpDir td("multi-subdb");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi a(wtxn, "_a");
+        LmdbDbi b(wtxn, "_b");
+        a.put(wtxn, "k", "from_a");
+        b.put(wtxn, "k", "from_b");
+        wtxn.commit();
+    }
+
+    {
+        ReadTxn rtxn(env);
+        LmdbDbi a(rtxn, "_a");
+        LmdbDbi b(rtxn, "_b");
+        auto ga = a.get(rtxn, "k");
+        auto gb = b.get(rtxn, "k");
+        check(ga.has_value() && std::string(*ga) == "from_a",
+              "sub-db _a returns _a's value for k");
+        check(gb.has_value() && std::string(*gb) == "from_b",
+              "sub-db _b returns _b's value for k");
+    }
+}
+
+void test_del_returns_false_on_missing() {
+    TmpDir td("del-missing");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "present", "x");
+        bool d1 = dbi.del(wtxn, "absent");
+        check(!d1, "del on missing key returns false (not an error)");
+        bool d2 = dbi.del(wtxn, "present");
+        check(d2, "del on present key returns true");
+        bool d3 = dbi.del(wtxn, "present");
+        check(!d3, "second del on now-absent key returns false");
+        wtxn.commit();
+    }
+}
+
+void test_count_matches_puts() {
+    TmpDir td("count");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    const int N = 100;
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        for (int i = 0; i < N; ++i) {
+            std::string k = "k" + std::to_string(i);
+            dbi.put(wtxn, k, "v");
+        }
+        wtxn.commit();
+    }
+
+    {
+        ReadTxn rtxn(env);
+        LmdbDbi dbi(rtxn, "_test");
+        check(dbi.count(rtxn) == static_cast<uint64_t>(N),
+              "count() matches puts");
+    }
+}
+
+void test_write_txn_throws_if_committed_twice() {
+    TmpDir td("double-commit");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    WriteTxn wtxn(env);
+    LmdbDbi dbi(wtxn, "_test");
+    dbi.put(wtxn, "k", "v");
+    wtxn.commit();
+
+    bool threw = false;
+    try {
+        wtxn.commit();
+    } catch (const std::runtime_error&) {
+        threw = true;
+    }
+    check(threw, "second commit() throws");
+}
+
+void test_write_txn_destructor_aborts_if_not_finalised() {
+    TmpDir td("dtor-abort");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    // Seed an empty sub-db so the post-scope read txn can open it.
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "seed", "s");
+        wtxn.commit();
+    }
+
+    // Drop a write txn without committing — destructor must abort, not commit.
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "alpha", "one");
+        // No commit; let the dtor run.
+    }
+
+    {
+        ReadTxn rtxn(env);
+        LmdbDbi dbi(rtxn, "_test");
+        auto a = dbi.get(rtxn, "alpha");
+        check(!a.has_value(), "destructor of uncommitted WriteTxn discards writes");
+    }
+}
+
+void test_commit_after_abort_throws() {
+    TmpDir td("commit-after-abort");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    WriteTxn wtxn(env);
+    LmdbDbi dbi(wtxn, "_test");
+    dbi.put(wtxn, "k", "v");
+    wtxn.abort();
+
+    bool threw = false;
+    try {
+        wtxn.commit();
+    } catch (const std::runtime_error&) {
+        threw = true;
+    }
+    check(threw, "commit() after abort() throws");
+}
+
+void test_write_txn_move_construct() {
+    TmpDir td("write-move");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    auto make_txn = [&]() {
+        WriteTxn t(env);
+        return t;  // forces move-construction
+    };
+
+    {
+        WriteTxn moved = make_txn();
+        LmdbDbi dbi(moved, "_test");
+        dbi.put(moved, "k", "from-moved");
+        moved.commit();
+    }
+
+    {
+        ReadTxn rtxn(env);
+        LmdbDbi dbi(rtxn, "_test");
+        auto g = dbi.get(rtxn, "k");
+        check(g.has_value() && std::string(*g) == "from-moved",
+              "moved WriteTxn commits successfully");
+    }
+}
+
+void test_get_returns_view_into_mmap() {
+    // The contract says get() returns a string_view that lives for the
+    // duration of the txn. Exercise that lifetime: read the value, use
+    // it while the txn is alive, drop the txn.
+    TmpDir td("mmap-view");
+    LmdbEnvOpts opts;
+    opts.path = td.path;
+    LmdbEnv env(opts);
+
+    {
+        WriteTxn wtxn(env);
+        LmdbDbi dbi(wtxn, "_test");
+        dbi.put(wtxn, "k", "hello world");
+        wtxn.commit();
+    }
+
+    ReadTxn rtxn(env);
+    LmdbDbi dbi(rtxn, "_test");
+    auto v = dbi.get(rtxn, "k");
+    check(v.has_value(), "get returns value");
+    check(v->size() == 11, "string_view has expected length");
+    check(std::string(*v) == "hello world", "string_view content matches");
+    // rtxn drops at end of scope; we don't touch v after that.
+}
+
 }  // namespace
 
 int main() {
+    // Task 1.2 — env.
     test_open_creates_dir();
     test_path_accessor();
     test_mapsize_too_small_fails();
     test_double_open_same_path_separate_envs();
     test_on_disk_bytes_nonzero_after_create();
     test_move_constructor_transfers_ownership();
+
+    // Task 1.3 — txn + dbi.
+    test_write_txn_commit_roundtrip();
+    test_write_txn_abort_discards();
+    test_read_during_write();
+    test_multiple_subdbs_isolated();
+    test_del_returns_false_on_missing();
+    test_count_matches_puts();
+    test_write_txn_throws_if_committed_twice();
+    test_write_txn_destructor_aborts_if_not_finalised();
+    test_commit_after_abort_throws();
+    test_write_txn_move_construct();
+    test_get_returns_view_into_mmap();
+
     std::cout << "test_lmdb_env: all passed\n";
     return 0;
 }