From 0ac092d1772f6c06e802d6eddec3a5e5b948f252 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 10 Jun 2026 10:58:50 +0200 Subject: [PATCH] :bug: Make set-file-shared idempotent to fix race condition with optimistic updates (#10093) --- backend/src/app/rpc/commands/files.clj | 6 +++ backend/test/backend_tests/rpc_file_test.clj | 43 ++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index a05ef28e78..e8a4bc32b0 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -974,6 +974,12 @@ {:id id}) file) + (= (:is-shared file) (:is-shared params)) + ;; File is already in the desired state (idempotent); + ;; this can happen when the frontend sends a duplicate + ;; request due to optimistic updates or race conditions. + file + :else (ex/raise :type :validation :code :invalid-shared-state diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 24bb45cb80..5460b4143f 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -830,6 +830,49 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found)))) +(t/deftest set-file-shared-idempotent + (let [profile (th/create-profile* 1) + file (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile)})] + + ;; Share the file + (let [data {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id file) + :is-shared true} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (true? (-> out :result :is-shared)))) + + ;; Calling set-file-shared with is-shared=true again should be a + ;; no-op success (idempotent), not an error. + (let [data {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id file) + :is-shared true} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (true? (-> out :result :is-shared)))) + + ;; Unshare the file + (let [data {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id file) + :is-shared false} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (false? (-> out :result :is-shared)))) + + ;; Calling set-file-shared with is-shared=false again should also + ;; be a no-op success (idempotent). + (let [data {::th/type :set-file-shared + ::rpc/profile-id (:id profile) + :id (:id file) + :is-shared false} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (false? (-> out :result :is-shared)))))) + (t/deftest permissions-checks-link-to-library-1 (let [profile1 (th/create-profile* 1) profile2 (th/create-profile* 2)