1use crate::bus::{BusManager, BusMessage};
2use crate::cache::{CacheWrite, EntityCache, LifetimeOrdering, PatchOrigin};
3use crate::mutation_batch::{MutationBatch, SlotContext, SlotIndexDomain};
4use crate::view::{ViewIndex, ViewSpec};
5use crate::websocket::frame::{apply_wire_format, Mode, SourceFrame};
6use arete_interpreter::vm::{VmContext, WholeEntityRequests};
7use arete_interpreter::{AccountPosition, CanonicalLog, WholeEntity};
8use bytes::Bytes;
9use lru::LruCache;
10use serde_json::Value;
11use smallvec::SmallVec;
12use std::collections::HashSet;
13use std::future::Future;
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::{Arc, Mutex as StdMutex};
16use tokio::sync::mpsc;
17use tracing::{debug, debug_span, error, instrument, warn};
18
19#[cfg(feature = "otel")]
20use crate::metrics::Metrics;
21
22const WHOLE_ENTITY_REQUEST_CAPACITY: usize = 4_096;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28struct RememberedChange {
29 seq: Option<String>,
30 source_domain: Option<SlotIndexDomain>,
31 account_position: Option<AccountPosition>,
32}
33
34type LastChanges = LruCache<(String, String), RememberedChange>;
36
37#[derive(Clone)]
70pub struct EntityResync {
71 requests: WholeEntityRequests,
72 last_changes: Arc<StdMutex<LastChanges>>,
76 linked: Arc<AtomicBool>,
77 warned_unlinked: Arc<AtomicBool>,
78}
79
80tokio::task_local! {
81 static ACTIVE_ENTITY_RESYNC: EntityResync;
82}
83
84impl Default for EntityResync {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl EntityResync {
91 pub fn new() -> Self {
92 Self {
93 requests: WholeEntityRequests::new(WHOLE_ENTITY_REQUEST_CAPACITY),
94 last_changes: Arc::new(StdMutex::new(LruCache::unbounded())),
95 linked: Arc::new(AtomicBool::new(false)),
96 warned_unlinked: Arc::new(AtomicBool::new(false)),
97 }
98 }
99
100 pub async fn scope<F: Future>(&self, future: F) -> F::Output {
103 ACTIVE_ENTITY_RESYNC.scope(self.clone(), future).await
104 }
105
106 pub fn link(&self, vm: &Arc<StdMutex<VmContext>>) {
108 vm.lock()
109 .unwrap_or_else(|poisoned| poisoned.into_inner())
110 .set_whole_entity_requests(self.requests.clone());
111 self.linked.store(true, Ordering::Release);
112 }
113
114 pub fn is_linked(&self) -> bool {
115 self.linked.load(Ordering::Acquire)
116 }
117
118 fn unmarked_origin(&self) -> PatchOrigin {
121 if self.is_linked() {
122 PatchOrigin::Change
123 } else {
124 PatchOrigin::Unknown
125 }
126 }
127
128 pub fn requests(&self) -> &WholeEntityRequests {
130 &self.requests
131 }
132
133 fn last_changes(&self) -> std::sync::MutexGuard<'_, LastChanges> {
134 self.last_changes
135 .lock()
136 .unwrap_or_else(|poisoned| poisoned.into_inner())
137 }
138
139 fn note_change(
144 &self,
145 export: &str,
146 key: &str,
147 seq: &Option<String>,
148 source_domain: Option<SlotIndexDomain>,
149 account_position: Option<AccountPosition>,
150 refused: bool,
151 ) {
152 if !self.is_linked() {
153 return;
154 }
155 let mut last_changes = self.last_changes();
156 if refused {
157 last_changes.put(
158 (export.to_string(), key.to_string()),
159 RememberedChange {
160 seq: seq.clone(),
161 source_domain,
162 account_position,
163 },
164 );
165 while last_changes.len() > WHOLE_ENTITY_REQUEST_CAPACITY {
166 last_changes.pop_lru();
167 }
168 } else if !last_changes.is_empty() {
169 if let Some(last) = last_changes.get_mut(&(export.to_string(), key.to_string())) {
170 last.seq = seq.clone();
171 last.source_domain = source_domain;
172 if account_position.is_some() {
173 last.account_position = account_position;
174 }
175 }
176 }
177 }
178
179 fn take_last_change(&self, export: &str, key: &str) -> Option<RememberedChange> {
182 let mut last_changes = self.last_changes();
183 if last_changes.is_empty() {
184 return None;
185 }
186 last_changes.pop(&(export.to_string(), key.to_string()))
187 }
188
189 fn request(&self, export: &str, key: &Value) {
190 if self.is_linked() {
191 self.requests.request(export, key);
192 } else if !self.warned_unlinked.swap(true, Ordering::Relaxed) {
193 warn!(
194 export,
195 "Entity cache refused a patch for an evicted key and no VM is registered to \
196 send the whole entity; the key stays out of snapshots until its source sends \
197 a mutation marked whole or deletes it. Raise max_entities_per_view if the \
198 working set exceeds it."
199 );
200 }
201 }
202
203 fn cancel(&self, export: &str, key: &Value, cache_key: &str) {
204 self.requests.cancel(export, key);
205 self.take_last_change(export, cache_key);
206 }
207}
208
209pub(crate) fn link_active_resync(vm: &Arc<StdMutex<VmContext>>) {
212 let _ = ACTIVE_ENTITY_RESYNC.try_with(|resync| resync.link(vm));
213}
214
215struct FrameVersions {
230 epoch: String,
231 last: AtomicU64,
232}
233
234impl FrameVersions {
235 fn new() -> Self {
236 let epoch = hex::encode(&uuid::Uuid::new_v4().as_bytes()[..4]);
239 Self {
240 epoch,
241 last: AtomicU64::new(0),
242 }
243 }
244
245 fn stamp(&self, data: &mut Value) {
248 let Value::Object(map) = data else {
249 debug!("mutation is not an object; publishing it without a version");
250 return;
251 };
252 let counter = self.last.fetch_add(1, Ordering::Relaxed) + 1;
253 map.insert(
254 "_version".to_string(),
255 Value::String(format!("{}:{counter}", self.epoch)),
256 );
257 }
258}
259
260pub struct Projector {
261 view_index: Arc<ViewIndex>,
262 bus_manager: BusManager,
263 entity_cache: EntityCache,
264 mutations_rx: mpsc::Receiver<MutationBatch>,
265 snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
266 journal: Option<Arc<crate::journal::EventJournal>>,
267 resync: EntityResync,
268 versions: FrameVersions,
269 #[cfg(feature = "otel")]
270 metrics: Option<Arc<Metrics>>,
271}
272
273impl Projector {
274 #[cfg(feature = "otel")]
275 pub fn new(
276 view_index: Arc<ViewIndex>,
277 bus_manager: BusManager,
278 entity_cache: EntityCache,
279 mutations_rx: mpsc::Receiver<MutationBatch>,
280 metrics: Option<Arc<Metrics>>,
281 ) -> Self {
282 Self {
283 view_index,
284 bus_manager,
285 entity_cache,
286 mutations_rx,
287 snapshot_runtime: None,
288 journal: None,
289 resync: EntityResync::new(),
290 versions: FrameVersions::new(),
291 metrics,
292 }
293 }
294
295 #[cfg(not(feature = "otel"))]
296 pub fn new(
297 view_index: Arc<ViewIndex>,
298 bus_manager: BusManager,
299 entity_cache: EntityCache,
300 mutations_rx: mpsc::Receiver<MutationBatch>,
301 ) -> Self {
302 Self {
303 view_index,
304 bus_manager,
305 entity_cache,
306 mutations_rx,
307 snapshot_runtime: None,
308 journal: None,
309 resync: EntityResync::new(),
310 versions: FrameVersions::new(),
311 }
312 }
313
314 pub fn with_snapshot_runtime(
316 mut self,
317 snapshot_runtime: crate::snapshot::SnapshotRuntime,
318 ) -> Self {
319 self.snapshot_runtime = Some(snapshot_runtime);
320 self
321 }
322
323 pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
325 self.journal = Some(journal);
326 self
327 }
328
329 pub fn with_entity_resync(mut self, resync: EntityResync) -> Self {
331 self.resync = resync;
332 self
333 }
334
335 pub async fn run(mut self) {
336 debug!("Projector started");
337
338 let mut json_buffer = Vec::with_capacity(4096);
339
340 while let Some(mut batch) = self.mutations_rx.recv().await {
341 let mut log = CanonicalLog::new();
342 log.set("phase", "projector");
343
344 let batch_size = batch.len();
345 let slot_context = batch.slot_context;
346 let batch_span = debug_span!(
347 parent: &batch.span,
348 "projector.batch",
349 batch.mutations = batch_size,
350 batch.position = tracing::field::Empty,
351 frames_published = tracing::field::Empty,
352 );
353 if let (Some(context), false) = (slot_context, batch_span.is_disabled()) {
354 batch_span.record("batch.position", context.to_seq_string());
355 }
356 let _span_guard = batch_span.enter();
357 let mut frames_published = 0u32;
358 let mut errors = 0u32;
359
360 if let Some(ctx) = batch.event_context.as_ref() {
361 log.set("program", &ctx.program)
362 .set("event_kind", &ctx.event_kind)
363 .set("event_type", &ctx.event_type)
364 .set("account", &ctx.account)
365 .set("accounts_count", ctx.accounts_count);
366 }
367
368 let arriving_whole: HashSet<(String, String)> = batch
371 .mutations
372 .iter()
373 .filter(|mutation| mutation.is_whole_entity())
374 .map(|mutation| (mutation.export.clone(), Self::extract_key(&mutation.key)))
375 .collect();
376
377 for mutation in std::mem::take(&mut batch.mutations).into_iter() {
378 #[cfg(feature = "otel")]
379 let export = mutation.export.clone();
380
381 match self
382 .process_mutation(mutation, slot_context, &arriving_whole, &mut json_buffer)
383 .await
384 {
385 Ok(count) => frames_published += count,
386 Err(e) => {
387 error!("Failed to process mutation: {}", e);
388 errors += 1;
389 }
390 }
391
392 #[cfg(feature = "otel")]
393 if let Some(ref metrics) = self.metrics {
394 metrics.record_mutation_processed(&export);
395 }
396 }
397
398 if batch_size > 0 {
403 if let Some(snapshot_runtime) = &self.snapshot_runtime {
404 snapshot_runtime.record_applied_batch(slot_context.map(|ctx| ctx.slot));
405 }
406 }
407 drop(batch.snapshot_guard.take());
408
409 if let Some(ack) = batch.flush_ack.take() {
412 let _ = ack.send(());
413 }
414
415 log.set("batch_size", batch_size)
416 .set("frames_published", frames_published)
417 .set("errors", errors);
418 batch_span.record("frames_published", frames_published);
419
420 #[cfg(feature = "otel")]
421 if let Some(ref metrics) = self.metrics {
422 metrics.record_projector_latency(log.duration_ms());
423 }
424
425 log.emit();
426 }
427
428 debug!("Projector stopped");
429 }
430
431 #[instrument(
432 name = "projector.mutation",
433 level = "debug",
434 skip(self, mutation, slot_context, arriving_whole, json_buffer),
435 fields(export = %mutation.export)
436 )]
437 async fn process_mutation(
438 &self,
439 mut mutation: arete_interpreter::Mutation,
440 slot_context: Option<SlotContext>,
441 arriving_whole: &HashSet<(String, String)>,
442 json_buffer: &mut Vec<u8>,
443 ) -> anyhow::Result<u32> {
444 let specs = self.view_index.by_export(&mutation.export);
445
446 if specs.is_empty() {
447 return Ok(0);
448 }
449
450 let account_position = mutation
451 .take_account_position()
452 .map_err(anyhow::Error::msg)?;
453
454 if mutation.is_delete() {
455 let key = Self::extract_key(&mutation.key);
456 let seq = slot_context.map(|ctx| ctx.to_seq_string());
457 let source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
458 self.resync.cancel(&mutation.export, &mutation.key, &key);
459 let mut published = 0;
460 for spec in specs.iter().filter(|spec| spec.filters.matches(&key)) {
461 let deleted = if account_position.is_none() && seq.is_none() {
462 self.entity_cache.delete_current(&spec.id, &key).await
463 } else {
464 match source_domain {
465 Some(source_domain) => {
466 self.entity_cache
467 .delete_ordered_in_domain(
468 &spec.id,
469 &key,
470 account_position,
471 seq.as_deref(),
472 source_domain,
473 )
474 .await
475 }
476 None => {
477 self.entity_cache
478 .delete_ordered(&spec.id, &key, account_position, seq.as_deref())
479 .await
480 }
481 }
482 };
483 if !deleted {
484 continue;
485 }
486 let sorted = self.view_index.sorted_caches();
487 let mut caches = sorted.write().await;
488 for derived in self.view_index.get_derived_views_for_source(&spec.id) {
489 if let Some(cache) = caches.get_mut(&derived.id) {
490 cache.remove(&key);
491 }
492 }
493 drop(caches);
494 if spec.mode == Mode::Append {
497 continue;
498 }
499 let frame = SourceFrame {
500 mode: spec.mode,
501 export: spec.id.clone(),
502 op: "delete",
503 key: key.clone(),
504 data: Value::Null,
505 append: Vec::new(),
506 seq: seq.clone(),
507 offset: None,
508 };
509 json_buffer.clear();
510 serde_json::to_writer(&mut *json_buffer, &frame)?;
511 self.publish_frame(
512 spec,
513 Arc::new(BusMessage {
514 key: key.clone(),
515 entity: spec.id.clone(),
516 payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
517 }),
518 )
519 .await;
520 published += 1;
521 }
522 return Ok(published);
523 }
524 let mark = mutation.take_whole_entity_mark();
525 let whole = mark == Some(WholeEntity::Resent);
526 let origin = match mark {
527 Some(WholeEntity::Created) => PatchOrigin::Creation,
528 _ => self.resync.unmarked_origin(),
529 };
530 let key = Self::extract_key(&mutation.key);
531 let arete_interpreter::Mutation {
532 export,
533 key: source_key,
534 mut patch,
535 append,
536 } = mutation;
537
538 let batch_seq = slot_context.map(|ctx| ctx.to_seq_string());
541 let batch_source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
542 let remembered = if whole {
543 self.resync.take_last_change(&export, &key)
544 } else {
545 None
546 };
547 let requested_resend = remembered.is_some();
548 let seq = if whole {
549 remembered
550 .as_ref()
551 .map(|change| change.seq.clone())
552 .unwrap_or(batch_seq)
553 } else {
554 batch_seq
555 };
556 let source_domain = if whole {
557 remembered
558 .as_ref()
559 .and_then(|change| change.source_domain)
560 .or(batch_source_domain)
561 } else {
562 batch_source_domain
563 };
564 let resend_account_position = remembered.and_then(|change| change.account_position);
565 if let (Some(seq), Value::Object(map)) = (&seq, &mut patch) {
566 map.insert("_seq".to_string(), Value::String(seq.clone()));
567 }
568
569 let matching_specs: SmallVec<[&ViewSpec; 4]> = specs
570 .iter()
571 .filter(|spec| spec.filters.matches(&key))
572 .collect();
573
574 let match_count = matching_specs.len();
575 if match_count == 0 {
576 return Ok(0);
577 }
578 let current_account_position = self
579 .entity_cache
580 .account_position(&matching_specs[0].id, &key)
581 .await;
582 let change_account_position = account_position.or(current_account_position);
583
584 let mut frames_published = 0u32;
585 let mut refused = false;
586
587 for (i, spec) in matching_specs.into_iter().enumerate() {
588 if !whole {
589 let accepted = match source_domain {
590 Some(source_domain) => {
591 self.entity_cache
592 .accepts_ordered_lifetime_mutation_in_domain(
593 &spec.id,
594 &key,
595 origin == PatchOrigin::Creation,
596 account_position,
597 seq.as_deref(),
598 source_domain,
599 )
600 .await
601 }
602 None => {
603 self.entity_cache
604 .accepts_lifetime_mutation(
605 &spec.id,
606 &key,
607 origin == PatchOrigin::Creation,
608 account_position,
609 )
610 .await
611 }
612 };
613 if !accepted {
614 continue;
615 }
616 }
617 let is_last = i == match_count - 1;
618 let patch_data = if is_last {
619 std::mem::take(&mut patch)
620 } else {
621 patch.clone()
622 };
623
624 let mut projected = spec.projection.apply(patch_data);
628 self.versions.stamp(&mut projected);
629 let mut wire_data = projected.clone();
630 apply_wire_format(&mut wire_data, &spec.wire_format);
631
632 let seq = seq.clone();
633
634 if whole {
635 frames_published += self
636 .apply_whole_entity(
637 spec,
638 &key,
639 projected,
640 wire_data,
641 (
642 RememberedChange {
643 seq,
644 source_domain,
645 account_position: resend_account_position,
646 },
647 requested_resend,
648 ),
649 json_buffer,
650 )
651 .await?;
652 continue;
653 }
654
655 let journal = self
660 .journal
661 .as_ref()
662 .filter(|journal| journal.is_enabled() && spec.mode == Mode::Append);
663
664 let mut frame = SourceFrame {
665 mode: spec.mode,
666 export: spec.id.clone(),
667 op: if mark == Some(WholeEntity::Created) && spec.mode != Mode::Append {
668 "upsert"
669 } else {
670 "patch"
671 },
672 key: key.clone(),
673 data: wire_data,
674 append: append.clone(),
675 seq,
676 offset: None,
677 };
678
679 let retained = match journal {
680 Some(journal) => {
681 journal
682 .append_with(&spec.id, &key, |offset| {
683 frame.offset = Some(offset);
684 json_buffer.clear();
685 serde_json::to_writer(&mut *json_buffer, &frame)?;
686 Ok::<_, anyhow::Error>(Arc::new(Bytes::copy_from_slice(json_buffer)))
687 })
688 .await?
689 }
690 None => None,
691 };
692 let payload = match retained {
693 Some((_offset, payload)) => payload,
694 None => {
697 frame.offset = None;
698 json_buffer.clear();
699 serde_json::to_writer(&mut *json_buffer, &frame)?;
700 Arc::new(Bytes::copy_from_slice(json_buffer))
701 }
702 };
703
704 let write = self
705 .entity_cache
706 .upsert_with_ordering(
707 &spec.id,
708 &key,
709 projected,
710 &frame.append,
711 origin,
712 LifetimeOrdering {
713 account_position,
714 source_seq: frame.seq.as_deref(),
715 source_domain: account_position
720 .is_none()
721 .then_some(source_domain)
722 .flatten(),
723 },
724 )
725 .await;
726
727 match write {
728 CacheWrite::Refused { patch } => {
729 refused = true;
730 if spec.mode == Mode::List {
731 self.merge_into_held_derived_entities(&spec.id, &key, patch, &frame.append)
732 .await;
733 }
734 }
735 CacheWrite::Merged | CacheWrite::Created => {
736 if spec.mode == Mode::List {
737 self.update_derived_view_caches(&spec.id, &key).await;
738 }
739 }
740 }
741
742 let message = Arc::new(BusMessage {
743 key: key.clone(),
744 entity: spec.id.clone(),
745 payload,
746 });
747
748 self.publish_frame(spec, message).await;
749 frames_published += 1;
750
751 #[cfg(feature = "otel")]
752 if let Some(ref metrics) = self.metrics {
753 let mode_str = match spec.mode {
754 Mode::List => "list",
755 Mode::State => "state",
756 Mode::Append => "append",
757 };
758 metrics.record_frame_published(mode_str, &spec.export);
759 }
760 }
761
762 if !whole {
763 self.resync.note_change(
764 &export,
765 &key,
766 &seq,
767 source_domain,
768 change_account_position,
769 refused,
770 );
771 }
772 if refused && !arriving_whole.contains(&(export.clone(), key.clone())) {
773 self.resync.request(&export, &source_key);
774 }
775
776 Ok(frames_published)
777 }
778
779 async fn apply_whole_entity(
788 &self,
789 spec: &ViewSpec,
790 key: &str,
791 projected: Value,
792 wire_data: Value,
793 resend: (RememberedChange, bool),
794 json_buffer: &mut Vec<u8>,
795 ) -> anyhow::Result<u32> {
796 let (position, requested_resend) = resend;
797 let stored = if requested_resend {
798 self.entity_cache
799 .store_whole_for_ordering_in_domain(
800 &spec.id,
801 key,
802 projected,
803 position.account_position,
804 position.seq.as_deref(),
805 position.source_domain.unwrap_or(SlotIndexDomain::Legacy),
806 )
807 .await
808 } else if self.resync.is_linked() {
809 false
812 } else {
813 self.entity_cache
816 .store_whole_for_lifetime(&spec.id, key, projected, position.account_position)
817 .await
818 };
819 if !stored {
820 return Ok(0);
821 }
822 match spec.mode {
823 Mode::Append => return Ok(0),
824 Mode::List => self.update_derived_view_caches(&spec.id, key).await,
825 Mode::State => {}
826 }
827 let frame = SourceFrame {
828 mode: spec.mode,
829 export: spec.id.clone(),
830 op: "upsert",
831 key: key.to_string(),
832 data: wire_data,
833 append: Vec::new(),
834 seq: position.seq,
835 offset: None,
836 };
837 json_buffer.clear();
838 serde_json::to_writer(&mut *json_buffer, &frame)?;
839 let message = Arc::new(BusMessage {
840 key: key.to_string(),
841 entity: spec.id.clone(),
842 payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
843 });
844 self.publish_frame(spec, message).await;
845 Ok(1)
846 }
847
848 pub(crate) fn extract_key(key: &serde_json::Value) -> String {
849 key.as_str()
850 .map(|s| s.to_string())
851 .or_else(|| key.as_u64().map(|n| n.to_string()))
852 .or_else(|| key.as_i64().map(|n| n.to_string()))
853 .or_else(|| {
854 key.as_array().and_then(|arr| {
855 let bytes: Vec<u8> = arr
856 .iter()
857 .filter_map(|v| v.as_u64().map(|n| n as u8))
858 .collect();
859 if bytes.len() == arr.len() {
860 Some(hex::encode(&bytes))
861 } else {
862 None
863 }
864 })
865 })
866 .unwrap_or_else(|| key.to_string())
867 }
868
869 async fn update_derived_view_caches(&self, source_view_id: &str, entity_key: &str) {
870 let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
871 if derived_views.is_empty() {
872 return;
873 }
874
875 let entity_data = match self.entity_cache.get(source_view_id, entity_key).await {
876 Some(data) => data,
877 None => return,
878 };
879
880 let max_entries = self.entity_cache.max_entities_per_view();
883 let sorted_caches = self.view_index.sorted_caches();
884 let mut caches = sorted_caches.write().await;
885
886 let mut keeping: SmallVec<[&str; 4]> = SmallVec::new();
891 for spec in &derived_views {
892 let Some(cache) = caches.get_mut(&spec.id) else {
893 continue;
894 };
895 let passes = spec
896 .pipeline
897 .as_ref()
898 .and_then(|pipeline| pipeline.filter.as_ref())
899 .is_none_or(|filter| filter.matches(&entity_data));
900 if !passes {
901 cache.remove(entity_key);
902 continue;
903 }
904 if cache.would_keep(entity_key, &entity_data, max_entries) {
905 keeping.push(spec.id.as_str());
906 }
907 }
908 let mut entity_data = Some(entity_data);
909 for (index, view_id) in keeping.iter().enumerate() {
910 let Some(cache) = caches.get_mut(*view_id) else {
911 continue;
912 };
913 let entity = if index + 1 == keeping.len() {
914 entity_data.take()
915 } else {
916 entity_data.clone()
917 };
918 let Some(entity) = entity else {
919 continue;
920 };
921 cache.upsert_bounded(entity_key.to_string(), entity, max_entries);
922 debug!(
923 "Updated sorted cache for derived view {} with key {}",
924 view_id, entity_key
925 );
926 }
927 }
928
929 async fn merge_into_held_derived_entities(
939 &self,
940 source_view_id: &str,
941 entity_key: &str,
942 patch: Value,
943 append_paths: &[String],
944 ) {
945 let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
946 if derived_views.is_empty() {
947 return;
948 }
949 let max_entries = self.entity_cache.max_entities_per_view();
950 let sorted_caches = self.view_index.sorted_caches();
951 let mut caches = sorted_caches.write().await;
952 for spec in &derived_views {
953 let Some(cache) = caches.get_mut(&spec.id) else {
954 continue;
955 };
956 let Some(mut entity) = cache.get(entity_key).cloned() else {
957 continue;
958 };
959 self.entity_cache
960 .merge_patch(&mut entity, patch.clone(), append_paths);
961 let passes = spec
962 .pipeline
963 .as_ref()
964 .and_then(|pipeline| pipeline.filter.as_ref())
965 .is_none_or(|filter| filter.matches(&entity));
966 if passes {
967 cache.remove(entity_key);
971 cache.upsert_bounded(entity_key.to_string(), entity, max_entries);
972 } else {
973 cache.remove(entity_key);
974 }
975 }
976 }
977
978 #[instrument(
979 name = "projector.publish",
980 level = "debug",
981 skip(self, spec, message),
982 fields(view_id = %spec.id, mode = ?spec.mode)
983 )]
984 async fn publish_frame(&self, spec: &ViewSpec, message: Arc<BusMessage>) {
985 match spec.mode {
986 Mode::State => {
987 self.bus_manager
988 .publish_state(&spec.id, &message.key, message.payload.clone())
989 .await;
990 }
991 Mode::List | Mode::Append => {
992 self.bus_manager.publish_list(&spec.id, message).await;
993 }
994 }
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn linked() -> EntityResync {
1003 let resync = EntityResync::new();
1004 resync.link(&Arc::new(StdMutex::new(VmContext::new())));
1005 resync
1006 }
1007
1008 fn seq(slot: u64) -> Option<String> {
1009 Some(SlotContext::new(slot, 0).to_seq_string())
1010 }
1011
1012 #[test]
1015 fn a_resend_keeps_the_latest_change_seen_before_it() {
1016 let resync = linked();
1017 let lifetime = Some(AccountPosition::new(4, 3));
1018 resync.note_change(
1019 "Round",
1020 "1",
1021 &seq(5),
1022 Some(SlotIndexDomain::Legacy),
1023 lifetime,
1024 true,
1025 );
1026 resync.note_change(
1027 "Round",
1028 "1",
1029 &seq(6),
1030 Some(SlotIndexDomain::Legacy),
1031 lifetime,
1032 true,
1033 );
1034 resync.note_change(
1035 "Round",
1036 "2",
1037 &seq(7),
1038 Some(SlotIndexDomain::Legacy),
1039 None,
1040 false,
1041 );
1042 assert_eq!(resync.take_last_change("Round", "2"), None, "never refused");
1043 resync.note_change(
1044 "Round",
1045 "1",
1046 &seq(8),
1047 Some(SlotIndexDomain::Legacy),
1048 None,
1049 false,
1050 );
1051 assert_eq!(
1052 resync.take_last_change("Round", "1"),
1053 Some(RememberedChange {
1054 seq: seq(8),
1055 source_domain: Some(SlotIndexDomain::Legacy),
1056 account_position: lifetime,
1057 })
1058 );
1059 assert_eq!(resync.take_last_change("Round", "1"), None);
1060 }
1061
1062 #[test]
1063 fn remembered_positions_are_bounded_like_requests() {
1064 let resync = linked();
1065 for key in 0..=WHOLE_ENTITY_REQUEST_CAPACITY {
1066 resync.note_change(
1067 "Round",
1068 &key.to_string(),
1069 &seq(key as u64),
1070 Some(SlotIndexDomain::Legacy),
1071 None,
1072 true,
1073 );
1074 }
1075 assert_eq!(
1076 resync.take_last_change("Round", "0"),
1077 None,
1078 "the oldest goes"
1079 );
1080 let newest = WHOLE_ENTITY_REQUEST_CAPACITY;
1081 assert_eq!(
1082 resync.take_last_change("Round", &newest.to_string()),
1083 Some(RememberedChange {
1084 seq: seq(newest as u64),
1085 source_domain: Some(SlotIndexDomain::Legacy),
1086 account_position: None,
1087 })
1088 );
1089 }
1090
1091 #[test]
1094 fn nothing_is_remembered_without_a_vm() {
1095 let resync = EntityResync::new();
1096 resync.note_change(
1097 "Round",
1098 "1",
1099 &seq(5),
1100 Some(SlotIndexDomain::Legacy),
1101 None,
1102 true,
1103 );
1104 assert_eq!(resync.take_last_change("Round", "1"), None);
1105 }
1106}