From f946488d9c16aa2a2bbbb57f86e428e36e9163ae Mon Sep 17 00:00:00 2001 From: Pon Vijayalakdhmi T Date: Sun, 26 Jul 2026 18:44:13 +0530 Subject: [PATCH 1/3] Fix race condition in bulk upsert verification --- src/Database/Database.php | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index cc87483a8..31ec726e2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7488,11 +7488,28 @@ public function upsertDocumentsWithIncrease( /** * @var array $chunk */ - $batch = $this->withTransaction(fn () => $this->authorization->skip(fn () => $this->adapter->upsertDocuments( - $collection, - $attribute, - $chunk - ))); + $batch = $this->withTransaction(function () use ($collection, $attribute, $chunk) { + // Re-fetch each existing document with FOR UPDATE to lock the rows + // and verify no concurrent modification occurred since the initial read. + foreach ($chunk as $change) { + $old = $change->getOld(); + if ($old->isEmpty()) { + continue; + } + $fresh = $this->authorization->skip(fn () => $this->silent( + fn () => $this->getDocument($collection->getId(), $old->getId(), forUpdate: true) + )); + if (!$fresh->isEmpty() && $fresh->getUpdatedAt() !== $old->getUpdatedAt()) { + throw new ConflictException('Document was updated after the request timestamp'); + } + } + + return $this->authorization->skip(fn () => $this->adapter->upsertDocuments( + $collection, + $attribute, + $chunk + )); + }); $batch = $this->adapter->getSequences($collection->getId(), $batch); From 52bcd3ca620c51360c0de69c50ff6d35a6459bf4 Mon Sep 17 00:00:00 2001 From: Pon Vijayalakdhmi T Date: Mon, 27 Jul 2026 12:36:52 +0530 Subject: [PATCH 2/3] Handle concurrent deletion during bulk upsert verification --- src/Database/Database.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 31ec726e2..5c5412340 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7499,7 +7499,10 @@ public function upsertDocumentsWithIncrease( $fresh = $this->authorization->skip(fn () => $this->silent( fn () => $this->getDocument($collection->getId(), $old->getId(), forUpdate: true) )); - if (!$fresh->isEmpty() && $fresh->getUpdatedAt() !== $old->getUpdatedAt()) { + if ($fresh->isEmpty()) { + throw new ConflictException('Document was deleted after the request timestamp'); + } + if ($fresh->getUpdatedAt() !== $old->getUpdatedAt()) { throw new ConflictException('Document was updated after the request timestamp'); } } From a12d665d2d47d7746fdc25e7137abc243ae0b046 Mon Sep 17 00:00:00 2001 From: Pon Vijayalakdhmi T Date: Mon, 27 Jul 2026 17:06:04 +0530 Subject: [PATCH 3/3] Scope bulk upsert verification to document tenant --- src/Database/Database.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 5c5412340..b1329a51c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7497,7 +7497,9 @@ public function upsertDocumentsWithIncrease( continue; } $fresh = $this->authorization->skip(fn () => $this->silent( - fn () => $this->getDocument($collection->getId(), $old->getId(), forUpdate: true) + fn () => $this->getSharedTables() && $this->getTenantPerDocument() + ? $this->withTenant($old->getTenant(), fn () => $this->getDocument($collection->getId(), $old->getId(), forUpdate: true)) + : $this->getDocument($collection->getId(), $old->getId(), forUpdate: true) )); if ($fresh->isEmpty()) { throw new ConflictException('Document was deleted after the request timestamp');