Skip to main content

linkrs_storage/edge/mutable_csr/
write.rs

1use super::super::csr_shared::{
2    can_revert_delete, decide_slot_delete, decode_endpoint_pair, is_reclaimable_cold,
3    DeleteSlotOutcome,
4};
5use super::super::{ColdStamps, EdgeId, HotNbr, Nbr, RowEdgeBatch, Timestamp, VertexId};
6use super::core::REUSE_HINT_UNKNOWN;
7use super::overflow::OVERFLOW_REPACK_CHUNKS_PER_VERTEX;
8use super::MutableCsr;
9use linkrs_core::{StorageError, StorageResult};
10use std::collections::HashSet;
11
12/// Hot-path bound for the tombstone-reuse scan. Rows wider than this leave
13/// leftover reclaimable slots to the maintenance pass instead of scanning
14/// the whole row on every insert. The per-vertex reuse hint usually avoids
15/// the scan entirely; the bound only caps the fallback walk when the hint
16/// is unknown or stale.
17///
18/// Source: insert-path microbenchmarks in `benches/csr_perf_bench.rs` show
19/// the reuse scan staying negligible against append cost up to a few dozen
20/// slots; 64 keeps the worst fallback walk to one cache-friendly row pass.
21/// Recommended range 32..=128. Tune against `benches/csr_perf_bench.rs`
22/// before changing it; changes without a benchmark run are not accepted.
23const TOMBSTONE_REUSE_SCAN_BOUND: usize = 64;
24
25/// Physical position of one stored edge inside its row.
26///
27/// Primary positions address the primary block by slot index; overflow
28/// positions address a chunk and a slot inside it. Positions are valid only
29/// while the row is untouched by compaction, rebalance, repack or removal:
30/// every positional write revalidates the expected edge id first and
31/// refuses stale positions instead of touching the wrong edge.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum EdgePosition {
34    Primary { slot: u32 },
35    Overflow { chunk: u32, slot: u32 },
36}
37
38impl MutableCsr {
39    fn append_overflow(&mut self, src_vid: u32, nbr: Nbr, live_hint: usize) {
40        let chunk_edges = self.effective_chunk_edges(live_hint);
41        // Single table routing per call: the tail-chunk check, the push and
42        // the new-chunk capacity report all happen inside one lookup.
43        let (chunk_count, added) = self.overflow_chunks.push_to_row(src_vid, nbr, chunk_edges);
44        if let Some(new_cap) = added {
45            self.add_capacity(new_cap);
46            self.overflow_chunk_allocs += 1;
47        }
48        if nbr.delete_ts == Timestamp::MAX {
49            // Tail slot just pushed: the row-relative position feeds the
50            // wide-row location index directly, so no rescan is needed to
51            // keep it exact. Rows still narrow stay set-free through the
52            // width gate inside the tracker.
53            let position = self
54                .overflow_chunks
55                .get(src_vid)
56                .map(|chunks| {
57                    let chunk = chunks.len().saturating_sub(1);
58                    let slot = chunks
59                        .last()
60                        .map(|tail| tail.len().saturating_sub(1))
61                        .unwrap_or(0);
62                    EdgePosition::Overflow {
63                        chunk: chunk as u32,
64                        slot: slot as u32,
65                    }
66                })
67                .unwrap_or(EdgePosition::Overflow {
68                    chunk: chunk_count.saturating_sub(1) as u32,
69                    slot: 0,
70                });
71            self.track_live_insert(src_vid, nbr.endpoint, nbr.rank, position);
72        }
73        // Per-vertex overflow bound: past the limit the row is consolidated
74        // into one contiguous chunk whether or not it holds dead entries,
75        // so skewed rows cannot grow unbounded pointer chains. The repack
76        // preserves every entry (no watermark here); watermark-confirmed
77        // reclaim runs through the vertex-level reporting passes. Later
78        // appends grow fresh graded tail chunks until the next breach, so
79        // the consolidation cost stays amortized.
80        if chunk_count > OVERFLOW_REPACK_CHUNKS_PER_VERTEX {
81            let mut noop = |_id: EdgeId, _ts: Timestamp| {};
82            self.compact_overflow_for_vertex(src_vid, Timestamp::MAX, &mut noop);
83            self.repack_count += 1;
84        }
85    }
86
87    /// Insert an edge with automatic capacity expansion
88    pub fn insert_edge(
89        &mut self,
90        src_vid: u32,
91        dst: VertexId,
92        edge_id: EdgeId,
93        ts: Timestamp,
94    ) -> StorageResult<()> {
95        let (decoded_endpoint, decoded_rank) = decode_endpoint_pair(dst).ok_or_else(|| {
96            StorageError::invalid_input(format!("Malformed edge endpoint key: {}", dst))
97        })?;
98
99        let src_idx = src_vid as usize;
100
101        if src_idx >= self.vertex_capacity() {
102            self.ensure_vertex_capacity(src_idx + 1);
103        }
104
105        // Lazy primary block allocation on first edge
106        if self.rows.primary_capacities[src_idx] == 0 {
107            self.allocate_primary_block(src_idx);
108        }
109
110        // Duplicate check via the single live set covering primary and
111        // overflow. The set is authoritative: it is rebuilt on load and
112        // compact and updated on every write. Indexed rows answer from the
113        // set; narrow rows pay one combined scan that also counts live
114        // entries, so the width probe below costs no second walk.
115        let live = if let Some(set) = self.live_sets.get(&src_vid) {
116            if set.contains(&(decoded_endpoint, decoded_rank)) {
117                return Err(StorageError::edge_already_exists(format!(
118                    "{} -> {:?}",
119                    src_vid, dst
120                )));
121            }
122            set.len()
123        } else {
124            let (present, live) = self.row_live_scan(src_vid, decoded_endpoint, decoded_rank);
125            if present {
126                return Err(StorageError::edge_already_exists(format!(
127                    "{} -> {:?}",
128                    src_vid, dst
129                )));
130            }
131            live
132        };
133
134        let nbr_with_ts = Nbr::with_create_ts(decoded_endpoint, decoded_rank, edge_id, ts);
135
136        // Steady-state gap fill: primary trailing gaps are filled first
137        // even when overflow exists, so everyday writes land in row gaps.
138        // Row rebalances that pull overflow back into freed gaps run on the
139        // maintenance path, never on this hot path, keeping inserts O(1).
140        let degree = self.rows.degrees[src_idx] as usize;
141        if degree < self.rows.primary_capacities[src_idx] as usize {
142            let base = self.rows.adj_offsets[src_idx] as usize;
143            self.set_slot(base + degree, nbr_with_ts);
144            self.mark_primary_unsorted(src_idx);
145            self.rows.degrees[src_idx] += 1;
146            self.live_counts[src_idx] += 1;
147            self.track_live_insert(
148                src_vid,
149                decoded_endpoint,
150                decoded_rank,
151                EdgePosition::Primary {
152                    slot: degree as u32,
153                },
154            );
155            self.edge_count += 1;
156            return Ok(());
157        }
158
159        // Full row: with a watermark-derived reuse cutoff, reclaim one
160        // GC-eligible primary tombstone in place instead of growing
161        // overflow. The eligibility predicate matches the maintenance
162        // compaction exactly, so the hot path never drops a tombstone the
163        // reclaim pass would keep. Overflow tombstones stay for the region
164        // compaction. The per-vertex hint gives O(1) reuse when a recent
165        // delete recorded its slot; the bounded prefix scan below is only
166        // the fallback when the hint is unknown or stale.
167        if self.tombstone_reuse_cutoff != Timestamp::MAX {
168            let base = self.rows.adj_offsets[src_idx] as usize;
169            let cutoff = self.tombstone_reuse_cutoff;
170            if let Some(&hint) = self.reuse_hint.get(src_idx) {
171                if hint != REUSE_HINT_UNKNOWN {
172                    let slot = hint as usize;
173                    if slot < degree {
174                        let reclaimable = self
175                            .cold_at(base + slot)
176                            .is_some_and(|cold| is_reclaimable_cold(&cold, cutoff));
177                        if reclaimable {
178                            self.tombstone_reuse_hint_hits += 1;
179                            self.set_slot(base + slot, nbr_with_ts);
180                            self.mark_primary_unsorted(src_idx);
181                            self.invalidate_reuse_hint(src_idx);
182                            self.live_counts[src_idx] += 1;
183                            self.tombstone_counts[src_idx] =
184                                self.tombstone_counts[src_idx].saturating_sub(1);
185                            self.track_live_insert(
186                                src_vid,
187                                decoded_endpoint,
188                                decoded_rank,
189                                EdgePosition::Primary { slot: slot as u32 },
190                            );
191                            self.edge_count += 1;
192                            self.tombstone_reuse_count += 1;
193                            return Ok(());
194                        }
195                    }
196                    self.tombstone_reuse_hint_misses += 1;
197                    self.invalidate_reuse_hint(src_idx);
198                }
199            }
200            let bound = degree.min(TOMBSTONE_REUSE_SCAN_BOUND);
201            for i in 0..bound {
202                let reclaimable = self
203                    .cold_at(base + i)
204                    .is_some_and(|cold| is_reclaimable_cold(&cold, cutoff));
205                if reclaimable {
206                    self.set_slot(base + i, nbr_with_ts);
207                    self.mark_primary_unsorted(src_idx);
208                    self.invalidate_reuse_hint(src_idx);
209                    self.live_counts[src_idx] += 1;
210                    self.tombstone_counts[src_idx] =
211                        self.tombstone_counts[src_idx].saturating_sub(1);
212                    self.track_live_insert(
213                        src_vid,
214                        decoded_endpoint,
215                        decoded_rank,
216                        EdgePosition::Primary { slot: i as u32 },
217                    );
218                    self.edge_count += 1;
219                    self.tombstone_reuse_count += 1;
220                    return Ok(());
221                }
222            }
223        }
224
225        self.append_overflow(src_vid, nbr_with_ts, live.saturating_add(1));
226        self.live_counts[src_idx] += 1;
227        self.edge_count += 1;
228        Ok(())
229    }
230
231    /// Locate one overflow entry by edge id, stopping at the first match.
232    ///
233    /// Point-lookup fast path: never builds a match vector, so hot single
234    /// deletes pay no heap allocation. Callers needing every match use the
235    /// in-place stamping passes instead.
236    fn scan_overflow_for_edge_id(&self, src_vid: u32, edge_id: EdgeId) -> Option<(usize, usize)> {
237        let chunks = self.overflow_chunks.get(src_vid)?;
238        for (chunk_idx, chunk) in chunks.iter().enumerate() {
239            for (edge_idx, hot) in chunk.hot_slice().iter().enumerate() {
240                if hot.edge_id == edge_id {
241                    return Some((chunk_idx, edge_idx));
242                }
243            }
244        }
245        None
246    }
247
248    /// Delete an edge by edge_id.
249    ///
250    /// Returns `Ok(true)` when deleted, `Ok(false)` when the edge does not
251    /// exist or is not deletable at `ts`, and
252    /// `Err(StorageError::write_write_conflict)` when the edge was already
253    /// deleted at a different timestamp (write-write conflict at the storage
254    /// layer, surfaced immediately instead of a silent `false`).
255    pub fn delete_edge(
256        &mut self,
257        src_vid: u32,
258        edge_id: EdgeId,
259        ts: Timestamp,
260    ) -> StorageResult<bool> {
261        let src_idx = src_vid as usize;
262        if src_idx >= self.vertex_capacity() {
263            return Ok(false);
264        }
265
266        // Scan primary: locate by edge id on shared slices first, then
267        // stamp through one indexed write.
268        let found = {
269            let (hot, cold) = self.primary_pair(src_idx);
270            hot.iter()
271                .zip(cold.iter())
272                .enumerate()
273                .find_map(|(i, (h, c))| {
274                    (h.edge_id == edge_id).then(|| (i, Nbr::from_parts(*h, *c)))
275                })
276        };
277        if let Some((i, probe)) = found {
278            match decide_slot_delete(&probe, edge_id, ts)? {
279                DeleteSlotOutcome::AlreadyStamped => {
280                    return Ok(false);
281                }
282                DeleteSlotOutcome::Stamped => {}
283            }
284            let (start, _) = self.primary_window(src_idx);
285            self.cold_list[start + i].delete_ts = ts;
286            self.edge_count -= 1;
287            self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
288            self.tombstone_counts[src_idx] += 1;
289            self.note_primary_tombstone(src_idx, i);
290            self.track_live_remove(src_vid, probe.endpoint, probe.rank);
291            return Ok(true);
292        }
293
294        // Scan overflow
295        if let Some((chunk_idx, edge_idx)) = self.scan_overflow_for_edge_id(src_vid, edge_id) {
296            // Capture the assembled slot before mutable borrow ends for live set update.
297            let probe = {
298                let chunks = self.overflow_chunks.get(src_vid).unwrap();
299                chunks[chunk_idx].slot_at(edge_idx).unwrap()
300            };
301            if let Some(chunks) = self.overflow_chunks.get_mut(src_vid) {
302                match decide_slot_delete(&probe, edge_id, ts)? {
303                    DeleteSlotOutcome::AlreadyStamped => {
304                        return Ok(false);
305                    }
306                    DeleteSlotOutcome::Stamped => {}
307                }
308                chunks[chunk_idx].cold_at_mut(edge_idx).unwrap().delete_ts = ts;
309                self.edge_count -= 1;
310                self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
311                self.tombstone_counts[src_idx] += 1;
312                self.track_live_remove(src_vid, probe.endpoint, probe.rank);
313                return Ok(true);
314            }
315        }
316
317        Ok(false)
318    }
319
320    /// Delete edges by destination vertex with full-match semantics.
321    ///
322    /// Deletes every live match and returns the deleted count so table
323    /// rollback can reconcile by count. One call deletes the whole match.
324    /// Single pass: primary and overflow rows are stamped in place while
325    /// walking, so no match vector is materialized and no second scan runs.
326    /// Every stamped id is reported through `on_deleted` for append-log and
327    /// audit callers that previously paid a separate collection scan.
328    pub fn delete_edge_by_dst_reporting(
329        &mut self,
330        src_vid: u32,
331        dst: VertexId,
332        ts: Timestamp,
333        on_deleted: &mut dyn FnMut(EdgeId),
334    ) -> usize {
335        let mut noop_pos = |edge_id: EdgeId, _position: EdgePosition| {
336            on_deleted(edge_id);
337        };
338        self.delete_edge_by_dst_reporting_positioned(src_vid, dst, ts, &mut noop_pos)
339    }
340
341    /// Position-reporting form of [`Self::delete_edge_by_dst_reporting`].
342    ///
343    /// Same single stamping pass, but each stamped id arrives with its row
344    /// position so the caller can revert or re-delete the exact slot without
345    /// rescanning. Positions are row-local and expire on the next compaction,
346    /// rebalance, repack or removal of the row.
347    pub fn delete_edge_by_dst_reporting_positioned(
348        &mut self,
349        src_vid: u32,
350        dst: VertexId,
351        ts: Timestamp,
352        on_deleted: &mut dyn FnMut(EdgeId, EdgePosition),
353    ) -> usize {
354        let Some((decoded_endpoint, decoded_rank)) = decode_endpoint_pair(dst) else {
355            return 0;
356        };
357        let src_idx = src_vid as usize;
358        if src_idx >= self.vertex_capacity() {
359            return 0;
360        }
361
362        let mut deleted = 0usize;
363
364        // Stamp primary matches in place over disjoint hot/cold slices, so
365        // the walk pays one bounds check per row while stamping inline.
366        // Only direct field accesses run inside the loop: no `&self` method
367        // calls, so the shared hot borrow and the exclusive cold borrow
368        // coexist with the edge-count ledger update.
369        let (start, end) = self.primary_window(src_idx);
370        let hot = &self.hot_list[start..end];
371        let cold = &mut self.cold_list[start..end];
372        let mut first_primary_tombstone: Option<usize> = None;
373        for (i, (h, c)) in hot.iter().zip(cold.iter_mut()).enumerate() {
374            if h.endpoint == decoded_endpoint && h.rank == decoded_rank && c.is_live() {
375                c.delete_ts = ts;
376                self.edge_count -= 1;
377                self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
378                self.tombstone_counts[src_idx] += 1;
379                on_deleted(h.edge_id, EdgePosition::Primary { slot: i as u32 });
380                if first_primary_tombstone.is_none_or(|first| i < first) {
381                    first_primary_tombstone = Some(i);
382                }
383                deleted += 1;
384            }
385        }
386
387        // Stamp overflow matches in the same pass.
388        if let Some(chunks) = self.overflow_chunks.get_mut(src_vid) {
389            for (chunk_idx, chunk) in chunks.iter_mut().enumerate() {
390                for slot_idx in 0..chunk.len() {
391                    let hot = chunk.hot_at(slot_idx).unwrap();
392                    let cold = chunk.cold_at(slot_idx).unwrap();
393                    if hot.endpoint == decoded_endpoint
394                        && hot.rank == decoded_rank
395                        && cold.is_live()
396                    {
397                        chunk.cold_at_mut(slot_idx).unwrap().delete_ts = ts;
398                        self.edge_count -= 1;
399                        self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
400                        self.tombstone_counts[src_idx] += 1;
401                        on_deleted(
402                            hot.edge_id,
403                            EdgePosition::Overflow {
404                                chunk: chunk_idx as u32,
405                                slot: slot_idx as u32,
406                            },
407                        );
408                        deleted += 1;
409                    }
410                }
411            }
412        }
413        if deleted > 0 {
414            if let Some(slot) = first_primary_tombstone {
415                self.note_primary_tombstone(src_idx, slot);
416            }
417            self.track_live_remove(src_vid, decoded_endpoint, decoded_rank);
418        }
419
420        deleted
421    }
422
423    /// Delete edges by destination vertex with full-match semantics.
424    ///
425    /// Deletes every live match and returns the deleted count so table
426    /// rollback can reconcile by count. One call deletes the whole match.
427    pub fn delete_edge_by_dst(&mut self, src_vid: u32, dst: VertexId, ts: Timestamp) -> usize {
428        let mut noop = |_: EdgeId| {};
429        self.delete_edge_by_dst_reporting(src_vid, dst, ts, &mut noop)
430    }
431
432    pub fn delete_edge_by_offset(
433        &mut self,
434        src_vid: u32,
435        offset: i32,
436        ts: Timestamp,
437    ) -> StorageResult<bool> {
438        if offset < 0 {
439            return Ok(false);
440        }
441        let src_idx = src_vid as usize;
442        if src_idx >= self.vertex_capacity() || self.rows.primary_capacities[src_idx] == 0 {
443            return Ok(false);
444        }
445        if offset as usize >= self.rows.degrees[src_idx] as usize {
446            return Ok(false);
447        }
448        let idx = self.rows.adj_offsets[src_idx] as usize + offset as usize;
449        if idx >= self.hot_list.len() {
450            return Ok(false);
451        }
452        let probe = Nbr::from_parts(self.hot_list[idx], self.cold_list[idx]);
453        // Shared delete state machine: a tombstone at another timestamp
454        // surfaces as a write-write conflict, an identical re-delete or a
455        // not-yet-created edge reports `false`, and only a live deletable
456        // slot falls through to the stamp below.
457        match decide_slot_delete(&probe, probe.edge_id, ts)? {
458            DeleteSlotOutcome::AlreadyStamped => {
459                return Ok(false);
460            }
461            DeleteSlotOutcome::Stamped => {}
462        }
463        self.cold_list[idx].delete_ts = ts;
464        self.edge_count -= 1;
465        self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
466        self.tombstone_counts[src_idx] += 1;
467        self.note_primary_tombstone(src_idx, offset as usize);
468        self.track_live_remove(src_vid, probe.endpoint, probe.rank);
469        Ok(true)
470    }
471
472    /// Revert a deleted edge by offset position in the primary block.
473    ///
474    /// Only reverts deletions that occurred at or before the given timestamp.
475    /// This maintains MVCC semantics during transaction rollback: we can only
476    /// undo deletions that happened before the rollback point.
477    pub fn revert_delete_by_offset(&mut self, src_vid: u32, offset: i32, ts: Timestamp) -> bool {
478        if offset < 0 {
479            return false;
480        }
481        let src_idx = src_vid as usize;
482        if src_idx >= self.vertex_capacity() || self.rows.primary_capacities[src_idx] == 0 {
483            return false;
484        }
485        if offset as usize >= self.rows.degrees[src_idx] as usize {
486            return false;
487        }
488
489        let base_offset = self.rows.adj_offsets[src_idx] as usize;
490        let idx = base_offset + offset as usize;
491
492        if idx >= self.hot_list.len() {
493            return false;
494        }
495
496        let probe = Nbr::from_parts(self.hot_list[idx], self.cold_list[idx]);
497        // Only revert deletions that happened at or before rollback time.
498        // Prevents rolling back deletions that occur after the rollback point.
499        if can_revert_delete(&probe, ts) {
500            self.cold_list[idx].delete_ts = Timestamp::MAX;
501            self.edge_count += 1;
502            self.live_counts[src_idx] += 1;
503            self.tombstone_counts[src_idx] = self.tombstone_counts[src_idx].saturating_sub(1);
504            self.invalidate_reuse_hint(src_idx);
505            self.track_live_insert(
506                src_vid,
507                probe.endpoint,
508                probe.rank,
509                EdgePosition::Primary {
510                    slot: offset as u32,
511                },
512            );
513            return true;
514        }
515        false
516    }
517
518    /// Erase one just-inserted edge for insert rollback.
519    ///
520    /// Rollback-only path: erases the slot and updates degree/edge count,
521    /// leaving no tombstone trace, so a failed double-write can pretend the
522    /// edge never existed. This is deliberately distinct from MVCC deletes
523    /// (`delete_edge` family), which stamp `delete_ts` and stay visible to
524    /// the reclaim machinery (`reclaimable_count`, `vertex_reclaim_probe`,
525    /// `fragmentation_ratio`). Physical erasures free their slots inline
526    /// and therefore never appear in tombstone statistics; mixing the two
527    /// models on one row is expected (erasure for insert rollback,
528    /// tombstones for MVCC) and both keep the capacity ledger and the live
529    /// index exact.
530    pub fn rollback_insert(&mut self, src_vid: u32, edge_id: EdgeId) -> bool {
531        let src_idx = src_vid as usize;
532        if src_idx >= self.vertex_capacity() {
533            return false;
534        }
535
536        // Scan primary on the hot slice, then close the gap with one
537        // memmove per half instead of a per-slot copy loop.
538        let found = {
539            let (hot, cold) = self.primary_pair(src_idx);
540            hot.iter()
541                .zip(cold.iter())
542                .enumerate()
543                .find_map(|(i, (h, c))| {
544                    (h.edge_id == edge_id).then(|| (i, Nbr::from_parts(*h, *c)))
545                })
546        };
547        if let Some((i, probe)) = found {
548            let (start, _) = self.primary_window(src_idx);
549            let degree = self.rows.degrees[src_idx] as usize;
550            let was_live = probe.delete_ts == Timestamp::MAX;
551            // Shift left to close the gap, then decrement the degree.
552            self.hot_list
553                .copy_within(start + i + 1..start + degree, start + i);
554            self.cold_list
555                .copy_within(start + i + 1..start + degree, start + i);
556            self.rows.degrees[src_idx] -= 1;
557            // The left-shift duplicates the old tail slot outside the
558            // shrunk window; it stays live unless killed here.
559            let tail = start + self.rows.degrees[src_idx] as usize;
560            self.hot_list[tail] = HotNbr::dead_gap();
561            self.cold_list[tail] = ColdStamps::dead_gap();
562            self.invalidate_reuse_hint(src_idx);
563            if was_live {
564                self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
565                // Gap-closing memmove shifts every later primary slot left
566                // by one, so stored row positions go stale: rebuild the
567                // indexed row when one exists instead of dropping one key.
568                if self.live_sets.get(&src_vid).is_some() {
569                    self.rebuild_live_set_for_vertex(src_vid);
570                }
571                self.edge_count -= 1;
572            } else {
573                self.tombstone_counts[src_idx] = self.tombstone_counts[src_idx].saturating_sub(1);
574            }
575            return true;
576        }
577
578        // Scan overflow
579        if let Some((chunk_idx, edge_idx)) = self.scan_overflow_for_edge_id(src_vid, edge_id) {
580            // Capture live status before removal for set maintenance.
581            let probe = {
582                let chunks = self.overflow_chunks.get(src_vid).unwrap();
583                chunks[chunk_idx].slot_at(edge_idx).unwrap()
584            };
585            let was_live = probe.delete_ts == Timestamp::MAX;
586            // Detach the entry, capturing the ledger delta before the shard
587            // borrow ends so the release routes through the ledger primitive.
588            // `Some((freed, emptied))` when an empty chunk detached.
589            let detached = if let Some(chunks) = self.overflow_chunks.get_mut(src_vid) {
590                chunks[chunk_idx].remove(edge_idx);
591                // Clean up empty chunk vectors to keep per-vertex chunk count bounded.
592                if chunks[chunk_idx].is_empty() {
593                    let removed = chunks.remove(chunk_idx);
594                    Some((removed.capacity(), chunks.is_empty()))
595                } else {
596                    None
597                }
598            } else {
599                return false;
600            };
601            if let Some((freed, emptied)) = detached {
602                self.sub_capacity(freed);
603                if emptied {
604                    if was_live {
605                        self.edge_count -= 1;
606                        self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
607                    } else {
608                        self.tombstone_counts[src_idx] =
609                            self.tombstone_counts[src_idx].saturating_sub(1);
610                    }
611                    self.overflow_chunks.remove(src_vid);
612                    self.rebuild_live_set_for_vertex(src_vid);
613                    return true;
614                }
615            }
616            if was_live {
617                // In-chunk removal shifts later slots of the same chunk, so
618                // stored overflow positions go stale: rebuild the indexed
619                // row when one exists instead of dropping one key.
620                if self.live_sets.get(&src_vid).is_some() {
621                    self.rebuild_live_set_for_vertex(src_vid);
622                }
623                self.edge_count -= 1;
624                self.live_counts[src_idx] = self.live_counts[src_idx].saturating_sub(1);
625            } else {
626                self.tombstone_counts[src_idx] = self.tombstone_counts[src_idx].saturating_sub(1);
627            }
628            return true;
629        }
630
631        false
632    }
633
634    /// Revert a deletion of an edge by edge id.
635    ///
636    /// Restores `delete_ts` to MAX when the entry was deleted at or before the
637    /// given timestamp. Used to roll back the out-direction when the
638    /// in-direction deletion fails.
639    pub fn revert_delete_by_edge_id(
640        &mut self,
641        src_vid: u32,
642        edge_id: EdgeId,
643        ts: Timestamp,
644    ) -> bool {
645        let src_idx = src_vid as usize;
646        if src_idx >= self.vertex_capacity() {
647            return false;
648        }
649
650        // Scan primary on shared slices first, then revert one slot.
651        let found = {
652            let (hot, cold) = self.primary_pair(src_idx);
653            hot.iter()
654                .zip(cold.iter())
655                .enumerate()
656                .find_map(|(i, (h, c))| {
657                    let probe = Nbr::from_parts(*h, *c);
658                    (probe.edge_id == edge_id && can_revert_delete(&probe, ts))
659                        .then_some((i, probe))
660                })
661        };
662        if let Some((i, probe)) = found {
663            let (start, _) = self.primary_window(src_idx);
664            self.cold_list[start + i].delete_ts = Timestamp::MAX;
665            self.edge_count += 1;
666            self.invalidate_reuse_hint(src_idx);
667            self.track_live_insert(
668                src_vid,
669                probe.endpoint,
670                probe.rank,
671                EdgePosition::Primary { slot: i as u32 },
672            );
673            return true;
674        }
675
676        // Scan overflow
677        if let Some((chunk_idx, edge_idx)) = self.scan_overflow_for_edge_id(src_vid, edge_id) {
678            let probe = {
679                let chunks = self.overflow_chunks.get(src_vid).unwrap();
680                chunks[chunk_idx].slot_at(edge_idx).unwrap()
681            };
682            if let Some(chunks) = self.overflow_chunks.get_mut(src_vid) {
683                if can_revert_delete(&probe, ts) {
684                    chunks[chunk_idx].cold_at_mut(edge_idx).unwrap().delete_ts = Timestamp::MAX;
685                    self.edge_count += 1;
686                    self.track_live_insert(
687                        src_vid,
688                        probe.endpoint,
689                        probe.rank,
690                        EdgePosition::Overflow {
691                            chunk: chunk_idx as u32,
692                            slot: edge_idx as u32,
693                        },
694                    );
695                    return true;
696                }
697            }
698        }
699
700        false
701    }
702
703    /// Reserve primary capacity for a batch of `(src, count)` pairs.
704    ///
705    /// Each touched row is sized once at the packed density target for its
706    /// current live width plus the incoming count, so the following inserts
707    /// land in reserved gaps instead of allocating chunk by chunk. Rows not
708    /// listed are untouched. Live sets are left alone; per-edge inserts keep
709    /// maintaining them incrementally.
710    ///
711    /// Two growth paths share the sizing above. Small batches touching few
712    /// rows append fresh blocks at the tail, moving only touched rows and
713    /// leaving every other offset stable; the superseded blocks stay in the
714    /// vectors as orphaned filler until the next compaction reclaims them.
715    /// Large batches touching a substantial share of rows use the single
716    /// front-to-back rebuild, which moves each reserved byte exactly once
717    /// and reclaims all orphaned filler. Row positions are row-relative, so
718    /// neither path disturbs the live-key index.
719    pub fn reserve_for_batch(&mut self, counts: &[(u32, usize)]) {
720        if counts.is_empty() {
721            return;
722        }
723        // Aggregate duplicate entries per row so one row is sized once.
724        // Ordered bulk writes arrive src-ordered, so the aggregation
725        // re-sort is skipped when the input already arrives ordered.
726        let mut aggregated: Vec<(u32, usize)> = Vec::with_capacity(counts.len());
727        {
728            let mut push = |src: u32, incoming: usize| {
729                if let Some(last) = aggregated.last_mut() {
730                    if last.0 == src {
731                        last.1 = last.1.saturating_add(incoming);
732                        return;
733                    }
734                }
735                aggregated.push((src, incoming));
736            };
737            if counts.windows(2).all(|w| w[0].0 <= w[1].0) {
738                for &(src, incoming) in counts {
739                    push(src, incoming);
740                }
741            } else {
742                let mut sorted = counts.to_vec();
743                sorted.sort_unstable_by_key(|(src, _)| *src);
744                for (src, incoming) in sorted {
745                    push(src, incoming);
746                }
747            }
748        }
749        // Make every touched row addressable with a primary block first.
750        for (src_vid, _) in &aggregated {
751            let src_idx = *src_vid as usize;
752            if src_idx >= self.vertex_capacity() {
753                self.ensure_vertex_capacity(src_idx + 1);
754            }
755            if self.rows.primary_capacities[src_idx] == 0 {
756                self.allocate_primary_block(src_idx);
757            }
758        }
759        // Compute target capacities; track whether anything must grow.
760        let mut new_caps = self.rows.primary_capacities.clone();
761        let mut total_extra = 0usize;
762        for (src_vid, incoming) in &aggregated {
763            let src_idx = *src_vid as usize;
764            let live = self.live_key_count(*src_vid);
765            let want = Self::sized_row_capacity(live.saturating_add(*incoming));
766            let have = new_caps[src_idx] as usize;
767            if want > have {
768                total_extra += want - have;
769                new_caps[src_idx] = want as u32;
770            }
771        }
772        if total_extra == 0 {
773            return;
774        }
775        let rows = self.vertex_capacity();
776        // Narrow batches avoid the full-table copy: each touched row moves
777        // once to a tail block while untouched rows keep their offsets and
778        // reuse hints. Positions are row-relative, so the live-key index
779        // needs no rebuild on either path.
780        if aggregated.len().saturating_mul(8) < rows.max(1) {
781            for (src_vid, incoming) in &aggregated {
782                let src_idx = *src_vid as usize;
783                let live = self.live_key_count(*src_vid);
784                let want = Self::sized_row_capacity(live.saturating_add(*incoming));
785                let have = self.rows.primary_capacities[src_idx] as usize;
786                if want <= have {
787                    continue;
788                }
789                let degree = self.rows.degrees[src_idx] as usize;
790                let old_base = self.rows.adj_offsets[src_idx] as usize;
791                if old_base.saturating_add(have) == self.hot_list.len() {
792                    let grow = want - have;
793                    self.hot_list
794                        .resize(self.hot_list.len() + grow, HotNbr::dead_gap());
795                    self.cold_list
796                        .resize(self.cold_list.len() + grow, ColdStamps::dead_gap());
797                    self.rows.primary_capacities[src_idx] = want as u32;
798                    self.add_capacity(grow);
799                } else {
800                    let new_base = self.hot_list.len();
801                    self.hot_list.resize(new_base + want, HotNbr::dead_gap());
802                    self.cold_list
803                        .resize(new_base + want, ColdStamps::dead_gap());
804                    if degree > 0 {
805                        self.hot_list
806                            .copy_within(old_base..old_base + degree, new_base);
807                        self.cold_list
808                            .copy_within(old_base..old_base + degree, new_base);
809                    }
810                    self.rows.adj_offsets[src_idx] = new_base as u32;
811                    self.rows.primary_capacities[src_idx] = want as u32;
812                    self.add_capacity(want);
813                    // The superseded block stays allocated as orphaned
814                    // filler, but it must not stay live: dumps persist the
815                    // whole column and loads count every live slot, so a
816                    // moved entry left behind would double-count.
817                    for slot in old_base..old_base + have {
818                        self.hot_list[slot] = HotNbr::dead_gap();
819                        self.cold_list[slot] = ColdStamps::dead_gap();
820                    }
821                }
822            }
823            return;
824        }
825        let total: usize = new_caps.iter().map(|&c| c as usize).sum();
826        let mut new_hot = Vec::with_capacity(total);
827        let mut new_cold = Vec::with_capacity(total);
828        let mut new_offsets = vec![0u32; rows];
829        for vid in 0..rows {
830            new_offsets[vid] = new_hot.len() as u32;
831            let base = self.rows.adj_offsets[vid] as usize;
832            let cap = self.rows.primary_capacities[vid] as usize;
833            let want = new_caps[vid] as usize;
834            if cap > 0 {
835                new_hot.extend_from_slice(&self.hot_list[base..base + cap]);
836                new_cold.extend_from_slice(&self.cold_list[base..base + cap]);
837            }
838            if want > cap {
839                new_hot.resize(new_hot.len() + (want - cap), HotNbr::dead_gap());
840                new_cold.resize(new_cold.len() + (want - cap), ColdStamps::dead_gap());
841            }
842        }
843        self.hot_list = new_hot;
844        self.cold_list = new_cold;
845        self.rows.adj_offsets = new_offsets;
846        self.rows.primary_capacities = new_caps;
847        self.add_capacity(total_extra);
848        self.reset_reuse_hints();
849    }
850
851    /// Bulk insert pre-grouped edges: `groups` holds one `(src, batch)` per
852    /// touched row with decoded `(endpoint, rank, edge_id, ts)` tuples.
853    ///
854    /// Each row is reserved once at the packed density target, written in a
855    /// single pass, and has its live set rebuilt once instead of per edge.
856    /// Duplicate keys inside or against the row are rejected before any write
857    /// when `check_duplicates` is set; otherwise the caller guarantees
858    /// uniqueness. Returns the inserted edge count.
859    pub fn batch_put_edges(
860        &mut self,
861        groups: &[RowEdgeBatch],
862        check_duplicates: bool,
863    ) -> StorageResult<usize> {
864        if check_duplicates {
865            // Scratch buffers live for the whole batch: each row clears and
866            // refills them instead of allocating per row, so duplicate
867            // checking over many rows keeps peak allocation to one key
868            // buffer plus one probe set.
869            let mut keys: Vec<(u32, i64)> = Vec::new();
870            let mut seen: HashSet<(u32, i64)> = HashSet::new();
871            for (src_vid, batch) in groups {
872                keys.clear();
873                keys.extend(
874                    batch
875                        .iter()
876                        .map(|(endpoint, rank, _, _)| (*endpoint, *rank)),
877                );
878                // Ordered batches arrive key-ordered; skip the re-sort when
879                // the keys already arrive ordered.
880                if !keys.windows(2).all(|w| w[0] <= w[1]) {
881                    keys.sort_unstable();
882                }
883                for window in keys.windows(2) {
884                    if window[0] == window[1] {
885                        return Err(StorageError::edge_already_exists(format!(
886                            "duplicate key in bulk batch for vertex {}",
887                            src_vid
888                        )));
889                    }
890                }
891                // One row scan into the reused set: per-edge
892                // `live_key_present` would rescan the row for every batch
893                // edge on set-free rows, degrading bulk loads to
894                // O(batch x degree). The transient set also absorbs batch
895                // keys as they are checked, so intra-row conflicts against
896                // both stored and staged edges surface in one pass.
897                seen.clear();
898                seen.reserve(batch.len());
899                self.visit_physical(*src_vid, |nbr| {
900                    if nbr.delete_ts == Timestamp::MAX {
901                        seen.insert((nbr.endpoint, nbr.rank));
902                    }
903                    true
904                });
905                for (endpoint, rank, _, _) in batch {
906                    if !seen.insert((*endpoint, *rank)) {
907                        return Err(StorageError::edge_already_exists(format!(
908                            "{} -> ({}, {})",
909                            src_vid, endpoint, rank
910                        )));
911                    }
912                }
913            }
914        }
915        let counts: Vec<(u32, usize)> = groups
916            .iter()
917            .map(|(src, batch)| (*src, batch.len()))
918            .collect();
919        self.reserve_for_batch(&counts);
920        let mut inserted = 0usize;
921        for (src_vid, batch) in groups {
922            let src_idx = *src_vid as usize;
923            // Running live width per row: counted once up front, then bumped
924            // per inserted edge, so overflow sizing never rescans the row.
925            let mut live = self.live_key_count(*src_vid);
926            for (endpoint, rank, edge_id, create_ts) in batch {
927                let nbr = Nbr::with_create_ts(*endpoint, *rank, *edge_id, *create_ts);
928                // Every batch edge is a live insert: keep the running width
929                // exact for the overflow sizing below.
930                live += 1;
931                let degree = self.rows.degrees[src_idx] as usize;
932                let cap = self.rows.primary_capacities[src_idx] as usize;
933                if degree < cap {
934                    let base = self.rows.adj_offsets[src_idx] as usize;
935                    self.set_slot(base + degree, nbr);
936                    self.mark_primary_unsorted(src_idx);
937                    self.rows.degrees[src_idx] += 1;
938                } else {
939                    self.append_overflow(*src_vid, nbr, live);
940                }
941                self.edge_count += 1;
942                inserted += 1;
943            }
944            self.rebuild_live_set_for_vertex(*src_vid);
945        }
946        Ok(inserted)
947    }
948
949    /// Delete the edge at `position` when it still holds `expected` id.
950    ///
951    /// Stale positions (row moved since the read) are refused with `Ok(false)`
952    /// instead of touching the wrong edge. Timestamp conflicts follow the
953    /// same state machine as the edge-id path.
954    pub fn delete_edge_at_position(
955        &mut self,
956        src_vid: u32,
957        position: EdgePosition,
958        expected: EdgeId,
959        ts: Timestamp,
960    ) -> StorageResult<bool> {
961        match position {
962            EdgePosition::Primary { slot } => {
963                let src_idx = src_vid as usize;
964                if src_idx >= self.vertex_capacity() {
965                    return Ok(false);
966                }
967                if slot as usize >= self.rows.degrees[src_idx] as usize {
968                    return Ok(false);
969                }
970                let idx = self.rows.adj_offsets[src_idx] as usize + slot as usize;
971                let probe = match (self.hot_list.get(idx), self.cold_list.get(idx)) {
972                    (Some(hot), Some(cold)) => Nbr::from_parts(*hot, *cold),
973                    _ => return Ok(false),
974                };
975                if probe.edge_id != expected {
976                    return Ok(false);
977                }
978                match decide_slot_delete(&probe, expected, ts)? {
979                    DeleteSlotOutcome::AlreadyStamped => return Ok(false),
980                    DeleteSlotOutcome::Stamped => {}
981                }
982                self.cold_list[idx].delete_ts = ts;
983                self.edge_count -= 1;
984                self.note_primary_tombstone(src_idx, slot as usize);
985                self.track_live_remove(src_vid, probe.endpoint, probe.rank);
986                Ok(true)
987            }
988            EdgePosition::Overflow { chunk, slot } => {
989                let Some(chunks) = self.overflow_chunks.get_mut(src_vid) else {
990                    return Ok(false);
991                };
992                let Some(chunk) = chunks.get_mut(chunk as usize) else {
993                    return Ok(false);
994                };
995                let probe = match (chunk.hot_at(slot as usize), chunk.cold_at(slot as usize)) {
996                    (Some(hot), Some(cold)) => Nbr::from_parts(hot, cold),
997                    _ => return Ok(false),
998                };
999                if probe.edge_id != expected {
1000                    return Ok(false);
1001                }
1002                match decide_slot_delete(&probe, expected, ts)? {
1003                    DeleteSlotOutcome::AlreadyStamped => return Ok(false),
1004                    DeleteSlotOutcome::Stamped => {}
1005                }
1006                chunk.cold_at_mut(slot as usize).unwrap().delete_ts = ts;
1007                self.edge_count -= 1;
1008                self.track_live_remove(src_vid, probe.endpoint, probe.rank);
1009                Ok(true)
1010            }
1011        }
1012    }
1013
1014    /// Revert the deletion at `position` when it still holds `expected` id
1015    /// and the tombstone predates `ts`. Stale positions are refused with
1016    /// `false`.
1017    pub fn revert_delete_at_position(
1018        &mut self,
1019        src_vid: u32,
1020        position: EdgePosition,
1021        expected: EdgeId,
1022        ts: Timestamp,
1023    ) -> bool {
1024        match position {
1025            EdgePosition::Primary { slot } => {
1026                let src_idx = src_vid as usize;
1027                if src_idx >= self.vertex_capacity() {
1028                    return false;
1029                }
1030                if slot as usize >= self.rows.degrees[src_idx] as usize {
1031                    return false;
1032                }
1033                let idx = self.rows.adj_offsets[src_idx] as usize + slot as usize;
1034                let probe = match (self.hot_list.get(idx), self.cold_list.get(idx)) {
1035                    (Some(hot), Some(cold)) => Nbr::from_parts(*hot, *cold),
1036                    _ => return false,
1037                };
1038                if probe.edge_id != expected || !can_revert_delete(&probe, ts) {
1039                    return false;
1040                }
1041                self.cold_list[idx].delete_ts = Timestamp::MAX;
1042                self.edge_count += 1;
1043                self.invalidate_reuse_hint(src_idx);
1044                self.track_live_insert(
1045                    src_vid,
1046                    probe.endpoint,
1047                    probe.rank,
1048                    EdgePosition::Primary { slot },
1049                );
1050                true
1051            }
1052            EdgePosition::Overflow { chunk, slot } => {
1053                let (chunk_idx, slot_idx) = (chunk, slot);
1054                let Some(chunks) = self.overflow_chunks.get_mut(src_vid) else {
1055                    return false;
1056                };
1057                let Some(chunk) = chunks.get_mut(chunk_idx as usize) else {
1058                    return false;
1059                };
1060                let probe = match (
1061                    chunk.hot_at(slot_idx as usize),
1062                    chunk.cold_at(slot_idx as usize),
1063                ) {
1064                    (Some(hot), Some(cold)) => Nbr::from_parts(hot, cold),
1065                    _ => return false,
1066                };
1067                if probe.edge_id != expected || !can_revert_delete(&probe, ts) {
1068                    return false;
1069                }
1070                chunk.cold_at_mut(slot_idx as usize).unwrap().delete_ts = Timestamp::MAX;
1071                self.edge_count += 1;
1072                self.track_live_insert(
1073                    src_vid,
1074                    probe.endpoint,
1075                    probe.rank,
1076                    EdgePosition::Overflow {
1077                        chunk: chunk_idx,
1078                        slot: slot_idx,
1079                    },
1080                );
1081                true
1082            }
1083        }
1084    }
1085}