@@ -51,6 +51,19 @@ pub async fn put_object(
5151 None => body,
5252 } ;
5353
54+ // Serialize index-mutating operations on the same user-scoped bucket.
55+ // Without this, parallel chunk PUTs (fula-client fans out up to 16) all
56+ // open at the same root_cid, each flushes a tree containing only its
57+ // own key, and DashMap::insert last-writer-wins drops every other
58+ // mapping — leaving the chunk bytes in IPFS but the bucket index
59+ // pointing at only one of them. Held until the end of the handler so
60+ // the open → mutate → flush → persist sequence is atomic per bucket.
61+ let _bucket_guard = state
62+ . bucket_manager
63+ . bucket_write_lock ( & session. hashed_user_id , & bucket_name)
64+ . lock_owned ( )
65+ . await ;
66+
5467 // Open bucket first so conditional-write guards can consult the current
5568 // stored ETag without doing extra I/O later. (Moved ahead of put_block.)
5669 tracing:: debug!( bucket = %bucket_name, "Opening user-scoped bucket" ) ;
@@ -60,15 +73,10 @@ pub async fn put_object(
6073 e
6174 } ) ?;
6275
63- // RFC 7232 conditional-write preconditions. Used by fula-client forest
64- // flush to catch concurrent writers (surfaces as ClientError::Concurrent
65- // Modification on 412). Checked before put_block to avoid an orphan
66- // block when the precondition fails.
67- //
68- // NOTE: This is a best-effort check — get_object + put_object are not
69- // atomic under concurrent PUTs on the same key (each request opens its
70- // own bucket snapshot). The client's retry-on-412 loop handles the
71- // residual commit-window race.
76+ // RFC 7232 conditional-write preconditions. With the per-bucket write
77+ // lock held above, get_object + put_object now observe a consistent
78+ // snapshot for the same bucket; the client still needs retry-on-412
79+ // for cross-device races.
7280 let existing = bucket. get_object ( & key) . await ?;
7381 let current_etag: Option < & str > = existing. as_ref ( ) . map ( |m| m. etag . as_str ( ) ) ;
7482
@@ -495,6 +503,13 @@ pub async fn delete_object(
495503 return Err ( ApiError :: s3 ( S3ErrorCode :: AccessDenied , "Write access required" ) ) ;
496504 }
497505
506+ // Serialize same-bucket index mutations (see `put_object` for rationale).
507+ let _bucket_guard = state
508+ . bucket_manager
509+ . bucket_write_lock ( & session. hashed_user_id , & bucket_name)
510+ . lock_owned ( )
511+ . await ;
512+
498513 // User-scoped bucket access
499514 let mut bucket = state. bucket_manager . open_bucket_for_user ( & session. hashed_user_id , & bucket_name) . await ?;
500515
@@ -596,7 +611,7 @@ pub async fn copy_object(
596611 . split_once ( '/' )
597612 . ok_or_else ( || ApiError :: s3 ( S3ErrorCode :: InvalidArgument , "Invalid copy source format" ) ) ?;
598613
599- // Get source object (user-scoped)
614+ // Get source object (user-scoped). Read-only, so no write lock needed here.
600615 let source_bucket_handle = state. bucket_manager . open_bucket_for_user ( & session. hashed_user_id , source_bucket) . await ?;
601616
602617 let source_metadata = source_bucket_handle. get_object ( source_key) . await ?
@@ -605,12 +620,23 @@ pub async fn copy_object(
605620 "Source object not found" ,
606621 copy_source,
607622 ) ) ?;
623+ drop ( source_bucket_handle) ;
608624
609625 // Copy to destination (user-scoped)
610626 let mut dest_metadata = source_metadata. clone ( ) ;
611627 dest_metadata. last_modified = chrono:: Utc :: now ( ) ;
612628 dest_metadata. owner_id = Some ( session. hashed_user_id . clone ( ) ) ;
613629
630+ // Serialize same-bucket index mutations on the destination (see
631+ // `put_object` for rationale). Acquired after the source read so a copy
632+ // within the same bucket can still proceed without the reader holding
633+ // its own handle through the write.
634+ let _bucket_guard = state
635+ . bucket_manager
636+ . bucket_write_lock ( & session. hashed_user_id , & dest_bucket)
637+ . lock_owned ( )
638+ . await ;
639+
614640 let mut dest_bucket_handle = state. bucket_manager . open_bucket_for_user ( & session. hashed_user_id , & dest_bucket) . await ?;
615641
616642 dest_bucket_handle. put_object ( dest_key, dest_metadata. clone ( ) ) . await ?;
0 commit comments