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        crate::hybrid::stream_open(open).map_err(Error::Invalid)?;
341        if open.source.as_ref() != Some(&self.source) {
342            return Err(Error::Invalid("stream source mismatch"));
343        }
344        let binding: [u8; 32] = open
345            .binding_digest
346            .as_slice()
347            .try_into()
348            .map_err(|_| Error::Invalid("invalid binding"))?;
349        let accepted = open
350            .accepted_budget
351            .ok_or(Error::Invalid("missing accepted budget"))?;
352        if accepted.max_items == 0
353            || accepted.max_items > self.requested.max_items
354            || accepted.max_frame_bytes == 0
355            || accepted.max_frame_bytes > self.requested.max_frame_bytes
356            || accepted.max_snapshot_bytes == 0
357            || accepted.max_snapshot_bytes > self.requested.max_snapshot_bytes
358        {
359            return Err(Error::Invalid("server widened or omitted the read budget"));
360        }
361        self.accepted = accepted;
362        self.binding = Some(binding);
363        self.state = Some(ObservationState::new(
364            self.resume.as_ref().map(|r| r.binding).unwrap_or(binding),
365            self.resume
366                .as_ref()
367                .map(|r| r.cursor.clone())
368                .unwrap_or_default(),
369        ));
370        let state = self
371            .state
372            .as_mut()
373            .ok_or(Error::Invalid("missing observation state"))?;
374        match state.accept(frame, event.has_payload())? {
375            ObservationAction::BeginSnapshot => self.snapshot = true,
376            ObservationAction::Resumed => self.snapshot = false,
377            ObservationAction::Heartbeat => {}
378            _ => return Err(Error::Invalid("unexpected opening frame")),
379        }
380        Ok(())
381    }
382
383    pub fn cancel(&mut self) {
384        self.messages.cancel();
385        self.pending.clear();
386        self.done = true;
387    }
388
389    pub async fn next_commit(&mut self) -> Result<Option<CommittedBatch<E::Payload>>, Error> {
390        let result = self.next_inner().await;
391        if result.is_err() {
392            self.cancel();
393        }
394        result
395    }
396
397    async fn next_inner(&mut self) -> Result<Option<CommittedBatch<E::Payload>>, Error> {
398        if let Some(error) = self.primed_error.take() {
399            return Err(error);
400        }
401        if self.done {
402            return Ok(None);
403        }
404        loop {
405            let mut event = self.messages.next().await?.ok_or(Error::Interrupted)?;
406            let size = prost::Message::encoded_len(&event) as u64;
407            if size > u64::from(self.accepted.max_frame_bytes) {
408                return Err(Error::Invalid("frame budget exceeded"));
409            }
410            let frame = event.frame().ok_or(Error::Invalid("missing frame"))?;
411            if self.state.is_none() {
412                if let Some(stream_frame::Body::Reset(reset)) = &frame.body {
413                    if frame.sequence != 1 || event.has_payload() {
414                        return Err(Error::Invalid("malformed initial reset"));
415                    }
416                    return Err(Error::Reset(reset.reason));
417                }
418                let Some(stream_frame::Body::Open(open)) = &frame.body else {
419                    return Err(Error::Invalid("missing Open"));
420                };
421                crate::hybrid::stream_open(open).map_err(Error::Invalid)?;
422                if open.source.as_ref() != Some(&self.source) {
423                    return Err(Error::Invalid("stream source mismatch"));
424                }
425                let binding: [u8; 32] = open
426                    .binding_digest
427                    .as_slice()
428                    .try_into()
429                    .map_err(|_| Error::Invalid("invalid binding"))?;
430                let accepted = open
431                    .accepted_budget
432                    .ok_or(Error::Invalid("missing accepted budget"))?;
433                if accepted.max_items == 0
434                    || accepted.max_items > self.requested.max_items
435                    || accepted.max_frame_bytes == 0
436                    || accepted.max_frame_bytes > self.requested.max_frame_bytes
437                    || accepted.max_snapshot_bytes == 0
438                    || accepted.max_snapshot_bytes > self.requested.max_snapshot_bytes
439                {
440                    return Err(Error::Invalid("server widened or omitted the read budget"));
441                }
442                self.accepted = accepted;
443                self.binding = Some(binding);
444                self.state = Some(ObservationState::new(
445                    self.resume.as_ref().map(|r| r.binding).unwrap_or(binding),
446                    self.resume
447                        .as_ref()
448                        .map(|r| r.cursor.clone())
449                        .unwrap_or_default(),
450                ));
451            }
452            let state = self
453                .state
454                .as_mut()
455                .ok_or(Error::Invalid("missing observation state"))?;
456            match state.accept(frame, event.has_payload())? {
457                ObservationAction::BeginSnapshot => self.snapshot = true,
458                ObservationAction::Resumed => self.snapshot = false,
459                ObservationAction::Stage(kind) => {
460                    if event.is_removal() != (kind == StreamDataKind::Remove) {
461                        return Err(Error::Invalid("removal payload/kind mismatch"));
462                    }
463                    let ceiling = if self.snapshot {
464                        self.accepted.max_snapshot_bytes
465                    } else {
466                        self.max_batch_bytes
467                    };
468                    if self.pending.len() >= self.accepted.max_items as usize
469                        || size > ceiling.saturating_sub(self.pending_bytes)
470                    {
471                        return Err(Error::Invalid("uncommitted batch budget exceeded"));
472                    }
473                    self.pending_bytes += size;
474                    self.pending.push(
475                        event
476                            .take_payload()
477                            .ok_or(Error::Invalid("missing data payload"))?,
478                    );
479                }
480                ObservationAction::Commit => {
481                    let binding = self
482                        .binding
483                        .ok_or(Error::Invalid("missing opening binding"))?;
484                    let resume = Resume {
485                        cursor: state.cursor().to_vec(),
486                        binding,
487                        source: self.source.clone(),
488                        query: self.query.clone(),
489                    };
490                    let batch = CommittedBatch {
491                        page: match &frame.body {
492                            Some(stream_frame::Body::Checkpoint(c)) => c.page.clone(),
493                            _ => None,
494                        },
495                        replace: self.snapshot,
496                        changes: std::mem::take(&mut self.pending),
497                        resume: resume.clone(),
498                    };
499                    self.resume = Some(resume);
500                    self.pending_bytes = 0;
501                    self.snapshot = false;
502                    return Ok(Some(batch));
503                }
504                ObservationAction::Complete => {
505                    self.done = true;
506                    self.messages.cancel();
507                    return Ok(None);
508                }
509                ObservationAction::Reset => {
510                    let Some(stream_frame::Body::Reset(reset)) = &frame.body else {
511                        return Err(Error::Invalid("missing Reset"));
512                    };
513                    return Err(Error::Reset(reset.reason));
514                }
515                ObservationAction::Heartbeat => {}
516            }
517        }
518    }
519}