1use crate::bus::{BusManager, BusMessage};
2use crate::cache::{CacheWrite, EntityCache, LifetimeOrdering, PatchOrigin, ViewVersion};
3use crate::mutation_batch::{MutationBatch, SlotContext, SlotIndexDomain};
4use crate::shared_entity::{split_version, with_version, SharedEntity};
5use crate::view::{Projection, ViewIndex, ViewSpec};
6use crate::websocket::frame::{apply_wire_format, Mode, SourceFrame};
7use arete_interpreter::vm::{VmContext, WholeEntityRequests};
8use arete_interpreter::{AccountPosition, CanonicalLog, WholeEntity};
9use bytes::Bytes;
10use lru::LruCache;
11use serde_json::Value;
12use smallvec::SmallVec;
13use std::collections::HashSet;
14use std::future::Future;
15use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
16use std::sync::{Arc, Mutex as StdMutex};
17use tokio::sync::mpsc;
18use tracing::{debug, debug_span, error, instrument, warn};
19
20#[cfg(feature = "otel")]
21use crate::metrics::Metrics;
22
23const WHOLE_ENTITY_REQUEST_CAPACITY: usize = 4_096;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29struct RememberedChange {
30 seq: Option<String>,
31 source_domain: Option<SlotIndexDomain>,
32 account_position: Option<AccountPosition>,
33}
34
35type LastChanges = LruCache<(String, String), RememberedChange>;
37
38#[derive(Clone)]
71pub struct EntityResync {
72 requests: WholeEntityRequests,
73 last_changes: Arc<StdMutex<LastChanges>>,
77 linked: Arc<AtomicBool>,
78 warned_unlinked: Arc<AtomicBool>,
79}
80
81tokio::task_local! {
82 static ACTIVE_ENTITY_RESYNC: EntityResync;
83}
84
85impl Default for EntityResync {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl EntityResync {
92 pub fn new() -> Self {
93 Self {
94 requests: WholeEntityRequests::new(WHOLE_ENTITY_REQUEST_CAPACITY),
95 last_changes: Arc::new(StdMutex::new(LruCache::unbounded())),
96 linked: Arc::new(AtomicBool::new(false)),
97 warned_unlinked: Arc::new(AtomicBool::new(false)),
98 }
99 }
100
101 pub async fn scope<F: Future>(&self, future: F) -> F::Output {
104 ACTIVE_ENTITY_RESYNC.scope(self.clone(), future).await
105 }
106
107 pub fn link(&self, vm: &Arc<StdMutex<VmContext>>) {
109 vm.lock()
110 .unwrap_or_else(|poisoned| poisoned.into_inner())
111 .set_whole_entity_requests(self.requests.clone());
112 self.linked.store(true, Ordering::Release);
113 }
114
115 pub fn is_linked(&self) -> bool {
116 self.linked.load(Ordering::Acquire)
117 }
118
119 fn unmarked_origin(&self) -> PatchOrigin {
122 if self.is_linked() {
123 PatchOrigin::Change
124 } else {
125 PatchOrigin::Unknown
126 }
127 }
128
129 pub fn requests(&self) -> &WholeEntityRequests {
131 &self.requests
132 }
133
134 fn last_changes(&self) -> std::sync::MutexGuard<'_, LastChanges> {
135 self.last_changes
136 .lock()
137 .unwrap_or_else(|poisoned| poisoned.into_inner())
138 }
139
140 fn note_change(
145 &self,
146 export: &str,
147 key: &str,
148 seq: &Option<String>,
149 source_domain: Option<SlotIndexDomain>,
150 account_position: Option<AccountPosition>,
151 refused: bool,
152 ) {
153 if !self.is_linked() {
154 return;
155 }
156 let mut last_changes = self.last_changes();
157 if refused {
158 last_changes.put(
159 (export.to_string(), key.to_string()),
160 RememberedChange {
161 seq: seq.clone(),
162 source_domain,
163 account_position,
164 },
165 );
166 while last_changes.len() > WHOLE_ENTITY_REQUEST_CAPACITY {
167 last_changes.pop_lru();
168 }
169 } else if !last_changes.is_empty() {
170 if let Some(last) = last_changes.get_mut(&(export.to_string(), key.to_string())) {
171 last.seq = seq.clone();
172 last.source_domain = source_domain;
173 if account_position.is_some() {
174 last.account_position = account_position;
175 }
176 }
177 }
178 }
179
180 fn take_last_change(&self, export: &str, key: &str) -> Option<RememberedChange> {
183 let mut last_changes = self.last_changes();
184 if last_changes.is_empty() {
185 return None;
186 }
187 last_changes.pop(&(export.to_string(), key.to_string()))
188 }
189
190 fn request(&self, export: &str, key: &Value) {
191 if self.is_linked() {
192 self.requests.request(export, key);
193 } else if !self.warned_unlinked.swap(true, Ordering::Relaxed) {
194 warn!(
195 export,
196 "Entity cache refused a patch for an evicted key and no VM is registered to \
197 send the whole entity; the key stays out of snapshots until its source sends \
198 a mutation marked whole or deletes it. Raise max_entities_per_view if the \
199 working set exceeds it."
200 );
201 }
202 }
203
204 fn cancel(&self, export: &str, key: &Value, cache_key: &str) {
205 self.requests.cancel(export, key);
206 self.take_last_change(export, cache_key);
207 }
208}
209
210pub(crate) fn link_active_resync(vm: &Arc<StdMutex<VmContext>>) {
213 let _ = ACTIVE_ENTITY_RESYNC.try_with(|resync| resync.link(vm));
214}
215
216struct FrameVersions {
231 epoch: String,
232 last: AtomicU64,
233}
234
235impl FrameVersions {
236 fn new() -> Self {
237 let epoch = hex::encode(&uuid::Uuid::new_v4().as_bytes()[..4]);
240 Self {
241 epoch,
242 last: AtomicU64::new(0),
243 }
244 }
245
246 fn next(&self, data: &Value) -> Option<Value> {
250 if !data.is_object() {
251 debug!("mutation is not an object; publishing it without a version");
252 return None;
253 }
254 let counter = self.last.fetch_add(1, Ordering::Relaxed) + 1;
255 Some(Value::String(format!("{}:{counter}", self.epoch)))
256 }
257}
258
259struct PendingFrame<'a> {
261 spec: &'a ViewSpec,
262 projection: usize,
264 version: Option<Value>,
266 row_scope: Option<crate::row_scope::RowScope>,
268 output: FrameOutput,
269}
270
271enum FrameOutput {
272 Change(Arc<Bytes>),
275 Whole(Value),
277}
278
279pub struct Projector {
280 view_index: Arc<ViewIndex>,
281 bus_manager: BusManager,
282 entity_cache: EntityCache,
283 mutations_rx: mpsc::Receiver<MutationBatch>,
284 snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
285 journal: Option<Arc<crate::journal::EventJournal>>,
286 resync: EntityResync,
287 versions: FrameVersions,
288 #[cfg(feature = "otel")]
289 metrics: Option<Arc<Metrics>>,
290}
291
292impl Projector {
293 #[cfg(feature = "otel")]
294 pub fn new(
295 view_index: Arc<ViewIndex>,
296 bus_manager: BusManager,
297 entity_cache: EntityCache,
298 mutations_rx: mpsc::Receiver<MutationBatch>,
299 metrics: Option<Arc<Metrics>>,
300 ) -> Self {
301 Self {
302 view_index,
303 bus_manager,
304 entity_cache,
305 mutations_rx,
306 snapshot_runtime: None,
307 journal: None,
308 resync: EntityResync::new(),
309 versions: FrameVersions::new(),
310 metrics,
311 }
312 }
313
314 #[cfg(not(feature = "otel"))]
315 pub fn new(
316 view_index: Arc<ViewIndex>,
317 bus_manager: BusManager,
318 entity_cache: EntityCache,
319 mutations_rx: mpsc::Receiver<MutationBatch>,
320 ) -> Self {
321 Self {
322 view_index,
323 bus_manager,
324 entity_cache,
325 mutations_rx,
326 snapshot_runtime: None,
327 journal: None,
328 resync: EntityResync::new(),
329 versions: FrameVersions::new(),
330 }
331 }
332
333 pub fn with_snapshot_runtime(
335 mut self,
336 snapshot_runtime: crate::snapshot::SnapshotRuntime,
337 ) -> Self {
338 self.snapshot_runtime = Some(snapshot_runtime);
339 self
340 }
341
342 pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
344 self.journal = Some(journal);
345 self
346 }
347
348 pub fn with_entity_resync(mut self, resync: EntityResync) -> Self {
350 self.resync = resync;
351 self
352 }
353
354 pub async fn run(mut self) {
355 debug!("Projector started");
356
357 let mut json_buffer = Vec::with_capacity(4096);
358
359 while let Some(mut batch) = self.mutations_rx.recv().await {
360 let mut log = CanonicalLog::new();
361 log.set("phase", "projector");
362
363 let batch_size = batch.len();
364 let slot_context = batch.slot_context;
365 let batch_span = debug_span!(
366 parent: &batch.span,
367 "projector.batch",
368 batch.mutations = batch_size,
369 batch.position = tracing::field::Empty,
370 frames_published = tracing::field::Empty,
371 );
372 if let (Some(context), false) = (slot_context, batch_span.is_disabled()) {
373 batch_span.record("batch.position", context.to_seq_string());
374 }
375 let _span_guard = batch_span.enter();
376 let mut frames_published = 0u32;
377 let mut errors = 0u32;
378
379 if let Some(ctx) = batch.event_context.as_ref() {
380 log.set("program", &ctx.program)
381 .set("event_kind", &ctx.event_kind)
382 .set("event_type", &ctx.event_type)
383 .set("account", &ctx.account)
384 .set("accounts_count", ctx.accounts_count);
385 }
386
387 let arriving_whole: HashSet<(String, String)> = batch
390 .mutations
391 .iter()
392 .filter(|mutation| mutation.is_whole_entity())
393 .map(|mutation| (mutation.export.clone(), Self::extract_key(&mutation.key)))
394 .collect();
395
396 for mutation in std::mem::take(&mut batch.mutations).into_iter() {
397 #[cfg(feature = "otel")]
398 let export = mutation.export.clone();
399
400 match self
401 .process_mutation(mutation, slot_context, &arriving_whole, &mut json_buffer)
402 .await
403 {
404 Ok(count) => frames_published += count,
405 Err(e) => {
406 error!("Failed to process mutation: {}", e);
407 errors += 1;
408 }
409 }
410
411 #[cfg(feature = "otel")]
412 if let Some(ref metrics) = self.metrics {
413 metrics.record_mutation_processed(&export);
414 }
415 }
416
417 if batch_size > 0 {
422 if let Some(snapshot_runtime) = &self.snapshot_runtime {
423 snapshot_runtime.record_applied_batch(slot_context.map(|ctx| ctx.slot));
424 }
425 }
426 drop(batch.snapshot_guard.take());
427
428 if let Some(ack) = batch.flush_ack.take() {
431 let _ = ack.send(());
432 }
433
434 log.set("batch_size", batch_size)
435 .set("frames_published", frames_published)
436 .set("errors", errors);
437 batch_span.record("frames_published", frames_published);
438
439 #[cfg(feature = "otel")]
440 if let Some(ref metrics) = self.metrics {
441 metrics.record_projector_latency(log.duration_ms());
442 }
443
444 log.emit();
445 }
446
447 debug!("Projector stopped");
448 }
449
450 #[instrument(
451 name = "projector.mutation",
452 level = "debug",
453 skip(self, mutation, slot_context, arriving_whole, json_buffer),
454 fields(export = %mutation.export)
455 )]
456 async fn process_mutation(
457 &self,
458 mut mutation: arete_interpreter::Mutation,
459 slot_context: Option<SlotContext>,
460 arriving_whole: &HashSet<(String, String)>,
461 json_buffer: &mut Vec<u8>,
462 ) -> anyhow::Result<u32> {
463 let specs = self.view_index.by_export(&mutation.export);
464 for spec in specs {
465 self.view_index.validate_scope_projection(spec)?;
466 }
467
468 if specs.is_empty() {
469 return Ok(0);
470 }
471
472 let account_position = mutation
473 .take_account_position()
474 .map_err(anyhow::Error::msg)?;
475
476 if mutation.is_delete() {
477 let key = Self::extract_key(&mutation.key);
478 let seq = slot_context.map(|ctx| ctx.to_seq_string());
479 let source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
480 self.resync.cancel(&mutation.export, &mutation.key, &key);
481 let mut published = 0;
482 for spec in specs.iter().filter(|spec| spec.filters.matches(&key)) {
483 let deleted = if account_position.is_none() && seq.is_none() {
484 self.entity_cache.delete_current(&spec.id, &key).await
485 } else {
486 match source_domain {
487 Some(source_domain) => {
488 self.entity_cache
489 .delete_ordered_in_domain(
490 &spec.id,
491 &key,
492 account_position,
493 seq.as_deref(),
494 source_domain,
495 )
496 .await
497 }
498 None => {
499 self.entity_cache
500 .delete_ordered(&spec.id, &key, account_position, seq.as_deref())
501 .await
502 }
503 }
504 };
505 if !deleted {
506 continue;
507 }
508 let sorted = self.view_index.sorted_caches();
509 let mut caches = sorted.write().await;
510 for derived in self.view_index.get_derived_views_for_source(&spec.id) {
511 if let Some(cache) = caches.get_mut(&derived.id) {
512 cache.remove_key(&key);
513 }
514 }
515 drop(caches);
516 if spec.mode == Mode::Append {
519 continue;
520 }
521 let frame = SourceFrame {
522 mode: spec.mode,
523 export: spec.id.clone(),
524 op: "delete",
525 key: key.clone(),
526 data: Value::Null,
527 append: Vec::new(),
528 seq: seq.clone(),
529 offset: None,
530 };
531 json_buffer.clear();
532 serde_json::to_writer(&mut *json_buffer, &frame)?;
533 self.publish_frame(
534 spec,
535 Arc::new(BusMessage {
536 row_scope: None,
537 key: key.clone(),
538 entity: spec.id.clone(),
539 payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
540 }),
541 )
542 .await;
543 published += 1;
544 }
545 return Ok(published);
546 }
547 let mark = mutation.take_whole_entity_mark();
548 let whole = mark == Some(WholeEntity::Resent);
549 let origin = match mark {
550 Some(WholeEntity::Created) => PatchOrigin::Creation,
551 _ => self.resync.unmarked_origin(),
552 };
553 let key = Self::extract_key(&mutation.key);
554 let arete_interpreter::Mutation {
555 export,
556 key: source_key,
557 mut patch,
558 append,
559 } = mutation;
560
561 let batch_seq = slot_context.map(|ctx| ctx.to_seq_string());
564 let batch_source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
565 let remembered = if whole {
566 self.resync.take_last_change(&export, &key)
567 } else {
568 None
569 };
570 let requested_resend = remembered.is_some();
571 let seq = if whole {
572 remembered
573 .as_ref()
574 .map(|change| change.seq.clone())
575 .unwrap_or(batch_seq)
576 } else {
577 batch_seq
578 };
579 let source_domain = if whole {
580 remembered
581 .as_ref()
582 .and_then(|change| change.source_domain)
583 .or(batch_source_domain)
584 } else {
585 batch_source_domain
586 };
587 let resend_account_position = remembered.and_then(|change| change.account_position);
588 if let (Some(seq), Value::Object(map)) = (&seq, &mut patch) {
589 map.insert("_seq".to_string(), Value::String(seq.clone()));
590 }
591
592 let matching_specs: SmallVec<[&ViewSpec; 4]> = specs
593 .iter()
594 .filter(|spec| spec.filters.matches(&key))
595 .collect();
596
597 let match_count = matching_specs.len();
598 if match_count == 0 {
599 return Ok(0);
600 }
601 let current_account_position = self
602 .entity_cache
603 .account_position(&matching_specs[0].id, &key)
604 .await;
605 let change_account_position = account_position.or(current_account_position);
606
607 let mut frames_published = 0u32;
608 let mut refused = false;
609
610 let mut accepted: SmallVec<[&ViewSpec; 4]> = SmallVec::new();
615 for spec in matching_specs {
616 if !whole {
617 let accepts = match source_domain {
618 Some(source_domain) => {
619 self.entity_cache
620 .accepts_ordered_lifetime_mutation_in_domain(
621 &spec.id,
622 &key,
623 origin == PatchOrigin::Creation,
624 account_position,
625 seq.as_deref(),
626 source_domain,
627 )
628 .await
629 }
630 None => {
631 self.entity_cache
632 .accepts_lifetime_mutation(
633 &spec.id,
634 &key,
635 origin == PatchOrigin::Creation,
636 account_position,
637 )
638 .await
639 }
640 };
641 if !accepts {
642 continue;
643 }
644 }
645 accepted.push(spec);
646 }
647
648 let mut projections: SmallVec<[(&Projection, Value); 1]> = SmallVec::new();
652 for spec in &accepted {
653 if !projections
654 .iter()
655 .any(|(projection, _)| projection.fields == spec.projection.fields)
656 {
657 projections.push((&spec.projection, Value::Null));
658 }
659 }
660 let distinct = projections.len();
661 for (index, (projection, projected)) in projections.iter_mut().enumerate() {
662 let source = if index + 1 == distinct {
663 std::mem::take(&mut patch)
664 } else {
665 patch.clone()
666 };
667 *projected = split_version(projection.apply(source)).0;
668 }
669
670 let mut pending: SmallVec<[PendingFrame<'_>; 4]> = SmallVec::new();
671 for spec in accepted {
672 let projection = projections
673 .iter()
674 .position(|(projection, _)| projection.fields == spec.projection.fields)
675 .unwrap_or_default();
676 let fields = &projections[projection].1;
677 let row_scope = self
678 .scope_at_publication(
679 &spec.id,
680 &key,
681 fields,
682 whole || origin == PatchOrigin::Creation,
683 )
684 .await;
685 let version = self.versions.next(fields);
689 let mut wire_data = with_version(fields.clone(), version.clone());
690 apply_wire_format(&mut wire_data, &spec.wire_format);
691
692 if whole {
693 pending.push(PendingFrame {
694 spec,
695 projection,
696 version,
697 row_scope,
698 output: FrameOutput::Whole(wire_data),
699 });
700 continue;
701 }
702
703 let journal = self
708 .journal
709 .as_ref()
710 .filter(|journal| journal.is_enabled() && spec.mode == Mode::Append);
711
712 let mut frame = SourceFrame {
713 mode: spec.mode,
714 export: spec.id.clone(),
715 op: if mark == Some(WholeEntity::Created) && spec.mode != Mode::Append {
716 "upsert"
717 } else {
718 "patch"
719 },
720 key: key.clone(),
721 data: wire_data,
722 append: append.clone(),
723 seq: seq.clone(),
724 offset: None,
725 };
726
727 let retained = match journal {
728 Some(journal) => {
729 journal
730 .append_scoped_with(&spec.id, &key, row_scope.clone(), |offset| {
731 frame.offset = Some(offset);
732 json_buffer.clear();
733 serde_json::to_writer(&mut *json_buffer, &frame)?;
734 Ok::<_, anyhow::Error>(Arc::new(Bytes::copy_from_slice(json_buffer)))
735 })
736 .await?
737 }
738 None => None,
739 };
740 let payload = match retained {
741 Some((_offset, payload)) => payload,
742 None => {
745 frame.offset = None;
746 json_buffer.clear();
747 serde_json::to_writer(&mut *json_buffer, &frame)?;
748 Arc::new(Bytes::copy_from_slice(json_buffer))
749 }
750 };
751 pending.push(PendingFrame {
752 spec,
753 projection,
754 version,
755 row_scope,
756 output: FrameOutput::Change(payload),
757 });
758 }
759
760 if whole {
761 frames_published += self
762 .apply_whole_entity(
763 &key,
764 projections,
765 pending,
766 (
767 RememberedChange {
768 seq: seq.clone(),
769 source_domain,
770 account_position: resend_account_position,
771 },
772 requested_resend,
773 ),
774 json_buffer,
775 )
776 .await?;
777 } else {
778 let ordering = LifetimeOrdering {
781 account_position,
782 source_seq: seq.as_deref(),
783 source_domain: account_position
787 .is_none()
788 .then_some(source_domain)
789 .flatten(),
790 };
791 let mut writes: SmallVec<[Option<CacheWrite>; 4]> =
792 pending.iter().map(|_| None).collect();
793 for (projection, (_, fields)) in projections.into_iter().enumerate() {
794 let members: SmallVec<[usize; 4]> = pending
795 .iter()
796 .enumerate()
797 .filter(|(_, frame)| frame.projection == projection)
798 .map(|(index, _)| index)
799 .collect();
800 let views: SmallVec<[ViewVersion<'_>; 4]> = members
801 .iter()
802 .map(|&index| ViewVersion {
803 view_id: &pending[index].spec.id,
804 version: pending[index].version.clone(),
805 })
806 .collect();
807 let results = self
808 .entity_cache
809 .upsert_views(&key, fields, &views, &append, origin, ordering)
810 .await;
811 for (index, write) in members.into_iter().zip(results) {
812 writes[index] = Some(write);
813 }
814 }
815
816 for (frame, write) in pending.into_iter().zip(writes) {
817 let (Some(write), FrameOutput::Change(payload)) = (write, frame.output) else {
818 continue;
819 };
820 let spec = frame.spec;
821 match write {
822 CacheWrite::Refused { patch } => {
823 refused = true;
824 if spec.mode == Mode::List {
825 self.merge_into_held_derived_entities(&spec.id, &key, patch, &append)
826 .await;
827 }
828 }
829 CacheWrite::Merged | CacheWrite::Created => {
830 if spec.mode == Mode::List {
831 self.update_derived_view_caches(&spec.id, &key).await;
832 }
833 }
834 }
835
836 let message = Arc::new(BusMessage {
837 row_scope: frame.row_scope,
838 key: key.clone(),
839 entity: spec.id.clone(),
840 payload,
841 });
842
843 self.publish_frame(spec, message).await;
844 frames_published += 1;
845
846 #[cfg(feature = "otel")]
847 if let Some(ref metrics) = self.metrics {
848 let mode_str = match spec.mode {
849 Mode::List => "list",
850 Mode::State => "state",
851 Mode::Append => "append",
852 };
853 metrics.record_frame_published(mode_str, &spec.export);
854 }
855 }
856 }
857
858 if !whole {
859 self.resync.note_change(
860 &export,
861 &key,
862 &seq,
863 source_domain,
864 change_account_position,
865 refused,
866 );
867 }
868 if refused && !arriving_whole.contains(&(export.clone(), key.clone())) {
869 self.resync.request(&export, &source_key);
870 }
871
872 Ok(frames_published)
873 }
874
875 async fn apply_whole_entity(
885 &self,
886 key: &str,
887 projections: SmallVec<[(&Projection, Value); 1]>,
888 pending: SmallVec<[PendingFrame<'_>; 4]>,
889 resend: (RememberedChange, bool),
890 json_buffer: &mut Vec<u8>,
891 ) -> anyhow::Result<u32> {
892 let (position, requested_resend) = resend;
893 let mut stored: SmallVec<[bool; 4]> = pending.iter().map(|_| false).collect();
894 if requested_resend || !self.resync.is_linked() {
899 let requested_source = requested_resend.then(|| {
900 (
901 position.seq.as_deref(),
902 position.source_domain.unwrap_or(SlotIndexDomain::Legacy),
903 )
904 });
905 for (projection, (_, fields)) in projections.into_iter().enumerate() {
906 let members: SmallVec<[usize; 4]> = pending
907 .iter()
908 .enumerate()
909 .filter(|(_, frame)| frame.projection == projection)
910 .map(|(index, _)| index)
911 .collect();
912 let views: SmallVec<[ViewVersion<'_>; 4]> = members
913 .iter()
914 .map(|&index| ViewVersion {
915 view_id: &pending[index].spec.id,
916 version: pending[index].version.clone(),
917 })
918 .collect();
919 let results = self
920 .entity_cache
921 .store_whole_views(
922 key,
923 fields,
924 &views,
925 position.account_position,
926 requested_source,
927 )
928 .await;
929 for (index, result) in members.into_iter().zip(results) {
930 stored[index] = result;
931 }
932 }
933 }
934
935 let mut published = 0;
936 for (frame, stored) in pending.into_iter().zip(stored) {
937 let (true, FrameOutput::Whole(wire_data)) = (stored, frame.output) else {
938 continue;
939 };
940 let spec = frame.spec;
941 match spec.mode {
942 Mode::Append => continue,
943 Mode::List => self.update_derived_view_caches(&spec.id, key).await,
944 Mode::State => {}
945 }
946 let frame = SourceFrame {
947 mode: spec.mode,
948 export: spec.id.clone(),
949 op: "upsert",
950 key: key.to_string(),
951 data: wire_data,
952 append: Vec::new(),
953 seq: position.seq.clone(),
954 offset: None,
955 };
956 json_buffer.clear();
957 serde_json::to_writer(&mut *json_buffer, &frame)?;
958 let message = Arc::new(BusMessage {
959 row_scope: None,
960 key: key.to_string(),
961 entity: spec.id.clone(),
962 payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
963 });
964 self.publish_frame(spec, message).await;
965 published += 1;
966 }
967 Ok(published)
968 }
969
970 pub(crate) fn extract_key(key: &serde_json::Value) -> String {
971 key.as_str()
972 .map(|s| s.to_string())
973 .or_else(|| key.as_u64().map(|n| n.to_string()))
974 .or_else(|| key.as_i64().map(|n| n.to_string()))
975 .or_else(|| {
976 key.as_array().and_then(|arr| {
977 let bytes: Vec<u8> = arr
978 .iter()
979 .filter_map(|v| v.as_u64().map(|n| n as u8))
980 .collect();
981 if bytes.len() == arr.len() {
982 Some(hex::encode(&bytes))
983 } else {
984 None
985 }
986 })
987 })
988 .unwrap_or_else(|| key.to_string())
989 }
990
991 async fn update_derived_view_caches(&self, source_view_id: &str, entity_key: &str) {
992 let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
993 if derived_views.is_empty() {
994 return;
995 }
996
997 let Some(entity) = self
999 .entity_cache
1000 .get_shared(source_view_id, entity_key)
1001 .await
1002 else {
1003 return;
1004 };
1005
1006 let max_entries = self.entity_cache.max_entities_per_view();
1009 let sorted_caches = self.view_index.sorted_caches();
1010 let mut caches = sorted_caches.write().await;
1011
1012 for spec in &derived_views {
1017 let Some(cache) = caches.get_mut(&spec.id) else {
1018 continue;
1019 };
1020 let passes = spec
1021 .pipeline
1022 .as_ref()
1023 .and_then(|pipeline| pipeline.filter.as_ref())
1024 .is_none_or(|filter| filter.matches(&entity));
1025 if !passes {
1026 cache.remove_key(entity_key);
1027 continue;
1028 }
1029 if cache.upsert_if_kept(entity_key, &entity, max_entries) {
1030 debug!(
1031 "Updated sorted cache for derived view {} with key {}",
1032 spec.id, entity_key
1033 );
1034 }
1035 self.report_scope_partition_evictions(&spec.id, cache);
1036 }
1037 }
1038
1039 fn report_scope_partition_evictions(
1041 &self,
1042 view_id: &str,
1043 cache: &mut crate::sorted_cache::SortedViewCache,
1044 ) {
1045 let evictions = cache.take_scope_partition_evictions();
1046 if evictions == 0 {
1047 return;
1048 }
1049 debug!(
1050 view_id,
1051 evictions, "Row-scoped view evicted scope partitions"
1052 );
1053 #[cfg(feature = "otel")]
1054 if let Some(ref metrics) = self.metrics {
1055 metrics.record_view_scope_partition_evictions(view_id, evictions);
1056 }
1057 }
1058
1059 async fn merge_into_held_derived_entities(
1070 &self,
1071 source_view_id: &str,
1072 entity_key: &str,
1073 patch: Value,
1074 append_paths: &[String],
1075 ) {
1076 let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
1077 if derived_views.is_empty() {
1078 return;
1079 }
1080 let max_entries = self.entity_cache.max_entities_per_view();
1081 let sorted_caches = self.view_index.sorted_caches();
1082 let mut caches = sorted_caches.write().await;
1083 let mut merged: SmallVec<[(SharedEntity, SharedEntity); 2]> = SmallVec::new();
1085 for spec in &derived_views {
1086 let Some(cache) = caches.get_mut(&spec.id) else {
1087 continue;
1088 };
1089 let Some(held) = cache.get(entity_key).cloned() else {
1090 continue;
1091 };
1092 let entity = match merged.iter().find(|(before, _)| {
1095 before.shares_fields_with(&held) && before.version() == held.version()
1096 }) {
1097 Some((_, after)) => after.clone(),
1098 None => {
1099 let mut entity = held.clone();
1100 self.entity_cache
1101 .merge_patch_shared(&mut entity, patch.clone(), append_paths);
1102 merged.push((held, entity.clone()));
1103 entity
1104 }
1105 };
1106 let passes = spec
1107 .pipeline
1108 .as_ref()
1109 .and_then(|pipeline| pipeline.filter.as_ref())
1110 .is_none_or(|filter| filter.matches(&entity));
1111 if passes {
1112 cache.remove_key(entity_key);
1116 cache.upsert_if_kept(entity_key, &entity, max_entries);
1117 self.report_scope_partition_evictions(&spec.id, cache);
1118 } else {
1119 cache.remove_key(entity_key);
1120 }
1121 }
1122 }
1123
1124 async fn scope_at_publication(
1125 &self,
1126 view: &str,
1127 key: &str,
1128 data: &Value,
1129 creation: bool,
1130 ) -> Option<crate::row_scope::RowScope> {
1131 let field = self.view_index.scope_field_for_view(view)?;
1132 if let Some(scope) = field.scope_in(data) {
1133 return Some(scope);
1134 }
1135 if !creation {
1136 if let Some(row) = self.entity_cache.get_shared(view, key).await {
1137 return Some(field.scope_of(&row));
1138 }
1139 }
1140 Some(if creation {
1141 crate::row_scope::RowScope::Public
1142 } else {
1143 crate::row_scope::RowScope::Invalid
1144 })
1145 }
1146
1147 #[instrument(
1148 name = "projector.publish",
1149 level = "debug",
1150 skip(self, spec, message),
1151 fields(view_id = %spec.id, mode = ?spec.mode)
1152 )]
1153 async fn publish_frame(&self, spec: &ViewSpec, mut message: Arc<BusMessage>) {
1154 if let Some(field) = self
1155 .view_index
1156 .scope_field_for_view(&spec.id)
1157 .filter(|_| message.row_scope.is_none())
1158 {
1159 let scope = self
1162 .entity_cache
1163 .get_shared(&spec.id, &message.key)
1164 .await
1165 .map(|row| field.scope_of(&row))
1166 .unwrap_or(crate::row_scope::RowScope::Invalid);
1167 Arc::make_mut(&mut message).row_scope = Some(scope);
1168 }
1169 match spec.mode {
1170 Mode::State => {
1171 self.bus_manager
1172 .publish_state(&spec.id, &message.key, message.payload.clone())
1173 .await;
1174 }
1175 Mode::List | Mode::Append => {
1176 self.bus_manager.publish_list(&spec.id, message).await;
1177 }
1178 }
1179 }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185
1186 fn linked() -> EntityResync {
1187 let resync = EntityResync::new();
1188 resync.link(&Arc::new(StdMutex::new(VmContext::new())));
1189 resync
1190 }
1191
1192 fn seq(slot: u64) -> Option<String> {
1193 Some(SlotContext::new(slot, 0).to_seq_string())
1194 }
1195
1196 #[test]
1199 fn a_resend_keeps_the_latest_change_seen_before_it() {
1200 let resync = linked();
1201 let lifetime = Some(AccountPosition::new(4, 3));
1202 resync.note_change(
1203 "Round",
1204 "1",
1205 &seq(5),
1206 Some(SlotIndexDomain::Legacy),
1207 lifetime,
1208 true,
1209 );
1210 resync.note_change(
1211 "Round",
1212 "1",
1213 &seq(6),
1214 Some(SlotIndexDomain::Legacy),
1215 lifetime,
1216 true,
1217 );
1218 resync.note_change(
1219 "Round",
1220 "2",
1221 &seq(7),
1222 Some(SlotIndexDomain::Legacy),
1223 None,
1224 false,
1225 );
1226 assert_eq!(resync.take_last_change("Round", "2"), None, "never refused");
1227 resync.note_change(
1228 "Round",
1229 "1",
1230 &seq(8),
1231 Some(SlotIndexDomain::Legacy),
1232 None,
1233 false,
1234 );
1235 assert_eq!(
1236 resync.take_last_change("Round", "1"),
1237 Some(RememberedChange {
1238 seq: seq(8),
1239 source_domain: Some(SlotIndexDomain::Legacy),
1240 account_position: lifetime,
1241 })
1242 );
1243 assert_eq!(resync.take_last_change("Round", "1"), None);
1244 }
1245
1246 #[test]
1247 fn remembered_positions_are_bounded_like_requests() {
1248 let resync = linked();
1249 for key in 0..=WHOLE_ENTITY_REQUEST_CAPACITY {
1250 resync.note_change(
1251 "Round",
1252 &key.to_string(),
1253 &seq(key as u64),
1254 Some(SlotIndexDomain::Legacy),
1255 None,
1256 true,
1257 );
1258 }
1259 assert_eq!(
1260 resync.take_last_change("Round", "0"),
1261 None,
1262 "the oldest goes"
1263 );
1264 let newest = WHOLE_ENTITY_REQUEST_CAPACITY;
1265 assert_eq!(
1266 resync.take_last_change("Round", &newest.to_string()),
1267 Some(RememberedChange {
1268 seq: seq(newest as u64),
1269 source_domain: Some(SlotIndexDomain::Legacy),
1270 account_position: None,
1271 })
1272 );
1273 }
1274
1275 #[test]
1278 fn nothing_is_remembered_without_a_vm() {
1279 let resync = EntityResync::new();
1280 resync.note_change(
1281 "Round",
1282 "1",
1283 &seq(5),
1284 Some(SlotIndexDomain::Legacy),
1285 None,
1286 true,
1287 );
1288 assert_eq!(resync.take_last_change("Round", "1"), None);
1289 }
1290}
1291
1292#[cfg(test)]
1293mod scope_publication_tests {
1294 use super::*;
1295 use crate::journal::{EventJournal, JournalConfig};
1296 use crate::row_scope::{RowScope, ScopeField};
1297 use crate::view::{Delivery, Filters};
1298 use serde_json::json;
1299
1300 #[tokio::test]
1301 async fn sparse_patches_keep_their_original_scope_on_tape_and_bus() {
1302 let mut index = ViewIndex::new();
1303 index.set_entity_scope("Row", ScopeField::new("meta.scope"));
1304 index.add_spec(ViewSpec {
1305 id: "Row/append".into(),
1306 export: "Row".into(),
1307 mode: Mode::Append,
1308 wire_format: Default::default(),
1309 projection: Projection::all(),
1310 filters: Filters::all(),
1311 delivery: Delivery::default(),
1312 pipeline: None,
1313 source_view: None,
1314 });
1315 let bus = BusManager::new();
1316 let mut receiver = bus.get_or_create_list_bus("Row/append").await;
1317 let cache = EntityCache::new();
1318 let (_, rx) = tokio::sync::mpsc::channel(1);
1319 let journal = Arc::new(EventJournal::new(JournalConfig {
1320 enabled: true,
1321 ..Default::default()
1322 }));
1323 let projector = Projector::new(
1324 Arc::new(index),
1325 bus,
1326 cache.clone(),
1327 rx,
1328 #[cfg(feature = "otel")]
1329 None,
1330 )
1331 .with_journal(journal.clone());
1332 for (i, patch) in [
1333 json!({"meta":{"scope":"B"}}),
1334 json!({"secret":"private-at-the-time"}),
1335 json!({"meta":{"scope":null}}),
1336 ]
1337 .into_iter()
1338 .enumerate()
1339 {
1340 projector
1341 .process_mutation(
1342 arete_interpreter::Mutation {
1343 export: "Row".into(),
1344 key: json!("row"),
1345 patch,
1346 append: vec![],
1347 },
1348 Some(SlotContext::new(i as u64 + 1, 0)),
1349 &HashSet::new(),
1350 &mut Vec::new(),
1351 )
1352 .await
1353 .unwrap();
1354 }
1355 assert!(cache
1356 .get_shared("Row/append", "row")
1357 .await
1358 .unwrap()
1359 .to_value()["meta"]["scope"]
1360 .is_null());
1361 let expected = [
1362 RowScope::Scoped("B".into()),
1363 RowScope::Scoped("B".into()),
1364 RowScope::Public,
1365 ];
1366 let records = journal.replay_after("Row/append", None).await.unwrap();
1367 assert_eq!(records.len(), 3);
1368 for (record, scope) in records.iter().zip(expected) {
1369 assert_eq!(record.row_scope.as_ref(), Some(&scope));
1370 assert_eq!(
1371 receiver.recv().await.unwrap().row_scope.as_ref(),
1372 Some(&scope)
1373 );
1374 }
1375 }
1376}