use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Context as _;
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitOp {
Upsert,
Delete,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommitEvent {
pub did: String,
pub collection: String,
pub rkey: String,
pub op: CommitOp,
pub record: Option<Value>,
pub cid: Option<String>,
pub rev: String,
pub time_us: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AccountEvent {
pub did: String,
pub active: bool,
pub deleted: bool,
pub time_us: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityEvent {
pub did: String,
pub handle: Option<String>,
pub time_us: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FirehoseEvent {
Commit(CommitEvent),
Account(AccountEvent),
Identity(IdentityEvent),
}
impl FirehoseEvent {
pub fn time_us(&self) -> i64 {
match self {
FirehoseEvent::Commit(e) => e.time_us,
FirehoseEvent::Account(e) => e.time_us,
FirehoseEvent::Identity(e) => e.time_us,
}
}
}
#[async_trait]
pub trait EventSink: Send + Sync {
async fn handle(&self, event: FirehoseEvent) -> anyhow::Result<()>;
async fn handle_hinted(&self, event: FirehoseEvent) -> anyhow::Result<()> {
self.handle(event).await
}
fn progress(&self) -> String {
String::new()
}
}
#[async_trait]
pub trait Progress: Send + Sync {
async fn load(&self, key: &str) -> anyhow::Result<Option<String>>;
async fn save(&self, key: &str, value: &str) -> anyhow::Result<()>;
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct TapRecord {
pub live: bool,
pub did: String,
pub rev: String,
pub collection: String,
pub rkey: String,
pub action: String,
#[serde(default)]
pub record: Option<Value>,
#[serde(default)]
pub cid: Option<String>,
#[serde(default)]
pub hinted: bool,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct TapIdentity {
pub did: String,
#[serde(default)]
pub handle: String,
pub is_active: bool,
#[serde(default)]
pub status: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TapFrame {
Record { id: u64, record: TapRecord },
Identity { id: u64, identity: TapIdentity },
Unknown { id: u64, kind: String },
}
impl TapFrame {
pub fn id(&self) -> u64 {
match self {
TapFrame::Record { id, .. } | TapFrame::Identity { id, .. } | TapFrame::Unknown { id, .. } => *id,
}
}
pub fn is_live(&self) -> bool {
match self {
TapFrame::Record { record, .. } => record.live,
TapFrame::Identity { .. } | TapFrame::Unknown { .. } => true,
}
}
}
#[derive(Deserialize)]
struct WireFrame {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
record: Option<TapRecord>,
#[serde(default)]
identity: Option<TapIdentity>,
}
#[derive(Debug)]
pub enum FrameError {
Unreadable(String),
Malformed { id: u64, kind: String, reason: String },
}
impl fmt::Display for FrameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FrameError::Unreadable(reason) => write!(f, "unreadable Tap frame: {reason}"),
FrameError::Malformed { id, kind, reason } => write!(f, "malformed Tap {kind} frame {id}: {reason}"),
}
}
}
impl std::error::Error for FrameError {}
pub fn parse_frame(text: &str) -> Result<TapFrame, FrameError> {
let raw: Value = serde_json::from_str(text).map_err(|e| FrameError::Unreadable(e.to_string()))?;
let id = raw["id"].as_u64().ok_or_else(|| FrameError::Unreadable("no numeric id".into()))?;
let kind = raw["type"].as_str().unwrap_or_default().to_owned();
let malformed = |reason: String| FrameError::Malformed { id, kind: kind.clone(), reason };
let wire: WireFrame = serde_json::from_value(raw).map_err(|e| malformed(e.to_string()))?;
match wire.kind.as_str() {
"record" => {
let record = wire.record.ok_or_else(|| malformed("no `record` object".into()))?;
Ok(TapFrame::Record { id, record })
}
"identity" => {
let identity = wire.identity.ok_or_else(|| malformed("no `identity` object".into()))?;
Ok(TapFrame::Identity { id, identity })
}
_ => Ok(TapFrame::Unknown { id, kind: wire.kind }),
}
}
pub fn ack_message(id: u64) -> String {
format!(r#"{{"type":"ack","id":{id}}}"#)
}
pub fn events(frame: &TapFrame) -> Vec<FirehoseEvent> {
match frame {
TapFrame::Record { record: r, .. } => {
let op = match r.action.as_str() {
"create" | "update" => CommitOp::Upsert,
"delete" => CommitOp::Delete,
other => {
tracing::warn!(action = other, did = %r.did, "a Tap record frame with an unknown action; skipped");
return Vec::new();
}
};
let upsert = op == CommitOp::Upsert;
vec![FirehoseEvent::Commit(CommitEvent {
did: r.did.clone(),
collection: r.collection.clone(),
rkey: r.rkey.clone(),
op,
record: if upsert { r.record.clone() } else { None },
cid: if upsert { r.cid.clone() } else { None },
rev: r.rev.clone(),
time_us: tid_time_us(&r.rev).unwrap_or_else(now_us),
})]
}
TapFrame::Identity { identity: i, .. } => {
let time_us = now_us();
let mut out = vec![FirehoseEvent::Account(AccountEvent { did: i.did.clone(), active: i.is_active, deleted: i.status == "deleted", time_us })];
if !i.handle.is_empty() {
out.push(FirehoseEvent::Identity(IdentityEvent { did: i.did.clone(), handle: Some(i.handle.clone()), time_us }));
}
out
}
TapFrame::Unknown { kind, id } => {
tracing::warn!(%kind, id, "a Tap frame of a type this reader does not know; acked and skipped");
Vec::new()
}
}
}
pub fn tid_time_us(tid: &str) -> Option<i64> {
const ALPHABET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
if tid.len() != 13 {
return None;
}
let mut n: u64 = 0;
for c in tid.bytes() {
let digit = ALPHABET.iter().position(|&a| a == c)? as u64;
n = n.checked_mul(32)?.checked_add(digit)?;
}
if n >> 63 != 0 {
return None;
}
Some((n >> 10) as i64)
}
fn now_us() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as i64).unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Incoming {
Text(String),
Control,
}
#[async_trait]
pub trait TapConnection: Send {
async fn next_frame(&mut self) -> Option<anyhow::Result<Incoming>>;
async fn ack(&mut self, id: u64) -> anyhow::Result<()>;
async fn ping(&mut self) -> anyhow::Result<()>;
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TapKeepalive {
pub ping_every: Duration,
pub idle_timeout: Duration,
pub connect_timeout: Duration,
pub backoff_min: Duration,
pub backoff_max: Duration,
pub ack_max_frames: usize,
pub ack_max_wait: Duration,
}
impl Default for TapKeepalive {
fn default() -> Self {
Self {
ping_every: Duration::from_secs(30),
idle_timeout: Duration::from_secs(90),
connect_timeout: Duration::from_secs(30),
backoff_min: Duration::from_secs(1),
backoff_max: Duration::from_secs(60),
ack_max_frames: 256,
ack_max_wait: Duration::from_millis(500),
}
}
}
impl TapKeepalive {
pub fn backoff(&self, failures: u32) -> Duration {
self.backoff_min.saturating_mul(2u32.saturating_pow(failures.min(16))).min(self.backoff_max)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TapStats {
pub acked: u64,
pub historical: u64,
pub live: u64,
pub identity: u64,
pub skipped: u64,
}
#[derive(Default)]
struct PendingAcks {
ids: Vec<u64>,
counted: u64,
wrote: bool,
since: Option<tokio::time::Instant>,
}
impl PendingAcks {
fn push(&mut self, id: u64, counted: bool, wrote: bool) {
self.ids.push(id);
self.counted += u64::from(counted);
self.wrote |= wrote;
self.since.get_or_insert_with(tokio::time::Instant::now);
}
fn is_empty(&self) -> bool {
self.ids.is_empty()
}
fn due(&self, keepalive: &TapKeepalive) -> bool {
self.ids.len() >= keepalive.ack_max_frames.max(1) || self.since.is_some_and(|s| s.elapsed() >= keepalive.ack_max_wait)
}
async fn flush(&mut self, conn: &mut dyn TapConnection, durable: &dyn Progress, key: &str, stats: &mut TapStats) -> anyhow::Result<()> {
let batch = std::mem::take(self);
let Some(&newest) = batch.ids.last() else { return Ok(()) };
if batch.wrote {
durable.save(key, &newest.to_string()).await.map_err(|e| e.context("tap: the durability barrier before the ack"))?;
}
for id in batch.ids {
conn.ack(id).await?;
}
stats.acked += batch.counted;
Ok(())
}
async fn ack_handled_before(&mut self, error: anyhow::Error, conn: &mut dyn TapConnection, durable: &dyn Progress, key: &str, stats: &mut TapStats) -> anyhow::Error {
if let Err(flush) = self.flush(conn, durable, key, stats).await {
tracing::warn!(error = %format!("{flush:#}"), "tap: the frames handled before the failure were not acked (Tap sends them again)");
}
error
}
}
pub async fn run_connection(
conn: &mut dyn TapConnection,
sink: &dyn EventSink,
durable: &dyn Progress,
durable_key: &str,
wanted: Option<&HashSet<String>>,
keepalive: &TapKeepalive,
stats: &mut TapStats,
) -> anyhow::Result<()> {
let mut last = tokio::time::Instant::now();
let mut pending = PendingAcks::default();
loop {
let next = if pending.is_empty() {
next_text(conn, keepalive, &mut last).await
} else {
match tokio::time::timeout(Duration::ZERO, next_text(conn, keepalive, &mut last)).await {
Ok(next) => next,
Err(_) => {
pending.flush(conn, durable, durable_key, stats).await?;
last = tokio::time::Instant::now();
continue;
}
}
};
let text = match next {
None => return pending.flush(conn, durable, durable_key, stats).await,
Some(Ok(text)) => text,
Some(Err(e)) => return Err(pending.ack_handled_before(e, conn, durable, durable_key, stats).await),
};
match parse_frame(&text) {
Ok(frame) => {
let unwanted = match (&frame, wanted) {
(TapFrame::Record { record, .. }, Some(w)) => !w.contains(&record.collection),
_ => false,
};
let evs = if unwanted { Vec::new() } else { events(&frame) };
let ingest = !evs.is_empty();
let hinted = matches!(&frame, TapFrame::Record { record, .. } if record.hinted);
for ev in evs {
let handled = if hinted { sink.handle_hinted(ev).await } else { sink.handle(ev).await };
if let Err(e) = handled {
return Err(pending.ack_handled_before(e, conn, durable, durable_key, stats).await);
}
}
pending.push(frame.id(), true, ingest);
match &frame {
TapFrame::Record { record, .. } if ingest && record.live => stats.live += 1,
TapFrame::Record { .. } if ingest => stats.historical += 1,
TapFrame::Record { .. } => stats.skipped += 1,
TapFrame::Identity { .. } => stats.identity += 1,
TapFrame::Unknown { .. } => stats.skipped += 1,
}
}
Err(FrameError::Malformed { id, kind, reason }) => {
tracing::error!(id, %kind, %reason, "a malformed Tap frame; acked and skipped (its data is lost)");
pending.push(id, false, false);
stats.skipped += 1;
}
Err(e @ FrameError::Unreadable(_)) => {
tracing::error!(error = %e, frame = %text.chars().take(200).collect::<String>(), "an unreadable Tap frame; not acked");
}
}
last = tokio::time::Instant::now();
if pending.due(keepalive) {
pending.flush(conn, durable, durable_key, stats).await?;
last = tokio::time::Instant::now();
}
}
}
pub async fn next_text(conn: &mut dyn TapConnection, keepalive: &TapKeepalive, last: &mut tokio::time::Instant) -> Option<anyhow::Result<String>> {
loop {
let next = match tokio::time::timeout(keepalive.ping_every, conn.next_frame()).await {
Ok(next) => next,
Err(_) => {
if last.elapsed() >= keepalive.idle_timeout {
return Some(Err(anyhow::anyhow!("tap: no frame for {:?}; the connection is dead", last.elapsed())));
}
match tokio::time::timeout(keepalive.ping_every, conn.ping()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => return Some(Err(e)),
Err(_) => return Some(Err(anyhow::anyhow!("tap ping: not sent within {:?}", keepalive.ping_every))),
}
continue;
}
};
let item = next?;
*last = tokio::time::Instant::now();
match item {
Ok(Incoming::Text(text)) => return Some(Ok(text)),
Ok(Incoming::Control) => continue,
Err(e) => return Some(Err(e)),
}
}
}
pub fn channel_url(base: &str) -> String {
let base = base.trim().trim_end_matches('/');
let base = if let Some(rest) = base.strip_prefix("http://") {
format!("ws://{rest}")
} else if let Some(rest) = base.strip_prefix("https://") {
format!("wss://{rest}")
} else {
base.to_owned()
};
let path = base.split_once("://").map_or(base.as_str(), |(_, r)| r).split_once('/').map_or("", |(_, p)| p);
if path == "channel" || path.ends_with("/channel") || path.starts_with("channel/") || path.contains("/channel/") {
base
} else {
format!("{base}/channel")
}
}
pub fn tap_urls(raw: Option<&str>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for u in raw.unwrap_or_default().split(|c: char| c == ',' || c.is_whitespace()).filter(|s| !s.is_empty()) {
let u = channel_url(u);
if !out.contains(&u) {
out.push(u);
}
}
out
}
type WsStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
struct WsConnection(WsStream);
#[async_trait]
impl TapConnection for WsConnection {
async fn next_frame(&mut self) -> Option<anyhow::Result<Incoming>> {
use tokio_tungstenite::tungstenite::Message;
let msg = match self.0.next().await? {
Ok(m) => m,
Err(e) => return Some(Err(anyhow::anyhow!("tap read: {e}"))),
};
match msg {
Message::Text(text) => Some(Ok(Incoming::Text(text.as_str().to_owned()))),
Message::Close(_) => None,
_ => Some(Ok(Incoming::Control)),
}
}
async fn ack(&mut self, id: u64) -> anyhow::Result<()> {
self.0
.send(tokio_tungstenite::tungstenite::Message::text(ack_message(id)))
.await
.map_err(|e| anyhow::anyhow!("tap ack {id}: {e}"))
}
async fn ping(&mut self) -> anyhow::Result<()> {
self.0
.send(tokio_tungstenite::tungstenite::Message::Ping("keepalive".into()))
.await
.map_err(|e| anyhow::anyhow!("tap ping: {e}"))
}
}
pub async fn connect(url: &str, timeout: Duration) -> anyhow::Result<impl TapConnection> {
match tokio::time::timeout(timeout, tokio_tungstenite::connect_async(url)).await {
Ok(Ok((ws, _resp))) => Ok(WsConnection(ws)),
Ok(Err(e)) => Err(anyhow::anyhow!("tap connect: {e}")),
Err(_) => Err(anyhow::anyhow!("tap connect: no answer within {timeout:?}")),
}
}
pub fn durable_key(reader: &str, url: &str) -> String {
format!("tap:{reader}@{url}")
}
pub async fn follow_tap(
url: String,
wanted: Option<Arc<HashSet<String>>>,
sink: Arc<dyn EventSink>,
durable: Arc<dyn Progress>,
durable_key: String,
keepalive: TapKeepalive,
) {
let mut failures = 0u32;
loop {
let mut stats = TapStats::default();
let outcome = match connect(&url, keepalive.connect_timeout).await {
Ok(mut conn) => {
tracing::info!(%url, "following tap");
run_connection(&mut conn, sink.as_ref(), durable.as_ref(), &durable_key, wanted.as_deref(), &keepalive, &mut stats).await
}
Err(error) => Err(error),
};
if stats.acked > 0 {
failures = 0;
}
let wait = keepalive.backoff(failures);
failures = failures.saturating_add(1);
match outcome {
Ok(()) => tracing::warn!(%url, ?stats, wait_ms = wait.as_millis() as u64, progress = %sink.progress(), "tap connection closed; reconnecting"),
Err(error) => tracing::error!(%url, ?stats, wait_ms = wait.as_millis() as u64, error = %format!("{error:#}"),
"tap lane lost its connection; reconnecting (Tap resends what was not acked)"),
}
tokio::time::sleep(wait).await;
}
}
pub async fn follow_taps(urls: Vec<String>, wanted: &[String], sink: Arc<dyn EventSink>, durable: Arc<dyn Progress>, reader: &str) -> anyhow::Result<()> {
if urls.is_empty() {
tracing::warn!("no Tap configured: no live ingest");
std::future::pending::<()>().await;
}
let wanted = (!wanted.is_empty()).then(|| Arc::new(wanted.iter().cloned().collect::<HashSet<String>>()));
let mut tasks = tokio::task::JoinSet::new();
for url in urls {
let (sink, wanted, durable, key) = (sink.clone(), wanted.clone(), durable.clone(), durable_key(reader, &url));
tasks.spawn(async move {
follow_tap(url.clone(), wanted, sink, durable, key, TapKeepalive::default()).await;
anyhow::bail!("the tap lane {url} stopped")
});
}
let error = match tasks.join_next().await {
Some(Ok(Ok(()))) => anyhow::anyhow!("a task that runs for the life of the process returned"),
Some(Ok(Err(error))) => error,
Some(Err(join)) => anyhow::Error::new(join).context("a task panicked or was cancelled"),
None => anyhow::anyhow!("no task was running"),
};
Err(error).context("a Tap lane ended")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn channel_urls_take_any_base_form() {
assert_eq!(channel_url("ws://tap:2480"), "ws://tap:2480/channel");
assert_eq!(channel_url("http://tap:2480/"), "ws://tap:2480/channel");
assert_eq!(channel_url("https://tap.example"), "wss://tap.example/channel");
assert_eq!(channel_url("ws://t:1/channel"), "ws://t:1/channel");
assert_eq!(channel_url("ws://f:2480/channel/public/indexer"), "ws://f:2480/channel/public/indexer");
assert_eq!(channel_url("wss://x.example/tap"), "wss://x.example/tap/channel");
assert_eq!(tap_urls(Some("ws://a:2480, http://a:2480/channel ws://b:2480")), vec!["ws://a:2480/channel", "ws://b:2480/channel"]);
assert!(tap_urls(None).is_empty());
}
#[test]
fn the_ack_is_tap_s_ws_response() {
let v: Value = serde_json::from_str(&ack_message(42)).unwrap();
assert_eq!(v, serde_json::json!({"type": "ack", "id": 42}));
}
#[test]
fn an_identity_with_no_handle_never_clears_one() {
let f = parse_frame(r#"{"id":7,"type":"identity","identity":{"did":"did:plc:x","handle":"","is_active":false,"status":"takendown"}}"#).unwrap();
let evs = events(&f);
assert_eq!(evs.len(), 1);
assert!(matches!(&evs[0], FirehoseEvent::Account(a) if !a.active && a.did == "did:plc:x"));
}
#[test]
fn unknown_types_and_actions_ingest_nothing() {
let f = parse_frame(r#"{"id":9,"type":"sync","sync":{}}"#).unwrap();
assert_eq!(f, TapFrame::Unknown { id: 9, kind: "sync".into() });
assert!(events(&f).is_empty());
let f = parse_frame(r#"{"id":10,"type":"record","record":{"live":true,"did":"d","rev":"r","collection":"c","rkey":"k","action":"rename"}}"#).unwrap();
assert!(events(&f).is_empty());
assert!(matches!(parse_frame("{\"type\":\"record\"}"), Err(FrameError::Unreadable(_))));
assert!(matches!(parse_frame(r#"{"id":3,"type":"record"}"#), Err(FrameError::Malformed { id: 3, .. })));
}
#[test]
fn a_record_maps_to_a_commit_timed_by_its_revision() {
let f = parse_frame(r#"{"id":4,"type":"record","record":{"live":false,"did":"did:plc:a","rev":"3m2aaaaaaaaa2","collection":"c.x","rkey":"k","action":"create","record":{"n":1},"cid":"bafy"}}"#).unwrap();
assert!(!f.is_live());
let evs = events(&f);
let FirehoseEvent::Commit(c) = &evs[0] else { panic!("{evs:?}") };
assert_eq!((c.op, c.record.clone(), c.cid.as_deref()), (CommitOp::Upsert, Some(serde_json::json!({"n": 1})), Some("bafy")));
assert_eq!(Some(c.time_us), tid_time_us("3m2aaaaaaaaa2"));
let f = parse_frame(r#"{"id":5,"type":"record","record":{"live":true,"did":"d","rev":"3m2aaaaaaaab2","collection":"c","rkey":"k","action":"delete","record":{},"cid":"x"}}"#).unwrap();
assert!(matches!(&events(&f)[0], FirehoseEvent::Commit(c) if c.op == CommitOp::Delete && c.record.is_none() && c.cid.is_none()));
let f = parse_frame(r#"{"id":6,"type":"identity","identity":{"did":"d","handle":"h.test","is_active":false,"status":"deleted"}}"#).unwrap();
let evs = events(&f);
assert!(matches!(&evs[0], FirehoseEvent::Account(a) if a.deleted && !a.active));
assert!(matches!(&evs[1], FirehoseEvent::Identity(i) if i.handle.as_deref() == Some("h.test")));
}
#[test]
fn backoff_doubles_to_its_cap() {
let k = TapKeepalive::default();
assert_eq!((k.backoff(0), k.backoff(1), k.backoff(3), k.backoff(99)), (Duration::from_secs(1), Duration::from_secs(2), Duration::from_secs(8), Duration::from_secs(60)));
}
#[derive(Default)]
struct Recorder {
log: Arc<Mutex<Vec<String>>>,
fail_rkey: Option<String>,
}
impl Recorder {
fn line(event: &FirehoseEvent) -> String {
match event {
FirehoseEvent::Commit(c) => format!("commit {} {:?}", c.rkey, c.op),
FirehoseEvent::Account(a) => format!("account {} {}", a.did, a.active),
FirehoseEvent::Identity(i) => format!("identity {} {}", i.did, i.handle.clone().unwrap_or_default()),
}
}
}
#[async_trait]
impl EventSink for Recorder {
async fn handle(&self, event: FirehoseEvent) -> anyhow::Result<()> {
if matches!(&event, FirehoseEvent::Commit(c) if Some(&c.rkey) == self.fail_rkey.as_ref()) {
anyhow::bail!("the sink refused");
}
self.log.lock().unwrap().push(Self::line(&event));
Ok(())
}
async fn handle_hinted(&self, event: FirehoseEvent) -> anyhow::Result<()> {
self.log.lock().unwrap().push(format!("hinted {}", Self::line(&event)));
Ok(())
}
}
struct Barrier(Arc<Mutex<Vec<String>>>);
#[async_trait]
impl Progress for Barrier {
async fn load(&self, _key: &str) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn save(&self, key: &str, value: &str) -> anyhow::Result<()> {
self.0.lock().unwrap().push(format!("save {key}={value}"));
Ok(())
}
}
struct Scripted {
frames: std::collections::VecDeque<String>,
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl TapConnection for Scripted {
async fn next_frame(&mut self) -> Option<anyhow::Result<Incoming>> {
self.frames.pop_front().map(|f| Ok(Incoming::Text(f)))
}
async fn ack(&mut self, id: u64) -> anyhow::Result<()> {
self.log.lock().unwrap().push(format!("ack {id}"));
Ok(())
}
async fn ping(&mut self) -> anyhow::Result<()> {
Ok(())
}
}
fn record_frame(id: u64, collection: &str, rkey: &str, hinted: bool) -> String {
serde_json::json!({"id": id, "type": "record", "record": {"live": true, "did": "did:plc:a", "rev": "3m2aaaaaaaaa2",
"collection": collection, "rkey": rkey, "action": "create", "record": {}, "cid": "bafy", "hinted": hinted}})
.to_string()
}
#[tokio::test]
async fn the_barrier_comes_before_the_acks_and_a_refused_frame_is_not_acked() {
let log = Arc::new(Mutex::new(Vec::new()));
let frames = [
record_frame(1, "c.want", "a", false),
record_frame(2, "c.other", "b", false),
"not json".to_string(),
r#"{"id":3,"type":"record"}"#.to_string(),
record_frame(4, "c.want", "h", true),
record_frame(5, "c.want", "boom", false),
record_frame(6, "c.want", "never", false),
];
let mut conn = Scripted { frames: frames.into_iter().collect(), log: log.clone() };
let sink = Recorder { log: log.clone(), fail_rkey: Some("boom".into()) };
let wanted: HashSet<String> = ["c.want".to_string()].into();
let mut stats = TapStats::default();
let err = run_connection(&mut conn, &sink, &Barrier(log.clone()), "k", Some(&wanted), &TapKeepalive::default(), &mut stats).await.unwrap_err();
assert_eq!(err.to_string(), "the sink refused");
assert_eq!(
*log.lock().unwrap(),
["commit a Upsert", "hinted commit h Upsert", "save k=4", "ack 1", "ack 2", "ack 3", "ack 4"].map(String::from).to_vec()
);
assert_eq!(stats, TapStats { acked: 3, historical: 0, live: 2, identity: 0, skipped: 2 });
}
#[tokio::test]
async fn frames_that_wrote_nothing_need_no_barrier() {
let log = Arc::new(Mutex::new(Vec::new()));
let mut conn = Scripted { frames: [r#"{"id":9,"type":"sync"}"#.to_string()].into_iter().collect(), log: log.clone() };
let mut stats = TapStats::default();
run_connection(&mut conn, &Recorder::default(), &Barrier(log.clone()), "k", None, &TapKeepalive::default(), &mut stats).await.unwrap();
assert_eq!(*log.lock().unwrap(), vec!["ack 9".to_string()]);
}
async fn fake_tap(frames: Vec<String>) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<Vec<Value>>) {
use tokio_tungstenite::tungstenite::Message;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
for f in &frames {
ws.send(Message::text(f.clone())).await.unwrap();
}
let mut acks = Vec::new();
while acks.len() < frames.len() {
match ws.next().await {
Some(Ok(Message::Text(t))) => acks.push(serde_json::from_str::<Value>(t.as_str()).unwrap()),
Some(Ok(_)) => {}
_ => break,
}
}
let _ = ws.close(None).await;
let _ = tx.send(acks);
});
(addr, rx)
}
fn two_frames() -> Vec<String> {
vec![
r#"{"id":11,"type":"record","record":{"live":false,"did":"did:plc:a","rev":"3m2aaaaaaaaa2","collection":"c.x","rkey":"r1","action":"create","record":{"n":1},"cid":"bafy"}}"#.into(),
r#"{"id":12,"type":"identity","identity":{"did":"did:plc:a","handle":"a.test","is_active":true,"status":"active"}}"#.into(),
]
}
fn assert_delivered_then_saved(log: &[String], key: &str) {
let events: Vec<&str> = log.iter().map(String::as_str).filter(|l| !l.starts_with("save ")).collect();
assert_eq!(events, ["commit r1 Upsert", "account did:plc:a true", "identity did:plc:a a.test"], "{log:?}");
assert_eq!(log.last().map(String::as_str), Some(format!("save {key}=12").as_str()), "{log:?}");
}
#[tokio::test]
async fn run_connection_over_a_websocket_delivers_then_acks() {
let (addr, acks) = fake_tap(two_frames()).await;
let log = Arc::new(Mutex::new(Vec::new()));
let mut conn = connect(&format!("ws://{addr}/channel"), Duration::from_secs(5)).await.unwrap();
let mut stats = TapStats::default();
let sink = Recorder { log: log.clone(), fail_rkey: None };
tokio::time::timeout(Duration::from_secs(10), run_connection(&mut conn, &sink, &Barrier(log.clone()), "tap:t@x", None, &TapKeepalive::default(), &mut stats))
.await
.expect("the connection ends when the Tap closes")
.unwrap();
assert_delivered_then_saved(&log.lock().unwrap(), "tap:t@x");
assert_eq!(acks.await.unwrap(), vec![serde_json::json!({"type": "ack", "id": 11}), serde_json::json!({"type": "ack", "id": 12})]);
assert_eq!(stats, TapStats { acked: 2, historical: 1, live: 0, identity: 1, skipped: 0 });
}
#[tokio::test]
async fn wss_needs_no_installed_crypto_provider() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
drop(tcp);
});
let refused = connect(&format!("wss://{addr}/channel"), Duration::from_secs(5)).await;
assert!(refused.is_err());
}
#[tokio::test]
async fn follow_tap_delivers_and_acks() {
let (addr, acks) = fake_tap(two_frames()).await;
let log = Arc::new(Mutex::new(Vec::new()));
let sink: Arc<dyn EventSink> = Arc::new(Recorder { log: log.clone(), fail_rkey: None });
let durable: Arc<dyn Progress> = Arc::new(Barrier(log.clone()));
let wanted = Some(Arc::new(HashSet::from(["c.x".to_string()])));
let lane = tokio::spawn(follow_tap(channel_url(&format!("http://{addr}")), wanted, sink, durable, durable_key("t", "x"), TapKeepalive::default()));
let acks = tokio::time::timeout(Duration::from_secs(10), acks).await.expect("the Tap got its acks").unwrap();
lane.abort();
assert_eq!(acks, vec![serde_json::json!({"type": "ack", "id": 11}), serde_json::json!({"type": "ack", "id": 12})]);
assert_delivered_then_saved(&log.lock().unwrap(), "tap:t@x");
}
}