Skip to main content

arete_server/
projector.rs

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
22/// Whole-entity requests the projector keeps outstanding at once. One per
23/// refused key; asking again after one is forgotten is always safe. The same
24/// bound holds for the positions their resends keep.
25const 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
34/// `(export, key)` to the recency and account lifetime of its latest change.
35type LastChanges = LruCache<(String, String), RememberedChange>;
36
37/// The projector's way back to the VM its mutations come from.
38///
39/// The entity cache is bounded. A patch for a key it does not hold carries
40/// only the fields that changed and cannot rebuild the entity, unless it is
41/// the mutation that created the entity. A linked VM marks those
42/// ([`WholeEntity::Created`]), so the cache stores a creation and refuses any
43/// other patch for a key it lacks, whether it evicted the key or never held
44/// it. The projector then asks the VM, which holds the whole entity, to send
45/// all of it at the end of its next batch of mutations, whether or not the
46/// key changes in it (see [`WholeEntityRequests`]).
47///
48/// A resend is the entity again, not a change, so it must not take the
49/// position (`_seq`) of the batch it arrives in: in a view ordered by recency
50/// it would rank ahead of entities that really changed there. The projector
51/// remembers the position of each requested key's latest change — the refused
52/// patch, or any change it sees for the key after it — and stamps the resend
53/// with that, in the cache, in derived views and on the `upsert` it
54/// publishes. It learns a key's changes in stream order, which the VM cannot:
55/// positions are assigned per batch, after the VM has emitted it. A resend
56/// with no remembered position (one past the bound, or from a source without
57/// a VM, which sends whole entities as changes) takes its batch's.
58/// For an account-owned entity it also remembers the explicit account position
59/// at request time, so a whole entity produced for an old lifetime cannot land
60/// after deletion and recreation.
61///
62/// The generated runtime hands its VM over through
63/// [`crate::snapshot::register_runtime`], inside [`Self::scope`], before it
64/// emits anything. A mutation source that registers no VM marks nothing and
65/// cannot be asked: the cache takes a patch for a key it lacks for a new
66/// entity unless it remembers evicting the key, and a remembered key stays
67/// out until the source sends a mutation marked whole
68/// ([`arete_interpreter::Mutation::mark_whole_entity`]) or a source delete.
69#[derive(Clone)]
70pub struct EntityResync {
71    requests: WholeEntityRequests,
72    /// For each key with a resend on its way: the `_seq` of its latest change
73    /// and the account lifetime for which it was requested. Capped by hand at
74    /// [`WHOLE_ENTITY_REQUEST_CAPACITY`], least recently changed out first.
75    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    /// Run a mutation source (the generated parser) so that the VM it
101    /// registers answers this projector's requests.
102    pub async fn scope<F: Future>(&self, future: F) -> F::Output {
103        ACTIVE_ENTITY_RESYNC.scope(self.clone(), future).await
104    }
105
106    /// Answer requests from `vm`.
107    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    /// What an unmarked patch from the source is: with a VM linked, which
119    /// marks every creation, a change to an entity created earlier.
120    fn unmarked_origin(&self) -> PatchOrigin {
121        if self.is_linked() {
122            PatchOrigin::Change
123        } else {
124            PatchOrigin::Unknown
125        }
126    }
127
128    /// The request set, for inspection.
129    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    /// Note a change to `export` `key` at `seq`. A refused change is about to
140    /// be requested whole, so its position is remembered for the resend; any
141    /// other change moves a remembered position forward. Only a linked VM
142    /// resends.
143    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    /// The recency and account-lifetime position a resend of `export` `key`
180    /// keeps, if one is remembered. Forgets it.
181    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
209/// Link `vm` to the projector of the server whose parser is running, if any.
210/// Called by [`crate::snapshot::register_runtime`].
211pub(crate) fn link_active_resync(vm: &Arc<StdMutex<VmContext>>) {
212    let _ = ACTIVE_ENTITY_RESYNC.try_with(|resync| resync.link(vm));
213}
214
215/// The `_version` stamped into every frame's data: `{epoch}:{counter}`.
216///
217/// Clients order one key's frames by it. `_seq` cannot do that: every update
218/// decoded from one transaction shares it, and within a slot account updates
219/// and instructions number themselves differently. The counter follows the
220/// order this projector merges changes into the entity cache, which is the
221/// order that defines the cached entity. The epoch names this projector, so a
222/// client never compares counters from another one (a restart, or a stack
223/// loaded again).
224///
225/// An entity restored from a snapshot keeps the version it was saved with,
226/// from an earlier epoch. Clients take a version from another epoch as newer,
227/// and every change the restored entity takes is stamped in this one, so an
228/// old version never follows a new one for the same key.
229struct FrameVersions {
230    epoch: String,
231    last: AtomicU64,
232}
233
234impl FrameVersions {
235    fn new() -> Self {
236        // Eight hex digits: enough to tell one projector's lifetime from
237        // another, and every frame carries them.
238        let epoch = hex::encode(&uuid::Uuid::new_v4().as_bytes()[..4]);
239        Self {
240            epoch,
241            last: AtomicU64::new(0),
242        }
243    }
244
245    /// Stamp the next version into `data`. Mutations are objects; anything
246    /// else has nowhere to carry one and goes out unversioned.
247    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    /// Associate projection progress with one server's snapshot lifecycle.
315    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    /// Retain published events for replayable append subscriptions.
324    pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
325        self.journal = Some(journal);
326        self
327    }
328
329    /// Ask the VM linked to `resync` for whole entities the cache refused.
330    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            // Keys this batch carries whole, resent or created: a refused
369            // patch ahead of one needs no request of its own.
370            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            // The batch is now applied to the caches: advance the snapshot
399            // resume watermark, then release the processing guard transferred
400            // by the VM producer. An exclusive snapshot cut cannot begin until
401            // every earlier guarded batch reaches this point.
402            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            // Flush markers remain useful to non-snapshot callers that need
410            // to observe a drained projector queue.
411            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                // Append tapes are historical events; deletion only clears
495                // their current entity cache, not their retained history.
496                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        // The position (`_seq`) recency order sorts by: the batch's for a
539        // change, the latest change's for a resend (see `EntityResync`).
540        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            // Stamped after projection, which a field list could otherwise
625            // strip it from, and before the cache merge and the frame, so
626            // both carry it.
627            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            // Replayable append views carry the offset the record is about to
656            // take, so a live subscriber can checkpoint the same cursor a
657            // replay would hand it. The frame is built inside the journal's
658            // lock so the published offset is always the one the record gets.
659            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                // No tape, or a sealed one: the event still publishes, it just
695                // carries no position to resume from.
696                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                        // Account mutations carry their authoritative
716                        // position. Every other source keeps its own recency
717                        // cursor; instruction and resolver offsets are never
718                        // compared with one another.
719                        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    /// Store a whole entity the VM resent for a key the cache had to refuse,
780    /// and tell list and state subscribers. `projected` and `seq` carry the
781    /// position of the entity's latest change, not the resend's batch.
782    ///
783    /// It is state, not an event: append views get only their cache entry, and
784    /// neither the journal nor their tape subscribers see it. List and state
785    /// views publish it as an `upsert`, which the WebSocket layer hands to a
786    /// client that holds the key and uses to (re)admit it to windows.
787    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            // A delete cancels its outstanding request. A late whole result
810            // with no matching request therefore belongs to an old lifetime.
811            false
812        } else {
813            // Sources without a linked VM may still explicitly vouch that a
814            // mutation is whole; there is no asynchronous request to match.
815            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        // Bound each derived sorted copy by the source view's cache size,
881        // evicting from the bottom of the sort order (see `SortedViewCache`).
882        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        // A derived view holds only the entities its filter passes, so one
887        // that stops passing leaves it. Of the rest, only a view that would
888        // keep the entity gets a copy of it: once a view is full, most updates
889        // sort below its last entry. The last one gets the entity itself.
890        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    /// Apply a patch the entity cache refused (its key was evicted there) to
930    /// the derived views that still hold the whole entity.
931    ///
932    /// A derived view is bounded by sort position, not recency, so it can keep
933    /// a top-ranked entity the entity cache has evicted; that copy is whole,
934    /// so the patch merges into it exactly as it would have in the entity
935    /// cache. A view that does not hold the key gets nothing: the patch alone
936    /// is not an entity, and inserting it would rank a handful of changed
937    /// fields as though they were the whole entity.
938    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                // Replace rather than merge: `entity` is already the whole
968                // merged value, and the cache's own merge knows nothing of
969                // append paths.
970                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    /// Each refusal moves the position forward, and so does any change seen
1013    /// before the resend; the resend takes the position once.
1014    #[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    /// A source without a VM gets no resends, so nothing is remembered: a
1092    /// whole entity it sends is a change and takes its batch's position.
1093    #[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}