···66// @NOTE77//88// Using `serde_json::value::RawValue` breaks if any part of the deserialization99-// features a tagged enum or if `#[serde(flatten)]` is used.99+// features a tagged enum or `#[serde(flatten)]` is used.1010//1111+// To get around this limitation we first deserialize into a less ideal type,1212+// then manually transform into the types we want.11131214#[derive(Debug, Deserialize, Serialize)]1315struct InnerEvent<'a> {
+1-1
crates/jetstream/src/lib.rs
···22mod client_builder;33mod de;44pub mod metrics;55+pub mod subscriber_options;56mod task;66-pub mod types;7788pub use atproto::{Did, Nsid};99pub use client::{EventRef, JetstreamClient, JetstreamClientError, Receiver};
+3-15
crates/jetstream/src/main.rs
···9191 writeln!(log, "{msg}").expect("Failed to write to log file");9292 }93939494- let event = match message.deserialize() {9595- Ok(message) => message,9696- Err(error) => {9797- tracing::error!(?error, %msg, "failed to deserialize event");9898- continue;9999- }100100- };101101-102102- match event {103103- Event::Account { .. } if arguments.hide_account => continue,104104- Event::Identity { .. } if arguments.hide_identity => continue,105105- _ => {}9494+ if let Ok(Event::Commit(commit)) = message.deserialize() {9595+ println!("{commit:#?}");9696+ eprintln!("{:?}", client.metrics());10697 }107107-108108- println!("{event:#?}");109109- eprintln!("{:?}", client.metrics());11098 }11199112100 client.join().await.unwrap();
···11+use core::{error, fmt};22+use std::collections::HashSet;33+44+use serde::{Serialize, Serializer};55+66+use crate::{Did, Nsid};77+88+pub const MAX_WANTED_COLLECTIONS: usize = 100;99+1010+pub const MAX_WANTED_DIDS: usize = 10_000;1111+1212+// @TODO Review1313+pub const MAX_URL_LENGTH: usize = 4000;1414+1515+/// Subscriber sourced message.1616+///1717+/// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#subscriber-sourced-messages>1818+///1919+#[derive(Debug, Serialize)]2020+#[serde(tag = "type", content = "payload", rename_all = "snake_case")]2121+pub enum SubscriberSourcedMessage<'a> {2222+ OptionsUpdate(&'a Options),2323+}2424+2525+impl<'a> SubscriberSourcedMessage<'a> {2626+ /// Serialize the [`SubscriberSourcedMessage`] to JSON.2727+ pub fn to_json(&self) -> String {2828+ serde_json::to_string(self).expect("SubscriberSourcedMessage should be serializable")2929+ }3030+}3131+3232+/// Jetstream subscription options.3333+///3434+/// Can either be appended to the `/subscribe` URL on connection to the Jetstream instance3535+/// or sent as an options update message after connection.3636+///3737+/// Ref: <https://github.com/bluesky-social/jetstream?tab=readme-ov-file#options-updates>3838+///3939+#[derive(Debug, Default, Serialize)]4040+#[serde(rename_all = "camelCase")]4141+pub struct Options {4242+ /// Collection NSIDs to filter which records are received.4343+ ///4444+ /// Maximum: 1004545+ wanted_collections: HashSet<Box<Nsid>>,4646+4747+ /// Repository DIDs to filter which records are received.4848+ ///4949+ /// Maximum: 10_0005050+ wanted_dids: HashSet<Box<Did>>,5151+5252+ /// Maximum message size in bytes the subscriber wants to receive.5353+ ///5454+ /// Zero means no limit, negative values are treated as zero by Jetstream, and5555+ /// will be normalized to zero when serialized.5656+ #[serde(serialize_with = "serialize_max_message_size")]5757+ pub max_message_size_bytes: i64,5858+}5959+6060+fn normalize_max_message_size(value: i64) -> i64 {6161+ value.abs()6262+}6363+6464+fn serialize_max_message_size<S>(value: &i64, serializer: S) -> Result<S::Ok, S::Error>6565+where6666+ S: Serializer,6767+{6868+ serializer.serialize_i64(normalize_max_message_size(*value))6969+}7070+7171+impl Options {7272+ /// Add a collection NSID to the subscription options.7373+ ///7474+ /// Returns an error if the maximum number of subscribed collections has been reached; `Ok(true)`7575+ /// if the collection was newly added to the set, or `Ok(false)` if the colletion was already in the7676+ /// the set.7777+ pub fn add_collection(&mut self, collection: Box<Nsid>) -> Result<bool, Box<Nsid>> {7878+ if self.wanted_collections.len() == MAX_WANTED_COLLECTIONS7979+ && !self.wanted_collections.contains(&collection)8080+ {8181+ return Err(collection);8282+ }8383+8484+ Ok(self.wanted_collections.insert(collection))8585+ }8686+8787+ pub fn remove_collection(&mut self, collection: &Nsid) -> bool {8888+ self.wanted_collections.remove(collection)8989+ }9090+9191+ /// Add a DID to the subscription options.9292+ ///9393+ /// Returns an error if the maximum number of subscribed DIDs has been reached; `Ok(true)`9494+ /// if the DID was newly added to the set, or `Ok(false)` if the DID was already in the9595+ /// the set.9696+ pub fn add_did(&mut self, did: Box<Did>) -> Result<bool, Box<Did>> {9797+ if self.wanted_dids.len() == MAX_WANTED_DIDS && !self.wanted_dids.contains(&did) {9898+ return Err(did);9999+ }100100+101101+ Ok(self.wanted_dids.insert(did))102102+ }103103+104104+ pub fn remove_did(&mut self, did: &Did) -> bool {105105+ self.wanted_dids.remove(did)106106+ }107107+108108+ /// Get the normalized maximum message size.109109+ pub fn max_message_size(&self) -> i64 {110110+ normalize_max_message_size(self.max_message_size_bytes)111111+ }112112+113113+ /// Compute the length, in bytes, of the query string required to express114114+ /// this set of options in a Jetstream `/subscribe` request.115115+ pub fn subscribe_url_len(&self, base: &url::Url) -> usize {116116+ const WANTED_DIDS_LEN: usize = "wantedDids=".len();117117+ const WANTED_COLLECTIONS_LEN: usize = "wantedCollections=".len();118118+119119+ let (wdl, wdc): (_, usize) = self.wanted_dids.iter().fold((0, 0), |(sl, sc), v| {120120+ (sl + WANTED_DIDS_LEN + v.len(), sc + 1)121121+ });122122+123123+ let (wcl, wcc): (_, usize) = self.wanted_collections.iter().fold((0, 0), |(sl, sc), v| {124124+ (sl + WANTED_COLLECTIONS_LEN + v.len(), sc + 1)125125+ });126126+127127+ let mml = match self.max_message_size() {128128+ 0 => 0,129129+ n if wdc > 0 || wcc > 0 => 1 + n.to_string().len() + "maxMessageSizeBytes=".len(),130130+ n => n.to_string().len() + "maxMessageSizeBytes=".len(),131131+ };132132+133133+ mml + (wdl + wdc.saturating_sub(1)) + wcl + wcc.saturating_sub(1) + base.as_str().len()134134+ }135135+136136+ /// Appends the subscription options to the specified URL.137137+ pub fn subscribe_url(&self, url: &url::Url, cursor: &Option<u128>) -> SubscribeMethod {138138+ let mut url = url.to_owned();139139+ url.set_path("/subscribe");140140+ url.query_pairs_mut().clear();141141+142142+ if let Some(cursor) = cursor {143143+ url.query_pairs_mut()144144+ .append_pair("cursor", &cursor.to_string());145145+ }146146+147147+ if self.subscribe_url_len(&url) > MAX_URL_LENGTH {148148+ url.query_pairs_mut().append_pair("requireHello", "true");149149+ return SubscribeMethod::Hello(url);150150+ }151151+152152+ {153153+ let mut query = url.query_pairs_mut();154154+ for collection in &self.wanted_collections {155155+ query.append_pair("wantedCollections", collection);156156+ }157157+ for did in &self.wanted_dids {158158+ query.append_pair("wantedDids", did.as_str());159159+ }160160+ if self.max_message_size() > 0 {161161+ query.append_pair(162162+ "maxMessageSizeBytes",163163+ &self.max_message_size_bytes.to_string(),164164+ );165165+ }166166+ }167167+168168+ SubscribeMethod::Query(url)169169+ }170170+}171171+172172+#[derive(Debug)]173173+pub struct UrlTooLong;174174+175175+impl fmt::Display for UrlTooLong {176176+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {177177+ f.write_str("URL too long")178178+ }179179+}180180+181181+impl error::Error for UrlTooLong {}182182+183183+pub enum SubscribeMethod {184184+ Query(url::Url),185185+ Hello(url::Url),186186+}187187+188188+#[cfg(test)]189189+mod tests {190190+ use atproto::Nsid;191191+192192+ use super::Options;193193+194194+ #[test]195195+ fn query_len() {196196+ let mut options = Options::default();197197+ options198198+ .add_collection(Nsid::from_static("sh.tangled.*").into())199199+ .unwrap();200200+201201+ assert_eq!(202202+ options.subscribe_url_len(&"https://example.url/subscribe".parse().unwrap()),203203+ "wantedCollections=sh.tangled.*".len()204204+ );205205+206206+ options207207+ .add_collection(Nsid::from_static("app.bsky.*").into())208208+ .unwrap();209209+ assert_eq!(210210+ options.subscribe_url_len(&"https://example.url/subscribe".parse().unwrap()),211211+ "wantedCollections=sh.tangled.*&wantedCollections=app.bsky.*".len()212212+ );213213+214214+ options.max_message_size_bytes = 1_000_000;215215+ assert_eq!(216216+ options.subscribe_url_len(&"https://example.url/subscribe".parse().unwrap()),217217+ "wantedCollections=sh.tangled.*&wantedCollections=app.bsky.*&maxMessageSizeBytes=1000000".len()218218+ );219219+ }220220+}
+60-53
crates/jetstream/src/task.rs
···11-use crate::{client::ClientCommand, metrics::Metrics, types::Options};11+use crate::{22+ client::ClientCommand,33+ metrics::Metrics,44+ subscriber_options::{Options, SubscribeMethod},55+};26use bytes::Bytes;37use futures_util::{SinkExt, StreamExt as _};48use serde::Deserialize;59use std::{ops::ControlFlow, sync::Arc, time::Duration};610use tokio::{sync::Notify, time::Instant};77-use tokio_tungstenite::tungstenite::{Error as TungsteniteError, Message};1111+use tokio_tungstenite::{1212+ connect_async,1313+ tungstenite::{Error as TungsteniteError, Message},1414+};1515+use tokio_util::sync::CancellationToken;816917#[cfg(feature = "zstd")]1018const ZSTD_DICTIONARY: &[u8] = include_bytes!("dictionary");1111-1212-const MAX_URL_LENGTH: usize = 2000;13191420/// Duration since last receipt of a message to consider a subscription broken.1521const THRESHOLD: Duration = Duration::from_secs(35);···3024 mut instance: url::Url,3125 mut options: Options,3226 initial_cursor: Option<u128>,2727+ shutdown: CancellationToken,3328) {3429 // Prepare the URL3530 instance.set_path("/subscribe");···5245 // timeout.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);53465447 'outer: loop {5555- let mut url = instance.clone();4848+ let (subscribe_url, require_hello) = match options.subscribe_url(&instance, &cursor) {4949+ SubscribeMethod::Query(url) => (url, false),5050+ SubscribeMethod::Hello(url) => (url, true),5151+ };5252+5353+ tracing::debug!(%subscribe_url, "connecting to jetstream");5454+ let (socket, _) = match shutdown5555+ .run_until_cancelled(connect_async(subscribe_url.as_str()))5656+ .await5657 {5757- let mut query = url.query_pairs_mut();5858- if let Some(cursor) = cursor {5959- query.append_pair("cursor", &format!("{cursor}"));6060- }6161- }6262-6363- let mut require_hello = true;6464- if url.as_str().len() + options.subscribe_query_len() < MAX_URL_LENGTH {6565- tracing::debug!("appending subscribe options to URL");6666- options.append_to_url(&mut url);6767- require_hello = false;6868- }6969-7070- if require_hello {7171- url.query_pairs_mut().append_pair("requireHello", "true");7272- }7373-7474- tracing::debug!(%url, "connecting to jetstream");7575- let (socket, _) = match tokio_tungstenite::connect_async(url.as_str()).await {7676- Ok(socket) => socket,7777- Err(error) => {5858+ Some(Ok(socket)) => socket,5959+ Some(Err(error)) => {7860 match error {7961 tokio_tungstenite::tungstenite::Error::Http(response) => {8062 let body = response.body().as_deref().unwrap_or_default();···7579 tokio::time::sleep(Duration::from_secs(5)).await;7680 continue;7781 }8282+ None => break,7883 };79848085 metrics.modify(|mut data| data.connects += 1);8186 let (mut write, mut read) = socket.split();82878388 if require_hello {8484- // Subscribe!8589 if let Err(e) = send_options_update::<_, TungsteniteError>(&mut write, &options).await {8690 tracing::error!(?e, "failed to send subscibe options update");8791 continue;···141145 payload.into()142146 }143147 #[cfg(feature = "zstd")]144144- Message::Binary(raw_payload) => {148148+ Message::Binary(compressed_payload) => {145149 use bytes::Buf as _;146150 use std::io::Read as _;147151148148- let raw_bytes = raw_payload.len();149149- let mut payload = Vec::with_capacity(raw_payload.len());150150- let mut decoder =151151- zstd::Decoder::with_prepared_dictionary(raw_payload.reader(), &dictionary)152152- .expect("Failed to initialise zstd dictionary");152152+ let compressed_bytes = compressed_payload.len();153153+ let mut payload = Vec::with_capacity(compressed_payload.len());154154+ let mut decoder = zstd::Decoder::with_prepared_dictionary(155155+ compressed_payload.reader(),156156+ &dictionary,157157+ )158158+ .expect("prepared zstd dictionary should be valid");153159154160 let Ok(_) = decoder.read_to_end(&mut payload) else {155161 tracing::error!("failed to dezstd message with zstd");···159161 };160162161163 metrics.modify(|mut data| {162162- data.bytes_received_raw += raw_bytes;164164+ data.bytes_received_raw += compressed_bytes;163165 data.bytes_received += payload.len();164166 });165167 Bytes::from(payload)···200202 kind: &'a str,201203 }202204203203- // Deserialize just the event timestamp.205205+ let mut new_cursor = cursor;206206+207207+ // Deserialize just the event timestamp and kind.204208 match serde_json::from_slice::<PartialEvent>(&bytes) {205209 Ok(event) => {206206- _ = {207207- match event.kind {208208- "identity" => metrics.modify(|mut data| data.identity_messages += 1),209209- "account" => metrics.modify(|mut data| data.account_messages += 1),210210- "commit" => metrics.modify(|mut data| data.commit_messages += 1),211211- _ => metrics.modify(|mut data| data.unknown_messages += 1),212212- }213213- cursor.replace(event.time_us.into())214214- }210210+ metrics.increment_message_kind(event.kind);211211+ new_cursor.replace(event.time_us.into());215212 }216213 Err(error) => {217214 match std::str::from_utf8(&bytes) {···215222 }216223 Err(_) => tracing::error!(?error, ?bytes, "failed to deserialize event"),217224 }218218- continue;225225+ break;219226 }220227 }221228···228235 }229236 break 'outer;230237 }238238+239239+ // Update the cursor since the message has been dispatched.240240+ cursor = new_cursor;231241 }232242233243 metrics.modify(|mut data| data.disconnects += 1);···244248 S: SinkExt<Message> + Unpin,245249 E: From<S::Error>,246250{247247- use crate::types::SubscriberOptionsUpdate;251251+ use crate::subscriber_options::SubscriberSourcedMessage;248252249249- let update = SubscriberOptionsUpdate::serialize_options_update(options);253253+ let update = SubscriberSourcedMessage::OptionsUpdate(options).to_json();250254 tracing::debug!(%update, "sending options update");251255 sink.send(Message::Text(update.into())).await?;252256 Ok(())···262266 options: &mut Options,263267) -> ControlFlow<(), (Outcome, Arc<Notify>)> {264268 match command {265265- ClientCommand::AddDid { complete, did } => match options.insert_did(did) {266266- true => ControlFlow::Continue((Outcome::UpdateOptions, complete)),267267- false => ControlFlow::Continue((Outcome::NoAction, complete)),269269+ ClientCommand::AddDid { complete, did } => match options.add_did(did) {270270+ Ok(true) => ControlFlow::Continue((Outcome::UpdateOptions, complete)),271271+ Ok(false) => ControlFlow::Continue((Outcome::NoAction, complete)),272272+ Err(did) => {273273+ tracing::error!(?did, "too many DIDs encountered when adding DID");274274+ ControlFlow::Break(())275275+ }268276 },269277 ClientCommand::RemoveDid { complete, did } => match options.remove_did(&did) {270278 true => ControlFlow::Continue((Outcome::UpdateOptions, complete)),···277277 ClientCommand::AddCollection {278278 complete,279279 collection,280280- } => match options.insert_collection(collection) {281281- true => ControlFlow::Continue((Outcome::UpdateOptions, complete)),282282- false => ControlFlow::Continue((Outcome::NoAction, complete)),280280+ } => match options.add_collection(collection) {281281+ Ok(true) => ControlFlow::Continue((Outcome::UpdateOptions, complete)),282282+ Ok(false) => ControlFlow::Continue((Outcome::NoAction, complete)),283283+ Err(collection) => {284284+ tracing::error!(285285+ ?collection,286286+ "too many collections encountered when adding collection"287287+ );288288+ ControlFlow::Break(())289289+ }283290 },284291 ClientCommand::RemoveCollection {285292 complete,