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    authority_valid_until: Option<prost_types::Timestamp>,
265    source: EndpointRef,
266    requested: ReadBudget,
267    accepted: ReadBudget,
268    max_batch_bytes: u64,
269    resume: Option<Resume>,
270    query: Vec<u8>,
271    pending: Vec<E::Payload>,
272    pending_bytes: u64,
273    snapshot: bool,
274    done: bool,
275    primed_error: Option<Error>,
276}
277
278impl<R: MessageReader<Error = transport::Error>, E: ObservedEvent> Observation<R, E> {
279    pub(crate) fn new(
280        messages: Messages<R, E>,
281        description: &DescribeEndpointResponse,
282        requested: ReadBudget,
283        resume: Option<Resume>,
284        query: Vec<u8>,
285    ) -> Result<Self, Error> {
286        let source = description
287            .endpoint
288            .clone()
289            .ok_or(Error::Invalid("missing endpoint"))?;
290        if resume
291            .as_ref()
292            .is_some_and(|r| r.source != source || r.query != query)
293        {
294            return Err(Error::Invalid(
295                "resume belongs to a different source or projection",
296            ));
297        }
298        Ok(Self {
299            messages,
300            state: None,
301            binding: None,
302            authority_valid_until: None,
303            source,
304            accepted: requested,
305            requested,
306            max_batch_bytes: u64::from(description.max_pending_batch_bytes).min(4 * 1024 * 1024),
307            resume,
308            query,
309            pending: vec![],
310            pending_bytes: 0,
311            snapshot: true,
312            done: false,
313            primed_error: None,
314        })
315    }
316
317    pub(crate) fn prime_error(&mut self, error: Error) {
318        self.primed_error = Some(error);
319    }
320
321    /// Admit the stream Open (or surface a terminal opening failure) so a
322    /// retryable authority-change can reopen a fresh exact selection.
323    pub(crate) async fn consume_open(&mut self) -> Result<(), Error> {
324        if self.state.is_some() || self.done {
325            return Ok(());
326        }
327        let event = self.messages.next().await?.ok_or(Error::Interrupted)?;
328        let size = prost::Message::encoded_len(&event) as u64;
329        if size > u64::from(self.accepted.max_frame_bytes) {
330            return Err(Error::Invalid("frame budget exceeded"));
331        }
332        let frame = event.frame().ok_or(Error::Invalid("missing frame"))?;
333        if let Some(stream_frame::Body::Reset(reset)) = &frame.body {
334            if frame.sequence != 1 || event.has_payload() {
335                return Err(Error::Invalid("malformed initial reset"));
336            }
337            return Err(Error::Reset(reset.reason));
338        }
339        let Some(stream_frame::Body::Open(open)) = &frame.body else {
340            return Err(Error::Invalid("missing Open"));
341        };
342        crate::hybrid::stream_open(open).map_err(Error::Invalid)?;
343        if open.source.as_ref() != Some(&self.source) {
344            return Err(Error::Invalid("stream source mismatch"));
345        }
346        let binding: [u8; 32] = open
347            .binding_digest
348            .as_slice()
349            .try_into()
350            .map_err(|_| Error::Invalid("invalid binding"))?;
351        let accepted = open
352            .accepted_budget
353            .ok_or(Error::Invalid("missing accepted budget"))?;
354        if accepted.max_items == 0
355            || accepted.max_items > self.requested.max_items
356            || accepted.max_frame_bytes == 0
357            || accepted.max_frame_bytes > self.requested.max_frame_bytes
358            || accepted.max_snapshot_bytes == 0
359            || accepted.max_snapshot_bytes > self.requested.max_snapshot_bytes
360        {
361            return Err(Error::Invalid("server widened or omitted the read budget"));
362        }
363        self.accepted = accepted;
364        self.binding = Some(binding);
365        self.authority_valid_until = open.authority_valid_until;
366        self.state = Some(ObservationState::new(
367            self.resume.as_ref().map(|r| r.binding).unwrap_or(binding),
368            self.resume
369                .as_ref()
370                .map(|r| r.cursor.clone())
371                .unwrap_or_default(),
372        ));
373        let state = self
374            .state
375            .as_mut()
376            .ok_or(Error::Invalid("missing observation state"))?;
377        match state.accept(frame, event.has_payload())? {
378            ObservationAction::BeginSnapshot => self.snapshot = true,
379            ObservationAction::Resumed => self.snapshot = false,
380            ObservationAction::Heartbeat => {}
381            _ => return Err(Error::Invalid("unexpected opening frame")),
382        }
383        Ok(())
384    }
385
386    pub fn cancel(&mut self) {
387        self.messages.cancel();
388        self.pending.clear();
389        self.done = true;
390    }
391
392    /// Authenticated opening deadline for this exact projection and caller.
393    /// A retained observation never extends this authority at local install.
394    pub fn authority_valid_until(&self) -> Option<prost_types::Timestamp> {
395        self.authority_valid_until
396    }
397
398    pub async fn next_commit(&mut self) -> Result<Option<CommittedBatch<E::Payload>>, Error> {
399        let result = self.next_inner().await;
400        if result.is_err() {
401            self.cancel();
402        }
403        result
404    }
405
406    async fn next_inner(&mut self) -> Result<Option<CommittedBatch<E::Payload>>, Error> {
407        if let Some(error) = self.primed_error.take() {
408            return Err(error);
409        }
410        if self.done {
411            return Ok(None);
412        }
413        loop {
414            let mut event = self.messages.next().await?.ok_or(Error::Interrupted)?;
415            let size = prost::Message::encoded_len(&event) as u64;
416            if size > u64::from(self.accepted.max_frame_bytes) {
417                return Err(Error::Invalid("frame budget exceeded"));
418            }
419            let frame = event.frame().ok_or(Error::Invalid("missing frame"))?;
420            if self.state.is_none() {
421                if let Some(stream_frame::Body::Reset(reset)) = &frame.body {
422                    if frame.sequence != 1 || event.has_payload() {
423                        return Err(Error::Invalid("malformed initial reset"));
424                    }
425                    return Err(Error::Reset(reset.reason));
426                }
427                let Some(stream_frame::Body::Open(open)) = &frame.body else {
428                    return Err(Error::Invalid("missing Open"));
429                };
430                crate::hybrid::stream_open(open).map_err(Error::Invalid)?;
431                if open.source.as_ref() != Some(&self.source) {
432                    return Err(Error::Invalid("stream source mismatch"));
433                }
434                let binding: [u8; 32] = open
435                    .binding_digest
436                    .as_slice()
437                    .try_into()
438                    .map_err(|_| Error::Invalid("invalid binding"))?;
439                let accepted = open
440                    .accepted_budget
441                    .ok_or(Error::Invalid("missing accepted budget"))?;
442                if accepted.max_items == 0
443                    || accepted.max_items > self.requested.max_items
444                    || accepted.max_frame_bytes == 0
445                    || accepted.max_frame_bytes > self.requested.max_frame_bytes
446                    || accepted.max_snapshot_bytes == 0
447                    || accepted.max_snapshot_bytes > self.requested.max_snapshot_bytes
448                {
449                    return Err(Error::Invalid("server widened or omitted the read budget"));
450                }
451                self.accepted = accepted;
452                self.binding = Some(binding);
453                self.authority_valid_until = open.authority_valid_until;
454                self.state = Some(ObservationState::new(
455                    self.resume.as_ref().map(|r| r.binding).unwrap_or(binding),
456                    self.resume
457                        .as_ref()
458                        .map(|r| r.cursor.clone())
459                        .unwrap_or_default(),
460                ));
461            }
462            let state = self
463                .state
464                .as_mut()
465                .ok_or(Error::Invalid("missing observation state"))?;
466            match state.accept(frame, event.has_payload())? {
467                ObservationAction::BeginSnapshot => self.snapshot = true,
468                ObservationAction::Resumed => self.snapshot = false,
469                ObservationAction::Stage(kind) => {
470                    if event.is_removal() != (kind == StreamDataKind::Remove) {
471                        return Err(Error::Invalid("removal payload/kind mismatch"));
472                    }
473                    let ceiling = if self.snapshot {
474                        self.accepted.max_snapshot_bytes
475                    } else {
476                        self.max_batch_bytes
477                    };
478                    if self.pending.len() >= self.accepted.max_items as usize
479                        || size > ceiling.saturating_sub(self.pending_bytes)
480                    {
481                        return Err(Error::Invalid("uncommitted batch budget exceeded"));
482                    }
483                    self.pending_bytes += size;
484                    self.pending.push(
485                        event
486                            .take_payload()
487                            .ok_or(Error::Invalid("missing data payload"))?,
488                    );
489                }
490                ObservationAction::Commit => {
491                    let binding = self
492                        .binding
493                        .ok_or(Error::Invalid("missing opening binding"))?;
494                    let resume = Resume {
495                        cursor: state.cursor().to_vec(),
496                        binding,
497                        source: self.source.clone(),
498                        query: self.query.clone(),
499                    };
500                    let batch = CommittedBatch {
501                        page: match &frame.body {
502                            Some(stream_frame::Body::Checkpoint(c)) => c.page.clone(),
503                            _ => None,
504                        },
505                        replace: self.snapshot,
506                        changes: std::mem::take(&mut self.pending),
507                        resume: resume.clone(),
508                    };
509                    self.resume = Some(resume);
510                    self.pending_bytes = 0;
511                    self.snapshot = false;
512                    return Ok(Some(batch));
513                }
514                ObservationAction::Complete => {
515                    self.done = true;
516                    self.messages.cancel();
517                    return Ok(None);
518                }
519                ObservationAction::Reset => {
520                    let Some(stream_frame::Body::Reset(reset)) = &frame.body else {
521                        return Err(Error::Invalid("missing Reset"));
522                    };
523                    return Err(Error::Reset(reset.reason));
524                }
525                ObservationAction::Heartbeat => {}
526            }
527        }
528    }
529}