Skip to main content

arete_server/
projector.rs

1use crate::bus::{BusManager, BusMessage};
2use crate::cache::EntityCache;
3use crate::mutation_batch::{MutationBatch, SlotContext};
4use crate::view::{ViewIndex, ViewSpec};
5use crate::websocket::frame::{apply_wire_format, Mode, SourceFrame};
6use arete_interpreter::CanonicalLog;
7use bytes::Bytes;
8use serde_json::Value;
9use smallvec::SmallVec;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tracing::{debug, debug_span, error, instrument};
13
14#[cfg(feature = "otel")]
15use crate::metrics::Metrics;
16
17pub struct Projector {
18    view_index: Arc<ViewIndex>,
19    bus_manager: BusManager,
20    entity_cache: EntityCache,
21    mutations_rx: mpsc::Receiver<MutationBatch>,
22    snapshot_runtime: Option<crate::snapshot::SnapshotRuntime>,
23    journal: Option<Arc<crate::journal::EventJournal>>,
24    #[cfg(feature = "otel")]
25    metrics: Option<Arc<Metrics>>,
26}
27
28impl Projector {
29    #[cfg(feature = "otel")]
30    pub fn new(
31        view_index: Arc<ViewIndex>,
32        bus_manager: BusManager,
33        entity_cache: EntityCache,
34        mutations_rx: mpsc::Receiver<MutationBatch>,
35        metrics: Option<Arc<Metrics>>,
36    ) -> Self {
37        Self {
38            view_index,
39            bus_manager,
40            entity_cache,
41            mutations_rx,
42            snapshot_runtime: None,
43            journal: None,
44            metrics,
45        }
46    }
47
48    #[cfg(not(feature = "otel"))]
49    pub fn new(
50        view_index: Arc<ViewIndex>,
51        bus_manager: BusManager,
52        entity_cache: EntityCache,
53        mutations_rx: mpsc::Receiver<MutationBatch>,
54    ) -> Self {
55        Self {
56            view_index,
57            bus_manager,
58            entity_cache,
59            mutations_rx,
60            snapshot_runtime: None,
61            journal: None,
62        }
63    }
64
65    /// Associate projection progress with one server's snapshot lifecycle.
66    pub fn with_snapshot_runtime(
67        mut self,
68        snapshot_runtime: crate::snapshot::SnapshotRuntime,
69    ) -> Self {
70        self.snapshot_runtime = Some(snapshot_runtime);
71        self
72    }
73
74    /// Retain published events for replayable append subscriptions.
75    pub fn with_journal(mut self, journal: Arc<crate::journal::EventJournal>) -> Self {
76        self.journal = Some(journal);
77        self
78    }
79
80    pub async fn run(mut self) {
81        debug!("Projector started");
82
83        let mut json_buffer = Vec::with_capacity(4096);
84
85        while let Some(mut batch) = self.mutations_rx.recv().await {
86            let mut log = CanonicalLog::new();
87            log.set("phase", "projector");
88
89            let batch_size = batch.len();
90            let slot_context = batch.slot_context;
91            let batch_span = debug_span!(
92                parent: &batch.span,
93                "projector.batch",
94                batch.mutations = batch_size,
95                batch.position = tracing::field::Empty,
96                frames_published = tracing::field::Empty,
97            );
98            if let (Some(context), false) = (slot_context, batch_span.is_disabled()) {
99                batch_span.record("batch.position", context.to_seq_string());
100            }
101            let _span_guard = batch_span.enter();
102            let mut frames_published = 0u32;
103            let mut errors = 0u32;
104
105            if let Some(ctx) = batch.event_context.as_ref() {
106                log.set("program", &ctx.program)
107                    .set("event_kind", &ctx.event_kind)
108                    .set("event_type", &ctx.event_type)
109                    .set("account", &ctx.account)
110                    .set("accounts_count", ctx.accounts_count);
111            }
112
113            for mutation in std::mem::take(&mut batch.mutations).into_iter() {
114                #[cfg(feature = "otel")]
115                let export = mutation.export.clone();
116
117                match self
118                    .process_mutation(mutation, slot_context, &mut json_buffer)
119                    .await
120                {
121                    Ok(count) => frames_published += count,
122                    Err(e) => {
123                        error!("Failed to process mutation: {}", e);
124                        errors += 1;
125                    }
126                }
127
128                #[cfg(feature = "otel")]
129                if let Some(ref metrics) = self.metrics {
130                    metrics.record_mutation_processed(&export);
131                }
132            }
133
134            // The batch is now applied to the caches: advance the snapshot
135            // resume watermark, then release the processing guard transferred
136            // by the VM producer. An exclusive snapshot cut cannot begin until
137            // every earlier guarded batch reaches this point.
138            if batch_size > 0 {
139                if let Some(snapshot_runtime) = &self.snapshot_runtime {
140                    snapshot_runtime.record_applied_batch(slot_context.map(|ctx| ctx.slot));
141                }
142            }
143            drop(batch.snapshot_guard.take());
144
145            // Flush markers remain useful to non-snapshot callers that need
146            // to observe a drained projector queue.
147            if let Some(ack) = batch.flush_ack.take() {
148                let _ = ack.send(());
149            }
150
151            log.set("batch_size", batch_size)
152                .set("frames_published", frames_published)
153                .set("errors", errors);
154            batch_span.record("frames_published", frames_published);
155
156            #[cfg(feature = "otel")]
157            if let Some(ref metrics) = self.metrics {
158                metrics.record_projector_latency(log.duration_ms());
159            }
160
161            log.emit();
162        }
163
164        debug!("Projector stopped");
165    }
166
167    #[instrument(
168        name = "projector.mutation",
169        level = "debug",
170        skip(self, mutation, slot_context, json_buffer),
171        fields(export = %mutation.export)
172    )]
173    async fn process_mutation(
174        &self,
175        mutation: arete_interpreter::Mutation,
176        slot_context: Option<SlotContext>,
177        json_buffer: &mut Vec<u8>,
178    ) -> anyhow::Result<u32> {
179        let specs = self.view_index.by_export(&mutation.export);
180
181        if specs.is_empty() {
182            return Ok(0);
183        }
184
185        let key = Self::extract_key(&mutation.key);
186        let arete_interpreter::Mutation {
187            mut patch, append, ..
188        } = mutation;
189
190        // Inject _seq for recency sorting if slot context is available
191        if let Some(ctx) = slot_context {
192            if let Value::Object(ref mut map) = patch {
193                map.insert("_seq".to_string(), Value::String(ctx.to_seq_string()));
194            }
195        }
196
197        let matching_specs: SmallVec<[&ViewSpec; 4]> = specs
198            .iter()
199            .filter(|spec| spec.filters.matches(&key))
200            .collect();
201
202        let match_count = matching_specs.len();
203        if match_count == 0 {
204            return Ok(0);
205        }
206
207        let mut frames_published = 0u32;
208
209        for (i, spec) in matching_specs.into_iter().enumerate() {
210            let is_last = i == match_count - 1;
211            let patch_data = if is_last {
212                std::mem::take(&mut patch)
213            } else {
214                patch.clone()
215            };
216
217            let projected = spec.projection.apply(patch_data);
218            let mut wire_data = projected.clone();
219            apply_wire_format(&mut wire_data, &spec.wire_format);
220
221            // Extract _seq from the patch data to include in the frame
222            let seq = slot_context.map(|ctx| ctx.to_seq_string());
223
224            // Replayable append views carry the offset the record is about to
225            // take, so a live subscriber can checkpoint the same cursor a
226            // replay would hand it. The frame is built inside the journal's
227            // lock so the published offset is always the one the record gets.
228            let journal = self
229                .journal
230                .as_ref()
231                .filter(|journal| journal.is_enabled() && spec.mode == Mode::Append);
232
233            let mut frame = SourceFrame {
234                mode: spec.mode,
235                export: spec.id.clone(),
236                op: "patch",
237                key: key.clone(),
238                data: wire_data,
239                append: append.clone(),
240                seq,
241                offset: None,
242            };
243
244            let retained = match journal {
245                Some(journal) => {
246                    journal
247                        .append_with(&spec.id, &key, |offset| {
248                            frame.offset = Some(offset);
249                            json_buffer.clear();
250                            serde_json::to_writer(&mut *json_buffer, &frame)?;
251                            Ok::<_, anyhow::Error>(Arc::new(Bytes::copy_from_slice(json_buffer)))
252                        })
253                        .await?
254                }
255                None => None,
256            };
257            let payload = match retained {
258                Some((_offset, payload)) => payload,
259                // No tape, or a sealed one: the event still publishes, it just
260                // carries no position to resume from.
261                None => {
262                    frame.offset = None;
263                    json_buffer.clear();
264                    serde_json::to_writer(&mut *json_buffer, &frame)?;
265                    Arc::new(Bytes::copy_from_slice(json_buffer))
266                }
267            };
268
269            self.entity_cache
270                .upsert_with_append(&spec.id, &key, projected, &frame.append)
271                .await;
272
273            if spec.mode == Mode::List {
274                self.update_derived_view_caches(&spec.id, &key).await;
275            }
276
277            let message = Arc::new(BusMessage {
278                key: key.clone(),
279                entity: spec.id.clone(),
280                payload,
281            });
282
283            self.publish_frame(spec, message).await;
284            frames_published += 1;
285
286            #[cfg(feature = "otel")]
287            if let Some(ref metrics) = self.metrics {
288                let mode_str = match spec.mode {
289                    Mode::List => "list",
290                    Mode::State => "state",
291                    Mode::Append => "append",
292                };
293                metrics.record_frame_published(mode_str, &spec.export);
294            }
295        }
296
297        Ok(frames_published)
298    }
299
300    fn extract_key(key: &serde_json::Value) -> String {
301        key.as_str()
302            .map(|s| s.to_string())
303            .or_else(|| key.as_u64().map(|n| n.to_string()))
304            .or_else(|| key.as_i64().map(|n| n.to_string()))
305            .or_else(|| {
306                key.as_array().and_then(|arr| {
307                    let bytes: Vec<u8> = arr
308                        .iter()
309                        .filter_map(|v| v.as_u64().map(|n| n as u8))
310                        .collect();
311                    if bytes.len() == arr.len() {
312                        Some(hex::encode(&bytes))
313                    } else {
314                        None
315                    }
316                })
317            })
318            .unwrap_or_else(|| key.to_string())
319    }
320
321    async fn update_derived_view_caches(&self, source_view_id: &str, entity_key: &str) {
322        let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
323        if derived_views.is_empty() {
324            return;
325        }
326
327        let entity_data = match self.entity_cache.get(source_view_id, entity_key).await {
328            Some(data) => data,
329            None => return,
330        };
331
332        // Bound each derived sorted copy by the source view's cache size,
333        // evicting from the bottom of the sort order (see `SortedViewCache`).
334        let max_entries = self.entity_cache.max_entities_per_view();
335        let sorted_caches = self.view_index.sorted_caches();
336        let mut caches = sorted_caches.write().await;
337
338        // A derived view holds only the entities its filter passes, so one
339        // that stops passing leaves it. Of the rest, only a view that would
340        // keep the entity gets a copy of it: once a view is full, most updates
341        // sort below its last entry. The last one gets the entity itself.
342        let mut keeping: SmallVec<[&str; 4]> = SmallVec::new();
343        for spec in &derived_views {
344            let Some(cache) = caches.get_mut(&spec.id) else {
345                continue;
346            };
347            let passes = spec
348                .pipeline
349                .as_ref()
350                .and_then(|pipeline| pipeline.filter.as_ref())
351                .is_none_or(|filter| filter.matches(&entity_data));
352            if !passes {
353                cache.remove(entity_key);
354                continue;
355            }
356            if cache.would_keep(entity_key, &entity_data, max_entries) {
357                keeping.push(spec.id.as_str());
358            }
359        }
360        let mut entity_data = Some(entity_data);
361        for (index, view_id) in keeping.iter().enumerate() {
362            let Some(cache) = caches.get_mut(*view_id) else {
363                continue;
364            };
365            let entity = if index + 1 == keeping.len() {
366                entity_data.take()
367            } else {
368                entity_data.clone()
369            };
370            let Some(entity) = entity else {
371                continue;
372            };
373            cache.upsert_bounded(entity_key.to_string(), entity, max_entries);
374            debug!(
375                "Updated sorted cache for derived view {} with key {}",
376                view_id, entity_key
377            );
378        }
379    }
380
381    #[instrument(
382        name = "projector.publish",
383        level = "debug",
384        skip(self, spec, message),
385        fields(view_id = %spec.id, mode = ?spec.mode)
386    )]
387    async fn publish_frame(&self, spec: &ViewSpec, message: Arc<BusMessage>) {
388        match spec.mode {
389            Mode::State => {
390                self.bus_manager
391                    .publish_state(&spec.id, &message.key, message.payload.clone())
392                    .await;
393            }
394            Mode::List | Mode::Append => {
395                self.bus_manager.publish_list(&spec.id, message).await;
396            }
397        }
398    }
399}