Skip to main content

linkrs_sync/manager/
intents.rs

1//! Transactional intent staging: pre-filtering, backpressure, staging,
2//! commit materialization and rollback.
3use super::*;
4use crate::outbox::OutboxPayload;
5use crate::types::ChangeType;
6use linkrs_core::types::{TransactionContextInfo, TransactionId};
7#[cfg(feature = "fulltext")]
8use linkrs_core::Value;
9pub(crate) fn payload_to_intent(
10    txn_id: linkrs_core::types::TransactionId,
11    intent_sequence: u32,
12    target_name: &str,
13    payload: &OutboxPayload,
14) -> Result<linkrs_core::wal::OutboxIntent, SyncError> {
15    use linkrs_core::types::{IdempotencyKey, IndexGeneration, OrderingKey, TargetId, VertexId};
16    use linkrs_core::wal::{EntityRef, IndexMutation, IndexOperation, WAL_SYNC_WIRE_VERSION};
17
18    let (space_id, index_name, entity_ref, operation) = match payload {
19        OutboxPayload::Vertex {
20            space_id,
21            tag_name,
22            vertex_id,
23            change_type,
24            ..
25        } => (
26            *space_id,
27            tag_name.as_str(),
28            EntityRef::Vertex(VertexId::try_from(vertex_id).map_err(|error| {
29                SyncError::PersistenceError(format!("Invalid vertex ID in outbox event: {}", error))
30            })?),
31            match change_type {
32                ChangeType::Insert | ChangeType::Update => IndexOperation::Upsert,
33                ChangeType::Delete => IndexOperation::Delete,
34            },
35        ),
36        OutboxPayload::EdgeInsert { space_id, edge } => (
37            *space_id,
38            edge.edge_type.as_str(),
39            EntityRef::Edge {
40                src: edge.src,
41                dst: edge.dst,
42                edge_type: stable_hash(edge.edge_type.as_bytes()) as u32,
43                ranking: edge.ranking,
44            },
45            IndexOperation::Upsert,
46        ),
47        OutboxPayload::EdgeDelete {
48            space_id,
49            src,
50            dst,
51            edge_type,
52            ranking,
53            ..
54        } => (
55            *space_id,
56            edge_type.as_str(),
57            EntityRef::Edge {
58                src: VertexId::try_from(src).map_err(|error| {
59                    SyncError::PersistenceError(format!(
60                        "Invalid edge source ID in outbox event: {}",
61                        error
62                    ))
63                })?,
64                dst: VertexId::try_from(dst).map_err(|error| {
65                    SyncError::PersistenceError(format!(
66                        "Invalid edge destination ID in outbox event: {}",
67                        error
68                    ))
69                })?,
70                edge_type: stable_hash(edge_type.as_bytes()) as u32,
71                ranking: *ranking,
72            },
73            IndexOperation::Delete,
74        ),
75        OutboxPayload::CreateIndex {
76            space_id,
77            index_name,
78            ..
79        } => (
80            *space_id,
81            index_name.as_str(),
82            EntityRef::Vertex(VertexId::from_u64(0)),
83            IndexOperation::Upsert,
84        ),
85        OutboxPayload::DropIndex {
86            space_id,
87            index_name,
88            ..
89        } => (
90            *space_id,
91            index_name.as_str(),
92            EntityRef::Vertex(VertexId::from_u64(0)),
93            IndexOperation::Delete,
94        ),
95        OutboxPayload::DropSpace { space_id } => (
96            *space_id,
97            "__space__",
98            EntityRef::Vertex(VertexId::from_u64(0)),
99            IndexOperation::Delete,
100        ),
101        OutboxPayload::DropTag { space_id, tag_name } => (
102            *space_id,
103            tag_name.as_str(),
104            EntityRef::Vertex(VertexId::from_u64(0)),
105            IndexOperation::Delete,
106        ),
107    };
108    let sequence = u64::from(intent_sequence).saturating_add(1);
109    let id = format!("{}:{}:{}", txn_id.0, target_name, sequence);
110    let target = TargetId::new(target_name.to_string()).map_err(SyncError::PersistenceError)?;
111    // Entity-scoped ordering key so concurrent updates to the same graph
112    // entity are serialized by the SQLite `NOT EXISTS (ordering_key)` fence
113    // in `claim_next`. The previous per-event `default:{txn}:{seq}` made every
114    // ordering_key unique, so the fence was vacuously true. Now all events
115    // for the same logical entity share one key: {target}:{space}:{index}:{entity}.
116    let ordering_key = {
117        let entity_str = match payload {
118            OutboxPayload::Vertex { vertex_id, .. } => format!("{}", vertex_id),
119            OutboxPayload::EdgeInsert { edge, .. } => {
120                format!("{}->{}#{}", edge.src, edge.dst, edge.ranking)
121            }
122            OutboxPayload::EdgeDelete {
123                src, dst, ranking, ..
124            } => format!("{}->{}#{}", src, dst, ranking),
125            OutboxPayload::CreateIndex { index_name, .. }
126            | OutboxPayload::DropIndex { index_name, .. } => {
127                // DDL is per-index; serialize all DDL for the same index.
128                format!("ddl:{}", index_name)
129            }
130            OutboxPayload::DropSpace { .. } => "ddl:__space__".to_string(),
131            OutboxPayload::DropTag { tag_name, .. } => {
132                format!("ddl:tag:{}", tag_name)
133            }
134        };
135        let key = format!("{}:{}:{}:{}", target_name, space_id, index_name, entity_str);
136        OrderingKey::new(key).map_err(SyncError::PersistenceError)?
137    };
138    let idempotency_key = IdempotencyKey::new(id).map_err(SyncError::PersistenceError)?;
139    Ok(linkrs_core::wal::OutboxIntent {
140        wire_version: WAL_SYNC_WIRE_VERSION,
141        transaction_id: txn_id,
142        intent_sequence,
143        mutation: IndexMutation {
144            wire_version: WAL_SYNC_WIRE_VERSION,
145            target,
146            index_id: stable_hash(
147                format!("{}:{}:{}", target_name, space_id, index_name).as_bytes(),
148            ),
149            index_generation: IndexGeneration::new(1),
150            entity_ref,
151            operation,
152            document_or_vector: postcard::to_allocvec(payload).map_err(|error| {
153                SyncError::PersistenceError(format!(
154                    "Failed to serialize target mutation: {}",
155                    error
156                ))
157            })?,
158            idempotency_key,
159            ordering_key,
160        },
161    })
162}
163pub(crate) fn stable_hash(bytes: &[u8]) -> u64 {
164    let mut hash = 0xcbf29ce484222325u64;
165    for byte in bytes {
166        hash ^= u64::from(*byte);
167        hash = hash.wrapping_mul(0x100000001b3);
168    }
169    // Index IDs are persisted in SQLite INTEGER columns, so keep the
170    // deterministic hash within the signed 64-bit range.
171    hash & (i64::MAX as u64)
172}
173#[cfg(feature = "vector")]
174pub(crate) fn format_vector_point_id(
175    vertex_id: &linkrs_core::Value,
176    tag: &str,
177    field: &str,
178) -> String {
179    let raw = format!("{}", vertex_id);
180    // Escape the delimiter '#' and the escape char '%' inside the vertex id
181    // so that decoding remains unambiguous across Local and Qdrant backends.
182    // Percent-encoding is minimal and deterministic; '%' is escaped first to
183    // avoid double-encoding.
184    let encoded = raw.replace('%', "%25").replace('#', "%23");
185    format!("{}#{}#{}", encoded, tag, field)
186}
187impl super::SyncManager {
188    #[cfg(any(feature = "fulltext", feature = "vector"))]
189    fn delivery_target_names(&self) -> Vec<&'static str> {
190        let mut targets = Vec::new();
191        #[cfg(feature = "fulltext")]
192        if self.sync_coordinator.is_some() {
193            targets.push("fulltext");
194        }
195        #[cfg(feature = "vector")]
196        if self.vector_coordinator.is_some() {
197            targets.push("vector");
198        }
199        targets
200    }
201
202    /// No sync engines are compiled in, so there are no delivery targets.
203    #[cfg(not(any(feature = "fulltext", feature = "vector")))]
204    fn delivery_target_names(&self) -> Vec<&'static str> {
205        Vec::new()
206    }
207
208    fn payload_needs_vector(&self, payload: &OutboxPayload) -> bool {
209        #[cfg(feature = "vector")]
210        {
211            let Some(coord) = self.vector_coordinator.as_ref() else {
212                return false;
213            };
214            match payload {
215                OutboxPayload::Vertex {
216                    space_id,
217                    tag_name,
218                    properties,
219                    change_type,
220                    ..
221                } => {
222                    if matches!(change_type, ChangeType::Delete) {
223                        // For deletes with empty properties, still need to fan out
224                        // to all vector fields of this tag if any index exists.
225                        if properties.is_empty() {
226                            return coord
227                                .list_indexes()
228                                .iter()
229                                .any(|m| m.space_id == *space_id && m.tag_name == *tag_name);
230                        }
231                    }
232                    for (field, value) in properties {
233                        if value.as_vector().is_some()
234                            && coord.index_exists(*space_id, tag_name, field)
235                        {
236                            return true;
237                        }
238                    }
239                    // Delete case with no matching field still handled above; for
240                    // Insert/Update with vector-like values but missing index, the
241                    // mutation will be filtered later in apply_vector_mutation, but
242                    // we pre-filter here to avoid write amplification. If no
243                    // indexed field matched, no vector intent is needed.
244                    false
245                }
246                OutboxPayload::CreateIndex { fields, .. } => {
247                    fields.iter().any(|(_, v)| v.as_vector().is_some())
248                }
249                OutboxPayload::DropIndex {
250                    space_id,
251                    schema_name,
252                    fields,
253                    ..
254                } => {
255                    // Drop needs vector only if any dropped field is a vector index
256                    fields
257                        .iter()
258                        .any(|field| coord.index_exists(*space_id, schema_name, field))
259                }
260                OutboxPayload::DropSpace { space_id } => {
261                    coord.list_indexes().iter().any(|m| m.space_id == *space_id)
262                }
263                OutboxPayload::DropTag { space_id, tag_name } => coord
264                    .list_indexes()
265                    .iter()
266                    .any(|m| m.space_id == *space_id && m.tag_name == *tag_name),
267                OutboxPayload::EdgeInsert { .. } | OutboxPayload::EdgeDelete { .. } => false,
268            }
269        }
270        #[cfg(not(feature = "vector"))]
271        {
272            let _ = payload;
273            false
274        }
275    }
276
277    fn payload_needs_fulltext(&self, payload: &OutboxPayload) -> bool {
278        #[cfg(feature = "fulltext")]
279        {
280            let Some(coord) = self.sync_coordinator.as_ref() else {
281                return false;
282            };
283            let manager = coord.fulltext_manager();
284            match payload {
285                OutboxPayload::Vertex {
286                    space_id,
287                    tag_name,
288                    properties,
289                    change_type,
290                    ..
291                } => {
292                    if matches!(change_type, ChangeType::Delete) && properties.is_empty() {
293                        return manager
294                            .get_space_indexes(*space_id)
295                            .into_iter()
296                            .any(|meta| meta.tag_name == *tag_name);
297                    }
298                    for (field, value) in properties {
299                        let is_text = matches!(value, Value::String(_) | Value::FixedString(_));
300                        if is_text && manager.has_index(*space_id, tag_name, field) {
301                            return true;
302                        }
303                    }
304                    false
305                }
306                OutboxPayload::CreateIndex { fields, .. } => fields
307                    .iter()
308                    .any(|(_, v)| matches!(v, Value::String(_) | Value::FixedString(_))),
309                OutboxPayload::DropIndex {
310                    space_id,
311                    schema_name,
312                    fields,
313                    ..
314                } => fields.iter().any(|field| {
315                    manager
316                        .get_space_indexes(*space_id)
317                        .iter()
318                        .any(|meta| meta.tag_name == *schema_name && meta.field_name == *field)
319                }),
320                OutboxPayload::DropSpace { space_id } => {
321                    !manager.get_space_indexes(*space_id).is_empty()
322                }
323                OutboxPayload::DropTag { space_id, tag_name } => manager
324                    .get_space_indexes(*space_id)
325                    .into_iter()
326                    .any(|meta| meta.tag_name == *tag_name),
327                OutboxPayload::EdgeInsert { space_id, edge } => {
328                    edge.props.iter().any(|(field, value)| {
329                        let is_text = matches!(value, Value::String(_) | Value::FixedString(_));
330                        is_text && manager.has_index(*space_id, &edge.edge_type, field)
331                    })
332                }
333                OutboxPayload::EdgeDelete {
334                    space_id,
335                    edge_type,
336                    ..
337                } => manager
338                    .get_space_indexes(*space_id)
339                    .into_iter()
340                    .any(|meta| meta.tag_name == *edge_type),
341            }
342        }
343        #[cfg(not(feature = "fulltext"))]
344        {
345            let _ = payload;
346            false
347        }
348    }
349
350    pub(super) fn stage_intent(
351        &self,
352        txn_id: TransactionId,
353        payload: OutboxPayload,
354    ) -> Result<(), SyncError> {
355        // Pre-filter by durable outbox requirement: if at least one target
356        // actually needs this payload and the outbox is not configured, fail fast.
357        let needs_vector = self.payload_needs_vector(&payload);
358        let needs_fulltext = self.payload_needs_fulltext(&payload);
359        let has_needed_target = needs_vector || needs_fulltext;
360        // If neither target needs the payload, skip entirely to avoid write
361        // amplification for pure-graph mutations.
362        if !has_needed_target {
363            // Still validate outbox presence if any target *could* have been
364            // needed but was filtered due to missing index: no intent needed.
365            return Ok(());
366        }
367        if has_needed_target && self.sqlite_outbox.is_none() {
368            return Err(SyncError::PersistenceError(
369                "Synchronized writes require a configured durable outbox".to_string(),
370            ));
371        }
372        // Backpressure: bound the in-memory staging map so a hot writer cannot
373        // grow `pending_intents` without bound and hide `p99` delivery latency.
374        let needed_targets = self
375            .delivery_target_names()
376            .into_iter()
377            .filter(|name| match *name {
378                "vector" => needs_vector,
379                "fulltext" => needs_fulltext,
380                _ => true,
381            })
382            .count();
383        if needed_targets > 0 {
384            // Per-transaction limit
385            let current_len = self
386                .pending_intents
387                .get(&txn_id)
388                .map(|v| v.len())
389                .unwrap_or(0);
390            if current_len + needed_targets > self.backpressure.max_pending_per_txn {
391                return Err(SyncError::OutboxBackpressure(format!(
392                    "txn {} pending {} + {} exceeds max_pending_per_txn {}",
393                    txn_id.0, current_len, needed_targets, self.backpressure.max_pending_per_txn
394                )));
395            }
396            // Global in-memory limit (approximate; durable backlog is counted
397            // separately via `outbox_pending` metric but not used for fencing
398            // to keep the hot path lock-free).
399            let total_pending: usize = self.pending_intents.iter().map(|e| e.value().len()).sum();
400            if total_pending + needed_targets > self.backpressure.max_pending_total {
401                return Err(SyncError::OutboxBackpressure(format!(
402                    "global pending {} + {} exceeds max_outbox_pending {}",
403                    total_pending, needed_targets, self.backpressure.max_pending_total
404                )));
405            }
406            // Optional: also fence on durable `pending` if outbox is configured
407            // and already large. This is best-effort via the cached stats; a
408            // stale read may still allow one extra batch through.
409            if let Some(outbox) = self.sqlite_outbox.as_ref() {
410                if let Some(stats) = self
411                    .stats_manager
412                    .as_ref()
413                    .and_then(|s| s.get_value(linkrs_metrics::MetricType::OutboxPending))
414                {
415                    let durable_pending = stats as usize;
416                    if durable_pending + total_pending + needed_targets
417                        > self.backpressure.max_pending_total
418                    {
419                        return Err(SyncError::OutboxBackpressure(format!(
420                            "durable pending {} + staged {} + {} exceeds limit {}",
421                            durable_pending,
422                            total_pending,
423                            needed_targets,
424                            self.backpressure.max_pending_total
425                        )));
426                    }
427                    let _ = outbox;
428                }
429            }
430        }
431        let mut intents = self.pending_intents.entry(txn_id).or_default();
432        for target_name in self.delivery_target_names() {
433            let should_deliver = match target_name {
434                "vector" => needs_vector,
435                "fulltext" => needs_fulltext,
436                _ => true,
437            };
438            if !should_deliver {
439                continue;
440            }
441            let sequence = u32::try_from(intents.len()).map_err(|_| {
442                SyncError::PersistenceError(
443                    "Transaction intent count exceeds u32 range".to_string(),
444                )
445            })?;
446            intents.push(payload_to_intent(txn_id, sequence, target_name, &payload)?);
447        }
448        // Update the staged backlog gauge for observability.
449        if let Some(stats) = self.stats_manager.as_ref() {
450            let total: usize = self.pending_intents.iter().map(|e| e.value().len()).sum();
451            stats.set_value(linkrs_metrics::MetricType::OutboxPending, total as u64);
452        }
453        Ok(())
454    }
455
456    pub fn clear_transaction_intents(&self, txn_id: TransactionId) {
457        self.pending_intents.remove(&txn_id);
458    }
459
460    pub fn attach_transaction_context(&self, txn_id: TransactionId) -> TransactionContextInfo {
461        TransactionContextInfo::new(txn_id, 0, false, 0)
462    }
463
464    pub fn rollback_transaction_to_sequence_sync(
465        &self,
466        txn_id: TransactionId,
467        sequence: u64,
468    ) -> Result<(), SyncError> {
469        if let Some(mut intents) = self.pending_intents.get_mut(&txn_id) {
470            intents.retain(|intent| u64::from(intent.intent_sequence) <= sequence);
471        }
472
473        Ok(())
474    }
475
476    pub async fn rollback_transaction(
477        &self,
478        txn_id: linkrs_core::types::TransactionId,
479    ) -> Result<(), SyncError> {
480        self.rollback_transaction_to_sequence_sync(txn_id, 0)?;
481        self.pending_intents.remove(&txn_id);
482        Ok(())
483    }
484
485    pub fn rollback_transaction_sync(
486        &self,
487        txn_id: linkrs_core::types::TransactionId,
488    ) -> Result<(), SyncError> {
489        self.execute_sync(|| self.rollback_transaction(txn_id))
490    }
491
492    pub fn pending_transaction_intents(
493        &self,
494        txn_id: linkrs_core::types::TransactionId,
495    ) -> Result<Vec<linkrs_core::wal::OutboxIntent>, SyncError> {
496        Ok(self
497            .pending_intents
498            .get(&txn_id)
499            .map(|intents| intents.clone())
500            .unwrap_or_default())
501    }
502
503    /// Return the last staged intent sequence for a transaction.
504    ///
505    /// Savepoints retain intents whose sequence is less than or equal to the
506    /// saved boundary, so an empty transaction maps to boundary zero.
507    pub fn pending_transaction_intent_sequence(
508        &self,
509        txn_id: linkrs_core::types::TransactionId,
510    ) -> u64 {
511        self.pending_intents
512            .get(&txn_id)
513            .and_then(|intents| {
514                intents
515                    .last()
516                    .map(|intent| u64::from(intent.intent_sequence))
517            })
518            .unwrap_or(0)
519    }
520
521    pub fn materialize_committed_transaction(
522        &self,
523        txn_id: linkrs_core::types::TransactionId,
524        commit_lsn: linkrs_core::types::CommitLsn,
525        intents: &[linkrs_core::wal::OutboxIntent],
526    ) -> Result<(), SyncError> {
527        let Some(outbox) = &self.sqlite_outbox else {
528            return Ok(());
529        };
530        let mut targets = intents
531            .iter()
532            .map(|intent| intent.mutation.target.clone())
533            .collect::<Vec<_>>();
534        targets.sort();
535        targets.dedup();
536        let materializer_started = std::time::Instant::now();
537        self.execute_sync(|| async {
538            outbox
539                .materialize_commit(commit_lsn, intents, &targets)
540                .await
541                .map_err(SyncError::PersistenceError)
542        })?;
543        if let Some(stats) = &self.stats_manager {
544            stats.record_materializer_latency(materializer_started.elapsed().as_millis() as u64);
545        }
546        log::debug!(
547            "Materialized transaction {} at commit LSN {}",
548            txn_id,
549            commit_lsn
550        );
551        Ok(())
552    }
553
554    /// Return the durable projection frontier used as the lower bound for
555    /// committed-WAL replay after an outbox restore.
556    pub fn outbox_materialized_lsn(
557        &self,
558    ) -> Result<Option<linkrs_core::types::CommitLsn>, SyncError> {
559        let Some(outbox) = &self.sqlite_outbox else {
560            return Ok(None);
561        };
562        self.execute_sync(|| async {
563            outbox
564                .materialized_lsn()
565                .await
566                .map(Some)
567                .map_err(SyncError::PersistenceError)
568        })
569    }
570}