Skip to main content

linkrs_sync/manager/
delivery.rs

1//! Outbox delivery: claim-apply-ack loop and per-target mutation appliers.
2use 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    /// Rebuild catch-up replay targets the scratch engine instead of the
29    /// live map lookup. `None` = resolve via the manager (live delivery).
30    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        // Auth pause fence: if the last failure was an auth error, suppress
134        // delivery until the pause window expires.
135        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        // Capture consumer config for the async block (Clone is cheap).
149        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                    // Single-threaded path (Local): preserves global
185                    // commit_lsn ordering and keeps SQLite contention minimal.
186                    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                        // Re-check auth pause inside loop in case a worker set it.
190                        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                    // Concurrent delivery (Qdrant): multiple workers claim with
248                    // distinct lease owners so `claim_next`'s BEGIN IMMEDIATE +
249                    // lease_epoch fencing guarantees no overlapping acks. Each
250                    // worker respects the global commit_lsn order via the
251                    // `NOT EXISTS (ordering_key)` fence plus `ORDER BY`.
252                    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                // Per-target lag for granular alerting.
363                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            // Serialize against rebuild publish: the fence write guard held
584            // by publish covers final replay and engine swap, so an apply is
585            // either fully before or fully after the swap.
586            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                // Vertex/edge data-plane mapping is shared with rebuild
668                // catch-up replay (`vector_contexts_for_payload`). Edge
669                // payloads intentionally map to zero contexts: vector
670                // indexes are vertex-only, so edge mutations never produce
671                // points on either the live or the rebuild path.
672                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        // Serialize data-plane delivery against rebuild purge/snapshot and
728        // the final catch-up drain: an apply is either fully before or
729        // fully after each fenced window.
730        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    /// Map one vertex/edge outbox payload to vector change contexts without
759    /// any receiver gating. Shared by live delivery (`apply_vector_mutation`,
760    /// which gates + batches + records) and rebuild catch-up replay (which
761    /// must accept LSNs below the receiver water-level and never records).
762    #[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            // Edge mutations do not trigger vector index updates. Vector
833            // indexes are maintained per-vertex; edge-only changes do not
834            // carry vector fields and are not indexed.
835            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}