Skip to main content

rushdb_backup/
export.rs

1//! Pinned logical graph export. This is not a canonical storage checkpoint:
2//! policy/index metadata, outcomes, history, and journal state are not included.
3
4use std::io::Write;
5
6use rushdb_lmpg::{reader::try_access, reconstruct};
7use rushdb_storage::{ReadView, ScanBounds, ScanProjection};
8use rushdb_types::{
9    ids::TenantId,
10    keys::{key, P_REL},
11    occurrence::{RecordHeader, RelationshipHeader},
12};
13
14/// Write records first, then relationships, from exactly one retained view.
15/// Header scans are bounded to 128 rows / 1 MiB per batch. Reconstruction is
16/// still eager per owner and subject to the view's local read limits; there
17/// is not yet a shared graph/operator memory budget.
18///
19/// Errors stop export and are never represented as missing rows. A writer can
20/// contain an incomplete prefix after failure; callers must discard it rather
21/// than publish it as a valid backup. Flush/fsync/atomic file publication are
22/// the caller's responsibility, not implied by a successful return here.
23///
24/// # Errors
25///
26/// Returns an error string if a header row is malformed, a key/header or
27/// ordinal mapping is inconsistent, a record/relationship cannot be
28/// reconstructed, the view is cancelled, the writer rejects bytes, or the
29/// exported count overflows.
30pub fn export_tenant_jsonl_to<W: Write + ?Sized>(
31    view: &ReadView,
32    tenant: TenantId,
33    writer: &mut W,
34) -> Result<(u64, u64), String> {
35    let mut counts = [0u64; 2];
36    // P20: one reconstruction session per export shares the family-definition
37    // cache across all owners (families are frozen within this pinned view).
38    let mut session = reconstruct::ReconstructSession::new(view, tenant);
39    for (kind, count) in counts.iter_mut().enumerate() {
40        // Records: every header row (`RECORD_HDR`). Relationships: the id-keyed
41        // header rows (a trailing separator excludes relationship-ordinal).
42        let prefix = if kind == 0 {
43            rushdb_types::keys::record_hdr_prefix(tenant)
44        } else {
45            key(tenant, &[P_REL, b""])
46        };
47        let mut cursor = view
48            .scan(ScanBounds::prefix(&prefix), ScanProjection::KeyValue)
49            .map_err(|e| e.to_string())?;
50        loop {
51            let batch = cursor
52                .next_batch(128, 1024 * 1024)
53                .map_err(|e| e.to_string())?;
54            for row in batch.rows {
55                let raw = row.value.ok_or("export: missing header value")?;
56                let line = if kind == 0 {
57                    record_line(&mut session, view, tenant, &row.key, &raw)?
58                } else {
59                    relationship_line(&mut session, view, tenant, &row.key, &raw)?
60                };
61                view.check_cancelled().map_err(|e| e.to_string())?;
62                serde_json::to_writer(&mut *writer, &line).map_err(|e| e.to_string())?;
63                writer.write_all(b"\n").map_err(|e| e.to_string())?;
64                view.check_cancelled().map_err(|e| e.to_string())?;
65                *count = count.checked_add(1).ok_or("export count overflow")?;
66            }
67            if batch.done {
68                break;
69            }
70        }
71    }
72    // Every header was resolved through its identity row above; a surplus
73    // identity row (an id pointing at another record's ordinal) would
74    // otherwise be silently dropped from the export.
75    let identities = count_keys(view, &rushdb_types::keys::record_id_prefix(tenant))?;
76    if identities != counts[0] {
77        return Err("export: record identity index does not match headers".into());
78    }
79    Ok((counts[0], counts[1]))
80}
81
82/// Number of keys under `prefix`, scanned in bounded key-only batches.
83fn count_keys(view: &ReadView, prefix: &[u8]) -> Result<u64, String> {
84    let mut cursor = view
85        .scan(ScanBounds::prefix(prefix), ScanProjection::Keys)
86        .map_err(|e| e.to_string())?;
87    let mut total = 0u64;
88    loop {
89        let batch = cursor
90            .next_batch(1024, 1024 * 1024)
91            .map_err(|e| e.to_string())?;
92        total = total
93            .checked_add(batch.rows.len() as u64)
94            .ok_or("export count overflow")?;
95        if batch.done {
96            return Ok(total);
97        }
98    }
99}
100
101/// One exported record line from its header row (`ordinal` key suffix).
102fn record_line(
103    session: &mut reconstruct::ReconstructSession<'_, ReadView>,
104    view: &ReadView,
105    tenant: TenantId,
106    header_key: &[u8],
107    raw: &[u8],
108) -> Result<serde_json::Value, String> {
109    let ordinal = rushdb_types::keys::parse_record_hdr_key(header_key)
110        .ok_or("export: invalid record header key")?;
111    let (record_id, label) =
112        rushdb_types::keys::decode_record_hdr(raw).ok_or("export: invalid record header")?;
113    let hdr = RecordHeader {
114        record_id,
115        ordinal,
116        label: label.to_owned(),
117    };
118    let indexed = try_access::record_by_id(view, tenant, &rushdb_types::ids::RecordId(record_id))
119        .map_err(|e| e.to_string())?
120        .ok_or("export: missing record id mapping")?;
121    if indexed.ordinal != hdr.ordinal {
122        return Err("export: record id/ordinal identity mismatch".into());
123    }
124    let rec = session
125        .reconstruct_record(hdr.ordinal)
126        .map_err(|e| e.to_string())?
127        .ok_or("export: missing record during reconstruction")?;
128    let serde_json::Value::Object(mut properties) = rec else {
129        return Err("export: invalid record object".into());
130    };
131    properties.retain(|name, _| !rushdb_types::value::is_reserved_name(name));
132    Ok(serde_json::json!({
133        "op": "put", "kind": "record",
134        "id": hex(&hdr.record_id), "label": hdr.label,
135        "properties": properties,
136    }))
137}
138
139/// One exported relationship line from its id-keyed header row.
140fn relationship_line(
141    session: &mut reconstruct::ReconstructSession<'_, ReadView>,
142    view: &ReadView,
143    tenant: TenantId,
144    row_key: &[u8],
145    raw: &[u8],
146) -> Result<serde_json::Value, String> {
147    let hdr: RelationshipHeader = serde_json::from_slice(raw)
148        .map_err(|e| format!("export: invalid relationship header: {e}"))?;
149    if row_key != key(tenant, &[P_REL, &hdr.relationship_id]) {
150        return Err("export: relationship key/header identity mismatch".into());
151    }
152    let indexed = try_access::relationship_by_ord(view, tenant, hdr.ordinal)
153        .map_err(|e| e.to_string())?
154        .ok_or("export: missing relationship ordinal mapping")?;
155    if indexed.relationship_id != hdr.relationship_id {
156        return Err("export: relationship ordinal identity mismatch".into());
157    }
158    for (id, ordinal) in [
159        (hdr.source_id, hdr.source_ord),
160        (hdr.target_id, hdr.target_ord),
161    ] {
162        // Resolve by public id so a record whose id row was lost is caught
163        // instead of emitting a dangling relationship.
164        let endpoint = try_access::record_by_id(view, tenant, &rushdb_types::ids::RecordId(id))
165            .map_err(|e| e.to_string())?
166            .ok_or("export: missing relationship endpoint")?;
167        if endpoint.ordinal != ordinal {
168            return Err("export: relationship endpoint identity mismatch".into());
169        }
170    }
171    let rel = session
172        .reconstruct_relationship(hdr.ordinal)
173        .map_err(|e| e.to_string())?
174        .ok_or("export: missing relationship during reconstruction")?;
175    let serde_json::Value::Object(mut properties) = rel else {
176        return Err("export: invalid relationship object".into());
177    };
178    properties.retain(|name, _| !rushdb_types::value::is_reserved_name(name));
179    Ok(serde_json::json!({
180        "op": "put", "kind": "relationship",
181        "id": hex(&hdr.relationship_id), "type": hdr.rel_type,
182        "source": hex(&hdr.source_id), "target": hex(&hdr.target_id),
183        "properties": properties,
184    }))
185}
186
187fn hex(id: &[u8; 16]) -> String {
188    uuid::Uuid::from_bytes(*id).simple().to_string()
189}