Skip to main content

heddle_thread_api/
observation.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Deliver bounded, committed changes; never expose half a snapshot as a view.
3use api::v2::{
4    ObservationAction, ObservationState, StreamProtocolError,
5    client::{ClientError, MessageReader, Messages},
6};
7
8use crate::{contract::*, reopen::ReopenRetryable, transport};
9
10#[derive(Debug, thiserror::Error)]
11pub enum Error {
12    #[error(transparent)]
13    Client(#[from] ClientError<transport::Error>),
14    #[error(transparent)]
15    Stream(#[from] StreamProtocolError),
16    #[error("invalid observation: {0}")]
17    Invalid(&'static str),
18    #[error("observation interrupted; resume only from the last committed batch")]
19    Interrupted,
20    #[error("observation reset ({0}); start a replacement snapshot")]
21    Reset(i32),
22}
23
24impl ReopenRetryable for Error {
25    fn is_reopen_retryable(&self) -> bool {
26        match self {
27            Error::Client(error) => crate::reopen::client_error_is_reopen_retryable(error),
28            _ => false,
29        }
30    }
31}
32
33/// Store this atomically with the view changed by its committed batch. A resume
34/// token belongs to one authenticated endpoint and one exact request projection.
35#[derive(Clone, Debug)]
36pub struct Resume {
37    pub(crate) cursor: Vec<u8>,
38    binding: [u8; 32],
39    source: EndpointRef,
40    query: Vec<u8>,
41}
42
43// Local bookmark format, not a server cursor or a signed authority record.
44#[derive(prost::Message)]
45struct StoredResume {
46    #[prost(uint32, tag = "1")]
47    version: u32,
48    #[prost(message, optional, tag = "2")]
49    source: Option<EndpointRef>,
50    #[prost(bytes = "vec", tag = "3")]
51    binding: Vec<u8>,
52    #[prost(bytes = "vec", tag = "4")]
53    query: Vec<u8>,
54    #[prost(bytes = "vec", tag = "5")]
55    cursor: Vec<u8>,
56}
57
58impl Resume {
59    pub fn encode(&self) -> Vec<u8> {
60        prost::Message::encode_to_vec(&StoredResume {
61            version: 1,
62            source: Some(self.source.clone()),
63            binding: self.binding.to_vec(),
64            query: self.query.clone(),
65            cursor: self.cursor.clone(),
66        })
67    }
68
69    pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
70        if bytes.len() > 512 * 1024 {
71            return Err(Error::Invalid("oversized observation bookmark"));
72        }
73        let stored: StoredResume = prost::Message::decode(bytes)
74            .map_err(|_| Error::Invalid("malformed observation bookmark"))?;
75        let source = stored
76            .source
77            .ok_or(Error::Invalid("missing bookmark source"))?;
78        if stored.version != 1
79            || source.public_key.len() != 32
80            || !matches!(
81                EndpointKind::try_from(source.kind),
82                Ok(EndpointKind::Weft | EndpointKind::Device)
83            )
84            || stored.cursor.is_empty()
85            || stored.cursor.len() > api::v2::MAX_CURSOR_BYTES
86            || stored.query.is_empty()
87        {
88            return Err(Error::Invalid("invalid observation bookmark"));
89        }
90        let binding = stored
91            .binding
92            .try_into()
93            .map_err(|_| Error::Invalid("invalid bookmark binding"))?;
94        Ok(Self {
95            cursor: stored.cursor,
96            binding,
97            source,
98            query: stored.query,
99        })
100    }
101}
102
103pub type CommittedThreadBatch = CommittedBatch<thread_event::Payload>;
104pub type CommittedAnalysisBatch = CommittedBatch<analysis_event::Payload>;
105
106pub struct CommittedBatch<P> {
107    pub replace: bool,
108    pub changes: Vec<P>,
109    pub page: Option<PageInfo>,
110    pub resume: Resume,
111}
112
113pub(crate) fn validate_resume(
114    resume: &Option<Resume>,
115    description: &DescribeEndpointResponse,
116    query: &[u8],
117) -> Result<(), Error> {
118    if resume
119        .as_ref()
120        .is_some_and(|r| Some(&r.source) != description.endpoint.as_ref() || r.query != query)
121    {
122        return Err(Error::Invalid(
123            "resume belongs to a different source or projection",
124        ));
125    }
126    Ok(())
127}
128
129pub fn budget(description: &DescribeEndpointResponse) -> Result<ReadBudget, Error> {
130    let budget = description
131        .default_read_budget
132        .ok_or(Error::Invalid("missing default read budget"))?;
133    if budget.max_items == 0
134        || budget.max_frame_bytes == 0
135        || budget.max_snapshot_bytes == 0
136        || description.max_pending_batch_bytes == 0
137    {
138        return Err(Error::Invalid("unbounded endpoint budget"));
139    }
140    // Local ceilings apply even when a peer advertises excessive defaults.
141    Ok(ReadBudget {
142        max_items: budget.max_items.min(1000),
143        max_frame_bytes: budget.max_frame_bytes.min(256 * 1024),
144        max_snapshot_bytes: budget.max_snapshot_bytes.min(4 * 1024 * 1024),
145    })
146}
147
148pub type ThreadObservation<R> = Observation<R, ThreadEvent>;
149pub type AnalysisObservation<R> = Observation<R, AnalysisEvent>;
150
151/// Typed payload access; the checkpoint/budget state machine is shared.
152pub trait ObservedEvent: prost::Message + Default {
153    type Payload;
154    fn frame(&self) -> Option<&StreamFrame>;
155    fn has_payload(&self) -> bool;
156    fn take_payload(&mut self) -> Option<Self::Payload>;
157    fn is_removal(&self) -> bool;
158}
159
160impl ObservedEvent for ThreadEvent {
161    type Payload = thread_event::Payload;
162    fn frame(&self) -> Option<&StreamFrame> {
163        self.frame.as_ref()
164    }
165    fn has_payload(&self) -> bool {
166        self.payload.is_some()
167    }
168    fn take_payload(&mut self) -> Option<Self::Payload> {
169        self.payload.take()
170    }
171    fn is_removal(&self) -> bool {
172        matches!(self.payload, Some(thread_event::Payload::Removal(_)))
173    }
174}
175impl ObservedEvent for AnalysisEvent {
176    type Payload = analysis_event::Payload;
177    fn frame(&self) -> Option<&StreamFrame> {
178        self.frame.as_ref()
179    }
180    fn has_payload(&self) -> bool {
181        self.payload.is_some()
182    }
183    fn take_payload(&mut self) -> Option<Self::Payload> {
184        self.payload.take()
185    }
186    fn is_removal(&self) -> bool {
187        matches!(
188            self.payload,
189            Some(analysis_event::Payload::Removal(_) | analysis_event::Payload::BehaviorRemoval(_))
190        )
191    }
192}
193
194/// Request shapes with the common observation controls. Typed RPC selection still
195/// comes from the contract; this trait never guesses a method from a payload.
196pub trait ObservationRequest: prost::Message {
197    fn options_mut(&mut self) -> &mut ObserveOptions;
198}
199macro_rules! observation_requests {
200    ($($request:ty),+ $(,)?) => { $(
201        impl ObservationRequest for $request {
202            fn options_mut(&mut self) -> &mut ObserveOptions {
203                self.observe.get_or_insert_default()
204            }
205        }
206    )+ };
207}
208observation_requests!(
209    ObserveThreadRequest,
210    ObserveThreadsRequest,
211    ObserveAnalysisRequest,
212    ObserveIdentityRequest,
213    ObservePairingRequest,
214    ObserveOwnershipRequest,
215    ObserveWorkspaceRequest,
216    ObserveCatalogRequest,
217    ObserveSpoolRequest,
218    ObserveCollaborationRequest,
219    ObserveCheckoutsRequest,
220    ObserveRunsRequest,
221    ObserveAttentionRequest,
222    ObserveNotificationsRequest,
223    ObserveOperationsRequest,
224    ObserveIntegrationsRequest,
225);
226
227macro_rules! observed_events {
228    ($($event:ty => $module:ident [$($removal:ident),*]),+ $(,)?) => { $(
229        impl ObservedEvent for $event {
230            type Payload = $module::Payload;
231            fn frame(&self) -> Option<&StreamFrame> { self.frame.as_ref() }
232            fn has_payload(&self) -> bool { self.payload.is_some() }
233            fn take_payload(&mut self) -> Option<Self::Payload> { self.payload.take() }
234            fn is_removal(&self) -> bool {
235                match &self.payload {
236                    $(Some($module::Payload::$removal(_)) => true,)*
237                    _ => false,
238                }
239            }
240        }
241    )+ };
242}
243observed_events!(
244    IdentityEvent => identity_event [Removal],
245    PairingEvent => pairing_event [],
246    OwnershipEvent => ownership_event [],
247    WorkspaceEvent => workspace_event [Removal],
248    CatalogEvent => catalog_event [Removal],
249    SpoolEvent => spool_event [Removal],
250    ThreadListEvent => thread_list_event [Removal],
251    CollaborationEvent => collaboration_event [Removal],
252    CheckoutEvent => checkout_event [Removal],
253    RunEvent => run_event [Removal],
254    AttentionEvent => attention_event [Removal],
255    NotificationEvent => notification_event [Removal],
256    OperationEvent => operation_event [Removal],
257    IntegrationEvent => integration_event [Removal],
258);
259
260pub struct Observation<R: MessageReader<Error = transport::Error>, E: ObservedEvent> {
261    messages: Messages<R, E>,
262    state: Option<ObservationState>,
263    binding: Option<[u8; 32]>,
264    source: EndpointRef,
265    requested: ReadBudget,
266    accepted: ReadBudget,
267    max_batch_bytes: u64,
268    resume: Option<Resume>,
269    query: Vec<u8>,
270    pending: Vec<E::Payload>,
271    pending_bytes: u64,
272    snapshot: bool,
273    done: bool,
274    primed_error: Option<Error>,
275}
276
277impl<R: MessageReader<Error = transport::Error>, E: ObservedEvent> Observation<R, E> {
278    pub(crate) fn new(
279        messages: Messages<R, E>,
280        description: &DescribeEndpointResponse,
281        requested: ReadBudget,
282        resume: Option<Resume>,
283        query: Vec<u8>,
284    ) -> Result<Self, Error> {
285        let source = description
286            .endpoint
287            .clone()
288            .ok_or(Error::Invalid("missing endpoint"))?;
289        if resume
290            .as_ref()
291            .is_some_and(|r| r.source != source || r.query != query)
292        {
293            return Err(Error::Invalid(
294                "resume belongs to a different source or projection",
295            ));
296        }
297        Ok(Self {
298            messages,
299            state: None,
300            binding: None,
301            source,
302            accepted: requested,
303            requested,
304            max_batch_bytes: u64::from(description.max_pending_batch_bytes).min(4 * 1024 * 1024),
305            resume,
306            query,
307            pending: vec![],
308            pending_bytes: 0,
309            snapshot: true,
310            done: false,
311            primed_error: None,
312        })
313    }
314
315    pub(crate) fn prime_error(&mut self, error: Error) {
316        self.primed_error = Some(error);
317    }
318
319    /// Admit the stream Open (or surface a terminal opening failure) so a
320    /// retryable authority-change can reopen a fresh exact selection.
321    pub(crate) async fn consume_open(&mut self) -> Result<(), Error> {
322        if self.state.is_some() || self.done {
323            return Ok(());
324        }
325        let event = self.messages.next().await?.ok_or(Error::Interrupted)?;
326        let size = prost::Message::encoded_len(&event) as u64;
327        if size > u64::from(self.accepted.max_frame_bytes) {
328            return Err(Error::Invalid("frame budget exceeded"));
329        }
330        let frame = event.frame().ok_or(Error::Invalid("missing frame"))?;
331        if let Some(stream_frame::Body::Reset(reset)) = &frame.body {
332            if frame.sequence != 1 || event.has_payload() {
333                return Err(Error::Invalid("malformed initial reset"));
334            }
335            return Err(Error::Reset(reset.reason));
336        }
337        let Some(stream_frame::Body::Open(open)) = &frame.body else {
338            return Err(Error::Invalid("missing Open"));
339        };
340        if open.source.as_ref() != Some(&self.source) {
341            return Err(Error::Invalid("stream source mismatch"));
342        }
343        let binding: [u8; 32] = open
344            .binding_digest
345            .as_slice()
346            .try_into()
347            .map_err(|_| Error::Invalid("invalid binding"))?;
348        let accepted = open
349            .accepted_budget
350            .ok_or(Error::Invalid("missing accepted budget"))?;
351        if accepted.max_items == 0
352            || accepted.max_items > self.requested.max_items
353            || accepted.max_frame_bytes == 0
354            || accepted.max_frame_bytes > self.requested.max_frame_bytes
355            || accepted.max_snapshot_bytes == 0
356            || accepted.max_snapshot_bytes > self.requested.max_snapshot_bytes
357        {
358            return Err(Error::Invalid("server widened or omitted the read budget"));
359        }
360        self.accepted = accepted;
361        self.binding = Some(binding);
362        self.state = Some(ObservationState::new(
363            self.resume.as_ref().map(|r| r.binding).unwrap_or(binding),
364            self.resume
365                .as_ref()
366                .map(|r| r.cursor.clone())
367                .unwrap_or_default(),
368        ));
369        let state = self
370            .state
371            .as_mut()
372            .ok_or(Error::Invalid("missing observation state"))?;
373        match state.accept(frame, event.has_payload())? {
374            ObservationAction::BeginSnapshot => self.snapshot = true,
375            ObservationAction::Resumed => self.snapshot = false,
376            ObservationAction::Heartbeat => {}
377            _ => return Err(Error::Invalid("unexpected opening frame")),
378        }
379        Ok(())
380    }
381
382    pub fn cancel(&mut self) {
383        self.messages.cancel();
384        self.pending.clear();
385        self.done = true;
386    }
387
388    pub async fn next_commit(&mut self) -> Result<Option<CommittedBatch<E::Payload>>, Error> {
389        let result = self.next_inner().await;
390        if result.is_err() {
391            self.cancel();
392        }
393        result
394    }
395
396    async fn next_inner(&mut self) -> Result<Option<CommittedBatch<E::Payload>>, Error> {
397        if let Some(error) = self.primed_error.take() {
398            return Err(error);
399        }
400        if self.done {
401            return Ok(None);
402        }
403        loop {
404            let mut event = self.messages.next().await?.ok_or(Error::Interrupted)?;
405            let size = prost::Message::encoded_len(&event) as u64;
406            if size > u64::from(self.accepted.max_frame_bytes) {
407                return Err(Error::Invalid("frame budget exceeded"));
408            }
409            let frame = event.frame().ok_or(Error::Invalid("missing frame"))?;
410            if self.state.is_none() {
411                if let Some(stream_frame::Body::Reset(reset)) = &frame.body {
412                    if frame.sequence != 1 || event.has_payload() {
413                        return Err(Error::Invalid("malformed initial reset"));
414                    }
415                    return Err(Error::Reset(reset.reason));
416                }
417                let Some(stream_frame::Body::Open(open)) = &frame.body else {
418                    return Err(Error::Invalid("missing Open"));
419                };
420                if open.source.as_ref() != Some(&self.source) {
421                    return Err(Error::Invalid("stream source mismatch"));
422                }
423                let binding: [u8; 32] = open
424                    .binding_digest
425                    .as_slice()
426                    .try_into()
427                    .map_err(|_| Error::Invalid("invalid binding"))?;
428                let accepted = open
429                    .accepted_budget
430                    .ok_or(Error::Invalid("missing accepted budget"))?;
431                if accepted.max_items == 0
432                    || accepted.max_items > self.requested.max_items
433                    || accepted.max_frame_bytes == 0
434                    || accepted.max_frame_bytes > self.requested.max_frame_bytes
435                    || accepted.max_snapshot_bytes == 0
436                    || accepted.max_snapshot_bytes > self.requested.max_snapshot_bytes
437                {
438                    return Err(Error::Invalid("server widened or omitted the read budget"));
439                }
440                self.accepted = accepted;
441                self.binding = Some(binding);
442                self.state = Some(ObservationState::new(
443                    self.resume.as_ref().map(|r| r.binding).unwrap_or(binding),
444                    self.resume
445                        .as_ref()
446                        .map(|r| r.cursor.clone())
447                        .unwrap_or_default(),
448                ));
449            }
450            let state = self
451                .state
452                .as_mut()
453                .ok_or(Error::Invalid("missing observation state"))?;
454            match state.accept(frame, event.has_payload())? {
455                ObservationAction::BeginSnapshot => self.snapshot = true,
456                ObservationAction::Resumed => self.snapshot = false,
457                ObservationAction::Stage(kind) => {
458                    if event.is_removal() != (kind == StreamDataKind::Remove) {
459                        return Err(Error::Invalid("removal payload/kind mismatch"));
460                    }
461                    let ceiling = if self.snapshot {
462                        self.accepted.max_snapshot_bytes
463                    } else {
464                        self.max_batch_bytes
465                    };
466                    if self.pending.len() >= self.accepted.max_items as usize
467                        || size > ceiling.saturating_sub(self.pending_bytes)
468                    {
469                        return Err(Error::Invalid("uncommitted batch budget exceeded"));
470                    }
471                    self.pending_bytes += size;
472                    self.pending.push(
473                        event
474                            .take_payload()
475                            .ok_or(Error::Invalid("missing data payload"))?,
476                    );
477                }
478                ObservationAction::Commit => {
479                    let binding = self
480                        .binding
481                        .ok_or(Error::Invalid("missing opening binding"))?;
482                    let resume = Resume {
483                        cursor: state.cursor().to_vec(),
484                        binding,
485                        source: self.source.clone(),
486                        query: self.query.clone(),
487                    };
488                    let batch = CommittedBatch {
489                        page: match &frame.body {
490                            Some(stream_frame::Body::Checkpoint(c)) => c.page.clone(),
491                            _ => None,
492                        },
493                        replace: self.snapshot,
494                        changes: std::mem::take(&mut self.pending),
495                        resume: resume.clone(),
496                    };
497                    self.resume = Some(resume);
498                    self.pending_bytes = 0;
499                    self.snapshot = false;
500                    return Ok(Some(batch));
501                }
502                ObservationAction::Complete => {
503                    self.done = true;
504                    self.messages.cancel();
505                    return Ok(None);
506                }
507                ObservationAction::Reset => {
508                    let Some(stream_frame::Body::Reset(reset)) = &frame.body else {
509                        return Err(Error::Invalid("missing Reset"));
510                    };
511                    return Err(Error::Reset(reset.reason));
512                }
513                ObservationAction::Heartbeat => {}
514            }
515        }
516    }
517}