From df1c1feff14d91d129287e477b9d76852d8921d9 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sat, 17 Oct 2026 16:38:32 -0700 Subject: [PATCH] fix(storage): read CSR length via Column in getDegreeForOffset Persistent CSR length chunks are ResidencyState::ON_DISK, so reading them via ColumnChunk::getValue returns uninitialized memory (garbage degrees / 0). Scan the length through its Column like forEachDegree/CountRelTable instead, under captureScanPin so parked readers get their retained snapshot. Also count uncommitted local rels even when the node group has no committed CSR group. Fixes #1292 --- src/storage/table/rel_table.cpp | 51 +++++++++++++++++++++++--------- test/storage/rel_degree_test.cpp | 6 ++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/storage/table/rel_table.cpp b/src/storage/table/rel_table.cpp index fa4980c9b..f91b2e4f7 100644 --- a/src/storage/table/rel_table.cpp +++ b/src/storage/table/rel_table.cpp @@ -887,30 +887,53 @@ row_idx_t RelTable::getDegreeForOffset(const Transaction* transaction, RelDataDi const auto nodeGroupIdx = StorageUtils::getNodeGroupIdx(nodeOffset); const auto offsetInGroup = nodeOffset - StorageUtils::getStartOffsetOfNodeGroup(nodeGroupIdx); auto* relTableData = getDirectedTableData(direction); + // Uncommitted rels may target node groups with no committed CSR node group yet + // (see forEachDegree's handling of local-only groups): resolve them first so the + // early returns below don't drop them. + row_idx_t localCount = 0; + if (transaction->isWriteTransaction()) { + if (auto* localTable = transaction->getLocalStorage()->getLocalTable(*this)) { + auto& localCSRIndex = localTable->cast().getCSRIndex(direction); + if (auto it = localCSRIndex.find(nodeOffset); it != localCSRIndex.end()) { + localCount += it->second.size(); + } + } + } if (nodeGroupIdx >= relTableData->getNumNodeGroups()) { - return 0; + return localCount; } auto* nodeGroup = relTableData->getNodeGroup(nodeGroupIdx); if (!nodeGroup) { - return 0; + return localCount; } auto& csrNodeGroup = nodeGroup->cast(); row_idx_t count = 0; - if (auto* persistentGroup = csrNodeGroup.getPersistentChunkedGroup()) { - count += - persistentGroup->cast().getCSRHeader().getCSRLength(offsetInGroup); - } - if (const auto* csrIndex = csrNodeGroup.getCSRIndex()) { - count += csrIndex->getNumRows(offsetInGroup); - } - if (transaction->isWriteTransaction()) { - if (auto* localTable = transaction->getLocalStorage()->getLocalTable(*this)) { - auto& localCSRIndex = localTable->cast().getCSRIndex(direction); - if (auto it = localCSRIndex.find(nodeOffset); it != localCSRIndex.end()) { - count += it->second.size(); + // The persistent CSR header chunks are ON_DISK: their in-memory buffers are not + // positionally valid. Reading them directly via ChunkedCSRHeader::getCSRLength (i.e. + // ColumnChunk::getValue) returns uninitialized memory (see #1292). Scan the length + // through its Column like every other reader (forEachDegree, CountRelTable) instead. + // Pin under the group lock so parked readers resolve their retained snapshot. + const auto pin = csrNodeGroup.captureScanPin(transaction); + if (auto* persistentGroup = pin.persistent.get()) { + auto& csrHeader = persistentGroup->cast().getCSRHeader(); + const auto numNodes = csrHeader.length->getNumValues(); + if (offsetInGroup < numNodes) { + auto lengthChunk = ColumnChunkFactory::createColumnChunkData(*memoryManager, + common::LogicalType::UINT64(), false /*enableCompression*/, 1, + ResidencyState::IN_MEMORY, false /*hasNullData*/, false /*initializeToZero*/); + ChunkState chunkState; + auto* csrLengthColumn = relTableData->getCSRLengthColumn(); + csrHeader.length->initializeScanState(chunkState, csrLengthColumn); + csrLengthColumn->scan(chunkState, lengthChunk.get(), offsetInGroup, 1); + if (lengthChunk->getNumValues() == 1) { + count += lengthChunk->getValue(0); } } } + if (pin.index) { + count += pin.index->getNumRows(offsetInGroup); + } + count += localCount; return count; } diff --git a/test/storage/rel_degree_test.cpp b/test/storage/rel_degree_test.cpp index 9a97941c5..509ef8522 100644 --- a/test/storage/rel_degree_test.cpp +++ b/test/storage/rel_degree_test.cpp @@ -67,6 +67,12 @@ class RelDegreeTest : public EmptyDBTest { auto entries = table.getDegreeEntries(transaction, direction); EXPECT_TRUE(std::is_sorted(entries.begin(), entries.end())); EXPECT_EQ(entries, expected); + // getDegreeForOffset must agree with the entries, including for checkpointed + // (on-disk CSR header) data read before any scan pins the pages (#1292). + for (const auto& [offset, degree] : expected) { + EXPECT_EQ(table.getDegreeForOffset(transaction, direction, offset), degree) + << "offset " << offset; + } EXPECT_EQ(table.getNumActiveBoundNodes(transaction, direction), expected.size());