1use 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 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 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 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 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 #[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 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 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 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 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 !has_needed_target {
363 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 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 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 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 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 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 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 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}