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    /// Membership established before a cache write can evict or refuse a row.
267    row_scope: Option<crate::row_scope::RowScope>,
268    output: FrameOutput,
269}
270
271enum FrameOutput {
272    /// A change's frame, serialized (and, for a replayable append view,
273    /// already on the journal).
274    Change(Arc<Bytes>),
275    /// A resent whole entity's data, published only if the cache stores it.
276    Whole(Value),
277}
278
279pub struct Projector {
280    view_index: Arc<ViewIndex>,
281    bus_manager: BusManager,
282    entity_cache: EntityCache,
283    mutations_rx: mpsc::Receiver<MutationBatch>,
284    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
285    journal: Option<Arc<crate::journal::EventJournal>>,
286    resync: EntityResync,
287    versions: FrameVersions,
288    #[cfg(feature = "otel")]
289    metrics: Option<Arc<Metrics>>,
290}
291
292impl Projector {
293    #[cfg(feature = "otel")]
294    pub fn new(
295        view_index: Arc<ViewIndex>,
296        bus_manager: BusManager,
297        entity_cache: EntityCache,
298        mutations_rx: mpsc::Receiver<MutationBatch>,
299        metrics: Option<Arc<Metrics>>,
300    ) -> Self {
301        Self {
302            view_index,
303            bus_manager,
304            entity_cache,
305            mutations_rx,
306            snapshot_runtime: None,
307            journal: None,
308            resync: EntityResync::new(),
309            versions: FrameVersions::new(),
310            metrics,
311        }
312    }
313
314    #[cfg(not(feature = "otel"))]
315    pub fn new(
316        view_index: Arc<ViewIndex>,
317        bus_manager: BusManager,
318        entity_cache: EntityCache,
319        mutations_rx: mpsc::Receiver<MutationBatch>,
320    ) -> Self {
321        Self {
322            view_index,
323            bus_manager,
324            entity_cache,
325            mutations_rx,
326            snapshot_runtime: None,
327            journal: None,
328            resync: EntityResync::new(),
329            versions: FrameVersions::new(),
330        }
331    }
332
333    /// Associate projection progress with one server's snapshot lifecycle.
334    pub fn with_snapshot_runtime(
335        mut self,
336        snapshot_runtime: crate::snapshot::SnapshotRuntime,
337    ) -> Self {
338        self.snapshot_runtime = Some(snapshot_runtime);
339        self
340    }
341
342    /// Retain published events for replayable append subscriptions.
343    pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
344        self.journal = Some(journal);
345        self
346    }
347
348    /// Ask the VM linked to `resync` for whole entities the cache refused.
349    pub fn with_entity_resync(mut self, resync: EntityResync) -> Self {
350        self.resync = resync;
351        self
352    }
353
354    pub async fn run(mut self) {
355        debug!("Projector started");
356
357        let mut json_buffer = Vec::with_capacity(4096);
358
359        while let Some(mut batch) = self.mutations_rx.recv().await {
360            let mut log = CanonicalLog::new();
361            log.set("phase", "projector");
362
363            let batch_size = batch.len();
364            let slot_context = batch.slot_context;
365            let batch_span = debug_span!(
366                parent: &batch.span,
367                "projector.batch",
368                batch.mutations = batch_size,
369                batch.position = tracing::field::Empty,
370                frames_published = tracing::field::Empty,
371            );
372            if let (Some(context), false) = (slot_context, batch_span.is_disabled()) {
373                batch_span.record("batch.position", context.to_seq_string());
374            }
375            let _span_guard = batch_span.enter();
376            let mut frames_published = 0u32;
377            let mut errors = 0u32;
378
379            if let Some(ctx) = batch.event_context.as_ref() {
380                log.set("program", &ctx.program)
381                    .set("event_kind", &ctx.event_kind)
382                    .set("event_type", &ctx.event_type)
383                    .set("account", &ctx.account)
384                    .set("accounts_count", ctx.accounts_count);
385            }
386
387            // Keys this batch carries whole, resent or created: a refused
388            // patch ahead of one needs no request of its own.
389            let arriving_whole: HashSet<(String, String)> = batch
390                .mutations
391                .iter()
392                .filter(|mutation| mutation.is_whole_entity())
393                .map(|mutation| (mutation.export.clone(), Self::extract_key(&mutation.key)))
394                .collect();
395
396            for mutation in std::mem::take(&mut batch.mutations).into_iter() {
397                #[cfg(feature = "otel")]
398                let export = mutation.export.clone();
399
400                match self
401                    .process_mutation(mutation, slot_context, &arriving_whole, &mut json_buffer)
402                    .await
403                {
404                    Ok(count) => frames_published += count,
405                    Err(e) => {
406                        error!("Failed to process mutation: {}", e);
407                        errors += 1;
408                    }
409                }
410
411                #[cfg(feature = "otel")]
412                if let Some(ref metrics) = self.metrics {
413                    metrics.record_mutation_processed(&export);
414                }
415            }
416
417            // The batch is now applied to the caches: advance the snapshot
418            // resume watermark, then release the processing guard transferred
419            // by the VM producer. An exclusive snapshot cut cannot begin until
420            // every earlier guarded batch reaches this point.
421            if batch_size > 0 {
422                if let Some(snapshot_runtime) = &self.snapshot_runtime {
423                    snapshot_runtime.record_applied_batch(slot_context.map(|ctx| ctx.slot));
424                }
425            }
426            drop(batch.snapshot_guard.take());
427
428            // Flush markers remain useful to non-snapshot callers that need
429            // to observe a drained projector queue.
430            if let Some(ack) = batch.flush_ack.take() {
431                let _ = ack.send(());
432            }
433
434            log.set("batch_size", batch_size)
435                .set("frames_published", frames_published)
436                .set("errors", errors);
437            batch_span.record("frames_published", frames_published);
438
439            #[cfg(feature = "otel")]
440            if let Some(ref metrics) = self.metrics {
441                metrics.record_projector_latency(log.duration_ms());
442            }
443
444            log.emit();
445        }
446
447        debug!("Projector stopped");
448    }
449
450    #[instrument(
451        name = "projector.mutation",
452        level = "debug",
453        skip(self, mutation, slot_context, arriving_whole, json_buffer),
454        fields(export = %mutation.export)
455    )]
456    async fn process_mutation(
457        &self,
458        mut mutation: arete_interpreter::Mutation,
459        slot_context: Option<SlotContext>,
460        arriving_whole: &HashSet<(String, String)>,
461        json_buffer: &mut Vec<u8>,
462    ) -> anyhow::Result<u32> {
463        let specs = self.view_index.by_export(&mutation.export);
464        for spec in specs {
465            self.view_index.validate_scope_projection(spec)?;
466        }
467
468        if specs.is_empty() {
469            return Ok(0);
470        }
471
472        let account_position = mutation
473            .take_account_position()
474            .map_err(anyhow::Error::msg)?;
475
476        if mutation.is_delete() {
477            let key = Self::extract_key(&mutation.key);
478            let seq = slot_context.map(|ctx| ctx.to_seq_string());
479            let source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
480            self.resync.cancel(&mutation.export, &mutation.key, &key);
481            let mut published = 0;
482            for spec in specs.iter().filter(|spec| spec.filters.matches(&key)) {
483                let deleted = if account_position.is_none() && seq.is_none() {
484                    self.entity_cache.delete_current(&spec.id, &key).await
485                } else {
486                    match source_domain {
487                        Some(source_domain) => {
488                            self.entity_cache
489                                .delete_ordered_in_domain(
490                                    &spec.id,
491                                    &key,
492                                    account_position,
493                                    seq.as_deref(),
494                                    source_domain,
495                                )
496                                .await
497                        }
498                        None => {
499                            self.entity_cache
500                                .delete_ordered(&spec.id, &key, account_position, seq.as_deref())
501                                .await
502                        }
503                    }
504                };
505                if !deleted {
506                    continue;
507                }
508                let sorted = self.view_index.sorted_caches();
509                let mut caches = sorted.write().await;
510                for derived in self.view_index.get_derived_views_for_source(&spec.id) {
511                    if let Some(cache) = caches.get_mut(&derived.id) {
512                        cache.remove_key(&key);
513                    }
514                }
515                drop(caches);
516                // Append tapes are historical events; deletion only clears
517                // their current entity cache, not their retained history.
518                if spec.mode == Mode::Append {
519                    continue;
520                }
521                let frame = SourceFrame {
522                    mode: spec.mode,
523                    export: spec.id.clone(),
524                    op: "delete",
525                    key: key.clone(),
526                    data: Value::Null,
527                    append: Vec::new(),
528                    seq: seq.clone(),
529                    offset: None,
530                };
531                json_buffer.clear();
532                serde_json::to_writer(&mut *json_buffer, &frame)?;
533                self.publish_frame(
534                    spec,
535                    Arc::new(BusMessage {
536                        row_scope: None,
537                        key: key.clone(),
538                        entity: spec.id.clone(),
539                        payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
540                    }),
541                )
542                .await;
543                published += 1;
544            }
545            return Ok(published);
546        }
547        let mark = mutation.take_whole_entity_mark();
548        let whole = mark == Some(WholeEntity::Resent);
549        let origin = match mark {
550            Some(WholeEntity::Created) => PatchOrigin::Creation,
551            _ => self.resync.unmarked_origin(),
552        };
553        let key = Self::extract_key(&mutation.key);
554        let arete_interpreter::Mutation {
555            export,
556            key: source_key,
557            mut patch,
558            append,
559        } = mutation;
560
561        // The position (`_seq`) recency order sorts by: the batch's for a
562        // change, the latest change's for a resend (see `EntityResync`).
563        let batch_seq = slot_context.map(|ctx| ctx.to_seq_string());
564        let batch_source_domain = slot_context.map(|ctx| ctx.slot_index_domain);
565        let remembered = if whole {
566            self.resync.take_last_change(&export, &key)
567        } else {
568            None
569        };
570        let requested_resend = remembered.is_some();
571        let seq = if whole {
572            remembered
573                .as_ref()
574                .map(|change| change.seq.clone())
575                .unwrap_or(batch_seq)
576        } else {
577            batch_seq
578        };
579        let source_domain = if whole {
580            remembered
581                .as_ref()
582                .and_then(|change| change.source_domain)
583                .or(batch_source_domain)
584        } else {
585            batch_source_domain
586        };
587        let resend_account_position = remembered.and_then(|change| change.account_position);
588        if let (Some(seq), Value::Object(map)) = (&seq, &mut patch) {
589            map.insert("_seq".to_string(), Value::String(seq.clone()));
590        }
591
592        let matching_specs: SmallVec<[&ViewSpec; 4]> = specs
593            .iter()
594            .filter(|spec| spec.filters.matches(&key))
595            .collect();
596
597        let match_count = matching_specs.len();
598        if match_count == 0 {
599            return Ok(0);
600        }
601        let current_account_position = self
602            .entity_cache
603            .account_position(&matching_specs[0].id, &key)
604            .await;
605        let change_account_position = account_position.or(current_account_position);
606
607        let mut frames_published = 0u32;
608        let mut refused = false;
609
610        // The views this mutation reaches. A whole entity is checked when it
611        // is stored; a change must first pass each view's lifetime ordering.
612        // Each view's ordering is its own, so checking every view before any
613        // of them is written decides exactly what checking each in turn would.
614        let mut accepted: SmallVec<[&ViewSpec; 4]> = SmallVec::new();
615        for spec in matching_specs {
616            if !whole {
617                let accepts = match source_domain {
618                    Some(source_domain) => {
619                        self.entity_cache
620                            .accepts_ordered_lifetime_mutation_in_domain(
621                                &spec.id,
622                                &key,
623                                origin == PatchOrigin::Creation,
624                                account_position,
625                                seq.as_deref(),
626                                source_domain,
627                            )
628                            .await
629                    }
630                    None => {
631                        self.entity_cache
632                            .accepts_lifetime_mutation(
633                                &spec.id,
634                                &key,
635                                origin == PatchOrigin::Creation,
636                                account_position,
637                            )
638                            .await
639                    }
640                };
641                if !accepts {
642                    continue;
643                }
644            }
645            accepted.push(spec);
646        }
647
648        // Views that keep the same fields get the same projected patch, which
649        // the cache then stores (or merges) once for all of them. A `_version`
650        // the patch carries is dropped: each view's frame stamps its own.
651        let mut projections: SmallVec<[(&Projection, Value); 1]> = SmallVec::new();
652        for spec in &accepted {
653            if !projections
654                .iter()
655                .any(|(projection, _)| projection.fields == spec.projection.fields)
656            {
657                projections.push((&spec.projection, Value::Null));
658            }
659        }
660        let distinct = projections.len();
661        for (index, (projection, projected)) in projections.iter_mut().enumerate() {
662            let source = if index + 1 == distinct {
663                std::mem::take(&mut patch)
664            } else {
665                patch.clone()
666            };
667            *projected = split_version(projection.apply(source)).0;
668        }
669
670        let mut pending: SmallVec<[PendingFrame<'_>; 4]> = SmallVec::new();
671        for spec in accepted {
672            let projection = projections
673                .iter()
674                .position(|(projection, _)| projection.fields == spec.projection.fields)
675                .unwrap_or_default();
676            let fields = &projections[projection].1;
677            let row_scope = self
678                .scope_at_publication(
679                    &spec.id,
680                    &key,
681                    fields,
682                    whole || origin == PatchOrigin::Creation,
683                )
684                .await;
685            // Stamped after projection, which a field list could otherwise
686            // strip it from, and before the cache write and the frame, so
687            // both carry it.
688            let version = self.versions.next(fields);
689            let mut wire_data = with_version(fields.clone(), version.clone());
690            apply_wire_format(&mut wire_data, &spec.wire_format);
691
692            if whole {
693                pending.push(PendingFrame {
694                    spec,
695                    projection,
696                    version,
697                    row_scope,
698                    output: FrameOutput::Whole(wire_data),
699                });
700                continue;
701            }
702
703            // Replayable append views carry the offset the record is about to
704            // take, so a live subscriber can checkpoint the same cursor a
705            // replay would hand it. The frame is built inside the journal's
706            // lock so the published offset is always the one the record gets.
707            let journal = self
708                .journal
709                .as_ref()
710                .filter(|journal| journal.is_enabled() && spec.mode == Mode::Append);
711
712            let mut frame = SourceFrame {
713                mode: spec.mode,
714                export: spec.id.clone(),
715                op: if mark == Some(WholeEntity::Created) && spec.mode != Mode::Append {
716                    "upsert"
717                } else {
718                    "patch"
719                },
720                key: key.clone(),
721                data: wire_data,
722                append: append.clone(),
723                seq: seq.clone(),
724                offset: None,
725            };
726
727            let retained = match journal {
728                Some(journal) => {
729                    journal
730                        .append_scoped_with(&spec.id, &key, row_scope.clone(), |offset| {
731                            frame.offset = Some(offset);
732                            json_buffer.clear();
733                            serde_json::to_writer(&mut *json_buffer, &frame)?;
734                            Ok::<_, anyhow::Error>(Arc::new(Bytes::copy_from_slice(json_buffer)))
735                        })
736                        .await?
737                }
738                None => None,
739            };
740            let payload = match retained {
741                Some((_offset, payload)) => payload,
742                // No tape, or a sealed one: the event still publishes, it just
743                // carries no position to resume from.
744                None => {
745                    frame.offset = None;
746                    json_buffer.clear();
747                    serde_json::to_writer(&mut *json_buffer, &frame)?;
748                    Arc::new(Bytes::copy_from_slice(json_buffer))
749                }
750            };
751            pending.push(PendingFrame {
752                spec,
753                projection,
754                version,
755                row_scope,
756                output: FrameOutput::Change(payload),
757            });
758        }
759
760        if whole {
761            frames_published += self
762                .apply_whole_entity(
763                    &key,
764                    projections,
765                    pending,
766                    (
767                        RememberedChange {
768                            seq: seq.clone(),
769                            source_domain,
770                            account_position: resend_account_position,
771                        },
772                        requested_resend,
773                    ),
774                    json_buffer,
775                )
776                .await?;
777        } else {
778            // Every view's cache write happens before any view publishes, so a
779            // subscriber reading the cache on a frame finds it written.
780            let ordering = LifetimeOrdering {
781                account_position,
782                source_seq: seq.as_deref(),
783                // Account mutations carry their authoritative position. Every
784                // other source keeps its own recency cursor; instruction and
785                // resolver offsets are never compared with one another.
786                source_domain: account_position
787                    .is_none()
788                    .then_some(source_domain)
789                    .flatten(),
790            };
791            let mut writes: SmallVec<[Option<CacheWrite>; 4]> =
792                pending.iter().map(|_| None).collect();
793            for (projection, (_, fields)) in projections.into_iter().enumerate() {
794                let members: SmallVec<[usize; 4]> = pending
795                    .iter()
796                    .enumerate()
797                    .filter(|(_, frame)| frame.projection == projection)
798                    .map(|(index, _)| index)
799                    .collect();
800                let views: SmallVec<[ViewVersion<'_>; 4]> = members
801                    .iter()
802                    .map(|&index| ViewVersion {
803                        view_id: &pending[index].spec.id,
804                        version: pending[index].version.clone(),
805                    })
806                    .collect();
807                let results = self
808                    .entity_cache
809                    .upsert_views(&key, fields, &views, &append, origin, ordering)
810                    .await;
811                for (index, write) in members.into_iter().zip(results) {
812                    writes[index] = Some(write);
813                }
814            }
815
816            for (frame, write) in pending.into_iter().zip(writes) {
817                let (Some(write), FrameOutput::Change(payload)) = (write, frame.output) else {
818                    continue;
819                };
820                let spec = frame.spec;
821                match write {
822                    CacheWrite::Refused { patch } => {
823                        refused = true;
824                        if spec.mode == Mode::List {
825                            self.merge_into_held_derived_entities(&spec.id, &key, patch, &append)
826                                .await;
827                        }
828                    }
829                    CacheWrite::Merged | CacheWrite::Created => {
830                        if spec.mode == Mode::List {
831                            self.update_derived_view_caches(&spec.id, &key).await;
832                        }
833                    }
834                }
835
836                let message = Arc::new(BusMessage {
837                    row_scope: frame.row_scope,
838                    key: key.clone(),
839                    entity: spec.id.clone(),
840                    payload,
841                });
842
843                self.publish_frame(spec, message).await;
844                frames_published += 1;
845
846                #[cfg(feature = "otel")]
847                if let Some(ref metrics) = self.metrics {
848                    let mode_str = match spec.mode {
849                        Mode::List => "list",
850                        Mode::State => "state",
851                        Mode::Append => "append",
852                    };
853                    metrics.record_frame_published(mode_str, &spec.export);
854                }
855            }
856        }
857
858        if !whole {
859            self.resync.note_change(
860                &export,
861                &key,
862                &seq,
863                source_domain,
864                change_account_position,
865                refused,
866            );
867        }
868        if refused && !arriving_whole.contains(&(export.clone(), key.clone())) {
869            self.resync.request(&export, &source_key);
870        }
871
872        Ok(frames_published)
873    }
874
875    /// Store a whole entity the VM resent for a key the cache had to refuse,
876    /// and tell list and state subscribers. `pending` carries each view's
877    /// frame data, and `resend` the position of the entity's latest change,
878    /// not the resend's batch.
879    ///
880    /// It is state, not an event: append views get only their cache entry, and
881    /// neither the journal nor their tape subscribers see it. List and state
882    /// views publish it as an `upsert`, which the WebSocket layer hands to a
883    /// client that holds the key and uses to (re)admit it to windows.
884    async fn apply_whole_entity(
885        &self,
886        key: &str,
887        projections: SmallVec<[(&Projection, Value); 1]>,
888        pending: SmallVec<[PendingFrame<'_>; 4]>,
889        resend: (RememberedChange, bool),
890        json_buffer: &mut Vec<u8>,
891    ) -> anyhow::Result<u32> {
892        let (position, requested_resend) = resend;
893        let mut stored: SmallVec<[bool; 4]> = pending.iter().map(|_| false).collect();
894        // A delete cancels its outstanding request, so with a linked VM a late
895        // whole result with no matching request belongs to an old lifetime
896        // and is not stored. Sources without a linked VM may still explicitly
897        // vouch that a mutation is whole; there is no request to match.
898        if requested_resend || !self.resync.is_linked() {
899            let requested_source = requested_resend.then(|| {
900                (
901                    position.seq.as_deref(),
902                    position.source_domain.unwrap_or(SlotIndexDomain::Legacy),
903                )
904            });
905            for (projection, (_, fields)) in projections.into_iter().enumerate() {
906                let members: SmallVec<[usize; 4]> = pending
907                    .iter()
908                    .enumerate()
909                    .filter(|(_, frame)| frame.projection == projection)
910                    .map(|(index, _)| index)
911                    .collect();
912                let views: SmallVec<[ViewVersion<'_>; 4]> = members
913                    .iter()
914                    .map(|&index| ViewVersion {
915                        view_id: &pending[index].spec.id,
916                        version: pending[index].version.clone(),
917                    })
918                    .collect();
919                let results = self
920                    .entity_cache
921                    .store_whole_views(
922                        key,
923                        fields,
924                        &views,
925                        position.account_position,
926                        requested_source,
927                    )
928                    .await;
929                for (index, result) in members.into_iter().zip(results) {
930                    stored[index] = result;
931                }
932            }
933        }
934
935        let mut published = 0;
936        for (frame, stored) in pending.into_iter().zip(stored) {
937            let (true, FrameOutput::Whole(wire_data)) = (stored, frame.output) else {
938                continue;
939            };
940            let spec = frame.spec;
941            match spec.mode {
942                Mode::Append => continue,
943                Mode::List => self.update_derived_view_caches(&spec.id, key).await,
944                Mode::State => {}
945            }
946            let frame = SourceFrame {
947                mode: spec.mode,
948                export: spec.id.clone(),
949                op: "upsert",
950                key: key.to_string(),
951                data: wire_data,
952                append: Vec::new(),
953                seq: position.seq.clone(),
954                offset: None,
955            };
956            json_buffer.clear();
957            serde_json::to_writer(&mut *json_buffer, &frame)?;
958            let message = Arc::new(BusMessage {
959                row_scope: None,
960                key: key.to_string(),
961                entity: spec.id.clone(),
962                payload: Arc::new(Bytes::copy_from_slice(json_buffer)),
963            });
964            self.publish_frame(spec, message).await;
965            published += 1;
966        }
967        Ok(published)
968    }
969
970    pub(crate) fn extract_key(key: &serde_json::Value) -> String {
971        key.as_str()
972            .map(|s| s.to_string())
973            .or_else(|| key.as_u64().map(|n| n.to_string()))
974            .or_else(|| key.as_i64().map(|n| n.to_string()))
975            .or_else(|| {
976                key.as_array().and_then(|arr| {
977                    let bytes: Vec<u8> = arr
978                        .iter()
979                        .filter_map(|v| v.as_u64().map(|n| n as u8))
980                        .collect();
981                    if bytes.len() == arr.len() {
982                        Some(hex::encode(&bytes))
983                    } else {
984                        None
985                    }
986                })
987            })
988            .unwrap_or_else(|| key.to_string())
989    }
990
991    async fn update_derived_view_caches(&self, source_view_id: &str, entity_key: &str) {
992        let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
993        if derived_views.is_empty() {
994            return;
995        }
996
997        // The source view's own copy: derived views share its fields.
998        let Some(entity) = self
999            .entity_cache
1000            .get_shared(source_view_id, entity_key)
1001            .await
1002        else {
1003            return;
1004        };
1005
1006        // Bound each derived sorted view by the source view's cache size,
1007        // evicting from the bottom of the sort order (see `SortedViewCache`).
1008        let max_entries = self.entity_cache.max_entities_per_view();
1009        let sorted_caches = self.view_index.sorted_caches();
1010        let mut caches = sorted_caches.write().await;
1011
1012        // A derived view holds only the entities its filter passes, so one
1013        // that stops passing leaves it. Of the rest, only a view that would
1014        // keep the entity holds on to it: once a view is full, most updates
1015        // sort below its last entry.
1016        for spec in &derived_views {
1017            let Some(cache) = caches.get_mut(&spec.id) else {
1018                continue;
1019            };
1020            let passes = spec
1021                .pipeline
1022                .as_ref()
1023                .and_then(|pipeline| pipeline.filter.as_ref())
1024                .is_none_or(|filter| filter.matches(&entity));
1025            if !passes {
1026                cache.remove_key(entity_key);
1027                continue;
1028            }
1029            if cache.upsert_if_kept(entity_key, &entity, max_entries) {
1030                debug!(
1031                    "Updated sorted cache for derived view {} with key {}",
1032                    spec.id, entity_key
1033                );
1034            }
1035            self.report_scope_partition_evictions(&spec.id, cache);
1036        }
1037    }
1038
1039    /// Count the scope partitions a row-scoped sorted view evicted at its cap.
1040    fn report_scope_partition_evictions(
1041        &self,
1042        view_id: &str,
1043        cache: &mut crate::sorted_cache::SortedViewCache,
1044    ) {
1045        let evictions = cache.take_scope_partition_evictions();
1046        if evictions == 0 {
1047            return;
1048        }
1049        debug!(
1050            view_id,
1051            evictions, "Row-scoped view evicted scope partitions"
1052        );
1053        #[cfg(feature = "otel")]
1054        if let Some(ref metrics) = self.metrics {
1055            metrics.record_view_scope_partition_evictions(view_id, evictions);
1056        }
1057    }
1058
1059    /// Apply a patch the entity cache refused (its key was evicted there) to
1060    /// the derived views that still hold the whole entity.
1061    ///
1062    /// A derived view is bounded by sort position, not recency, so it can keep
1063    /// a top-ranked entity the entity cache has evicted; that copy is whole,
1064    /// so the patch merges into it exactly as it would have in the entity
1065    /// cache. A view that does not hold the key gets nothing: the patch alone
1066    /// is not an entity, and inserting it would rank a handful of changed
1067    /// fields as though they were the whole entity. Views that shared the
1068    /// entity's fields share the merged ones.
1069    async fn merge_into_held_derived_entities(
1070        &self,
1071        source_view_id: &str,
1072        entity_key: &str,
1073        patch: Value,
1074        append_paths: &[String],
1075    ) {
1076        let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
1077        if derived_views.is_empty() {
1078            return;
1079        }
1080        let max_entries = self.entity_cache.max_entities_per_view();
1081        let sorted_caches = self.view_index.sorted_caches();
1082        let mut caches = sorted_caches.write().await;
1083        // The entities merged so far: what each view held, and the result.
1084        let mut merged: SmallVec<[(SharedEntity, SharedEntity); 2]> = SmallVec::new();
1085        for spec in &derived_views {
1086            let Some(cache) = caches.get_mut(&spec.id) else {
1087                continue;
1088            };
1089            let Some(held) = cache.get(entity_key).cloned() else {
1090                continue;
1091            };
1092            // The same patch merged into the same entity gives the same
1093            // entity: reuse it rather than merge (and copy) again.
1094            let entity = match merged.iter().find(|(before, _)| {
1095                before.shares_fields_with(&held) && before.version() == held.version()
1096            }) {
1097                Some((_, after)) => after.clone(),
1098                None => {
1099                    let mut entity = held.clone();
1100                    self.entity_cache
1101                        .merge_patch_shared(&mut entity, patch.clone(), append_paths);
1102                    merged.push((held, entity.clone()));
1103                    entity
1104                }
1105            };
1106            let passes = spec
1107                .pipeline
1108                .as_ref()
1109                .and_then(|pipeline| pipeline.filter.as_ref())
1110                .is_none_or(|filter| filter.matches(&entity));
1111            if passes {
1112                // Replace rather than merge: `entity` is already the whole
1113                // merged value, and the cache's own merge knows nothing of
1114                // append paths.
1115                cache.remove_key(entity_key);
1116                cache.upsert_if_kept(entity_key, &entity, max_entries);
1117                self.report_scope_partition_evictions(&spec.id, cache);
1118            } else {
1119                cache.remove_key(entity_key);
1120            }
1121        }
1122    }
1123
1124    async fn scope_at_publication(
1125        &self,
1126        view: &str,
1127        key: &str,
1128        data: &Value,
1129        creation: bool,
1130    ) -> Option<crate::row_scope::RowScope> {
1131        let field = self.view_index.scope_field_for_view(view)?;
1132        if let Some(scope) = field.scope_in(data) {
1133            return Some(scope);
1134        }
1135        if !creation {
1136            if let Some(row) = self.entity_cache.get_shared(view, key).await {
1137                return Some(field.scope_of(&row));
1138            }
1139        }
1140        Some(if creation {
1141            crate::row_scope::RowScope::Public
1142        } else {
1143            crate::row_scope::RowScope::Invalid
1144        })
1145    }
1146
1147    #[instrument(
1148        name = "projector.publish",
1149        level = "debug",
1150        skip(self, spec, message),
1151        fields(view_id = %spec.id, mode = ?spec.mode)
1152    )]
1153    async fn publish_frame(&self, spec: &ViewSpec, mut message: Arc<BusMessage>) {
1154        if let Some(field) = self
1155            .view_index
1156            .scope_field_for_view(&spec.id)
1157            .filter(|_| message.row_scope.is_none())
1158        {
1159            // Cache writes precede publication. Freeze membership before the
1160            // broadcast queue can be read after another mutation.
1161            let scope = self
1162                .entity_cache
1163                .get_shared(&spec.id, &message.key)
1164                .await
1165                .map(|row| field.scope_of(&row))
1166                .unwrap_or(crate::row_scope::RowScope::Invalid);
1167            Arc::make_mut(&mut message).row_scope = Some(scope);
1168        }
1169        match spec.mode {
1170            Mode::State => {
1171                self.bus_manager
1172                    .publish_state(&spec.id, &message.key, message.payload.clone())
1173                    .await;
1174            }
1175            Mode::List | Mode::Append => {
1176                self.bus_manager.publish_list(&spec.id, message).await;
1177            }
1178        }
1179    }
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    use super::*;
1185
1186    fn linked() -> EntityResync {
1187        let resync = EntityResync::new();
1188        resync.link(&Arc::new(StdMutex::new(VmContext::new())));
1189        resync
1190    }
1191
1192    fn seq(slot: u64) -> Option<String> {
1193        Some(SlotContext::new(slot, 0).to_seq_string())
1194    }
1195
1196    /// Each refusal moves the position forward, and so does any change seen
1197    /// before the resend; the resend takes the position once.
1198    #[test]
1199    fn a_resend_keeps_the_latest_change_seen_before_it() {
1200        let resync = linked();
1201        let lifetime = Some(AccountPosition::new(4, 3));
1202        resync.note_change(
1203            "Round",
1204            "1",
1205            &seq(5),
1206            Some(SlotIndexDomain::Legacy),
1207            lifetime,
1208            true,
1209        );
1210        resync.note_change(
1211            "Round",
1212            "1",
1213            &seq(6),
1214            Some(SlotIndexDomain::Legacy),
1215            lifetime,
1216            true,
1217        );
1218        resync.note_change(
1219            "Round",
1220            "2",
1221            &seq(7),
1222            Some(SlotIndexDomain::Legacy),
1223            None,
1224            false,
1225        );
1226        assert_eq!(resync.take_last_change("Round", "2"), None, "never refused");
1227        resync.note_change(
1228            "Round",
1229            "1",
1230            &seq(8),
1231            Some(SlotIndexDomain::Legacy),
1232            None,
1233            false,
1234        );
1235        assert_eq!(
1236            resync.take_last_change("Round", "1"),
1237            Some(RememberedChange {
1238                seq: seq(8),
1239                source_domain: Some(SlotIndexDomain::Legacy),
1240                account_position: lifetime,
1241            })
1242        );
1243        assert_eq!(resync.take_last_change("Round", "1"), None);
1244    }
1245
1246    #[test]
1247    fn remembered_positions_are_bounded_like_requests() {
1248        let resync = linked();
1249        for key in 0..=WHOLE_ENTITY_REQUEST_CAPACITY {
1250            resync.note_change(
1251                "Round",
1252                &key.to_string(),
1253                &seq(key as u64),
1254                Some(SlotIndexDomain::Legacy),
1255                None,
1256                true,
1257            );
1258        }
1259        assert_eq!(
1260            resync.take_last_change("Round", "0"),
1261            None,
1262            "the oldest goes"
1263        );
1264        let newest = WHOLE_ENTITY_REQUEST_CAPACITY;
1265        assert_eq!(
1266            resync.take_last_change("Round", &newest.to_string()),
1267            Some(RememberedChange {
1268                seq: seq(newest as u64),
1269                source_domain: Some(SlotIndexDomain::Legacy),
1270                account_position: None,
1271            })
1272        );
1273    }
1274
1275    /// A source without a VM gets no resends, so nothing is remembered: a
1276    /// whole entity it sends is a change and takes its batch's position.
1277    #[test]
1278    fn nothing_is_remembered_without_a_vm() {
1279        let resync = EntityResync::new();
1280        resync.note_change(
1281            "Round",
1282            "1",
1283            &seq(5),
1284            Some(SlotIndexDomain::Legacy),
1285            None,
1286            true,
1287        );
1288        assert_eq!(resync.take_last_change("Round", "1"), None);
1289    }
1290}
1291
1292#[cfg(test)]
1293mod scope_publication_tests {
1294    use super::*;
1295    use crate::journal::{EventJournal, JournalConfig};
1296    use crate::row_scope::{RowScope, ScopeField};
1297    use crate::view::{Delivery, Filters};
1298    use serde_json::json;
1299
1300    #[tokio::test]
1301    async fn sparse_patches_keep_their_original_scope_on_tape_and_bus() {
1302        let mut index = ViewIndex::new();
1303        index.set_entity_scope("Row", ScopeField::new("meta.scope"));
1304        index.add_spec(ViewSpec {
1305            id: "Row/append".into(),
1306            export: "Row".into(),
1307            mode: Mode::Append,
1308            wire_format: Default::default(),
1309            projection: Projection::all(),
1310            filters: Filters::all(),
1311            delivery: Delivery::default(),
1312            pipeline: None,
1313            source_view: None,
1314        });
1315        let bus = BusManager::new();
1316        let mut receiver = bus.get_or_create_list_bus("Row/append").await;
1317        let cache = EntityCache::new();
1318        let (_, rx) = tokio::sync::mpsc::channel(1);
1319        let journal = Arc::new(EventJournal::new(JournalConfig {
1320            enabled: true,
1321            ..Default::default()
1322        }));
1323        let projector = Projector::new(
1324            Arc::new(index),
1325            bus,
1326            cache.clone(),
1327            rx,
1328            #[cfg(feature = "otel")]
1329            None,
1330        )
1331        .with_journal(journal.clone());
1332        for (i, patch) in [
1333            json!({"meta":{"scope":"B"}}),
1334            json!({"secret":"private-at-the-time"}),
1335            json!({"meta":{"scope":null}}),
1336        ]
1337        .into_iter()
1338        .enumerate()
1339        {
1340            projector
1341                .process_mutation(
1342                    arete_interpreter::Mutation {
1343                        export: "Row".into(),
1344                        key: json!("row"),
1345                        patch,
1346                        append: vec![],
1347                    },
1348                    Some(SlotContext::new(i as u64 + 1, 0)),
1349                    &HashSet::new(),
1350                    &mut Vec::new(),
1351                )
1352                .await
1353                .unwrap();
1354        }
1355        assert!(cache
1356            .get_shared("Row/append", "row")
1357            .await
1358            .unwrap()
1359            .to_value()["meta"]["scope"]
1360            .is_null());
1361        let expected = [
1362            RowScope::Scoped("B".into()),
1363            RowScope::Scoped("B".into()),
1364            RowScope::Public,
1365        ];
1366        let records = journal.replay_after("Row/append", None).await.unwrap();
1367        assert_eq!(records.len(), 3);
1368        for (record, scope) in records.iter().zip(expected) {
1369            assert_eq!(record.row_scope.as_ref(), Some(&scope));
1370            assert_eq!(
1371                receiver.recv().await.unwrap().row_scope.as_ref(),
1372                Some(&scope)
1373            );
1374        }
1375    }
1376}