1use crate::store::TaskStore;
5use futures::{SinkExt, StreamExt};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::Notify;
9use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest};
10
11const CURSOR_KEY: &str = "relay_cursor";
12const PING_EVERY: Duration = Duration::from_secs(300);
13const MAX_BACKOFF_SECS: u64 = 300;
14const STABLE_SESSION: Duration = Duration::from_secs(60);
17
18#[derive(serde::Deserialize)]
19struct Incoming {
20 #[serde(rename = "type")]
21 kind: String,
22 #[serde(default)]
23 seq: i64,
24 #[serde(default)]
25 event: String,
26 #[serde(default)]
27 action: String,
28 #[serde(default)]
29 repo: String,
30 #[serde(default)]
31 payload: Option<serde_json::Value>,
32}
33
34pub async fn run(url: String, token: String, store: Arc<TaskStore>, notify: Arc<Notify>) {
36 let mut backoff = 1;
37
38 loop {
39 let started = Instant::now();
40 match session(&url, &token, &store, ¬ify).await {
41 Ok(()) => tracing::warn!("relay stream closed, reconnecting"),
42 Err(e) => tracing::warn!(err = %e, "relay stream failed, reconnecting"),
43 }
44
45 let wait = retry_after(backoff, started.elapsed());
46 tokio::time::sleep(Duration::from_secs(wait)).await;
47 backoff = (wait * 2).min(MAX_BACKOFF_SECS);
48 }
49}
50
51fn retry_after(backoff: u64, lasted: Duration) -> u64 {
53 if lasted >= STABLE_SESSION { 1 } else { backoff }
54}
55
56async fn session(url: &str, token: &str, store: &TaskStore, notify: &Notify) -> anyhow::Result<()> {
57 let ws_url = format!("{}/ws", url.trim_end_matches('/'))
58 .replacen("https://", "wss://", 1)
59 .replacen("http://", "ws://", 1);
60 let mut request = ws_url.into_client_request()?;
61 request
62 .headers_mut()
63 .insert("authorization", format!("Bearer {token}").parse()?);
64 let (mut ws, _) = tokio_tungstenite::connect_async(request).await?;
65
66 let after = cursor(store).await;
67 let hello = serde_json::json!({ "v": 1, "type": "hello", "after": after });
68 ws.send(Message::Text(hello.to_string())).await?;
69 tracing::info!(after, "relay stream connected");
70
71 let mut ping = tokio::time::interval(PING_EVERY);
72 ping.tick().await;
73
74 loop {
75 tokio::select! {
76 _ = ping.tick() => ws.send(Message::Text("ping".to_string())).await?,
77 msg = ws.next() => match msg {
78 None => return Ok(()),
79 Some(msg) => match msg? {
80 Message::Text(text) => handle(&text, store, notify).await,
81 Message::Close(_) => return Ok(()),
82 _ => {}
83 },
84 },
85 }
86 }
87}
88
89async fn cursor(store: &TaskStore) -> i64 {
90 store
91 .kv_get(CURSOR_KEY)
92 .await
93 .ok()
94 .flatten()
95 .and_then(|v| v.parse().ok())
96 .unwrap_or(0)
97}
98
99pub(crate) async fn handle(text: &str, store: &TaskStore, notify: &Notify) {
101 if text == "pong" {
102 return;
103 }
104
105 let msg: Incoming = match serde_json::from_str(text) {
106 Ok(msg) => msg,
107 Err(e) => {
108 tracing::warn!(err = %e, "unreadable relay message");
109 return;
110 }
111 };
112
113 match msg.kind.as_str() {
114 "gap" => {
115 tracing::warn!("relay deleted events this client never read, syncing every project");
116 notify.notify_one();
117 }
118 "event" => {
119 if msg.event == "issues" && msg.action == "closed" {
121 let number = msg
122 .payload
123 .as_ref()
124 .and_then(|p| p.pointer("/issue/number"))
125 .and_then(serde_json::Value::as_i64);
126
127 if let Some(number) = number {
128 let key = format!("dep_blocker_read_at:{}#{number}", msg.repo);
129
130 if let Err(e) = store.kv_delete(&key).await {
131 tracing::warn!(err = %e, key, "failed to release a closed blocker");
132 }
133 }
134 }
135
136 if let Err(e) = store.kv_set(CURSOR_KEY, &msg.seq.to_string()).await {
137 tracing::warn!(err = %e, seq = msg.seq, "failed to store the relay cursor");
138 }
139
140 notify.notify_one();
141 }
142 _ => {}
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 async fn woke(notify: &Notify) -> bool {
151 tokio::time::timeout(Duration::from_millis(200), notify.notified())
152 .await
153 .is_ok()
154 }
155
156 #[tokio::test]
157 async fn an_event_stores_the_cursor_and_wakes_the_sync() {
158 let store = TaskStore::open_memory().await.unwrap();
159 let notify = Notify::new();
160 handle(
161 r#"{"type":"event","seq":7,"event":"pull_request","action":"opened","repo":"o/r","payload":{}}"#,
162 &store,
163 ¬ify,
164 )
165 .await;
166
167 assert_eq!(cursor(&store).await, 7);
168 assert!(woke(¬ify).await);
169 }
170
171 #[tokio::test]
172 async fn a_closed_issue_releases_its_blocker_cache_entry() {
173 let store = TaskStore::open_memory().await.unwrap();
174 store
175 .kv_set("dep_blocker_read_at:o/r#12", "1")
176 .await
177 .unwrap();
178 store
179 .kv_set("dep_blocker_read_at:o/r#13", "1")
180 .await
181 .unwrap();
182 handle(
183 r#"{"type":"event","seq":1,"event":"issues","action":"closed","repo":"o/r","payload":{"issue":{"number":12}}}"#,
184 &store,
185 &Notify::new(),
186 )
187 .await;
188
189 assert_eq!(
190 store.kv_get("dep_blocker_read_at:o/r#12").await.unwrap(),
191 None
192 );
193 assert!(
194 store
195 .kv_get("dep_blocker_read_at:o/r#13")
196 .await
197 .unwrap()
198 .is_some()
199 );
200 }
201
202 #[tokio::test]
203 async fn a_gap_wakes_the_sync_and_keeps_the_cursor() {
204 let store = TaskStore::open_memory().await.unwrap();
205 store.kv_set(CURSOR_KEY, "3").await.unwrap();
206 let notify = Notify::new();
207 handle(r#"{"type":"gap","oldest":50}"#, &store, ¬ify).await;
208
209 assert_eq!(cursor(&store).await, 3);
210 assert!(woke(¬ify).await);
211 }
212
213 #[tokio::test]
214 async fn pong_and_unknown_messages_change_nothing() {
215 let store = TaskStore::open_memory().await.unwrap();
216 let notify = Notify::new();
217 handle("pong", &store, ¬ify).await;
218 handle("not json", &store, ¬ify).await;
219
220 assert_eq!(cursor(&store).await, 0);
221 assert!(!woke(¬ify).await);
222 }
223
224 #[expect(
225 clippy::result_large_err,
226 reason = "the handshake callback returns tungstenite's large error type"
227 )]
228 #[tokio::test]
229 async fn a_session_says_hello_with_the_cursor_and_applies_events() {
230 use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
231
232 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
233 let addr = listener.local_addr().unwrap();
234 let server = tokio::spawn(async move {
235 let (tcp, _) = listener.accept().await.unwrap();
236 let mut auth = String::new();
237 let mut ws =
238 tokio_tungstenite::accept_hdr_async(tcp, |req: &Request, res: Response| {
239 auth = req
240 .headers()
241 .get("authorization")
242 .and_then(|v| v.to_str().ok())
243 .unwrap_or_default()
244 .to_string();
245 Ok(res)
246 })
247 .await
248 .unwrap();
249 let hello = ws.next().await.unwrap().unwrap().into_text().unwrap();
250 let event = r#"{"type":"event","seq":9,"event":"issues","action":"opened","repo":"o/r","payload":{}}"#;
251 ws.send(Message::Text(event.to_string())).await.unwrap();
252 ws.close(None).await.unwrap();
253 (auth, hello)
254 });
255
256 let store = TaskStore::open_memory().await.unwrap();
257 store.kv_set(CURSOR_KEY, "4").await.unwrap();
258 let notify = Notify::new();
259 session(&format!("http://{addr}"), "tok", &store, ¬ify)
260 .await
261 .unwrap();
262
263 let (auth, hello) = server.await.unwrap();
264 assert_eq!(auth, "Bearer tok");
265 let hello: serde_json::Value = serde_json::from_str(&hello).unwrap();
266 assert_eq!(
267 hello,
268 serde_json::json!({ "v": 1, "type": "hello", "after": 4 })
269 );
270 assert_eq!(cursor(&store).await, 9);
271 assert!(woke(¬ify).await);
272 }
273
274 #[test]
275 fn a_socket_closed_at_once_keeps_the_backoff_growing() {
276 let quick = Duration::from_secs(1);
277 let mut backoff = 1;
278 let mut waits = Vec::new();
279
280 for _ in 0..12 {
281 let wait = retry_after(backoff, quick);
282 waits.push(wait);
283 backoff = (wait * 2).min(MAX_BACKOFF_SECS);
284 }
285
286 assert_eq!(waits[..9], [1, 2, 4, 8, 16, 32, 64, 128, 256]);
287 assert_eq!(waits[9..], [300, 300, 300]);
288 assert_eq!(retry_after(300, STABLE_SESSION), 1);
289 }
290}