1use notedthat_core::{
4 ConditionalHeaders, CopyObjectOptions, KbSlug, ObjectPath, PutOutcome, StagedBody, Storage,
5 StorageError,
6};
7use notedthat_indexer::IndexEvent;
8use tokio::sync::mpsc::Sender;
9use tokio::sync::mpsc::error::TrySendError;
10
11use crate::WriteError;
12use crate::mime::sniff_content_type;
13
14pub const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024 * 1024;
16
17pub fn check_size(size: u64, limit: u64) -> Result<(), WriteError> {
19 if size > limit {
20 Err(WriteError::TooLarge { size, limit })
21 } else {
22 Ok(())
23 }
24}
25
26pub async fn commit<B: Into<StagedBody>>(
28 storage: &dyn Storage,
29 indexer_tx: &Sender<IndexEvent>,
30 kb: &KbSlug,
31 path: &ObjectPath,
32 body: B,
33 caller_content_type: Option<&str>,
34 conditionals: ConditionalHeaders,
35) -> Result<PutOutcome, WriteError> {
36 let body = body.into();
37 check_size(body.len(), MAX_UPLOAD_BYTES)?;
38 let _prefix = body.prefix(512).await.map_err(|source| {
39 WriteError::Storage(StorageError::Other {
40 source: Box::new(source),
41 })
42 })?;
43 let mime = sniff_content_type(caller_content_type, path);
44 let outcome = storage
45 .put_staged_object(kb, path, body, Some(&mime), conditionals)
46 .await?;
47
48 enqueue_upsert(indexer_tx, kb, path, &outcome)?;
49 Ok(outcome)
50}
51
52pub async fn commit_copy(
54 storage: &dyn Storage,
55 indexer_tx: &Sender<IndexEvent>,
56 kb: &KbSlug,
57 source: &ObjectPath,
58 destination: &ObjectPath,
59 options: CopyObjectOptions,
60) -> Result<PutOutcome, WriteError> {
61 let outcome = storage
62 .copy_object(kb, source, destination, options)
63 .await?;
64 enqueue_upsert(indexer_tx, kb, destination, &outcome)?;
65 Ok(outcome)
66}
67
68fn enqueue_upsert(
69 indexer_tx: &Sender<IndexEvent>,
70 kb: &KbSlug,
71 path: &ObjectPath,
72 outcome: &PutOutcome,
73) -> Result<(), WriteError> {
74 let event = IndexEvent::Upsert {
75 kb: kb.clone(),
76 object_key: path.clone(),
77 etag: outcome.etag.clone().unwrap_or_default(),
78 mtime: current_unix_seconds(),
79 };
80 match indexer_tx.try_send(event) {
81 Ok(()) => {}
82 Err(TrySendError::Full(ev)) => {
83 tracing::warn!(target: "notedthat::indexing", kb = %kb, path = %path, "INDEX_QUEUE_FULL");
84 let _ = ev;
85 return Err(WriteError::IndexerBackpressureUpsert);
86 }
87 Err(TrySendError::Closed(ev)) => {
88 tracing::error!(target: "notedthat::indexing", kb = %kb, path = %path, "INDEX_QUEUE_CLOSED");
89 let _ = ev;
90 }
93 }
94
95 Ok(())
96}
97
98pub async fn commit_delete(
100 storage: &dyn Storage,
101 indexer_tx: &Sender<IndexEvent>,
102 kb: &KbSlug,
103 path: &ObjectPath,
104 conditionals: ConditionalHeaders,
105) -> Result<(), WriteError> {
106 match storage.delete_object(kb, path, conditionals).await {
107 Ok(()) | Err(StorageError::NotFound { .. }) => {}
108 Err(e) => return Err(WriteError::Storage(e)),
109 }
110
111 let event = IndexEvent::Tombstone {
112 kb: kb.clone(),
113 object_key: path.clone(),
114 };
115 match indexer_tx.try_send(event) {
116 Ok(()) => {}
117 Err(TrySendError::Full(ev)) => {
118 tracing::warn!(target: "notedthat::indexing", kb = %kb, path = %path, "INDEX_QUEUE_FULL");
119 let _ = ev;
120 return Err(WriteError::IndexerBackpressureTombstone);
121 }
122 Err(TrySendError::Closed(ev)) => {
124 tracing::error!(target: "notedthat::indexing", kb = %kb, path = %path, "INDEX_QUEUE_CLOSED");
125 let _ = ev;
126 }
127 }
128
129 Ok(())
130}
131
132fn current_unix_seconds() -> i64 {
133 std::time::SystemTime::now()
134 .duration_since(std::time::UNIX_EPOCH)
135 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use async_trait::async_trait;
142 use bytes::Bytes;
143 use notedthat_core::{KbManifest, ListResponse, ObjectMeta, ObjectRead};
144 use std::collections::HashMap;
145 use std::sync::{Arc, Mutex};
146 use tokio::sync::mpsc;
147
148 #[derive(Default)]
149 struct TestStorage {
150 objects: Mutex<HashMap<String, String>>,
151 }
152
153 #[async_trait]
154 impl Storage for TestStorage {
155 async fn ensure_bucket(&self, _kb: &KbSlug) -> Result<(), StorageError> {
156 unimplemented!()
157 }
158
159 async fn read_manifest(&self, _kb: &KbSlug) -> Result<KbManifest, StorageError> {
160 unimplemented!()
161 }
162
163 async fn write_manifest(
164 &self,
165 _kb: &KbSlug,
166 _manifest: &KbManifest,
167 ) -> Result<(), StorageError> {
168 unimplemented!()
169 }
170
171 async fn head_object(
172 &self,
173 _kb: &KbSlug,
174 _path: &ObjectPath,
175 _conditionals: ConditionalHeaders,
176 ) -> Result<ObjectMeta, StorageError> {
177 unimplemented!()
178 }
179
180 async fn get_object(
181 &self,
182 _kb: &KbSlug,
183 _path: &ObjectPath,
184 _range: Option<Vec<notedthat_core::ByteRange>>,
185 _conditionals: ConditionalHeaders,
186 ) -> Result<ObjectRead, StorageError> {
187 unimplemented!()
188 }
189
190 async fn get_object_stream(
191 &self,
192 _kb: &KbSlug,
193 _path: &ObjectPath,
194 _range: Option<Vec<notedthat_core::ByteRange>>,
195 _conditionals: ConditionalHeaders,
196 ) -> Result<notedthat_core::ObjectStream, StorageError> {
197 unimplemented!()
198 }
199
200 async fn put_object(
201 &self,
202 kb: &KbSlug,
203 path: &ObjectPath,
204 _bytes: Bytes,
205 _content_type: Option<&str>,
206 conditionals: ConditionalHeaders,
207 ) -> Result<PutOutcome, StorageError> {
208 let key = format!("{}/{}", kb.as_str(), path.as_str());
209 let mut objects = self.objects.lock().expect("mutex not poisoned");
210 let existing = objects.get(&key);
211 if let Some(if_match) = conditionals.if_match
212 && existing.is_none_or(|etag| etag != &if_match)
213 {
214 return Err(StorageError::PreconditionFailed);
215 }
216
217 let etag = format!("\"etag-{}\"", objects.len() + 1);
218 objects.insert(key, etag.clone());
219 Ok(PutOutcome { etag: Some(etag) })
220 }
221
222 async fn put_staged_object(
223 &self,
224 kb: &KbSlug,
225 path: &ObjectPath,
226 body: StagedBody,
227 content_type: Option<&str>,
228 conditionals: ConditionalHeaders,
229 ) -> Result<PutOutcome, StorageError> {
230 let bytes =
231 body.memory_bytes()
232 .cloned()
233 .ok_or_else(|| StorageError::BackendUnavailable {
234 message: "file staging is outside this commit unit test".into(),
235 })?;
236 self.put_object(kb, path, bytes, content_type, conditionals)
237 .await
238 }
239
240 async fn copy_object(
241 &self,
242 kb: &KbSlug,
243 _source: &ObjectPath,
244 destination: &ObjectPath,
245 _options: CopyObjectOptions,
246 ) -> Result<PutOutcome, StorageError> {
247 let key = format!("{}/{}", kb.as_str(), destination.as_str());
248 let mut objects = self.objects.lock().expect("mutex not poisoned");
249 let etag = format!("\"etag-{}\"", objects.len() + 1);
250 objects.insert(key, etag.clone());
251 Ok(PutOutcome { etag: Some(etag) })
252 }
253
254 async fn delete_object(
255 &self,
256 kb: &KbSlug,
257 path: &ObjectPath,
258 _conditionals: ConditionalHeaders,
259 ) -> Result<(), StorageError> {
260 let key = format!("{}/{}", kb.as_str(), path.as_str());
261 self.objects
262 .lock()
263 .expect("mutex not poisoned")
264 .remove(&key);
265 Ok(())
266 }
267
268 async fn list_objects(
269 &self,
270 _kb: &KbSlug,
271 _prefix: Option<&str>,
272 _limit: u32,
273 _cursor: Option<&str>,
274 ) -> Result<ListResponse, StorageError> {
275 unimplemented!()
276 }
277 }
278
279 fn kb() -> KbSlug {
280 KbSlug::try_new("test-kb").expect("valid kb slug")
281 }
282
283 fn path() -> ObjectPath {
284 ObjectPath::try_from_str("test.md").expect("valid path")
285 }
286
287 fn path_named(value: &str) -> ObjectPath {
288 ObjectPath::try_from_str(value).expect("valid path")
289 }
290
291 #[tokio::test]
292 async fn successful_put_enqueues_event() {
293 let storage = TestStorage::default();
294 let kb = kb();
295 let path = path();
296 let (indexer_tx, mut rx) = mpsc::channel(1024);
297
298 let outcome = commit(
299 &storage,
300 &indexer_tx,
301 &kb,
302 &path,
303 Bytes::from_static(b"# Test"),
304 Some("text/markdown"),
305 ConditionalHeaders::default(),
306 )
307 .await;
308
309 assert!(outcome.is_ok());
310 assert!(outcome.unwrap().etag.is_some());
311
312 let event = rx.recv().await.expect("event should be enqueued");
313 assert_eq!(event.kb().as_str(), "test-kb");
314 assert_eq!(event.object_key().as_str(), "test.md");
315 }
316
317 #[tokio::test]
318 async fn successful_native_copy_enqueues_destination_event() {
319 let storage = TestStorage::default();
320 let kb = kb();
321 let source = path_named("source.md");
322 let destination = path_named("destination.md");
323 let (indexer_tx, mut rx) = mpsc::channel(1);
324
325 let outcome = commit_copy(
326 &storage,
327 &indexer_tx,
328 &kb,
329 &source,
330 &destination,
331 CopyObjectOptions::default(),
332 )
333 .await
334 .expect("copy succeeds");
335
336 assert!(outcome.etag.is_some());
337 let event = rx.recv().await.expect("destination event");
338 assert_eq!(event.object_key(), &destination);
339 }
340
341 #[tokio::test]
342 async fn full_queue_returns_indexer_backpressure() {
343 let storage = TestStorage::default();
344 let kb = kb();
345 let path = path();
346 let (indexer_tx, _rx) = mpsc::channel(1);
347
348 let dummy_event = IndexEvent::Upsert {
349 kb: kb.clone(),
350 object_key: path.clone(),
351 etag: "dummy".to_string(),
352 mtime: 0,
353 };
354 indexer_tx
355 .try_send(dummy_event)
356 .expect("first send should succeed");
357
358 let outcome = commit(
359 &storage,
360 &indexer_tx,
361 &kb,
362 &path,
363 Bytes::from_static(b"# Test"),
364 Some("text/markdown"),
365 ConditionalHeaders::default(),
366 )
367 .await;
368
369 let err = outcome.unwrap_err();
370 assert!(
371 matches!(err, WriteError::IndexerBackpressureUpsert),
372 "expected IndexerBackpressureUpsert, got {err:?}"
373 );
374 }
375
376 #[tokio::test]
377 async fn burst_write_returns_backpressure_after_capacity() {
378 let storage = Arc::new(TestStorage::default());
379 let kb = kb();
380 let path_a = path_named("a.md");
381 let path_b = path_named("b.md");
382 let path_c = path_named("c.md");
383 let (indexer_tx, _rx) = mpsc::channel::<IndexEvent>(2);
384
385 let first = commit(
386 storage.as_ref(),
387 &indexer_tx,
388 &kb,
389 &path_a,
390 Bytes::from_static(b"# A"),
391 Some("text/markdown"),
392 ConditionalHeaders::default(),
393 )
394 .await;
395 let second = commit(
396 storage.as_ref(),
397 &indexer_tx,
398 &kb,
399 &path_b,
400 Bytes::from_static(b"# B"),
401 Some("text/markdown"),
402 ConditionalHeaders::default(),
403 )
404 .await;
405 let third = commit(
406 storage.as_ref(),
407 &indexer_tx,
408 &kb,
409 &path_c,
410 Bytes::from_static(b"# C"),
411 Some("text/markdown"),
412 ConditionalHeaders::default(),
413 )
414 .await;
415
416 assert!(first.is_ok(), "first write should fill queue slot one");
417 assert!(second.is_ok(), "second write should fill queue slot two");
418 let err = third.unwrap_err();
419 assert!(
420 matches!(err, WriteError::IndexerBackpressureUpsert),
421 "expected IndexerBackpressureUpsert, got {err:?}"
422 );
423 assert!(
424 storage
425 .objects
426 .lock()
427 .expect("mutex not poisoned")
428 .contains_key("test-kb/c.md"),
429 "stored object should remain after enqueue backpressure"
430 );
431 }
432
433 #[tokio::test]
434 async fn commit_delete_full_queue_returns_backpressure() {
435 let storage = TestStorage::default();
436 let kb = kb();
437 let path = path_named("delete.md");
438 storage
439 .put_object(
440 &kb,
441 &path,
442 Bytes::from_static(b"# Delete"),
443 Some("text/markdown"),
444 ConditionalHeaders::default(),
445 )
446 .await
447 .expect("prepopulate object");
448 let (indexer_tx, _rx) = mpsc::channel(1);
449 let dummy_event = IndexEvent::Tombstone {
450 kb: kb.clone(),
451 object_key: path.clone(),
452 };
453 indexer_tx
454 .try_send(dummy_event)
455 .expect("first send should succeed");
456
457 let outcome = commit_delete(
458 &storage,
459 &indexer_tx,
460 &kb,
461 &path,
462 ConditionalHeaders::default(),
463 )
464 .await;
465
466 let err = outcome.unwrap_err();
467 assert!(
468 matches!(err, WriteError::IndexerBackpressureTombstone),
469 "expected IndexerBackpressureTombstone, got {err:?}"
470 );
471 assert!(
472 !storage
473 .objects
474 .lock()
475 .expect("mutex not poisoned")
476 .contains_key("test-kb/delete.md"),
477 "deleted object should remain deleted after enqueue backpressure"
478 );
479 }
480
481 #[tokio::test]
482 async fn closed_queue_returns_write_success() {
483 let storage = TestStorage::default();
484 let kb = kb();
485 let path = path();
486 let (indexer_tx, rx) = mpsc::channel(1024);
487
488 drop(rx);
489
490 let outcome = commit(
491 &storage,
492 &indexer_tx,
493 &kb,
494 &path,
495 Bytes::from_static(b"# Test"),
496 Some("text/markdown"),
497 ConditionalHeaders::default(),
498 )
499 .await;
500
501 assert!(
502 outcome.is_ok(),
503 "write should succeed even if queue is closed"
504 );
505 }
506
507 #[tokio::test]
508 async fn put_failure_returns_error_no_event() {
509 let storage = TestStorage::default();
510 let kb = kb();
511 let path = path();
512 let (indexer_tx, mut rx) = mpsc::channel(1024);
513
514 let conditionals = ConditionalHeaders {
515 if_match: Some("\"wrong-etag\"".to_string()),
516 ..ConditionalHeaders::default()
517 };
518
519 let outcome = commit(
520 &storage,
521 &indexer_tx,
522 &kb,
523 &path,
524 Bytes::from_static(b"# Test"),
525 Some("text/markdown"),
526 conditionals,
527 )
528 .await;
529
530 assert!(outcome.is_err(), "put should fail with precondition");
531 assert!(
532 rx.try_recv().is_err(),
533 "no event should be enqueued on put failure"
534 );
535 }
536
537 #[test]
538 fn check_size_over_limit_returns_too_large() {
539 let err = check_size(MAX_UPLOAD_BYTES + 1, MAX_UPLOAD_BYTES).expect_err("too large");
540 assert!(matches!(
541 err,
542 WriteError::TooLarge {
543 size,
544 limit
545 } if size == MAX_UPLOAD_BYTES + 1 && limit == MAX_UPLOAD_BYTES
546 ));
547 }
548
549 #[test]
550 fn check_size_at_limit_returns_ok() {
551 assert!(check_size(MAX_UPLOAD_BYTES, MAX_UPLOAD_BYTES).is_ok());
552 }
553
554 #[test]
555 fn check_size_below_limit_returns_ok() {
556 assert!(check_size(1024, MAX_UPLOAD_BYTES).is_ok());
557 }
558}