open_chess_server/http/
feed.rs1use std::collections::{HashMap, HashSet};
27use std::convert::Infallible;
28use std::time::Duration;
29
30use axum::extract::{Query, State};
31use axum::http::HeaderMap;
32use axum::response::sse::{Event, KeepAlive, Sse};
33use axum::response::{IntoResponse, Response};
34use chess_core::Game;
35use futures_util::stream;
36use tokio::sync::broadcast::error::RecvError;
37use tokio::sync::mpsc;
38
39use crate::app::{Ctx, Reader};
40use crate::db::Note;
41use crate::error::ApiResult;
42use crate::http::{games, streams};
43use crate::metrics::StreamGuard;
44use crate::model::{self, GAME_SELECT, GameRow};
45
46const PAGE: i64 = 200;
48
49#[derive(Debug, Clone, Default)]
50struct Filter {
51 game: Option<String>,
52 only_end: bool,
53}
54
55impl Filter {
56 fn wants(&self, id: &str) -> bool {
57 self.game.as_deref().is_none_or(|g| g == id)
58 }
59}
60
61fn end_event(ctx: &Ctx, row: &GameRow) -> Option<Event> {
62 let game = Game::from_moves(row.initial_fen.as_deref(), &row.moves).ok()?;
63 let json = games::json(row, &game, &HashMap::new(), &ctx.cfg);
64 let mut e = Event::default().event("end").json_data(json).ok()?;
65 if let Some(ms) = row.feed_ms.or(row.finished_ms) {
66 e = e.id(ms.to_string());
67 }
68 Some(e)
69}
70
71fn view_event(kind: &str, ctx: &Ctx, row: &GameRow) -> Option<Event> {
72 Event::default().event(kind).json_data(streams::describe(row, ctx.cfg.public_delay_plies)).ok()
73}
74
75pub async fn feed(
76 State(ctx): State<Ctx>,
77 _reader: Reader,
78 h: HeaderMap,
79 Query(q): Query<HashMap<String, String>>,
80) -> ApiResult<Response> {
81 let filter = Filter {
82 game: q.get("game").filter(|g| !g.is_empty()).cloned(),
83 only_end: q.get("only").is_some_and(|o| o == "end"),
84 };
85 let since: Option<i64> = h
86 .get("last-event-id")
87 .and_then(|v| v.to_str().ok())
88 .or_else(|| q.get("since").map(String::as_str))
89 .and_then(|s| s.trim().parse().ok());
90 let notes = ctx.db.subscribe();
92 let (tx, rx) = mpsc::channel::<Event>(256);
93 tokio::spawn(run(ctx, filter, since, notes, tx));
94 let events =
95 stream::unfold(rx, |mut rx| async move { rx.recv().await.map(|e| (Ok::<_, Infallible>(e), rx)) });
96 Ok(Sse::new(events).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response())
97}
98
99async fn run(
100 ctx: Ctx,
101 filter: Filter,
102 since: Option<i64>,
103 mut notes: tokio::sync::broadcast::Receiver<Note>,
104 tx: mpsc::Sender<Event>,
105) {
106 let _g = StreamGuard::new(&ctx.metrics, "feed");
107 let mut ended: HashSet<String> = HashSet::new();
109 let mut seen: HashMap<String, i32> = HashMap::new();
111 if let Some(mut after) = since {
112 loop {
113 let rows = match catch_up(&ctx, &filter, after).await {
114 Ok(r) => r,
115 Err(e) => {
116 tracing::warn!("feed catch-up: {e:#}");
117 break;
118 }
119 };
120 let n = rows.len();
121 for row in rows {
122 after = after.max(row.feed_ms.or(row.finished_ms).unwrap_or(after));
123 if let Some(e) = end_event(&ctx, &row)
124 && ended.insert(row.id.clone())
125 && tx.send(e).await.is_err()
126 {
127 return;
128 }
129 }
130 if i64::try_from(n).unwrap_or(0) < PAGE {
131 break;
132 }
133 after += 1;
135 }
136 }
137 loop {
138 let id = match notes.recv().await {
139 Ok(Note::Game(id)) if filter.wants(&id) => id,
140 Ok(_) | Err(RecvError::Lagged(_)) => continue,
141 Err(RecvError::Closed) => return,
142 };
143 let row = match ctx.db.conn().await {
144 Ok(c) => match model::game(&ctx.db, &*c, &id).await {
145 Ok(Some(r)) => r,
146 Ok(None) => continue,
147 Err(e) => {
148 tracing::warn!("feed: {e:#}");
149 continue;
150 }
151 },
152 Err(e) => {
153 tracing::warn!("feed: {e:#}");
154 continue;
155 }
156 };
157 let event = if !row.status.is_ongoing() {
158 if row.feed_ms.is_none() {
161 continue;
162 }
163 seen.remove(&row.id);
164 if !ended.insert(row.id.clone()) {
165 continue;
166 }
167 end_event(&ctx, &row)
168 } else if filter.only_end {
169 continue;
170 } else {
171 match seen.insert(row.id.clone(), row.ply) {
172 None => view_event("start", &ctx, &row),
173 Some(ply) if ply != row.ply => view_event("move", &ctx, &row),
174 Some(_) => continue,
175 }
176 };
177 if let Some(e) = event
178 && tx.send(e).await.is_err()
179 {
180 return;
181 }
182 if ended.len() > 50_000 {
184 ended.clear();
185 }
186 }
187}
188
189async fn catch_up(ctx: &Ctx, filter: &Filter, after: i64) -> anyhow::Result<Vec<GameRow>> {
191 let c = ctx.db.conn().await?;
192 #[allow(clippy::cast_precision_loss)] let at = after as f64 / 1000.0;
194 let rows = c
195 .query(
196 &ctx.db.sql(&format!(
197 "{GAME_SELECT} WHERE g.feed_at >= to_timestamp($1) AND ($2::text IS NULL OR g.id = $2)
198 ORDER BY g.feed_at, g.id LIMIT {PAGE}"
199 )),
200 &[&at, &filter.game],
201 )
202 .await?;
203 Ok(rows.iter().map(GameRow::from_row).collect())
204}