chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
Jetstream: give up a silent connection, and resume from the index
A connection gone half-open delivered nothing and never ended, so the watcher waited on it forever: prod stopped indexing for three hours. Now a stream silent through a ping is dropped and followed again, and Jetstream's cursor is kept in the index, so a restart replays what it missed. CLAUDE.md: land changes as pulls rather than on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 5 changed files with 171 additions and 11 deletions
CLAUDE.md+8−0
| @@ -40,3 +40,11 @@ work is done. | ||
`cargo fmt` can put a `?` on a line of its own, which coverage then counts | ||
as a line never run: bind the value first, or use a named function for a | ||
row mapper, rather than lowering the gate. | ||
| ||
## Landing a change | ||
| ||
Open a pull rather than committing to `main`: push the work to a branch | ||
named for it (`web/mermaid`, `serve/jetstream`), then `knotless pull | ||
knotless://chadtmiller.com/knotless main knotless://chadtmiller.com/knotless | ||
<branch> "<title>" -m "<body>"`. It is merged into `main` and deployed from | ||
there. | ||
crates/knotless-serve/src/firehose.rs+34−3
| @@ -3,12 +3,42 @@ | ||
//! refreshes them, so the next clone needs no round trip to the PDS. | ||
| ||
use crate::App; | ||
use futures::StreamExt; | ||
use futures::{SinkExt, StreamExt}; | ||
use knotless::records::nsid; | ||
use std::sync::Arc; | ||
use std::sync::atomic::Ordering; | ||
use std::time::Duration; | ||
use tokio_tungstenite::tungstenite::Message; | ||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; | ||
| ||
/// How long a stream may stay silent before it is pinged, and then before | ||
/// it is given up on. | ||
pub const IDLE: Duration = Duration::from_secs(60); | ||
| ||
/// A connection to a firehose or Jetstream. | ||
pub type Socket = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>; | ||
| ||
/// Hands `each` the frames of `ws` until it ends, fails, or stays silent | ||
/// through a ping: a connection gone half-open delivers nothing and never | ||
/// ends, so without one a watcher waits on it forever. | ||
pub async fn frames(ws: &mut Socket, idle: Duration, mut each: impl FnMut(Message)) { | ||
let mut pinged = false; | ||
loop { | ||
match tokio::time::timeout(idle, ws.next()).await { | ||
Ok(Some(Ok(msg))) => { | ||
pinged = false; | ||
each(msg); | ||
} | ||
Ok(_) => return, | ||
Err(_) if pinged => return, | ||
// A failed send shows as silence on the next wait. | ||
Err(_) => { | ||
pinged = true; | ||
let _ = ws.send(Message::Ping(Default::default())).await; | ||
} | ||
} | ||
} | ||
} | ||
| ||
/// What a firehose frame says happened. | ||
#[derive(Debug, Default, PartialEq)] | ||
| @@ -102,14 +132,15 @@ pub async fn watch(app: Arc<App>, base: String) { | ||
loop { | ||
if let Ok((mut ws, _)) = tokio_tungstenite::connect_async(endpoint(&base, cursor)).await { | ||
app.watching.store(true, Ordering::Relaxed); | ||
while let Some(Ok(msg)) = ws.next().await { | ||
frames(&mut ws, IDLE, |msg| { | ||
if let Message::Binary(frame) = msg | ||
&& let Some(event) = parse(&frame) | ||
{ | ||
cursor = event.seq.or(cursor); | ||
app.observe(event); | ||
} | ||
} | ||
}) | ||
.await; | ||
} | ||
app.watching.store(false, Ordering::Relaxed); | ||
tokio::time::sleep(Duration::from_millis(500)).await; | ||
crates/knotless-serve/src/index.rs+22−1
| @@ -66,7 +66,7 @@ const VERSION: i64 = 6; | ||
const CACHE: &str = | ||
"DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; DROP TABLE IF EXISTS push; | ||
DROP TABLE IF EXISTS moved; DROP TABLE IF EXISTS block; DROP TABLE IF EXISTS label; | ||
DROP TABLE IF EXISTS labeler;"; | ||
DROP TABLE IF EXISTS labeler; DROP TABLE IF EXISTS stream;"; | ||
| ||
impl Index { | ||
pub fn open(path: &Path) -> rusqlite::Result<Index> { | ||
| @@ -106,6 +106,7 @@ impl Index { | ||
src TEXT NOT NULL, uri TEXT NOT NULL, val TEXT NOT NULL, exp TEXT, | ||
PRIMARY KEY (src, uri, val)); | ||
CREATE TABLE IF NOT EXISTS labeler (src TEXT PRIMARY KEY, seq INTEGER NOT NULL); | ||
CREATE TABLE IF NOT EXISTS stream (src TEXT PRIMARY KEY, seq INTEGER NOT NULL); | ||
CREATE TABLE IF NOT EXISTS takedown ( | ||
subject TEXT PRIMARY KEY, value TEXT NOT NULL, at TEXT NOT NULL); | ||
CREATE TABLE IF NOT EXISTS emitted (seq INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL); | ||
| @@ -364,6 +365,26 @@ impl Index { | ||
.optional() | ||
} | ||
| ||
/// Where the index was last fed to from the event stream `src`. | ||
pub fn stream_seq(&self, src: &str) -> rusqlite::Result<Option<i64>> { | ||
let db = self.db.lock().unwrap(); | ||
db.query_row("SELECT seq FROM stream WHERE src = ?1", [src], |row| { | ||
row.get(0) | ||
}) | ||
.optional() | ||
} | ||
| ||
/// Records that the index was fed to `seq` from the event stream `src`. | ||
pub fn set_stream_seq(&self, src: &str, seq: i64) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
db.execute( | ||
"INSERT INTO stream (src, seq) VALUES (?1, ?2) | ||
ON CONFLICT (src) DO UPDATE SET seq = excluded.seq", | ||
params![src, seq], | ||
)?; | ||
Ok(()) | ||
} | ||
| ||
/// Keeps a report, as `com.atproto.moderation.createReport` answers it | ||
/// without its `id`, and returns it with the `id` it was given. | ||
pub fn report(&self, mut report: Value) -> rusqlite::Result<Value> { | ||
crates/knotless-serve/src/jetstream.rs+31−7
| @@ -10,12 +10,11 @@ | ||
//! the relay it follows, and the index is a cache either way. | ||
| ||
use crate::App; | ||
use crate::firehose::{Event, Op, knotless}; | ||
use futures::StreamExt; | ||
use crate::firehose::{Event, IDLE, Op, frames, knotless}; | ||
use serde_json::Value; | ||
use std::sync::Arc; | ||
use std::sync::atomic::Ordering; | ||
use std::time::Duration; | ||
use std::time::{Duration, Instant}; | ||
use tokio_tungstenite::tungstenite::Message; | ||
use tokio_tungstenite::tungstenite::client::IntoClientRequest; | ||
use tokio_tungstenite::tungstenite::http::HeaderValue; | ||
| @@ -26,6 +25,9 @@ pub const COLLECTIONS: &str = "dev.knotless.*"; | ||
/// The one framing the endpoint speaks. | ||
const SUBPROTOCOL: &str = "xrpc.v1.json"; | ||
| ||
/// How often the index is told where the stream was read to. | ||
const SAVE: Duration = Duration::from_secs(5); | ||
| ||
/// The payload `$type`s, after this prefix, name the event kind. | ||
const KIND: &str = "network.bsky.jetstream.subscribeEvents#"; | ||
| ||
| @@ -84,8 +86,24 @@ fn text(v: &Value, name: &str) -> Option<String> { | ||
| ||
/// Follows Jetstream until the process ends, reconnecting after the last | ||
/// sequence number seen. While disconnected, mirrors fall back to the TTL. | ||
pub async fn watch(app: Arc<App>, base: String) { | ||
let mut cursor = None; | ||
pub fn watch(app: Arc<App>, base: String) -> impl Future<Output = ()> { | ||
follow(app, base, IDLE) | ||
} | ||
| ||
/// Follows Jetstream, giving a connection up after it stays silent through | ||
/// a ping `idle` long. With an index, it starts where the index was last | ||
/// fed from `base`, so a restart misses nothing Jetstream still keeps. | ||
pub async fn follow(app: Arc<App>, base: String, idle: Duration) { | ||
let index = app.index.clone(); | ||
let mut cursor = index | ||
.as_ref() | ||
.and_then(|i| i.stream_seq(&base).ok().flatten()); | ||
let mut saved: Option<Instant> = None; | ||
let save = |cursor: Option<i64>| { | ||
if let (Some(index), Some(seq)) = (&index, cursor) { | ||
let _ = index.set_stream_seq(&base, seq); | ||
} | ||
}; | ||
loop { | ||
if let Ok(mut request) = endpoint(&base, cursor).into_client_request() { | ||
request.headers_mut().insert( | ||
| @@ -94,15 +112,21 @@ pub async fn watch(app: Arc<App>, base: String) { | ||
); | ||
if let Ok((mut ws, _)) = tokio_tungstenite::connect_async(request).await { | ||
app.watching.store(true, Ordering::Relaxed); | ||
while let Some(Ok(msg)) = ws.next().await { | ||
frames(&mut ws, idle, |msg| { | ||
if let Message::Text(frame) = msg | ||
&& let Some(event) = parse(&frame) | ||
{ | ||
// The cursor is inclusive: resume after the last one. | ||
cursor = event.seq.map(|s| s + 1).or(cursor); | ||
app.observe(event); | ||
if saved.is_none_or(|at| at.elapsed() >= SAVE) { | ||
save(cursor); | ||
saved = Some(Instant::now()); | ||
} | ||
} | ||
} | ||
}) | ||
.await; | ||
save(cursor); | ||
} | ||
} | ||
app.watching.store(false, Ordering::Relaxed); | ||
crates/knotless-serve/tests/jetstream.rs+76−0
| @@ -153,6 +153,9 @@ fn the_index_fills_from_jetstream() { | ||
let (third, _) = rt.block_on(asks.recv()).unwrap(); | ||
assert!(third.ends_with("&cursor=15"), "{third}"); | ||
| ||
// The index keeps where it was fed to, for a restart to resume from. | ||
assert_eq!(index.stream_seq(&format!("ws://{addr}")).unwrap(), Some(15)); | ||
| ||
// A Jetstream it cannot reach, or cannot even name, leaves the TTL in | ||
// charge. | ||
for url in ["ws://127.0.0.1:9", "not a url"] { | ||
| @@ -165,3 +168,76 @@ fn the_index_fills_from_jetstream() { | ||
wait_until(|| !lost.watching.load(Ordering::Relaxed)); | ||
} | ||
} | ||
| ||
// The handshake callback's error type is tungstenite's, not ours. | ||
#[allow(clippy::result_large_err)] | ||
#[test] | ||
fn a_silent_jetstream_is_left() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let listener = rt | ||
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) | ||
.unwrap(); | ||
let addr = listener.local_addr().unwrap(); | ||
let (asked, mut asks) = tokio::sync::mpsc::unbounded_channel(); | ||
rt.spawn(async move { | ||
let mut held = Vec::new(); | ||
loop { | ||
let (tcp, _) = listener.accept().await.unwrap(); | ||
let asked = asked.clone(); | ||
let mut ws = | ||
tokio_tungstenite::accept_hdr_async(tcp, |req: &Request, mut res: Response| { | ||
asked.send(req.uri().to_string()).unwrap(); | ||
let protocol = req.headers()["sec-websocket-protocol"].clone(); | ||
res.headers_mut().insert("sec-websocket-protocol", protocol); | ||
Ok(res) | ||
}) | ||
.await | ||
.unwrap(); | ||
ws.send(commit( | ||
7, | ||
"did:plc:owner", | ||
"delete", | ||
"dev.knotless.issue", | ||
"3l3qo2vuowo2b", | ||
None, | ||
)) | ||
.await | ||
.unwrap(); | ||
// Never read again, so its pings go unanswered: a connection | ||
// gone half-open. | ||
held.push(ws); | ||
} | ||
}); | ||
| ||
// Restarted, it starts where the index was last fed to. | ||
let index = Index::open(&dir.path().join("index.db")).unwrap(); | ||
index.set_stream_seq(&format!("ws://{addr}"), 5).unwrap(); | ||
index.set_stream_seq(&format!("ws://{addr}"), 6).unwrap(); | ||
let app = App::build( | ||
knotless::env::Ctx::new(knotless::env::Env::from_vars(|_| None)), | ||
Duration::ZERO, | ||
None, | ||
Some(index), | ||
false, | ||
); | ||
rt.spawn(knotless_serve::jetstream::follow( | ||
app.clone(), | ||
format!("ws://{addr}"), | ||
Duration::from_millis(100), | ||
)); | ||
let first = rt.block_on(asks.recv()).unwrap(); | ||
assert!(first.ends_with("&cursor=6"), "{first}"); | ||
// Silent through a ping, it is given up on and followed again from | ||
// past what it said. | ||
let second = rt.block_on(asks.recv()).unwrap(); | ||
assert!(second.ends_with("&cursor=8"), "{second}"); | ||
assert_eq!( | ||
app.index | ||
.as_ref() | ||
.unwrap() | ||
.stream_seq(&format!("ws://{addr}")) | ||
.unwrap(), | ||
Some(8) | ||
); | ||
} | ||