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