Skip to main content

notedthat_write/
commit.rs

1//! Shared commit operations for object writes and deletes.
2
3use 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
14/// Maximum upload size accepted by shared write paths: 5 GiB.
15pub const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024 * 1024;
16
17/// Validate an upload size against a byte limit.
18pub 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
26/// Store an object and enqueue a best-effort index upsert event.
27pub 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
52/// Copy an object natively and enqueue its destination for indexing.
53pub 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            // Closed = indexer worker ended (shutdown OR panic). v1 preserves success-with-error-log
91            // until post-v1 worker liveness detection is added.
92        }
93    }
94
95    Ok(())
96}
97
98/// Delete an object idempotently and enqueue a best-effort tombstone event.
99pub 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        // Closed means the indexer worker task ended via shutdown OR panic; v1 deliberately preserves success-with-error-log so writes are not blocked by a crashed worker, and post-v1 worker liveness detection will revisit this.
123        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}