1use std::collections::{BTreeMap, HashMap};
16use std::sync::{Arc, Mutex, OnceLock};
17use std::time::Instant;
18
19use axum::extract::{Path, State};
20use axum::http::header;
21use axum::response::{IntoResponse, Response};
22
23use crate::error::{ApiError, ErrorCode};
24use crate::types::Document;
25use crate::types::attachment::mime_of;
26
27fn data_key(id: &str) -> String {
30 format!("attachment/{id}")
31}
32
33fn exp_key(id: &str) -> String {
34 format!("attachment-exp/{id}")
35}
36
37fn now_ms() -> u64 {
38 std::time::SystemTime::now()
39 .duration_since(std::time::UNIX_EPOCH)
40 .map_or(0, |d| d.as_millis() as u64)
41}
42
43#[derive(Clone)]
45pub(crate) struct Disk {
46 pub store: pagesift_store::Store,
47 pub mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
48}
49
50fn remove(d: &Disk, id: &str) {
52 let r = crate::jobs::mirror::write(&d.store, d.mirror.as_deref(), |w| {
53 w.kv_delete(&data_key(id))?;
54 w.kv_delete(&exp_key(id))
55 });
56 if let Err(e) = r {
57 tracing::warn!(attachment = %id, error = %e, "deleting an expired attachment failed");
58 }
59}
60
61type Kv = (String, Vec<u8>);
63
64pub(crate) fn stored(t: &pagesift_store::Tasks<'_>) -> Vec<(Kv, Kv)> {
66 let mut out = Vec::new();
67 for k in t.kv_keys("attachment-exp/").unwrap_or_default() {
68 let Ok(k) = String::from_utf8(k) else {
69 continue;
70 };
71 let id = &k["attachment-exp/".len()..];
72 if let (Ok(Some(exp)), Ok(Some(data))) = (t.kv_get(&k), t.kv_get(data_key(id))) {
73 out.push(((data_key(id), data), (k.clone(), exp)));
74 }
75 }
76 out
77}
78
79pub(crate) fn sweep_expired(d: Disk) {
81 tokio::spawn(async move {
82 let s = d.store.clone();
83 s.blocking(move |s| {
84 let now = now_ms();
85 for k in s.tasks().kv_keys("attachment-exp/").unwrap_or_default() {
86 let Ok(k) = String::from_utf8(k) else {
87 continue;
88 };
89 let id = &k["attachment-exp/".len()..];
90 if read_exp(s, id).is_none_or(|e| e <= now) {
91 remove(&d, id);
92 }
93 }
94 })
95 .await;
96 });
97}
98
99fn read_exp(s: &pagesift_store::Store, id: &str) -> Option<u64> {
100 let v = s.tasks().kv_get(exp_key(id)).ok()??;
101 Some(u64::from_le_bytes(v.as_slice().try_into().ok()?))
102}
103
104fn save(disk: Option<&Disk>, id: String, data: Arc<[u8]>) {
106 let (Some(d), Ok(rt)) = (disk.cloned(), tokio::runtime::Handle::try_current()) else {
107 return;
108 };
109 let exp = now_ms() + crate::jobs::JOB_TTL.as_millis() as u64;
110 rt.spawn(async move {
111 let s = d.store.clone();
112 s.blocking(move |s| {
113 let mirror = d
115 .mirror
116 .as_deref()
117 .filter(|m| data.len() + data_key(&id).len() < m.max_op());
118 if d.mirror.is_some() && mirror.is_none() {
119 tracing::warn!(attachment = %id, bytes = data.len(), "attachment too large to replicate to the standby; stored locally only");
120 }
121 let fresh = read_exp(s, &id).is_none();
122 let r = crate::jobs::mirror::write(s, mirror, |w| {
123 if fresh {
124 w.kv_put(&data_key(&id), &data)?;
125 }
126 w.kv_put(&exp_key(&id), &exp.to_le_bytes())
127 });
128 if let Err(e) = r {
129 tracing::warn!(attachment = %id, error = %e, "saving an attachment failed; it stays in memory only");
130 }
131 })
132 .await;
133 });
134}
135
136pub(crate) async fn load(disk: Option<Disk>, id: String) -> Option<(Arc<[u8]>, &'static str)> {
138 let d = disk?;
139 let store = d.store.clone();
140 store
141 .blocking(move |s| {
142 let exp = read_exp(s, &id)?;
143 if exp <= now_ms() {
144 remove(&d, &id);
145 return None;
146 }
147 let data = s.tasks().kv_get(data_key(&id)).ok()??;
148 let ext = id.rsplit_once('.').map_or("", |(_, e)| e);
149 Some((Arc::from(data), mime_of(ext)))
150 })
151 .await
152}
153
154struct Entry {
155 data: Arc<[u8]>,
156 mime: &'static str,
157 expires: Instant,
158}
159
160#[derive(Default)]
161struct Store {
162 map: HashMap<String, Entry>,
163 order: BTreeMap<(Instant, u64), String>,
165 seq: u64,
166 bytes: usize,
167}
168
169fn store() -> &'static Mutex<Store> {
170 static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
171 STORE.get_or_init(Default::default)
172}
173
174fn cap() -> usize {
176 let delta = 0.05;
178 let fallback = 64 * 1024 * 1024;
180 pagesift_net::resources::budget()
181 .map_or(fallback, |b| (b as f64 * delta) as usize)
182 .max(1)
183}
184
185pub fn keep(doc: &Document, disk: Option<&pagesift_store::Store>) {
187 let disk = disk.map(|store| Disk {
188 store: store.clone(),
189 mirror: None,
190 });
191 keep_in(doc, disk.as_ref());
192}
193
194pub(crate) fn keep_in(doc: &Document, disk: Option<&Disk>) {
196 keep_inner(doc, disk, true);
197}
198
199pub(crate) fn keep_job_in(doc: &Document, disk: Option<&Disk>) {
201 keep_inner(doc, disk, false);
202}
203
204fn keep_inner(doc: &Document, disk: Option<&Disk>, save_inline: bool) {
205 if doc.attachments.iter().all(|a| a.data.is_none()) {
206 return;
207 }
208 let expires = Instant::now() + crate::jobs::JOB_TTL;
209 let cap = cap();
210 let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
211 let now = Instant::now();
212 while let Some((&key, _)) = st.order.first_key_value() {
214 if key.0 > now {
215 break;
216 }
217 drop_oldest(&mut st);
218 }
219 for a in &doc.attachments {
220 let Some(data) = &a.data else { continue };
221 if data.len() > cap {
222 if save_inline || !a.inline_data {
224 save(disk, a.id.clone(), data.clone());
225 }
226 continue;
227 }
228 if let Some(old) = st.map.remove(&a.id) {
229 st.bytes -= old.data.len();
230 let id = a.id.clone();
231 st.order.retain(|_, v| *v != id);
232 }
233 while st.bytes + data.len() > cap && !st.order.is_empty() {
234 drop_oldest(&mut st);
235 }
236 st.seq += 1;
237 let seq = st.seq;
238 st.order.insert((expires, seq), a.id.clone());
239 st.bytes += data.len();
240 st.map.insert(
241 a.id.clone(),
242 Entry {
243 data: data.clone(),
244 mime: a.mime(),
245 expires,
246 },
247 );
248 if save_inline || !a.inline_data {
249 save(disk, a.id.clone(), data.clone());
250 }
251 }
252}
253
254pub(crate) fn persist_job_asset(
256 w: &mut crate::jobs::mirror::Writer<'_>,
257 id: &str,
258 data: &[u8],
259) -> pagesift_store::Result<()> {
260 let key = data_key(id);
261 if w.kv_get(&key)?.is_none() {
262 w.kv_put(&key, data)?;
263 }
264 let exp = now_ms() + crate::jobs::JOB_TTL.as_millis() as u64;
265 w.kv_put(&exp_key(id), &exp.to_le_bytes())
266}
267
268fn drop_oldest(st: &mut Store) {
269 if let Some((_, id)) = st.order.pop_first()
270 && let Some(e) = st.map.remove(&id)
271 {
272 st.bytes -= e.data.len();
273 }
274}
275
276pub fn get(id: &str) -> Option<(Arc<[u8]>, &'static str)> {
278 let st = store().lock().unwrap_or_else(|e| e.into_inner());
279 st.map
280 .get(id)
281 .filter(|e| e.expires > Instant::now())
282 .map(|e| (e.data.clone(), e.mime))
283}
284
285pub(crate) async fn download(
287 State(s): State<crate::server::AppState>,
288 Path(id): Path<String>,
289) -> Result<Response, ApiError> {
290 let found = match get(&id) {
291 Some(x) => Some(x),
292 None => load(s.jobs.disk(), id.clone()).await,
293 };
294 let Some((data, mime)) = found else {
295 return Err(ApiError::new(
296 ErrorCode::NotFound,
297 format!("attachment {id} not found or expired"),
298 ));
299 };
300 Ok((
301 [
302 (header::CONTENT_TYPE, mime),
303 (header::CACHE_CONTROL, "private, max-age=86400, immutable"),
304 ],
305 data.to_vec(),
306 )
307 .into_response())
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::types::Attachment;
314
315 #[test]
316 fn kept_attachments_can_be_fetched_by_id() {
317 let mut doc = Document::default();
318 doc.attachments
319 .push(Attachment::new(b"GIF89a-test".to_vec(), "gif", None));
320 keep(&doc, None);
321 let id = &doc.attachments[0].id;
322 let (data, mime) = get(id).unwrap();
323 assert_eq!(&*data, b"GIF89a-test");
324 assert_eq!(mime, "image/gif");
325 assert!(get("0000.png").is_none());
326 }
327
328 #[tokio::test]
329 async fn synchronous_job_asset_expires_and_is_removed() {
330 let dir = tempfile::tempdir().unwrap();
331 let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
332 let a = Attachment::new(b"\x89PNG\r\n\x1a\nexpiry-test".to_vec(), "png", None);
333 crate::jobs::mirror::write(&store, None, |w| {
334 persist_job_asset(w, &a.id, a.data.as_deref().unwrap())
335 })
336 .unwrap();
337 let disk = Disk {
338 store: store.clone(),
339 mirror: None,
340 };
341 let (bytes, mime) = load(Some(disk.clone()), a.id.clone()).await.unwrap();
342 assert_eq!(bytes.as_ref(), a.data.as_deref().unwrap());
343 assert_eq!(mime, "image/png");
344 store
345 .tasks()
346 .kv_put(exp_key(&a.id), &1u64.to_le_bytes())
347 .unwrap();
348 assert!(load(Some(disk), a.id.clone()).await.is_none());
349 assert!(store.tasks().kv_get(data_key(&a.id)).unwrap().is_none());
350 }
351
352 #[tokio::test(flavor = "multi_thread")]
354 async fn persisted_attachments_survive_losing_the_memory_copy() {
355 let dir = tempfile::tempdir().unwrap();
356 let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
357 let mut doc = Document::default();
358 doc.attachments
359 .push(Attachment::new(b"\x89PNG-persisted".to_vec(), "png", None));
360 keep(&doc, Some(&store));
361 let id = doc.attachments[0].id.clone();
362 for _ in 0..500 {
364 if read_exp(&store, &id).is_some() {
365 break;
366 }
367 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
368 }
369 {
371 let mut st = super::store().lock().unwrap();
372 let e = st.map.remove(&id).unwrap();
373 st.bytes -= e.data.len();
374 st.order.retain(|_, v| *v != id);
375 }
376 assert!(get(&id).is_none());
377 let (data, mime) = load(
378 Some(Disk {
379 store: store.clone(),
380 mirror: None,
381 }),
382 id.clone(),
383 )
384 .await
385 .unwrap();
386 assert_eq!((&*data, mime), (&b"\x89PNG-persisted"[..], "image/png"));
387 store
389 .tasks()
390 .kv_put(exp_key(&id), &1u64.to_le_bytes())
391 .unwrap();
392 assert!(
393 load(
394 Some(Disk {
395 store: store.clone(),
396 mirror: None
397 }),
398 id.clone()
399 )
400 .await
401 .is_none()
402 );
403 assert!(store.tasks().kv_get(data_key(&id)).unwrap().is_none());
404 }
405
406 struct Ops(Mutex<Vec<Vec<u8>>>);
407
408 impl crate::JobLog for Ops {
409 fn append(&self, op: Vec<u8>) -> Result<(), String> {
410 self.0.lock().unwrap().push(op);
411 Ok(())
412 }
413 }
414
415 #[tokio::test(flavor = "multi_thread")]
417 async fn attachments_replicate_with_the_job_records() {
418 let dirs: Vec<_> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
419 let open = |i: usize| pagesift_store::Store::open_dir(dirs[i].path()).unwrap();
420 let (src, dst, from_snapshot) = (open(0), open(1), open(2));
421 let log = Arc::new(Ops(Mutex::default()));
422 let mirror = crate::JobMirror::new(log.clone());
423 let mut doc = Document::default();
424 doc.attachments
425 .push(Attachment::new(b"GIF89a-replicated".to_vec(), "gif", None));
426 let disk = Disk {
427 store: src.clone(),
428 mirror: Some(mirror.clone()),
429 };
430 keep_in(&doc, Some(&disk));
431 let id = doc.attachments[0].id.clone();
432 for _ in 0..500 {
433 if log.0.lock().unwrap().len() >= 2 {
434 break;
435 }
436 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
437 }
438 for op in log.0.lock().unwrap().iter() {
439 crate::apply_op(&dst, op).unwrap();
440 }
441 for op in mirror.dump(&src) {
442 crate::apply_op(&from_snapshot, &op).unwrap();
443 }
444 for s in [dst, from_snapshot] {
445 let d = Disk {
446 store: s,
447 mirror: None,
448 };
449 let (data, mime) = load(Some(d), id.clone()).await.expect("replicated");
450 assert_eq!((&*data, mime), (&b"GIF89a-replicated"[..], "image/gif"));
451 }
452 }
453}