Skip to content

Commit e37a74e

Browse files
pomagrenateclaude
andcommitted
Harden graph membrane: WAL replay, deletion API, memory accounting, O(1) seen-set
- Wire WarmUp() in GraphMembraneImpl to replay WAL via Wal::ReplayGraphInto, fixing silent data loss on every restart - Add DeleteVertex/DeleteEdge with tombstone keys ('T'/'X' prefixes) written to WAL so deletions survive restarts - Track bytes_used_ in GraphMembraneImpl for quota reporting via GetQuota() - Fix FlushAll/CloseAll in MembraneManager to flush the graph WAL on shutdown - Add kDeleteVertex/kDeleteEdge opcodes through the full kernel/message path - Expose DeleteVertex/DeleteEdge in public C++ and C APIs - Fix O(n²) seen-set in QueryOrchestrator: replace std::vector linear scan with std::unordered_set for O(1) multi-hop traversal - Fix latent Corruption return in Wal::ReplayInto for kRawKV records - Add graph_persistence_test (5 tests) and graph_memory_accounting_test (6 tests) - Add 4 new integration tests in db_error_paths_test All 92 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 71a66a2 commit e37a74e

21 files changed

Lines changed: 754 additions & 35 deletions

CMakeLists.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,14 @@ if (POMAI_BUILD_TESTS)
440440
pomai_setup_test(graph_rag_test)
441441
pomai_add_labeled_test(graph_rag_test "unit")
442442

443+
add_executable(graph_persistence_test tests/unit/graph_persistence_test.cc)
444+
pomai_setup_test(graph_persistence_test)
445+
pomai_add_labeled_test(graph_persistence_test "unit")
446+
447+
add_executable(graph_memory_accounting_test tests/unit/graph_memory_accounting_test.cc)
448+
pomai_setup_test(graph_memory_accounting_test)
449+
pomai_add_labeled_test(graph_memory_accounting_test "unit")
450+
443451
add_executable(rag_chunking_test tests/unit/rag_chunking_test.cc)
444452
pomai_setup_test(rag_chunking_test)
445453
pomai_add_labeled_test(rag_chunking_test "unit")

