1use 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#[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#[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 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
151pub 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
194pub 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 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}