Explorar el Código

feat(doc_binary): yyjson mut_doc-backed in-memory storage (phase B)

fszontagh hace 2 meses
padre
commit
6c0e56f043
Se han modificado 5 ficheros con 474 adiciones y 0 borrados
  1. 1 0
      service/CMakeLists.txt
  2. 224 0
      service/src/doc_binary.cpp
  3. 72 0
      service/src/doc_binary.hpp
  4. 22 0
      tests/CMakeLists.txt
  5. 155 0
      tests/test_doc_binary.cpp

+ 1 - 0
service/CMakeLists.txt

@@ -31,6 +31,7 @@ set(DATABASE_SERVICE_SOURCES
     src/main.cpp
     src/database_service.cpp
     src/database_grpc_impl.cpp
+    src/doc_binary.cpp
     src/json_parse.cpp
     src/memory_store.cpp
     src/persistence/persistence_manager.cpp

+ 224 - 0
service/src/doc_binary.cpp

@@ -0,0 +1,224 @@
+// v1.11 Phase B — binary in-memory document storage backed by yyjson mut_doc.
+//
+// All yyjson includes are confined to this translation unit; the public
+// header forward-declares yyjson_mut_doc / yyjson_mut_val so callsites stay
+// yyjson-agnostic.
+
+#include "doc_binary.hpp"
+
+#include <yyjson.h>
+
+#include <cstdlib>
+#include <new>
+#include <string>
+#include <utility>
+
+namespace smartbotic::db::doc_binary {
+
+namespace {
+
+// Build a yyjson_mut_val for `j` inside `doc`. Returns nullptr on allocator
+// failure; the caller treats nullptr as bad_alloc.
+yyjson_mut_val* build_value(yyjson_mut_doc* doc, const nlohmann::json& j) {
+    using value_t = nlohmann::detail::value_t;
+    switch (j.type()) {
+        case value_t::null:
+            return yyjson_mut_null(doc);
+        case value_t::boolean:
+            return yyjson_mut_bool(doc, j.get<bool>());
+        case value_t::number_integer:
+            return yyjson_mut_sint(doc, j.get<int64_t>());
+        case value_t::number_unsigned:
+            return yyjson_mut_uint(doc, j.get<uint64_t>());
+        case value_t::number_float:
+            return yyjson_mut_real(doc, j.get<double>());
+        case value_t::string: {
+            const std::string& s = j.get_ref<const std::string&>();
+            return yyjson_mut_strncpy(doc, s.data(), s.size());
+        }
+        case value_t::array: {
+            yyjson_mut_val* arr = yyjson_mut_arr(doc);
+            if (!arr) return nullptr;
+            for (const auto& elem : j) {
+                yyjson_mut_val* v = build_value(doc, elem);
+                if (!v) return nullptr;
+                if (!yyjson_mut_arr_add_val(arr, v)) return nullptr;
+            }
+            return arr;
+        }
+        case value_t::object: {
+            yyjson_mut_val* obj = yyjson_mut_obj(doc);
+            if (!obj) return nullptr;
+            for (auto it = j.begin(); it != j.end(); ++it) {
+                const std::string& key = it.key();
+                yyjson_mut_val* k = yyjson_mut_strncpy(doc, key.data(), key.size());
+                if (!k) return nullptr;
+                yyjson_mut_val* v = build_value(doc, it.value());
+                if (!v) return nullptr;
+                if (!yyjson_mut_obj_add(obj, k, v)) return nullptr;
+            }
+            return obj;
+        }
+        case value_t::binary:
+            // nlohmann's binary type has no direct JSON mapping; fall through
+            // to null. No production callsite uses binary().
+            return yyjson_mut_null(doc);
+        case value_t::discarded:
+        default:
+            return yyjson_mut_null(doc);
+    }
+}
+
+// Convert a yyjson_mut_val back into an nlohmann::json. Mirrors json_parse.cpp
+// but operates on the mut tree.
+nlohmann::json convert_to_nlohmann(yyjson_mut_val* v) {
+    if (!v) {
+        return nullptr;
+    }
+    switch (yyjson_mut_get_type(v)) {
+        case YYJSON_TYPE_NULL:
+            return nullptr;
+        case YYJSON_TYPE_BOOL:
+            return yyjson_mut_get_bool(v);
+        case YYJSON_TYPE_NUM:
+            switch (yyjson_mut_get_subtype(v)) {
+                case YYJSON_SUBTYPE_UINT:
+                    return yyjson_mut_get_uint(v);
+                case YYJSON_SUBTYPE_SINT:
+                    return yyjson_mut_get_sint(v);
+                case YYJSON_SUBTYPE_REAL:
+                default:
+                    return yyjson_mut_get_real(v);
+            }
+        case YYJSON_TYPE_STR:
+            return std::string(yyjson_mut_get_str(v), yyjson_mut_get_len(v));
+        case YYJSON_TYPE_ARR: {
+            nlohmann::json out = nlohmann::json::array();
+            size_t idx = 0, max = 0;
+            yyjson_mut_val* elem = nullptr;
+            yyjson_mut_arr_foreach(v, idx, max, elem) {
+                out.push_back(convert_to_nlohmann(elem));
+            }
+            return out;
+        }
+        case YYJSON_TYPE_OBJ: {
+            nlohmann::json out = nlohmann::json::object();
+            size_t idx = 0, max = 0;
+            yyjson_mut_val* key = nullptr;
+            yyjson_mut_val* val = nullptr;
+            yyjson_mut_obj_foreach(v, idx, max, key, val) {
+                out[std::string(yyjson_mut_get_str(key), yyjson_mut_get_len(key))] =
+                    convert_to_nlohmann(val);
+            }
+            return out;
+        }
+        default:
+            return nullptr;
+    }
+}
+
+// RAII guard for the malloc'd buffer returned by yyjson_mut_write.
+struct WriteBuf {
+    char* data = nullptr;
+    ~WriteBuf() {
+        if (data) {
+            std::free(data);
+        }
+    }
+};
+
+}  // namespace
+
+// ---------------- Doc ----------------
+
+Doc::Doc(yyjson_mut_doc* doc) : doc_(doc) {}
+
+Doc::Doc(Doc&& other) noexcept : doc_(other.doc_) {
+    other.doc_ = nullptr;
+}
+
+Doc& Doc::operator=(Doc&& other) noexcept {
+    if (this != &other) {
+        if (doc_) {
+            yyjson_mut_doc_free(doc_);
+        }
+        doc_ = other.doc_;
+        other.doc_ = nullptr;
+    }
+    return *this;
+}
+
+Doc::~Doc() {
+    if (doc_) {
+        yyjson_mut_doc_free(doc_);
+        doc_ = nullptr;
+    }
+}
+
+bool Doc::empty() const noexcept {
+    return doc_ == nullptr;
+}
+
+yyjson_mut_val* Doc::root() const noexcept {
+    if (!doc_) return nullptr;
+    return yyjson_mut_doc_get_root(doc_);
+}
+
+yyjson_mut_doc* Doc::raw() const noexcept {
+    return doc_;
+}
+
+// ---------------- free functions ----------------
+
+Doc encode(const nlohmann::json& j) {
+    yyjson_mut_doc* doc = yyjson_mut_doc_new(nullptr);
+    if (!doc) {
+        throw std::bad_alloc();
+    }
+    yyjson_mut_val* root = build_value(doc, j);
+    if (!root) {
+        yyjson_mut_doc_free(doc);
+        throw std::bad_alloc();
+    }
+    yyjson_mut_doc_set_root(doc, root);
+    return Doc(doc);
+}
+
+nlohmann::json decode(const Doc& doc) {
+    if (doc.empty()) {
+        return nlohmann::json(nullptr);
+    }
+    return convert_to_nlohmann(doc.root());
+}
+
+std::optional<nlohmann::json> get_field(const Doc& doc, std::string_view field_name) {
+    if (doc.empty()) {
+        return std::nullopt;
+    }
+    yyjson_mut_val* root = doc.root();
+    if (!root || yyjson_mut_get_type(root) != YYJSON_TYPE_OBJ) {
+        return std::nullopt;
+    }
+    // yyjson_mut_obj_getn does NOT require a null-terminated key — it takes
+    // explicit length, so std::string_view's non-terminated buffer is safe.
+    yyjson_mut_val* v = yyjson_mut_obj_getn(root, field_name.data(), field_name.size());
+    if (!v) {
+        return std::nullopt;
+    }
+    return convert_to_nlohmann(v);
+}
+
+std::string to_json_text(const Doc& doc) {
+    if (doc.empty()) {
+        return "null";
+    }
+    size_t len = 0;
+    WriteBuf buf;
+    buf.data = yyjson_mut_write(doc.raw(), 0, &len);
+    if (!buf.data) {
+        throw std::bad_alloc();
+    }
+    return std::string(buf.data, len);
+}
+
+}  // namespace smartbotic::db::doc_binary

+ 72 - 0
service/src/doc_binary.hpp

@@ -0,0 +1,72 @@
+#pragma once
+
+// v1.11 Phase B — binary in-memory document storage.
+//
+// Wraps a yyjson_mut_doc as the canonical Document::data representation,
+// exposing encode/decode/get_field/to_json_text. The yyjson types are
+// forward-declared here so this header does NOT transitively pull in
+// <yyjson.h> — only doc_binary.cpp links against yyjson. Callsites that
+// only need the public surface stay yyjson-agnostic.
+//
+// See docs/superpowers/specs/2026-05-15-binary-doc-format-decision.md for
+// the format-choice rationale and lifecycle contract.
+
+#include <optional>
+#include <string>
+#include <string_view>
+
+#include <nlohmann/json.hpp>
+
+// Forward declarations — keep <yyjson.h> out of the public header.
+struct yyjson_mut_doc;
+struct yyjson_mut_val;
+
+namespace smartbotic::db::doc_binary {
+
+// Owning, move-only RAII wrapper around a yyjson_mut_doc*. The destructor
+// calls yyjson_mut_doc_free, so any Doc constructed from a raw pointer takes
+// ownership of that pointer.
+class Doc {
+public:
+    Doc() = default;
+    explicit Doc(yyjson_mut_doc* doc);
+    Doc(Doc&& other) noexcept;
+    Doc& operator=(Doc&& other) noexcept;
+    Doc(const Doc&) = delete;
+    Doc& operator=(const Doc&) = delete;
+    ~Doc();
+
+    // True if this Doc holds no underlying yyjson document.
+    bool empty() const noexcept;
+
+    // Root value of the underlying doc, or nullptr if empty.
+    yyjson_mut_val* root() const noexcept;
+
+    // Raw underlying pointer (or nullptr). Caller must not free.
+    yyjson_mut_doc* raw() const noexcept;
+
+private:
+    yyjson_mut_doc* doc_ = nullptr;
+};
+
+// Encode an nlohmann::json tree into a freshly-allocated yyjson mut_doc.
+// Strings are COPIED into yyjson (yyjson_mut_strncpy) so the source can be
+// destroyed safely. Throws std::bad_alloc on yyjson allocator failure.
+Doc encode(const nlohmann::json& j);
+
+// Decode a Doc back to nlohmann::json (full-tree materialise). Used by the
+// lazy view and any caller that needs full-tree semantics. An empty Doc
+// returns nlohmann::json(nullptr).
+nlohmann::json decode(const Doc& doc);
+
+// Field-fast-path. Returns nullopt if the field is absent or if the root is
+// not an object. Walks only the immediate children of the root (NOT
+// recursive). For nested access, callers should decode() and walk the
+// resulting nlohmann tree.
+std::optional<nlohmann::json> get_field(const Doc& doc, std::string_view field_name);
+
+// Serialise the Doc back to compact JSON text. Used at WAL/snapshot write
+// sites and gRPC egress. An empty Doc returns "null".
+std::string to_json_text(const Doc& doc);
+
+}  // namespace smartbotic::db::doc_binary

+ 22 - 0
tests/CMakeLists.txt

@@ -249,6 +249,27 @@ endif()
 
 target_link_libraries(test_json_parse PRIVATE ${yyjson_LIBRARIES})
 
+# Binary doc encoder/decoder test (v1.11 Phase B T2)
+# Exercises doc_binary::Doc, encode, decode, get_field, to_json_text against
+# nlohmann::json round-trips. yyjson resolution is shared at file scope above.
+add_executable(test_doc_binary
+    test_doc_binary.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/doc_binary.cpp
+)
+
+target_include_directories(test_doc_binary PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+    ${yyjson_INCLUDE_DIRS}
+)
+
+if(TARGET nlohmann_json::nlohmann_json)
+    target_link_libraries(test_doc_binary PRIVATE nlohmann_json::nlohmann_json)
+else()
+    target_include_directories(test_doc_binary PRIVATE ${NLOHMANN_JSON_INCLUDE_DIRS})
+endif()
+
+target_link_libraries(test_doc_binary PRIVATE ${yyjson_LIBRARIES})
+
 # Parse-time micro-benchmark (v1.10 Phase A T8) — manual bench, NOT
 # registered with CTest. Run as ./build/tests/bench_json_parse <corpus>.
 add_executable(bench_json_parse
@@ -278,3 +299,4 @@ add_test(NAME snapshot_durability COMMAND test_snapshot_durability)
 add_test(NAME config_dropins COMMAND test_config_dropins)
 add_test(NAME eviction COMMAND test_eviction)
 add_test(NAME json_parse COMMAND test_json_parse)
+add_test(NAME doc_binary COMMAND test_doc_binary)

+ 155 - 0
tests/test_doc_binary.cpp

@@ -0,0 +1,155 @@
+#include <cassert>
+#include <iostream>
+#include <optional>
+#include <string>
+
+#include <nlohmann/json.hpp>
+
+#include "doc_binary.hpp"
+
+using nlohmann::json;
+using smartbotic::db::doc_binary::Doc;
+using smartbotic::db::doc_binary::encode;
+using smartbotic::db::doc_binary::decode;
+using smartbotic::db::doc_binary::get_field;
+using smartbotic::db::doc_binary::to_json_text;
+
+namespace {
+
+void check(bool cond, const char* msg) {
+    if (!cond) {
+        std::cerr << "FAIL: " << msg << "\n";
+        std::abort();
+    }
+}
+
+void roundtrip(const json& j, const char* label) {
+    auto doc = encode(j);
+    auto back = decode(doc);
+    check(back == j, label);
+}
+
+void test_default_doc_is_empty() {
+    Doc d;
+    check(d.empty(), "default-constructed Doc is empty");
+    check(d.root() == nullptr, "empty Doc has null root");
+    auto back = decode(d);
+    check(back.is_null(), "decoding empty Doc returns null");
+}
+
+void test_primitives_roundtrip() {
+    roundtrip(json(nullptr), "null");
+    roundtrip(json(true), "true");
+    roundtrip(json(false), "false");
+    roundtrip(json(0), "int zero");
+    roundtrip(json(42), "int 42");
+    roundtrip(json(-7), "negative int");
+    roundtrip(json(9223372036854775807LL), "int64 max");
+    roundtrip(json(3.14), "double");
+    roundtrip(json("hello"), "string");
+    roundtrip(json(""), "empty string");
+}
+
+void test_collections_roundtrip() {
+    roundtrip(json::array(), "empty array");
+    roundtrip(json::object(), "empty object");
+    roundtrip(json::array({1, 2, 3}), "int array");
+    roundtrip(json::array({1, "two", 3.0, true, nullptr}), "mixed array");
+    roundtrip(json({{"k", "v"}, {"n", 1}}), "small object");
+}
+
+void test_zoe_shape_roundtrip() {
+    json zoe = {
+        {"_id", "doc-1"},
+        {"_created_at", 1731628800123LL},
+        {"name", "Zoe"},
+        {"tags", json::array({"hot", "pinned"})},
+        {"_vector", json::array({0.1, -0.2, 0.3, 0.4})},
+        {"meta", {{"flag", true}, {"score", 0.93}}},
+    };
+    roundtrip(zoe, "Zoe-shape");
+}
+
+void test_unicode_roundtrip() {
+    roundtrip(
+        json({{"hu", "Árvíztűrő tükörfúrógép"}, {"emoji", "🚀"}}),
+        "unicode strings");
+}
+
+void test_get_field_present() {
+    json j = {{"name", "Zoe"}, {"count", 42}, {"flag", true}};
+    auto doc = encode(j);
+
+    auto name = get_field(doc, "name");
+    check(name.has_value(), "name present");
+    check(name->get<std::string>() == "Zoe", "name value");
+
+    auto count = get_field(doc, "count");
+    check(count.has_value(), "count present");
+    check(count->get<int>() == 42, "count value");
+
+    auto flag = get_field(doc, "flag");
+    check(flag.has_value(), "flag present");
+    check(flag->get<bool>() == true, "flag value");
+}
+
+void test_get_field_missing() {
+    json j = {{"name", "Zoe"}};
+    auto doc = encode(j);
+    auto absent = get_field(doc, "nonexistent");
+    check(!absent.has_value(), "missing field returns nullopt");
+}
+
+void test_get_field_on_non_object() {
+    auto doc = encode(json::array({1, 2, 3}));
+    auto v = get_field(doc, "anything");
+    check(!v.has_value(), "get_field on array returns nullopt");
+}
+
+void test_get_field_on_empty() {
+    Doc d;
+    auto v = get_field(d, "anything");
+    check(!v.has_value(), "get_field on empty Doc returns nullopt");
+}
+
+void test_to_json_text_roundtrip() {
+    json j = {{"a", 1}, {"b", "two"}, {"c", json::array({1, 2})}};
+    auto doc = encode(j);
+    std::string text = to_json_text(doc);
+    json parsed = json::parse(text);
+    check(parsed == j, "to_json_text round-trips through nlohmann::json::parse");
+}
+
+void test_to_json_text_empty() {
+    Doc d;
+    check(to_json_text(d) == "null", "to_json_text of empty Doc is \"null\"");
+}
+
+void test_doc_is_move_only() {
+    auto d1 = encode(json({{"a", 1}}));
+    check(!d1.empty(), "encoded doc not empty");
+    Doc d2 = std::move(d1);
+    check(d1.empty(), "moved-from Doc is empty");
+    check(!d2.empty(), "moved-to Doc holds the data");
+    auto field = get_field(d2, "a");
+    check(field.has_value() && field->get<int>() == 1, "moved Doc still readable");
+}
+
+}  // namespace
+
+int main() {
+    test_default_doc_is_empty();
+    test_primitives_roundtrip();
+    test_collections_roundtrip();
+    test_zoe_shape_roundtrip();
+    test_unicode_roundtrip();
+    test_get_field_present();
+    test_get_field_missing();
+    test_get_field_on_non_object();
+    test_get_field_on_empty();
+    test_to_json_text_roundtrip();
+    test_to_json_text_empty();
+    test_doc_is_move_only();
+    std::cout << "test_doc_binary: all passed\n";
+    return 0;
+}