synd_persistence/sqlite/feed_registry/crawl/target/
mod.rs1use 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 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 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;