wowlab_sentinel/cron/
docs.rs1use std::time::Duration;
2
3use async_trait::async_trait;
4use futures::StreamExt;
5use reqwest::Client;
6use wowlab_types::constants::{
7 BYTES_PER_MB_USIZE, HTTP_CONNECT_TIMEOUT_SECS, HTTP_REQUEST_TIMEOUT_SECS,
8};
9
10use super::CronJob;
11use crate::state::{McpStateHandle, ServerState};
12
13pub(crate) const CACHE_DOCS_INDEX: &str = "docs_index";
14pub(crate) const CACHE_DOCS_FULL: &str = "docs_full";
15const MAX_DOC_BYTES: usize = 2 * BYTES_PER_MB_USIZE;
16
17#[derive(Debug)]
18pub(crate) struct DocsCacheJob {
19 schedule: String,
20}
21
22impl DocsCacheJob {
23 pub(crate) fn new(schedule: &str) -> Self {
24 Self {
25 schedule: schedule.to_owned(),
26 }
27 }
28}
29
30#[async_trait]
31impl CronJob for DocsCacheJob {
32 fn name(&self) -> &'static str {
33 "docs_cache"
34 }
35
36 fn schedule(&self) -> &str {
37 &self.schedule
38 }
39
40 async fn run(&self, state: &ServerState) {
41 refresh(&state.config.docs_base_url, &state.mcp).await;
42 }
43}
44
45pub(crate) async fn refresh(base_url: &str, mcp: &McpStateHandle) {
46 let client = match Client::builder()
47 .connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
48 .timeout(Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECS))
49 .build()
50 {
51 Ok(client) => client,
52 Err(error) => {
53 tracing::warn!(%error, "Failed to configure docs HTTP client");
54
55 return;
56 }
57 };
58
59 refresh_with_client(base_url, mcp, &client, MAX_DOC_BYTES).await;
60}
61
62async fn refresh_with_client(
63 base_url: &str,
64 mcp: &McpStateHandle,
65 client: &Client,
66 max_bytes: usize,
67) {
68 let index_url = format!("{base_url}/llms.txt");
69 let full_url = format!("{base_url}/llms-full.txt");
70 let (index, full) = tokio::join!(
71 fetch_text(client, &index_url, max_bytes),
72 fetch_text(client, &full_url, max_bytes)
73 );
74
75 let mut c = mcp.docs_mut().await;
76
77 if let Some(t) = index {
78 c.set(CACHE_DOCS_INDEX, t);
79 }
80
81 if let Some(t) = full {
82 c.set(CACHE_DOCS_FULL, t);
83 }
84
85 tracing::info!(
86 index = c.get(CACHE_DOCS_INDEX).map_or(0, str::len),
87 full = c.get(CACHE_DOCS_FULL).map_or(0, str::len),
88 "Docs cache refreshed"
89 );
90}
91
92async fn fetch_text(client: &Client, url: &str, max_bytes: usize) -> Option<String> {
93 let response = match client.get(url).send().await {
94 Ok(response) => response,
95 Err(error) => {
96 tracing::warn!(url, %error, "Failed to fetch docs");
97
98 return None;
99 }
100 };
101
102 if !response.status().is_success() {
103 tracing::warn!(url, status = %response.status(), "Failed to fetch docs");
104
105 return None;
106 }
107
108 if response
109 .content_length()
110 .is_some_and(|length| usize::try_from(length).map_or(true, |length| length > max_bytes))
111 {
112 tracing::warn!(url, max_bytes, "Docs response exceeds size limit");
113
114 return None;
115 }
116
117 let mut body = Vec::with_capacity(
118 response
119 .content_length()
120 .and_then(|length| usize::try_from(length).ok())
121 .unwrap_or_default()
122 .min(max_bytes),
123 );
124 let mut stream = response.bytes_stream();
125 let mut read_error = None;
126 let mut oversized = false;
127
128 loop {
129 let Some(chunk) = stream.next().await else {
130 break;
131 };
132 let chunk = match chunk {
133 Ok(chunk) => chunk,
134 Err(error) => {
135 read_error = Some(error);
136 break;
137 }
138 };
139
140 if chunk.len() > max_bytes.saturating_sub(body.len()) {
141 oversized = true;
142 break;
143 }
144
145 body.extend_from_slice(&chunk);
146 }
147
148 if let Some(error) = read_error {
149 tracing::warn!(url, %error, "Failed to read docs response");
150
151 return None;
152 }
153
154 if oversized {
155 tracing::warn!(url, max_bytes, "Docs response exceeds size limit");
156
157 return None;
158 }
159
160 match String::from_utf8(body) {
161 Ok(body) => Some(body),
162 Err(error) => {
163 tracing::warn!(url, %error, "Docs response is not valid UTF-8");
164
165 None
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use std::sync::Arc;
173
174 use googletest::prelude::*;
175 use wiremock::{Mock, MockServer, ResponseTemplate, matchers::path};
176
177 use super::*;
178 use crate::state::McpState;
179
180 #[gtest]
181 #[tokio::test]
182 async fn oversized_response_does_not_replace_cached_docs() -> Result<()> {
183 let server = MockServer::start().await;
184
185 Mock::given(path("/llms.txt"))
186 .respond_with(ResponseTemplate::new(200).set_body_string("too large"))
187 .mount(&server)
188 .await;
189
190 Mock::given(path("/llms-full.txt"))
191 .respond_with(ResponseTemplate::new(200).set_body_string("new full"))
192 .mount(&server)
193 .await;
194
195 let mcp = Arc::new(McpState::new()?);
196
197 mcp.docs_mut()
198 .await
199 .set(CACHE_DOCS_INDEX, "old index".to_owned());
200
201 refresh_with_client(&server.uri(), &mcp, &Client::new(), 8).await;
202
203 let docs = mcp.docs().await;
204
205 verify_that!(docs.get(CACHE_DOCS_INDEX), eq(Some("old index")))?;
206 verify_that!(docs.get(CACHE_DOCS_FULL), eq(Some("new full")))?;
207
208 Ok(())
209 }
210
211 #[gtest]
212 #[tokio::test]
213 async fn request_timeout_is_nonfatal() -> Result<()> {
214 let server = MockServer::start().await;
215
216 Mock::given(path("/llms.txt"))
217 .respond_with(
218 ResponseTemplate::new(200)
219 .set_delay(Duration::from_millis(200))
220 .set_body_string("late"),
221 )
222 .mount(&server)
223 .await;
224 let client = Client::builder()
225 .connect_timeout(Duration::from_millis(25))
226 .timeout(Duration::from_millis(25))
227 .build()?;
228
229 let result = fetch_text(&client, &format!("{}/llms.txt", server.uri()), 1024).await;
230
231 verify_true!(result.is_none())?;
232
233 Ok(())
234 }
235}