Skip to main content

wowlab_sentinel/cron/
docs.rs

1use 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}