1use std::collections::{BTreeMap, HashMap, HashSet};
18use std::sync::{Arc, Mutex, OnceLock};
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20
21use axum::extract::{Extension, Path, State};
22use axum::http::header;
23use axum::response::{IntoResponse, Response};
24
25use crate::error::{ApiError, ErrorCode};
26use crate::limits::Caller;
27use crate::types::Document;
28use crate::types::attachment::mime_of;
29
30fn data_key(id: &str) -> String {
33 format!("attachment/{id}")
34}
35
36fn exp_key(id: &str) -> String {
37 format!("attachment-exp/{id}")
38}
39
40fn access_key(account: &str, id: &str) -> String {
41 format!("attachment-access/{account}/{id}")
42}
43
44fn now_ms() -> u64 {
45 SystemTime::now()
46 .duration_since(UNIX_EPOCH)
47 .map_or(0, |d| d.as_millis() as u64)
48}
49
50#[derive(Clone)]
52pub(crate) struct Disk {
53 pub store: pagesift_store::Store,
54 pub mirror: Option<Arc<crate::jobs::mirror::JobMirror>>,
55}
56
57fn remove(d: &Disk, id: &str) {
59 let mut access_keys = Vec::new();
60 if let Ok(keys) = d.store.tasks().kv_keys("attachment-access/") {
61 let suffix = format!("/{id}");
62 for k in keys {
63 if let Ok(s) = String::from_utf8(k)
64 && s.ends_with(&suffix)
65 {
66 access_keys.push(s);
67 }
68 }
69 }
70 let r = crate::jobs::mirror::write(&d.store, d.mirror.as_deref(), |w| {
71 w.kv_delete(&data_key(id))?;
72 w.kv_delete(&exp_key(id))?;
73 for ak in &access_keys {
74 w.kv_delete(ak)?;
75 }
76 Ok(())
77 });
78 if let Err(e) = r {
79 tracing::warn!(attachment = %id, error = %e, "deleting an expired attachment failed");
80 }
81}
82
83type Kv = (String, Vec<u8>);
85
86pub(crate) fn stored(t: &pagesift_store::Tasks<'_>) -> Vec<(Kv, Kv, Vec<Kv>)> {
88 let mut access_map: HashMap<String, Vec<Kv>> = HashMap::new();
89 for k in t.kv_keys("attachment-access/").unwrap_or_default() {
90 let Ok(k_str) = String::from_utf8(k) else {
91 continue;
92 };
93 let Some(rest) = k_str.strip_prefix("attachment-access/") else {
94 continue;
95 };
96 let Some((_account, id)) = rest.rsplit_once('/') else {
97 continue;
98 };
99 if let Ok(Some(val)) = t.kv_get(&k_str) {
100 access_map
101 .entry(id.to_string())
102 .or_default()
103 .push((k_str, val));
104 }
105 }
106 let mut out = Vec::new();
107 for k in t.kv_keys("attachment-exp/").unwrap_or_default() {
108 let Ok(k) = String::from_utf8(k) else {
109 continue;
110 };
111 let id = &k["attachment-exp/".len()..];
112 if let (Ok(Some(exp)), Ok(Some(data))) = (t.kv_get(&k), t.kv_get(data_key(id))) {
113 let access = access_map.remove(id).unwrap_or_default();
114 out.push(((data_key(id), data), (k.clone(), exp), access));
115 }
116 }
117 out
118}
119
120pub(crate) fn sweep_expired(d: Disk) {
122 tokio::spawn(async move {
123 let s = d.store.clone();
124 s.blocking(move |s| {
125 let now = now_ms();
126 let mut expired_ids = HashSet::new();
127 for k in s.tasks().kv_keys("attachment-exp/").unwrap_or_default() {
128 let Ok(k) = String::from_utf8(k) else {
129 continue;
130 };
131 let id = &k["attachment-exp/".len()..];
132 if read_exp(s, id).is_none_or(|e| e <= now) {
133 expired_ids.insert(id.to_string());
134 remove(&d, id);
135 }
136 }
137 for k in s.tasks().kv_keys("attachment-access/").unwrap_or_default() {
139 let Ok(k_str) = String::from_utf8(k) else {
140 continue;
141 };
142 let Some(rest) = k_str.strip_prefix("attachment-access/") else {
143 continue;
144 };
145 let Some((_, id)) = rest.rsplit_once('/') else {
146 continue;
147 };
148 if expired_ids.contains(id) {
150 continue;
151 }
152 let should_delete = match s.tasks().kv_get(&k_str) {
153 Ok(Some(v)) if v.len() == 8 => {
154 let exp = u64::from_le_bytes(v.as_slice().try_into().unwrap());
155 exp <= now
156 }
157 Ok(Some(_)) => read_exp(s, id).is_none_or(|e| e <= now),
158 _ => true,
159 };
160 if should_delete {
161 let _ =
162 crate::jobs::mirror::write(s, d.mirror.as_deref(), |w| w.kv_delete(&k_str));
163 }
164 }
165 })
166 .await;
167 });
168}
169
170fn read_exp(s: &pagesift_store::Store, id: &str) -> Option<u64> {
171 let v = s.tasks().kv_get(exp_key(id)).ok()??;
172 Some(u64::from_le_bytes(v.as_slice().try_into().ok()?))
173}
174
175fn save(disk: Option<&Disk>, id: String, data: Arc<[u8]>, account: String) {
177 let (Some(d), Ok(rt)) = (disk.cloned(), tokio::runtime::Handle::try_current()) else {
178 return;
179 };
180 let exp = now_ms() + crate::jobs::JOB_TTL.as_millis() as u64;
181 rt.spawn(async move {
182 let s = d.store.clone();
183 s.blocking(move |s| {
184 let mirror = d
186 .mirror
187 .as_deref()
188 .filter(|m| data.len() + data_key(&id).len() < m.max_op());
189 if d.mirror.is_some() && mirror.is_none() {
190 tracing::warn!(attachment = %id, bytes = data.len(), "attachment too large to replicate to the standby; stored locally only");
191 }
192 let fresh = read_exp(s, &id).is_none();
193 let r = crate::jobs::mirror::write(s, mirror, |w| {
194 if fresh {
195 w.kv_put(&data_key(&id), &data)?;
196 }
197 w.kv_put(&exp_key(&id), &exp.to_le_bytes())?;
198 w.kv_put(&access_key(&account, &id), &exp.to_le_bytes())
199 });
200 if let Err(e) = r {
201 tracing::warn!(attachment = %id, error = %e, "saving an attachment failed; it stays in memory only");
202 }
203 })
204 .await;
205 });
206}
207
208fn save_access(disk: Option<&Disk>, id: String, account: String) {
210 let (Some(d), Ok(rt)) = (disk.cloned(), tokio::runtime::Handle::try_current()) else {
211 return;
212 };
213 rt.spawn(async move {
214 let s = d.store.clone();
215 s.blocking(move |s| {
216 let Some(exp) = read_exp(s, &id).filter(|e| *e > now_ms()) else {
217 return;
218 };
219 let r = crate::jobs::mirror::write(s, d.mirror.as_deref(), |w| {
220 w.kv_put(&access_key(&account, &id), &exp.to_le_bytes())
221 });
222 if let Err(e) = r {
223 tracing::warn!(attachment = %id, account = %account, error = %e, "saving attachment authorization failed");
224 }
225 })
226 .await;
227 });
228}
229
230pub(crate) async fn load(disk: Option<Disk>, id: String) -> Option<(Arc<[u8]>, &'static str)> {
232 let d = disk?;
233 let store = d.store.clone();
234 store
235 .blocking(move |s| {
236 let exp = read_exp(s, &id)?;
237 if exp <= now_ms() {
238 remove(&d, &id);
239 return None;
240 }
241 let data = s.tasks().kv_get(data_key(&id)).ok()??;
242 let ext = id.rsplit_once('.').map_or("", |(_, e)| e);
243 Some((Arc::from(data), mime_of(ext)))
244 })
245 .await
246}
247
248struct Entry {
249 data: Arc<[u8]>,
250 mime: &'static str,
251 expires: Instant,
252}
253
254#[derive(Default)]
255struct Store {
256 map: HashMap<String, Entry>,
257 order: BTreeMap<(Instant, u64), String>,
259 access: HashMap<(String, String), Instant>,
261 seq: u64,
262 bytes: usize,
263}
264
265fn store() -> &'static Mutex<Store> {
266 static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
267 STORE.get_or_init(Default::default)
268}
269
270fn cap() -> usize {
272 let delta = 0.05;
274 let fallback = 64 * 1024 * 1024;
276 pagesift_net::resources::budget()
277 .map_or(fallback, |b| (b as f64 * delta) as usize)
278 .max(1)
279}
280
281pub fn keep(doc: &Document, disk: Option<&pagesift_store::Store>) {
283 let disk = disk.map(|store| Disk {
284 store: store.clone(),
285 mirror: None,
286 });
287 keep_in_for(doc, disk.as_ref(), "anonymous");
288}
289
290pub(crate) fn keep_in_for(doc: &Document, disk: Option<&Disk>, account: &str) {
292 keep_inner(doc, disk, true, account);
293}
294
295pub(crate) fn keep_job_in_for(doc: &Document, disk: Option<&Disk>, account: &str) {
297 keep_inner(doc, disk, false, account);
298}
299
300fn keep_inner(doc: &Document, disk: Option<&Disk>, save_inline: bool, account: &str) {
301 if doc.attachments.is_empty() {
302 return;
303 }
304 let expires = Instant::now() + crate::jobs::JOB_TTL;
305 let cap = cap();
306 let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
307 let now = Instant::now();
308 while let Some((&key, _)) = st.order.first_key_value() {
310 if key.0 > now {
311 break;
312 }
313 drop_oldest(&mut st);
314 }
315 st.access.retain(|_, exp| *exp > now);
316
317 for a in &doc.attachments {
318 st.access
319 .insert((account.to_string(), a.id.clone()), expires);
320 let Some(data) = &a.data else {
321 if save_inline {
322 save_access(disk, a.id.clone(), account.to_string());
323 }
324 continue;
325 };
326 if data.len() > cap {
327 if save_inline || !a.inline_data {
329 save(disk, a.id.clone(), data.clone(), account.to_string());
330 }
331 continue;
332 }
333 if let Some(old) = st.map.remove(&a.id) {
334 st.bytes -= old.data.len();
335 let id = a.id.clone();
336 st.order.retain(|_, v| *v != id);
337 }
338 while st.bytes + data.len() > cap && !st.order.is_empty() {
339 drop_oldest(&mut st);
340 }
341 st.seq += 1;
342 let seq = st.seq;
343 st.order.insert((expires, seq), a.id.clone());
344 st.bytes += data.len();
345 st.map.insert(
346 a.id.clone(),
347 Entry {
348 data: data.clone(),
349 mime: a.mime(),
350 expires,
351 },
352 );
353 if save_inline || !a.inline_data {
354 save(disk, a.id.clone(), data.clone(), account.to_string());
355 }
356 }
357}
358
359pub(crate) fn persist_job_asset(
361 w: &mut crate::jobs::mirror::Writer<'_>,
362 id: &str,
363 data: &[u8],
364) -> pagesift_store::Result<()> {
365 let key = data_key(id);
366 if w.kv_get(&key)?.is_none() {
367 w.kv_put(&key, data)?;
368 }
369 let exp = now_ms() + crate::jobs::JOB_TTL.as_millis() as u64;
370 w.kv_put(&exp_key(id), &exp.to_le_bytes())
371}
372
373pub(crate) fn persist_access(
375 w: &mut crate::jobs::mirror::Writer<'_>,
376 account: &str,
377 id: &str,
378) -> pagesift_store::Result<()> {
379 let exp = match w.kv_get(&exp_key(id))? {
380 Some(v) if v.len() == 8 => u64::from_le_bytes(v.as_slice().try_into().unwrap()),
381 _ => now_ms() + crate::jobs::JOB_TTL.as_millis() as u64,
382 };
383 w.kv_put(&access_key(account, id), &exp.to_le_bytes())
384}
385
386pub(crate) async fn check_access(disk: Option<&Disk>, account: &str, id: &str) -> bool {
388 let now = Instant::now();
389 let in_memory = {
390 let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
391 st.access.retain(|_, exp| *exp > now);
392 st.access
393 .contains_key(&(account.to_string(), id.to_string()))
394 };
395 if in_memory {
396 return true;
397 }
398 let Some(d) = disk else {
399 return false;
400 };
401 let s = d.store.clone();
402 let account_owned = account.to_string();
403 let id_owned = id.to_string();
404 let mirror = d.mirror.clone();
405 let has = s
406 .blocking(move |s| {
407 let key = access_key(&account_owned, &id_owned);
408 let Some(val) = s.tasks().kv_get(&key).ok().flatten() else {
409 return false;
410 };
411 if val.len() == 8 {
412 let exp = u64::from_le_bytes(val.as_slice().try_into().unwrap());
413 if exp <= now_ms() {
414 let _ = crate::jobs::mirror::write(s, mirror.as_deref(), |w| w.kv_delete(&key));
415 return false;
416 }
417 }
418 true
419 })
420 .await;
421 if has {
422 let mut st = store().lock().unwrap_or_else(|e| e.into_inner());
423 st.access.insert(
424 (account.to_string(), id.to_string()),
425 Instant::now() + crate::jobs::JOB_TTL,
426 );
427 true
428 } else {
429 false
430 }
431}
432
433pub(crate) fn backfill_from_jobs(d: &Disk) -> pagesift_store::Result<usize> {
435 let s = &d.store;
436 let t = s.tasks();
437 let keys = t.kv_keys("job/")?;
438 let now = SystemTime::now();
439 let now_ms_val = now_ms();
440 let mut count = 0;
441 for key in keys {
442 let Some(job_id) = std::str::from_utf8(&key)
443 .ok()
444 .and_then(|k| k.strip_prefix("job/"))
445 .filter(|id| !id.contains('/'))
446 else {
447 continue;
448 };
449 let Some(meta) = t
450 .kv_get(&key)
451 .ok()
452 .flatten()
453 .and_then(|v| serde_json::from_slice::<crate::jobs::store::Meta>(&v).ok())
454 else {
455 continue;
456 };
457 let created = UNIX_EPOCH + Duration::from_millis(meta.created_ms);
458 if created + crate::jobs::JOB_TTL <= now {
459 continue;
460 }
461 let owner = meta.owner.as_deref().unwrap_or("anonymous");
462 let pages_key = crate::jobs::store::list_key(job_id, "pages");
463 let len = t.list_len(&pages_key);
464 for start in (0..len).step_by(256) {
465 let Ok(batch) = t.list_range(&pages_key, start, 256) else {
466 continue;
467 };
468 for raw in batch {
469 let Ok(val) = serde_json::from_slice::<serde_json::Value>(&raw) else {
470 continue;
471 };
472 let Some(attachments) = val.get("attachments").and_then(|v| v.as_array()) else {
473 continue;
474 };
475 for a in attachments {
476 let Some(id) = a.get("id").and_then(|v| v.as_str()) else {
477 continue;
478 };
479 let Some(exp) = read_exp(s, id).filter(|e| *e > now_ms_val) else {
480 continue;
481 };
482 let acc_key = access_key(owner, id);
483 if t.kv_get(&acc_key).ok().flatten().is_none() {
484 let _ = crate::jobs::mirror::write(s, d.mirror.as_deref(), |w| {
485 w.kv_put(&acc_key, &exp.to_le_bytes())
486 });
487 count += 1;
488 }
489 }
490 }
491 }
492 }
493 Ok(count)
494}
495
496pub(crate) fn backfill_access(d: Disk) {
498 tokio::spawn(async move {
499 let d_clone = d.clone();
500 let r = d
501 .store
502 .blocking(move |_| backfill_from_jobs(&d_clone))
503 .await;
504 match r {
505 Ok(n) => {
506 if n > 0 {
507 tracing::info!(
508 count = n,
509 "backfilled attachment authorizations from saved jobs"
510 );
511 }
512 }
513 Err(e) => {
514 tracing::warn!(error = %e, "backfilling attachment authorizations failed");
515 }
516 }
517 });
518}
519
520fn drop_oldest(st: &mut Store) {
521 if let Some((_, id)) = st.order.pop_first()
522 && let Some(e) = st.map.remove(&id)
523 {
524 st.bytes -= e.data.len();
525 }
526}
527
528pub fn get(id: &str) -> Option<(Arc<[u8]>, &'static str)> {
530 let st = store().lock().unwrap_or_else(|e| e.into_inner());
531 st.map
532 .get(id)
533 .filter(|e| e.expires > Instant::now())
534 .map(|e| (e.data.clone(), e.mime))
535}
536
537pub(crate) async fn download(
539 State(s): State<crate::server::AppState>,
540 caller: Option<Extension<Caller>>,
541 Path(id): Path<String>,
542) -> Result<Response, ApiError> {
543 let account = caller
544 .as_ref()
545 .map_or("anonymous", |Extension(c)| &*c.account);
546 if !check_access(s.jobs.disk().as_ref(), account, &id).await {
547 return Err(ApiError::new(
548 ErrorCode::NotFound,
549 format!("attachment {id} not found or expired"),
550 ));
551 }
552 let found = match get(&id) {
553 Some(x) => Some(x),
554 None => load(s.jobs.disk(), id.clone()).await,
555 };
556 let Some((data, mime)) = found else {
557 return Err(ApiError::new(
558 ErrorCode::NotFound,
559 format!("attachment {id} not found or expired"),
560 ));
561 };
562 Ok((
563 [
564 (header::CONTENT_TYPE, mime),
565 (header::CACHE_CONTROL, "private, max-age=86400, immutable"),
566 ],
567 data.to_vec(),
568 )
569 .into_response())
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use crate::types::Attachment;
576
577 #[test]
578 fn kept_attachments_can_be_fetched_by_id() {
579 let mut doc = Document::default();
580 doc.attachments
581 .push(Attachment::new(b"GIF89a-test".to_vec(), "gif", None));
582 keep(&doc, None);
583 let id = &doc.attachments[0].id;
584 let (data, mime) = get(id).unwrap();
585 assert_eq!(&*data, b"GIF89a-test");
586 assert_eq!(mime, "image/gif");
587 assert!(get("0000.png").is_none());
588 }
589
590 #[tokio::test]
591 async fn synchronous_job_asset_expires_and_is_removed() {
592 let dir = tempfile::tempdir().unwrap();
593 let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
594 let a = Attachment::new(b"\x89PNG\r\n\x1a\nexpiry-test".to_vec(), "png", None);
595 crate::jobs::mirror::write(&store, None, |w| {
596 persist_job_asset(w, &a.id, a.data.as_deref().unwrap())
597 })
598 .unwrap();
599 let disk = Disk {
600 store: store.clone(),
601 mirror: None,
602 };
603 let (bytes, mime) = load(Some(disk.clone()), a.id.clone()).await.unwrap();
604 assert_eq!(bytes.as_ref(), a.data.as_deref().unwrap());
605 assert_eq!(mime, "image/png");
606 store
607 .tasks()
608 .kv_put(exp_key(&a.id), &1u64.to_le_bytes())
609 .unwrap();
610 assert!(load(Some(disk), a.id.clone()).await.is_none());
611 assert!(store.tasks().kv_get(data_key(&a.id)).unwrap().is_none());
612 }
613
614 #[tokio::test(flavor = "multi_thread")]
616 async fn persisted_attachments_survive_losing_the_memory_copy() {
617 let dir = tempfile::tempdir().unwrap();
618 let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
619 let mut doc = Document::default();
620 doc.attachments
621 .push(Attachment::new(b"\x89PNG-persisted".to_vec(), "png", None));
622 keep(&doc, Some(&store));
623 let id = doc.attachments[0].id.clone();
624 for _ in 0..500 {
626 if read_exp(&store, &id).is_some() {
627 break;
628 }
629 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
630 }
631 {
633 let mut st = super::store().lock().unwrap();
634 let e = st.map.remove(&id).unwrap();
635 st.bytes -= e.data.len();
636 st.order.retain(|_, v| *v != id);
637 }
638 assert!(get(&id).is_none());
639 let (data, mime) = load(
640 Some(Disk {
641 store: store.clone(),
642 mirror: None,
643 }),
644 id.clone(),
645 )
646 .await
647 .unwrap();
648 assert_eq!((&*data, mime), (&b"\x89PNG-persisted"[..], "image/png"));
649 store
651 .tasks()
652 .kv_put(exp_key(&id), &1u64.to_le_bytes())
653 .unwrap();
654 assert!(
655 load(
656 Some(Disk {
657 store: store.clone(),
658 mirror: None
659 }),
660 id.clone()
661 )
662 .await
663 .is_none()
664 );
665 assert!(store.tasks().kv_get(data_key(&id)).unwrap().is_none());
666 }
667
668 struct Ops(Mutex<Vec<Vec<u8>>>);
669
670 impl crate::JobLog for Ops {
671 fn append(&self, op: Vec<u8>) -> Result<(), String> {
672 self.0.lock().unwrap().push(op);
673 Ok(())
674 }
675 }
676
677 #[tokio::test(flavor = "multi_thread")]
679 async fn attachments_replicate_with_the_job_records() {
680 let dirs: Vec<_> = (0..3).map(|_| tempfile::tempdir().unwrap()).collect();
681 let open = |i: usize| pagesift_store::Store::open_dir(dirs[i].path()).unwrap();
682 let (src, dst, from_snapshot) = (open(0), open(1), open(2));
683 let log = Arc::new(Ops(Mutex::default()));
684 let mirror = crate::JobMirror::new(log.clone());
685 let mut doc = Document::default();
686 doc.attachments
687 .push(Attachment::new(b"GIF89a-replicated".to_vec(), "gif", None));
688 let disk = Disk {
689 store: src.clone(),
690 mirror: Some(mirror.clone()),
691 };
692 keep_in_for(&doc, Some(&disk), "alice");
693 let id = doc.attachments[0].id.clone();
694 for _ in 0..500 {
695 if log.0.lock().unwrap().len() >= 3 {
696 break;
697 }
698 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
699 }
700 for op in log.0.lock().unwrap().iter() {
701 crate::apply_op(&dst, op).unwrap();
702 }
703 for op in mirror.dump(&src) {
704 crate::apply_op(&from_snapshot, &op).unwrap();
705 }
706 for s in [dst, from_snapshot] {
707 assert!(
709 s.tasks()
710 .kv_get(access_key("alice", &id))
711 .unwrap()
712 .is_some()
713 );
714 assert!(s.tasks().kv_get(access_key("bob", &id)).unwrap().is_none());
715 let d = Disk {
716 store: s,
717 mirror: None,
718 };
719 assert!(check_access(Some(&d), "alice", &id).await);
720 assert!(!check_access(Some(&d), "bob", &id).await);
721 let (data, mime) = load(Some(d), id.clone()).await.expect("replicated");
722 assert_eq!((&*data, mime), (&b"GIF89a-replicated"[..], "image/gif"));
723 }
724 }
725
726 #[tokio::test(flavor = "multi_thread")]
728 async fn legacy_attachments_are_backfilled_from_saved_jobs() {
729 let dir = tempfile::tempdir().unwrap();
730 let store = pagesift_store::Store::open_dir(dir.path()).unwrap();
731 let disk = Disk {
732 store: store.clone(),
733 mirror: None,
734 };
735 let a = Attachment::new(b"\x89PNG-legacy".to_vec(), "png", None);
736 let id = a.id.clone();
737 crate::jobs::mirror::write(&store, None, |w| {
739 persist_job_asset(w, &id, a.data.as_deref().unwrap())
740 })
741 .unwrap();
742
743 let mut doc = Document::default();
745 doc.attachments.push(a);
746 let meta = crate::jobs::store::Meta {
747 kind: crate::jobs::JobKind::BatchScrape,
748 state: crate::jobs::JobState::Completed,
749 created_ms: now_ms(),
750 total: 1,
751 completed: 1,
752 owner: Some("alice".into()),
753 webhook: None,
754 crawl: None,
755 batch: None,
756 summary: None,
757 };
758 store
759 .tasks()
760 .kv_put("job/job-1", &serde_json::to_vec(&meta).unwrap())
761 .unwrap();
762 let page_raw = serde_json::to_vec(&doc).unwrap();
763 store
764 .tasks()
765 .list_push("job/job-1/pages", &page_raw)
766 .unwrap();
767
768 assert!(!check_access(Some(&disk), "alice", &id).await);
770 assert!(!check_access(Some(&disk), "bob", &id).await);
771
772 let count = backfill_from_jobs(&disk).unwrap();
774 assert_eq!(count, 1);
775
776 assert!(check_access(Some(&disk), "alice", &id).await);
778 assert!(!check_access(Some(&disk), "bob", &id).await);
779 }
780}