From 663586b8d07c979218ceff79576bb549ee1973c5 Mon Sep 17 00:00:00 2001 From: Marcos Magueta Date: Mon, 22 Jun 2026 21:58:54 -0300 Subject: Ephemeral relations --- CMakeLists.txt | 2 + include/EphemeralRelation.h | 143 ++++++++++++++++++++++++++++++++ include/LifecycleManager.h | 30 +++++++ include/ObjectManager.h | 8 +- include/RNT_C_API.h | 99 ++++++++++++++++++++++ include/VM.h | 6 +- src/CursorManager.cpp | 11 +-- src/EphemeralRelation.cpp | 196 ++++++++++++++++++++++++++++++++++++++++++++ src/LifecycleManager.cpp | 40 +++++++++ src/RNT_C_API.cpp | 131 +++++++++++++++++++++++++++++ test/EphemeralRelation.cpp | 168 +++++++++++++++++++++++++++++++++++++ 11 files changed, 822 insertions(+), 12 deletions(-) create mode 100644 include/EphemeralRelation.h create mode 100644 src/EphemeralRelation.cpp create mode 100644 test/EphemeralRelation.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c418d3..f517826 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ add_library(rnt_core OBJECT "src/LifecycleManager.cpp" "src/HandlerManager.cpp" "src/CursorManager.cpp" + "src/EphemeralRelation.cpp" "src/Merkle.cpp" "src/TupleCodec.cpp" "src/MultigroupCodec.cpp" @@ -48,6 +49,7 @@ add_executable(RNT_tests "test/NamespaceReferenceManager.cpp" "test/LifecycleManager.cpp" "test/CursorManager.cpp" + "test/EphemeralRelation.cpp" ) target_link_libraries(RNT_tests PRIVATE rnt_core Catch2::Catch2WithMain) target_compile_definitions(RNT_tests PRIVATE NT_CONSOLE_APP) diff --git a/include/EphemeralRelation.h b/include/EphemeralRelation.h new file mode 100644 index 0000000..9834337 --- /dev/null +++ b/include/EphemeralRelation.h @@ -0,0 +1,143 @@ +#pragma once + +#include "LifecycleManager.h" +#include "ObjectManager.h" + +#include +#include +#include + +/** + * @file EphemeralRelation.h + * @brief Writer path and lifetime helpers for ephemeral relations. + * + * An ephemeral relation has no tuple storage of its own; its tuples are + * produced on demand by a generator (see ObjectManager.h). Relational-algebra + * operations (select, project, join) all yield one. The functions here register + * such a relation into the object manager, hash its identity, pin the base + * relations it is defined atop, and release session-owned entries at session + * close. + * + * They are free functions over an ObjectManager and a LifecycleManager, not a + * stateful manager object. + * + * See docs/ephemeral-relations.org for the lifetime model. + */ + +namespace nt::Ephemeral { + /** + * @brief Lifetime class of a freshly produced ephemeral relation. + * + * Naming is what makes a result outlive its cursor: an anonymous result is + * owned by nobody. + * + * - Scratch: an anonymous per-statement intermediate. Registered under + * /system/sessions//scratch/ and not session-pinned, so it + * is collected by reference counting once its consuming cursor closes. + * - Named: a view-like binding. Registered under + * /system/sessions//ephemeral/ with one session-ownership + * Pin, so it survives between commands and can be re-requested. Released + * at rnt_session_close or (later) by the idle reaper. + */ + enum class Tier { Scratch, Named }; + + /** + * @brief Splits a slash-joined logical path into registry components. + * + * Drops a leading slash and any empty segments, matching split_path in the + * C API layer. Used by the writer path here and by + * LifecycleManager::CascadeEphemeral when re-splitting stored dependency + * paths at GC time. + */ + std::vector SplitPath(const std::string& path); + + /** + * @brief Deterministic merkle_root for an ephemeral relation. + * + * Hashes SHA256 over the generator identity, the schema, and the + * merkle_roots of the declared base relations. Two ephemerals with the same + * operator, schema, and base roots hash identically, so a repeated query + * reuses one registry entry instead of creating duplicates; rebinding a base + * produces a new root that propagates like a tuple insertion in a stored + * relation. + * + * Schema entries and base roots are sorted internally, so input order does + * not affect the result. + * + * @param generator_identity Stable label of the producing operator/builtin + * (e.g. "join", "select:age", "eq"). + * @param schema (attribute_name, attribute_type) pairs. + * @param base_roots merkle_roots of the base relations, any order. + * @return 64-char lowercase-hex SHA256 digest. + */ + std::string ComposeRoot(const std::string& generator_identity, + const std::vector>& schema, + const std::vector& base_roots); + + /** + * @brief Registers an ephemeral relation produced by a relational operation. + * + * Resolves each dependency in the registry to read its current merkle_root + * (for the identity hash) and to take a structural Pin on it: a base + * relation cannot be GC'd while an ephemeral is defined atop it. Then + * registers an EphemeralRelation IObject with an ephemeral_object_type + * carrying the generator and cardinality. For Tier::Named, an extra + * session-ownership Pin is taken so the entry outlives its cursor. + * + * Idempotent: an existing entry at the target path (identical scratch root, + * or a live named binding) is returned without re-registering or + * double-pinning. + * + * The caller should Monitor the returned entry when it opens a cursor over + * it; a Scratch entry with no cursor and no session pin lingers until + * session close. + * + * @param objects Registry to register into. + * @param lifecycles Lifecycle manager for the dependency / ownership pins. + * @param session_hash Owning session's hash (the namespace parent). + * @param tier Scratch (anonymous) or Named (session-owned). + * @param name Leaf name for Tier::Named; ignored for Tier::Scratch + * (the composed root is used as the leaf instead). + * @param generator Tuple-producing generator for this relation. + * @param cardinality Finite / ConstrainedFinite / AlephZero / Continuum. + * @param generator_identity Stable operator label, see ComposeRoot. + * @param schema (attribute_name, attribute_type) pairs. + * @param dependencies Slash-joined logical paths (no leading slash) of the + * base relations this is defined atop, e.g. + * "system/snapshots//relations/". Stored verbatim + * on the object and re-split at GC time. Every path must + * resolve; an unresolvable dependency fails the whole + * registration, since a stored-but-unpinned path would + * make CascadeEphemeral Unpin a hold it never took. + * @return Borrowed pointer to the registered (or pre-existing) entry, or + * nullptr on error (empty session hash, empty name for Tier::Named, + * or any unresolvable dependency). + */ + ObjectManager::registry* Register( + ObjectManager& objects, LifecycleManager& lifecycles, const std::string& session_hash, + Tier tier, const std::string& name, + ObjectManager::ephemeral_object_type::Generator generator, + ObjectManager::ephemeral_object_type::Cardinality cardinality, + const std::string& generator_identity, + const std::vector>& schema, + const std::vector& dependencies); + + /** + * @brief Releases all ephemeral relations owned by a session. + * + * Called by rnt_session_close before the session entry is removed. Named + * entries directly under the session's "ephemeral" subdir have their + * session-ownership Pin released (collecting them when no cursor or + * dependent still holds them, cascading their base-relation Unpins). Scratch + * entries under the session's "scratch" subdir were never session-pinned; + * any that linger with no open cursor are collected directly. Entries still + * held by an open cursor are left for the normal handle-release path. + * + * @param objects Registry to walk. + * @param lifecycles Lifecycle manager driving the releases. + * @param session_hash Session whose ephemerals to release. + * @return Number of entries released or collected. + */ + std::size_t ReleaseSession(ObjectManager& objects, LifecycleManager& lifecycles, + const std::string& session_hash); +} // namespace nt::Ephemeral diff --git a/include/LifecycleManager.h b/include/LifecycleManager.h index c1e63d8..daf82dd 100644 --- a/include/LifecycleManager.h +++ b/include/LifecycleManager.h @@ -95,6 +95,24 @@ namespace nt { */ bool IsEligibleForGC(ObjectManager::registry* object) const; + /** + * @brief Forces a GC attempt on an object without decrementing a counter. + * + * Runs the same eligibility check, type-specific cascade, and Unregister + * as Unmonitor / Unpin, but is callable directly. No-op when the object + * is still referenced (either counter non-zero) or is a named-lifetime + * type (BRANCH / SESSION). + * + * Used by the session-close path to collect scratch ephemerals that were + * never session-pinned and whose consuming cursor has already closed, and + * (later) by the idle reaper to sweep named entries after Unpinning their + * session-ownership pin. + * + * @param object Object to attempt to collect. + * @return True when the object was eligible and collected. + */ + bool Collect(ObjectManager::registry* object); + /** * @brief Serializes contention for changes to mutable reference states. * @@ -156,6 +174,18 @@ namespace nt { */ void CascadeBranchTree(ObjectManager::registry* branch_tree); + /** + * @brief Releases pins an EPHEMERAL_RELATION holds on its base relations. + * + * An ephemeral relation Pins every entry named in its `dependencies` + * list at registration time (a stored or ephemeral base cannot be GC'd + * while something is defined atop it). When the ephemeral itself becomes + * eligible for GC, this resolves each dependency path and Unpins it. + * Dependency paths are stored slash-joined with no leading slash and + * split on '/' to address the registry. Called only from TryCollect. + */ + void CascadeEphemeral(ObjectManager::registry* ephemeral); + ObjectManager& objects_; IStorageBackend& storage_; }; diff --git a/include/ObjectManager.h b/include/ObjectManager.h index 355f4c7..adc780f 100644 --- a/include/ObjectManager.h +++ b/include/ObjectManager.h @@ -88,10 +88,10 @@ namespace nt { * also the foundation for attribute-level provenance tracking added * later. * - * @todo Define the merkle_root composition function (hashes the - * generator identity + schema + sorted base merkle_roots). - * Until ephemeral relations are registered through a writer - * path this remains a documentation-only contract. + * The merkle_root composition function is nt::Ephemeral::ComposeRoot + * (SHA256 over generator identity + schema + sorted base merkle_roots); + * nt::Ephemeral::Register is the writer path that populates both + * fields. See include/EphemeralRelation.h. */ class EphemeralRelation : public IObject { public: diff --git a/include/RNT_C_API.h b/include/RNT_C_API.h index 19f6165..d12b23c 100644 --- a/include/RNT_C_API.h +++ b/include/RNT_C_API.h @@ -137,6 +137,105 @@ NT_API int rnt_session_close(const char* session_hash); NT_API int rnt_session_set_branch(const char* session_hash, const char* branch_name, const char* target_hash); +/* ------------------------------------------------------------------ */ +/* Ephemeral relations */ +/* ------------------------------------------------------------------ */ + +/** + * Opaque sink passed to a generator callback. The callback pushes each tuple + * it produces through rnt_sink_emit; the runtime copies the tuple out + * immediately, so the string only needs to stay valid for the duration of + * the rnt_sink_emit call. + */ +typedef void* rnt_tuple_sink_t; + +/** + * @brief Tuple-producing callback backing an ephemeral relation. + * + * Invoked by the cursor layer each time a page of tuples is needed. The + * callback must emit at most @p limit tuples starting at logical position + * @p offset by calling rnt_sink_emit once per tuple. Calls may re-enter the + * RNT API (e.g. to execute a VM plan over base relations); the runtime is + * single-threaded, so no locking is required. + * + * @param ctx Opaque context pointer given at registration time. + * @param args Bound argument values written by a JOIN before probing, + * newline-separated. Empty string when scanned standalone. + * @param offset Zero-based logical tuple offset (for pagination). + * @param limit Maximum number of tuples to emit. + * @param sink Pass to rnt_sink_emit for each produced tuple. + * @return 0 on success, negative on error (the page is treated as empty). + */ +typedef int (*rnt_generator_fn)(void* ctx, const char* args, size_t offset, size_t limit, + rnt_tuple_sink_t sink); + +/** + * @brief Emits one tuple from inside a generator callback. + * + * @param sink Sink received by the callback. + * @param tuple_kv Newline-separated "name=value" attribute lines — the same + * wire format rnt_link_tuple consumes and rnt_cursor_next + * returns. Copied before returning. + * @return 0 on success, negative on error. + */ +NT_API int rnt_sink_emit(rnt_tuple_sink_t sink, const char* tuple_kv); + +/** + * @brief Registers an ephemeral relation owned by a session. + * + * An ephemeral relation has no tuple storage of its own: its tuples are + * produced on demand by @p generator. The entry lands under the session's + * namespace — /system/sessions//ephemeral/ when @p named is + * non-zero (a named binding that survives between commands until dropped or + * session close), or /system/sessions//scratch/ when + * zero (an anonymous intermediate collected as soon as its consuming cursor + * closes). Scan it through the normal pipeline: rnt_open_handle on the + * registered path, then rnt_cursor_open / rnt_plan_scan. + * + * Registration pins every dependency, so a base relation cannot be collected + * while this relation is defined atop it; the pins are released when the + * ephemeral itself is collected. Registration is idempotent for an identical + * scratch result or a live named binding. See docs/ephemeral-relations.org. + * + * @param session_hash Hash returned by rnt_session_open. + * @param named Non-zero for a named binding, 0 for scratch. + * @param name Leaf name for named bindings; ignored for scratch. + * @param generator Tuple-producing callback. Must stay callable until + * the entry is collected. + * @param generator_ctx Opaque pointer passed back to @p generator. + * @param cardinality 0 Finite, 1 ConstrainedFinite, 2 AlephZero, + * 3 Continuum. + * @param generator_identity Stable operator label for the identity hash, + * e.g. "join:id", "select:age". + * @param schema_kv Newline-separated "name=type" attribute lines. + * @param dependencies Newline-separated logical paths of the base + * relations this is defined atop, e.g. + * "system/snapshots//relations/". Every + * path must resolve or the call fails. + * @param path_out Optional (may be NULL): set to the heap-allocated + * slash-joined registered path. Release with + * rnt_free_string(). + * @return 0 on success, negative on error. + */ +NT_API int rnt_register_ephemeral_relation(const char* session_hash, int named, const char* name, + rnt_generator_fn generator, void* generator_ctx, + int cardinality, const char* generator_identity, + const char* schema_kv, const char* dependencies, + char** path_out); + +/** + * @brief Drops a named ephemeral relation, releasing its session-ownership pin. + * + * The entry is collected as soon as no cursor or dependent ephemeral still + * holds it, cascading the pins it took on its base relations. Scratch entries + * cannot be dropped by name; they die with their consuming cursor. + * + * @param session_hash Owning session's hash. + * @param name Leaf name given at registration. + * @return 0 on success, negative when no such named binding exists. + */ +NT_API int rnt_drop_ephemeral_relation(const char* session_hash, const char* name); + /* ------------------------------------------------------------------ */ /* Handle lifecycle */ /* ------------------------------------------------------------------ */ diff --git a/include/VM.h b/include/VM.h index 3f2f665..2ee7fe8 100644 --- a/include/VM.h +++ b/include/VM.h @@ -119,9 +119,9 @@ namespace nt { PlanNode* right = nullptr; /** - * SCAN: pre-opened cursor. For ephemeral relations this cursor is - * initially exhausted; the JOIN resets it with resolved args before - * each probe. + * SCAN: pre-opened cursor. For ephemeral relations the JOIN resets it + * (args, offset, exhausted flag) with resolved args before each probe, + * overwriting whatever standalone-scan state it opened with. */ CursorManager::cursor* scan_cursor = nullptr; diff --git a/src/CursorManager.cpp b/src/CursorManager.cpp index 0d71b1c..23f826f 100644 --- a/src/CursorManager.cpp +++ b/src/CursorManager.cpp @@ -61,17 +61,18 @@ namespace nt { }; if (label == EPHEMERAL_RELATION) { - // Cursor is created exhausted — the JOIN operator writes args and - // resets the state before the first probe. The generator is copied - // off the registry-owned ephemeral_object_type so Next() never has - // to dereference the handle again. + // Cursor opens ready for a standalone scan (no bound args, offset + // zero) so a named ephemeral can be drained like a stored relation. + // A JOIN probe overwrites this state anyway: the operator writes + // args and resets the cursor before the first probe. The generator + // is copied off the registry-owned ephemeral_object_type so Next() + // never has to dereference the handle again. auto* etype = static_cast( handle->object->head->type.get()); auto* c = new cursor(); c->is_ephemeral = true; c->generator = etype->generator; - c->exhausted = true; pin_snapshot(c); return c; } diff --git a/src/EphemeralRelation.cpp b/src/EphemeralRelation.cpp new file mode 100644 index 0000000..5cb2dd7 --- /dev/null +++ b/src/EphemeralRelation.cpp @@ -0,0 +1,196 @@ +#include "EphemeralRelation.h" + +#include "Types.h" + +#include + +#include +#include + +namespace nt::Ephemeral { + namespace { + // Object-type descriptor for an EPHEMERAL_RELATION. Ephemeral relations + // are not durable (disposable=true) and never contend (readers compose + // generators independently). The generator and cardinality live on the + // descriptor; the cursor layer copies the generator out at Open time. + std::unique_ptr + make_type(ObjectManager::ephemeral_object_type::Cardinality cardinality, + ObjectManager::ephemeral_object_type::Generator generator) { + auto t = std::make_unique(); + t->label = EPHEMERAL_RELATION; + t->disposable = true; + t->methods = {OPEN, CLOSE}; + t->exclusive = false; + t->cardinality = cardinality; + t->generator = std::move(generator); + return t; + } + + // Reads the merkle_root off a registry entry whose object is a stored or + // ephemeral relation. Empty string for anything else (or missing root). + std::string entry_root(ObjectManager::registry* entry) { + if (entry == nullptr || entry->object == nullptr) + return {}; + if (auto* r = dynamic_cast(entry->object.get())) + return r->merkle_root; + if (auto* e = dynamic_cast(entry->object.get())) + return e->merkle_root; + return {}; + } + } // namespace + + std::vector SplitPath(const std::string& path) { + std::vector parts; + std::string seg; + for (char c : path) { + if (c == '/') { + if (!seg.empty()) + parts.push_back(seg); + seg.clear(); + } else { + seg.push_back(c); + } + } + if (!seg.empty()) + parts.push_back(seg); + return parts; + } + + std::string ComposeRoot(const std::string& generator_identity, + const std::vector>& schema, + const std::vector& base_roots) { + // Sort schema and base roots so input order does not change the digest. + std::vector> sorted_schema = schema; + std::sort(sorted_schema.begin(), sorted_schema.end()); + std::vector sorted_roots = base_roots; + std::sort(sorted_roots.begin(), sorted_roots.end()); + + std::string buf; + buf += generator_identity; + buf.push_back('\0'); + // NUL-separate each field so distinct (name, type) splits cannot + // serialize to the same bytes, then a lone NUL to close the schema. + for (const auto& [name, type] : sorted_schema) { + buf += name; + buf.push_back('\0'); + buf += type; + buf.push_back('\0'); + } + buf.push_back('\0'); + for (const auto& root : sorted_roots) { + buf += root; + buf.push_back('\n'); + } + return picosha2::hash256_hex_string(buf.begin(), buf.end()); + } + + ObjectManager::registry* Register( + ObjectManager& objects, LifecycleManager& lifecycles, const std::string& session_hash, + Tier tier, const std::string& name, + ObjectManager::ephemeral_object_type::Generator generator, + ObjectManager::ephemeral_object_type::Cardinality cardinality, + const std::string& generator_identity, + const std::vector>& schema, + const std::vector& dependencies) { + if (session_hash.empty()) + return nullptr; + if (tier == Tier::Named && name.empty()) + return nullptr; + + // Resolve dependencies up front: read their current roots for the + // identity hash and keep the entries to pin after registration. An + // unresolvable dependency fails the registration; skipping it would + // store a path that was never pinned, and CascadeEphemeral would later + // Unpin a hold it never took. + std::vector dep_entries; + std::vector base_roots; + dep_entries.reserve(dependencies.size()); + base_roots.reserve(dependencies.size()); + for (const auto& dep : dependencies) { + auto* entry = objects.Find(SplitPath(dep)); + if (entry == nullptr) + return nullptr; + dep_entries.push_back(entry); + base_roots.push_back(entry_root(entry)); + } + + const std::string root = ComposeRoot(generator_identity, schema, base_roots); + const std::string subdir = (tier == Tier::Named) ? "ephemeral" : "scratch"; + const std::string leaf = (tier == Tier::Named) ? name : root; + const std::vector path = {"system", "sessions", session_hash, subdir, leaf}; + + // Idempotent: an identical scratch result, or a live named binding, + // reuses the existing entry without double-pinning. + if (auto* existing = objects.Find(path)) + return existing; + + auto er = std::make_unique(); + er->merkle_root = root; + er->dependencies = dependencies; + objects.Register(path, std::move(er), make_type(cardinality, std::move(generator))); + + auto* entry = objects.Find(path); + if (entry == nullptr) + return nullptr; + + // Structural pins on the base relations: released by + // LifecycleManager::CascadeEphemeral when this entry is collected. + for (auto* dep : dep_entries) + lifecycles.Pin(dep); + + // Tier policy: a named ephemeral is owned by its session and outlives + // its cursor; a scratch intermediate is owned by nobody and dies by + // counters with its cursor. + if (tier == Tier::Named) + lifecycles.Pin(entry); + + return entry; + } + + std::size_t ReleaseSession(ObjectManager& objects, LifecycleManager& lifecycles, + const std::string& session_hash) { + const std::vector named_prefix = {"system", "sessions", session_hash, + "ephemeral"}; + const std::vector scratch_prefix = {"system", "sessions", session_hash, + "scratch"}; + + auto is_child = [](const std::vector& path, + const std::vector& prefix) { + if (path.size() != prefix.size() + 1) + return false; + for (std::size_t i = 0; i < prefix.size(); ++i) + if (path[i] != prefix[i]) + return false; + return true; + }; + + // Collect targets before mutating: Unpin / Collect splice entries out of + // the same list we are walking. + std::vector named; + std::vector scratch; + for (auto* cur = objects.entries.get(); cur != nullptr; cur = cur->next.get()) { + if (cur->head == nullptr) + continue; + const auto& p = cur->head->path; + if (is_child(p, named_prefix)) + named.push_back(cur); + else if (is_child(p, scratch_prefix)) + scratch.push_back(cur); + } + + std::size_t released = 0; + // Named: drop the session-ownership pin; collects if nothing else holds it. + for (auto* entry : named) { + const uint32_t before = entry->head->reference_count; + lifecycles.Unpin(entry); + if (before > 0) + ++released; + } + // Scratch: never session-pinned; collect any stragglers whose cursor closed. + for (auto* entry : scratch) + if (lifecycles.Collect(entry)) + ++released; + + return released; + } +} // namespace nt::Ephemeral diff --git a/src/LifecycleManager.cpp b/src/LifecycleManager.cpp index 4a12e09..10f3bb1 100644 --- a/src/LifecycleManager.cpp +++ b/src/LifecycleManager.cpp @@ -1,5 +1,6 @@ #include "LifecycleManager.h" +#include "EphemeralRelation.h" #include "Merkle.h" #include "Types.h" @@ -44,6 +45,20 @@ namespace nt { return object != nullptr; } + bool LifecycleManager::Collect(ObjectManager::registry* object) { + if (!IsEligibleForGC(object)) + return false; + // Named-lifetime types (BRANCH / SESSION) never auto-collect; TryCollect + // would no-op on them, so return false rather than report a collection. + if (object->head->type != nullptr) { + const auto label = object->head->type->label; + if (label == BRANCH || label == SESSION) + return false; + } + TryCollect(object); + return true; + } + void LifecycleManager::TryCollect(ObjectManager::registry* object) { if (!IsEligibleForGC(object)) return; @@ -66,6 +81,8 @@ namespace nt { CascadeMultigroup(object); if (label == BRANCH_TREE) CascadeBranchTree(object); + if (label == EPHEMERAL_RELATION) + CascadeEphemeral(object); objects_.Unregister(object->head->path); } @@ -140,4 +157,27 @@ namespace nt { for (auto* mg : mgs) Unpin(mg); } + + void LifecycleManager::CascadeEphemeral(ObjectManager::registry* ephemeral) { + if (ephemeral == nullptr || ephemeral->object == nullptr) + return; + auto* er = dynamic_cast(ephemeral->object.get()); + if (er == nullptr) + return; + + // Resolve each dependency path and collect before mutating: Unpin can + // cascade further GC, and iteration must outlive that mutation. + std::vector deps; + for (const auto& dep : er->dependencies) { + const auto parts = Ephemeral::SplitPath(dep); + if (parts.empty()) + continue; + auto* entry = objects_.Find(parts); + if (entry != nullptr) + deps.push_back(entry); + } + + for (auto* dep : deps) + Unpin(dep); + } } // namespace nt diff --git a/src/RNT_C_API.cpp b/src/RNT_C_API.cpp index a632ab9..a83ac3a 100644 --- a/src/RNT_C_API.cpp +++ b/src/RNT_C_API.cpp @@ -1,6 +1,7 @@ #include "RNT_C_API.h" #include "CursorManager.h" +#include "EphemeralRelation.h" #include "HandlerManager.h" #include "IdentityManager.h" #include "InMemoryBackend.h" @@ -751,6 +752,10 @@ int rnt_session_close(const char* session_hash) { rebind_branch_tree_pin(hash, ""); session->branch_overrides.clear(); + // Drop the session's owned ephemeral relations (named tier-1 bindings and + // any scratch stragglers), cascading their base-relation pins. + nt::Ephemeral::ReleaseSession(g_rt->objects, *g_rt->lifecycles, session_hash); + return g_rt->objects.Unregister(session_path) ? 0 : -1; } @@ -792,6 +797,132 @@ int rnt_session_set_branch(const char* session_hash, const char* branch_name, return 0; } +// --------------------------------------------------------------------------- +// Ephemeral relations +// --------------------------------------------------------------------------- + +namespace { + // Accumulates tuples emitted by a generator callback during one page + // request. Lives on the stack of the generator wrapper below; the opaque + // rnt_tuple_sink_t handed to the callback points at it. + struct TupleSink { + std::vector tuples; + }; + + // Splits a newline-separated list into its non-empty lines. + std::vector split_lines(const char* s) { + std::vector lines; + if (!s) + return lines; + std::stringstream ss(s); + std::string line; + while (std::getline(ss, line)) + if (!line.empty()) + lines.push_back(line); + return lines; + } + + // Joins registry path segments back into a slash-separated logical path. + std::string join_path(const std::vector& segs) { + std::string out; + for (const auto& seg : segs) { + if (!out.empty()) + out.push_back('/'); + out += seg; + } + return out; + } +} // namespace + +int rnt_sink_emit(rnt_tuple_sink_t sink, const char* tuple_kv) { + if (!sink || !tuple_kv) + return -1; + auto* s = static_cast(sink); + s->tuples.emplace_back(parse_kv(tuple_kv)); + return 0; +} + +int rnt_register_ephemeral_relation(const char* session_hash, int named, const char* name, + rnt_generator_fn generator, void* generator_ctx, + int cardinality, const char* generator_identity, + const char* schema_kv, const char* dependencies, + char** path_out) { + if (!g_rt || !session_hash || !generator || !generator_identity) + return -1; + using Card = nt::ObjectManager::ephemeral_object_type::Cardinality; + if (cardinality < static_cast(Card::Finite) || + cardinality > static_cast(Card::Continuum)) + return -1; + + // The session must exist: the entry lands under its namespace and is + // released by rnt_session_close. + auto* session = g_rt->objects.Find({"system", "sessions", session_hash}); + if (!session || !session->head || session->head->type->label != SESSION) + return -1; + + std::vector> schema; + for (const auto& line : split_lines(schema_kv)) { + const auto pos = line.find('='); + if (pos == std::string::npos) + return -1; + schema.emplace_back(line.substr(0, pos), line.substr(pos + 1)); + } + + // Wrap the C callback in the std::function shape the cursor layer copies + // out at Open time. The sink gives emitted tuples a single well-defined + // owner: this wrapper's stack frame, for the duration of one page request. + auto gen = [generator, generator_ctx](const std::vector& args, + std::size_t offset, + std::size_t limit) -> std::vector { + std::string joined; + for (const auto& a : args) { + joined += a; + joined.push_back('\n'); + } + TupleSink sink; + const int rc = generator(generator_ctx, joined.c_str(), offset, limit, &sink); + if (rc != 0) + return {}; + return std::move(sink.tuples); + }; + + // Resolve each dependency through the reference manager so callers may + // pass branch-relative paths (e.g. system/branches//multigroups// + // relations/); the stored dependency list then holds the resolved + // snapshot paths that raw registry lookups (and the GC cascade) address. + std::vector deps; + for (const auto& dep : split_lines(dependencies)) { + const auto resolved = g_rt->references->Resolve(nt::Ephemeral::SplitPath(dep)); + if (resolved.empty()) + return -1; + deps.push_back(join_path(resolved)); + } + + auto* entry = nt::Ephemeral::Register( + g_rt->objects, *g_rt->lifecycles, session_hash, + named ? nt::Ephemeral::Tier::Named : nt::Ephemeral::Tier::Scratch, + name ? name : "", std::move(gen), static_cast(cardinality), generator_identity, + schema, deps); + if (!entry) + return -1; + + if (path_out) + *path_out = heap_str(join_path(entry->head->path)); + return 0; +} + +int rnt_drop_ephemeral_relation(const char* session_hash, const char* name) { + if (!g_rt || !session_hash || !name || !*name) + return -1; + auto* entry = g_rt->objects.Find({"system", "sessions", session_hash, "ephemeral", name}); + if (!entry || !entry->head || entry->head->type->label != EPHEMERAL_RELATION) + return -1; + // Releases the session-ownership pin taken at registration; the entry is + // collected once no cursor or dependent ephemeral still holds it. + g_rt->lifecycles->Unpin(entry); + return 0; +} + rnt_handle_t rnt_open_handle(const char* path, const char* /*claims*/) { if (!path) return nullptr; diff --git a/test/EphemeralRelation.cpp b/test/EphemeralRelation.cpp new file mode 100644 index 0000000..18456c2 --- /dev/null +++ b/test/EphemeralRelation.cpp @@ -0,0 +1,168 @@ +#include + +#include "EphemeralRelation.h" +#include "InMemoryBackend.h" +#include "LifecycleManager.h" +#include "ObjectManager.h" + +#include + +// Ephemeral relations — writer path, tiered ownership, and GC cascade. +// +// nt::Ephemeral::Register lands an EPHEMERAL_RELATION in the object manager, +// pins the base relations it depends on, and (for the Named tier) takes a +// session-ownership pin so the entry outlives its cursor. Collection unpins +// the base relations via LifecycleManager::CascadeEphemeral. + +namespace { + using Card = nt::ObjectManager::ephemeral_object_type::Cardinality; + + std::unique_ptr make_relation_type() { + auto t = std::make_unique(); + t->label = RELATION; + t->methods = {OPEN, CLOSE}; + return t; + } + + // Registers a stored base relation at the given registry path and sets its + // merkle_root. Takes one baseline pin to model the owning multigroup + // snapshot, so the relation survives an ephemeral releasing its own pin — + // the reference_count then returns to 1 rather than dropping to 0 and being + // collected. Returns the slash-joined logical path for use as a dependency. + std::string make_base(nt::ObjectManager& om, nt::LifecycleManager& lm, + const std::vector& path, const std::string& root) { + auto rel = std::make_unique(); + rel->merkle_root = root; + om.Register(path, std::move(rel), make_relation_type()); + lm.Pin(om.Find(path)); // baseline pin: the snapshot that owns this relation + std::string joined; + for (const auto& seg : path) { + if (!joined.empty()) + joined.push_back('/'); + joined += seg; + } + return joined; + } + + // A trivial generator: yields nothing. Lifetime tests do not scan. + nt::ObjectManager::ephemeral_object_type::Generator empty_generator() { + return [](const std::vector&, std::size_t, std::size_t) { + return std::vector{}; + }; + } +} // namespace + +TEST_CASE("ComposeRoot is deterministic and order-independent", "[ephemeral][hash]") { + const std::vector> schema_a = {{"left", "natural"}, + {"right", "natural"}}; + const std::vector> schema_b = {{"right", "natural"}, + {"left", "natural"}}; + + const std::string h1 = nt::Ephemeral::ComposeRoot("eq", schema_a, {"aaa", "bbb"}); + const std::string h2 = nt::Ephemeral::ComposeRoot("eq", schema_b, {"bbb", "aaa"}); + REQUIRE(h1 == h2); // schema + base-root order must not matter + REQUIRE(h1.size() == 64); + + // Identity, schema, or base roots each change the digest. + REQUIRE(nt::Ephemeral::ComposeRoot("lt", schema_a, {"aaa", "bbb"}) != h1); + REQUIRE(nt::Ephemeral::ComposeRoot("eq", schema_a, {"aaa", "ccc"}) != h1); + REQUIRE(nt::Ephemeral::ComposeRoot("eq", {{"left", "natural"}}, {"aaa", "bbb"}) != h1); +} + +TEST_CASE("Scratch tier pins deps but is not session-owned", "[ephemeral][scratch]") { + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + + const std::string dep = + make_base(om, lm, {"system", "snapshots", "h", "relations", "r1"}, "root1"); + + auto* eph = nt::Ephemeral::Register(om, lm, "sess", nt::Ephemeral::Tier::Scratch, "", + empty_generator(), Card::Finite, "select", + {{"a", "abstract"}}, {dep}); + REQUIRE(eph != nullptr); + + // Registered under the session's scratch subdir, keyed by composed root. + REQUIRE(eph->head->path[0] == "system"); + REQUIRE(eph->head->path[3] == "scratch"); + + // Base relation gains the ephemeral's pin atop its snapshot baseline (1+1); + // the ephemeral itself carries no ownership pin. + REQUIRE(om.Find({"system", "snapshots", "h", "relations", "r1"})->head->reference_count == 2); + REQUIRE(eph->head->reference_count == 0); +} + +TEST_CASE("Scratch ephemeral is collected by counters and unpins its base", + "[ephemeral][scratch][gc]") { + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + + const std::string dep = + make_base(om, lm, {"system", "snapshots", "h", "relations", "r1"}, "root1"); + auto* eph = nt::Ephemeral::Register(om, lm, "sess", nt::Ephemeral::Tier::Scratch, "", + empty_generator(), Card::Finite, "select", + {{"a", "abstract"}}, {dep}); + const auto eph_path = eph->head->path; + + // A cursor opens (Monitor) then closes (Unmonitor) over the scratch result. + lm.Monitor(eph); + REQUIRE(om.Find(eph_path) != nullptr); + lm.Unmonitor(eph); + + // Gone by counters, and the cascade released the ephemeral's pin on the + // base, returning it to its snapshot baseline (still alive at 1). + REQUIRE(om.Find(eph_path) == nullptr); + REQUIRE(om.Find({"system", "snapshots", "h", "relations", "r1"})->head->reference_count == 1); +} + +TEST_CASE("Named ephemeral survives its cursor and is freed at session release", + "[ephemeral][named][gc]") { + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + + const std::string dep = + make_base(om, lm, {"system", "snapshots", "h", "relations", "r1"}, "root1"); + auto* eph = nt::Ephemeral::Register(om, lm, "sess", nt::Ephemeral::Tier::Named, "myview", + empty_generator(), Card::Finite, "join", + {{"a", "abstract"}}, {dep}); + REQUIRE(eph != nullptr); + REQUIRE(eph->head->path[3] == "ephemeral"); + REQUIRE(eph->head->path[4] == "myview"); + REQUIRE(eph->head->reference_count == 1); // session-ownership pin + + const auto eph_path = eph->head->path; + + // A cursor opens and closes; the session pin keeps the named view alive. + lm.Monitor(eph); + lm.Unmonitor(eph); + REQUIRE(om.Find(eph_path) != nullptr); + + // Session close releases it; the base relation's pin cascades free. + const std::size_t freed = nt::Ephemeral::ReleaseSession(om, lm, "sess"); + REQUIRE(freed == 1); + REQUIRE(om.Find(eph_path) == nullptr); + REQUIRE(om.Find({"system", "snapshots", "h", "relations", "r1"})->head->reference_count == 1); +} + +TEST_CASE("Identical scratch results dedup and do not double-pin", "[ephemeral][dedup]") { + nt::InMemoryBackend store; + nt::ObjectManager om; + nt::LifecycleManager lm(om, store); + + const std::string dep = + make_base(om, lm, {"system", "snapshots", "h", "relations", "r1"}, "root1"); + + auto* first = nt::Ephemeral::Register(om, lm, "sess", nt::Ephemeral::Tier::Scratch, "", + empty_generator(), Card::Finite, "select", + {{"a", "abstract"}}, {dep}); + auto* second = nt::Ephemeral::Register(om, lm, "sess", nt::Ephemeral::Tier::Scratch, "", + empty_generator(), Card::Finite, "select", + {{"a", "abstract"}}, {dep}); + + REQUIRE(first == second); // same composed root → same entry + // The ephemeral pinned the base exactly once (atop the baseline), not once + // per Register call. + REQUIRE(om.Find({"system", "snapshots", "h", "relations", "r1"})->head->reference_count == 2); +} -- cgit v1.2.3