ladybug 0.20.2.dev20260907__tar.gz → 0.20.2.dev20260908__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sdist/PKG-INFO CHANGED
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ladybug
3
- Version: 0.20.2.dev20260907
3
+ Version: 0.20.2.dev20260908
4
4
  Summary: Highly scalable, extremely fast, easy-to-use embeddable graph database
5
5
  Home-page: https://github.com/LadybugDB/ladybug
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  cmake_minimum_required(VERSION 3.15)
2
2
 
3
- project(Lbug VERSION 0.20.2.20260907 LANGUAGES CXX C)
3
+ project(Lbug VERSION 0.20.2.20260908 LANGUAGES CXX C)
4
4
 
5
5
  option(SINGLE_THREADED "Single-threaded mode" FALSE)
6
6
  if(SINGLE_THREADED)
@@ -1,15 +1,48 @@
1
1
  #pragma once
2
2
 
3
+ #include <mutex>
4
+ #include <thread>
5
+ #include <unordered_map>
6
+
3
7
  #include "common/task_system/task_scheduler.h"
4
8
 
5
9
  namespace lbug {
6
10
  namespace main {
7
11
  class QueryResult;
8
12
  }
13
+ namespace storage {
14
+ class MemoryManager;
15
+ }
9
16
  namespace processor {
10
17
  class FactorizedTable;
11
18
  class PhysicalPlan;
12
19
  class PhysicalOperator;
20
+ class ResultSet;
21
+ struct ResultSetDescriptor;
22
+
23
+ // Per-database pool of reusable ResultSets, one slot per executing thread. A cached ResultSet
24
+ // owns ValueVectors whose buffers belong to this database's MemoryManager, so the pool must be
25
+ // owned by (and destroyed with) the database rather than living in a process-wide thread_local:
26
+ // a thread_local slot outlives Database::~Database and later releases (or worse, reuses) buffers
27
+ // through a dangling MemoryManager pointer when the next database runs a query on that thread.
28
+ class ResultSetPool {
29
+ public:
30
+ // Returns the ResultSet cached for the calling thread when its descriptor id matches,
31
+ // otherwise allocates a fresh one and caches it. The caller must keep the returned
32
+ // shared_ptr for as long as it uses the ResultSet: a nested query on the same thread may
33
+ // replace the slot, and the pool's own reference is dropped when this database closes.
34
+ std::shared_ptr<ResultSet> getOrCreate(ResultSetDescriptor* descriptor,
35
+ storage::MemoryManager* memoryManager);
36
+
37
+ private:
38
+ struct Entry {
39
+ uint64_t descriptorID = UINT64_MAX;
40
+ std::shared_ptr<ResultSet> resultSet;
41
+ };
42
+ std::mutex mtx;
43
+ std::unordered_map<std::thread::id, Entry> entries;
44
+ };
45
+
13
46
  class QueryProcessor {
14
47
 
15
48
  public:
@@ -20,6 +53,7 @@ public:
20
53
  #endif
21
54
 
22
55
  common::TaskScheduler* getTaskScheduler() { return taskScheduler.get(); }
56
+ ResultSetPool& getResultSetPool() { return resultSetPool; }
23
57
 
24
58
  std::unique_ptr<main::QueryResult> execute(PhysicalPlan* physicalPlan,
25
59
  ExecutionContext* context);
@@ -30,6 +64,10 @@ private:
30
64
  void initTask(common::Task* task);
31
65
 
32
66
  private:
67
+ // Declared before the scheduler so worker threads are joined before the pool is destroyed.
68
+ // The pool never outlives the buffers it caches because Database destroys its QueryProcessor
69
+ // before its MemoryManager.
70
+ ResultSetPool resultSetPool;
33
71
  std::unique_ptr<common::TaskScheduler> taskScheduler;
34
72
  };
35
73
 
@@ -27,13 +27,13 @@ struct DataChunkDescriptor {
27
27
 
28
28
  struct LBUG_API ResultSetDescriptor {
29
29
  // Monotonically increasing identity that survives pointer reuse (ABA
30
- // prevention). Thread-local ResultSet caching in ProcessorTask::run()
30
+ // prevention). Per-database ResultSet pooling in ProcessorTask::run()
31
31
  // compares this ID instead of the raw pointer so that a descriptor
32
32
  // allocated at the same address as a freed one is never confused with it.
33
33
  // copy() preserves the id: a copy semantically represents the same descriptor
34
34
  // slot (same plan position, same content). In particular the per-execution
35
35
  // re-attach of cached-plan sink descriptors (ClientContext::attachSinkDescriptors)
36
- // must carry the same id across executions, otherwise the thread-local cache
36
+ // must carry the same id across executions, otherwise the per-database ResultSetPool
37
37
  // would miss on every execution of the same prepared statement.
38
38
  static inline std::atomic<uint64_t> nextID{0};
39
39
  uint64_t id;
@@ -13,6 +13,7 @@
13
13
  #include "storage/table/columnar_node_table_base.h"
14
14
 
15
15
  namespace lbug {
16
+ struct ArrowTableData;
16
17
  namespace storage {
17
18
 
18
19
  struct ArrowNodeTableScanState final : ColumnarNodeTableScanState {
@@ -75,7 +76,7 @@ class ArrowNodeTable final : public ColumnarNodeTableBase {
75
76
  public:
76
77
  ArrowNodeTable(const StorageManager* storageManager,
77
78
  const catalog::NodeTableCatalogEntry* nodeTableEntry, MemoryManager* memoryManager,
78
- ArrowSchemaWrapper schema, std::vector<ArrowArrayWrapper> arrays, std::string arrowId);
79
+ std::shared_ptr<ArrowTableData> arrowData, std::string arrowId);
79
80
 
80
81
  ~ArrowNodeTable();
81
82
 
@@ -121,6 +122,10 @@ private:
121
122
  const std::vector<int64_t>& outputToArrowColumnIdx) const;
122
123
 
123
124
  private:
125
+ // Pin on the registry entry: keeps the Arrow buffers alive even after
126
+ // unregisterArrowData() erases the registry map entry (issue #933).
127
+ // `schema`/`arrays` below are shallow non-owning views into `arrowData`.
128
+ std::shared_ptr<ArrowTableData> arrowData;
124
129
  ArrowSchemaWrapper schema;
125
130
  std::vector<ArrowArrayWrapper> arrays;
126
131
  std::vector<std::optional<common::ArrowLogicalTypeInfo>> columnLogicalTypeInfos;
@@ -32,10 +32,7 @@ public:
32
32
  ArrowRelTable(catalog::RelGroupCatalogEntry* relGroupEntry, common::table_id_t fromTableID,
33
33
  common::table_id_t toTableID, const StorageManager* storageManager,
34
34
  MemoryManager* memoryManager, const NodeTable* fromNodeTable, const NodeTable* toNodeTable,
35
- ArrowRelTableLayout layout, ArrowSchemaWrapper schema,
36
- std::vector<ArrowArrayWrapper> arrays, ArrowSchemaWrapper indptrSchema,
37
- std::vector<ArrowArrayWrapper> indptrArrays, std::string arrowId,
38
- std::string dstColumnName = "to");
35
+ std::shared_ptr<ArrowRelTableData> arrowData, std::string arrowId);
39
36
  ~ArrowRelTable();
40
37
 
41
38
  void initScanState(transaction::Transaction* transaction, TableScanState& scanState,
@@ -78,6 +75,9 @@ private:
78
75
 
79
76
  const NodeTable* fromNodeTable;
80
77
  const NodeTable* toNodeTable;
78
+ // Pin on the registry entry (issue #933); `schema`/`arrays`/`indptrSchema`/
79
+ // `indptrArrays` below are shallow non-owning views into `arrowData`.
80
+ std::shared_ptr<ArrowRelTableData> arrowData;
81
81
  ArrowRelTableLayout layout;
82
82
  ArrowSchemaWrapper schema;
83
83
  std::vector<ArrowArrayWrapper> arrays;
@@ -22,6 +22,16 @@ struct ArrowRelTableData {
22
22
  std::string dstColumnName = "to";
23
23
  };
24
24
 
25
+ // Registry-owned Arrow node-table payload. Stored as a shared_ptr in the
26
+ // process-wide registry so that tables can pin the data they view: the
27
+ // shared_ptr copy returned by getArrowData()/getArrowRelData() keeps the
28
+ // underlying Arrow buffers alive even if another thread unregisters (erases)
29
+ // the registry entry concurrently (CWE-416/667, issue #933).
30
+ struct ArrowTableData {
31
+ ArrowSchemaWrapper schema;
32
+ std::vector<ArrowArrayWrapper> arrays;
33
+ };
34
+
25
35
  // Result of creating an arrow table view
26
36
  struct ArrowTableCreationResult {
27
37
  std::unique_ptr<main::QueryResult> queryResult;
@@ -37,12 +47,15 @@ public:
37
47
  // Register Arrow relationship data and return an ID
38
48
  static std::string registerArrowRelData(ArrowRelTableData data);
39
49
 
40
- // Retrieve Arrow data by ID (returns pointers to data in registry)
41
- static bool getArrowData(const std::string& id, ArrowSchemaWrapper*& schema,
42
- std::vector<ArrowArrayWrapper>*& arrays);
50
+ // Retrieve Arrow data by ID. Returns a shared_ptr pinning the registry
51
+ // entry's lifetime: the caller may safely dereference it after the
52
+ // registry lock is released, even if another thread concurrently
53
+ // unregisters the same ID. Returns nullptr when the ID is unknown.
54
+ static std::shared_ptr<ArrowTableData> getArrowData(const std::string& id);
43
55
 
44
- // Retrieve Arrow relationship data by ID (returns pointer to data in registry)
45
- static bool getArrowRelData(const std::string& id, ArrowRelTableData*& data);
56
+ // Retrieve Arrow relationship data by ID (same lifetime semantics as
57
+ // getArrowData above). Returns nullptr when the ID is unknown.
58
+ static std::shared_ptr<ArrowRelTableData> getArrowRelData(const std::string& id);
46
59
 
47
60
  // Unregister Arrow data by ID
48
61
  static void unregisterArrowData(const std::string& id);
@@ -4,6 +4,7 @@
4
4
  #include <cstdlib>
5
5
  #include <unordered_set>
6
6
 
7
+ #include "binder/expression/expression_util.h"
7
8
  #include "common/enums/extend_direction_util.h"
8
9
  #include "main/client_context.h"
9
10
  #include "optimizer/acc_hash_join_optimizer.h"
@@ -32,6 +33,14 @@
32
33
  #include "planner/operator/logical_filter.h"
33
34
  #include "planner/operator/logical_hash_join.h"
34
35
  #include "planner/operator/logical_operator.h"
36
+ #include "planner/operator/logical_partitioner.h"
37
+ #include "planner/operator/logical_unwind_deduplicate.h"
38
+ #include "planner/operator/scan/logical_count_anti_edge_chain.h"
39
+ #include "planner/operator/scan/logical_count_extend_chain.h"
40
+ #include "planner/operator/scan/logical_count_rel_table.h"
41
+ #include "planner/operator/scan/logical_query_primary_key_lookup.h"
42
+ #include "planner/operator/scan/logical_reachable_count.h"
43
+ #include "planner/operator/scan/logical_rel_degree_table.h"
35
44
  #include "planner/operator/scan/logical_scan_node_table.h"
36
45
  #include "transaction/transaction.h"
37
46
 
@@ -88,6 +97,64 @@ void dumpLogicalTree(const planner::LogicalOperator* op, int depth,
88
97
  } else if (op->getOperatorType() == planner::LogicalOperatorType::SCAN_NODE_TABLE) {
89
98
  fprintf(stderr, " [%s]",
90
99
  op->constCast<planner::LogicalScanNodeTable>().getNodeID()->toString().c_str());
100
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::COUNT_REL_TABLE) {
101
+ auto& count = op->constCast<planner::LogicalCountRelTable>();
102
+ fprintf(stderr, " [%s dir=%s bound=%s]", count.getRelGroupEntry()->getName().c_str(),
103
+ common::ExtendDirectionUtil::toString(count.getDirection()).c_str(),
104
+ count.getBoundNode()->getUniqueName().c_str());
105
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::COUNT_EXTEND_CHAIN) {
106
+ auto& count = op->constCast<planner::LogicalCountExtendChain>();
107
+ fprintf(stderr, " [hops=%llu tables=", (unsigned long long)count.getHops().size());
108
+ bool first = true;
109
+ for (auto& hop : count.getHops()) {
110
+ for (auto& spec : hop.relScans) {
111
+ fprintf(stderr, "%s%s", first ? "" : ",", spec.relTableName.c_str());
112
+ first = false;
113
+ }
114
+ }
115
+ fprintf(stderr, "]");
116
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::COUNT_ANTI_EDGE_CHAIN) {
117
+ auto& count = op->constCast<planner::LogicalCountAntiEdgeChain>();
118
+ fprintf(stderr,
119
+ " [%s antiDir=%s hops=%llu id<>=", count.getAntiRelEntry()->getName().c_str(),
120
+ common::ExtendDirectionUtil::toString(count.getAntiEdgeDir()).c_str(),
121
+ (unsigned long long)count.getSuffixHops().size());
122
+ fprintf(stderr, "%s]", count.getHasNotEquals() ? "true" : "false");
123
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::REACHABLE_COUNT) {
124
+ auto& count = op->constCast<planner::LogicalReachableCount>();
125
+ fprintf(stderr, " [%s dir=%s bound=%s nbr=%s range=%u..%u]",
126
+ count.getRelGroupEntry()->getName().c_str(),
127
+ common::ExtendDirectionUtil::toString(count.getDirection()).c_str(),
128
+ count.getBoundNode()->getUniqueName().c_str(),
129
+ count.getNbrNode()->getUniqueName().c_str(), count.getLowerBound(),
130
+ count.getUpperBound());
131
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::REL_DEGREE_TABLE) {
132
+ auto& degree = op->constCast<planner::LogicalRelDegreeTable>();
133
+ fprintf(stderr, " [%s dir=%s mode=%s]", degree.getRelGroupEntry()->getName().c_str(),
134
+ common::ExtendDirectionUtil::toString(degree.getDirection()).c_str(),
135
+ degree.getMode() == planner::RelDegreeTableMode::ACTIVE_BOUND_COUNT ?
136
+ "ACTIVE_BOUND_COUNT" :
137
+ degree.getMode() == planner::RelDegreeTableMode::TOP_K_DEGREES ? "TOP_K_DEGREES" :
138
+ "OFFSET_COUNT");
139
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::QUERY_PRIMARY_KEY_LOOKUP) {
140
+ auto& lookup = op->constCast<planner::LogicalQueryPrimaryKeyLookup>();
141
+ fprintf(stderr, " [table=%llu key=%s]", (unsigned long long)lookup.getTableID(),
142
+ lookup.getKey()->toString().c_str());
143
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::UNWIND_DEDUPLICATE) {
144
+ auto& dedup = op->constCast<planner::LogicalUnwindDeduplicate>();
145
+ fprintf(stderr, " [keys=%s]",
146
+ binder::ExpressionUtil::toString(dedup.getKeyExpressions()).c_str());
147
+ } else if (op->getOperatorType() == planner::LogicalOperatorType::PARTITIONER) {
148
+ fprintf(stderr, " [keys=%llu]",
149
+ (unsigned long long)op->constCast<planner::LogicalPartitioner>()
150
+ .getInfo()
151
+ .getNumInfos());
152
+ } else {
153
+ // Generic fallback for operators without a dedicated handler above.
154
+ auto exprs = op->getExpressionsForPrinting();
155
+ if (!exprs.empty()) {
156
+ fprintf(stderr, " [%s]", exprs.c_str());
157
+ }
91
158
  }
92
159
  fprintf(stderr, "\n");
93
160
  for (auto i = 0u; i < op->getNumChildren(); ++i) {
@@ -24,6 +24,8 @@ std::string LogicalOperatorUtils::logicalOperatorTypeToString(LogicalOperatorTyp
24
24
  return "COPY_FROM";
25
25
  case LogicalOperatorType::COPY_TO:
26
26
  return "COPY_TO";
27
+ case LogicalOperatorType::COUNT_ANTI_EDGE_CHAIN:
28
+ return "COUNT_ANTI_EDGE_CHAIN";
27
29
  case LogicalOperatorType::COUNT_EXTEND_CHAIN:
28
30
  return "COUNT_EXTEND_CHAIN";
29
31
  case LogicalOperatorType::COUNT_REL_TABLE:
@@ -132,9 +134,11 @@ std::string LogicalOperatorUtils::logicalOperatorTypeToString(LogicalOperatorTyp
132
134
  return "EXTENSION_CLAUSE";
133
135
  case LogicalOperatorType::UNWIND_DEDUPLICATE:
134
136
  return "UNWIND_DEDUPLICATE";
135
- default:
136
- throw RuntimeException("Unknown logical operator type.");
137
137
  }
138
+ // No default label: -Wswitch then enforces that every LogicalOperatorType enumerator is
139
+ // handled above, so adding a new operator fails the build here instead of failing at
140
+ // runtime. The throw only guards against out-of-range values (e.g. deserialized data).
141
+ throw RuntimeException("Unknown logical operator type.");
138
142
  }
139
143
  // LCOV_EXCL_STOP
140
144
 
@@ -6,6 +6,7 @@
6
6
  #include "processor/operator/sink.h"
7
7
  #include "processor/physical_plan.h"
8
8
  #include "processor/processor_task.h"
9
+ #include "processor/result/result_set.h"
9
10
 
10
11
  using namespace lbug::common;
11
12
  using namespace lbug::storage;
@@ -13,6 +14,37 @@ using namespace lbug::storage;
13
14
  namespace lbug {
14
15
  namespace processor {
15
16
 
17
+ std::shared_ptr<ResultSet> ResultSetPool::getOrCreate(ResultSetDescriptor* descriptor,
18
+ MemoryManager* memoryManager) {
19
+ const auto threadID = std::this_thread::get_id();
20
+ std::shared_ptr<ResultSet> reusable;
21
+ {
22
+ std::lock_guard lock{mtx};
23
+ auto& entry = entries[threadID];
24
+ if (entry.resultSet && entry.descriptorID == descriptor->id) {
25
+ reusable = entry.resultSet;
26
+ }
27
+ }
28
+ if (reusable) {
29
+ // Same prepared statement on this thread: reuse the allocation. Only this thread touches
30
+ // its slot's ResultSet, so the reset happens after the lock is released.
31
+ reusable->resetForReuse();
32
+ return reusable;
33
+ }
34
+ // First time on this thread, or a different prepared statement: allocate fresh outside the
35
+ // lock, then swap it in and release the previous ResultSet after unlocking.
36
+ auto resultSet = std::make_shared<ResultSet>(descriptor, memoryManager);
37
+ std::shared_ptr<ResultSet> previous;
38
+ {
39
+ std::lock_guard lock{mtx};
40
+ auto& entry = entries[threadID];
41
+ previous = std::move(entry.resultSet);
42
+ entry.resultSet = resultSet;
43
+ entry.descriptorID = descriptor->id;
44
+ }
45
+ return resultSet;
46
+ }
47
+
16
48
  #if defined(__APPLE__)
17
49
  QueryProcessor::QueryProcessor(uint64_t numThreads, uint32_t threadQos) {
18
50
  taskScheduler = std::make_unique<TaskScheduler>(numThreads, threadQos);
@@ -2,8 +2,10 @@
2
2
 
3
3
  #include "common/task_system/progress_bar.h"
4
4
  #include "main/client_context.h"
5
+ #include "main/database.h"
5
6
  #include "main/settings.h"
6
7
  #include "processor/execution_context.h"
8
+ #include "processor/processor.h"
7
9
  #include "processor/result/result_set.h"
8
10
  #include "storage/buffer_manager/memory_manager.h"
9
11
 
@@ -33,30 +35,21 @@ void ProcessorTask::run() {
33
35
  // for i in range(n): conn.execute("RETURN $i", {"i": i})
34
36
  // that's a 16KB+ calloc on every iteration.
35
37
  //
36
- // We instead keep a thread-local shared_ptr<ResultSet> whose descriptor
37
- // matches this sink's, so each thread of a multi-threaded task gets
38
- // its own allocation-free slot. The thread owns the ResultSet outright
39
- // (no aliasing), so the lifetime is straightforward: dropped when the
40
- // thread exits, or when a different prepared statement runs on this
41
- // thread and the descriptor pointer no longer matches.
38
+ // The reusable ResultSet is cached per executing thread in the database-owned
39
+ // ResultSetPool (see QueryProcessor), never in a process-wide thread_local: the cached
40
+ // vectors' buffers belong to this database's MemoryManager, and a thread_local slot would
41
+ // outlive Database::~Database and free (or reuse) them through a dangling MemoryManager the
42
+ // next time any database runs a query on this thread. Holding the shared_ptr here keeps the
43
+ // ResultSet alive even if a nested query on this thread replaces the slot.
42
44
  ResultSet* resultSetPtr = nullptr;
43
45
  std::unique_ptr<ResultSet> ownedResultSet;
46
+ std::shared_ptr<ResultSet> pooledResultSet;
47
+ auto* clientContext = executionContext->clientContext;
44
48
  if (auto* desc = sink->getDescriptor()) {
45
- thread_local uint64_t cachedDescID = UINT64_MAX;
46
- thread_local std::shared_ptr<processor::ResultSet> cachedResultSet;
47
- if (cachedDescID == desc->id && cachedResultSet) {
48
- // Same prepared statement on this thread: reuse the allocation.
49
- cachedResultSet->resetForReuse();
50
- resultSetPtr = cachedResultSet.get();
51
- } else {
52
- // First time on this thread, or a different prepared statement:
53
- // allocate fresh. Owning shared_ptr; lifetime ends with the
54
- // thread (or when the descriptor changes).
55
- cachedResultSet = std::make_shared<processor::ResultSet>(desc,
56
- storage::MemoryManager::Get(*executionContext->clientContext));
57
- cachedDescID = desc->id;
58
- resultSetPtr = cachedResultSet.get();
59
- }
49
+ pooledResultSet =
50
+ clientContext->getDatabase()->getQueryProcessor()->getResultSetPool().getOrCreate(desc,
51
+ storage::MemoryManager::Get(*clientContext));
52
+ resultSetPtr = pooledResultSet.get();
60
53
  } else {
61
54
  // No descriptor (e.g. OrderByMerge): fall back to per-call allocation.
62
55
  ownedResultSet =
@@ -28,8 +28,8 @@ std::unique_ptr<ResultSetDescriptor> ResultSetDescriptor::copy() const {
28
28
  std::make_unique<ResultSetDescriptor>(std::move(dataChunkDescriptorsCopy));
29
29
  // A copy is the same descriptor slot with identical content (copies are never mutated
30
30
  // afterwards), so it inherits the source's identity. This keeps the id stable across
31
- // executions of a prepared statement's cached plan, letting the thread-local ResultSet
32
- // cache in ProcessorTask::run() actually hit. Two live descriptors with the same id are
31
+ // executions of a prepared statement's cached plan, letting the per-database ResultSetPool
32
+ // used by ProcessorTask::run() actually hit. Two live descriptors with the same id are
33
33
  // by construction content-identical, so cache reuse remains safe.
34
34
  descriptorCopy->id = id;
35
35
  return descriptorCopy;
@@ -3,6 +3,7 @@
3
3
  #include "catalog/catalog_entry/node_table_catalog_entry.h"
4
4
  #include "common/cast.h"
5
5
  #include "common/exception/message.h"
6
+ #include "common/exception/runtime.h"
6
7
  #include "common/types/types.h"
7
8
  #include "common/types/value/value.h"
8
9
  #include "storage/index/hash_index.h"
@@ -42,9 +43,19 @@ void LocalNodeTable::initLocalHashIndex(MemoryManager& mm) {
42
43
  }
43
44
 
44
45
  bool LocalNodeTable::isVisible(const Transaction* transaction, offset_t offset) const {
46
+ if (offset < startOffset) {
47
+ throw RuntimeException(
48
+ "Primary-key index contains an invalid local node offset. Please drop and rebuild "
49
+ "the _PK index.");
50
+ }
45
51
  auto [nodeGroupIdx, offsetInGroup] =
46
52
  StorageUtils::getNodeGroupIdxAndOffsetInChunk(offset - startOffset);
47
- auto* nodeGroup = nodeGroups.getNodeGroup(nodeGroupIdx);
53
+ auto* nodeGroup = nodeGroups.getNodeGroup(nodeGroupIdx, true /*mayOutOfBound*/);
54
+ if (nodeGroup == nullptr || offsetInGroup >= nodeGroup->getNumRows()) {
55
+ throw RuntimeException(
56
+ "Primary-key index contains an invalid local node offset. Please drop and rebuild "
57
+ "the _PK index.");
58
+ }
48
59
  if (nodeGroup->isDeleted(transaction, offsetInGroup)) {
49
60
  return false;
50
61
  }
@@ -211,24 +211,19 @@ void StorageManager::createNodeTable(NodeTableCatalogEntry* entry, main::ClientC
211
211
  // Extract Arrow ID from storage string
212
212
  std::string arrowId = entry->getStorage().substr(8);
213
213
 
214
- // Retrieve Arrow data from registry (as pointers to registry data)
215
- ArrowSchemaWrapper* schema = nullptr;
216
- std::vector<ArrowArrayWrapper>* arrays = nullptr;
217
- if (!ArrowTableSupport::getArrowData(arrowId, schema, arrays)) {
214
+ // Retrieve Arrow data from registry. The returned shared_ptr pins
215
+ // the entry's lifetime past the registry lock, so a concurrent
216
+ // DROP TABLE / unregister cannot free the buffers while we build
217
+ // the table's shallow views below (issue #933).
218
+ auto arrowData = ArrowTableSupport::getArrowData(arrowId);
219
+ if (!arrowData) {
218
220
  throw common::RuntimeException("Failed to retrieve Arrow data for ID: " + arrowId);
219
221
  }
220
222
 
221
- // Create wrappers that reference registry memory while registry keeps ownership.
222
- ArrowSchemaWrapper schemaCopy = createShallowCopy(*schema);
223
- std::vector<ArrowArrayWrapper> arraysCopy;
224
- arraysCopy.reserve(arrays->size());
225
- for (const auto& arr : *arrays) {
226
- arraysCopy.push_back(createShallowCopy(arr));
227
- }
228
-
229
- // Create Arrow-backed node table
223
+ // Create Arrow-backed node table (it keeps its own pin plus
224
+ // shallow non-owning views into the pinned data).
230
225
  tables[entry->getTableID()] = std::make_unique<ArrowNodeTable>(this, entry,
231
- &memoryManager, std::move(schemaCopy), std::move(arraysCopy), arrowId);
226
+ &memoryManager, std::move(arrowData), arrowId);
232
227
  } else {
233
228
  throw common::RuntimeException(
234
229
  "Unsupported storage option for node table: " + entry->getStorage());
@@ -262,8 +257,8 @@ void StorageManager::addRelTable(RelGroupCatalogEntry* entry, const RelTableCata
262
257
  } else if (!entry->getStorage().empty()) {
263
258
  if (entry->getStorage().substr(0, 8) == "arrow://") {
264
259
  std::string arrowId = entry->getStorage().substr(8);
265
- ArrowRelTableData* relData = nullptr;
266
- if (!ArrowTableSupport::getArrowRelData(arrowId, relData)) {
260
+ auto relData = ArrowTableSupport::getArrowRelData(arrowId);
261
+ if (!relData) {
267
262
  throw common::RuntimeException("Failed to retrieve Arrow data for ID: " + arrowId);
268
263
  }
269
264
  if (!tables.contains(info.nodePair.srcTableID) ||
@@ -277,23 +272,9 @@ void StorageManager::addRelTable(RelGroupCatalogEntry* entry, const RelTableCata
277
272
  throw common::RuntimeException(
278
273
  "Arrow rel table currently supports only regular node tables");
279
274
  }
280
- ArrowSchemaWrapper schemaCopy = createShallowCopy(relData->schema);
281
- std::vector<ArrowArrayWrapper> arraysCopy;
282
- arraysCopy.reserve(relData->arrays.size());
283
- for (const auto& arr : relData->arrays) {
284
- arraysCopy.push_back(createShallowCopy(arr));
285
- }
286
- ArrowSchemaWrapper indptrSchemaCopy = createShallowCopy(relData->indptrSchema);
287
- std::vector<ArrowArrayWrapper> indptrArraysCopy;
288
- indptrArraysCopy.reserve(relData->indptrArrays.size());
289
- for (const auto& arr : relData->indptrArrays) {
290
- indptrArraysCopy.push_back(createShallowCopy(arr));
291
- }
292
275
  tables[info.oid] = std::make_unique<ArrowRelTable>(entry, info.nodePair.srcTableID,
293
276
  info.nodePair.dstTableID, this, &memoryManager, fromNodeTable, toNodeTable,
294
- relData->layout, std::move(schemaCopy), std::move(arraysCopy),
295
- std::move(indptrSchemaCopy), std::move(indptrArraysCopy), arrowId,
296
- relData->dstColumnName);
277
+ std::move(relData), arrowId);
297
278
  } else {
298
279
  throw common::RuntimeException(
299
280
  "Unsupported storage option for rel table: " + entry->getStorage());
@@ -41,11 +41,21 @@ static std::vector<std::optional<common::ArrowLogicalTypeInfo>> resolveColumnLog
41
41
 
42
42
  ArrowNodeTable::ArrowNodeTable(const StorageManager* storageManager,
43
43
  const catalog::NodeTableCatalogEntry* nodeTableEntry, MemoryManager* memoryManager,
44
- ArrowSchemaWrapper schema, std::vector<ArrowArrayWrapper> arrays, std::string arrowId)
44
+ std::shared_ptr<ArrowTableData> arrowData, std::string arrowId)
45
45
  : ColumnarNodeTableBase{storageManager, nodeTableEntry, memoryManager,
46
46
  std::make_unique<ArrowNodeTableScanSharedState>(scanMorselSize)},
47
- schema{std::move(schema)}, arrays{std::move(arrays)}, totalRows{0},
48
- arrowId{std::move(arrowId)} {
47
+ arrowData{std::move(arrowData)}, totalRows{0}, arrowId{std::move(arrowId)} {
48
+ if (!this->arrowData) {
49
+ throw common::RuntimeException("Arrow data is null");
50
+ }
51
+ // Shallow non-owning views into the pinned registry data. Safe because
52
+ // `arrowData` keeps the underlying Arrow buffers alive for the table's
53
+ // lifetime, even after the registry entry is erased.
54
+ this->schema = createShallowCopy(this->arrowData->schema);
55
+ this->arrays.reserve(this->arrowData->arrays.size());
56
+ for (const auto& array : this->arrowData->arrays) {
57
+ this->arrays.push_back(createShallowCopy(array));
58
+ }
49
59
  // Note: release may be nullptr if schema is managed by registry
50
60
  if (!this->schema.format) {
51
61
  throw common::RuntimeException("Arrow schema format cannot be null");
@@ -108,14 +108,27 @@ void ArrowRelTableScanState::setToTable(const transaction::Transaction* transact
108
108
 
109
109
  ArrowRelTable::ArrowRelTable(catalog::RelGroupCatalogEntry* relGroupEntry, table_id_t fromTableID,
110
110
  table_id_t toTableID, const StorageManager* storageManager, MemoryManager* memoryManager,
111
- const NodeTable* fromNodeTable, const NodeTable* toNodeTable, ArrowRelTableLayout layout,
112
- ArrowSchemaWrapper schema, std::vector<ArrowArrayWrapper> arrays,
113
- ArrowSchemaWrapper indptrSchema, std::vector<ArrowArrayWrapper> indptrArrays,
114
- std::string arrowId, std::string dstColumnName)
111
+ const NodeTable* fromNodeTable, const NodeTable* toNodeTable,
112
+ std::shared_ptr<ArrowRelTableData> arrowData, std::string arrowId)
115
113
  : ColumnarRelTableBase{relGroupEntry, fromTableID, toTableID, storageManager, memoryManager},
116
- fromNodeTable{fromNodeTable}, toNodeTable{toNodeTable}, layout{layout},
117
- schema{std::move(schema)}, arrays{std::move(arrays)}, indptrSchema{std::move(indptrSchema)},
118
- indptrArrays{std::move(indptrArrays)}, arrowId{std::move(arrowId)} {
114
+ fromNodeTable{fromNodeTable}, toNodeTable{toNodeTable}, arrowData{std::move(arrowData)},
115
+ arrowId{std::move(arrowId)} {
116
+ if (!this->arrowData) {
117
+ throw RuntimeException("Arrow relationship data is null");
118
+ }
119
+ // Shallow non-owning views into the pinned registry data (see ArrowNodeTable).
120
+ this->layout = this->arrowData->layout;
121
+ const std::string dstColumnName = this->arrowData->dstColumnName;
122
+ this->schema = createShallowCopy(this->arrowData->schema);
123
+ this->arrays.reserve(this->arrowData->arrays.size());
124
+ for (const auto& array : this->arrowData->arrays) {
125
+ this->arrays.push_back(createShallowCopy(array));
126
+ }
127
+ this->indptrSchema = createShallowCopy(this->arrowData->indptrSchema);
128
+ this->indptrArrays.reserve(this->arrowData->indptrArrays.size());
129
+ for (const auto& array : this->arrowData->indptrArrays) {
130
+ this->indptrArrays.push_back(createShallowCopy(array));
131
+ }
119
132
  if (!this->schema.format) {
120
133
  throw RuntimeException("Arrow schema format cannot be null");
121
134
  }
@@ -12,16 +12,18 @@ namespace lbug {
12
12
 
13
13
  // Global registry for Arrow table data
14
14
  // Memory Management:
15
- // - Registry owns the Arrow data (ArrowSchemaWrapper/ArrowArrayWrapper with release callbacks)
16
- // - Arrow-backed tables store shallow copies (no release callbacks) and the arrowId
15
+ // - Registry owns the Arrow data via shared_ptr (ArrowSchemaWrapper/ArrowArrayWrapper with
16
+ // release callbacks). getArrowData()/getArrowRelData() return a shared_ptr copy taken under
17
+ // the mutex, so callers pin the data past the lock scope; erasing the map entry only drops
18
+ // the registry's reference while live tables keep buffers valid (issue #933).
19
+ // - Arrow-backed tables hold a shared_ptr pin plus shallow copies (no release callbacks) and
20
+ // the arrowId
17
21
  // - When a table is dropped (via DROP TABLE or unregisterArrowTable), the table's
18
22
  // destructor automatically calls unregisterArrowData to clean up the registry entry
19
23
  // - The wrappers' destructors call the release callbacks to free the actual Arrow memory
20
24
  static std::mutex g_arrowRegistryMutex;
21
- static std::unordered_map<std::string,
22
- std::pair<ArrowSchemaWrapper, std::vector<ArrowArrayWrapper>>>
23
- g_arrowRegistry;
24
- static std::unordered_map<std::string, ArrowRelTableData> g_arrowRelRegistry;
25
+ static std::unordered_map<std::string, std::shared_ptr<ArrowTableData>> g_arrowRegistry;
26
+ static std::unordered_map<std::string, std::shared_ptr<ArrowRelTableData>> g_arrowRelRegistry;
25
27
 
26
28
  std::string join(const std::vector<std::string>& strings, const std::string& delimiter) {
27
29
  if (strings.empty())
@@ -58,8 +60,11 @@ std::string ArrowTableSupport::registerArrowData(ArrowSchemaWrapper schema,
58
60
  static size_t nextId = 0;
59
61
  std::string id = "arrow_" + std::to_string(nextId++);
60
62
 
61
- // Store in registry
62
- g_arrowRegistry[id] = std::make_pair(std::move(schema), std::move(arrays));
63
+ // Store in registry (shared ownership so live tables can pin the data).
64
+ auto entry = std::make_shared<ArrowTableData>();
65
+ entry->schema = std::move(schema);
66
+ entry->arrays = std::move(arrays);
67
+ g_arrowRegistry[id] = std::move(entry);
63
68
 
64
69
  return id;
65
70
  }
@@ -69,34 +74,33 @@ std::string ArrowTableSupport::registerArrowRelData(ArrowRelTableData data) {
69
74
 
70
75
  static size_t nextRelId = 0;
71
76
  std::string id = "arrow_rel_" + std::to_string(nextRelId++);
72
- g_arrowRelRegistry[id] = std::move(data);
77
+ auto entry = std::make_shared<ArrowRelTableData>(std::move(data));
78
+ g_arrowRelRegistry[id] = std::move(entry);
73
79
  return id;
74
80
  }
75
81
 
76
- bool ArrowTableSupport::getArrowData(const std::string& id, ArrowSchemaWrapper*& schema,
77
- std::vector<ArrowArrayWrapper>*& arrays) {
82
+ std::shared_ptr<ArrowTableData> ArrowTableSupport::getArrowData(const std::string& id) {
78
83
  std::lock_guard<std::mutex> lock(g_arrowRegistryMutex);
79
84
 
80
85
  auto it = g_arrowRegistry.find(id);
81
86
  if (it == g_arrowRegistry.end()) {
82
- return false;
87
+ return nullptr;
83
88
  }
84
89
 
85
- // Return pointers to the data in the registry (not copies)
86
- schema = &it->second.first;
87
- arrays = &it->second.second;
88
- return true;
90
+ // Copy the shared_ptr under the lock: the caller pins the data's lifetime
91
+ // past the lock scope, so a concurrent unregisterArrowData() (erase)
92
+ // cannot free the buffers out from under it.
93
+ return it->second;
89
94
  }
90
95
 
91
- bool ArrowTableSupport::getArrowRelData(const std::string& id, ArrowRelTableData*& data) {
96
+ std::shared_ptr<ArrowRelTableData> ArrowTableSupport::getArrowRelData(const std::string& id) {
92
97
  std::lock_guard<std::mutex> lock(g_arrowRegistryMutex);
93
98
 
94
99
  auto it = g_arrowRelRegistry.find(id);
95
100
  if (it == g_arrowRelRegistry.end()) {
96
- return false;
101
+ return nullptr;
97
102
  }
98
- data = &it->second;
99
- return true;
103
+ return it->second;
100
104
  }
101
105
 
102
106
  void ArrowTableSupport::unregisterArrowData(const std::string& id) {
@@ -877,6 +877,9 @@ bool NodeTable::isVisibleNoLock(const Transaction* transaction, offset_t offset)
877
877
  return false;
878
878
  }
879
879
  const auto* nodeGroup = getNodeGroupNoLock(nodeGroupIdx);
880
+ if (nodeGroup == nullptr) {
881
+ return false;
882
+ }
880
883
  return nodeGroup->isVisibleNoLock(transaction, offsetInGroup);
881
884
  }
882
885
 
@@ -928,7 +931,32 @@ bool NodeTable::lookupPK(const Transaction* transaction, ValueVector* keyVector,
928
931
  }
929
932
  if (auto* pkIndex = tryGetPrimaryKeyIndex()) {
930
933
  return pkIndex->lookupPrimaryKey(transaction, keyVector, vectorPos, result,
931
- [&](offset_t offset) { return isVisibleNoLock(transaction, offset); });
934
+ [&](offset_t offset) {
935
+ // A stale/corrupt PK index entry must not reach isVisibleNoLock(), where an
936
+ // invalid node offset could otherwise be dereferenced in a release build. Keep
937
+ // this validation O(1) on the normal lookup path and fail the transaction instead
938
+ // of treating a corrupt entry as a missing key.
939
+ if (offset == INVALID_OFFSET) {
940
+ throw RuntimeException(
941
+ "Primary-key index contains an invalid node offset. Please drop and "
942
+ "rebuild the _PK index.");
943
+ }
944
+ const auto nodeGroupIdx = StorageUtils::getNodeGroupIdx(offset);
945
+ if (nodeGroupIdx >= nodeGroups->getNumNodeGroupsNoLock()) {
946
+ throw RuntimeException(
947
+ "Primary-key index points to a missing node group. Please drop and "
948
+ "rebuild the _PK index.");
949
+ }
950
+ const auto* nodeGroup = getNodeGroupNoLock(nodeGroupIdx);
951
+ const auto offsetInGroup =
952
+ offset - StorageUtils::getStartOffsetOfNodeGroup(nodeGroupIdx);
953
+ if (nodeGroup == nullptr || offsetInGroup >= nodeGroup->getNumRows()) {
954
+ throw RuntimeException(
955
+ "Primary-key index contains an out-of-range node offset. Please drop "
956
+ "and rebuild the _PK index.");
957
+ }
958
+ return isVisibleNoLock(transaction, offset);
959
+ });
932
960
  }
933
961
  auto keyToLookup = keyVector->getAsValue(vectorPos);
934
962
  ColumnPredicateSet predicateSet;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ladybug
3
- Version: 0.20.2.dev20260907
3
+ Version: 0.20.2.dev20260908
4
4
  Summary: Highly scalable, extremely fast, easy-to-use embeddable graph database
5
5
  Home-page: https://github.com/LadybugDB/ladybug
6
6
  License: MIT
sdist/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ description = "Highly scalable, extremely fast, easy-to-use embeddable graph dat
4
4
  readme = "README.md"
5
5
  license = { text = "MIT" }
6
6
  keywords = ["graph", "database"]
7
- version = "0.20.2.dev20260907"
7
+ version = "0.20.2.dev20260908"
8
8
  requires-python = ">=3.10,<3.15"
9
9
 
10
10
  [project.urls]