include/pomai/c_api.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ POMAI_API pomai_status_t* pomai_graph_add_vertex(pomai_db_t* db, pomai_vertex_id
7373
POMAI_API pomai_status_t* pomai_graph_add_edge(pomai_db_t* db, pomai_vertex_id_t src, pomai_vertex_id_t dst, pomai_edge_type_t type, uint32_t rank, const uint8_t* metadata, size_t metadata_len);
7474
POMAI_API pomai_status_t* pomai_graph_get_neighbors(pomai_db_t* db, pomai_vertex_id_t src, pomai_neighbor_t** out_neighbors, size_t* out_count);
7575
POMAI_API void pomai_graph_neighbors_free(pomai_neighbor_t* neighbors);
76+
POMAI_API pomai_status_t* pomai_graph_delete_vertex(pomai_db_t* db, pomai_vertex_id_t id);
77+
POMAI_API pomai_status_t* pomai_graph_delete_edge(pomai_db_t* db, pomai_vertex_id_t src, pomai_vertex_id_t dst, pomai_edge_type_t type);
7678

7779
// Multi-modal search
7880
POMAI_API pomai_status_t* pomai_search_multi_modal(pomai_db_t* db, const pomai_multi_modal_query_t* query, pomai_search_results_t** out);

include/pomai/database.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ class Database {
187187
/** Graph Operations */
188188
Status AddVertex(VertexId id, TagId tag, const Metadata& meta);
189189
Status AddEdge(VertexId src, VertexId dst, EdgeType type, uint32_t rank, const Metadata& meta);
190+
Status DeleteVertex(VertexId id);
191+
Status DeleteEdge(VertexId src, VertexId dst, EdgeType type);
190192
Status GetNeighbors(VertexId src, std::vector<Neighbor>* out);
191193
Status GetNeighbors(VertexId src, EdgeType type, std::vector<Neighbor>* out);
192194

include/pomai/graph.h

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,22 @@ class GraphMembrane {
3232
virtual Status AddVertex(VertexId id, TagId tag, const Metadata& meta) = 0;
3333
virtual Status AddEdge(VertexId src, VertexId dst, EdgeType type, uint32_t rank, const Metadata& meta) = 0;
3434

35+
/** Deletion */
36+
virtual Status DeleteVertex(VertexId id) = 0;
37+
virtual Status DeleteEdge(VertexId src, VertexId dst, EdgeType type) = 0;
38+
3539
/** Query */
3640
virtual Status GetNeighbors(VertexId src, std::vector<Neighbor>* out) = 0;
3741
virtual Status GetNeighbors(VertexId src, EdgeType type, std::vector<Neighbor>* out) = 0;
38-
42+
3943
/** Maintenance */
4044
virtual Status Flush() = 0;
45+
46+
/**
47+
* Called during database open to replay persisted state (e.g. WAL).
48+
* Default is a no-op; implementations that have durable storage override this.
49+
*/
50+
virtual Status WarmUp() { return Status::Ok(); }
4151
};
4252

4353
} // namespace pomai

include/pomai/pomai.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ namespace pomai
151151
// Graph API
152152
virtual Status AddVertex(VertexId id, TagId tag, const Metadata& meta) = 0;
153153
virtual Status AddEdge(VertexId src, VertexId dst, EdgeType type, uint32_t rank, const Metadata& meta) = 0;
154+
virtual Status DeleteVertex(VertexId id) = 0;
155+
virtual Status DeleteEdge(VertexId src, VertexId dst, EdgeType type) = 0;
154156
virtual Status GetNeighbors(VertexId src, std::vector<Neighbor>* out) = 0;
155157
virtual Status GetNeighbors(VertexId src, EdgeType type, std::vector<Neighbor>* out) = 0;
156158

src/capi/capi_db.cc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,16 @@ void pomai_graph_neighbors_free(pomai_neighbor_t* neighbors) {
774774
if (neighbors) palloc_free(neighbors);
775775
}
776776

777+
pomai_status_t* pomai_graph_delete_vertex(pomai_db_t* db, pomai_vertex_id_t id) {
778+
if (!db || !db->db) return MakeStatus(POMAI_STATUS_INVALID_ARGUMENT, "db is null");
779+
return ToCStatus(db->db->DeleteVertex(id));
780+
}
781+
782+
pomai_status_t* pomai_graph_delete_edge(pomai_db_t* db, pomai_vertex_id_t src, pomai_vertex_id_t dst, pomai_edge_type_t type) {
783+
if (!db || !db->db) return MakeStatus(POMAI_STATUS_INVALID_ARGUMENT, "db is null");
784+
return ToCStatus(db->db->DeleteEdge(src, dst, static_cast<pomai::EdgeType>(type)));
785+
}
786+
777787
pomai_status_t* pomai_search_multi_modal(pomai_db_t* db, const pomai_multi_modal_query_t* query, pomai_search_results_t** out) {
778788
if (!db || !db->db || !query || !out) return MakeStatus(POMAI_STATUS_INVALID_ARGUMENT, "invalid args");
779789

Lines changed: 132 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
#pragma once
22

3+
#include <algorithm>
34
#include <cstddef>
5+
#include <cstring>
6+
#include <endian.h>
47
#include <functional>
58
#include <unordered_map>
69
#include <vector>
710

811
#include "pomai/graph.h"
12+
#include "pomai/slice.h"
913
#include "pomai/status.h"
1014
#include "storage/wal/wal.h"
1115
#include "core/graph/graph_key.h"
@@ -14,35 +18,75 @@ namespace pomai::core {
1418

1519
/**
1620
* @brief Internal implementation of GraphMembrane.
21+
*
22+
* Persists vertex and edge writes to a WAL using kRawKV records.
23+
* On restart, WarmUp() replays the WAL to rebuild adj_lists_.
24+
* Deletions use tombstone key prefixes ('T' for vertex, 'X' for edge)
25+
* that are also replayed during WarmUp().
1726
*/
1827
class GraphMembraneImpl : public pomai::GraphMembrane {
1928
public:
20-
GraphMembraneImpl(std::unique_ptr<storage::Wal> wal) : wal_(std::move(wal)) {}
29+
explicit GraphMembraneImpl(std::unique_ptr<storage::Wal> wal) : wal_(std::move(wal)) {}
2130

2231
Status AddVertex(VertexId id, TagId tag, const Metadata& meta) override {
23-
// 1. Persist to WAL
2432
std::string key = GraphKey::EncodeVertex(id, tag);
2533
Status st = wal_->AppendRawKV(4 /* kRawKV */, Slice(key), Slice(meta.tenant));
2634
if (!st.ok()) return st;
27-
28-
// 2. Update RAM index (structural only)
2935
if (adj_lists_.find(id) == adj_lists_.end()) {
3036
adj_lists_[id] = {};
37+
bytes_used_ += sizeof(VertexId) + sizeof(std::vector<Neighbor>);
3138
}
3239
return Status::Ok();
3340
}
3441

3542
Status AddEdge(VertexId src, VertexId dst, EdgeType type, uint32_t rank, const Metadata& meta) override {
36-
// 1. Persist to WAL
3743
std::string key = GraphKey::EncodeEdge(src, type, rank, dst);
3844
Status st = wal_->AppendRawKV(4 /* kRawKV */, Slice(key), Slice(meta.tenant));
3945
if (!st.ok()) return st;
40-
41-
// 2. Update RAM index (structural only)
42-
// Add to contiguous store
4346
Neighbor n{dst, type, rank};
44-
auto& list = adj_lists_[src];
45-
list.push_back(n);
47+
adj_lists_[src].push_back(n);
48+
bytes_used_ += sizeof(Neighbor);
49+
return Status::Ok();
50+
}
51+
52+
Status DeleteVertex(VertexId id) override {
53+
// Tombstone key: 'T' (1) | VertexId (8)
54+
std::string key(1, 'T');
55+
uint64_t v_be = htobe64(id);
56+
key.append(reinterpret_cast<const char*>(&v_be), 8);
57+
Status st = wal_->AppendRawKV(4 /* kRawKV */, Slice(key), Slice(""));
58+
if (!st.ok()) return st;
59+
auto it = adj_lists_.find(id);
60+
if (it != adj_lists_.end()) {
61+
bytes_used_ -= sizeof(VertexId) + sizeof(std::vector<Neighbor>) +
62+
it->second.size() * sizeof(Neighbor);
63+
adj_lists_.erase(it);
64+
}
65+
return Status::Ok();
66+
}
67+
68+
Status DeleteEdge(VertexId src, VertexId dst, EdgeType type) override {
69+
// Tombstone key: 'X' (1) | SrcID (8) | EdgeType (4) | Rank=0 (4) | DstID (8)
70+
std::string key(1, 'X');
71+
uint64_t s_be = htobe64(src);
72+
uint32_t t_be = htobe32(static_cast<uint32_t>(type));
73+
uint32_t r_be = 0;
74+
uint64_t d_be = htobe64(dst);
75+
key.append(reinterpret_cast<const char*>(&s_be), 8);
76+
key.append(reinterpret_cast<const char*>(&t_be), 4);
77+
key.append(reinterpret_cast<const char*>(&r_be), 4);
78+
key.append(reinterpret_cast<const char*>(&d_be), 8);
79+
Status st = wal_->AppendRawKV(4 /* kRawKV */, Slice(key), Slice(""));
80+
if (!st.ok()) return st;
81+
auto it = adj_lists_.find(src);
82+
if (it != adj_lists_.end()) {
83+
auto& v = it->second;
84+
auto before = v.size();
85+
v.erase(std::remove_if(v.begin(), v.end(),
86+
[dst, type](const Neighbor& n) { return n.id == dst && n.type == type; }),
87+
v.end());
88+
bytes_used_ -= (before - v.size()) * sizeof(Neighbor);
89+
}
4690
return Status::Ok();
4791
}
4892

@@ -58,36 +102,101 @@ class GraphMembraneImpl : public pomai::GraphMembrane {
58102
auto it = adj_lists_.find(src);
59103
if (it != adj_lists_.end()) {
60104
for (const auto& n : it->second) {
61-
if (n.type == type) {
62-
out->push_back(n);
63-
}
105+
if (n.type == type) out->push_back(n);
64106
}
65107
}
66108
return Status::Ok();
67109
}
68110

69-
Status Flush() override {
70-
return wal_->Flush();
71-
}
111+
Status Flush() override { return wal_->Flush(); }
72112

73113
Status BeginBatch() { return wal_ ? wal_->BeginBatch() : Status::Ok(); }
74-
Status EndBatch() { return wal_ ? wal_->EndBatch() : Status::Ok(); }
114+
Status EndBatch() { return wal_ ? wal_->EndBatch() : Status::Ok(); }
115+
116+
/**
117+
* Called during Database::Open() to rebuild adj_lists_ from the WAL.
118+
* Delegates to Wal::ReplayGraphInto which calls ReplayEntry() for each
119+
* kRawKV record found in the WAL segments.
120+
*/
121+
Status WarmUp() { return wal_->ReplayGraphInto(this); }
122+
123+
/**
124+
* Called by Wal::ReplayGraphInto for each kRawKV key decoded from the WAL.
125+
* Reconstructs adj_lists_ and bytes_used_ from the encoded key bytes.
126+
*/
127+
void ReplayEntry(pomai::Slice key) {
128+
if (key.size() < 1) return;
129+
const auto* p = static_cast<const uint8_t*>(static_cast<const void*>(key.data()));
130+
const uint8_t prefix = p[0];
131+
132+
if (prefix == GraphKey::kVertex && key.size() >= 13) {
133+
uint64_t vid_be;
134+
std::memcpy(&vid_be, p + 1, 8);
135+
VertexId vid = be64toh(vid_be);
136+
if (adj_lists_.find(vid) == adj_lists_.end()) {
137+
adj_lists_[vid] = {};
138+
bytes_used_ += sizeof(VertexId) + sizeof(std::vector<Neighbor>);
139+
}
75140

76-
// Called during Database::Open()
77-
Status WarmUp() {
78-
// WarmUp can replay WAL entries to rebuild adj_lists_
79-
return Status::Ok();
141+
} else if (prefix == GraphKey::kEdge && key.size() >= 25) {
142+
uint64_t src_be, dst_be;
143+
uint32_t type_be, rank_be;
144+
std::memcpy(&src_be, p + 1, 8);
145+
std::memcpy(&type_be, p + 9, 4);
146+
std::memcpy(&rank_be, p + 13, 4);
147+
std::memcpy(&dst_be, p + 17, 8);
148+
VertexId src = be64toh(src_be);
149+
Neighbor n{be64toh(dst_be), be32toh(type_be), be32toh(rank_be)};
150+
adj_lists_[src].push_back(n);
151+
bytes_used_ += sizeof(Neighbor);
152+
153+
} else if (prefix == 'T' && key.size() >= 9) {
154+
// Vertex tombstone
155+
uint64_t vid_be;
156+
std::memcpy(&vid_be, p + 1, 8);
157+
VertexId vid = be64toh(vid_be);
158+
auto it = adj_lists_.find(vid);
159+
if (it != adj_lists_.end()) {
160+
bytes_used_ -= sizeof(VertexId) + sizeof(std::vector<Neighbor>) +
161+
it->second.size() * sizeof(Neighbor);
162+
adj_lists_.erase(it);
163+
}
164+
165+
} else if (prefix == 'X' && key.size() >= 25) {
166+
// Edge tombstone
167+
uint64_t src_be, dst_be;
168+
uint32_t type_be;
169+
std::memcpy(&src_be, p + 1, 8);
170+
std::memcpy(&type_be, p + 9, 4);
171+
// rank at +13 is ignored for tombstone matching
172+
std::memcpy(&dst_be, p + 17, 8);
173+
VertexId src = be64toh(src_be);
174+
VertexId dst = be64toh(dst_be);
175+
EdgeType etype = be32toh(type_be);
176+
auto it = adj_lists_.find(src);
177+
if (it != adj_lists_.end()) {
178+
auto& v = it->second;
179+
auto before = v.size();
180+
v.erase(std::remove_if(v.begin(), v.end(),
181+
[dst, etype](const Neighbor& n) { return n.id == dst && n.type == etype; }),
182+
v.end());
183+
bytes_used_ -= (before - v.size()) * sizeof(Neighbor);
184+
}
185+
}
80186
}
81187

188+
std::size_t MemoryBytesUsed() const { return bytes_used_; }
189+
82190
void ForEachVertex(const std::function<void(pomai::VertexId id, std::size_t out_degree)>& fn) const {
83191
for (const auto& [vid, neigh] : adj_lists_) fn(vid, neigh.size());
84192
}
85193

86194
private:
87195
std::unique_ptr<storage::Wal> wal_;
88-
// Contiguous Adjacency Store (Simplified for now - using map to vectors but intended for mmap)
89-
// In a full implementation, this would be a single large buffer + offset index.
196+
// In-memory adjacency store. Rebuilt from WAL on open via WarmUp().
90197
std::unordered_map<VertexId, std::vector<Neighbor>> adj_lists_;
198+
// Approximate memory usage for backpressure / quota reporting.
199+
std::size_t bytes_used_ = 0;
91200
};
92201

93202
} // namespace pomai::core

src/core/kernel/message.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ namespace pomai::core {
4646
constexpr uint32_t kAddEdge = 0x11;
4747
constexpr uint32_t kGetNeighbors = 0x12;
4848
constexpr uint32_t kGetNeighborsWithType = 0x13;
49+
constexpr uint32_t kDeleteVertex = 0x14;
50+
constexpr uint32_t kDeleteEdge = 0x15;
4951
}
5052

5153
inline bool IsKnownOpcode(uint32_t opcode) {
@@ -69,6 +71,8 @@ namespace pomai::core {
6971
case Op::kAddEdge:
7072
case Op::kGetNeighbors:
7173
case Op::kGetNeighborsWithType:
74+
case Op::kDeleteVertex:
75+
case Op::kDeleteEdge:
7276
return true;
7377
default:
7478
return false;

src/core/kernel/pods/graph_pod.cc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ namespace pomai::core {
6969
}
7070
break;
7171
}
72+
case Op::kDeleteVertex: {
73+
if (msg.payload.size() < sizeof(VertexId)) {
74+
SetStatus(msg.status_ptr, Status::InvalidArgument("graph delete-vertex payload too small"));
75+
return;
76+
}
77+
VertexId id = *reinterpret_cast<const VertexId*>(msg.payload.data());
78+
SetStatus(msg.status_ptr, runtime_->DeleteVertex(id));
79+
break;
80+
}
81+
case Op::kDeleteEdge: {
82+
struct P { VertexId src; VertexId dst; EdgeType type; };
83+
if (msg.payload.size() < sizeof(P)) {
84+
SetStatus(msg.status_ptr, Status::InvalidArgument("graph delete-edge payload too small"));
85+
return;
86+
}
87+
const auto* p = reinterpret_cast<const P*>(msg.payload.data());
88+
SetStatus(msg.status_ptr, runtime_->DeleteEdge(p->src, p->dst, p->type));
89+
break;
90+
}
7291
default:
7392
SetStatus(msg.status_ptr, Status::InvalidArgument("graph pod unsupported opcode"));
7493
break;

src/core/kernel/pods/graph_pod.h

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ namespace pomai::core {
1919
std::string Name() const override { return "GraphService"; }
2020

2121
MemoryQuota GetQuota() const override {
22-
// TODO: Implement actual accounting in GraphMembraneImpl
23-
return {0, 0};
22+
return {runtime_->MemoryBytesUsed(), 0};
2423
}
2524

2625
void OnStart() override { (void)runtime_->WarmUp(); }

0 commit comments

Comments
 (0)