aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarcos Magueta <maguetamarcos@gmail.com>2026-06-22 21:58:54 -0300
committerGitHub <noreply@github.com>2026-06-22 21:58:54 -0300
commit663586b8d07c979218ceff79576bb549ee1973c5 (patch)
tree8a7dabebe05d0c1ac7a509e8dcf6762364c0c095
parent9f0e1c642680c69e10eb1760be07984f9ecc6d39 (diff)
downloadRNT-master-old.tar.gz
RNT-master-old.zip
Ephemeral relationsmaster-old
Diffstat (limited to '')
-rw-r--r--CMakeLists.txt2
-rw-r--r--include/EphemeralRelation.h143
-rw-r--r--include/LifecycleManager.h30
-rw-r--r--include/ObjectManager.h8
-rw-r--r--include/RNT_C_API.h99
-rw-r--r--include/VM.h6
-rw-r--r--src/CursorManager.cpp11
-rw-r--r--src/EphemeralRelation.cpp196
-rw-r--r--src/LifecycleManager.cpp40
-rw-r--r--src/RNT_C_API.cpp131
-rw-r--r--test/EphemeralRelation.cpp168
11 files changed, 822 insertions, 12 deletions
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 <string>
+#include <utility>
+#include <vector>
+
+/**
+ * @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/<session>/scratch/<root> 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/<session>/ephemeral/<name> 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<std::string> 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<std::pair<std::string, std::string>>& schema,
+ const std::vector<std::string>& 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/<h>/relations/<rel>". 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<std::pair<std::string, std::string>>& schema,
+ const std::vector<std::string>& 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
@@ -96,6 +96,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.
*
* Immutable objects — relation snapshots, multigroup snapshots,
@@ -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
@@ -138,6 +138,105 @@ NT_API int rnt_session_set_branch(const char* session_hash, const char* branch_n
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/<hash>/ephemeral/<name> when @p named is
+ * non-zero (a named binding that survives between commands until dropped or
+ * session close), or /system/sessions/<hash>/scratch/<composed_root> 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/<h>/relations/<rel>". 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<ObjectManager::ephemeral_object_type*>(
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 <picosha2.h>
+
+#include <algorithm>
+#include <memory>
+
+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<ObjectManager::object_type>
+ make_type(ObjectManager::ephemeral_object_type::Cardinality cardinality,
+ ObjectManager::ephemeral_object_type::Generator generator) {
+ auto t = std::make_unique<ObjectManager::ephemeral_object_type>();
+ 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<ObjectManager::Relation*>(entry->object.get()))
+ return r->merkle_root;
+ if (auto* e = dynamic_cast<ObjectManager::EphemeralRelation*>(entry->object.get()))
+ return e->merkle_root;
+ return {};
+ }
+ } // namespace
+
+ std::vector<std::string> SplitPath(const std::string& path) {
+ std::vector<std::string> 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<std::pair<std::string, std::string>>& schema,
+ const std::vector<std::string>& base_roots) {
+ // Sort schema and base roots so input order does not change the digest.
+ std::vector<std::pair<std::string, std::string>> sorted_schema = schema;
+ std::sort(sorted_schema.begin(), sorted_schema.end());
+ std::vector<std::string> 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<std::pair<std::string, std::string>>& schema,
+ const std::vector<std::string>& 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<ObjectManager::registry*> dep_entries;
+ std::vector<std::string> 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<std::string> 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<ObjectManager::EphemeralRelation>();
+ 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<std::string> named_prefix = {"system", "sessions", session_hash,
+ "ephemeral"};
+ const std::vector<std::string> scratch_prefix = {"system", "sessions", session_hash,
+ "scratch"};
+
+ auto is_child = [](const std::vector<std::string>& path,
+ const std::vector<std::string>& 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<ObjectManager::registry*> named;
+ std::vector<ObjectManager::registry*> 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<ObjectManager::EphemeralRelation*>(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<ObjectManager::registry*> 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<nt::Tuple> tuples;
+ };
+
+ // Splits a newline-separated list into its non-empty lines.
+ std::vector<std::string> split_lines(const char* s) {
+ std::vector<std::string> 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<std::string>& 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<TupleSink*>(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<int>(Card::Finite) ||
+ cardinality > static_cast<int>(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<std::pair<std::string, std::string>> 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<std::string>& args,
+ std::size_t offset,
+ std::size_t limit) -> std::vector<nt::Tuple> {
+ 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/<b>/multigroups/<mg>/
+ // relations/<r>); the stored dependency list then holds the resolved
+ // snapshot paths that raw registry lookups (and the GC cascade) address.
+ std::vector<std::string> 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<Card>(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 <catch2/catch_test_macros.hpp>
+
+#include "EphemeralRelation.h"
+#include "InMemoryBackend.h"
+#include "LifecycleManager.h"
+#include "ObjectManager.h"
+
+#include <memory>
+
+// 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<nt::ObjectManager::object_type> make_relation_type() {
+ auto t = std::make_unique<nt::ObjectManager::object_type>();
+ 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<std::string>& path, const std::string& root) {
+ auto rel = std::make_unique<nt::ObjectManager::Relation>();
+ 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::string>&, std::size_t, std::size_t) {
+ return std::vector<nt::Tuple>{};
+ };
+ }
+} // namespace
+
+TEST_CASE("ComposeRoot is deterministic and order-independent", "[ephemeral][hash]") {
+ const std::vector<std::pair<std::string, std::string>> schema_a = {{"left", "natural"},
+ {"right", "natural"}};
+ const std::vector<std::pair<std::string, std::string>> 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);
+}