Skip to main content

synd_persistence/sqlite/feed_registry/crawl/target/
mod.rs

1use std::{fmt, str::FromStr};
2
3use chrono::{DateTime, Utc};
4use sqlx::{Sqlite, Transaction};
5use synd_feed::types::FeedUrl;
6use synd_registry::{
7    RegistryDbResult,
8    crawl::{
9        due::CrawlDueInput,
10        target_list::{CrawlTarget, CrawlTargetState},
11    },
12    db::CrawlTargetDb,
13};
14
15use super::{
16    super::{
17        SqliteRegistryTx, codec,
18        error::{DecodeResultExt, IntoDbResult, SqliteError, SqliteResult},
19        feed,
20    },
21    state::CrawlStateRow,
22};
23
24async fn upsert(tx: &mut Transaction<'_, Sqlite>, target: &CrawlTarget) -> SqliteResult<()> {
25    let feed_pk = feed::resolve_pk(tx, &target.feed_url).await?;
26    let state = CrawlTargetStateRow::try_from(&target.state)?;
27
28    // A pending manual request belongs to the request/completion lifecycle
29    // and is preserved across target upserts.
30    sqlx::query(
31        r#"
32            INSERT INTO crawl_target (
33                feed_pk,
34                state,
35                effective_policy_json
36            )
37            VALUES (?, ?, ?)
38            ON CONFLICT(feed_pk) DO UPDATE SET
39                state = excluded.state,
40                effective_policy_json = excluded.effective_policy_json
41            "#,
42    )
43    .bind(feed_pk)
44    .bind(state.state.to_string())
45    .bind(state.effective_policy_json.as_deref())
46    .execute(&mut **tx)
47    .await?;
48
49    Ok(())
50}
51
52async fn load(
53    tx: &mut Transaction<'_, Sqlite>,
54    feed_url: &FeedUrl,
55) -> SqliteResult<Option<CrawlTarget>> {
56    let row = sqlx::query_as::<_, CrawlTargetRow>(
57        r#"
58            SELECT
59                f.url AS feed_url,
60                ct.state AS state,
61                ct.effective_policy_json AS effective_policy_json
62            FROM crawl_target AS ct
63            INNER JOIN feed AS f
64                ON f.pk = ct.feed_pk
65            WHERE f.url = ?
66            "#,
67    )
68    .bind(feed_url.as_str())
69    .fetch_optional(&mut **tx)
70    .await?;
71
72    row.map(CrawlTargetRow::into_target).transpose()
73}
74
75const DUE_INPUT_SELECT: &str = r#"
76SELECT
77    f.url AS feed_url,
78    ct.effective_policy_json AS effective_policy_json,
79    ct.manual_requested_at AS manual_requested_at,
80    cs.last_started_at,
81    cs.last_finished_at,
82    cs.last_http_status,
83    cs.last_error_kind,
84    cs.failure_streak,
85    cs.retry_after,
86    cs.etag,
87    cs.last_modified
88FROM crawl_target AS ct
89INNER JOIN feed AS f
90    ON f.pk = ct.feed_pk
91LEFT JOIN crawl_state AS cs
92    ON cs.feed_pk = ct.feed_pk
93WHERE ct.state = 'active'
94"#;
95
96async fn load_due_input(
97    tx: &mut Transaction<'_, Sqlite>,
98    feed_url: &FeedUrl,
99) -> SqliteResult<Option<CrawlDueInput>> {
100    let sql = format!("{DUE_INPUT_SELECT} AND f.url = ?");
101    let row = sqlx::query_as::<_, CrawlDueInputRow>(&sql)
102        .bind(feed_url.as_str())
103        .fetch_optional(&mut **tx)
104        .await?;
105
106    row.map(CrawlDueInputRow::into_input).transpose()
107}
108
109async fn list_due_inputs(tx: &mut Transaction<'_, Sqlite>) -> SqliteResult<Vec<CrawlDueInput>> {
110    let sql = format!("{DUE_INPUT_SELECT} ORDER BY f.url");
111    let rows = sqlx::query_as::<_, CrawlDueInputRow>(&sql)
112        .fetch_all(&mut **tx)
113        .await?;
114
115    rows.into_iter().map(CrawlDueInputRow::into_input).collect()
116}
117
118async fn set_manual_request(
119    tx: &mut Transaction<'_, Sqlite>,
120    feed_url: &FeedUrl,
121    requested_at: DateTime<Utc>,
122) -> SqliteResult<()> {
123    sqlx::query(
124        r#"
125            UPDATE crawl_target
126            SET manual_requested_at = ?
127            WHERE feed_pk = (SELECT pk FROM feed WHERE url = ?)
128              AND manual_requested_at IS NULL
129            "#,
130    )
131    .bind(requested_at)
132    .bind(feed_url.as_str())
133    .execute(&mut **tx)
134    .await?;
135    Ok(())
136}
137
138async fn clear_manual_request(
139    tx: &mut Transaction<'_, Sqlite>,
140    feed_url: &FeedUrl,
141    served_by_crawl_started_at: DateTime<Utc>,
142) -> SqliteResult<()> {
143    sqlx::query(
144        r#"
145            UPDATE crawl_target
146            SET manual_requested_at = NULL
147            WHERE feed_pk = (SELECT pk FROM feed WHERE url = ?)
148              AND manual_requested_at <= ?
149            "#,
150    )
151    .bind(feed_url.as_str())
152    .bind(served_by_crawl_started_at)
153    .execute(&mut **tx)
154    .await?;
155    Ok(())
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159enum CrawlTargetStateDb {
160    Active,
161    Inactive,
162}
163
164impl CrawlTargetStateDb {
165    const ACTIVE: &'static str = "active";
166    const INACTIVE: &'static str = "inactive";
167}
168
169impl fmt::Display for CrawlTargetStateDb {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        match self {
172            Self::Active => f.write_str(Self::ACTIVE),
173            Self::Inactive => f.write_str(Self::INACTIVE),
174        }
175    }
176}
177
178impl FromStr for CrawlTargetStateDb {
179    type Err = SqliteError;
180
181    fn from_str(state: &str) -> Result<Self, Self::Err> {
182        match state {
183            Self::ACTIVE => Ok(Self::Active),
184            Self::INACTIVE => Ok(Self::Inactive),
185            state => Err(SqliteError::decode_message(format!(
186                "unknown crawl target state: {state}"
187            ))),
188        }
189    }
190}
191
192struct CrawlTargetStateRow {
193    state: CrawlTargetStateDb,
194    effective_policy_json: Option<String>,
195}
196
197impl TryFrom<&CrawlTargetState> for CrawlTargetStateRow {
198    type Error = SqliteError;
199
200    fn try_from(state: &CrawlTargetState) -> Result<Self, Self::Error> {
201        match state {
202            CrawlTargetState::Active { effective_policy } => Ok(Self {
203                state: CrawlTargetStateDb::Active,
204                effective_policy_json: Some(codec::encode_crawl_policy_json(*effective_policy)?),
205            }),
206            CrawlTargetState::Inactive => Ok(Self {
207                state: CrawlTargetStateDb::Inactive,
208                effective_policy_json: None,
209            }),
210        }
211    }
212}
213
214impl TryFrom<CrawlTargetStateRow> for CrawlTargetState {
215    type Error = SqliteError;
216
217    fn try_from(row: CrawlTargetStateRow) -> Result<Self, Self::Error> {
218        match (row.state, row.effective_policy_json) {
219            (CrawlTargetStateDb::Active, Some(policy_json)) => Ok(CrawlTargetState::Active {
220                effective_policy: codec::decode_crawl_policy_json(&policy_json)?,
221            }),
222            (CrawlTargetStateDb::Active, None) => Err(SqliteError::decode_message(
223                "active crawl target requires an effective policy",
224            )),
225            (CrawlTargetStateDb::Inactive, None) => Ok(CrawlTargetState::Inactive),
226            (CrawlTargetStateDb::Inactive, Some(_)) => Err(SqliteError::decode_message(
227                "inactive crawl target must not have an effective policy",
228            )),
229        }
230    }
231}
232
233#[derive(sqlx::FromRow)]
234struct CrawlTargetRow {
235    feed_url: String,
236    state: String,
237    effective_policy_json: Option<String>,
238}
239
240impl CrawlTargetRow {
241    fn into_target(self) -> SqliteResult<CrawlTarget> {
242        let state = CrawlTargetState::try_from(CrawlTargetStateRow {
243            state: self.state.parse()?,
244            effective_policy_json: self.effective_policy_json,
245        })?;
246
247        Ok(CrawlTarget {
248            feed_url: FeedUrl::parse(&self.feed_url).decode()?,
249            state,
250        })
251    }
252}
253
254#[derive(sqlx::FromRow)]
255struct CrawlDueInputRow {
256    feed_url: String,
257    effective_policy_json: Option<String>,
258    manual_requested_at: Option<DateTime<Utc>>,
259    last_started_at: Option<DateTime<Utc>>,
260    last_finished_at: Option<DateTime<Utc>>,
261    last_http_status: Option<i64>,
262    last_error_kind: Option<String>,
263    failure_streak: Option<i64>,
264    retry_after: Option<DateTime<Utc>>,
265    etag: Option<String>,
266    last_modified: Option<String>,
267}
268
269impl CrawlDueInputRow {
270    fn into_input(self) -> SqliteResult<CrawlDueInput> {
271        let feed_url = FeedUrl::parse(&self.feed_url).decode()?;
272        let Some(policy_json) = self.effective_policy_json else {
273            return Err(SqliteError::decode_message(
274                "active crawl target requires an effective policy",
275            ));
276        };
277        let policy = codec::decode_crawl_policy_json(&policy_json)?;
278
279        // The LEFT JOIN yields state columns together or not at all.
280        let state = match (
281            self.last_started_at,
282            self.last_finished_at,
283            self.failure_streak,
284        ) {
285            (Some(last_started_at), Some(last_finished_at), Some(failure_streak)) => Some(
286                CrawlStateRow {
287                    last_started_at,
288                    last_finished_at,
289                    last_http_status: self.last_http_status,
290                    last_error_kind: self.last_error_kind,
291                    failure_streak,
292                    retry_after: self.retry_after,
293                    etag: self.etag,
294                    last_modified: self.last_modified,
295                }
296                .into_state(&feed_url)?,
297            ),
298            _ => None,
299        };
300
301        Ok(CrawlDueInput {
302            feed_url,
303            polling: policy.polling,
304            manual_requested_at: self.manual_requested_at,
305            state,
306        })
307    }
308}
309
310impl CrawlTargetDb for SqliteRegistryTx<'_> {
311    async fn upsert_target(&mut self, target: &CrawlTarget) -> RegistryDbResult<()> {
312        upsert(&mut self.tx, target).await.db()
313    }
314
315    async fn load_target(&mut self, feed_url: &FeedUrl) -> RegistryDbResult<Option<CrawlTarget>> {
316        load(&mut self.tx, feed_url).await.db()
317    }
318
319    async fn load_crawl_due_input(
320        &mut self,
321        feed_url: &FeedUrl,
322    ) -> RegistryDbResult<Option<CrawlDueInput>> {
323        load_due_input(&mut self.tx, feed_url).await.db()
324    }
325
326    async fn list_crawl_due_inputs(&mut self) -> RegistryDbResult<Vec<CrawlDueInput>> {
327        list_due_inputs(&mut self.tx).await.db()
328    }
329
330    async fn set_manual_request(
331        &mut self,
332        feed_url: &FeedUrl,
333        requested_at: DateTime<Utc>,
334    ) -> RegistryDbResult<()> {
335        set_manual_request(&mut self.tx, feed_url, requested_at)
336            .await
337            .db()
338    }
339
340    async fn clear_manual_request(
341        &mut self,
342        feed_url: &FeedUrl,
343        served_by_crawl_started_at: DateTime<Utc>,
344    ) -> RegistryDbResult<()> {
345        clear_manual_request(&mut self.tx, feed_url, served_by_crawl_started_at)
346            .await
347            .db()
348    }
349}
350
351#[cfg(test)]
352mod tests;