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, error, info_span, 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 = info_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) = slot_context {
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        skip(self, mutation, slot_context, json_buffer),
170        fields(export = %mutation.export)
171    )]
172    async fn process_mutation(
173        &self,
174        mutation: arete_interpreter::Mutation,
175        slot_context: Option<SlotContext>,
176        json_buffer: &mut Vec<u8>,
177    ) -> anyhow::Result<u32> {
178        let specs = self.view_index.by_export(&mutation.export);
179
180        if specs.is_empty() {
181            return Ok(0);
182        }
183
184        let key = Self::extract_key(&mutation.key);
185        let arete_interpreter::Mutation {
186            mut patch, append, ..
187        } = mutation;
188
189        // Inject _seq for recency sorting if slot context is available
190        if let Some(ctx) = slot_context {
191            if let Value::Object(ref mut map) = patch {
192                map.insert("_seq".to_string(), Value::String(ctx.to_seq_string()));
193            }
194        }
195
196        let matching_specs: SmallVec<[&ViewSpec; 4]> = specs
197            .iter()
198            .filter(|spec| spec.filters.matches(&key))
199            .collect();
200
201        let match_count = matching_specs.len();
202        if match_count == 0 {
203            return Ok(0);
204        }
205
206        let mut frames_published = 0u32;
207
208        for (i, spec) in matching_specs.into_iter().enumerate() {
209            let is_last = i == match_count - 1;
210            let patch_data = if is_last {
211                std::mem::take(&mut patch)
212            } else {
213                patch.clone()
214            };
215
216            let projected = spec.projection.apply(patch_data);
217            let mut wire_data = projected.clone();
218            apply_wire_format(&mut wire_data, &spec.wire_format);
219
220            // Extract _seq from the patch data to include in the frame
221            let seq = slot_context.map(|ctx| ctx.to_seq_string());
222
223            // Replayable append views carry the offset the record is about to
224            // take, so a live subscriber can checkpoint the same cursor a
225            // replay would hand it. The frame is built inside the journal's
226            // lock so the published offset is always the one the record gets.
227            let journal = self
228                .journal
229                .as_ref()
230                .filter(|journal| journal.is_enabled() && spec.mode == Mode::Append);
231
232            let mut frame = SourceFrame {
233                mode: spec.mode,
234                export: spec.id.clone(),
235                op: "patch",
236                key: key.clone(),
237                data: wire_data,
238                append: append.clone(),
239                seq,
240                offset: None,
241            };
242
243            let retained = match journal {
244                Some(journal) => {
245                    journal
246                        .append_with(&spec.id, &key, |offset| {
247                            frame.offset = Some(offset);
248                            json_buffer.clear();
249                            serde_json::to_writer(&mut *json_buffer, &frame)?;
250                            Ok::<_, anyhow::Error>(Arc::new(Bytes::copy_from_slice(json_buffer)))
251                        })
252                        .await?
253                }
254                None => None,
255            };
256            let payload = match retained {
257                Some((_offset, payload)) => payload,
258                // No tape, or a sealed one: the event still publishes, it just
259                // carries no position to resume from.
260                None => {
261                    frame.offset = None;
262                    json_buffer.clear();
263                    serde_json::to_writer(&mut *json_buffer, &frame)?;
264                    Arc::new(Bytes::copy_from_slice(json_buffer))
265                }
266            };
267
268            self.entity_cache
269                .upsert_with_append(&spec.id, &key, projected, &frame.append)
270                .await;
271
272            if spec.mode == Mode::List {
273                self.update_derived_view_caches(&spec.id, &key).await;
274            }
275
276            let message = Arc::new(BusMessage {
277                key: key.clone(),
278                entity: spec.id.clone(),
279                payload,
280            });
281
282            self.publish_frame(spec, message).await;
283            frames_published += 1;
284
285            #[cfg(feature = "otel")]
286            if let Some(ref metrics) = self.metrics {
287                let mode_str = match spec.mode {
288                    Mode::List => "list",
289                    Mode::State => "state",
290                    Mode::Append => "append",
291                };
292                metrics.record_frame_published(mode_str, &spec.export);
293            }
294        }
295
296        Ok(frames_published)
297    }
298
299    fn extract_key(key: &serde_json::Value) -> String {
300        key.as_str()
301            .map(|s| s.to_string())
302            .or_else(|| key.as_u64().map(|n| n.to_string()))
303            .or_else(|| key.as_i64().map(|n| n.to_string()))
304            .or_else(|| {
305                key.as_array().and_then(|arr| {
306                    let bytes: Vec<u8> = arr
307                        .iter()
308                        .filter_map(|v| v.as_u64().map(|n| n as u8))
309                        .collect();
310                    if bytes.len() == arr.len() {
311                        Some(hex::encode(&bytes))
312                    } else {
313                        None
314                    }
315                })
316            })
317            .unwrap_or_else(|| key.to_string())
318    }
319
320    async fn update_derived_view_caches(&self, source_view_id: &str, entity_key: &str) {
321        let derived_views = self.view_index.get_derived_views_for_source(source_view_id);
322        if derived_views.is_empty() {
323            return;
324        }
325
326        let entity_data = match self.entity_cache.get(source_view_id, entity_key).await {
327            Some(data) => data,
328            None => return,
329        };
330
331        // Bound each derived sorted copy by the source view's cache size,
332        // evicting from the bottom of the sort order (see `SortedViewCache`).
333        let max_entries = self.entity_cache.max_entities_per_view();
334        let sorted_caches = self.view_index.sorted_caches();
335        let mut caches = sorted_caches.write().await;
336
337        for derived_spec in derived_views {
338            if let Some(cache) = caches.get_mut(&derived_spec.id) {
339                cache.upsert_bounded(entity_key.to_string(), entity_data.clone(), max_entries);
340                debug!(
341                    "Updated sorted cache for derived view {} with key {}",
342                    derived_spec.id, entity_key
343                );
344            }
345        }
346    }
347
348    #[instrument(
349        name = "projector.publish",
350        skip(self, spec, message),
351        fields(view_id = %spec.id, mode = ?spec.mode)
352    )]
353    async fn publish_frame(&self, spec: &ViewSpec, message: Arc<BusMessage>) {
354        match spec.mode {
355            Mode::State => {
356                self.bus_manager
357                    .publish_state(&spec.id, &message.key, message.payload.clone())
358                    .await;
359            }
360            Mode::List | Mode::Append => {
361                self.bus_manager.publish_list(&spec.id, message).await;
362            }
363        }
364    }
365}