1#[cfg(test)]
2use super::race_seam;
3use super::{
4 entity_fts_document, map_merge_entity_storage_error, merge_entity_sql, merge_properties,
5 stale_entity_snapshot_error, Any, AtomicUnitOp, ContentMergeStrategy, EmbeddingModelPlan,
6 Entity, EntityDedupMergePolicy, EntityMergeValidation, EntityPatch, EventAttribution,
7 EventKind, HashMap, KhiveRuntime, MergeEventContext, MergeSqlError, MergeSummary,
8 MergeTxLimits, NamespaceToken, RuntimeError, RuntimeResult, RuntimeWriteOperation,
9 SqlStatement, SqlValue, SqliteError, SubstrateKind, Uuid, Value,
10};
11
12use crate::secret_gate_finalizer::entity_admission::{
13 EntityAdmission, OrdinaryEntityUpdateContext,
14};
15use crate::EntityCandidateMutation;
16
17#[derive(Clone, Copy)]
18enum EntityUpdateRoute<'a> {
19 ReservationOnly,
20 OrdinaryConstructor(&'a OrdinaryEntityUpdateContext),
21}
22
23struct PreparedEntityUpdate {
24 entity: Entity,
25 reindex_required: bool,
26 changed_fields: Vec<&'static str>,
27 expected_updated_at: i64,
28 expected_deleted_at: Option<i64>,
29 original: Entity,
30}
31
32impl KhiveRuntime {
33 pub(crate) async fn validate_canonical_entity_patch(
34 &self,
35 token: &NamespaceToken,
36 current: &Entity,
37 mut patch: EntityPatch,
38 ) -> RuntimeResult<EntityPatch> {
39 crate::atomic_prepare::canonical_refuse_registry_tags(¤t.tags, "update")?;
40 if let Some(name) = &patch.name {
41 if name.trim().is_empty() {
42 return Err(RuntimeError::InvalidInput("name must not be empty".into()));
43 }
44 crate::secret_gate::check_at(name, "entity", "name")?;
45 }
46 if let Some(Some(description)) = &patch.description {
47 crate::secret_gate::check_at(description, "entity", "description")?;
48 }
49 if let Some(properties) = &patch.properties {
50 crate::secret_gate::check_json_at(properties, "entity", "properties")?;
51 }
52 if let Some(tags) = &patch.tags {
53 crate::atomic_prepare::canonical_refuse_registry_tags(tags, "update")?;
54 crate::secret_gate::check_tags_at(tags, "entity", "tags")?;
55 }
56 if let Some(Some(entity_type)) = &patch.entity_type {
57 patch.entity_type =
58 Some(self.validate_entity_type_for_kind(¤t.kind, Some(entity_type))?);
59 }
60 if let Some(properties) = &patch.properties {
63 crate::secret_gate::strip_reserved_echo(
64 &mut properties.clone(),
65 current.properties.as_ref(),
66 )?;
67 }
68 let mut projected = current.clone();
69 Self::apply_entity_patch_fields(&mut projected, patch.clone(), &[], true);
70 if let Some(hook) = self.entity_kind_hook(¤t.kind) {
71 hook.validate_entity_update(self, token, &projected, projected.properties.as_ref())
72 .await?;
73 }
74 Ok(patch)
75 }
76
77 pub(crate) fn apply_entity_patch_fields(
78 entity: &mut Entity,
79 patch: EntityPatch,
80 remove_properties: &[&str],
81 replace_properties: bool,
82 ) -> (bool, Vec<&'static str>) {
83 let mut reindex_required = false;
84 let mut changed_fields: Vec<&'static str> = Vec::new();
85
86 if let Some(name) = patch.name {
87 reindex_required |= entity.name != name;
88 entity.name = name;
89 changed_fields.push("name");
90 }
91 if let Some(desc_patch) = patch.description {
92 reindex_required |= entity.description != desc_patch;
93 entity.description = desc_patch;
94 changed_fields.push("description");
95 }
96 if let Some(props) = patch.properties {
97 if replace_properties {
98 entity.properties = Some(props);
99 } else {
100 let (merged, _) = merge_properties(
101 &entity.properties,
102 &Some(props),
103 EntityDedupMergePolicy::PreferFrom,
104 );
105 entity.properties = merged;
106 }
107 changed_fields.push("properties");
108 }
109 if let Some(Value::Object(properties)) = entity.properties.as_mut() {
110 let mut removed = false;
111 for key in remove_properties {
112 removed |= properties.remove(*key).is_some();
113 }
114 if removed && !changed_fields.contains(&"properties") {
115 changed_fields.push("properties");
116 }
117 }
118 if let Some(tags) = patch.tags {
119 entity.tags = tags;
120 changed_fields.push("tags");
121 }
122 if let Some(entity_type) = patch.entity_type {
123 reindex_required |= entity.entity_type != entity_type;
124 entity.entity_type = entity_type;
125 changed_fields.push("entity_type");
126 }
127
128 (reindex_required, changed_fields)
129 }
130
131 pub(crate) async fn prepare_update_entity(
143 &self,
144 token: &NamespaceToken,
145 id: Uuid,
146 patch: EntityPatch,
147 ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
148 self.prepare_guarded_entity_update(token, id, patch, None, &[])
149 .await
150 }
151
152 async fn prepare_guarded_entity_update(
153 &self,
154 token: &NamespaceToken,
155 id: Uuid,
156 patch: EntityPatch,
157 expected: Option<&Entity>,
158 remove_properties: &[&str],
159 ) -> RuntimeResult<(Entity, bool, Vec<&'static str>, i64, Option<i64>)> {
160 let prepared = self
161 .prepare_entity_update_for_route(
162 token,
163 id,
164 patch,
165 expected,
166 remove_properties,
167 EntityUpdateRoute::ReservationOnly,
168 )
169 .await?;
170 Ok((
171 prepared.entity,
172 prepared.reindex_required,
173 prepared.changed_fields,
174 prepared.expected_updated_at,
175 prepared.expected_deleted_at,
176 ))
177 }
178
179 async fn prepare_entity_update_for_route(
180 &self,
181 token: &NamespaceToken,
182 id: Uuid,
183 patch: EntityPatch,
184 expected: Option<&Entity>,
185 remove_properties: &[&str],
186 route: EntityUpdateRoute<'_>,
187 ) -> RuntimeResult<PreparedEntityUpdate> {
188 crate::secret_gate::reject_reserved_secret_gate_property(patch.properties.as_ref())?;
189 if !remove_properties.is_empty() {
190 let removals = Value::Object(
191 remove_properties
192 .iter()
193 .map(|key| ((*key).to_string(), Value::Null))
194 .collect(),
195 );
196 crate::secret_gate::reject_reserved_secret_gate_property(Some(&removals))?;
197 }
198 if matches!(route, EntityUpdateRoute::ReservationOnly) {
199 if let Some(ref name) = patch.name {
200 crate::secret_gate::check_at(name, "entity", "name")?;
201 }
202 if let Some(Some(ref desc)) = patch.description {
203 crate::secret_gate::check_at(desc, "entity", "description")?;
204 }
205 if let Some(ref props) = patch.properties {
206 crate::secret_gate::check_json_at(props, "entity", "properties")?;
207 }
208 if let Some(ref tags) = patch.tags {
209 crate::secret_gate::check_tags_at(tags, "entity", "tags")?;
210 }
211 } else if let EntityUpdateRoute::OrdinaryConstructor(context) = route {
212 context.check_patch(
213 patch.name.as_deref(),
214 patch
215 .description
216 .as_ref()
217 .and_then(|value| value.as_deref()),
218 patch.properties.as_ref(),
219 patch.tags.as_deref(),
220 )?;
221 }
222 let store = self.entities(token)?;
223 let mut entity = store.get_entity(id).await?.ok_or_else(|| {
224 if expected.is_some() {
225 stale_entity_snapshot_error(id)
226 } else {
227 RuntimeError::NotFound(format!("entity {id}"))
228 }
229 })?;
230 if let Some(expected) = expected {
231 let actual = serde_json::to_value(&entity)
232 .map_err(|error| RuntimeError::Internal(error.to_string()))?;
233 let expected = serde_json::to_value(expected)
234 .map_err(|error| RuntimeError::Internal(error.to_string()))?;
235 if actual != expected {
236 return Err(stale_entity_snapshot_error(id));
237 }
238 }
239 let original = entity.clone();
240 let expected_updated_at = entity.updated_at;
241 let expected_deleted_at = entity.deleted_at;
242 #[cfg(test)]
243 race_seam::pause_after_read().await;
244
245 let validated_entity_type = match &patch.entity_type {
249 Some(None) => Some(None),
250 Some(Some(raw)) => Some(Some(
251 self.validate_entity_type_for_kind(&entity.kind, Some(raw))?
252 .expect("set branch always yields a normalized value"),
253 )),
254 None => None,
255 };
256
257 let patch = EntityPatch {
258 entity_type: validated_entity_type,
259 ..patch
260 };
261 let (reindex_required, changed_fields) =
262 Self::apply_entity_patch_fields(&mut entity, patch, remove_properties, false);
263
264 crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
267
268 if expected.is_some() && changed_fields.is_empty() {
269 return Ok(PreparedEntityUpdate {
270 entity,
271 reindex_required,
272 changed_fields,
273 expected_updated_at,
274 expected_deleted_at,
275 original,
276 });
277 }
278
279 if let Some(hook) = self.entity_kind_hook(&entity.kind) {
288 hook.validate_entity_update(self, token, &entity, entity.properties.as_ref())
289 .await?;
290 }
291
292 let minimum_updated_at = expected_updated_at.checked_add(1).ok_or_else(|| {
298 RuntimeError::Internal(format!(
299 "entity {id} updated_at is already at i64::MAX and cannot advance"
300 ))
301 })?;
302 entity.updated_at = chrono::Utc::now()
303 .timestamp_micros()
304 .max(minimum_updated_at);
305 Ok(PreparedEntityUpdate {
306 entity,
307 reindex_required,
308 changed_fields,
309 expected_updated_at,
310 expected_deleted_at,
311 original,
312 })
313 }
314
315 #[cfg(test)]
316 pub(crate) async fn update_entity(
317 &self,
318 token: &NamespaceToken,
319 id: Uuid,
320 patch: EntityPatch,
321 ) -> RuntimeResult<Entity> {
322 Ok(self
323 .update_entity_with_embedding_report(token, id, patch)
324 .await?
325 .0)
326 }
327
328 pub async fn update_entity_with_embedding_report(
329 &self,
330 token: &NamespaceToken,
331 id: Uuid,
332 patch: EntityPatch,
333 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
334 self.update_entity_with_expected_version_and_embedding_report(token, id, patch, None)
335 .await
336 }
337
338 pub async fn update_entity_with_expected_version_and_embedding_report(
340 &self,
341 token: &NamespaceToken,
342 id: Uuid,
343 patch: EntityPatch,
344 expected_version: Option<i64>,
345 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
346 crate::entity_write::validate_expected_version(expected_version)?;
347 let admission = OrdinaryEntityUpdateContext::capture(token);
348 let PreparedEntityUpdate {
349 entity,
350 reindex_required,
351 changed_fields,
352 expected_updated_at,
353 expected_deleted_at,
354 original,
355 } = self
356 .prepare_entity_update_for_route(
357 token,
358 id,
359 patch,
360 None,
361 &[],
362 EntityUpdateRoute::OrdinaryConstructor(&admission),
363 )
364 .await?;
365 let entity = match admission.admit(token, entity)? {
366 EntityAdmission::Legacy(entity) => entity,
367 EntityAdmission::Exempt(prepared) => {
368 use crate::atomic_plan::PostCommitEffect;
369 use crate::atomic_runner::{
370 run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome,
371 };
372 let mut entity = prepared.entity().clone();
373 let next_version = original
374 .version
375 .checked_add(1)
376 .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
377 let (mut required, report) = self
378 .prepare_admitted_entity_indexes(token, &entity, &[], reindex_required)
379 .await?;
380 required.extend(crate::atomic_prepare::event_append_statements(
381 token, &entity.namespace, "update", EventKind::EntityUpdated,
382 SubstrateKind::Entity, entity.id,
383 serde_json::json!({"id": entity.id, "namespace": entity.namespace, "changed_fields": changed_fields}),
384 )?);
385 let plan = prepared.into_plan(
386 EntityCandidateMutation::ReplaceIfUnchanged {
387 expected: original,
388 expected_version,
389 },
390 required,
391 PostCommitEffect::None,
392 )?;
393 match run_atomic_unit(
394 self.sql().as_ref(),
395 vec![AtomicOpPlan::FinalizeEntity(Box::new(plan))],
396 )
397 .await
398 {
399 Ok(AtomicRunOutcome::Committed { .. }) => {
400 entity.version = next_version;
401 return Ok((entity, report));
402 }
403 Ok(AtomicRunOutcome::RolledBack {
404 failure: AtomicOpFailure::EntityConflict(conflict),
405 ..
406 }) => {
407 return Err(conflict.into_error().into());
408 }
409 Ok(AtomicRunOutcome::RolledBack {
410 failure: AtomicOpFailure::GuardFailed { .. },
411 ..
412 }) => {
413 return Err(stale_entity_snapshot_error(id));
414 }
415 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
416 return Err(RuntimeError::Internal(format!(
417 "entity update finalization rolled back: {failure:?}"
418 )));
419 }
420 Err(error) => return Err(RuntimeError::Storage(error.0)),
421 }
422 }
423 };
424
425 self.persist_prepared_entity_update(
426 token,
427 entity,
428 reindex_required,
429 changed_fields,
430 expected_updated_at,
431 expected_deleted_at,
432 expected_version,
433 )
434 .await
435 }
436
437 pub async fn update_entity_if_unchanged(
444 &self,
445 token: &NamespaceToken,
446 expected: &Entity,
447 patch: EntityPatch,
448 remove_properties: &[&str],
449 ) -> RuntimeResult<Entity> {
450 let (entity, embedding) = self
451 .update_entity_if_unchanged_with_embedding_report(
452 token,
453 expected,
454 patch,
455 remove_properties,
456 )
457 .await?;
458 crate::operations::legacy_post_commit_result_with_embedding(
459 "update_entity_if_unchanged",
460 entity.id,
461 entity,
462 embedding,
463 Vec::new(),
464 )
465 }
466
467 pub async fn update_entity_if_unchanged_with_embedding_report(
469 &self,
470 token: &NamespaceToken,
471 expected: &Entity,
472 patch: EntityPatch,
473 remove_properties: &[&str],
474 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
475 let (entity, reindex_required, changed_fields, expected_updated_at, expected_deleted_at) =
476 self.prepare_guarded_entity_update(
477 token,
478 expected.id,
479 patch,
480 Some(expected),
481 remove_properties,
482 )
483 .await?;
484 if changed_fields.is_empty() {
485 return Ok((
486 entity,
487 crate::retrieval::EmbeddingTruncationReport::default(),
488 ));
489 }
490 self.persist_prepared_entity_update(
491 token,
492 entity,
493 reindex_required,
494 changed_fields,
495 expected_updated_at,
496 expected_deleted_at,
497 None,
498 )
499 .await
500 }
501
502 #[allow(clippy::too_many_arguments)]
503 pub(super) async fn persist_prepared_entity_update(
504 &self,
505 token: &NamespaceToken,
506 mut entity: Entity,
507 reindex_required: bool,
508 changed_fields: Vec<&'static str>,
509 expected_updated_at: i64,
510 expected_deleted_at: Option<i64>,
511 expected_version: Option<i64>,
512 ) -> RuntimeResult<(Entity, crate::retrieval::EmbeddingTruncationReport)> {
513 crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
516 let id = entity.id;
517 let _ = self.entities(token)?;
518 let next_version = entity
519 .version
520 .checked_add(1)
521 .ok_or_else(|| RuntimeError::InvalidInput("entity version overflow".into()))?;
522 use crate::atomic_plan::{AffectedRowGuard, PlanStatement, PostCommitEffect, UpdatePlan};
523 use crate::atomic_runner::{
524 run_atomic_unit, AtomicOpFailure, AtomicOpPlan, AtomicRunOutcome,
525 };
526 let plan = UpdatePlan {
527 target_id: id,
528 statements: vec![PlanStatement {
529 statement: khive_db::stores::entity::entity_replace_if_unchanged_statement(
530 &entity,
531 expected_updated_at,
532 expected_deleted_at,
533 ),
534 guard: Some(AffectedRowGuard::exactly(1)),
535 }],
536 post_commit: PostCommitEffect::None,
537 edge_natural_key: None,
538 idempotent_noop: false,
539 entity_guard: expected_version.map(|expected_version| {
540 crate::entity_write::EntityWriteGuard {
541 id,
542 expected_version,
543 }
544 }),
545 note_guard: None,
546 note_vector_purge: None,
547 note_embedding_inheritance: None,
548 graph_effects: Vec::new(),
549 };
550 match run_atomic_unit(
551 self.sql().as_ref(),
552 vec![AtomicOpPlan::Update(Box::new(plan))],
553 )
554 .await
555 {
556 Ok(AtomicRunOutcome::Committed { .. }) => entity.version = next_version,
557 Ok(AtomicRunOutcome::RolledBack {
558 failure: AtomicOpFailure::EntityConflict(conflict),
559 ..
560 }) => return Err(conflict.into_error().into()),
561 Ok(AtomicRunOutcome::RolledBack {
562 failure: AtomicOpFailure::GuardFailed { .. },
563 ..
564 }) => return Err(stale_entity_snapshot_error(id)),
565 Ok(AtomicRunOutcome::RolledBack { failure, .. }) => {
566 return Err(RuntimeError::Internal(format!(
567 "entity update rolled back: {failure:?}"
568 )))
569 }
570 Err(error) => return Err(RuntimeError::Storage(error.0)),
571 }
572
573 let event_token =
574 token.with_namespace(crate::Namespace::parse(&entity.namespace).map_err(|error| {
575 RuntimeError::Internal(format!("entity namespace invalid: {error}"))
576 })?);
577 let event_store = self.events(&event_token)?;
578 let event = khive_storage::event::Event::new(
579 entity.namespace.clone(),
580 "update",
581 EventKind::EntityUpdated,
582 SubstrateKind::Entity,
583 "",
584 )
585 .with_target(entity.id)
586 .with_payload(serde_json::json!({
587 "id": entity.id,
588 "namespace": entity.namespace,
589 "changed_fields": changed_fields,
590 }));
591 let event_result = event_store.append_event(event).await.map_err(|e| {
592 RuntimeError::Internal(format!("update_entity: event store write failed: {e}"))
593 });
594
595 let embedding_report = if reindex_required {
596 self.reindex_entity(token, &entity).await?
597 } else {
598 crate::retrieval::EmbeddingTruncationReport::default()
599 };
600
601 event_result?;
602
603 Ok((entity, embedding_report))
604 }
605
606 pub async fn merge_entity(
620 &self,
621 token: &NamespaceToken,
622 into_id: Uuid,
623 from_id: Uuid,
624 strategy: EntityDedupMergePolicy,
625 content_strategy: ContentMergeStrategy,
626 dry_run: bool,
627 ) -> RuntimeResult<MergeSummary> {
628 self.merge_entity_with_reason(
629 token,
630 into_id,
631 from_id,
632 strategy,
633 content_strategy,
634 dry_run,
635 None,
636 )
637 .await
638 }
639
640 #[allow(clippy::too_many_arguments)]
644 pub async fn merge_entity_with_reason(
645 &self,
646 token: &NamespaceToken,
647 into_id: Uuid,
648 from_id: Uuid,
649 strategy: EntityDedupMergePolicy,
650 content_strategy: ContentMergeStrategy,
651 dry_run: bool,
652 reason: Option<String>,
653 ) -> RuntimeResult<MergeSummary> {
654 self.merge_entity_with_validation(
655 token,
656 into_id,
657 from_id,
658 strategy,
659 content_strategy,
660 dry_run,
661 reason,
662 EntityMergeValidation::LegacyKind,
663 )
664 .await
665 }
666
667 #[allow(clippy::too_many_arguments)]
676 pub async fn merge_entity_with_reason_and_force(
677 &self,
678 token: &NamespaceToken,
679 into_id: Uuid,
680 from_id: Uuid,
681 strategy: EntityDedupMergePolicy,
682 content_strategy: ContentMergeStrategy,
683 dry_run: bool,
684 reason: Option<String>,
685 force: bool,
686 ) -> RuntimeResult<MergeSummary> {
687 let validation = if force {
688 EntityMergeValidation::Forced
689 } else {
690 EntityMergeValidation::SafetyFloor
691 };
692 self.merge_entity_with_validation(
693 token,
694 into_id,
695 from_id,
696 strategy,
697 content_strategy,
698 dry_run,
699 reason,
700 validation,
701 )
702 .await
703 }
704
705 #[allow(clippy::too_many_arguments)]
706 async fn merge_entity_with_validation(
707 &self,
708 token: &NamespaceToken,
709 into_id: Uuid,
710 from_id: Uuid,
711 strategy: EntityDedupMergePolicy,
712 content_strategy: ContentMergeStrategy,
713 dry_run: bool,
714 reason: Option<String>,
715 validation: EntityMergeValidation,
716 ) -> RuntimeResult<MergeSummary> {
717 if let Some(reason) = reason.as_deref() {
718 crate::secret_gate::check_at(reason, "merge", "reason")?;
719 }
720 if into_id == from_id {
721 return Err(RuntimeError::InvalidInput(
722 "cannot merge an entity into itself".into(),
723 ));
724 }
725 let ns = token.namespace().as_str().to_owned();
726 let fts_table = "fts_entities".to_string();
727 let embedding_plan = EmbeddingModelPlan::capture(self);
731 let vec_tables = embedding_plan.vector_tables();
732 let pack_rules = self.pack_edge_rules();
735
736 let _ = self.entities(token)?;
738 let _ = self.graph(token)?;
739 let _ = self.text(token)?;
740 let _ = self.events(token)?;
741 for model_name in embedding_plan.model_names() {
744 let _ = self.vectors_for_model(token, model_name)?;
745 }
746
747 let pool = self.backend().pool_arc();
748 let writer_task = pool
749 .writer_task_for_runtime_write(RuntimeWriteOperation::MergeEntity)
750 .map_err(RuntimeError::Storage)?;
751 let merge_event_id = Uuid::new_v4();
754 let event_context = MergeEventContext {
755 attribution: EventAttribution::from_token(token),
756 reason,
757 force: validation == EntityMergeValidation::Forced,
758 strategy,
759 content_strategy,
760 kind: EventKind::EntityMerged,
761 substrate: SubstrateKind::Entity,
762 event_id: Some(merge_event_id),
763 };
764
765 let (mut summary, updated_entity) = if let Some(writer_task) = writer_task {
766 writer_task
767 .send(move |conn| {
768 merge_entity_sql(
769 conn,
770 ns,
771 fts_table,
772 vec_tables,
773 into_id,
774 from_id,
775 strategy,
776 content_strategy,
777 dry_run,
778 pack_rules,
779 validation,
780 MergeTxLimits::default(),
781 merge_event_id,
782 Some(event_context),
783 )
784 .map_err(|e| {
785 e.into_storage_error(
786 khive_storage::StorageCapability::Entities,
787 "merge_entity",
788 )
789 })
790 })
791 .await
792 .inspect_err(|error| khive_storage::usage::account_event_write(Err(error)))
793 .map_err(map_merge_entity_storage_error)?
794 } else {
795 tokio::task::spawn_blocking(move || {
796 let guard = pool.writer()?;
797 let mut refusal = None;
798 let result = guard.transaction(|conn| {
799 merge_entity_sql(
800 conn,
801 ns,
802 fts_table,
803 vec_tables,
804 into_id,
805 from_id,
806 strategy,
807 content_strategy,
808 dry_run,
809 pack_rules,
810 validation,
811 MergeTxLimits::default(),
812 merge_event_id,
813 Some(event_context),
814 )
815 .map_err(|error| match error {
816 MergeSqlError::Sqlite(error) => error,
817 MergeSqlError::Refusal(error) => {
818 refusal = Some(error);
819 SqliteError::InvalidData(
820 "entity merge refused by transactional policy".to_string(),
821 )
822 }
823 })
824 });
825 match refusal {
826 Some(error) => Err(error),
827 None => result.map_err(RuntimeError::from),
828 }
829 })
830 .await
831 .map_err(|e| RuntimeError::Internal(e.to_string()))??
832 };
833
834 if !dry_run {
836 khive_storage::usage::account_event_write(Ok(1));
837 tracing::info!(
838 into_id = %summary.kept_id,
839 from_id = %summary.removed_id,
840 budget_rows = summary.tx_budget.rows_charged,
841 budget_bytes = summary.tx_budget.bytes_charged,
842 budget_max_rows = summary.tx_budget.max_rows,
843 budget_max_bytes = summary.tx_budget.max_bytes,
844 "merge_entity: transaction materialization budget"
845 );
846 }
847
848 if !dry_run && !embedding_plan.is_empty() {
851 match self
852 .reindex_entity_with_plan(token, &updated_entity, &embedding_plan, None)
853 .await
854 {
855 Ok(report) => summary.embedding_truncation = report,
856 Err(error) => {
857 tracing::warn!(
858 into_id = %summary.kept_id,
859 from_id = %summary.removed_id,
860 error = %error,
861 "merge_entity: committed merge but survivor reindex failed"
862 );
863 summary.post_commit_reindex_error = Some(error.to_string());
864 }
865 }
866 }
867
868 Ok(summary)
869 }
870
871 async fn apply_entity_index_revision(
874 &self,
875 entity: &Entity,
876 statements: Vec<SqlStatement>,
877 ) -> RuntimeResult<bool> {
878 let namespace = entity.namespace.clone();
879 let id = entity.id.to_string();
880 let version = entity.version;
881 let op: AtomicUnitOp = Box::new(move |writer| {
882 Box::pin(async move {
883 let current = writer
884 .query_scalar(SqlStatement {
885 sql: "SELECT version FROM entities \
886 WHERE namespace=?1 AND id=?2 AND deleted_at IS NULL"
887 .into(),
888 params: vec![SqlValue::Text(namespace), SqlValue::Text(id)],
889 label: Some("entity-index-revision".into()),
890 })
891 .await?;
892 if !matches!(current, Some(SqlValue::Integer(current)) if current == version) {
893 return Ok(Box::new(false) as Box<dyn Any + Send>);
894 }
895 for statement in statements {
896 writer.execute(statement).await?;
897 }
898 Ok(Box::new(true) as Box<dyn Any + Send>)
899 })
900 });
901 self.sql()
902 .atomic_unit(op)
903 .await?
904 .downcast::<bool>()
905 .map(|result| *result)
906 .map_err(|_| RuntimeError::Internal("invalid entity index outcome".into()))
907 }
908
909 pub(crate) fn entity_vector_insert_statements(
910 table: &str,
911 entity: &Entity,
912 model_name: &str,
913 vector: &[f32],
914 ) -> Vec<SqlStatement> {
915 let subject = entity.id.to_string();
916 let model_key = table
917 .strip_prefix("vec_")
918 .expect("runtime vector tables use the vec_ prefix");
919 let kind = SubstrateKind::Entity.to_string();
920 let field = "entity.body";
921 let blob = khive_storage::encode_f32_native(vector);
922 vec![
923 SqlStatement {
924 sql: format!(
925 "INSERT INTO ann_write_log \
926 (namespace, embedding_model, kind, field, subject_id, op) \
927 SELECT namespace, embedding_model, kind, field, subject_id, 'delete' \
928 FROM {table} WHERE subject_id=?1 AND NOT \
929 (namespace=?2 AND embedding_model=?3 AND kind=?4 AND field=?5)"
930 ),
931 params: vec![
932 SqlValue::Text(subject.clone()),
933 SqlValue::Text(entity.namespace.clone()),
934 SqlValue::Text(model_name.to_string()),
935 SqlValue::Text(kind.clone()),
936 SqlValue::Text(field.into()),
937 ],
938 label: Some("entity-reindex-log-delete".into()),
939 },
940 SqlStatement {
941 sql: format!("DELETE FROM {table} WHERE subject_id=?1"),
942 params: vec![SqlValue::Text(subject.clone())],
943 label: Some("entity-reindex-vector-delete".into()),
944 },
945 SqlStatement {
949 sql: "DELETE FROM vector_provenance \
950 WHERE model_key = ?1 AND subject_id = ?2"
951 .into(),
952 params: vec![
953 SqlValue::Text(model_key.to_string()),
954 SqlValue::Text(subject.clone()),
955 ],
956 label: Some("entity-reindex-provenance-clear".into()),
957 },
958 SqlStatement {
959 sql: format!(
960 "INSERT INTO {table} \
961 (subject_id, namespace, kind, field, embedding_model, embedding) \
962 VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
963 ),
964 params: vec![
965 SqlValue::Text(subject.clone()),
966 SqlValue::Text(entity.namespace.clone()),
967 SqlValue::Text(kind.clone()),
968 SqlValue::Text(field.into()),
969 SqlValue::Text(model_name.to_string()),
970 SqlValue::Blob(blob),
971 ],
972 label: Some("entity-reindex-vector-insert".into()),
973 },
974 SqlStatement {
975 sql: "INSERT INTO ann_write_log \
976 (namespace, embedding_model, kind, field, subject_id, op) \
977 VALUES (?1, ?2, ?3, ?4, ?5, 'upsert')"
978 .into(),
979 params: vec![
980 SqlValue::Text(entity.namespace.clone()),
981 SqlValue::Text(model_name.to_string()),
982 SqlValue::Text(kind),
983 SqlValue::Text(field.into()),
984 SqlValue::Text(subject),
985 ],
986 label: Some("entity-reindex-log-upsert".into()),
987 },
988 ]
989 }
990
991 pub(crate) async fn publish_entity_vector_revision(
992 &self,
993 token: &NamespaceToken,
994 entity: &Entity,
995 model_name: &str,
996 vector: &[f32],
997 ) -> RuntimeResult<bool> {
998 self.vectors_for_model(token, model_name)?;
999 let (storage_model, dimensions) = self.vector_model_metadata(model_name)?;
1000 if let Some(index) = crate::atomic_message::non_finite_index(vector) {
1001 return Err(RuntimeError::InvalidInput(format!(
1002 "non-finite entity vector at index {index}"
1003 )));
1004 }
1005 if vector.len() != dimensions {
1006 return Err(RuntimeError::InvalidInput(format!(
1007 "entity vector has {} dimensions; expected {dimensions}",
1008 vector.len()
1009 )));
1010 }
1011 let table = format!("vec_{}", crate::config::sanitize_key(&storage_model));
1012 let statements =
1013 Self::entity_vector_insert_statements(&table, entity, &storage_model, vector);
1014 #[cfg(test)]
1015 race_seam::pause_before_entity_vector_publish().await;
1016 self.apply_entity_index_revision(entity, statements).await
1017 }
1018
1019 pub(crate) async fn reindex_entity(
1032 &self,
1033 token: &NamespaceToken,
1034 entity: &Entity,
1035 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1036 let embedding_plan = EmbeddingModelPlan::capture(self);
1037 self.reindex_entity_with_plan(token, entity, &embedding_plan, None)
1038 .await
1039 }
1040
1041 pub(crate) async fn reindex_entity_with_precomputed(
1042 &self,
1043 token: &NamespaceToken,
1044 entity: &Entity,
1045 mut precomputed: HashMap<String, crate::retrieval::DocumentEmbeddingOutcome>,
1046 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1047 let embedding_plan = EmbeddingModelPlan::capture(self);
1048 self.reindex_entity_with_plan(token, entity, &embedding_plan, Some(&mut precomputed))
1049 .await
1050 }
1051
1052 pub(super) async fn reindex_entity_with_plan(
1053 &self,
1054 token: &NamespaceToken,
1055 entity: &Entity,
1056 embedding_plan: &EmbeddingModelPlan,
1057 mut precomputed: Option<&mut HashMap<String, crate::retrieval::DocumentEmbeddingOutcome>>,
1058 ) -> RuntimeResult<crate::retrieval::EmbeddingTruncationReport> {
1059 #[cfg(test)]
1062 if crate::operations::consume_fts_fail_fault(&entity.namespace) {
1063 return Err(RuntimeError::Internal("injected FTS failure".to_string()));
1064 }
1065 let doc = entity_fts_document(entity);
1067 let embed_body = doc.body.clone();
1068 let _ = self.text(token)?;
1069 #[cfg(test)]
1070 race_seam::pause_before_entity_index_publish().await;
1071 let statements = khive_db::stores::text::delete_document_statements(
1072 "fts_entities",
1073 &entity.namespace,
1074 entity.id,
1075 )
1076 .into_iter()
1077 .chain(khive_db::stores::text::insert_document_statements(
1078 "fts_entities",
1079 &doc,
1080 ))
1081 .collect();
1082 if !self.apply_entity_index_revision(entity, statements).await? {
1083 return Ok(crate::retrieval::EmbeddingTruncationReport::default());
1084 }
1085
1086 let mut report = crate::retrieval::EmbeddingTruncationReport::default();
1087 for model_name in embedding_plan.model_names() {
1088 let embedding = match precomputed
1089 .as_mut()
1090 .and_then(|outcomes| outcomes.remove(model_name))
1091 {
1092 Some(outcome) => Ok(outcome),
1093 None => {
1094 self.embed_document_with_model_outcome_for_token(token, model_name, &embed_body)
1095 .await
1096 }
1097 };
1098 match embedding {
1099 Ok(outcome) => {
1100 report.observe(&outcome);
1101 match self
1102 .publish_entity_vector_revision(token, entity, model_name, &outcome.vector)
1103 .await
1104 {
1105 Ok(true) => {}
1106 Ok(false) => break,
1107 Err(error) => tracing::warn!(
1108 model = model_name,
1109 id = %entity.id,
1110 "reindex_entity: vector insert failed, skipping model: {error}"
1111 ),
1112 }
1113 }
1114 Err(e) => {
1115 tracing::warn!(
1116 model = model_name,
1117 id = %entity.id,
1118 "reindex_entity: embed failed for model, skipping: {e}"
1119 );
1120 }
1121 }
1122 }
1123
1124 Ok(report)
1125 }
1126}