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 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}