Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

add a delay/debounce queue

this catches some amount of spurious notifications (accidental like + quick unlike) and also possibly some cases of bsky moderation actions? or maybe would need to listen to labellers for that (and prob should)

+10 -7
spacedust/src/consumer.rs
···
use tokio_util::sync::CancellationToken;
use crate::LinkEvent;
use crate::error::ConsumerError;
+
use crate::removable_delay_queue;
use jetstream::{
DefaultJetstreamEndpoints, JetstreamCompression, JetstreamConfig, JetstreamConnector,
events::{CommitOp, Cursor, EventKind},
···
pub async fn consume(
b: broadcast::Sender<LinkEvent>,
+
d: removable_delay_queue::Input<(String, usize), LinkEvent>,
jetstream_endpoint: String,
cursor: Option<Cursor>,
no_zstd: bool,
···
continue;
};
+
let at_uri = format!("at://{}/{}/{}", &*event.did, &*commit.collection, &*commit.rkey);
+
// TODO: keep a buffer and remove quick deletes to debounce notifs
// for now we just drop all deletes eek
if commit.operation == CommitOp::Delete {
+
d.remove_range((at_uri.clone(), 0)..=(at_uri.clone(), MAX_LINKS_PER_EVENT)).await;
continue;
}
let Some(record) = commit.record else {
···
let link_ev = LinkEvent {
collection: commit.collection.to_string(),
path: link.path,
-
origin: format!(
-
"at://{}/{}/{}",
-
&*event.did,
-
&*commit.collection,
-
&*commit.rkey,
-
),
+
origin: at_uri.clone(),
rev: commit.rev.to_string(),
target: link.target.into_string(),
};
-
let _ = b.send(link_ev); // only errors if no subscribers are connected, which is just fine.
+
let _ = b.send(link_ev.clone()); // only errors if no subscribers are connected, which is just fine.
+
d.enqueue((at_uri.clone(), i), link_ev)
+
.await
+
.map_err(|_| ConsumerError::DelayQueueOutputDropped)?;
}
}
+23
spacedust/src/delay.rs
···
+
use crate::removable_delay_queue;
+
use crate::LinkEvent;
+
use tokio_util::sync::CancellationToken;
+
use tokio::sync::broadcast;
+
use crate::error::DelayError;
+
+
pub async fn to_broadcast(
+
source: removable_delay_queue::Output<(String, usize), LinkEvent>,
+
dest: broadcast::Sender<LinkEvent>,
+
shutdown: CancellationToken,
+
) -> Result<(), DelayError> {
+
loop {
+
tokio::select! {
+
ev = source.next() => match ev {
+
Some(event) => {
+
let _ = dest.send(event); // only errors of there are no listeners, but that's normal
+
},
+
None => return Err(DelayError::DelayEnded),
+
},
+
_ = shutdown.cancelled() => return Ok(()),
+
}
+
}
+
}
+11 -1
spacedust/src/error.rs
···
ConsumerTaskError(#[from] ConsumerError),
#[error(transparent)]
ServerTaskError(#[from] ServerError),
+
#[error(transparent)]
+
DelayTaskError(#[from] DelayError),
}
#[derive(Debug, Error)]
···
#[error(transparent)]
JetstreamConfigValidationError(#[from] jetstream::error::ConfigValidationError),
#[error("jetstream ended")]
-
JetstreamEnded
+
JetstreamEnded,
+
#[error("delay queue output dropped")]
+
DelayQueueOutputDropped,
+
}
+
+
#[derive(Debug, Error)]
+
pub enum DelayError {
+
#[error("delay ended")]
+
DelayEnded,
}
#[derive(Debug, Error)]
+2
spacedust/src/lib.rs
···
pub mod consumer;
+
pub mod delay;
pub mod error;
pub mod server;
pub mod subscriber;
+
pub mod removable_delay_queue;
use serde::Serialize;
+16 -1
spacedust/src/main.rs
···
use spacedust::error::MainTaskError;
use spacedust::consumer;
use spacedust::server;
+
use spacedust::delay;
+
use spacedust::removable_delay_queue::removable_delay_queue;
use clap::Parser;
use metrics_exporter_prometheus::PrometheusBuilder;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
+
use std::time::Duration;
/// Aggregate links in the at-mosphere
#[derive(Parser, Debug, Clone)]
···
// paths + slow subscriber -> 16GiB queue)
let (b, _) = broadcast::channel(16_384);
let consumer_sender = b.clone();
+
let (d, _) = broadcast::channel(16_384);
+
let consumer_delayed_sender = d.clone();
+
+
let delay = Duration::from_secs(21);
+
let (delay_queue_sender, delay_queue_receiver) = removable_delay_queue(delay);
let shutdown = CancellationToken::new();
···
let server_shutdown = shutdown.clone();
tasks.spawn(async move {
-
server::serve(b, server_shutdown).await?;
+
server::serve(b, d, server_shutdown).await?;
Ok(())
});
···
tasks.spawn(async move {
consumer::consume(
consumer_sender,
+
delay_queue_sender,
args.jetstream,
None,
args.jetstream_no_zstd,
consumer_shutdown
)
.await?;
+
Ok(())
+
});
+
+
let delay_shutdown = shutdown.clone();
+
tasks.spawn(async move {
+
delay::to_broadcast(delay_queue_receiver, consumer_delayed_sender, delay_shutdown).await?;
Ok(())
});
+118
spacedust/src/removable_delay_queue.rs
···
+
use std::ops::RangeBounds;
+
use std::collections::{BTreeMap, VecDeque};
+
use std::time::{Duration, Instant};
+
use tokio::sync::Mutex;
+
use std::sync::Arc;
+
use thiserror::Error;
+
+
#[derive(Debug, Error)]
+
pub enum EnqueueError<T> {
+
#[error("queue ouput dropped")]
+
OutputDropped(T),
+
}
+
+
pub trait Key: Eq + Ord + Clone {}
+
impl<T: Eq + Ord + Clone> Key for T {}
+
+
#[derive(Debug)]
+
struct Queue<K: Key, T> {
+
queue: VecDeque<(Instant, K)>,
+
items: BTreeMap<K, T>
+
}
+
+
pub struct Input<K: Key, T> {
+
q: Arc<Mutex<Queue<K, T>>>,
+
}
+
+
impl<K: Key, T> Input<K, T> {
+
/// if a key is already present, its previous item will be overwritten and
+
/// its delay time will be reset for the new item.
+
///
+
/// errors if the remover has been dropped
+
pub async fn enqueue(&self, key: K, item: T) -> Result<(), EnqueueError<T>> {
+
if Arc::strong_count(&self.q) == 1 {
+
return Err(EnqueueError::OutputDropped(item));
+
}
+
// TODO: try to push out an old element first
+
// for now we just hope there's a listener
+
let now = Instant::now();
+
let mut q = self.q.lock().await;
+
q.queue.push_back((now, key.clone()));
+
q.items.insert(key, item);
+
Ok(())
+
}
+
/// remove an item from the queue, by key
+
///
+
/// the item itself is removed, but the key will remain in the queue -- it
+
/// will simply be skipped over when a new output item is requested. this
+
/// keeps the removal cheap (=btreemap remove), for a bit of space overhead
+
pub async fn remove_range(&self, range: impl RangeBounds<K>) {
+
let n = {
+
let mut q = self.q.lock().await;
+
let keys = q.items.range(range).map(|(k, _)| k).cloned().collect::<Vec<_>>();
+
for k in &keys {
+
q.items.remove(k);
+
}
+
keys.len()
+
};
+
if n == 0 {
+
metrics::counter!("delay_queue_remove_not_found").increment(1);
+
} else {
+
metrics::counter!("delay_queue_remove_total_records").increment(1);
+
metrics::counter!("delay_queue_remove_total_links").increment(n as u64);
+
}
+
}
+
}
+
+
pub struct Output<K: Key, T> {
+
delay: Duration,
+
q: Arc<Mutex<Queue<K, T>>>,
+
}
+
+
impl<K: Key, T> Output<K, T> {
+
pub async fn next(&self) -> Option<T> {
+
let get = || async {
+
let mut q = self.q.lock().await;
+
while let Some((t, k)) = q.queue.pop_front() {
+
// skip over queued keys that were removed from items
+
if let Some(item) = q.items.remove(&k) {
+
return Some((t, item));
+
}
+
}
+
None
+
};
+
loop {
+
if let Some((t, item)) = get().await {
+
let expected_release = t + self.delay;
+
let now = Instant::now();
+
if expected_release > now {
+
tokio::time::sleep_until(expected_release.into()).await;
+
metrics::counter!("delay_queue_emit_total", "early" => "yes").increment(1);
+
} else {
+
metrics::counter!("delay_queue_emit_total", "early" => "no").increment(1);
+
let overshoot = now - expected_release;
+
metrics::histogram!("delay_queue_emit_overshoot").record(overshoot.as_secs_f64());
+
}
+
return Some(item)
+
} else if Arc::strong_count(&self.q) == 1 {
+
return None;
+
}
+
// the queue is *empty*, so we need to wait at least as long as the current delay
+
tokio::time::sleep(self.delay).await;
+
metrics::counter!("delay_queue_entirely_empty_total").increment(1);
+
};
+
}
+
}
+
+
pub fn removable_delay_queue<K: Key, T>(
+
delay: Duration,
+
) -> (Input<K, T>, Output<K, T>) {
+
let q: Arc<Mutex<Queue<K, T>>> = Arc::new(Mutex::new(Queue {
+
queue: VecDeque::new(),
+
items: BTreeMap::new(),
+
}));
+
+
let input = Input::<K, T> { q: q.clone() };
+
let output = Output::<K, T> { q, delay };
+
(input, output)
+
}
+20 -4
spacedust/src/server.rs
···
const INDEX_HTML: &str = include_str!("../static/index.html");
const FAVICON: &[u8] = include_bytes!("../static/favicon.ico");
-
pub async fn serve(b: broadcast::Sender<LinkEvent>, shutdown: CancellationToken) -> Result<(), ServerError> {
+
pub async fn serve(
+
b: broadcast::Sender<LinkEvent>,
+
d: broadcast::Sender<LinkEvent>,
+
shutdown: CancellationToken
+
) -> Result<(), ServerError> {
let config_logging = ConfigLogging::StderrTerminal {
level: ConfigLoggingLevel::Info,
};
···
);
let sub_shutdown = shutdown.clone();
-
let ctx = Context { spec, b, shutdown: sub_shutdown };
+
let ctx = Context { spec, b, d, shutdown: sub_shutdown };
let server = ServerBuilder::new(api, ctx, log)
.config(ConfigDropshot {
···
struct Context {
pub spec: Arc<serde_json::Value>,
pub b: broadcast::Sender<LinkEvent>,
+
pub d: broadcast::Sender<LinkEvent>,
pub shutdown: CancellationToken,
}
···
}
}
+
#[derive(Deserialize, JsonSchema)]
+
#[serde(rename_all = "camelCase")]
+
struct ScalarSubscribeQuery {
+
#[serde(default)]
+
pub instant: bool,
+
}
+
#[channel {
protocol = WEBSOCKETS,
path = "/subscribe",
···
async fn subscribe(
reqctx: RequestContext<Context>,
query: MultiSubscribeQuery,
+
scalar_query: Query<ScalarSubscribeQuery>,
upgraded: WebsocketConnection,
) -> dropshot::WebsocketChannelResult {
let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
···
)
.await;
-
let Context { b, shutdown, .. } = reqctx.context();
+
let Context { b, d, shutdown, .. } = reqctx.context();
let sub_token = shutdown.child_token();
-
let subscription = b.subscribe();
+
+
let q = scalar_query.into_inner();
+
let subscription = if q.instant { b } else { d }.subscribe();
+
log::info!("starting subscriber with broadcast: instant={}", q.instant);
Subscriber::new(query, sub_token)
.start(ws, subscription)
+3 -4
spacedust/src/subscriber.rs
···
query: MultiSubscribeQuery,
shutdown: CancellationToken,
) -> Self {
-
log::warn!("new sub...");
Self { query, shutdown }
}
···
ws: WebSocketStream<WebsocketConnectionRaw>,
mut receiver: broadcast::Receiver<LinkEvent>
) -> Result<(), Box<dyn Error>> {
-
log::warn!("starting new sub...");
let mut ping_state = None;
let (mut ws_sender, mut ws_receiver) = ws.split();
let mut ping_interval = interval(PING_PERIOD);
···
// TODO: do we need to timeout ws sends??
-
metrics::gauge!("subscribers_connected_total").increment(1);
+
metrics::counter!("subscribers_connected_total").increment(1);
+
metrics::gauge!("subscribers_connected").increment(1);
loop {
tokio::select! {
···
}
}
log::trace!("end of subscriber. bye!");
-
metrics::gauge!("subscribers_connected_total").decrement(1);
+
metrics::gauge!("subscribers_connected").decrement(1);
Ok(())
}