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 output: FrameOutput,
267}
268
269enum FrameOutput {
270 Change(Arc<Bytes>),
273 Whole(Value),
275}
276
277pub struct Projector {
278 view_index: Arc<ViewIndex>,
279 bus_manager: BusManager,
280 entity_cache: EntityCache,
281 mutations_rx: mpsc::Receiver<MutationBatch>,
282 snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
283 journal: Option<Arc<crate::journal::EventJournal>>,
284 resync: EntityResync,
285 versions: FrameVersions,
286 #[cfg(feature = "otel")]
287 metrics: Option<Arc<Metrics>>,
288}
289
290impl Projector {
291 #[cfg(feature = "otel")]
292 pub fn new(
293 view_index: Arc<ViewIndex>,
294 bus_manager: BusManager,
295 entity_cache: EntityCache,
296 mutations_rx: mpsc::Receiver<MutationBatch>,
297 metrics: Option<Arc<Metrics>>,
298 ) -> Self {
299 Self {
300 view_index,
301 bus_manager,
302 entity_cache,
303 mutations_rx,
304 snapshot_runtime: None,
305 journal: None,
306 resync: EntityResync::new(),
307 versions: FrameVersions::new(),
308 metrics,
309 }
310 }
311
312 #[cfg(not(feature = "otel"))]
313 pub fn new(
314 view_index: Arc<ViewIndex>,
315 bus_manager: BusManager,
316 entity_cache: EntityCache,
317 mutations_rx: mpsc::Receiver<MutationBatch>,
318 ) -> Self {
319 Self {
320 view_index,
321 bus_manager,
322 entity_cache,
323 mutations_rx,
324 snapshot_runtime: None,
325 journal: None,
326 resync: EntityResync::new(),
327 versions: FrameVersions::new(),
328 }
329 }
330
331 pub fn with_snapshot_runtime(
333 mut self,
334 snapshot_runtime: crate::snapshot::SnapshotRuntime,
335 ) -> Self {
336 self.snapshot_runtime = Some(snapshot_runtime);
337 self
338 }
339
340 pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
342 self.journal = Some(journal);
343 self
344 }
345
346 pub fn with_entity_resync(mut self, resync: EntityResync) -> Self {
348 self.resync = resync;
349 self
350 }
351
352 pub async fn run(mut self) {
353 debug!("Projector started");
354
355 let mut json_buffer = Vec::with_capacity(4096);
356
357 while let Some(mut batch) = self.mutations_rx.recv().await {
358 let mut log = CanonicalLog::new();
359 log.set("phase", "projector");
360
361 let batch_size = batch.len();
362 let slot_context = batch.slot_context;
363 let batch_span = debug_span!(
364 parent: &batch.span,
365 "projector.batch",
366 batch.mutations = batch_size,
367 batch.position = tracing::field::Empty,
368 frames_published = tracing::field::Empty,
369 );
370 if let (Some(context), false) = (slot_context, batch_span.is_disabled()) {
371 batch_span.record("batch.position", context.to_seq_string());
372 }
373 let _span_guard = batch_span.enter();
374 let mut frames_published = 0u32;
375 let mut errors = 0u32;
376
377 if let Some(ctx) = batch.event_context.as_ref() {
378 log.set("program", &ctx.program)
379 .set("event_kind", &ctx.event_kind)
380 .set("event_type", &ctx.event_type)
381 .set("account", &ctx.account)
382 .set("accounts_count", ctx.accounts_count);
383 }
384
385 let arriving_whole: HashSet<(String, String)> = batch
388 .mutations
389 .iter()
390 .filter(|mutation| mutation.is_whole_entity())
391 .map(|mutation| (mutation.export.clone(), Self::extract_key(&mutation.key)))
392 .collect();
393
394 for mutation in std::mem::take(&mut batch.mutations).into_iter() {
395 #[cfg(feature = "otel")]
396 let export = mutation.export.clone();
397
398 match self
399 .process_mutation(mutation, slot_context, &arriving_whole, &mut json_buffer)
400 .await
401 {
402 Ok(count) => frames_published += count,
403 Err(e) => {
404 error!("Failed to process mutation: {}", e);
405 errors += 1;
406 }
407 }
408
409 #[cfg(feature = "otel")]
410 if let Some(ref metrics) = self.metrics {
411 metrics.record_mutation_processed(&export);
412 }
413 }
414
415 if batch_size > 0 {
420 if let Some(snapshot_runtime) = &self.snapshot_runtime {
421 snapshot_runtime.record_applied_batch(slot_context.map(|ctx| ctx.slot));
422 }
423 }
424 drop(batch.snapshot_guard.take());
425
426 if let Some(ack) = batch.flush_ack.take() {
429 let _ = ack.send(());
430 }
431
432 log.set("batch_size", batch_size)
433 .set("frames_published", frames_published)
434 .set("errors", errors);
435 batch_span.record("frames_published", frames_published);
436
437 #[cfg(feature = "otel")]
438 if let Some(ref metrics) = self.metrics {
439 metrics.record_projector_latency(log.duration_ms());
440 }
441
442 log.emit();
443 }
444
445 debug!("Projector stopped");
446 }
447
448 #[instrument(
449 name = "projector.mutation",
450 level = "debug",
451 skip(self, mutation, slot_context, arriving_whole, json_buffer),
452 fields(export = %mutation.export)
453 )]
454 async fn process_mutation(
455 &self,
456 mut mutation: arete_interpreter::Mutation,
457 slot_context: Option<SlotContext>,
458 arriving_whole: &HashSet<(String, String)>,
459 json_buffer: &mut Vec<u8>,
460 ) -> anyhow::Result<u32> {
461 let specs = self.view_index.by_export(&mutation.export);
462
463 if specs.is_empty() {
464 return Ok(0);
465 }
466
467 let account_position = mutation
468 .take_account_position()
469 .map_err(anyhow::Error::msg)?;
470
471 if mutation.is_delete() {
472 let key = Self::extract_key(&mutation.key);
473 let seq = slot_context.map(|ctx| ctx.to_seq_string());
474 let source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
475 self.resync.cancel(&mutation.export, &mutation.key, &key);
476 let mut published = 0;
477 for spec in specs.iter().filter(|spec| spec.filters.matches(&key)) {
478 let deleted = if account_position.is_none() && seq.is_none() {
479 self.entity_cache.delete_current(&spec.id, &key).await
480 } else {
481 match source_domain {
482 Some(source_domain) => {
483 self.entity_cache
484 .delete_ordered_in_domain(
485 &spec.id,
486 &key,
487 account_position,
488 seq.as_deref(),
489 source_domain,
490 )
491 .await
492 }
493 None => {
494 self.entity_cache
495 .delete_ordered(&spec.id, &key, account_position, seq.as_deref())
496 .await
497 }
498 }
499 };
500 if !deleted {
501 continue;
502 }
503 let sorted = self.view_index.sorted_caches();
504 let mut caches = sorted.write().await;
505 for derived in self.view_index.get_derived_views_for_source(&spec.id) {
506 if let Some(cache) = caches.get_mut(&derived.id) {
507 cache.remove_key(&key);
508 }
509 }
510 drop(caches);
511 if spec.mode == Mode::Append {
514 continue;
515 }
516 let frame = SourceFrame {
517 mode: spec.mode,
518 export: spec.id.clone(),
519 op: "delete",
520 key: key.clone(),
521 data: Value::Null,
522 append: Vec::new(),
523 seq: seq.clone(),
524 offset: None,
525 };
526 json_buffer.clear();
527 serde_json::to_writer(&mut *json_buffer, &frame)?;
528 self.publish_frame(
529 spec,
530 Arc::new(BusMessage {
531 key: key.clone(),
532 entity: spec.id.clone(),
533 payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
534 }),
535 )
536 .await;
537 published += 1;
538 }
539 return Ok(published);
540 }
541 let mark = mutation.take_whole_entity_mark();
542 let whole = mark == Some(WholeEntity::Resent);
543 let origin = match mark {
544 Some(WholeEntity::Created) => PatchOrigin::Creation,
545 _ => self.resync.unmarked_origin(),
546 };
547 let key = Self::extract_key(&mutation.key);
548 let arete_interpreter::Mutation {
549 export,
550 key: source_key,
551 mut patch,
552 append,
553 } = mutation;
554
555 let batch_seq = slot_context.map(|ctx| ctx.to_seq_string());
558 let batch_source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
559 let remembered = if whole {
560 self.resync.take_last_change(&export, &key)
561 } else {
562 None
563 };
564 let requested_resend = remembered.is_some();
565 let seq = if whole {
566 remembered
567 .as_ref()
568 .map(|change| change.seq.clone())
569 .unwrap_or(batch_seq)
570 } else {
571 batch_seq
572 };
573 let source_domain = if whole {
574 remembered
575 .as_ref()
576 .and_then(|change| change.source_domain)
577 .or(batch_source_domain)
578 } else {
579 batch_source_domain
580 };
581 let resend_account_position = remembered.and_then(|change| change.account_position);
582 if let (Some(seq), Value::Object(map)) = (&seq, &mut patch) {
583 map.insert("_seq".to_string(), Value::String(seq.clone()));
584 }
585
586 let matching_specs: SmallVec<[&ViewSpec; 4]> = specs
587 .iter()
588 .filter(|spec| spec.filters.matches(&key))
589 .collect();
590
591 let match_count = matching_specs.len();
592 if match_count == 0 {
593 return Ok(0);
594 }
595 let current_account_position = self
596 .entity_cache
597 .account_position(&matching_specs[0].id, &key)
598 .await;
599 let change_account_position = account_position.or(current_account_position);
600
601 let mut frames_published = 0u32;
602 let mut refused = false;
603
604 let mut accepted: SmallVec<[&ViewSpec; 4]> = SmallVec::new();
609 for spec in matching_specs {
610 if !whole {
611 let accepts = match source_domain {
612 Some(source_domain) => {
613 self.entity_cache
614 .accepts_ordered_lifetime_mutation_in_domain(
615 &spec.id,
616 &key,
617 origin == PatchOrigin::Creation,
618 account_position,
619 seq.as_deref(),
620 source_domain,
621 )
622 .await
623 }
624 None => {
625 self.entity_cache
626 .accepts_lifetime_mutation(
627 &spec.id,
628 &key,
629 origin == PatchOrigin::Creation,
630 account_position,
631 )
632 .await
633 }
634 };
635 if !accepts {
636 continue;
637 }
638 }
639 accepted.push(spec);
640 }
641
642 let mut projections: SmallVec<[(&Projection, Value); 1]> = SmallVec::new();
646 for spec in &accepted {
647 if !projections
648 .iter()
649 .any(|(projection, _)| projection.fields == spec.projection.fields)
650 {
651 projections.push((&spec.projection, Value::Null));
652 }
653 }
654 let distinct = projections.len();
655 for (index, (projection, projected)) in projections.iter_mut().enumerate() {
656 let source = if index + 1 == distinct {
657 std::mem::take(&mut patch)
658 } else {
659 patch.clone()
660 };
661 *projected = split_version(projection.apply(source)).0;
662 }
663
664 let mut pending: SmallVec<[PendingFrame<'_>; 4]> = SmallVec::new();
665 for spec in accepted {
666 let projection = projections
667 .iter()
668 .position(|(projection, _)| projection.fields == spec.projection.fields)
669 .unwrap_or_default();
670 let fields = &projections[projection].1;
671 let version = self.versions.next(fields);
675 let mut wire_data = with_version(fields.clone(), version.clone());
676 apply_wire_format(&mut wire_data, &spec.wire_format);
677
678 if whole {
679 pending.push(PendingFrame {
680 spec,
681 projection,
682 version,
683 output: FrameOutput::Whole(wire_data),
684 });
685 continue;
686 }
687
688 let journal = self
693 .journal
694 .as_ref()
695 .filter(|journal| journal.is_enabled() && spec.mode == Mode::Append);
696
697 let mut frame = SourceFrame {
698 mode: spec.mode,
699 export: spec.id.clone(),
700 op: if mark == Some(WholeEntity::Created) && spec.mode != Mode::Append {
701 "upsert"
702 } else {
703 "patch"
704 },
705 key: key.clone(),
706 data: wire_data,
707 append: append.clone(),
708 seq: seq.clone(),
709 offset: None,
710 };
711
712 let retained = match journal {
713 Some(journal) => {
714 journal
715 .append_with(&spec.id, &key, |offset| {
716 frame.offset = Some(offset);
717 json_buffer.clear();
718 serde_json::to_writer(&mut *json_buffer, &frame)?;
719 Ok::<_, anyhow::Error>(Arc::new(Bytes::copy_from_slice(json_buffer)))
720 })
721 .await?
722 }
723 None => None,
724 };
725 let payload = match retained {
726 Some((_offset, payload)) => payload,
727 None => {
730 frame.offset = None;
731 json_buffer.clear();
732 serde_json::to_writer(&mut *json_buffer, &frame)?;
733 Arc::new(Bytes::copy_from_slice(json_buffer))
734 }
735 };
736 pending.push(PendingFrame {
737 spec,
738 projection,
739 version,
740 output: FrameOutput::Change(payload),
741 });
742 }
743
744 if whole {
745 frames_published += self
746 .apply_whole_entity(
747 &key,
748 projections,
749 pending,
750 (
751 RememberedChange {
752 seq: seq.clone(),
753 source_domain,
754 account_position: resend_account_position,
755 },
756 requested_resend,
757 ),
758 json_buffer,
759 )
760 .await?;
761 } else {
762 let ordering = LifetimeOrdering {
765 account_position,
766 source_seq: seq.as_deref(),
767 source_domain: account_position
771 .is_none()
772 .then_some(source_domain)
773 .flatten(),
774 };
775 let mut writes: SmallVec<[Option<CacheWrite>; 4]> =
776 pending.iter().map(|_| None).collect();
777 for (projection, (_, fields)) in projections.into_iter().enumerate() {
778 let members: SmallVec<[usize; 4]> = pending
779 .iter()
780 .enumerate()
781 .filter(|(_, frame)| frame.projection == projection)
782 .map(|(index, _)| index)
783 .collect();
784 let views: SmallVec<[ViewVersion<'_>; 4]> = members
785 .iter()
786 .map(|&index| ViewVersion {
787 view_id: &pending[index].spec.id,
788 version: pending[index].version.clone(),
789 })
790 .collect();
791 let results = self
792 .entity_cache
793 .upsert_views(&key, fields, &views, &append, origin, ordering)
794 .await;
795 for (index, write) in members.into_iter().zip(results) {
796 writes[index] = Some(write);
797 }
798 }
799
800 for (frame, write) in pending.into_iter().zip(writes) {
801 let (Some(write), FrameOutput::Change(payload)) = (write, frame.output) else {
802 continue;
803 };
804 let spec = frame.spec;
805 match write {
806 CacheWrite::Refused { patch } => {
807 refused = true;
808 if spec.mode == Mode::List {
809 self.merge_into_held_derived_entities(&spec.id, &key, patch, &append)
810 .await;
811 }
812 }
813 CacheWrite::Merged | CacheWrite::Created => {
814 if spec.mode == Mode::List {
815 self.update_derived_view_caches(&spec.id, &key).await;
816 }
817 }
818 }
819
820 let message = Arc::new(BusMessage {
821 key: key.clone(),
822 entity: spec.id.clone(),
823 payload,
824 });
825
826 self.publish_frame(spec, message).await;
827 frames_published += 1;
828
829 #[cfg(feature = "otel")]
830 if let Some(ref metrics) = self.metrics {
831 let mode_str = match spec.mode {
832 Mode::List => "list",
833 Mode::State => "state",
834 Mode::Append => "append",
835 };
836 metrics.record_frame_published(mode_str, &spec.export);
837 }
838 }
839 }
840
841 if !whole {
842 self.resync.note_change(
843 &export,
844 &key,
845 &seq,
846 source_domain,
847 change_account_position,
848 refused,
849 );
850 }
851 if refused && !arriving_whole.contains(&(export.clone(), key.clone())) {
852 self.resync.request(&export, &source_key);
853 }
854
855 Ok(frames_published)
856 }
857
858 async fn apply_whole_entity(
868 &self,
869 key: &str,
870 projections: SmallVec<[(&Projection, Value); 1]>,
871 pending: SmallVec<[PendingFrame<'_>; 4]>,
872 resend: (RememberedChange, bool),
873 json_buffer: &mut Vec<u8>,
874 ) -> anyhow::Result<u32> {
875 let (position, requested_resend) = resend;
876 let mut stored: SmallVec<[bool; 4]> = pending.iter().map(|_| false).collect();
877 if requested_resend || !self.resync.is_linked() {
882 let requested_source = requested_resend.then(|| {
883 (
884 position.seq.as_deref(),
885 position.source_domain.unwrap_or(SlotIndexDomain::Legacy),
886 )
887 });
888 for (projection, (_, fields)) in projections.into_iter().enumerate() {
889 let members: SmallVec<[usize; 4]> = pending
890 .iter()
891 .enumerate()
892 .filter(|(_, frame)| frame.projection == projection)
893 .map(|(index, _)| index)
894 .collect();
895 let views: SmallVec<[ViewVersion<'_>; 4]> = members
896 .iter()
897 .map(|&index| ViewVersion {
898 view_id: &pending[index].spec.id,
899 version: pending[index].version.clone(),
900 })
901 .collect();
902 let results = self
903 .entity_cache
904 .store_whole_views(
905 key,
906 fields,
907 &views,
908 position.account_position,
909 requested_source,
910 )
911 .await;
912 for (index, result) in members.into_iter().zip(results) {
913 stored[index] = result;
914 }
915 }
916 }
917
918 let mut published = 0;
919 for (frame, stored) in pending.into_iter().zip(stored) {
920 let (true, FrameOutput::Whole(wire_data)) = (stored, frame.output) else {
921 continue;
922 };
923 let spec = frame.spec;
924 match spec.mode {
925 Mode::Append => continue,
926 Mode::List => self.update_derived_view_caches(&spec.id, key).await,
927 Mode::State => {}
928 }
929 let frame = SourceFrame {
930 mode: spec.mode,
931 export: spec.id.clone(),
932 op: "upsert",
933 key: key.to_string(),
934 data: wire_data,
935 append: Vec::new(),
936 seq: position.seq.clone(),
937 offset: None,
938 };
939 json_buffer.clear();
940 serde_json::to_writer(&mut *json_buffer, &frame)?;
941 let message = Arc::new(BusMessage {
942 key: key.to_string(),
943 entity: spec.id.clone(),
944 payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
945 });
946 self.publish_frame(spec, message).await;
947 published += 1;
948 }
949 Ok(published)
950 }
951
952 pub(crate) fn extract_key(key: &serde_json::Value) -> String {
953 key.as_str()
954 .map(|s| s.to_string())
955 .or_else(|| key.as_u64().map(|n| n.to_string()))
956 .or_else(|| key.as_i64().map(|n| n.to_string()))
957 .or_else(|| {
958 key.as_array().and_then(|arr| {
959 let bytes: Vec<u8> = arr
960 .iter()
961 .filter_map(|v| v.as_u64().map(|n| n as u8))
962 .collect();
963 if bytes.len() == arr.len() {
964 Some(hex::encode(&bytes))
965 } else {
966 None
967 }
968 })
969 })
970 .unwrap_or_else(|| key.to_string())
971 }
972
973 async fn update_derived_view_caches(&self, source_view_id: &str, entity_key: &str) {
974 let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
975 if derived_views.is_empty() {
976 return;
977 }
978
979 let Some(entity) = self
981 .entity_cache
982 .get_shared(source_view_id, entity_key)
983 .await
984 else {
985 return;
986 };
987
988 let max_entries = self.entity_cache.max_entities_per_view();
991 let sorted_caches = self.view_index.sorted_caches();
992 let mut caches = sorted_caches.write().await;
993
994 for spec in &derived_views {
999 let Some(cache) = caches.get_mut(&spec.id) else {
1000 continue;
1001 };
1002 let passes = spec
1003 .pipeline
1004 .as_ref()
1005 .and_then(|pipeline| pipeline.filter.as_ref())
1006 .is_none_or(|filter| filter.matches(&entity));
1007 if !passes {
1008 cache.remove_key(entity_key);
1009 continue;
1010 }
1011 if cache.upsert_if_kept(entity_key, &entity, max_entries) {
1012 debug!(
1013 "Updated sorted cache for derived view {} with key {}",
1014 spec.id, entity_key
1015 );
1016 }
1017 }
1018 }
1019
1020 async fn merge_into_held_derived_entities(
1031 &self,
1032 source_view_id: &str,
1033 entity_key: &str,
1034 patch: Value,
1035 append_paths: &[String],
1036 ) {
1037 let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
1038 if derived_views.is_empty() {
1039 return;
1040 }
1041 let max_entries = self.entity_cache.max_entities_per_view();
1042 let sorted_caches = self.view_index.sorted_caches();
1043 let mut caches = sorted_caches.write().await;
1044 let mut merged: SmallVec<[(SharedEntity, SharedEntity); 2]> = SmallVec::new();
1046 for spec in &derived_views {
1047 let Some(cache) = caches.get_mut(&spec.id) else {
1048 continue;
1049 };
1050 let Some(held) = cache.get(entity_key).cloned() else {
1051 continue;
1052 };
1053 let entity = match merged.iter().find(|(before, _)| {
1056 before.shares_fields_with(&held) && before.version() == held.version()
1057 }) {
1058 Some((_, after)) => after.clone(),
1059 None => {
1060 let mut entity = held.clone();
1061 self.entity_cache
1062 .merge_patch_shared(&mut entity, patch.clone(), append_paths);
1063 merged.push((held, entity.clone()));
1064 entity
1065 }
1066 };
1067 let passes = spec
1068 .pipeline
1069 .as_ref()
1070 .and_then(|pipeline| pipeline.filter.as_ref())
1071 .is_none_or(|filter| filter.matches(&entity));
1072 if passes {
1073 cache.remove_key(entity_key);
1077 cache.upsert_if_kept(entity_key, &entity, max_entries);
1078 } else {
1079 cache.remove_key(entity_key);
1080 }
1081 }
1082 }
1083
1084 #[instrument(
1085 name = "projector.publish",
1086 level = "debug",
1087 skip(self, spec, message),
1088 fields(view_id = %spec.id, mode = ?spec.mode)
1089 )]
1090 async fn publish_frame(&self, spec: &ViewSpec, message: Arc<BusMessage>) {
1091 match spec.mode {
1092 Mode::State => {
1093 self.bus_manager
1094 .publish_state(&spec.id, &message.key, message.payload.clone())
1095 .await;
1096 }
1097 Mode::List | Mode::Append => {
1098 self.bus_manager.publish_list(&spec.id, message).await;
1099 }
1100 }
1101 }
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::*;
1107
1108 fn linked() -> EntityResync {
1109 let resync = EntityResync::new();
1110 resync.link(&Arc::new(StdMutex::new(VmContext::new())));
1111 resync
1112 }
1113
1114 fn seq(slot: u64) -> Option<String> {
1115 Some(SlotContext::new(slot, 0).to_seq_string())
1116 }
1117
1118 #[test]
1121 fn a_resend_keeps_the_latest_change_seen_before_it() {
1122 let resync = linked();
1123 let lifetime = Some(AccountPosition::new(4, 3));
1124 resync.note_change(
1125 "Round",
1126 "1",
1127 &seq(5),
1128 Some(SlotIndexDomain::Legacy),
1129 lifetime,
1130 true,
1131 );
1132 resync.note_change(
1133 "Round",
1134 "1",
1135 &seq(6),
1136 Some(SlotIndexDomain::Legacy),
1137 lifetime,
1138 true,
1139 );
1140 resync.note_change(
1141 "Round",
1142 "2",
1143 &seq(7),
1144 Some(SlotIndexDomain::Legacy),
1145 None,
1146 false,
1147 );
1148 assert_eq!(resync.take_last_change("Round", "2"), None, "never refused");
1149 resync.note_change(
1150 "Round",
1151 "1",
1152 &seq(8),
1153 Some(SlotIndexDomain::Legacy),
1154 None,
1155 false,
1156 );
1157 assert_eq!(
1158 resync.take_last_change("Round", "1"),
1159 Some(RememberedChange {
1160 seq: seq(8),
1161 source_domain: Some(SlotIndexDomain::Legacy),
1162 account_position: lifetime,
1163 })
1164 );
1165 assert_eq!(resync.take_last_change("Round", "1"), None);
1166 }
1167
1168 #[test]
1169 fn remembered_positions_are_bounded_like_requests() {
1170 let resync = linked();
1171 for key in 0..=WHOLE_ENTITY_REQUEST_CAPACITY {
1172 resync.note_change(
1173 "Round",
1174 &key.to_string(),
1175 &seq(key as u64),
1176 Some(SlotIndexDomain::Legacy),
1177 None,
1178 true,
1179 );
1180 }
1181 assert_eq!(
1182 resync.take_last_change("Round", "0"),
1183 None,
1184 "the oldest goes"
1185 );
1186 let newest = WHOLE_ENTITY_REQUEST_CAPACITY;
1187 assert_eq!(
1188 resync.take_last_change("Round", &newest.to_string()),
1189 Some(RememberedChange {
1190 seq: seq(newest as u64),
1191 source_domain: Some(SlotIndexDomain::Legacy),
1192 account_position: None,
1193 })
1194 );
1195 }
1196
1197 #[test]
1200 fn nothing_is_remembered_without_a_vm() {
1201 let resync = EntityResync::new();
1202 resync.note_change(
1203 "Round",
1204 "1",
1205 &seq(5),
1206 Some(SlotIndexDomain::Legacy),
1207 None,
1208 true,
1209 );
1210 assert_eq!(resync.take_last_change("Round", "1"), None);
1211 }
1212}