1use super::*;
3use crate::outbox::OutboxPayload;
4#[cfg(any(feature = "fulltext", feature = "vector"))]
5use crate::types::ChangeType;
6#[cfg(feature = "vector")]
7use crate::vector_sync::VectorSyncCoordinator;
8use linkrs_core::types::CommitLsn;
9#[cfg(feature = "fulltext")]
10use linkrs_core::Value;
11#[cfg(feature = "fulltext")]
12use linkrs_fulltext::engine::FulltextSearchEngine;
13use linkrs_metrics::OutboxState;
14#[cfg(feature = "vector")]
15use std::collections::HashMap;
16#[cfg(feature = "fulltext")]
17use std::sync::Arc;
18#[cfg(feature = "fulltext")]
19pub(crate) struct FulltextFieldApply<'a> {
20 manager: Arc<linkrs_fulltext::manager::FulltextIndexManager>,
21 mutation: &'a linkrs_core::wal::IndexMutation,
22 commit_lsn: CommitLsn,
23 space_id: u64,
24 index_name: &'a str,
25 entity_id: String,
26 properties: Vec<(Arc<str>, Value)>,
27 deleted: bool,
28 engine_override: Option<Arc<dyn FulltextSearchEngine>>,
31}
32#[cfg(feature = "fulltext")]
33pub(crate) fn edge_entity_id(
34 src: impl std::fmt::Display,
35 dst: impl std::fmt::Display,
36 ranking: i64,
37) -> String {
38 format!("{}->{}#{}", src, dst, ranking)
39}
40
41fn is_disabled_error(message: &str) -> bool {
42 message.contains("Vector engine is disabled") || message.contains("EngineDisabled")
43}
44
45fn retry_backoff_ms(retry_count: u64) -> u64 {
46 100u64
47 .saturating_mul(1u64 << retry_count.min(16))
48 .min(300_000)
49}
50
51fn record_transport_latency(
52 stats: &Option<std::sync::Arc<linkrs_metrics::StatsManager>>,
53 elapsed_ms: u64,
54) {
55 if let Some(stats) = stats {
56 stats.record_transport_latency(elapsed_ms);
57 }
58}
59
60async fn handle_delivery_error(
61 outbox: &crate::sqlite_outbox::SqliteOutbox,
62 event: &crate::sqlite_outbox::ClaimedEvent,
63 error: &str,
64 now: u64,
65 consumer: &crate::manager::OutboxConsumerConfig,
66 auth_paused_until_ms: &std::sync::Arc<std::sync::atomic::AtomicU64>,
67 target: &str,
68) -> Result<bool, crate::manager::SyncError> {
69 use crate::vector_error::VectorErrorKind;
70 if is_disabled_error(error) {
71 outbox
72 .retry(event, now.saturating_add(5_000), error)
73 .await
74 .map_err(crate::manager::SyncError::PersistenceError)?;
75 return Ok(false);
76 }
77 match VectorErrorKind::classify_str(error) {
78 VectorErrorKind::Auth => {
79 let pause_until = now.saturating_add(60_000);
80 auth_paused_until_ms.store(pause_until, std::sync::atomic::Ordering::Relaxed);
81 tracing::error!(
82 target = target,
83 error = %error,
84 pause_until,
85 "auth failure: pausing outbox delivery"
86 );
87 outbox
88 .retry(event, now.saturating_add(60_000), error)
89 .await
90 .map_err(crate::manager::SyncError::PersistenceError)?;
91 Ok(true)
92 }
93 VectorErrorKind::NonRetryable => {
94 outbox
95 .dead_letter(event, now, error)
96 .await
97 .map_err(crate::manager::SyncError::PersistenceError)?;
98 Ok(false)
99 }
100 VectorErrorKind::Retryable => {
101 let retry_count = outbox
102 .retry_count(event.event_id)
103 .await
104 .map_err(crate::manager::SyncError::PersistenceError)?;
105 if retry_count.saturating_add(1) >= consumer.max_retries {
106 outbox
107 .dead_letter(event, now, error)
108 .await
109 .map_err(crate::manager::SyncError::PersistenceError)?;
110 } else {
111 outbox
112 .retry(
113 event,
114 now.saturating_add(retry_backoff_ms(retry_count)),
115 error,
116 )
117 .await
118 .map_err(crate::manager::SyncError::PersistenceError)?;
119 }
120 Ok(false)
121 }
122 }
123}
124#[cfg_attr(
125 not(any(feature = "fulltext", feature = "vector")),
126 allow(unused_variables)
127)]
128impl super::SyncManager {
129 pub fn retry_outbox_sync(&self) -> Result<usize, SyncError> {
130 let Some(outbox) = &self.sqlite_outbox else {
131 return Ok(0);
132 };
133 let now_for_pause = chrono::Utc::now().timestamp_millis().max(0) as u64;
136 let paused_until = self
137 .auth_paused_until_ms
138 .load(std::sync::atomic::Ordering::Relaxed);
139 if paused_until > now_for_pause {
140 tracing::warn!(
141 paused_until,
142 now = now_for_pause,
143 "outbox delivery paused due to auth failure"
144 );
145 return Ok(0);
146 }
147 let stats_manager = self.stats_manager.clone();
148 let consumer = self.outbox_consumer.clone();
150 let backend_policy_name = {
151 #[cfg(feature = "vector")]
152 {
153 self.backend_policy
154 .as_ref()
155 .map(|p| p.policy_name.clone())
156 .unwrap_or_default()
157 }
158 #[cfg(not(feature = "vector"))]
159 {
160 String::new()
161 }
162 };
163 let self_clone = self.clone();
164 self.execute_sync(move || async move {
165 let consumer = consumer.clone();
166 let stats_manager = stats_manager.clone();
167 let self_clone = self_clone.clone();
168 let backend_policy_name = backend_policy_name.clone();
169 let targets = outbox
170 .delivery_targets()
171 .await
172 .map_err(SyncError::PersistenceError)?;
173 let mut processed = 0usize;
174 for target in targets {
175 let max_concurrency =
176 if target.as_str() == "vector" && backend_policy_name == "qdrant" {
177 consumer.max_concurrency.max(4)
178 } else if target.as_str() == "vector" && backend_policy_name == "local" {
179 1
180 } else {
181 consumer.max_concurrency.max(1)
182 };
183 if max_concurrency <= 1 {
184 let mut target_processed = 0usize;
187 while target_processed < consumer.batch_size {
188 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
189 let paused = self_clone
191 .auth_paused_until_ms
192 .load(std::sync::atomic::Ordering::Relaxed);
193 if paused > now {
194 break;
195 }
196 let Some(event) = outbox
197 .claim_next(
198 &target,
199 &consumer.consumer_id,
200 now,
201 consumer.lease_duration_ms,
202 )
203 .await
204 .map_err(SyncError::PersistenceError)?
205 else {
206 break;
207 };
208 let apply_started = std::time::Instant::now();
209 let result = self_clone
210 .apply_index_mutation(&event.mutation, event.commit_lsn)
211 .await;
212 match result {
213 Ok(()) => {
214 record_transport_latency(
215 &stats_manager,
216 apply_started.elapsed().as_millis() as u64,
217 );
218 outbox
219 .acknowledge(&event)
220 .await
221 .map_err(SyncError::PersistenceError)?;
222 }
223 Err(error) => {
224 record_transport_latency(
225 &stats_manager,
226 apply_started.elapsed().as_millis() as u64,
227 );
228 let should_break = handle_delivery_error(
229 outbox,
230 &event,
231 &error,
232 now,
233 &consumer,
234 &self_clone.auth_paused_until_ms,
235 target.as_str(),
236 )
237 .await?;
238 if should_break {
239 break;
240 }
241 }
242 }
243 processed = processed.saturating_add(1);
244 target_processed = target_processed.saturating_add(1);
245 }
246 } else {
247 let per_worker_batch = consumer.batch_size.div_ceil(max_concurrency);
253 let outbox_for_workers = outbox.clone();
254 let target_for_workers = target.clone();
255 let consumer_for_workers = consumer.clone();
256 let self_for_workers = self_clone.clone();
257 let stats_for_workers = stats_manager.clone();
258 let mut handles = Vec::with_capacity(max_concurrency);
259 for worker_idx in 0..max_concurrency {
260 let outbox_c = outbox_for_workers.clone();
261 let target_c = target_for_workers.clone();
262 let consumer_c = consumer_for_workers.clone();
263 let self_c = self_for_workers.clone();
264 let stats_c = stats_for_workers.clone();
265 let worker_consumer_id =
266 format!("{}-worker-{}", consumer_c.consumer_id, worker_idx);
267 handles.push(tokio::spawn(async move {
268 let mut local_processed = 0usize;
269 while local_processed < per_worker_batch {
270 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
271 let paused = self_c
272 .auth_paused_until_ms
273 .load(std::sync::atomic::Ordering::Relaxed);
274 if paused > now {
275 break;
276 }
277 let Some(event) = outbox_c
278 .claim_next(
279 &target_c,
280 &worker_consumer_id,
281 now,
282 consumer_c.lease_duration_ms,
283 )
284 .await
285 .map_err(SyncError::PersistenceError)?
286 else {
287 break;
288 };
289 let apply_started = std::time::Instant::now();
290 let result = self_c
291 .apply_index_mutation(&event.mutation, event.commit_lsn)
292 .await;
293 match result {
294 Ok(()) => {
295 record_transport_latency(
296 &stats_c,
297 apply_started.elapsed().as_millis() as u64,
298 );
299 outbox_c
300 .acknowledge(&event)
301 .await
302 .map_err(SyncError::PersistenceError)?;
303 }
304 Err(error) => {
305 record_transport_latency(
306 &stats_c,
307 apply_started.elapsed().as_millis() as u64,
308 );
309 let should_break = handle_delivery_error(
310 &outbox_c,
311 &event,
312 &error,
313 now,
314 &consumer_c,
315 &self_c.auth_paused_until_ms,
316 target_c.as_str(),
317 )
318 .await?;
319 if should_break {
320 break;
321 }
322 }
323 }
324 local_processed += 1;
325 }
326 Ok::<usize, SyncError>(local_processed)
327 }));
328 }
329 for h in handles {
330 match h.await {
331 Ok(Ok(n)) => processed = processed.saturating_add(n),
332 Ok(Err(e)) => return Err(e),
333 Err(e) => return Err(SyncError::Internal(e.to_string())),
334 }
335 }
336 }
337 }
338 if let Some(stats) = &stats_manager {
339 let durable = outbox.stats().await.map_err(SyncError::PersistenceError)?;
340 let diagnostics = outbox
341 .diagnostics()
342 .await
343 .map_err(SyncError::PersistenceError)?;
344 let frontier_lag = diagnostics
345 .targets
346 .iter()
347 .map(|target| target.frontier_lag)
348 .chain(diagnostics.indexes.iter().map(|index| index.frontier_lag))
349 .max()
350 .unwrap_or(0);
351 let degraded = diagnostics.targets.iter().any(|target| target.degraded)
352 || diagnostics.indexes.iter().any(|index| index.degraded);
353 stats.record_outbox_state(OutboxState {
354 pending: durable.pending as u64,
355 retries: durable.retries,
356 dead_lettered: durable.dead_lettered as u64,
357 leased: durable.leased as u64,
358 oldest_event_age_ms: durable.oldest_event_age_ms,
359 frontier_lag,
360 degraded,
361 });
362 for target in &diagnostics.targets {
364 stats.record_target_frontier_lag(&target.target, target.frontier_lag);
365 }
366 for index in &diagnostics.indexes {
367 let label = format!("{}:{}", index.target, index.index_id);
368 stats.record_target_frontier_lag(&label, index.frontier_lag);
369 }
370 #[cfg(feature = "vector")]
371 if let Some(coord) = self.vector_coordinator.as_ref() {
372 stats.record_vector_disabled_skips(coord.disabled_skip_count());
373 }
374 }
375 Ok(processed)
376 })
377 }
378
379 async fn apply_index_mutation(
380 &self,
381 mutation: &linkrs_core::wal::IndexMutation,
382 commit_lsn: CommitLsn,
383 ) -> Result<(), String> {
384 let payload: OutboxPayload = postcard::from_bytes(&mutation.document_or_vector)
385 .map_err(|error| format!("Failed to decode index mutation: {}", error))?;
386 match mutation.target.as_str() {
387 #[cfg(feature = "fulltext")]
388 "fulltext" => {
389 self.apply_fulltext_mutation(mutation, commit_lsn, &payload, None)
390 .await
391 }
392 #[cfg(feature = "vector")]
393 "vector" => {
394 self.apply_vector_mutation(mutation, commit_lsn, &payload)
395 .await
396 }
397 _ => self.apply_payload(&payload),
398 }
399 }
400
401 fn apply_payload(&self, payload: &OutboxPayload) -> Result<(), String> {
402 match payload {
403 OutboxPayload::CreateIndex { .. }
404 | OutboxPayload::DropIndex { .. }
405 | OutboxPayload::DropSpace { .. }
406 | OutboxPayload::DropTag { .. } => Ok(()),
407 OutboxPayload::Vertex { .. }
408 | OutboxPayload::EdgeInsert { .. }
409 | OutboxPayload::EdgeDelete { .. } => Err(
410 "Outbox mutation has no registered target receiver; direct delivery is disabled"
411 .to_string(),
412 ),
413 }
414 }
415
416 #[cfg(feature = "fulltext")]
417 pub(crate) async fn apply_fulltext_mutation(
418 &self,
419 mutation: &linkrs_core::wal::IndexMutation,
420 commit_lsn: CommitLsn,
421 payload: &OutboxPayload,
422 engine_override: Option<Arc<dyn FulltextSearchEngine>>,
423 ) -> Result<(), String> {
424 let manager = self
425 .sync_coordinator
426 .as_ref()
427 .ok_or_else(|| "fulltext target is not configured".to_string())?
428 .fulltext_manager()
429 .clone();
430
431 match payload {
432 OutboxPayload::Vertex {
433 space_id,
434 tag_name,
435 vertex_id,
436 properties,
437 change_type,
438 } => {
439 let properties =
440 if matches!(change_type, ChangeType::Delete) && properties.is_empty() {
441 manager
442 .get_space_indexes(*space_id)
443 .into_iter()
444 .filter(|metadata| metadata.tag_name == *tag_name)
445 .map(|metadata| (metadata.field_name.into(), Value::string("")))
446 .collect()
447 } else {
448 properties.clone()
449 };
450 Self::apply_fulltext_fields(FulltextFieldApply {
451 manager: manager.clone(),
452 mutation,
453 commit_lsn,
454 space_id: *space_id,
455 index_name: tag_name,
456 entity_id: crate::rebuild::fulltext_vertex_doc_id(vertex_id),
457 properties,
458 deleted: matches!(change_type, ChangeType::Delete),
459 engine_override: engine_override.clone(),
460 })
461 .await
462 }
463 OutboxPayload::EdgeInsert { space_id, edge } => {
464 let properties = edge
465 .props
466 .iter()
467 .map(|(name, value)| (name.clone(), value.clone()))
468 .collect::<Vec<_>>();
469 Self::apply_fulltext_fields(FulltextFieldApply {
470 manager: manager.clone(),
471 mutation,
472 commit_lsn,
473 space_id: *space_id,
474 index_name: &edge.edge_type,
475 entity_id: edge_entity_id(edge.src, edge.dst, edge.ranking),
476 properties,
477 deleted: false,
478 engine_override: engine_override.clone(),
479 })
480 .await
481 }
482 OutboxPayload::EdgeDelete {
483 space_id,
484 src,
485 dst,
486 edge_type,
487 ranking,
488 } => {
489 let properties = manager
490 .get_space_indexes(*space_id)
491 .into_iter()
492 .filter(|metadata| metadata.tag_name == *edge_type)
493 .map(|metadata| (metadata.field_name.into(), Value::string("")))
494 .collect::<Vec<_>>();
495 Self::apply_fulltext_fields(FulltextFieldApply {
496 manager,
497 mutation,
498 commit_lsn,
499 space_id: *space_id,
500 index_name: edge_type,
501 entity_id: edge_entity_id(src, dst, *ranking),
502 properties,
503 deleted: true,
504 engine_override: engine_override.clone(),
505 })
506 .await
507 }
508 OutboxPayload::CreateIndex {
509 space_id,
510 index_name,
511 schema_name,
512 fields,
513 ..
514 } => {
515 for (field_name, value_type) in fields {
516 if !matches!(value_type, Value::String(_) | Value::FixedString(_)) {
517 continue;
518 }
519 if manager
520 .create_index(*space_id, schema_name, field_name, None)
521 .await
522 .is_err()
523 && !manager.has_index(*space_id, schema_name, field_name)
524 {
525 return Err(format!(
526 "Failed to create fulltext receiver index {}.{}.{}",
527 space_id, index_name, field_name
528 ));
529 }
530 }
531 Ok(())
532 }
533 OutboxPayload::DropIndex {
534 space_id,
535 index_name: _index_name,
536 schema_name,
537 ..
538 } => {
539 let indexes = manager
540 .get_space_indexes(*space_id)
541 .into_iter()
542 .filter(|metadata| metadata.tag_name == *schema_name)
543 .map(|metadata| metadata.field_name)
544 .collect::<Vec<_>>();
545 for field_name in indexes {
546 manager
547 .drop_index(*space_id, schema_name, &field_name)
548 .await
549 .map_err(|error| error.to_string())?;
550 }
551 Ok(())
552 }
553 OutboxPayload::DropSpace { space_id } => {
554 manager
555 .drop_space_indexes(*space_id)
556 .await
557 .map_err(|error| error.to_string())?;
558 Ok(())
559 }
560 OutboxPayload::DropTag { space_id, tag_name } => {
561 let indexes = manager
562 .get_space_indexes(*space_id)
563 .into_iter()
564 .filter(|metadata| metadata.tag_name == *tag_name)
565 .map(|metadata| metadata.field_name)
566 .collect::<Vec<_>>();
567 for field_name in indexes {
568 manager
569 .drop_index(*space_id, tag_name, &field_name)
570 .await
571 .map_err(|error| error.to_string())?;
572 }
573 Ok(())
574 }
575 }
576 }
577
578 #[cfg(feature = "fulltext")]
579 pub(crate) async fn apply_fulltext_fields(
580 request: FulltextFieldApply<'_>,
581 ) -> Result<(), String> {
582 for (field_name, value) in request.properties {
583 let fence = request.manager.publish_fence_for(
587 request.space_id,
588 request.index_name,
589 &field_name,
590 );
591 let _fence = fence.read().await;
592 let engine = match request.engine_override.clone() {
593 Some(engine) => Some(engine),
594 None => {
595 request
596 .manager
597 .get_engine(request.space_id, request.index_name, &field_name)
598 }
599 };
600 let Some(engine) = engine else {
601 continue;
602 };
603 let document = if request.deleted {
604 Vec::new()
605 } else if let Value::String(text) = value {
606 text.as_bytes().to_vec()
607 } else {
608 continue;
609 };
610 let mut field_mutation = request.mutation.clone();
611 field_mutation.index_id = stable_hash(
612 format!("{}:{}:{}", request.space_id, request.index_name, field_name).as_bytes(),
613 );
614 field_mutation.document_or_vector = document;
615 field_mutation.idempotency_key = linkrs_core::types::IdempotencyKey::new(format!(
616 "{}:{}",
617 request.mutation.idempotency_key.as_str(),
618 field_name
619 ))?;
620 let receiver = crate::receiver::FulltextReceiver::new(engine);
621 let late_arrival = receiver
622 .check_late_arrival(request.commit_lsn, field_mutation.idempotency_key.as_str())
623 .await;
624 if !late_arrival.accepted && !late_arrival.reason.contains("duplicate") {
625 return Err(late_arrival.reason);
626 }
627 receiver
628 .apply_index_batch(&[(&field_mutation, request.commit_lsn)])
629 .await?;
630 log::debug!(
631 "Applied fulltext mutation for {} at {}",
632 request.entity_id.as_str(),
633 request.commit_lsn
634 );
635 }
636 Ok(())
637 }
638
639 #[cfg(feature = "vector")]
640 async fn apply_vector_mutation(
641 &self,
642 mutation: &linkrs_core::wal::IndexMutation,
643 commit_lsn: CommitLsn,
644 payload: &OutboxPayload,
645 ) -> Result<(), String> {
646 let Some(coordinator) = self.vector_coordinator.as_ref() else {
647 return Err("vector target is not configured".to_string());
648 };
649 let Some(receiver) = self.vector_receiver.as_ref() else {
650 return Err("vector receiver is not configured".to_string());
651 };
652 let late_arrival = receiver
653 .check_late_arrival(commit_lsn, mutation.idempotency_key.as_str())
654 .await;
655 if !late_arrival.accepted && !late_arrival.reason.contains("duplicate") {
656 return Err(late_arrival.reason);
657 }
658 if !late_arrival.accepted {
659 return Ok(());
660 }
661
662 let mut contexts = Vec::new();
663 match payload {
664 OutboxPayload::Vertex { .. }
665 | OutboxPayload::EdgeInsert { .. }
666 | OutboxPayload::EdgeDelete { .. } => {
667 contexts = Self::vector_contexts_for_payload(coordinator, payload)?;
673 }
674 OutboxPayload::CreateIndex {
675 space_id,
676 index_name: _index_name,
677 schema_name,
678 fields,
679 ..
680 } => {
681 for (field_name, value_type) in fields {
682 let Some(vector_size) = value_type.as_vector().map(|vector| vector.len())
683 else {
684 continue;
685 };
686 coordinator
687 .create_vector_index(
688 *space_id,
689 schema_name,
690 field_name,
691 vector_size,
692 simvec::DistanceMetric::default(),
693 )
694 .await
695 .map_err(|error| error.to_string())?;
696 }
697 }
698 OutboxPayload::DropIndex {
699 space_id,
700 index_name: _index_name,
701 schema_name,
702 fields,
703 ..
704 } => {
705 for field_name in fields {
706 coordinator
707 .drop_vector_index(*space_id, schema_name, field_name)
708 .await
709 .map_err(|error| error.to_string())?;
710 }
711 }
712 OutboxPayload::DropSpace { space_id } => {
713 coordinator
714 .index_manager()
715 .drop_space_indexes(*space_id)
716 .await
717 .map_err(|error| error.to_string())?;
718 }
719 OutboxPayload::DropTag { space_id, tag_name } => {
720 coordinator
721 .index_manager()
722 .drop_tag_indexes(*space_id, tag_name)
723 .await
724 .map_err(|error| error.to_string())?;
725 }
726 }
727 let mut fence_keys: Vec<(u64, String, String)> = Vec::new();
731 for ctx in &contexts {
732 let key = (
733 ctx.location.space_id,
734 ctx.location.tag_name.clone(),
735 ctx.location.field_name.clone(),
736 );
737 if !fence_keys.contains(&key) {
738 fence_keys.push(key);
739 }
740 }
741 fence_keys.sort();
742 let mut _fence_guards = Vec::with_capacity(fence_keys.len());
743 for (space_id, tag_name, field_name) in &fence_keys {
744 let fence = coordinator.publish_fence_for(*space_id, tag_name, field_name);
745 _fence_guards.push(fence.clone().read_owned().await);
746 }
747 if !contexts.is_empty() {
748 coordinator
749 .on_vector_change_batch(contexts)
750 .await
751 .map_err(|error| error.to_string())?;
752 }
753 receiver
754 .record_application(commit_lsn, mutation.idempotency_key.as_str())
755 .await
756 }
757
758 #[cfg(feature = "vector")]
763 pub(crate) fn vector_contexts_for_payload(
764 coordinator: &VectorSyncCoordinator,
765 payload: &OutboxPayload,
766 ) -> Result<Vec<crate::vector_sync::VectorChangeContext>, String> {
767 let mut contexts = Vec::new();
768 match payload {
769 OutboxPayload::Vertex {
770 space_id,
771 tag_name,
772 vertex_id,
773 properties,
774 change_type,
775 } => {
776 for (field_name, value) in properties {
777 if !coordinator.index_exists(*space_id, tag_name, field_name) {
778 continue;
779 }
780 let (vector, vector_change_type) = match (value.as_vector(), change_type) {
781 (Some(vector), ChangeType::Insert | ChangeType::Update) => (
782 vector.to_vec(),
783 crate::vector_sync::VectorChangeType::Insert,
784 ),
785 _ => (Vec::new(), crate::vector_sync::VectorChangeType::Delete),
786 };
787 let payload = properties
788 .iter()
789 .map(|(name, value)| (name.clone(), value.clone()))
790 .collect::<HashMap<_, _>>();
791 contexts.push(crate::vector_sync::VectorChangeContext::new(
792 *space_id,
793 tag_name,
794 field_name.as_ref(),
795 vector_change_type,
796 crate::vector_sync::VectorPointData {
797 id: format_vector_point_id(vertex_id, tag_name, field_name),
798 vector,
799 payload,
800 },
801 ));
802 }
803 if matches!(change_type, ChangeType::Delete) {
804 let staged_fields = properties
805 .iter()
806 .map(|(field_name, _)| field_name.as_ref())
807 .collect::<std::collections::HashSet<_>>();
808 for metadata in coordinator.list_indexes() {
809 if metadata.space_id == *space_id
810 && metadata.tag_name == *tag_name
811 && !staged_fields.contains(metadata.field_name.as_str())
812 {
813 contexts.push(crate::vector_sync::VectorChangeContext::new(
814 *space_id,
815 tag_name,
816 &metadata.field_name,
817 crate::vector_sync::VectorChangeType::Delete,
818 crate::vector_sync::VectorPointData {
819 id: format_vector_point_id(
820 vertex_id,
821 tag_name,
822 &metadata.field_name,
823 ),
824 vector: Vec::new(),
825 payload: HashMap::new(),
826 },
827 ));
828 }
829 }
830 }
831 }
832 OutboxPayload::EdgeInsert { .. } | OutboxPayload::EdgeDelete { .. } => {}
836 _ => {
837 return Err(
838 "vector_contexts_for_payload only maps vertex/edge payloads".to_string()
839 );
840 }
841 }
842 Ok(contexts)
843 }
844}