Describe the bug
SingleMutableCsr::put_edge performs writes to the neighbor list without any locking mechanism. If two threads call put_edge on the same source vertex concurrently, this results in a data race on the MutableNbr fields (neighbor, data, timestamp).
Affected location
include/neug/storages/csr/mutable_csr.h:343-361
std::pair<int32_t, const void*> put_edge(vid_t src, vid_t dst,
const EDATA_T& data, timestamp_t ts,
Allocator& alloc) override {
if (src >= vertex_capacity()) {
THROW_INVALID_ARGUMENT_EXCEPTION(/* ... */);
}
auto* nbrs = reinterpret_cast<nbr_t*>(nbr_list_->GetData());
nbrs[src].neighbor = dst; // ← non-atomic write
nbrs[src].data = data; // ← non-atomic write
CHECK_EQ(nbrs[src].timestamp, // ← implicit atomic load (operator T())
std::numeric_limits<timestamp_t>::max());
nbrs[src].timestamp.store(ts); // ← atomic store
edge_num_.fetch_add(1, std::memory_order_relaxed);
// ...
}
The race
Since SingleMutableCsr has no per-vertex lock (unlike MutableCsr which has locks_[src]):
-
Concurrent put_edge on same src: Two threads write neighbor and data simultaneously, resulting in a torn MutableNbr (e.g., neighbor from Thread A and data from Thread B).
-
Concurrent put_edge + read: A reader via get_generic_view could see a MutableNbr with a partially updated neighbor field (e.g., upper 16 bits from old value, lower 16 bits from new value on platforms where vid_t writes are not atomic).
-
CHECK_EQ is not a guard: CHECK_EQ(nbrs[src].timestamp, max) is meant to catch double-insertion, but:
- In Release builds,
CHECK_EQ aborts the process (FATAL), which is a crash, not a graceful handling.
- In a concurrent scenario, both threads could pass the check before either stores the new timestamp, leading to a race.
-
Unused alloc parameter: The Allocator& alloc parameter is not used, suggesting the method signature was inherited from MutableCsr without adaptation.
Expected behavior
SingleMutableCsr::put_edge should be safe for concurrent calls on different src values (no contention), but must handle concurrent calls on the same src gracefully — either by:
- Adding a per-vertex lock (like
MutableCsr), or
- Documenting that
put_edge for the same src must not be called concurrently (single-writer guarantee), and enforcing it at a higher level.
Suggested fix
Option A — Add per-vertex lock (consistent with MutableCsr):
// Add member:
std::unique_ptr<SpinLock[]> locks_;
// In put_edge:
std::lock_guard<SpinLock> guard(locks_[src]);
// ... rest of the logic ...
Note: This adds memory overhead for the locks array. Since SingleMutableCsr stores exactly one edge per vertex, the lock could alternatively be embedded in the MutableNbr struct or use a striped lock approach.
Option B — Document single-writer constraint:
/// @pre The caller must ensure no concurrent put_edge calls for the same src.
/// This is typically enforced by the transaction layer.
std::pair<int32_t, const void*> put_edge(vid_t src, vid_t dst, ...) {
// ...
}
Impact
- Severity: Medium — data race on
neighbor and data fields. For vid_t (typically uint32_t), writes are likely atomic on x86, but this is not guaranteed by the C++ standard. The real risk is the interleaving of neighbor and data writes, which can produce a semantically incorrect edge (wrong neighbor paired with wrong data).
- Likelihood: Low-Medium — requires concurrent
put_edge on the same vertex, which may happen if transaction routing doesn't shard by vertex.
- Note: The
CHECK_EQ will cause a FATAL crash if an already-inserted edge is re-inserted, which is more severe than the race itself.
Describe the bug
SingleMutableCsr::put_edgeperforms writes to the neighbor list without any locking mechanism. If two threads callput_edgeon the same source vertex concurrently, this results in a data race on theMutableNbrfields (neighbor,data,timestamp).Affected location
include/neug/storages/csr/mutable_csr.h:343-361The race
Since
SingleMutableCsrhas no per-vertex lock (unlikeMutableCsrwhich haslocks_[src]):Concurrent put_edge on same src: Two threads write
neighboranddatasimultaneously, resulting in a tornMutableNbr(e.g.,neighborfrom Thread A anddatafrom Thread B).Concurrent put_edge + read: A reader via
get_generic_viewcould see aMutableNbrwith a partially updatedneighborfield (e.g., upper 16 bits from old value, lower 16 bits from new value on platforms wherevid_twrites are not atomic).CHECK_EQ is not a guard:
CHECK_EQ(nbrs[src].timestamp, max)is meant to catch double-insertion, but:CHECK_EQaborts the process (FATAL), which is a crash, not a graceful handling.Unused
allocparameter: TheAllocator& allocparameter is not used, suggesting the method signature was inherited fromMutableCsrwithout adaptation.Expected behavior
SingleMutableCsr::put_edgeshould be safe for concurrent calls on differentsrcvalues (no contention), but must handle concurrent calls on the samesrcgracefully — either by:MutableCsr), orput_edgefor the samesrcmust not be called concurrently (single-writer guarantee), and enforcing it at a higher level.Suggested fix
Option A — Add per-vertex lock (consistent with MutableCsr):
Note: This adds memory overhead for the locks array. Since
SingleMutableCsrstores exactly one edge per vertex, the lock could alternatively be embedded in theMutableNbrstruct or use a striped lock approach.Option B — Document single-writer constraint:
Impact
neighboranddatafields. Forvid_t(typicallyuint32_t), writes are likely atomic on x86, but this is not guaranteed by the C++ standard. The real risk is the interleaving ofneighboranddatawrites, which can produce a semantically incorrect edge (wrong neighbor paired with wrong data).put_edgeon the same vertex, which may happen if transaction routing doesn't shard by vertex.CHECK_EQwill cause a FATAL crash if an already-inserted edge is re-inserted, which is more severe than the race itself.