Skip to main content

graphar/writer/
edge_writer.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use arrow_array::{Int64Array, RecordBatch};
8use arrow_schema::{DataType as ArrowDataType, Field, Schema};
9
10use crate::{
11    error::Result,
12    io,
13    metadata::{EdgeInfo, PropertyGroup},
14    types::AdjListType,
15    writer::vertex_writer::{PropertyValue, build_record_batch_for_edges},
16};
17
18/// An edge with source/destination and optional properties.
19#[derive(Debug, Clone)]
20pub struct Edge {
21    pub src: i64,
22    pub dst: i64,
23    properties: HashMap<String, PropertyValue>,
24}
25
26impl Edge {
27    pub fn new(src: i64, dst: i64) -> Self {
28        Self {
29            src,
30            dst,
31            properties: HashMap::new(),
32        }
33    }
34
35    pub fn add_property(mut self, name: impl Into<String>, value: PropertyValue) -> Self {
36        self.properties.insert(name.into(), value);
37        self
38    }
39
40    pub fn get(&self, name: &str) -> Option<&PropertyValue> {
41        self.properties.get(name)
42    }
43}
44
45/// Builds and writes edge chunks (adj list + properties + offsets) to disk.
46pub struct EdgesBuilder {
47    info: EdgeInfo,
48    base_dir: PathBuf,
49    adj_type: AdjListType,
50    /// Total number of vertices in the aligned dimension (src or dst).
51    vertex_count: u64,
52    edges: Vec<Edge>,
53}
54
55impl EdgesBuilder {
56    pub fn new(
57        info: EdgeInfo,
58        base_dir: impl AsRef<Path>,
59        adj_type: AdjListType,
60        vertex_count: u64,
61    ) -> Self {
62        Self {
63            info,
64            base_dir: base_dir.as_ref().to_path_buf(),
65            adj_type,
66            vertex_count,
67            edges: Vec::new(),
68        }
69    }
70
71    pub fn add_edge(&mut self, edge: Edge) {
72        self.edges.push(edge);
73    }
74
75    /// Sort edges by the alignment key and flush as chunked files.
76    pub fn dump(&mut self) -> Result<()> {
77        // Chunk membership tests the alignment id as a `u64` (see `key` below),
78        // so sort by that same `u64` key. For the only ids that ever land in a
79        // chunk — non-negative vertex ids in `[0, vertex_count)` — this orders
80        // identically to sorting by the raw `i64`; out-of-range ids (negatives,
81        // which cast to huge `u64`) are dropped either way, so the written
82        // output is unchanged. Keeping the sort key equal to the membership key
83        // is what lets the single advancing cursor below stay correct.
84        let aligned_by_src = self.adj_type.aligned_by_src();
85        let key = |e: &Edge| -> u64 {
86            if aligned_by_src {
87                e.src as u64
88            } else {
89                e.dst as u64
90            }
91        };
92        self.edges.sort_unstable_by_key(key);
93
94        let vchunk_size = if aligned_by_src {
95            self.info.src_chunk_size
96        } else {
97            self.info.dst_chunk_size
98        };
99        let num_vertex_chunks = self.vertex_count.div_ceil(vchunk_size);
100
101        let file_type = self
102            .info
103            .get_adj_list_info(&self.adj_type)
104            .map(|a| a.file_type.clone())
105            .unwrap_or_default();
106
107        // The edges are now sorted ascending by the alignment key, and the
108        // vertex chunks below are visited with monotonically increasing
109        // `[v_start, v_end)` ranges. So a single cursor can walk the sorted Vec
110        // once, yielding the contiguous slice for each chunk in O(edges) total
111        // instead of re-scanning every edge per vertex chunk.
112        //
113        // The first chunk starts at key 0 and the cursor begins at the front.
114        // Edges whose key lands beyond the final chunk's `v_end`
115        // (>= vertex_count) are simply never reached and thus dropped, matching
116        // the original per-chunk filter.
117        let mut cursor = 0usize;
118
119        for vc in 0..num_vertex_chunks {
120            let v_start = vc * vchunk_size;
121            let v_end = ((vc + 1) * vchunk_size).min(self.vertex_count);
122
123            // Edges belonging to this vertex chunk: a contiguous run in the
124            // sorted Vec starting at `cursor`. Advance `cursor` to the first
125            // edge with `key >= v_end`.
126            debug_assert!(self.edges[cursor..].iter().all(|e| key(e) >= v_start));
127            let chunk_end = cursor + self.edges[cursor..].partition_point(|e| key(e) < v_end);
128            let chunk_edges: Vec<&Edge> = self.edges[cursor..chunk_end].iter().collect();
129            cursor = chunk_end;
130
131            // Write adjacency list chunks.
132            let edge_chunk_size = self.info.chunk_size as usize;
133            for (ec, edge_slice) in chunk_edges.chunks(edge_chunk_size).enumerate() {
134                let batch = build_adj_list_batch(edge_slice)?;
135                let path = self.base_dir.join(self.info.adj_list_chunk_path(
136                    &self.adj_type,
137                    vc,
138                    ec as u64,
139                )?);
140                io::write_chunk(&path, &[batch], &file_type)?;
141
142                // Write property group chunks.
143                for group in self.info.property_groups.iter() {
144                    let prop_batch = build_edge_prop_batch(edge_slice, group)?;
145                    let prop_path = self.base_dir.join(self.info.property_chunk_path(
146                        &self.adj_type,
147                        group,
148                        vc,
149                        ec as u64,
150                    ));
151                    io::write_chunk(&prop_path, &[prop_batch], &group.file_type)?;
152                }
153            }
154
155            // Write offset chunk for ordered adj lists.
156            if self.adj_type.is_ordered() {
157                let offset_batch =
158                    build_offset_batch(&chunk_edges, v_start, v_end, vchunk_size, aligned_by_src)?;
159                let offset_path = self
160                    .base_dir
161                    .join(self.info.offset_chunk_path(&self.adj_type, vc)?);
162                io::write_chunk(&offset_path, &[offset_batch], &file_type)?;
163            }
164        }
165
166        self.edges.clear();
167        Ok(())
168    }
169}
170
171fn build_adj_list_batch(edges: &[&Edge]) -> Result<RecordBatch> {
172    let srcs: Vec<i64> = edges.iter().map(|e| e.src).collect();
173    let dsts: Vec<i64> = edges.iter().map(|e| e.dst).collect();
174    let schema = Arc::new(Schema::new(vec![
175        Field::new("src", ArrowDataType::Int64, false),
176        Field::new("dst", ArrowDataType::Int64, false),
177    ]));
178    Ok(RecordBatch::try_new(
179        schema,
180        vec![
181            Arc::new(Int64Array::from(srcs)),
182            Arc::new(Int64Array::from(dsts)),
183        ],
184    )?)
185}
186
187fn build_offset_batch(
188    chunk_edges: &[&Edge],
189    v_start: u64,
190    v_end: u64,
191    _vchunk_size: u64,
192    aligned_by_src: bool,
193) -> Result<RecordBatch> {
194    let count = (v_end - v_start) as usize + 1;
195    let mut offsets = vec![0i64; count];
196    // Histogram: count edges per vertex slot, then prefix-sum.
197    let mut hist = vec![0i64; (v_end - v_start) as usize];
198    for edge in chunk_edges {
199        // Key the histogram on the same vertex dimension the adj list is aligned
200        // by — `src` for *_by_source, `dst` for *_by_dest. Mirrors the `key`
201        // closure used in `dump()`; using `src` unconditionally would bucket
202        // ordered_by_dest offsets against the wrong dimension.
203        let key_id = if aligned_by_src { edge.src } else { edge.dst };
204        let slot = (key_id as u64).saturating_sub(v_start) as usize;
205        if slot < hist.len() {
206            hist[slot] += 1;
207        }
208    }
209    // Prefix sum -> offsets; first row is always 0.
210    let mut running = 0i64;
211    offsets[0] = 0;
212    for (i, count) in hist.iter().enumerate() {
213        running += count;
214        offsets[i + 1] = running;
215    }
216
217    let schema = Arc::new(Schema::new(vec![Field::new(
218        "offset",
219        ArrowDataType::Int64,
220        false,
221    )]));
222    Ok(RecordBatch::try_new(
223        schema,
224        vec![Arc::new(Int64Array::from(offsets))],
225    )?)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use arrow_array::Array;
232
233    fn offsets_of(batch: &RecordBatch) -> Vec<i64> {
234        batch
235            .column(0)
236            .as_any()
237            .downcast_ref::<Int64Array>()
238            .unwrap()
239            .values()
240            .to_vec()
241    }
242
243    #[test]
244    fn offset_batch_keys_on_dst_for_ordered_by_dest() {
245        // An OrderedByDest chunk is aligned/sorted by `dst`; the offset
246        // histogram must bucket on `dst`, not `src`.
247        //
248        // FAIL-ON-BUG: the old code keyed on `edge.src` unconditionally. Here
249        // every `src` (100) is outside [v_start, v_end) = [0, 3), so the buggy
250        // path drops all edges and yields all-zero offsets. PASS-ON-FIX: keying
251        // on `dst` produces the correct prefix sums.
252        let edges = vec![
253            Edge::new(100, 0),
254            Edge::new(100, 0),
255            Edge::new(100, 1),
256            Edge::new(100, 2),
257        ];
258        let refs: Vec<&Edge> = edges.iter().collect();
259
260        // aligned_by_src = false → key on dst.
261        let batch = build_offset_batch(&refs, 0, 3, 4, false).unwrap();
262        assert_eq!(
263            offsets_of(&batch),
264            vec![0, 2, 3, 4],
265            "ordered_by_dest offsets must be the dst-keyed prefix sum",
266        );
267
268        // Sanity: keying on src (aligned_by_src = true) drops every edge here.
269        let buggy = build_offset_batch(&refs, 0, 3, 4, true).unwrap();
270        assert_eq!(offsets_of(&buggy), vec![0, 0, 0, 0]);
271    }
272
273    #[test]
274    fn offset_batch_keys_on_src_for_ordered_by_source() {
275        // The by-source path is unchanged: bucket on `src`.
276        let edges = vec![Edge::new(0, 9), Edge::new(1, 9), Edge::new(1, 9)];
277        let refs: Vec<&Edge> = edges.iter().collect();
278        let batch = build_offset_batch(&refs, 0, 2, 4, true).unwrap();
279        assert_eq!(offsets_of(&batch), vec![0, 1, 3]);
280    }
281}
282
283fn build_edge_prop_batch(edges: &[&Edge], group: &PropertyGroup) -> Result<RecordBatch> {
284    // Build pseudo-Vertex objects from edge properties for each property in the group.
285    let pseudo_vertices: Vec<crate::writer::vertex_writer::Vertex> = edges
286        .iter()
287        .map(|e| {
288            let mut v = crate::writer::vertex_writer::Vertex::new();
289            for prop in &group.properties {
290                if let Some(val) = e.get(&prop.name) {
291                    v = v.add_property(prop.name.clone(), val.clone());
292                }
293            }
294            v
295        })
296        .collect();
297
298    build_record_batch_for_edges(&pseudo_vertices, group)
299}