Skip to main content

arete_server/
projector.rs

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
23/// Whole-entity requests the projector keeps outstanding at once. One per
24/// refused key; asking again after one is forgotten is always safe. The same
25/// bound holds for the positions their resends keep.
26const 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
35/// `(export, key)` to the recency and account lifetime of its latest change.
36type LastChanges = LruCache<(String, String), RememberedChange>;
37
38/// The projector's way back to the VM its mutations come from.
39///
40/// The entity cache is bounded. A patch for a key it does not hold carries
41/// only the fields that changed and cannot rebuild the entity, unless it is
42/// the mutation that created the entity. A linked VM marks those
43/// ([`WholeEntity::Created`]), so the cache stores a creation and refuses any
44/// other patch for a key it lacks, whether it evicted the key or never held
45/// it. The projector then asks the VM, which holds the whole entity, to send
46/// all of it at the end of its next batch of mutations, whether or not the
47/// key changes in it (see [`WholeEntityRequests`]).
48///
49/// A resend is the entity again, not a change, so it must not take the
50/// position (`_seq`) of the batch it arrives in: in a view ordered by recency
51/// it would rank ahead of entities that really changed there. The projector
52/// remembers the position of each requested key's latest change — the refused
53/// patch, or any change it sees for the key after it — and stamps the resend
54/// with that, in the cache, in derived views and on the `upsert` it
55/// publishes. It learns a key's changes in stream order, which the VM cannot:
56/// positions are assigned per batch, after the VM has emitted it. A resend
57/// with no remembered position (one past the bound, or from a source without
58/// a VM, which sends whole entities as changes) takes its batch's.
59/// For an account-owned entity it also remembers the explicit account position
60/// at request time, so a whole entity produced for an old lifetime cannot land
61/// after deletion and recreation.
62///
63/// The generated runtime hands its VM over through
64/// [`crate::snapshot::register_runtime`], inside [`Self::scope`], before it
65/// emits anything. A mutation source that registers no VM marks nothing and
66/// cannot be asked: the cache takes a patch for a key it lacks for a new
67/// entity unless it remembers evicting the key, and a remembered key stays
68/// out until the source sends a mutation marked whole
69/// ([`arete_interpreter::Mutation::mark_whole_entity`]) or a source delete.
70#[derive(Clone)]
71pub struct EntityResync {
72    requests: WholeEntityRequests,
73    /// For each key with a resend on its way: the `_seq` of its latest change
74    /// and the account lifetime for which it was requested. Capped by hand at
75    /// [`WHOLE_ENTITY_REQUEST_CAPACITY`], least recently changed out first.
76    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    /// Run a mutation source (the generated parser) so that the VM it
102    /// registers answers this projector's requests.
103    pub async fn scope<F: Future>(&self, future: F) -> F::Output {
104        ACTIVE_ENTITY_RESYNC.scope(self.clone(), future).await
105    }
106
107    /// Answer requests from `vm`.
108    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    /// What an unmarked patch from the source is: with a VM linked, which
120    /// marks every creation, a change to an entity created earlier.
121    fn unmarked_origin(&self) -> PatchOrigin {
122        if self.is_linked() {
123            PatchOrigin::Change
124        } else {
125            PatchOrigin::Unknown
126        }
127    }
128
129    /// The request set, for inspection.
130    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    /// Note a change to `export` `key` at `seq`. A refused change is about to
141    /// be requested whole, so its position is remembered for the resend; any
142    /// other change moves a remembered position forward. Only a linked VM
143    /// resends.
144    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    /// The recency and account-lifetime position a resend of `export` `key`
181    /// keeps, if one is remembered. Forgets it.
182    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
210/// Link `vm` to the projector of the server whose parser is running, if any.
211/// Called by [`crate::snapshot::register_runtime`].
212pub(crate) fn link_active_resync(vm: &Arc<StdMutex<VmContext>>) {
213    let _ = ACTIVE_ENTITY_RESYNC.try_with(|resync| resync.link(vm));
214}
215
216/// The `_version` stamped into every frame's data: `{epoch}:{counter}`.
217///
218/// Clients order one key's frames by it. `_seq` cannot do that: every update
219/// decoded from one transaction shares it, and within a slot account updates
220/// and instructions number themselves differently. The counter follows the
221/// order this projector merges changes into the entity cache, which is the
222/// order that defines the cached entity. The epoch names this projector, so a
223/// client never compares counters from another one (a restart, or a stack
224/// loaded again).
225///
226/// An entity restored from a snapshot keeps the version it was saved with,
227/// from an earlier epoch. Clients take a version from another epoch as newer,
228/// and every change the restored entity takes is stamped in this one, so an
229/// old version never follows a new one for the same key.
230struct FrameVersions {
231    epoch: String,
232    last: AtomicU64,
233}
234
235impl FrameVersions {
236    fn new() -> Self {
237        // Eight hex digits: enough to tell one projector's lifetime from
238        // another, and every frame carries them.
239        let epoch = hex::encode(&uuid::Uuid::new_v4().as_bytes()[..4]);
240        Self {
241            epoch,
242            last: AtomicU64::new(0),
243        }
244    }
245
246    /// The next version, for a frame whose data is `data`. Mutations are
247    /// objects; anything else has nowhere to carry one and goes out
248    /// unversioned.
249    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
259/// One view's frame for a mutation, built before any view's cache write.
260struct PendingFrame<'a> {
261    spec: &'a ViewSpec,
262    /// Which of the mutation's distinct projections the view keeps.
263    projection: usize,
264    /// The `_version` stamped for this view.
265    version: Option<Value>,
266    output: FrameOutput,
267}
268
269enum FrameOutput {
270    /// A change's frame, serialized (and, for a replayable append view,
271    /// already on the journal).
272    Change(Arc<Bytes>),
273    /// A resent whole entity's data, published only if the cache stores it.
274    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    /// Associate projection progress with one server's snapshot lifecycle.
332    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    /// Retain published events for replayable append subscriptions.
341    pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
342        self.journal = Some(journal);
343        self
344    }
345
346    /// Ask the VM linked to `resync` for whole entities the cache refused.
347    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            // Keys this batch carries whole, resent or created: a refused
386            // patch ahead of one needs no request of its own.
387            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            // The batch is now applied to the caches: advance the snapshot
416            // resume watermark, then release the processing guard transferred
417            // by the VM producer. An exclusive snapshot cut cannot begin until
418            // every earlier guarded batch reaches this point.
419            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            // Flush markers remain useful to non-snapshot callers that need
427            // to observe a drained projector queue.
428            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                // Append tapes are historical events; deletion only clears
512                // their current entity cache, not their retained history.
513                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        // The position (`_seq`) recency order sorts by: the batch's for a
556        // change, the latest change's for a resend (see `EntityResync`).
557        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        // The views this mutation reaches. A whole entity is checked when it
605        // is stored; a change must first pass each view's lifetime ordering.
606        // Each view's ordering is its own, so checking every view before any
607        // of them is written decides exactly what checking each in turn would.
608        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        // Views that keep the same fields get the same projected patch, which
643        // the cache then stores (or merges) once for all of them. A `_version`
644        // the patch carries is dropped: each view's frame stamps its own.
645        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            // Stamped after projection, which a field list could otherwise
672            // strip it from, and before the cache write and the frame, so
673            // both carry it.
674            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            // Replayable append views carry the offset the record is about to
689            // take, so a live subscriber can checkpoint the same cursor a
690            // replay would hand it. The frame is built inside the journal's
691            // lock so the published offset is always the one the record gets.
692            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                // No tape, or a sealed one: the event still publishes, it just
728                // carries no position to resume from.
729                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            // Every view's cache write happens before any view publishes, so a
763            // subscriber reading the cache on a frame finds it written.
764            let ordering = LifetimeOrdering {
765                account_position,
766                source_seq: seq.as_deref(),
767                // Account mutations carry their authoritative position. Every
768                // other source keeps its own recency cursor; instruction and
769                // resolver offsets are never compared with one another.
770                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    /// Store a whole entity the VM resent for a key the cache had to refuse,
859    /// and tell list and state subscribers. `pending` carries each view's
860    /// frame data, and `resend` the position of the entity's latest change,
861    /// not the resend's batch.
862    ///
863    /// It is state, not an event: append views get only their cache entry, and
864    /// neither the journal nor their tape subscribers see it. List and state
865    /// views publish it as an `upsert`, which the WebSocket layer hands to a
866    /// client that holds the key and uses to (re)admit it to windows.
867    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        // A delete cancels its outstanding request, so with a linked VM a late
878        // whole result with no matching request belongs to an old lifetime
879        // and is not stored. Sources without a linked VM may still explicitly
880        // vouch that a mutation is whole; there is no request to match.
881        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        // The source view's own copy: derived views share its fields.
980        let Some(entity) = self
981            .entity_cache
982            .get_shared(source_view_id, entity_key)
983            .await
984        else {
985            return;
986        };
987
988        // Bound each derived sorted view by the source view's cache size,
989        // evicting from the bottom of the sort order (see `SortedViewCache`).
990        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        // A derived view holds only the entities its filter passes, so one
995        // that stops passing leaves it. Of the rest, only a view that would
996        // keep the entity holds on to it: once a view is full, most updates
997        // sort below its last entry.
998        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    /// Apply a patch the entity cache refused (its key was evicted there) to
1021    /// the derived views that still hold the whole entity.
1022    ///
1023    /// A derived view is bounded by sort position, not recency, so it can keep
1024    /// a top-ranked entity the entity cache has evicted; that copy is whole,
1025    /// so the patch merges into it exactly as it would have in the entity
1026    /// cache. A view that does not hold the key gets nothing: the patch alone
1027    /// is not an entity, and inserting it would rank a handful of changed
1028    /// fields as though they were the whole entity. Views that shared the
1029    /// entity's fields share the merged ones.
1030    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        // The entities merged so far: what each view held, and the result.
1045        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            // The same patch merged into the same entity gives the same
1054            // entity: reuse it rather than merge (and copy) again.
1055            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                // Replace rather than merge: `entity` is already the whole
1074                // merged value, and the cache's own merge knows nothing of
1075                // append paths.
1076                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    /// Each refusal moves the position forward, and so does any change seen
1119    /// before the resend; the resend takes the position once.
1120    #[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    /// A source without a VM gets no resends, so nothing is remembered: a
1198    /// whole entity it sends is a change and takes its batch's position.
1199    #[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}